authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-06 13:14:13-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 13:14:13-07:00
logadc3c9327abedda6a36694e2344e620fe0d62ae4
tree002c1ab76257344e3b44977372ccd017da6863df
parent1564aaa5d29deb2520d1211f9f35dd6150b1512e
signature Signed by PGP key B5690EEEBB952194

feat(server): pull requests talk to GitHub's API instead of the gh CLI (#16320)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

15 files changed, 2867 insertions(+), 2593 deletions(-)

apps/server/scripts/measure-pr-preview.ts+50-67
......@@ -1,18 +1,21 @@
11// Run with: node apps/server/scripts/measure-pr-preview.ts owner/repo 123 456
22// Numbers form a session with shared repository-permission caches. Browser and
33// service caches are excluded. Uses real GitHub reads, without a server or database.
4// CLI-generated GraphQL
5// queries are replayed with rateLimit.cost, outside the timed section, to measure
6// their cost without confusing other applications' traffic with this process's.
4// Every GraphQL read carries `rateLimit`, so its cost is read off its own answer.
75import * as NodeServices from "@effect/platform-node/NodeServices";
86import * as Effect from "effect/Effect";
97import * as Console from "effect/Console";
108import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
1110import * as Schema from "effect/Schema";
11import { FetchHttpClient } from "effect/http";
1212
1313import * as GitHubPullRequestCli from "../src/pullRequest/GitHubPullRequestCli.ts";
1414import * as GitHubPullRequestProvider from "../src/pullRequest/GitHubPullRequestProvider.ts";
15import * as GitHubCli from "../src/sourceControl/GitHubCli.ts";
15import * as GitHubApi from "../src/sourceControl/GitHubApi.ts";
16import * as GitHubCredentials from "../src/sourceControl/GitHubCredentials.ts";
17import * as GitHubGraphQlBudget from "../src/sourceControl/githubGraphQlBudget.ts";
18import * as SourceControlRateLimit from "../src/sourceControl/SourceControlRateLimit.ts";
1619import * as VcsProcess from "../src/vcs/VcsProcess.ts";
1720
1821const [repository, ...numbers] = process.argv.slice(2);
......@@ -20,77 +23,58 @@ if (!repository || numbers.length === 0 || numbers.some((number) => !/^\d+$/.tes
2023 throw new Error("Usage: node apps/server/scripts/measure-pr-preview.ts owner/repo number...");
2124}
2225
23type Read = { graphqlRequests: number; restRequests: number; cost: number; debug: string };
26type Read = { graphqlRequests: number; restRequests: number; cost: number };
2427const reads: Read[] = [];
25const layerMeasuredProcess = Layer.effect(
26 VcsProcess.VcsProcess,
28const decodeCost = Schema.decodeUnknownOption(
29 Schema.fromJsonString(
30 Schema.Struct({
31 data: Schema.Struct({ rateLimit: Schema.Struct({ cost: Schema.Number }) }),
32 }),
33 ),
34);
35const measuredApi = Layer.effect(
36 GitHubApi.GitHubApi,
2737 Effect.gen(function* () {
28 const vcs = yield* VcsProcess.VcsProcess;
29 return VcsProcess.VcsProcess.of({
30 run: (input) =>
31 vcs.run({ ...input, env: { ...input.env, GH_DEBUG: input.env?.GH_DEBUG ?? "api" } }).pipe(
32 Effect.tap((output) =>
33 Effect.sync(() => {
34 const graphqlRequests = [...output.stderr.matchAll(/^> POST \/graphql /gm)].length;
35 const requests = [...output.stderr.matchAll(/^> (?:GET|POST) /gm)].length;
36 const cost = /"rateLimit"\s*:\s*\{[^}]*"cost"\s*:\s*(\d+)/.exec(output.stdout)?.[1];
38 const api = yield* GitHubApi.make;
39 return GitHubApi.GitHubApi.of({
40 ...api,
41 graphql: (input) =>
42 api.graphql(input).pipe(
43 Effect.tap((body) =>
44 Effect.sync(() =>
3745 reads.push({
38 graphqlRequests,
39 restRequests: requests - graphqlRequests,
40 cost: Number(cost ?? 0),
41 debug: output.stderr,
42 });
43 }),
46 graphqlRequests: 1,
47 restRequests: 0,
48 cost: Option.match(decodeCost(body), {
49 onNone: () => 0,
50 onSome: (decoded) => decoded.data.rateLimit.cost,
51 }),
52 }),
53 ),
4454 ),
4555 ),
56 rest: (input) =>
57 api
58 .rest(input)
59 .pipe(
60 Effect.tap(() =>
61 Effect.sync(() => reads.push({ graphqlRequests: 0, restRequests: 1, cost: 0 })),
62 ),
63 ),
4664 });
4765 }),
48).pipe(Layer.provide(VcsProcess.layer), Layer.provide(NodeServices.layer));
49
50const layerServices = GitHubPullRequestCli.layer.pipe(
51 Layer.provide(GitHubCli.layer),
52 Layer.provideMerge(layerMeasuredProcess),
66).pipe(
67 Layer.provide(GitHubCredentials.layer),
68 Layer.provide(GitHubGraphQlBudget.layer),
69 Layer.provide(SourceControlRateLimit.layer),
70 Layer.provide(FetchHttpClient.layer),
71 Layer.provide(VcsProcess.layer),
5372 Layer.provide(NodeServices.layer),
5473);
5574
56const decodeJson = Schema.decodeSync(Schema.fromJsonString(Schema.Unknown));
75const services = GitHubPullRequestCli.layer.pipe(Layer.provideMerge(measuredApi));
76
5777const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
58const graphqlCost = Effect.fn("measurePrPreview.graphqlCost")(function* (read: Read) {
59 // The REST quota probe synthesizes rateLimit.cost for budget admission.
60 // It does not spend GraphQL points.
61 if (!read.graphqlRequests) return 0;
62 if (read.cost) return read.cost;
63 const vcs = yield* VcsProcess.VcsProcess;
64 let cost = 0;
65 let found = 0;
66 for (const match of read.debug.matchAll(
67 /GraphQL query:\n([\s\S]*?)\nGraphQL variables: (\{[^\n]*\})/g,
68 )) {
69 const query = match[1]!.replace(/(\bquery\b[^{]*\{)/, "$1 rateLimit { cost }");
70 const output = yield* vcs.run({
71 operation: "measurePrPreview.cost",
72 command: "gh",
73 cwd: process.cwd(),
74 args: [
75 "api",
76 "graphql",
77 "--hostname",
78 "github.com",
79 "--input",
80 "-",
81 "--jq",
82 ".data.rateLimit.cost",
83 ],
84 stdin: encodeJson({ query, variables: decodeJson(match[2]!) }),
85 env: { GH_DEBUG: "" },
86 });
87 cost += Number(output.stdout.trim());
88 found++;
89 }
90 if (found !== read.graphqlRequests)
91 throw new Error("Could not account for every GraphQL request");
92 return cost;
93});
9478
9579for (const mode of ["detail", "preview"] as const) {
9680 // Start each side cold, then retain the caches shared across PRs in one session.
......@@ -108,8 +92,7 @@ for (const mode of ["detail", "preview"] as const) {
10892 : provider.getChangeRequestPreview!(input);
10993 const elapsedMs = Math.round(performance.now() - start);
11094 const measured = [...reads];
111 const costs = yield* Effect.forEach(measured, graphqlCost);
112 const points = costs.reduce((total, cost) => total + cost, 0);
95 const points = measured.reduce((total, read) => total + read.cost, 0);
11396 return {
11497 repository,
11598 number,
......@@ -123,7 +106,7 @@ for (const mode of ["detail", "preview"] as const) {
123106 }),
124107 );
125108 }),
126 ).pipe(Effect.provide(layerServices)),
109 ).pipe(Effect.provide(services)),
127110 );
128111 for (const row of rows) await Effect.runPromise(Console.log(encodeJson(row)));
129112}
apps/server/src/pullRequest/GitHubPullRequestCli.test.ts+1033-1008
......@@ -7,13 +7,17 @@ import * as Fiber from "effect/Fiber";
77import * as Layer from "effect/Layer";
88import * as Schema from "effect/Schema";
99import * as TestClock from "effect/testing/TestClock";
10import { ChildProcessSpawner } from "effect/process";
10import * as Redacted from "effect/Redacted";
1111
12import * as GitHubCli from "../sourceControl/GitHubCli.ts";
13import * as VcsProcess from "../vcs/VcsProcess.ts";
14import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts";
12import { HttpClient, HttpClientResponse } from "effect/http";
13
14import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts";
15import * as GitHubApi from "../sourceControl/GitHubApi.ts";
16import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts";
1517import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts";
18import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts";
1619import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";
20import { KnownWorkflowRuns } from "./gitHubConditionalChecks.ts";
1721import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts";
1822
1923const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
......@@ -48,60 +52,141 @@ const coreResponse = (pullRequest: Readonly<Record<string, unknown>> = {}) => ({
4852 },
4953});
5054
51const mockedExecute = vi.fn<GitHubCli.GitHubCli["Service"]["execute"]>();
52const mockedStackMemberships = vi.fn<GitHubCli.GitHubCli["Service"]["execute"]>(() =>
55/** One request the CLI made, as the transport received it. */
56type ApiCall =
57 | ({ readonly kind: "graphql" } & GitHubApi.GitHubGraphQlInput)
58 | ({ readonly kind: "rest" } & GitHubApi.GitHubRestInput);
59
60type ApiAnswer = Effect.Effect<GitHubApi.GitHubRestResponse, GitHubApi.GitHubApiError>;
61
62const mockedExecute = vi.fn<(call: ApiCall) => ApiAnswer>();
63const defaultCredential = (host: string) =>
64 Effect.succeed({ token: Redacted.make("token"), fingerprint: `${host}:token` });
65const mockedCredential = vi.fn(defaultCredential);
66const mockedStackMemberships = vi.fn<(call: ApiCall) => ApiAnswer>(() =>
5367 Effect.succeed(output('{"data":{}}')),
5468);
55const mockedGetPullRequest = vi.fn<GitHubCli.GitHubCli["Service"]["getPullRequest"]>();
69
70/**
71 * A transport that answers from the mocks above but spends the real GraphQL budget, the way
72 * GitHubApi does, so the reserve and pause behaviour is still the module's to prove.
73 */
74const mockApi = Layer.effect(
75 GitHubApi.GitHubApi,
76 Effect.gen(function* () {
77 const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;
78 return GitHubApi.GitHubApi.of({
79 graphql: (input) =>
80 budget
81 .query(input.host, input.query, input.allowReserve ? { allowReserve: true } : undefined)
82 .pipe(
83 Effect.flatMap((query) =>
84 (input.query.includes("query PullRequestStackMemberships")
85 ? mockedStackMemberships
86 : mockedExecute)({ kind: "graphql", ...input, query }),
87 ),
88 Effect.map((answer) => answer.body),
89 Effect.tap((body) => budget.observe(input.host, body)),
90 ),
91 rest: (input) => mockedExecute({ kind: "rest", ...input }),
92 credential: (host) => mockedCredential(host),
93 });
94 }),
95);
5696
5797const layer = it.layer(
5898 GitHubPullRequestCli.layer.pipe(
59 Layer.provide(
60 Layer.mock(GitHubCli.GitHubCli)({
61 execute: (input) =>
62 input.args.some((arg) => arg.includes("query PullRequestStackMemberships"))
63 ? mockedStackMemberships(input)
64 : mockedExecute(input),
65 getPullRequest: mockedGetPullRequest,
66 }),
67 ),
99 Layer.provideMerge(mockApi),
68100 Layer.provideMerge(GitHubGraphQlBudget.layer),
69101 Layer.provide(NodeCrypto.layer),
70102 ),
71103);
72104
73function output(stdout: string, stdoutTruncated = false, stdoutInvalidUtf8 = false) {
74 return {
75 exitCode: ChildProcessSpawner.ExitCode(0),
76 stdout,
77 stderr: "",
78 stdoutTruncated,
79 stderrTruncated: false,
80 stdoutInvalidUtf8,
81 };
105/** A successful answer; GraphQL reads take its body. */
106function output(
107 body: string,
108 truncated = false,
109 invalidUtf8 = false,
110): GitHubApi.GitHubRestResponse {
111 return { status: 200, headers: {}, body, truncated, invalidUtf8 };
82112}
83113
84function pullRequests(
114/**
115 * Listing rows as GraphQL answers them. Overrides may use the flat shapes a test reads most
116 * easily: `reviewRequests` as `[{ login }]` or `[{ slug }]`, and `checks` as the rollup state.
117 */
118function rows(
85119 count: number,
86120 firstNumber: number,
87121 overrides: (number: number) => Readonly<Record<string, unknown>> = () => ({}),
88): string {
89 return JSON.stringify(
90 Array.from({ length: count }, (_, index) => ({
91 number: firstNumber + index,
92 title: `Pull request ${firstNumber + index}`,
93 url: `https://github.com/acme/web/pull/${firstNumber + index}`,
122): ReadonlyArray<Record<string, unknown>> {
123 return Array.from({ length: count }, (_, index) => {
124 const number = firstNumber + index;
125 const { reviewRequests, checks, ...rest } = overrides(number) as {
126 reviewRequests?: ReadonlyArray<Record<string, string>>;
127 checks?: string;
128 } & Record<string, unknown>;
129 return {
130 number,
131 title: `Pull request ${number}`,
132 url: `https://github.com/acme/web/pull/${number}`,
94133 headRefName: "feat/page",
95134 baseRefName: "main",
96135 createdAt: "2026-07-01T00:00:00Z",
97136 updatedAt: "2026-07-02T00:00:00Z",
98 ...overrides(firstNumber + index),
99 })),
100 );
137 repository: { nameWithOwner: "acme/web" },
138 ...(reviewRequests === undefined
139 ? {}
140 : {
141 reviewRequests: {
142 nodes: reviewRequests.map((requestedReviewer) => ({ requestedReviewer })),
143 },
144 }),
145 ...(checks === undefined
146 ? {}
147 : { commits: { nodes: [{ commit: { statusCheckRollup: { state: checks } } }] } }),
148 ...rest,
149 };
150 });
151}
152
153/** A page of the searched listing. */
154function pullRequests(
155 count: number,
156 firstNumber: number,
157 overrides?: (number: number) => Readonly<Record<string, unknown>>,
158): string {
159 return encodeJson({
160 data: {
161 search: { pageInfo: { hasNextPage: false }, nodes: rows(count, firstNumber, overrides) },
162 },
163 });
164}
165
166/** A page of a repository's own list, which is what the search-free fallback reads. */
167function listedPullRequests(
168 count: number,
169 firstNumber: number,
170 overrides?: (number: number) => Readonly<Record<string, unknown>>,
171): string {
172 return encodeJson({
173 data: {
174 repository: {
175 pullRequests: {
176 pageInfo: { hasNextPage: false },
177 nodes: rows(count, firstNumber, overrides),
178 },
179 },
180 },
181 });
101182}
102183
184/** A search that found nothing, which is also what GitHub says for a repository it does not index. */
185const emptySearch = () => output(pullRequests(0, 1));
186const emptyList = () => output(listedPullRequests(0, 1));
187
103188function pullRequestFiles(count: number, firstIndex: number): string {
104 return JSON.stringify(
189 return encodeJson(
105190 Array.from({ length: count }, (_, index) => ({
106191 filename: `src/file${firstIndex + index}.ts`,
107192 status: "modified",
......@@ -139,7 +224,7 @@ function reviewThreadsPage(
139224 nodes: ReadonlyArray<Record<string, unknown>>,
140225 endCursor: string | null,
141226): string {
142 return JSON.stringify({
227 return encodeJson({
143228 data: {
144229 repository: {
145230 pullRequest: {
......@@ -160,7 +245,7 @@ function threadCommentsPage(
160245 totalCount: number,
161246 pullRequestId = "PR_7",
162247): string {
163 return JSON.stringify({
248 return encodeJson({
164249 data: {
165250 repository: { pullRequest: { id: "PR_7" } },
166251 node: {
......@@ -171,26 +256,43 @@ function threadCommentsPage(
171256 });
172257}
173258
174/** What `gh pr diff` answers on a pull request GitHub will not serve a diff for. */
175const diffRefused = new GitHubCli.GitHubCliCommandError({
176 command: "gh",
177 cwd: "/w",
178 cause: new Error("HTTP 406: the diff exceeded the maximum number of files (300)"),
259/** What GitHub answers for a pull request it will not serve a whole diff for. */
260const diffRefused = new GitHubApi.GitHubApiResponseError({
261 host: "github.com",
262 operation: "getPullRequestDiff",
263 status: 406,
179264});
180265
181/** The whole invocation the nth call made, so both argv and stdin can be asserted. */
182function callAt(index: number) {
266/** The nth request the CLI made. */
267function callAt(index: number): ApiCall {
183268 const call = mockedExecute.mock.calls[index];
184269 assert.isDefined(call);
185270 return call[0];
186271}
187272
188/** The one argument `--search` carries, which is where every listing filter ends up. */
273/** The GraphQL variables of the nth request. */
274function varsAt(index: number): Readonly<Record<string, unknown>> {
275 const call = callAt(index);
276 assert.strictEqual(call.kind, "graphql");
277 return call.kind === "graphql" ? (call.variables ?? {}) : {};
278}
279
280/** The GraphQL document of the nth request. */
281function queryAt(index: number): string {
282 const call = callAt(index);
283 return call.kind === "graphql" ? call.query : "";
284}
285
286/** The REST path of the nth request. */
287function pathAt(index: number): string {
288 const call = callAt(index);
289 return call.kind === "rest" ? call.path : "";
290}
291
292/** The search the nth request carried, or undefined for a read that did not search. */
189293function searchOfCall(index: number): string | undefined {
190 const args = callAt(index).args;
191 const flag = args.indexOf("--search");
192 // Absent is its own answer: a read that carries no `--search` at all is what the fallback is.
193 return flag === -1 ? undefined : args[flag + 1];
294 const q = callAt(index).kind === "graphql" ? varsAt(index)["q"] : undefined;
295 return typeof q === "string" ? q : undefined;
194296}
195297
196298/** One row as a search answers it, which is the listing's row one connection deeper. */
......@@ -214,19 +316,143 @@ function searchItem(number: number, repository: string, updatedAt: string) {
214316}
215317
216318function searchPage(nodes: ReadonlyArray<unknown>, hasNextPage = false) {
217 return output(JSON.stringify({ data: { search: { pageInfo: { hasNextPage }, nodes } } }));
319 return output(encodeJson({ data: { search: { pageInfo: { hasNextPage }, nodes } } }));
218320}
219321
220/** The search a batched read sent, which travels in the request body rather than in argv. */
221function searchQueryOfCall(index: number): string | undefined {
222 const body = JSON.parse(callAt(index).stdin ?? "{}") as { variables?: { q?: string } };
223 return body.variables?.q;
322const searchQueryOfCall = searchOfCall;
323
324/**
325 * Answers every request with the body of the first route whose needle its GraphQL document or
326 * REST path contains, and anything unrouted with an empty object, which a mutation never reads.
327 */
328function route(...routes: ReadonlyArray<readonly [needle: string, body: unknown]>): void {
329 mockedExecute.mockImplementation((call) => {
330 const target = call.kind === "graphql" ? call.query : call.path;
331 const match = routes.find(([needle]) => target.includes(needle));
332 return Effect.succeed(output(match === undefined ? "{}" : encodeJson(match[1])));
333 });
334}
335
336/** The variables of every GraphQL request whose document contains `needle`, in order. */
337function variablesOf(needle: string): ReadonlyArray<Readonly<Record<string, unknown>>> {
338 return mockedExecute.mock.calls.flatMap(([call]) =>
339 call.kind === "graphql" && call.query.includes(needle) ? [call.variables ?? {}] : [],
340 );
341}
342
343/** Every REST request whose path contains `needle`, in order. */
344function restCallsTo(needle: string): ReadonlyArray<GitHubApi.GitHubRestInput> {
345 return mockedExecute.mock.calls.flatMap(([call]) =>
346 call.kind === "rest" && call.path.includes(needle) ? [call] : [],
347 );
224348}
225349
350/**
351 * The check that a client-given node hangs off the pull request it names. Routed before the node
352 * id lookup, whose document it contains.
353 */
354const SUBJECT_SCOPE_QUERY = "node(id: $subjectId)";
355const subjectScope = (subjectId: string, pullRequestId: string) => ({
356 data: {
357 repository: { pullRequest: { id: pullRequestId } },
358 node: { id: subjectId, pullRequest: { id: pullRequestId } },
359 },
360});
361
362/** One page of a head commit's check contexts, the read a rollup past one page is walked by. */
363const checkContextsPage = (
364 nodes: ReadonlyArray<Record<string, unknown>>,
365 endCursor: string | null,
366 headRefOid = "abc123",
367) =>
368 encodeJson({
369 data: {
370 repository: {
371 pullRequest: {
372 headRefOid,
373 commits: {
374 nodes: [
375 {
376 commit: {
377 statusCheckRollup: {
378 contexts: { nodes, pageInfo: { hasNextPage: endCursor !== null, endCursor } },
379 },
380 },
381 },
382 ],
383 },
384 },
385 },
386 },
387 });
388
389/** A pull request's base and head, as the REST read a file expansion starts from answers them. */
390const pullRequestRefs = encodeJson({ base: { sha: "a1b2c3d" }, head: { sha: "b1c2d3e" } });
391
392/** A cross-repository pull request whose head waits on a maintainer to run its workflows. */
393const crossRepositoryDetail = (headRefOid = "abc123") =>
394 coreResponse({
395 headRefName: "feat/page",
396 headRefOid,
397 isCrossRepository: true,
398 headRepositoryOwner: { login: "octocat" },
399 });
400
401/** Open pull requests on one head branch, every one of them from the same fork head. */
402const heads = (numbers: ReadonlyArray<number>) => ({
403 data: {
404 repository: {
405 pullRequests: {
406 pageInfo: { hasNextPage: false, endCursor: null },
407 nodes: numbers.map((number) => ({
408 number,
409 headRefOid: "abc123",
410 isCrossRepository: true,
411 headRepositoryOwner: { login: "octocat" },
412 })),
413 },
414 },
415 },
416});
417
418const workflowRuns = (ids: ReadonlyArray<number>) => ({
419 workflow_runs: ids.map((id) => ({
420 id,
421 name: id === 10 ? "build" : `run ${id}`,
422 html_url: `https://example.com/${id}`,
423 })),
424});
425
426/** Answers the reads an approval makes: the detail, the heads, the runs; approvals return nothing. */
427function workflowApprovalRoutes(
428 detail: () => unknown,
429 headsAnswer: unknown,
430 runsAnswer: unknown,
431): void {
432 mockedExecute.mockImplementation((call) =>
433 Effect.sync(() =>
434 output(
435 call.kind === "rest"
436 ? call.path.endsWith("/approve")
437 ? ""
438 : encodeJson(runsAnswer)
439 : call.query.includes("headRefName: $head")
440 ? encodeJson(headsAnswer)
441 : encodeJson(detail()),
442 ),
443 ),
444 );
445}
446
447/** The pull request node id lookup, which the layer caches for every test after the first. */
448const NODE_ID_QUERY = "pullRequest(number: $number) { id }";
449const nodeIdAnswer = (id: string) => ({ data: { repository: { pullRequest: { id } } } });
450
226451afterEach(() => {
227452 mockedExecute.mockReset();
228453 mockedStackMemberships.mockReset();
229 mockedGetPullRequest.mockReset();
454 mockedCredential.mockReset();
455 mockedCredential.mockImplementation(defaultCredential);
230456});
231457
232458it.effect(
......@@ -234,22 +460,41 @@ it.effect(
234460 () =>
235461 Effect.gen(function* () {
236462 let activeToken = "broad-credential";
237 const commands: VcsProcess.VcsProcessInput[] = [];
238 const github = yield* GitHubCli.make.pipe(
239 Effect.provide(Layer.merge(GitHubGraphQlBudget.layer, SourceControlRateLimit.layer)),
240 Effect.provideService(VcsProcess.VcsProcess, {
241 run: (input) =>
242 Effect.sync(() => {
243 commands.push(input);
244 if (input.args[0] === "auth") return output(activeToken);
245 if (input.args[0] === "api") return output('{"id":123,"login":"same-account"}');
246 return output("");
247 }),
463 const requests: Array<{ readonly url: string; readonly authorization: string | undefined }> =
464 [];
465 const credentials = Layer.succeed(
466 GitHubCredentials.GitHubCredentials,
467 GitHubCredentials.GitHubCredentials.of({
468 get: (host) =>
469 Effect.sync(() => ({
470 host,
471 token: Redacted.make(activeToken),
472 source: "gh" as const,
473 fingerprint: `${host}:${activeToken.length}${activeToken.at(-1)}`,
474 })),
475 invalidate: () => Effect.void,
248476 }),
249477 );
478 const http = Layer.succeed(
479 HttpClient.HttpClient,
480 HttpClient.make((request) =>
481 Effect.sync(() => {
482 requests.push({ url: request.url, authorization: request.headers["authorization"] });
483 const body = request.url.endsWith("/user")
484 ? { id: 123, login: "same-account" }
485 : { data: { repository: { pullRequest: { id: "PR_1" } }, addComment: {} } };
486 return HttpClientResponse.fromWeb(request, new Response(encodeJson(body)));
487 }),
488 ),
489 );
250490 const cli = yield* GitHubPullRequestCli.make.pipe(
251 Effect.provideService(GitHubCli.GitHubCli, github),
252 Effect.provide(Layer.merge(GitHubGraphQlBudget.layer, NodeCrypto.layer)),
491 Effect.provide(
492 GitHubApi.layer.pipe(
493 Layer.provide(Layer.mergeAll(credentials, http)),
494 Layer.provide(GitHubGraphQlBudget.layer),
495 Layer.provide(SourceControlRateLimit.layer),
496 ),
497 ),
253498 );
254499 const input = { cwd: "/repo", host: "github.com" };
255500 const first = yield* cli.withVerifiedCredential(input, (identity) =>
......@@ -270,16 +515,17 @@ it.effect(
270515 expect(first.credentialFingerprint).not.toBe(second.credentialFingerprint);
271516 expect(encodeJson([first, second])).not.toContain("broad-credential");
272517 expect(encodeJson([first, second])).not.toContain("restricted-credential");
273 expect(commands.find((command) => command.args[0] === "pr")?.env).toMatchObject({
274 GH_TOKEN: "broad-credential",
275 GITHUB_TOKEN: "broad-credential",
276 GH_DEBUG: "",
277 });
518 // The comment was written under the credential the page verified, not the switched one.
519 expect(
520 requests
521 .filter((request) => request.url.endsWith("/graphql"))
522 .map((request) => request.authorization),
523 ).toEqual(["Bearer broad-credential", "Bearer broad-credential"]);
278524 expect(
279 commands
280 .filter((command) => command.args[0] === "api")
281 .map((command) => command.env?.GH_TOKEN),
282 ).toEqual(["broad-credential", "restricted-credential"]);
525 requests
526 .filter((request) => request.url.endsWith("/user"))
527 .map((request) => request.authorization),
528 ).toEqual(["Bearer broad-credential", "Bearer restricted-credential"]);
283529 expect(yield* cli.getRoutingIdentity(input)).toEqual({
284530 accountId: "123",
285531 viewer: "same-account",
......@@ -394,18 +640,14 @@ layer("GitHubPullRequestCli.layer", (it) => {
394640 },
395641 });
396642 expect(mockedExecute).toHaveBeenCalledTimes(1);
397 expect(callAt(0).args).toEqual(
398 expect.arrayContaining(["api", "graphql", "--hostname", "github.example"]),
399 );
643 expect(callAt(0)).toMatchObject({ kind: "graphql", host: "github.example" });
400644 }),
401645 );
402646
403647 it.effect("coalesces concurrent identity verification for the same host and credential", () =>
404648 Effect.gen(function* () {
405 mockedExecute.mockImplementation((input) =>
406 input.args[0] === "auth"
407 ? Effect.succeed(output("shared-credential"))
408 : Effect.yieldNow.pipe(Effect.as(output('{"id":123,"login":"viewer"}'))),
649 mockedExecute.mockImplementation(() =>
650 Effect.yieldNow.pipe(Effect.as(output('{"id":123,"login":"viewer"}'))),
409651 );
410652 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
411653 const results = yield* Effect.all(
......@@ -417,7 +659,8 @@ layer("GitHubPullRequestCli.layer", (it) => {
417659 expect(results).toEqual(
418660 Array.from({ length: 4 }, () => ({ accountId: "123", viewer: "viewer" })),
419661 );
420 expect(mockedExecute.mock.calls.filter(([input]) => input.args[0] === "api")).toHaveLength(1);
662 expect(mockedExecute).toHaveBeenCalledTimes(1);
663 expect(pathAt(0)).toBe("user");
421664 }),
422665 );
423666
......@@ -426,15 +669,9 @@ layer("GitHubPullRequestCli.layer", (it) => {
426669 () =>
427670 Effect.gen(function* () {
428671 const firstStarted = yield* Deferred.make<void>();
429 const secondStarted = yield* Deferred.make<void>();
430 let tokens = 0;
431672 let verifications = 0;
432 mockedExecute.mockImplementation((input) =>
673 mockedExecute.mockImplementation(() =>
433674 Effect.gen(function* () {
434 if (input.args[0] === "auth") {
435 if (++tokens === 2) yield* Deferred.succeed(secondStarted, undefined);
436 return output("cancel-credential");
437 }
438675 if (++verifications === 1) {
439676 yield* Deferred.succeed(firstStarted, undefined);
440677 return yield* Effect.never;
......@@ -446,8 +683,9 @@ layer("GitHubPullRequestCli.layer", (it) => {
446683 const input = { cwd: "/w", host: "github.identity-cancel.test" };
447684 const first = yield* cli.getRoutingIdentity(input).pipe(Effect.forkChild);
448685 yield* Deferred.await(firstStarted);
686 // The second reader is queued on the same credential's lock behind the first.
449687 const second = yield* cli.getRoutingIdentity(input).pipe(Effect.forkChild);
450 yield* Deferred.await(secondStarted);
688 yield* Effect.yieldNow;
451689 yield* Fiber.interrupt(first);
452690 expect(yield* Fiber.join(second)).toEqual({ accountId: "123", viewer: "viewer" });
453691 expect(verifications).toBe(2);
......@@ -479,8 +717,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
479717 mockedExecute.mockReturnValueOnce(
480718 Effect.succeed(
481719 output(
482 // @effect-diagnostics-next-line preferSchemaOverJson:off
483 JSON.stringify({
720 encodeJson({
484721 data: { s0: { pullRequest: node(7) }, s1: { pullRequest: node(8) } },
485722 }),
486723 ),
......@@ -526,7 +763,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
526763 );
527764 assert.strictEqual(eight?.headBranch, "feat/8");
528765 expect(mockedExecute).toHaveBeenCalledOnce();
529 const document = callAt(0).args.at(-1) ?? "";
766 const document = queryAt(0);
530767 expect(document).toContain(
531768 's0: repository(owner: "acme", name: "web") { pullRequest(number: 7)',
532769 );
......@@ -575,7 +812,8 @@ layer("GitHubPullRequestCli.layer", (it) => {
575812 // GitHub had no answer for #8, so its watch reads it in full.
576813 expect(eight).toBeNull();
577814 expect(mockedExecute).toHaveBeenCalledOnce();
578 expect(callAt(0).args.at(-1) ?? "").toContain(
815 const call = callAt(0);
816 expect(call.kind === "graphql" ? call.query : "").toContain(
579817 'w1: repository(owner: "acme", name: "web") { pullRequest(number: 8)',
580818 );
581819 }),
......@@ -588,34 +826,28 @@ layer("GitHubPullRequestCli.layer", (it) => {
588826 .mockReturnValueOnce(
589827 Effect.succeed(
590828 output(
591 // @effect-diagnostics-next-line preferSchemaOverJson:off
592 JSON.stringify({
593 number: 7,
594 title: "Reuse the summary",
595 url: "https://github.com/acme/web/pull/7",
596 author: { login: "octocat", name: "Octo Cat" },
597 baseRefName: "main",
598 headRefName: "feat/summary",
599 state: "OPEN",
600 isDraft: false,
601 mergeable: "MERGEABLE",
602 reviewDecision: "APPROVED",
603 additions: 12,
604 deletions: 3,
605 changedFiles: 2,
606 createdAt: "2026-08-20T00:00:00.000Z",
607 updatedAt: "2026-08-24T12:34:56.000Z",
608 reviewRequests: [],
609 labels: [],
610 statusCheckRollup: [
611 {
612 __typename: "CheckRun",
613 status: "COMPLETED",
614 conclusion: "SUCCESS",
615 name: "ci",
829 encodeJson({
830 data: {
831 s0: {
832 pullRequest: {
833 number: 7,
834 title: "Reuse the summary",
835 url: "https://github.com/acme/web/pull/7",
836 author: { login: "octocat", name: "Octo Cat" },
837 baseRefName: "main",
838 headRefName: "feat/summary",
839 state: "OPEN",
840 isDraft: false,
841 mergeable: "MERGEABLE",
842 reviewDecision: "APPROVED",
843 additions: 12,
844 deletions: 3,
845 changedFiles: 2,
846 updatedAt: "2026-08-24T12:34:56.000Z",
847 commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] },
848 },
616849 },
617 ],
618 body: "",
850 },
619851 }),
620852 ),
621853 ),
......@@ -631,15 +863,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
631863 assert.strictEqual(summary.headBranch, "feat/summary");
632864 assert.strictEqual(summary.checksState, "passing");
633865 assert.strictEqual(mockedExecute.mock.calls.length, 2);
634 expect(callAt(1).args).toEqual([
635 "pr",
636 "view",
637 "7",
638 "--repo",
639 "github.com/acme/web",
640 "--json",
641 expect.stringContaining("statusCheckRollup"),
642 ]);
866 expect(varsAt(1)).toEqual({ owner: "acme", name: "web", number: 7 });
643867 }),
644868 );
645869
......@@ -648,8 +872,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
648872 mockedExecute.mockReturnValueOnce(
649873 Effect.succeed(
650874 output(
651 // @effect-diagnostics-next-line preferSchemaOverJson:off
652 JSON.stringify([
875 encodeJson([
653876 {
654877 id: 42,
655878 number: 3,
......@@ -688,12 +911,11 @@ layer("GitHubPullRequestCli.layer", (it) => {
688911 { number: 7, headBranch: "feat/two", state: "open" },
689912 ],
690913 });
691 assert.deepStrictEqual(callAt(0).args, [
692 "api",
693 "--hostname",
694 "ghe.example.com",
695 "repos/acme/web/stacks?pull_request=7",
696 ]);
914 expect(callAt(0)).toMatchObject({
915 kind: "rest",
916 host: "ghe.example.com",
917 path: "repos/acme/web/stacks?pull_request=7",
918 });
697919 }),
698920 );
699921
......@@ -707,13 +929,11 @@ layer("GitHubPullRequestCli.layer", (it) => {
707929 { number: 7, head: { ref: "feat/two", sha: "abc123" }, state: "open", merged_at: null },
708930 ],
709931 };
710 // @effect-diagnostics-next-line preferSchemaOverJson:off
711 mockedExecute.mockReturnValueOnce(Effect.succeed(output(JSON.stringify([minimal]))));
932 mockedExecute.mockReturnValueOnce(Effect.succeed(output(encodeJson([minimal]))));
712933 mockedExecute.mockReturnValueOnce(
713934 Effect.succeed(
714935 output(
715 // @effect-diagnostics-next-line preferSchemaOverJson:off
716 JSON.stringify({
936 encodeJson({
717937 ...minimal,
718938 pull_requests: [{ ...minimal.pull_requests[0], title: "Second layer", draft: false }],
719939 }),
......@@ -733,12 +953,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
733953 headSha: "abc123",
734954 isDraft: false,
735955 });
736 expect(callAt(1).args).toEqual([
737 "api",
738 "--hostname",
739 "github.com",
740 "repos/acme/web/stacks/3",
741 ]);
956 expect(pathAt(1)).toBe("repos/acme/web/stacks/3");
742957 }),
743958 );
744959
......@@ -760,13 +975,11 @@ layer("GitHubPullRequestCli.layer", (it) => {
760975
761976 it.effect("reads a host that refuses the stacks preview as not stacked", () =>
762977 Effect.gen(function* () {
763 // The CLI classifies a missing preview endpoint as not found.
764978 mockedExecute.mockReturnValueOnce(
765979 Effect.fail(
766 new GitHubCli.GitHubPullRequestNotFoundError({
767 command: "gh",
768 cwd: "/w",
769 cause: new Error("HTTP 404: Not Found (https://api.github.com/repos/acme/web/stacks)"),
980 new GitHubApi.GitHubApiNotFoundError({
981 host: "github.com",
982 operation: "getPullRequestStack",
770983 }),
771984 ),
772985 );
......@@ -783,14 +996,13 @@ layer("GitHubPullRequestCli.layer", (it) => {
783996 }),
784997 );
785998
786 it.effect("does not read a signed-out gh as an unstacked pull request", () =>
999 it.effect("does not read a refused credential as an unstacked pull request", () =>
7871000 Effect.gen(function* () {
7881001 mockedExecute.mockReturnValueOnce(
7891002 Effect.fail(
790 new GitHubCli.GitHubCliAuthenticationError({
791 command: "gh",
792 cwd: "/w",
793 cause: new Error("gh auth login"),
1003 new GitHubApi.GitHubApiAuthenticationError({
1004 host: "github.com",
1005 operation: "getPullRequestStack",
7941006 }),
7951007 ),
7961008 );
......@@ -805,16 +1017,16 @@ layer("GitHubPullRequestCli.layer", (it) => {
8051017 }),
8061018 );
8071019
808 assert.strictEqual(error._tag, "GitHubCliAuthenticationError");
1020 assert.strictEqual(error._tag, "GitHubApiAuthenticationError");
8091021 }),
8101022 );
8111023
8121024 it.effect("preserves transient stack failures instead of reporting no stack", () =>
8131025 Effect.gen(function* () {
814 const failure = new GitHubCli.GitHubCliCommandError({
815 command: "gh",
816 cwd: "/w",
817 cause: new Error("HTTP 503"),
1026 const failure = new GitHubApi.GitHubApiResponseError({
1027 host: "github.com",
1028 operation: "getPullRequestStack",
1029 status: 503,
8181030 });
8191031 mockedExecute.mockReturnValueOnce(Effect.fail(failure));
8201032 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
......@@ -867,13 +1079,9 @@ layer("GitHubPullRequestCli.layer", (it) => {
8671079
8681080 assert.strictEqual(batch.items.length, 3);
8691081 assert.isFalse(batch.truncated);
870 const args = callAt(0).args;
871 expect(args).toContain("--repo");
872 expect(args).toContain("github.com/acme/web");
873 expect(args).toContain("--state");
874 expect(args).toContain("open");
875 expect(args).toContain("--limit");
876 expect(args).toContain("11");
1082 expect(callAt(0)).toMatchObject({ kind: "graphql", host: "github.com" });
1083 expect(searchOfCall(0)).toBe("is:pr is:open sort:updated-desc repo:acme/web");
1084 expect(queryAt(0)).toContain("first: 11");
8771085 }),
8781086 );
8791087
......@@ -899,7 +1107,13 @@ layer("GitHubPullRequestCli.layer", (it) => {
8991107
9001108 it.effect("excludes merged pull requests from the Closed tab", () =>
9011109 Effect.gen(function* () {
902 mockedExecute.mockReturnValue(Effect.succeed(output("[]")));
1110 mockedExecute.mockImplementation((call) =>
1111 Effect.succeed(
1112 call.kind === "graphql" && call.query.includes("pullRequests(")
1113 ? emptyList()
1114 : emptySearch(),
1115 ),
1116 );
9031117 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
9041118
9051119 yield* cli.listPullRequests({
......@@ -912,14 +1126,20 @@ layer("GitHubPullRequestCli.layer", (it) => {
9121126 limit: 10,
9131127 });
9141128
915 // `--state closed` includes merged pull requests, so the tab narrows through search.
916 expect(searchOfCall(0)).toBe("is:unmerged sort:updated-desc");
1129 // A closed pull request may also be a merged one, so the tab narrows through search.
1130 expect(searchOfCall(0)).toBe("is:pr is:closed is:unmerged sort:updated-desc repo:acme/web");
9171131 }),
9181132 );
9191133
9201134 it.effect("narrows to the author on the authored tab", () =>
9211135 Effect.gen(function* () {
922 mockedExecute.mockReturnValue(Effect.succeed(output("[]")));
1136 mockedExecute.mockImplementation((call) =>
1137 Effect.succeed(
1138 call.kind === "graphql" && call.query.includes("pullRequests(")
1139 ? emptyList()
1140 : emptySearch(),
1141 ),
1142 );
9231143 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
9241144
9251145 yield* cli.listPullRequests({
......@@ -932,15 +1152,19 @@ layer("GitHubPullRequestCli.layer", (it) => {
9321152 limit: 10,
9331153 });
9341154
935 const args = callAt(0).args;
936 expect(args).toContain("--author");
937 expect(args).toContain("bilal");
1155 expect(searchOfCall(0)).toBe("is:pr is:open author:bilal sort:updated-desc repo:acme/web");
9381156 }),
9391157 );
9401158
9411159 it.effect("narrows through search on the reviewing tab", () =>
9421160 Effect.gen(function* () {
943 mockedExecute.mockReturnValue(Effect.succeed(output("[]")));
1161 mockedExecute.mockImplementation((call) =>
1162 Effect.succeed(
1163 call.kind === "graphql" && call.query.includes("pullRequests(")
1164 ? emptyList()
1165 : emptySearch(),
1166 ),
1167 );
9441168 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
9451169
9461170 yield* cli.listPullRequests({
......@@ -953,7 +1177,9 @@ layer("GitHubPullRequestCli.layer", (it) => {
9531177 limit: 10,
9541178 });
9551179
956 expect(searchOfCall(0)).toBe("review-requested:bilal sort:updated-desc");
1180 expect(searchOfCall(0)).toBe(
1181 "is:pr is:open review-requested:bilal sort:updated-desc repo:acme/web",
1182 );
9571183 }),
9581184 );
9591185
......@@ -1008,7 +1234,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
10081234 }),
10091235 );
10101236
1011 it.effect("keeps a searched-for qualifier inside the phrase, and out of argv", () =>
1237 it.effect("keeps a searched-for qualifier inside the phrase", () =>
10121238 Effect.gen(function* () {
10131239 mockedExecute.mockReturnValue(Effect.succeed(searchPage([])));
10141240 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
......@@ -1024,13 +1250,11 @@ layer("GitHubPullRequestCli.layer", (it) => {
10241250 query: 'x" is:merged repo:evil/repo',
10251251 });
10261252
1027 // Quoted and escaped, so the words a reader typed narrow the listing rather than widening
1028 // it — and the whole document travels over stdin rather than in a visible argv.
1253 // Quoted and escaped, so the words a reader typed narrow the listing rather than widening it.
10291254 assert.strictEqual(
10301255 searchQueryOfCall(0),
10311256 'is:pr is:open "x\\" is:merged repo:evil/repo" sort:updated-desc repo:acme/web',
10321257 );
1033 expect(callAt(0).args).not.toContain("-f");
10341258 }),
10351259 );
10361260
......@@ -1139,12 +1363,12 @@ layer("GitHubPullRequestCli.layer", (it) => {
11391363
11401364 it.effect("enriches only the visible fallback rows after filtering and widening", () =>
11411365 Effect.gen(function* () {
1142 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
1366 mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch()));
11431367 mockedExecute.mockReturnValueOnce(
1144 Effect.succeed(output(pullRequests(3, 1, () => ({ isDraft: true })))),
1368 Effect.succeed(output(listedPullRequests(3, 1, () => ({ isDraft: true })))),
11451369 );
11461370 mockedExecute.mockReturnValueOnce(
1147 Effect.succeed(output(pullRequests(6, 1, (number) => ({ isDraft: number < 4 })))),
1371 Effect.succeed(output(listedPullRequests(6, 1, (number) => ({ isDraft: number < 4 })))),
11481372 );
11491373 mockedStackMemberships.mockReturnValueOnce(
11501374 Effect.succeed(
......@@ -1180,7 +1404,8 @@ layer("GitHubPullRequestCli.layer", (it) => {
11801404 expect(batch.truncated).toBe(true);
11811405 expect(batch.continues).toBe(false);
11821406 expect(mockedStackMemberships).toHaveBeenCalledTimes(1);
1183 const query = mockedStackMemberships.mock.calls[0]?.[0].args.at(-1) ?? "";
1407 const membership = mockedStackMemberships.mock.calls[0]?.[0];
1408 const query = membership?.kind === "graphql" ? membership.query : "";
11841409 expect(query).toContain("pullRequest(number: 4)");
11851410 expect(query).toContain("pullRequest(number: 5)");
11861411 expect(query).not.toContain("pullRequest(number: 1)");
......@@ -1192,12 +1417,12 @@ layer("GitHubPullRequestCli.layer", (it) => {
11921417 Effect.gen(function* () {
11931418 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(27, 1))));
11941419 mockedStackMemberships.mockImplementation((input) =>
1195 input.args.at(-1)?.includes("pullRequest(number: 26)")
1420 input.kind === "graphql" && input.query.includes("pullRequest(number: 26)")
11961421 ? Effect.fail(
1197 new GitHubCli.GitHubCliCommandError({
1198 command: "gh",
1199 cwd: "/w",
1200 cause: new Error("HTTP 502"),
1422 new GitHubApi.GitHubApiResponseError({
1423 host: "github.com",
1424 operation: "listPullRequestStackMemberships",
1425 status: 502,
12011426 }),
12021427 )
12031428 : Effect.succeed(
......@@ -1238,8 +1463,8 @@ layer("GitHubPullRequestCli.layer", (it) => {
12381463
12391464 it.effect("skips membership enrichment for empty pages and enterprise hosts", () =>
12401465 Effect.gen(function* () {
1241 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
1242 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
1466 mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch()));
1467 mockedExecute.mockReturnValueOnce(Effect.succeed(emptyList()));
12431468 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(1, 7))));
12441469 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
12451470 const input = {
......@@ -1268,7 +1493,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
12681493 // Every chunk answers for its first alias only, so a row GitHub said nothing about is
12691494 // dropped rather than shown as a change of no size.
12701495 Effect.succeed(
1271 output(JSON.stringify({ data: { s0: { pullRequest: { additions: 4, deletions: 1 } } } })),
1496 output(encodeJson({ data: { s0: { pullRequest: { additions: 4, deletions: 1 } } } })),
12721497 ),
12731498 );
12741499 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
......@@ -1285,7 +1510,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
12851510 { repository: "acme/web", number: 1, additions: 4, deletions: 1 },
12861511 { repository: "acme/web", number: 26, additions: 4, deletions: 1 },
12871512 ]);
1288 const document = callAt(0).args.at(-1) ?? "";
1513 const document = queryAt(0);
12891514 expect(document).toContain('s0: repository(owner: "acme", name: "web")');
12901515 expect(document).toContain("pullRequest(number: 25)");
12911516 }),
......@@ -1310,7 +1535,13 @@ layer("GitHubPullRequestCli.layer", (it) => {
13101535
13111536 it.effect("hands a search to GitHub rather than to the rows already read", () =>
13121537 Effect.gen(function* () {
1313 mockedExecute.mockReturnValue(Effect.succeed(output("[]")));
1538 mockedExecute.mockImplementation((call) =>
1539 Effect.succeed(
1540 call.kind === "graphql" && call.query.includes("pullRequests(")
1541 ? emptyList()
1542 : emptySearch(),
1543 ),
1544 );
13141545 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
13151546
13161547 yield* cli.listPullRequests({
......@@ -1326,13 +1557,21 @@ layer("GitHubPullRequestCli.layer", (it) => {
13261557
13271558 // The recency qualifier rides along, because free text would otherwise reorder the page
13281559 // by relevance and truncation would drop the newest matches.
1329 expect(searchOfCall(0)).toBe('"pull requests page" sort:updated-desc');
1560 expect(searchOfCall(0)).toBe(
1561 'is:pr is:open "pull requests page" sort:updated-desc repo:acme/web',
1562 );
13301563 }),
13311564 );
13321565
13331566 it.effect("joins a search onto the tab's own qualifiers instead of replacing them", () =>
13341567 Effect.gen(function* () {
1335 mockedExecute.mockReturnValue(Effect.succeed(output("[]")));
1568 mockedExecute.mockImplementation((call) =>
1569 Effect.succeed(
1570 call.kind === "graphql" && call.query.includes("pullRequests(")
1571 ? emptyList()
1572 : emptySearch(),
1573 ),
1574 );
13361575 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
13371576
13381577 yield* cli.listPullRequests({
......@@ -1346,16 +1585,21 @@ layer("GitHubPullRequestCli.layer", (it) => {
13461585 query: "page",
13471586 });
13481587
1349 // One `--search` is all gh reads, so a second would silently drop the first.
1350 const args = callAt(0).args;
1351 assert.strictEqual(args.filter((arg) => arg === "--search").length, 1);
1352 expect(searchOfCall(0)).toBe('review-requested:bilal is:unmerged "page" sort:updated-desc');
1588 expect(searchOfCall(0)).toBe(
1589 'is:pr is:closed is:unmerged review-requested:bilal "page" sort:updated-desc repo:acme/web',
1590 );
13531591 }),
13541592 );
13551593
13561594 it.effect("carries the further narrowings into the search as qualifiers", () =>
13571595 Effect.gen(function* () {
1358 mockedExecute.mockReturnValue(Effect.succeed(output("[]")));
1596 mockedExecute.mockImplementation((call) =>
1597 Effect.succeed(
1598 call.kind === "graphql" && call.query.includes("pullRequests(")
1599 ? emptyList()
1600 : emptySearch(),
1601 ),
1602 );
13591603 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
13601604
13611605 yield* cli.listPullRequests({
......@@ -1379,15 +1623,21 @@ layer("GitHubPullRequestCli.layer", (it) => {
13791623 // Quotes around anything a reader typed, and the one character that could end a quoted
13801624 // value early dropped rather than escaped.
13811625 expect(searchOfCall(0)).toBe(
1382 'label:"needs design" label:"quote" -label:"wip" author:"octocat" draft:false ' +
1383 "review:changes_requested status:failure sort:updated-desc",
1626 'is:pr is:open label:"needs design" label:"quote" -label:"wip" author:"octocat" ' +
1627 "draft:false review:changes_requested status:failure sort:updated-desc repo:acme/web",
13841628 );
13851629 }),
13861630 );
13871631
13881632 it.effect('resolves an author filter of "me" to the viewer, not the literal word', () =>
13891633 Effect.gen(function* () {
1390 mockedExecute.mockReturnValue(Effect.succeed(output("[]")));
1634 mockedExecute.mockImplementation((call) =>
1635 Effect.succeed(
1636 call.kind === "graphql" && call.query.includes("pullRequests(")
1637 ? emptyList()
1638 : emptySearch(),
1639 ),
1640 );
13911641 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
13921642
13931643 yield* cli.listPullRequests({
......@@ -1401,13 +1651,19 @@ layer("GitHubPullRequestCli.layer", (it) => {
14011651 filters: { author: "me" },
14021652 });
14031653
1404 expect(searchOfCall(0)).toBe('author:"bilal" sort:updated-desc');
1654 expect(searchOfCall(0)).toBe('is:pr is:open author:"bilal" sort:updated-desc repo:acme/web');
14051655 }),
14061656 );
14071657
14081658 it.effect("sends one label qualifier per group, its names joined the way GitHub ors them", () =>
14091659 Effect.gen(function* () {
1410 mockedExecute.mockReturnValue(Effect.succeed(output("[]")));
1660 mockedExecute.mockImplementation((call) =>
1661 Effect.succeed(
1662 call.kind === "graphql" && call.query.includes("pullRequests(")
1663 ? emptyList()
1664 : emptySearch(),
1665 ),
1666 );
14111667 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
14121668
14131669 yield* cli.listPullRequests({
......@@ -1422,8 +1678,9 @@ layer("GitHubPullRequestCli.layer", (it) => {
14221678 });
14231679
14241680 // One qualifier satisfied by either size, and a second one that must hold as well.
1425 expect(searchOfCall(0)).toBe('label:"size:S","size:XS" label:"bug" sort:updated-desc');
1426 expect(callAt(0).args).toContain('label:"size:S","size:XS" label:"bug" sort:updated-desc');
1681 expect(searchOfCall(0)).toBe(
1682 'is:pr is:open label:"size:S","size:XS" label:"bug" sort:updated-desc repo:acme/web',
1683 );
14271684 }),
14281685 );
14291686
......@@ -1431,15 +1688,12 @@ layer("GitHubPullRequestCli.layer", (it) => {
14311688 "falls back for a repository the index does not cover under a checks filter, keeping only the matching rows",
14321689 () =>
14331690 Effect.gen(function* () {
1434 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
1691 mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch()));
14351692 mockedExecute.mockReturnValueOnce(
14361693 Effect.succeed(
14371694 output(
1438 pullRequests(2, 1, (number) => ({
1439 statusCheckRollup:
1440 number === 1
1441 ? [{ name: "lint", status: "COMPLETED", conclusion: "SUCCESS" }]
1442 : [{ name: "test", status: "COMPLETED", conclusion: "FAILURE" }],
1695 listedPullRequests(2, 1, (number) => ({
1696 checks: number === 1 ? "SUCCESS" : "FAILURE",
14431697 })),
14441698 ),
14451699 ),
......@@ -1471,15 +1725,9 @@ layer("GitHubPullRequestCli.layer", (it) => {
14711725
14721726 it.effect("fails a checks filter for a row whose checks are still pending", () =>
14731727 Effect.gen(function* () {
1474 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
1728 mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch()));
14751729 mockedExecute.mockReturnValueOnce(
1476 Effect.succeed(
1477 output(
1478 pullRequests(1, 1, () => ({
1479 statusCheckRollup: [{ name: "build", status: "IN_PROGRESS" }],
1480 })),
1481 ),
1482 ),
1730 Effect.succeed(output(listedPullRequests(1, 1, () => ({ checks: "PENDING" })))),
14831731 );
14841732 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
14851733
......@@ -1504,9 +1752,9 @@ layer("GitHubPullRequestCli.layer", (it) => {
15041752 "falls back for a repository the index does not cover even under a judgeable filter",
15051753 () =>
15061754 Effect.gen(function* () {
1507 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
1755 mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch()));
15081756 mockedExecute.mockReturnValueOnce(
1509 Effect.succeed(output(pullRequests(2, 1, (number) => ({ isDraft: number === 1 })))),
1757 Effect.succeed(output(listedPullRequests(2, 1, (number) => ({ isDraft: number === 1 })))),
15101758 );
15111759 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
15121760
......@@ -1555,7 +1803,13 @@ layer("GitHubPullRequestCli.layer", (it) => {
15551803
15561804 it.effect("quotes a search, so it cannot add a qualifier or a flag of its own", () =>
15571805 Effect.gen(function* () {
1558 mockedExecute.mockReturnValue(Effect.succeed(output("[]")));
1806 mockedExecute.mockImplementation((call) =>
1807 Effect.succeed(
1808 call.kind === "graphql" && call.query.includes("pullRequests(")
1809 ? emptyList()
1810 : emptySearch(),
1811 ),
1812 );
15591813 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
15601814
15611815 yield* cli.listPullRequests({
......@@ -1572,15 +1826,20 @@ layer("GitHubPullRequestCli.layer", (it) => {
15721826 // Every word stays inside one phrase: nothing before it, nothing after it, and the
15731827 // leading dashes are text rather than the start of another argument.
15741828 expect(searchOfCall(0)).toBe(
1575 String.raw`"-- is:merged label:secret \"widen me\"" sort:updated-desc`,
1829 String.raw`is:pr is:open "-- is:merged label:secret \"widen me\"" sort:updated-desc repo:acme/web`,
15761830 );
1577 expect(callAt(0).args).not.toContain("is:merged");
15781831 }),
15791832 );
15801833
15811834 it.effect("escapes a backslash before the quote it would otherwise let out", () =>
15821835 Effect.gen(function* () {
1583 mockedExecute.mockReturnValue(Effect.succeed(output("[]")));
1836 mockedExecute.mockImplementation((call) =>
1837 Effect.succeed(
1838 call.kind === "graphql" && call.query.includes("pullRequests(")
1839 ? emptyList()
1840 : emptySearch(),
1841 ),
1842 );
15841843 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
15851844
15861845 yield* cli.listPullRequests({
......@@ -1596,13 +1855,21 @@ layer("GitHubPullRequestCli.layer", (it) => {
15961855
15971856 // GitHub reads `\\` as one backslash and `\"` as one quote, so the phrase ends where
15981857 // this says it does; escaping the quote alone would have closed it early.
1599 expect(searchOfCall(0)).toBe(String.raw`"a\\\" is:merged" sort:updated-desc`);
1858 expect(searchOfCall(0)).toBe(
1859 String.raw`is:pr is:open "a\\\" is:merged" sort:updated-desc repo:acme/web`,
1860 );
16001861 }),
16011862 );
16021863
16031864 it.effect("asks for nothing but the order when the reader typed only spaces", () =>
16041865 Effect.gen(function* () {
1605 mockedExecute.mockReturnValue(Effect.succeed(output("[]")));
1866 mockedExecute.mockImplementation((call) =>
1867 Effect.succeed(
1868 call.kind === "graphql" && call.query.includes("pullRequests(")
1869 ? emptyList()
1870 : emptySearch(),
1871 ),
1872 );
16061873 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
16071874
16081875 yield* cli.listPullRequests({
......@@ -1618,7 +1885,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
16181885
16191886 // An empty phrase would match nothing rather than everything, so it is left out; the
16201887 // order the page reads rows in is asked for whether or not anything was typed.
1621 expect(searchOfCall(0)).toBe("sort:updated-desc");
1888 expect(searchOfCall(0)).toBe("is:pr is:open sort:updated-desc repo:acme/web");
16221889 }),
16231890 );
16241891
......@@ -1640,7 +1907,9 @@ layer("GitHubPullRequestCli.layer", (it) => {
16401907
16411908 // Inclusive, so the rows already sent at that instant come back for the caller to drop —
16421909 // which is what keeps the ones beside them from being skipped.
1643 expect(searchOfCall(0)).toBe("updated:<=2026-07-02T00:00:00Z sort:updated-desc");
1910 expect(searchOfCall(0)).toBe(
1911 "is:pr is:open updated:<=2026-07-02T00:00:00Z sort:updated-desc repo:acme/web",
1912 );
16441913 assert.isTrue(batch.continues);
16451914 }),
16461915 );
......@@ -1650,7 +1919,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
16501919 // The fallback is for a repository the index does not cover. Under a text search an empty
16511920 // answer means the text matched nothing, and listing everything instead would fill the
16521921 // page with rows the reader did not search for.
1653 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
1922 mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch()));
16541923 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
16551924
16561925 const batch = yield* cli.listPullRequests({
......@@ -1669,12 +1938,12 @@ layer("GitHubPullRequestCli.layer", (it) => {
16691938 }),
16701939 );
16711940
1672 it.effect("reads a repository GitHub will not search the way gh lists one", () =>
1941 it.effect("reads a repository GitHub will not search from its own list", () =>
16731942 Effect.gen(function* () {
16741943 // GitHub answers for a repository outside its search index with no rows and no error.
1675 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
1944 mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch()));
16761945 mockedExecute.mockReturnValueOnce(
1677 Effect.succeed(output(pullRequests(3, 1, () => ({ state: "CLOSED" })))),
1946 Effect.succeed(output(listedPullRequests(3, 1, () => ({ state: "CLOSED" })))),
16781947 );
16791948 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
16801949
......@@ -1689,20 +1958,21 @@ layer("GitHubPullRequestCli.layer", (it) => {
16891958 });
16901959
16911960 assert.strictEqual(batch.items.length, 3);
1692 // The fallback itself uses no search, then narrows the decoded rows locally. They still
1693 // arrive in gh's own order, so nothing can carry on from them.
1961 // The fallback itself uses no search, then narrows the decoded rows locally. They arrive
1962 // newest-created first, so nothing can carry on from them.
16941963 expect(searchOfCall(1)).toBeUndefined();
1964 expect(varsAt(1)).toMatchObject({ owner: "acme", name: "web", states: ["CLOSED", "MERGED"] });
16951965 assert.isFalse(batch.continues);
16961966 }),
16971967 );
16981968
16991969 it.effect("keeps state and involvement filters on the search-free fallback", () =>
17001970 Effect.gen(function* () {
1701 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
1971 mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch()));
17021972 mockedExecute.mockReturnValueOnce(
17031973 Effect.succeed(
17041974 output(
1705 pullRequests(4, 1, (number) => ({
1975 listedPullRequests(4, 1, (number) => ({
17061976 state: number === 4 ? "OPEN" : "CLOSED",
17071977 ...(number === 3 ? { mergedAt: "2026-07-03T00:00:00Z" } : {}),
17081978 reviewRequests:
......@@ -1734,12 +2004,14 @@ layer("GitHubPullRequestCli.layer", (it) => {
17342004 it.effect("grows the search-free fallback until it fills the filtered page", () =>
17352005 Effect.gen(function* () {
17362006 const unrelated = () => ({ reviewRequests: [{ login: "somebody-else" }] });
1737 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
1738 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1, unrelated))));
2007 mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch()));
2008 mockedExecute.mockReturnValueOnce(
2009 Effect.succeed(output(listedPullRequests(3, 1, unrelated))),
2010 );
17392011 mockedExecute.mockReturnValueOnce(
17402012 Effect.succeed(
17412013 output(
1742 pullRequests(4, 1, (number) =>
2014 listedPullRequests(4, 1, (number) =>
17432015 number === 4 ? { reviewRequests: [{ login: "bilal" }] } : unrelated(),
17442016 ),
17452017 ),
......@@ -1758,25 +2030,34 @@ layer("GitHubPullRequestCli.layer", (it) => {
17582030 });
17592031
17602032 expect(batch.items.map((item) => item.number)).toEqual([4]);
1761 const firstFallbackArgs = callAt(1).args;
1762 const secondFallbackArgs = callAt(2).args;
1763 expect(firstFallbackArgs[firstFallbackArgs.indexOf("--limit") + 1]).toBe("3");
1764 expect(secondFallbackArgs[secondFallbackArgs.indexOf("--limit") + 1]).toBe("6");
2033 expect(queryAt(1)).toContain("first: 3,");
2034 expect(queryAt(2)).toContain("first: 6,");
17652035 assert.isFalse(batch.truncated);
17662036 }),
17672037 );
17682038
17692039 it.effect("bounds a sparse search-free fallback and reports the unread tail", () =>
17702040 Effect.gen(function* () {
1771 mockedExecute.mockImplementation((_input) => {
1772 if (mockedExecute.mock.calls.length === 1) return Effect.succeed(output("[]"));
1773 const args = callAt(mockedExecute.mock.calls.length - 1).args;
1774 const limit = Number(args[args.indexOf("--limit") + 1]);
2041 // A repository with far more pull requests than the bound, none of them for this reader.
2042 let listed = 0;
2043 mockedExecute.mockImplementation((call) => {
2044 if (call.kind !== "graphql" || !call.query.includes("pullRequests(")) {
2045 return Effect.succeed(emptySearch());
2046 }
2047 const first = Number(/first: (\d+)/.exec(call.query)?.[1]);
2048 const nodes = rows(first, listed + 1, () => ({
2049 reviewRequests: [{ login: "somebody-else" }],
2050 }));
2051 listed += first;
17752052 return Effect.succeed(
17762053 output(
1777 pullRequests(limit, 1, () => ({
1778 reviewRequests: [{ login: "somebody-else" }],
1779 })),
2054 encodeJson({
2055 data: {
2056 repository: {
2057 pullRequests: { pageInfo: { hasNextPage: true, endCursor: `c${listed}` }, nodes },
2058 },
2059 },
2060 }),
17802061 ),
17812062 );
17822063 });
......@@ -1792,8 +2073,10 @@ layer("GitHubPullRequestCli.layer", (it) => {
17922073 limit: 2,
17932074 });
17942075
1795 const finalArgs = callAt(mockedExecute.mock.calls.length - 1).args;
1796 expect(finalArgs[finalArgs.indexOf("--limit") + 1]).toBe("1000");
2076 // The last widening reads a thousand rows, a page of a hundred at a time, and stops there.
2077 const last = mockedExecute.mock.calls.length - 1;
2078 expect(varsAt(last)["after"]).toBe(`c${listed - 100}`);
2079 expect(listed - (3 + 6 + 12 + 24 + 48 + 96 + 192 + 384 + 768)).toBe(1000);
17972080 assert.strictEqual(batch.items.length, 0);
17982081 assert.isTrue(batch.truncated);
17992082 }),
......@@ -1801,7 +2084,13 @@ layer("GitHubPullRequestCli.layer", (it) => {
18012084
18022085 it.effect("takes an empty slice for a repository that has run out, not one to read again", () =>
18032086 Effect.gen(function* () {
1804 mockedExecute.mockReturnValue(Effect.succeed(output("[]")));
2087 mockedExecute.mockImplementation((call) =>
2088 Effect.succeed(
2089 call.kind === "graphql" && call.query.includes("pullRequests(")
2090 ? emptyList()
2091 : emptySearch(),
2092 ),
2093 );
18052094 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
18062095
18072096 yield* cli.listPullRequests({
......@@ -1821,9 +2110,32 @@ layer("GitHubPullRequestCli.layer", (it) => {
18212110 }),
18222111 );
18232112
2113 /** The state a merge or branch update reads first: behind its base and blocked on checks. */
2114 const actionState = (pullRequest: Readonly<Record<string, unknown>> = {}) => ({
2115 data: {
2116 repository: {
2117 pullRequest: {
2118 id: "PR_7",
2119 headRefOid: "abc123",
2120 isMergeQueueEnabled: false,
2121 mergeStateStatus: "BLOCKED",
2122 baseRef: { compare: { behindBy: 2 } },
2123 ...pullRequest,
2124 },
2125 },
2126 },
2127 });
2128 const mergeMessage = (body: string, isMergeQueueEnabled = false) => ({
2129 data: {
2130 repository: {
2131 pullRequest: { isMergeQueueEnabled, headRefOid: "abc123", viewerMergeBodyText: body },
2132 },
2133 },
2134 });
2135
18242136 it.effect("updates a stale branch with a merge commit unless asked to rebase", () =>
18252137 Effect.gen(function* () {
1826 mockedExecute.mockReturnValue(Effect.succeed(output("")));
2138 route(["query PullRequestActionState", actionState()]);
18272139 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
18282140
18292141 yield* cli.runPullRequestAction({
......@@ -1833,9 +2145,6 @@ layer("GitHubPullRequestCli.layer", (it) => {
18332145 number: 7,
18342146 action: "update-branch",
18352147 });
1836 // GitHub's own default, and `gh`'s: a merge commit unless the rebase flag says otherwise.
1837 expect(callAt(0).args).toEqual(["pr", "update-branch", "7", "--repo", "github.com/acme/web"]);
1838
18392148 yield* cli.runPullRequestAction({
18402149 cwd: "/w",
18412150 repository: "acme/web",
......@@ -1844,20 +2153,18 @@ layer("GitHubPullRequestCli.layer", (it) => {
18442153 action: "update-branch",
18452154 updateMethod: "rebase",
18462155 });
1847 expect(callAt(1).args).toEqual([
1848 "pr",
1849 "update-branch",
1850 "7",
1851 "--repo",
1852 "github.com/acme/web",
1853 "--rebase",
2156
2157 // GitHub's own default: a merge commit unless asked to rebase, pinned to the head it read.
2158 expect(variablesOf("updatePullRequestBranch(")).toEqual([
2159 { pullRequestId: "PR_7", expectedHeadOid: "abc123", updateMethod: "MERGE" },
2160 { pullRequestId: "PR_7", expectedHeadOid: "abc123", updateMethod: "REBASE" },
18542161 ]);
18552162 }),
18562163 );
18572164
18582165 it.effect("merges with the strategy it was asked for", () =>
18592166 Effect.gen(function* () {
1860 mockedExecute.mockReturnValue(Effect.succeed(output("")));
2167 route(["query PullRequestActionState", actionState()]);
18612168 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
18622169
18632170 yield* cli.runPullRequestAction({
......@@ -1869,14 +2176,10 @@ layer("GitHubPullRequestCli.layer", (it) => {
18692176 mergeMethod: "squash",
18702177 });
18712178
1872 expect(callAt(0).args).toEqual([
1873 "pr",
1874 "merge",
1875 "7",
1876 "--repo",
1877 "github.com/acme/web",
1878 "--squash",
2179 expect(variablesOf("mergePullRequest(")).toEqual([
2180 { input: { pullRequestId: "PR_7", mergeMethod: "SQUASH" } },
18792181 ]);
2182 expect(variablesOf("enablePullRequestAutoMerge(")).toEqual([]);
18802183 }),
18812184 );
18822185
......@@ -1884,26 +2187,15 @@ layer("GitHubPullRequestCli.layer", (it) => {
18842187 "removes agent credits from the proposed message for %s",
18852188 (action) =>
18862189 Effect.gen(function* () {
1887 mockedExecute
1888 .mockReturnValueOnce(
1889 Effect.succeed(
1890 output(
1891 encodeJson({
1892 data: {
1893 repository: {
1894 pullRequest: {
1895 isMergeQueueEnabled: false,
1896 headRefOid: "abc123",
1897 viewerMergeBodyText:
1898 "Details\n\nCo-authored-by: Alice <alice@example.com>\nCo-authored-by: Claude <noreply@anthropic.com>",
1899 },
1900 },
1901 },
1902 }),
1903 ),
2190 route(
2191 [
2192 "query PullRequestMergeMessage",
2193 mergeMessage(
2194 "Details\n\nCo-authored-by: Alice <alice@example.com>\nCo-authored-by: Claude <noreply@anthropic.com>",
19042195 ),
1905 )
1906 .mockReturnValueOnce(Effect.succeed(output("")));
2196 ],
2197 ["query PullRequestActionState", actionState()],
2198 );
19072199 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
19082200 yield* cli.runPullRequestAction({
19092201 cwd: "/w",
......@@ -1914,12 +2206,20 @@ layer("GitHubPullRequestCli.layer", (it) => {
19142206 mergeMethod: "squash",
19152207 removeAgentCreditsOnMerge: true,
19162208 });
1917 expect(callAt(0).args).toContain("method=SQUASH");
1918 expect(callAt(1).args.slice(-2)).toEqual(["--body-file", "-"]);
1919 expect(callAt(1).args).toContain("--match-head-commit");
1920 expect(callAt(1).args).toContain("abc123");
1921 expect(callAt(1).stdin).toBe("Details\n\nCo-authored-by: Alice <alice@example.com>");
1922 expect(callAt(1).args.join(" ")).not.toContain("alice@example.com");
2209 expect(variablesOf("query PullRequestMergeMessage")[0]?.["method"]).toBe("SQUASH");
2210 expect(
2211 variablesOf(action === "merge" ? "mergePullRequest(" : "enablePullRequestAutoMerge("),
2212 ).toEqual([
2213 {
2214 input: {
2215 pullRequestId: "PR_7",
2216 mergeMethod: "SQUASH",
2217 // Pinned to the head the message was read from, so it cannot describe other commits.
2218 expectedHeadOid: "abc123",
2219 commitBody: "Details\n\nCo-authored-by: Alice <alice@example.com>",
2220 },
2221 },
2222 ]);
19232223 }),
19242224 );
19252225
......@@ -1936,25 +2236,10 @@ layer("GitHubPullRequestCli.layer", (it) => {
19362236 },
19372237 ] as const)("keeps GitHub's default message for $description", ({ body, queued }) =>
19382238 Effect.gen(function* () {
1939 mockedExecute
1940 .mockReturnValueOnce(
1941 Effect.succeed(
1942 output(
1943 encodeJson({
1944 data: {
1945 repository: {
1946 pullRequest: {
1947 isMergeQueueEnabled: queued,
1948 headRefOid: "abc123",
1949 viewerMergeBodyText: body,
1950 },
1951 },
1952 },
1953 }),
1954 ),
1955 ),
1956 )
1957 .mockReturnValueOnce(Effect.succeed(output("")));
2239 route(
2240 ["query PullRequestMergeMessage", mergeMessage(body, queued)],
2241 ["query PullRequestActionState", actionState({ isMergeQueueEnabled: queued })],
2242 );
19582243 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
19592244 yield* cli.runPullRequestAction({
19602245 cwd: "/w",
......@@ -1964,33 +2249,23 @@ layer("GitHubPullRequestCli.layer", (it) => {
19642249 action: "merge",
19652250 removeAgentCreditsOnMerge: true,
19662251 });
1967 expect(callAt(0).args).toContain("method=MERGE");
1968 expect(callAt(1).args).not.toContain("--body-file");
1969 expect(callAt(1).stdin).toBeUndefined();
2252 expect(variablesOf("query PullRequestMergeMessage")[0]?.["method"]).toBe("MERGE");
2253 // A merge queue takes the pull request through auto-merge, with its own message.
2254 expect(variablesOf(queued ? "enablePullRequestAutoMerge(" : "mergePullRequest(")).toEqual([
2255 { input: { pullRequestId: "PR_7", mergeMethod: "MERGE" } },
2256 ]);
19702257 }),
19712258 );
19722259
19732260 it.effect("passes an explicitly empty body when the proposed message only credits an agent", () =>
19742261 Effect.gen(function* () {
1975 mockedExecute
1976 .mockReturnValueOnce(
1977 Effect.succeed(
1978 output(
1979 encodeJson({
1980 data: {
1981 repository: {
1982 pullRequest: {
1983 isMergeQueueEnabled: false,
1984 headRefOid: "abc123",
1985 viewerMergeBodyText: "Co-authored-by: Claude <noreply@anthropic.com>",
1986 },
1987 },
1988 },
1989 }),
1990 ),
1991 ),
1992 )
1993 .mockReturnValueOnce(Effect.succeed(output("")));
2262 route(
2263 [
2264 "query PullRequestMergeMessage",
2265 mergeMessage("Co-authored-by: Claude <noreply@anthropic.com>"),
2266 ],
2267 ["query PullRequestActionState", actionState()],
2268 );
19942269 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
19952270 yield* cli.runPullRequestAction({
19962271 cwd: "/w",
......@@ -2000,14 +2275,22 @@ layer("GitHubPullRequestCli.layer", (it) => {
20002275 action: "merge",
20012276 removeAgentCreditsOnMerge: true,
20022277 });
2003 expect(callAt(1).stdin).toBe("");
2004 expect(callAt(1).args).toContain("--body-file");
2278 expect(variablesOf("mergePullRequest(")).toEqual([
2279 {
2280 input: {
2281 pullRequestId: "PR_7",
2282 mergeMethod: "MERGE",
2283 expectedHeadOid: "abc123",
2284 commitBody: "",
2285 },
2286 },
2287 ]);
20052288 }),
20062289 );
20072290
20082291 it.effect("does not fetch a message for rebase merges", () =>
20092292 Effect.gen(function* () {
2010 mockedExecute.mockReturnValue(Effect.succeed(output("")));
2293 route(["query PullRequestActionState", actionState()]);
20112294 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
20122295 yield* cli.runPullRequestAction({
20132296 cwd: "/w",
......@@ -2018,15 +2301,18 @@ layer("GitHubPullRequestCli.layer", (it) => {
20182301 mergeMethod: "rebase",
20192302 removeAgentCreditsOnMerge: true,
20202303 });
2021 expect(mockedExecute).toHaveBeenCalledTimes(1);
2022 expect(callAt(0).args).toContain("--rebase");
2304 expect(variablesOf("query PullRequestMergeMessage")).toEqual([]);
2305 expect(variablesOf("mergePullRequest(")).toEqual([
2306 { input: { pullRequestId: "PR_7", mergeMethod: "REBASE" } },
2307 ]);
20232308 }),
20242309 );
20252310
20262311 it.effect("refuses to merge when the proposed message cannot be read", () =>
20272312 Effect.gen(function* () {
2028 mockedExecute.mockReturnValue(
2029 Effect.succeed(output('{"data":{"repository":{"pullRequest":null}}}')),
2313 route(
2314 ["query PullRequestMergeMessage", { data: { repository: { pullRequest: null } } }],
2315 ["query PullRequestActionState", actionState()],
20302316 );
20312317 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
20322318 const result = yield* Effect.result(
......@@ -2040,13 +2326,13 @@ layer("GitHubPullRequestCli.layer", (it) => {
20402326 }),
20412327 );
20422328 expect(result._tag).toBe("Failure");
2043 expect(mockedExecute).toHaveBeenCalledTimes(1);
2329 expect(variablesOf("mergePullRequest(")).toEqual([]);
20442330 }),
20452331 );
20462332
20472333 it.effect("arms auto-merge with the same strategy a merge would have used", () =>
20482334 Effect.gen(function* () {
2049 mockedExecute.mockReturnValue(Effect.succeed(output("")));
2335 route(["query PullRequestActionState", actionState()]);
20502336 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
20512337
20522338 yield* cli.runPullRequestAction({
......@@ -2057,39 +2343,47 @@ layer("GitHubPullRequestCli.layer", (it) => {
20572343 action: "enable-auto-merge",
20582344 mergeMethod: "squash",
20592345 });
2060 expect(callAt(0).args).toEqual([
2061 "pr",
2062 "merge",
2063 "7",
2064 "--repo",
2065 "github.com/acme/web",
2066 "--auto",
2067 "--squash",
2346 // No strategy asked for is GitHub's own default, exactly as it is for a merge now.
2347 yield* cli.runPullRequestAction({
2348 cwd: "/w",
2349 repository: "acme/web",
2350 host: "github.com",
2351 number: 7,
2352 action: "enable-auto-merge",
2353 });
2354
2355 expect(variablesOf("enablePullRequestAutoMerge(")).toEqual([
2356 { input: { pullRequestId: "PR_7", mergeMethod: "SQUASH" } },
2357 { input: { pullRequestId: "PR_7", mergeMethod: "MERGE" } },
20682358 ]);
2359 expect(variablesOf("mergePullRequest(")).toEqual([]);
2360 }),
2361 );
2362
2363 it.effect("merges at once when auto-merge is asked of a pull request that is ready now", () =>
2364 Effect.gen(function* () {
2365 route(["query PullRequestActionState", actionState({ mergeStateStatus: "CLEAN" })]);
2366 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
20692367
2070 // No strategy asked for is GitHub's own default, exactly as it is for a merge now.
20712368 yield* cli.runPullRequestAction({
20722369 cwd: "/w",
20732370 repository: "acme/web",
20742371 host: "github.com",
20752372 number: 7,
20762373 action: "enable-auto-merge",
2374 mergeMethod: "squash",
20772375 });
2078 expect(callAt(1).args).toEqual([
2079 "pr",
2080 "merge",
2081 "7",
2082 "--repo",
2083 "github.com/acme/web",
2084 "--auto",
2085 "--merge",
2376
2377 expect(variablesOf("mergePullRequest(")).toEqual([
2378 { input: { pullRequestId: "PR_7", mergeMethod: "SQUASH" } },
20862379 ]);
2380 expect(variablesOf("enablePullRequestAutoMerge(")).toEqual([]);
20872381 }),
20882382 );
20892383
20902384 it.effect("takes auto-merge back off without naming a strategy", () =>
20912385 Effect.gen(function* () {
2092 mockedExecute.mockReturnValue(Effect.succeed(output("")));
2386 route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]);
20932387 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
20942388
20952389 yield* cli.runPullRequestAction({
......@@ -2101,30 +2395,13 @@ layer("GitHubPullRequestCli.layer", (it) => {
21012395 mergeMethod: "squash",
21022396 });
21032397
2104 expect(callAt(0).args).toEqual([
2105 "pr",
2106 "merge",
2107 "7",
2108 "--repo",
2109 "github.com/acme/web",
2110 "--disable-auto",
2111 ]);
2398 expect(variablesOf("disablePullRequestAutoMerge(")).toEqual([{ pullRequestId: "PR_7" }]);
21122399 }),
21132400 );
21142401
21152402 it.effect("opens a pull request that reverts a merged pull request", () =>
21162403 Effect.gen(function* () {
2117 mockedExecute.mockReturnValueOnce(
2118 Effect.succeed(
2119 output(
2120 // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh GraphQL response.
2121 JSON.stringify({
2122 data: { repository: { pullRequest: { id: "PR_7" } } },
2123 }),
2124 ),
2125 ),
2126 );
2127 mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}")));
2404 route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]);
21282405 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
21292406
21302407 yield* cli.runPullRequestAction({
......@@ -2135,19 +2412,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
21352412 action: "revert",
21362413 });
21372414
2138 expect(callAt(0).args).toContain("owner=acme");
2139 expect(callAt(0).args).toContain("name=web");
2140 expect(callAt(0).args).toContain("number=7");
2141 expect(callAt(1).args).toEqual([
2142 "api",
2143 "graphql",
2144 "--hostname",
2145 "github.com",
2146 "--input",
2147 "-",
2148 ]);
2149 expect(callAt(1).stdin).toContain("revertPullRequest");
2150 expect(callAt(1).stdin).toContain('"pullRequestId":"PR_7"');
2415 expect(variablesOf("revertPullRequest(")).toEqual([{ pullRequestId: "PR_7" }]);
21512416 }),
21522417 );
21532418
......@@ -2189,55 +2454,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
21892454
21902455 it.effect("finds and approves every workflow waiting on a maintainer", () =>
21912456 Effect.gen(function* () {
2192 const detail = output(
2193 encodeJson(
2194 coreResponse({
2195 number: 7,
2196 title: "Pull request 7",
2197 url: "https://github.com/acme/web/pull/7",
2198 headRefName: "feat/page",
2199 headRefOid: "abc123",
2200 isCrossRepository: true,
2201 headRepositoryOwner: { login: "octocat" },
2202 baseRefName: "main",
2203 createdAt: "2026-07-01T00:00:00Z",
2204 updatedAt: "2026-07-02T00:00:00Z",
2205 }),
2206 ),
2207 );
2208 const heads = output(
2209 // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response.
2210 JSON.stringify([
2211 {
2212 number: 7,
2213 headRefOid: "abc123",
2214 isCrossRepository: true,
2215 headRepositoryOwner: { login: "octocat" },
2216 },
2217 ]),
2218 );
2219 const runs = output(
2220 // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response.
2221 JSON.stringify([
2222 { databaseId: 10, workflowName: "build", url: "https://example.com/10" },
2223 { databaseId: 11, workflowName: "test", url: "https://example.com/11" },
2224 ]),
2225 );
2226 for (const result of [
2227 detail,
2228 heads,
2229 runs,
2230 detail,
2231 heads,
2232 runs,
2233 output(""),
2234 detail,
2235 heads,
2236 runs,
2237 output(""),
2238 ]) {
2239 mockedExecute.mockReturnValueOnce(Effect.succeed(result));
2240 }
2457 workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7]), workflowRuns([10, 11]));
22412458 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
22422459
22432460 yield* cli.runPullRequestAction({
......@@ -2248,98 +2465,70 @@ layer("GitHubPullRequestCli.layer", (it) => {
22482465 action: "approve-workflows",
22492466 });
22502467
2251 expect(callAt(1).args).toEqual([
2252 "pr",
2253 "list",
2254 "--repo",
2255 "github.com/acme/web",
2256 "--state",
2257 "open",
2258 "--head",
2259 "feat/page",
2260 "--limit",
2261 "1001",
2262 "--json",
2263 "number,headRefOid,isCrossRepository,headRepositoryOwner",
2264 ]);
2265 expect(callAt(2).args).toEqual([
2266 "run",
2267 "list",
2268 "--repo",
2269 "github.com/acme/web",
2270 "--commit",
2271 "abc123",
2272 "--branch",
2273 "feat/page",
2274 "--event",
2275 "pull_request",
2276 "--status",
2277 "action_required",
2278 "--limit",
2279 "1001",
2280 "--json",
2281 "databaseId,workflowName,url",
2282 ]);
2283 expect([callAt(6).args, callAt(10).args]).toEqual([
2284 [
2285 "api",
2286 "--method",
2287 "POST",
2288 "--hostname",
2289 "github.com",
2290 "repos/acme/web/actions/runs/10/approve",
2291 "--silent",
2292 ],
2293 [
2294 "api",
2295 "--method",
2296 "POST",
2297 "--hostname",
2298 "github.com",
2299 "repos/acme/web/actions/runs/11/approve",
2300 "--silent",
2301 ],
2468 // Open pull requests on this head branch, and runs scoped to this exact head.
2469 expect(variablesOf("headRefName: $head")[0]).toEqual({
2470 owner: "acme",
2471 name: "web",
2472 head: "feat/page",
2473 after: null,
2474 });
2475 expect(restCallsTo("actions/runs?")[0]?.path).toBe(
2476 "repos/acme/web/actions/runs?head_sha=abc123&branch=feat%2Fpage&event=pull_request&status=action_required&per_page=100&page=1",
2477 );
2478 // Each run is approved only after the head is read again and still lists it.
2479 expect(restCallsTo("/approve").map((call) => [call.method, call.path])).toEqual([
2480 ["POST", "repos/acme/web/actions/runs/10/approve"],
2481 ["POST", "repos/acme/web/actions/runs/11/approve"],
23022482 ]);
2303 expect(mockedExecute).toHaveBeenCalledTimes(11);
2483 assert.strictEqual(restCallsTo("actions/runs?").length, 3);
23042484 }),
23052485 );
23062486
2307 it.effect("refuses a stale workflow approval after the pull request head changes", () =>
2487 it.effect("counts revalidated runs that wait on a maintainer as GitHub reports them", () =>
23082488 Effect.gen(function* () {
2309 const detail = {
2310 number: 7,
2311 title: "Pull request 7",
2312 url: "https://github.com/acme/web/pull/7",
2313 headRefName: "feat/page",
2314 headRefOid: "abc123",
2315 isCrossRepository: true,
2316 headRepositoryOwner: { login: "octocat" },
2317 baseRefName: "main",
2318 createdAt: "2026-07-01T00:00:00Z",
2319 updatedAt: "2026-07-02T00:00:00Z",
2320 };
2321 for (const value of [
2322 coreResponse(detail),
2323 [
2324 {
2325 number: 7,
2326 headRefOid: "abc123",
2327 isCrossRepository: true,
2328 headRepositoryOwner: { login: "octocat" },
2329 },
2330 ],
2331 [{ databaseId: 10, workflowName: "build", url: "https://example.com/10" }],
2332 coreResponse({ ...detail, headRefOid: "def456" }),
2333 ]) {
2334 mockedExecute.mockReturnValueOnce(
2335 Effect.succeed(
2336 output(
2337 // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response.
2338 JSON.stringify(value),
2339 ),
2340 ),
2489 workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7]), workflowRuns([]));
2490 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
2491
2492 const runs = yield* cli
2493 .listWorkflowRunsRequiringApproval({
2494 cwd: "/w",
2495 repository: "acme/web",
2496 host: "github.com",
2497 number: 7,
2498 headSha: "abc123",
2499 headBranch: "feat/page",
2500 headRepositoryOwner: "octocat",
2501 isCrossRepository: true,
2502 })
2503 .pipe(
2504 Effect.provideService(KnownWorkflowRuns, {
2505 headSha: "abc123",
2506 runs: [
2507 // Live shape: waiting on approval is completed + action_required.
2508 {
2509 id: 10,
2510 status: "completed",
2511 conclusion: "action_required",
2512 head_branch: "feat/page",
2513 },
2514 { id: 11, status: "completed", conclusion: "success", head_branch: "feat/page" },
2515 ],
2516 }),
23412517 );
2342 }
2518
2519 expect(runs.map((run) => run.id)).toEqual([10]);
2520 assert.strictEqual(restCallsTo("actions/runs?").length, 0);
2521 }),
2522 );
2523
2524 it.effect("refuses a stale workflow approval after the pull request head changes", () =>
2525 Effect.gen(function* () {
2526 let detailReads = 0;
2527 workflowApprovalRoutes(
2528 () => crossRepositoryDetail(++detailReads === 1 ? "abc123" : "def456"),
2529 heads([7]),
2530 workflowRuns([10]),
2531 );
23432532 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
23442533
23452534 const error = yield* Effect.flip(
......@@ -2356,7 +2545,8 @@ layer("GitHubPullRequestCli.layer", (it) => {
23562545 _tag: "GitHubWorkflowApprovalHeadChangedError",
23572546 number: 7,
23582547 });
2359 expect(mockedExecute).toHaveBeenCalledTimes(4);
2548 assert.strictEqual(detailReads, 2);
2549 expect(restCallsTo("/approve")).toEqual([]);
23602550 }),
23612551 );
23622552
......@@ -2364,21 +2554,15 @@ layer("GitHubPullRequestCli.layer", (it) => {
23642554 Effect.gen(function* () {
23652555 const headsStarted = yield* Deferred.make<void>();
23662556 const runsStarted = yield* Deferred.make<void>();
2367 mockedExecute.mockImplementation(({ args }) =>
2368 args[0] === "pr"
2557 mockedExecute.mockImplementation((call) =>
2558 call.kind === "graphql"
23692559 ? Deferred.succeed(headsStarted, undefined).pipe(
23702560 Effect.andThen(Deferred.await(runsStarted)),
2371 Effect.as(
2372 output(
2373 '[{"number":7,"headRefOid":"abc123","isCrossRepository":true,"headRepositoryOwner":{"login":"octocat"}}]',
2374 ),
2375 ),
2561 Effect.as(output(encodeJson(heads([7])))),
23762562 )
23772563 : Deferred.succeed(runsStarted, undefined).pipe(
23782564 Effect.andThen(Deferred.await(headsStarted)),
2379 Effect.as(
2380 output('[{"databaseId":10,"workflowName":"build","url":"https://example.com/10"}]'),
2381 ),
2565 Effect.as(output(encodeJson(workflowRuns([10])))),
23822566 ),
23832567 );
23842568 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
......@@ -2400,22 +2584,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
24002584
24012585 it.effect("refuses workflow approval when one head belongs to several pull requests", () =>
24022586 Effect.gen(function* () {
2403 mockedExecute.mockReturnValue(Effect.succeed(output("[]")));
2404 mockedExecute.mockReturnValueOnce(
2405 Effect.succeed(
2406 output(
2407 // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response.
2408 JSON.stringify(
2409 [7, 8].map((number) => ({
2410 number,
2411 headRefOid: "abc123",
2412 isCrossRepository: true,
2413 headRepositoryOwner: { login: "octocat" },
2414 })),
2415 ),
2416 ),
2417 ),
2418 );
2587 workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7, 8]), workflowRuns([]));
24192588 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
24202589
24212590 const error = yield* Effect.flip(
......@@ -2438,8 +2607,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
24382607 observedCount: 2,
24392608 limit: 1_000,
24402609 });
2441 expect(error.detail).toContain("instead of uniquely matching #7");
2442 expect(mockedExecute).toHaveBeenCalledTimes(2);
2610 expect(error.message).toContain("instead of uniquely matching #7");
24432611 }),
24442612 );
24452613
......@@ -2487,29 +2655,18 @@ layer("GitHubPullRequestCli.layer", (it) => {
24872655
24882656 it.effect("surfaces a workflow run list beyond the safe approval bound", () =>
24892657 Effect.gen(function* () {
2490 mockedExecute.mockReturnValueOnce(
2491 Effect.succeed(
2492 output(
2493 // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response.
2494 JSON.stringify([
2495 {
2496 number: 7,
2497 headRefOid: "abc123",
2498 isCrossRepository: true,
2499 headRepositoryOwner: { login: "octocat" },
2500 },
2501 ]),
2502 ),
2503 ),
2504 );
2505 mockedExecute.mockReturnValueOnce(
2506 Effect.succeed(
2658 let runPages = 0;
2659 mockedExecute.mockImplementation((call) => {
2660 if (call.kind === "graphql") return Effect.succeed(output(encodeJson(heads([7]))));
2661 const page = runPages++;
2662 return Effect.succeed(
25072663 output(
2508 // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response.
2509 JSON.stringify(Array.from({ length: 1_001 }, (_, id) => ({ databaseId: id + 1 }))),
2664 encodeJson(
2665 workflowRuns(Array.from({ length: 100 }, (_, index) => page * 100 + index + 1)),
2666 ),
25102667 ),
2511 ),
2512 );
2668 );
2669 });
25132670 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
25142671
25152672 const error = yield* Effect.flip(
......@@ -2525,21 +2682,22 @@ layer("GitHubPullRequestCli.layer", (it) => {
25252682 }),
25262683 );
25272684
2685 // Paged a hundred at a time, it stops at the first page past the bound.
25282686 expect(error).toMatchObject({
25292687 _tag: "GitHubWorkflowApprovalRefusedError",
25302688 reason: "run-list-truncated",
25312689 number: 7,
2532 observedCount: 1_001,
2690 observedCount: 1_100,
25332691 limit: 1_000,
25342692 });
2535 expect(error.detail).toContain("more than 1000 workflow runs");
2536 expect(mockedExecute).toHaveBeenCalledTimes(2);
2693 expect(error.message).toContain("more than 1000 workflow runs");
2694 assert.strictEqual(runPages, 11);
25372695 }),
25382696 );
25392697
2540 it.effect("returns a pull request to draft by undoing ready", () =>
2698 it.effect("returns a pull request to draft by converting it", () =>
25412699 Effect.gen(function* () {
2542 mockedExecute.mockReturnValue(Effect.succeed(output("")));
2700 route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]);
25432701 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
25442702
25452703 yield* cli.runPullRequestAction({
......@@ -2550,21 +2708,14 @@ layer("GitHubPullRequestCli.layer", (it) => {
25502708 action: "draft",
25512709 });
25522710
2553 // gh has no `draft` command; going back is `ready --undo`.
2554 expect(callAt(0).args).toEqual([
2555 "pr",
2556 "ready",
2557 "7",
2558 "--repo",
2559 "github.com/acme/web",
2560 "--undo",
2561 ]);
2711 expect(variablesOf("convertPullRequestToDraft(")).toEqual([{ pullRequestId: "PR_7" }]);
2712 expect(variablesOf("markPullRequestReadyForReview(")).toEqual([]);
25622713 }),
25632714 );
25642715
2565 it.effect("sends a comment body over stdin, never in argv", () =>
2716 it.effect("sends a comment body as a variable, never inside the document", () =>
25662717 Effect.gen(function* () {
2567 mockedExecute.mockReturnValue(Effect.succeed(output("")));
2718 route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]);
25682719 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
25692720
25702721 yield* cli.commentOnPullRequest({
......@@ -2575,24 +2726,20 @@ layer("GitHubPullRequestCli.layer", (it) => {
25752726 body: "Looks good.",
25762727 });
25772728
2578 // argv shows up in process listings and in process-runner failure messages.
2579 expect(callAt(0).args).toEqual([
2580 "pr",
2581 "comment",
2582 "7",
2583 "--repo",
2584 "github.com/acme/web",
2585 "--body-file",
2586 "-",
2587 ]);
2588 expect(callAt(0).stdin).toBe("Looks good.");
2589 expect(callAt(0).args).not.toContain("Looks good.");
2729 expect(variablesOf("addComment(")).toEqual([{ subjectId: "PR_7", body: "Looks good." }]);
2730 expect(queryAt(mockedExecute.mock.calls.length - 1)).not.toContain("Looks good.");
25902731 }),
25912732 );
25922733
25932734 it.effect("names the host on every repository it addresses", () =>
25942735 Effect.gen(function* () {
2595 mockedExecute.mockReturnValue(Effect.succeed(output("[]")));
2736 mockedExecute.mockImplementation((call) =>
2737 Effect.succeed(
2738 call.kind === "graphql" && call.query.includes("pullRequests(")
2739 ? emptyList()
2740 : emptySearch(),
2741 ),
2742 );
25962743 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
25972744
25982745 yield* cli.listPullRequests({
......@@ -2605,8 +2752,12 @@ layer("GitHubPullRequestCli.layer", (it) => {
26052752 limit: 10,
26062753 });
26072754
2608 // A bare `owner/repo` resolves against github.com, which is a different repository.
2609 expect(callAt(0).args).toContain("github.acme.dev/acme/web");
2755 // A request sent to github.com would read a different repository of the same name.
2756 assert.isAbove(mockedExecute.mock.calls.length, 0);
2757 expect(new Set(mockedExecute.mock.calls.map(([call]) => call.host))).toEqual(
2758 new Set(["github.acme.dev"]),
2759 );
2760 expect(searchOfCall(0)).toContain("repo:acme/web");
26102761 }),
26112762 );
26122763
......@@ -2615,8 +2766,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
26152766 mockedExecute.mockReturnValueOnce(
26162767 Effect.succeed(
26172768 output(
2618 // @effect-diagnostics-next-line preferSchemaOverJson:off
2619 JSON.stringify({
2769 encodeJson({
26202770 data: {
26212771 repository: { pullRequest: { reviewThreads: { totalCount: 0, nodes: [] } } },
26222772 },
......@@ -2633,11 +2783,8 @@ layer("GitHubPullRequestCli.layer", (it) => {
26332783 number: 7,
26342784 });
26352785
2636 const args = callAt(0).args;
2637 expect(args).toContain("--hostname");
2638 expect(args).toContain("github.acme.dev");
2639 expect(args).toContain("owner=acme");
2640 expect(args).toContain("name=web");
2786 expect(callAt(0).host).toBe("github.acme.dev");
2787 expect(varsAt(0)).toMatchObject({ owner: "acme", name: "web", number: 7 });
26412788 }),
26422789 );
26432790
......@@ -2653,13 +2800,18 @@ layer("GitHubPullRequestCli.layer", (it) => {
26532800 number: 7,
26542801 });
26552802
2803 assert.strictEqual(diff.patch, "diff --git a/a b/a");
26562804 assert.isNull(diff.nextCursor);
26572805 assert.isFalse(diff.truncated);
26582806 // The common case pays for one request and not the files API on top of it.
26592807 assert.strictEqual(mockedExecute.mock.calls.length, 1);
2660 // `--patch` asks gh for a format-patch stream, which repeats a file once per commit.
2661 // The review needs GitHub's combined pull-request diff: one section per changed file.
2662 expect(callAt(0).args).not.toContain("--patch");
2808 // GitHub's combined pull-request diff: one section per changed file, not one per commit.
2809 const call = callAt(0);
2810 assert.strictEqual(call.kind, "rest");
2811 if (call.kind === "rest") {
2812 assert.strictEqual(call.path, "repos/acme/web/pulls/7");
2813 assert.strictEqual(call.accept, "application/vnd.github.diff");
2814 }
26632815 }),
26642816 );
26652817
......@@ -2682,10 +2834,8 @@ layer("GitHubPullRequestCli.layer", (it) => {
26822834 assert.isNull(diff.nextCursor);
26832835 expect(diff.patch).toContain("diff --git a/src/file1.ts b/src/file1.ts");
26842836 expect(diff.patch).toContain("diff --git a/src/file2.ts b/src/file2.ts");
2685 const args = callAt(1).args;
2686 expect(args).toContain("--hostname");
2687 expect(args).toContain("github.acme.dev");
2688 expect(args).toContain("repos/acme/web/pulls/7/files?per_page=100&page=1");
2837 assert.strictEqual(callAt(1).host, "github.acme.dev");
2838 assert.strictEqual(pathAt(1), "repos/acme/web/pulls/7/files?per_page=100&page=1");
26892839 }),
26902840 );
26912841
......@@ -2709,7 +2859,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
27092859 }),
27102860 );
27112861
2712 it.effect("carries on from a cursor without asking `gh pr diff` again", () =>
2862 it.effect("carries on from a cursor without asking for the whole diff again", () =>
27132863 Effect.gen(function* () {
27142864 mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused));
27152865 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(100, 0))));
......@@ -2725,7 +2875,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
27252875 expect(second.patch).toContain("diff --git a/src/file100.ts b/src/file100.ts");
27262876 // The second slice is one request: the cursor already says where to read.
27272877 assert.strictEqual(mockedExecute.mock.calls.length, 3);
2728 expect(callAt(2).args).toContain("repos/acme/web/pulls/7/files?per_page=100&page=2");
2878 assert.strictEqual(pathAt(2), "repos/acme/web/pulls/7/files?per_page=100&page=2");
27292879 }),
27302880 );
27312881
......@@ -2748,9 +2898,11 @@ layer("GitHubPullRequestCli.layer", (it) => {
27482898 }),
27492899 );
27502900
2751 it.effect("reads a named commit from the commit endpoint rather than from `gh pr diff`", () =>
2901 it.effect("reads a named commit from the commit endpoint rather than the whole diff", () =>
27522902 Effect.gen(function* () {
2753 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(2, 1))));
2903 mockedExecute.mockReturnValueOnce(
2904 Effect.succeed(output(`{"files":${pullRequestFiles(2, 1)}}`)),
2905 );
27542906 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
27552907
27562908 const diff = yield* cli.getPullRequestDiff({
......@@ -2761,22 +2913,23 @@ layer("GitHubPullRequestCli.layer", (it) => {
27612913 commit: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0",
27622914 });
27632915
2764 // One request: the commit's own changes never take the `gh pr diff` road.
2916 // One request: the commit's own changes never take the whole-diff road.
27652917 assert.strictEqual(mockedExecute.mock.calls.length, 1);
27662918 assert.isNull(diff.nextCursor);
2919 // The commit endpoint wraps its files in an object, which the decoder unwraps.
27672920 expect(diff.patch).toContain("diff --git a/src/file1.ts b/src/file1.ts");
2768 const args = callAt(0).args;
2769 expect(args).toContain(
2921 assert.strictEqual(
2922 pathAt(0),
27702923 "repos/acme/web/commits/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0?per_page=100&page=1",
27712924 );
2772 // The commit endpoint wraps its files in an object, which jq unwraps for the decoder.
2773 expect(args).toContain(".files // []");
27742925 }),
27752926 );
27762927
27772928 it.effect("pages inside a commit the way it pages the pull request's own files", () =>
27782929 Effect.gen(function* () {
2779 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(100, 0))));
2930 mockedExecute.mockReturnValueOnce(
2931 Effect.succeed(output(`{"files":${pullRequestFiles(100, 0)}}`)),
2932 );
27802933 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
27812934 const target = {
27822935 cwd: "/w",
......@@ -2788,11 +2941,14 @@ layer("GitHubPullRequestCli.layer", (it) => {
27882941
27892942 const first = yield* cli.getPullRequestDiff(target);
27902943 assert.isNotNull(first.nextCursor);
2791 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(4, 100))));
2944 mockedExecute.mockReturnValueOnce(
2945 Effect.succeed(output(`{"files":${pullRequestFiles(4, 100)}}`)),
2946 );
27922947 const second = yield* cli.getPullRequestDiff({ ...target, cursor: first.nextCursor });
27932948
27942949 assert.isNull(second.nextCursor);
2795 expect(callAt(1).args).toContain("repos/acme/web/commits/a1b2c3d?per_page=100&page=2");
2950 expect(second.patch).toContain("src/file100.ts");
2951 assert.strictEqual(pathAt(1), "repos/acme/web/commits/a1b2c3d?per_page=100&page=2");
27962952 }),
27972953 );
27982954
......@@ -2817,7 +2973,9 @@ layer("GitHubPullRequestCli.layer", (it) => {
28172973
28182974 it.effect("expands a new file from a root commit without requiring a parent", () =>
28192975 Effect.gen(function* () {
2820 mockedExecute.mockReturnValueOnce(Effect.succeed(output("\ta1b2c3d\n")));
2976 mockedExecute.mockReturnValueOnce(
2977 Effect.succeed(output(encodeJson({ sha: "a1b2c3d", parents: [] }))),
2978 );
28212979 mockedExecute.mockReturnValueOnce(Effect.succeed(output("root contents\n")));
28222980 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
28232981
......@@ -2834,7 +2992,8 @@ layer("GitHubPullRequestCli.layer", (it) => {
28342992
28352993 expect(contents).toEqual({ oldContents: "", newContents: "root contents\n" });
28362994 assert.strictEqual(mockedExecute.mock.calls.length, 2);
2837 expect(callAt(1).args.join(" ")).toContain("contents/src/root.ts?ref=a1b2c3d");
2995 assert.strictEqual(pathAt(0), "repos/acme/web/commits/a1b2c3d");
2996 assert.strictEqual(pathAt(1), "repos/acme/web/contents/src/root.ts?ref=a1b2c3d");
28382997 }),
28392998 );
28402999
......@@ -2866,7 +3025,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
28663025
28673026 it.effect("reports an oversized diff file with its path and reason", () =>
28683027 Effect.gen(function* () {
2869 mockedExecute.mockReturnValueOnce(Effect.succeed(output("a1b2c3d\tb1c2d3e\n")));
3028 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs)));
28703029 mockedExecute.mockReturnValueOnce(Effect.succeed(output("partial", true)));
28713030 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
28723031
......@@ -2887,15 +3046,15 @@ layer("GitHubPullRequestCli.layer", (it) => {
28873046 assert.strictEqual(error.path, "src/large.ts");
28883047 assert.strictEqual(error.reason, "oversized");
28893048 }
3049 // A deleted file is read at the base revision only.
3050 assert.strictEqual(pathAt(1), "repos/acme/web/contents/src/large.ts?ref=a1b2c3d");
28903051 }),
28913052 );
28923053
28933054 it.effect("reports undecodable diff file contents as binary", () =>
28943055 Effect.gen(function* () {
2895 mockedExecute.mockReturnValueOnce(Effect.succeed(output("a1b2c3d\tb1c2d3e\n")));
2896 mockedExecute.mockReturnValueOnce(
2897 Effect.succeed(output("binary\uFFFDcontents", false, true)),
2898 );
3056 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs)));
3057 mockedExecute.mockReturnValueOnce(Effect.succeed(output("binary�contents", false, true)));
28993058 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
29003059
29013060 const error = yield* Effect.flip(
......@@ -2920,8 +3079,8 @@ layer("GitHubPullRequestCli.layer", (it) => {
29203079
29213080 it.effect("returns valid text containing a literal replacement character", () =>
29223081 Effect.gen(function* () {
2923 mockedExecute.mockReturnValueOnce(Effect.succeed(output("a1b2c3d\tb1c2d3e\n")));
2924 mockedExecute.mockReturnValueOnce(Effect.succeed(output("before\uFFFDafter")));
3082 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs)));
3083 mockedExecute.mockReturnValueOnce(Effect.succeed(output("before�after")));
29253084 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
29263085
29273086 const contents = yield* cli.getPullRequestDiffFileContents({
......@@ -2934,7 +3093,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
29343093 newPath: "docs/encoding.md",
29353094 });
29363095
2937 assert.strictEqual(contents.oldContents, "before\uFFFDafter");
3096 assert.strictEqual(contents.oldContents, "before�after");
29383097 }),
29393098 );
29403099
......@@ -2996,8 +3155,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
29963155 mockedExecute.mockReturnValueOnce(
29973156 Effect.succeed(
29983157 output(
2999 // @effect-diagnostics-next-line preferSchemaOverJson:off
3000 JSON.stringify({
3158 encodeJson({
30013159 data: {
30023160 nodes: [{ login: "octocat", avatarUrl: "https://avatars/octocat" }],
30033161 rateLimit: {
......@@ -3020,8 +3178,8 @@ layer("GitHubPullRequestCli.layer", (it) => {
30203178 ids: ["MDQ6VXNlcjE="],
30213179 });
30223180
3023 expect(callAt(0).args).toContain("ids[]=MDQ6VXNlcjE=");
3024 expect(callAt(0).args.at(-1)).toContain("rateLimit { cost limit remaining resetAt }");
3181 expect(varsAt(0)).toEqual({ ids: ["MDQ6VXNlcjE="] });
3182 expect(queryAt(0)).toContain("rateLimit { cost limit remaining resetAt }");
30253183 expect(avatars.get("octocat")).toBe("https://avatars/octocat");
30263184 }),
30273185 );
......@@ -3039,16 +3197,16 @@ layer("GitHubPullRequestCli.layer", (it) => {
30393197
30403198 it.effect("looks up the authenticated account on the requested enterprise host", () =>
30413199 Effect.gen(function* () {
3042 mockedExecute
3043 .mockReturnValueOnce(Effect.succeed(output("enterprise-test-credential")))
3044 .mockReturnValueOnce(Effect.succeed(output('{"id":456,"login":"enterprise-user"}')));
3200 mockedExecute.mockReturnValueOnce(
3201 Effect.succeed(output('{"id":456,"login":"enterprise-user"}')),
3202 );
30453203 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
30463204
30473205 const login = yield* cli.getViewerLogin({ cwd: "/w", host: "github.acme.com" });
30483206
30493207 expect(login).toBe("enterprise-user");
3050 expect(callAt(0).args).toEqual(["auth", "token", "--hostname", "github.acme.com"]);
3051 expect(callAt(1).args).toEqual(["api", "user", "--hostname", "github.acme.com"]);
3208 expect(mockedCredential).toHaveBeenCalledWith("github.acme.com");
3209 expect(callAt(0)).toMatchObject({ kind: "rest", host: "github.acme.com", path: "user" });
30523210 }),
30533211 );
30543212
......@@ -3056,47 +3214,42 @@ layer("GitHubPullRequestCli.layer", (it) => {
30563214 Effect.gen(function* () {
30573215 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
30583216 const input = { cwd: "/w", host: "github.identity-cache.test" };
3059 mockedExecute
3060 .mockReturnValueOnce(Effect.succeed(output("test-credential-a")))
3061 .mockReturnValueOnce(Effect.succeed(output('{"id":123,"login":"maria-rcks"}')));
3217 mockedCredential.mockImplementation((host) =>
3218 Effect.succeed({ token: Redacted.make("test-credential-a"), fingerprint: `${host}:a` }),
3219 );
3220 mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"id":123,"login":"maria-rcks"}')));
30623221 expect(yield* cli.getRoutingIdentity(input)).toEqual({
30633222 accountId: "123",
30643223 viewer: "maria-rcks",
30653224 });
3066 expect(callAt(1).env).toMatchObject({
3067 GH_ENTERPRISE_TOKEN: "test-credential-a",
3068 GH_DEBUG: "",
3069 });
30703225
3071 mockedExecute.mockReturnValueOnce(Effect.succeed(output("test-credential-a")));
3226 // The same credential again is answered from what was verified, without asking GitHub.
30723227 expect(yield* cli.getRoutingIdentity(input)).toEqual({
30733228 accountId: "123",
30743229 viewer: "maria-rcks",
30753230 });
3076 expect(mockedExecute).toHaveBeenCalledTimes(3);
3231 expect(mockedExecute).toHaveBeenCalledTimes(1);
30773232
3078 mockedExecute
3079 .mockReturnValueOnce(Effect.succeed(output("test-credential-b")))
3080 .mockReturnValueOnce(
3081 Effect.fail(
3082 new GitHubCli.GitHubCliCommandError({
3083 command: "gh",
3084 cwd: "/w",
3085 cause: new Error("upstream failed with test-credential-b"),
3086 }),
3087 ),
3088 );
3233 // A switched credential is not trusted on the strength of the old one's answer.
3234 mockedCredential.mockImplementation((host) =>
3235 Effect.succeed({ token: Redacted.make("test-credential-b"), fingerprint: `${host}:b` }),
3236 );
3237 mockedExecute.mockReturnValueOnce(
3238 Effect.fail(
3239 new GitHubApi.GitHubApiResponseError({
3240 host: "github.com",
3241 operation: "getRoutingIdentity",
3242 status: 502,
3243 }),
3244 ),
3245 );
3246 // GitHub's own refusal is reported as itself, so the page can say what went wrong.
30893247 const failure = yield* cli.getRoutingIdentity(input).pipe(Effect.flip);
3090 expect(failure._tag).toBe("GitHubViewerLoginUnavailableError");
3248 expect(failure._tag).toBe("GitHubApiResponseError");
30913249 expect(String(failure)).not.toContain("test-credential-b");
3092 expect(callAt(4).env).toMatchObject({
3093 GH_ENTERPRISE_TOKEN: "test-credential-b",
3094 GH_DEBUG: "",
3095 });
3250 expect(mockedExecute).toHaveBeenCalledTimes(2);
30963251
3097 mockedExecute
3098 .mockReturnValueOnce(Effect.succeed(output("test-credential-b")))
3099 .mockReturnValueOnce(Effect.succeed(output('{"id":456,"login":"maria-rcks"}')));
3252 mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"id":456,"login":"maria-rcks"}')));
31003253 expect(yield* cli.getRoutingIdentity(input)).toEqual({
31013254 accountId: "456",
31023255 viewer: "maria-rcks",
......@@ -3104,7 +3257,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
31043257 }),
31053258 );
31063259
3107 it.effect("sends a whole review as one request body over stdin", () =>
3260 it.effect("sends a whole review as one request", () =>
31083261 Effect.gen(function* () {
31093262 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
31103263 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
......@@ -3119,28 +3272,23 @@ layer("GitHubPullRequestCli.layer", (it) => {
31193272 comments: [{ path: "src/a.ts", position: { kind: "added", newLine: 4 }, body: "nit" }],
31203273 });
31213274
3122 expect(callAt(0).args).toEqual([
3123 "api",
3124 "--method",
3125 "POST",
3126 "--hostname",
3127 "github.com",
3128 "repos/acme/web/pulls/7/reviews",
3129 "--input",
3130 "-",
3131 ]);
31323275 // One request, so nothing is on the pull request until the verdict is.
31333276 assert.strictEqual(mockedExecute.mock.calls.length, 1);
3134 // @effect-diagnostics-next-line preferSchemaOverJson:off
3135 expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({
3136 event: "APPROVE",
3137 body: "Looks right.",
3138 comments: [{ path: "src/a.ts", line: 4, side: "RIGHT", body: "nit" }],
3139 });
3277 expect(restCallsTo("/reviews")).toMatchObject([
3278 {
3279 method: "POST",
3280 path: "repos/acme/web/pulls/7/reviews",
3281 body: {
3282 event: "APPROVE",
3283 body: "Looks right.",
3284 comments: [{ path: "src/a.ts", line: 4, side: "RIGHT", body: "nit" }],
3285 },
3286 },
3287 ]);
31403288 }),
31413289 );
31423290
3143 it.effect("sends a reply body over stdin, never in argv", () =>
3291 it.effect("sends a reply body as a variable, never inside the document", () =>
31443292 Effect.gen(function* () {
31453293 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
31463294 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
......@@ -3153,23 +3301,10 @@ layer("GitHubPullRequestCli.layer", (it) => {
31533301 body: "Fixed in 42ff8ec.",
31543302 });
31553303
3156 // A reply is the reader's own words, so it travels the same way a comment body does.
3157 expect(callAt(0).args).toEqual([
3158 "api",
3159 "graphql",
3160 "--hostname",
3161 "github.com",
3162 "--input",
3163 "-",
3304 expect(variablesOf("addPullRequestReviewThreadReply(")).toEqual([
3305 { threadId: "PRRT_1", body: "Fixed in 42ff8ec." },
31643306 ]);
3165 // @effect-diagnostics-next-line preferSchemaOverJson:off
3166 const request = JSON.parse(callAt(0).stdin ?? "") as {
3167 query: string;
3168 variables: Record<string, string>;
3169 };
3170 expect(request.query).toContain("addPullRequestReviewThreadReply");
3171 expect(request.variables).toEqual({ threadId: "PRRT_1", body: "Fixed in 42ff8ec." });
3172 expect(callAt(0).args.join(" ")).not.toContain("Fixed in 42ff8ec.");
3307 expect(queryAt(0)).not.toContain("Fixed in 42ff8ec.");
31733308 }),
31743309 );
31753310
......@@ -3193,30 +3328,18 @@ layer("GitHubPullRequestCli.layer", (it) => {
31933328 resolved: false,
31943329 });
31953330
3196 const parse = (index: number) => JSON.parse(callAt(index).stdin ?? "") as { query: string };
3197 expect(parse(0).query).toContain("resolveReviewThread(");
3198 expect(parse(1).query).toContain("unresolveReviewThread(");
3331 expect(queryAt(0)).toContain("resolveReviewThread(");
3332 expect(queryAt(0)).not.toContain("unresolveReviewThread(");
3333 expect(queryAt(1)).toContain("unresolveReviewThread(");
3334 expect([varsAt(0), varsAt(1)]).toEqual([{ threadId: "PRRT_1" }, { threadId: "PRRT_1" }]);
31993335 // A GitHub Enterprise thread is resolved on its own host, not on github.com.
3200 expect(callAt(0).args).toContain("github.acme.dev");
3336 expect([callAt(0).host, callAt(1).host]).toEqual(["github.acme.dev", "github.acme.dev"]);
32013337 }),
32023338 );
32033339
32043340 it.effect("confirms a given subject belongs to the named pull request, then reacts to it", () =>
32053341 Effect.gen(function* () {
3206 mockedExecute.mockReturnValueOnce(
3207 Effect.succeed(
3208 output(
3209 // @effect-diagnostics-next-line preferSchemaOverJson:off
3210 JSON.stringify({
3211 data: {
3212 repository: { pullRequest: { id: "PR_kwDOA" } },
3213 node: { id: "IC_1", pullRequest: { id: "PR_kwDOA" } },
3214 },
3215 }),
3216 ),
3217 ),
3218 );
3219 mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}")));
3342 route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]);
32203343 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
32213344
32223345 yield* cli.setReaction({
......@@ -3230,18 +3353,10 @@ layer("GitHubPullRequestCli.layer", (it) => {
32303353 });
32313354
32323355 assert.strictEqual(mockedExecute.mock.calls.length, 2);
3233 const scopeCheck = callAt(0).args;
3234 expect(scopeCheck).toContain("owner=acme");
3235 expect(scopeCheck).toContain("name=web");
3236 expect(scopeCheck).toContain("number=7");
3237 expect(scopeCheck).toContain("subjectId=IC_1");
3238 // @effect-diagnostics-next-line preferSchemaOverJson:off
3239 const request = JSON.parse(callAt(1).stdin ?? "") as {
3240 query: string;
3241 variables: Record<string, string>;
3242 };
3243 expect(request.query).toContain("addReaction(");
3244 expect(request.variables).toEqual({ subjectId: "IC_1", content: "HEART" });
3356 expect(variablesOf(SUBJECT_SCOPE_QUERY)).toEqual([
3357 { owner: "acme", name: "web", number: 7, subjectId: "IC_1" },
3358 ]);
3359 expect(variablesOf("addReaction(")).toEqual([{ subjectId: "IC_1", content: "HEART" }]);
32453360 }),
32463361 );
32473362
......@@ -3250,8 +3365,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
32503365 mockedExecute.mockReturnValueOnce(
32513366 Effect.succeed(
32523367 output(
3253 // @effect-diagnostics-next-line preferSchemaOverJson:off
3254 JSON.stringify({
3368 encodeJson({
32553369 data: {
32563370 repository: { pullRequest: { id: "PR_thisOne" } },
32573371 // A comment on pull request #99 of a different repository, named as though it
......@@ -3284,15 +3398,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
32843398
32853399 it.effect("looks up the pull request's own node id when no subject was given", () =>
32863400 Effect.gen(function* () {
3287 mockedExecute.mockReturnValueOnce(
3288 Effect.succeed(
3289 output(
3290 // @effect-diagnostics-next-line preferSchemaOverJson:off
3291 JSON.stringify({ data: { repository: { pullRequest: { id: "PR_kwDOA" } } } }),
3292 ),
3293 ),
3294 );
3295 mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}")));
3401 route([NODE_ID_QUERY, nodeIdAnswer("PR_kwDOA")]);
32963402 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
32973403
32983404 // Its own pull request: a node id looked up once is remembered for the life of the service.
......@@ -3306,36 +3412,14 @@ layer("GitHubPullRequestCli.layer", (it) => {
33063412 });
33073413
33083414 assert.strictEqual(mockedExecute.mock.calls.length, 2);
3309 const lookup = callAt(0).args;
3310 expect(lookup).toContain("owner=acme");
3311 expect(lookup).toContain("name=web");
3312 expect(lookup).toContain("number=21");
3313 // @effect-diagnostics-next-line preferSchemaOverJson:off
3314 const request = JSON.parse(callAt(1).stdin ?? "") as {
3315 query: string;
3316 variables: Record<string, string>;
3317 };
3318 expect(request.query).toContain("addReaction(");
3319 expect(request.variables).toEqual({ subjectId: "PR_kwDOA", content: "ROCKET" });
3415 expect(variablesOf(NODE_ID_QUERY)).toEqual([{ owner: "acme", name: "web", number: 21 }]);
3416 expect(variablesOf("addReaction(")).toEqual([{ subjectId: "PR_kwDOA", content: "ROCKET" }]);
33203417 }),
33213418 );
33223419
33233420 it.effect("takes a reaction back through the remove mutation", () =>
33243421 Effect.gen(function* () {
3325 mockedExecute.mockReturnValueOnce(
3326 Effect.succeed(
3327 output(
3328 // @effect-diagnostics-next-line preferSchemaOverJson:off
3329 JSON.stringify({
3330 data: {
3331 repository: { pullRequest: { id: "PR_kwDOA" } },
3332 node: { id: "IC_1", pullRequest: { id: "PR_kwDOA" } },
3333 },
3334 }),
3335 ),
3336 ),
3337 );
3338 mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}")));
3422 route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]);
33393423 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
33403424
33413425 yield* cli.setReaction({
......@@ -3348,22 +3432,14 @@ layer("GitHubPullRequestCli.layer", (it) => {
33483432 reacted: false,
33493433 });
33503434
3351 // @effect-diagnostics-next-line preferSchemaOverJson:off
3352 const request = JSON.parse(callAt(1).stdin ?? "") as { query: string };
3353 expect(request.query).toContain("removeReaction(");
3435 expect(variablesOf("removeReaction(")).toEqual([{ subjectId: "IC_1", content: "HEART" }]);
3436 expect(variablesOf("addReaction(")).toEqual([]);
33543437 }),
33553438 );
33563439
33573440 it.effect("rewrites only the words a request named", () =>
33583441 Effect.gen(function* () {
3359 mockedExecute.mockReturnValue(
3360 Effect.succeed(
3361 output(
3362 // @effect-diagnostics-next-line preferSchemaOverJson:off
3363 JSON.stringify({ data: { repository: { pullRequest: { id: "PR_kwDOA" } } } }),
3364 ),
3365 ),
3366 );
3442 route([NODE_ID_QUERY, nodeIdAnswer("PR_kwDOA")]);
33673443 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
33683444 const rewrite = (fields: { readonly title?: string; readonly body?: string }) =>
33693445 cli.updatePullRequest({
......@@ -3379,38 +3455,18 @@ layer("GitHubPullRequestCli.layer", (it) => {
33793455 yield* rewrite({ title: "Both", body: "at once." });
33803456
33813457 // One node id lookup for the pull request, then a mutation per rewrite.
3382 const variablesAt = (index: number) =>
3383 (JSON.parse(callAt(index).stdin ?? "") as { variables: Record<string, string> }).variables;
3384 expect(variablesAt(1)).toEqual({ pullRequestId: "PR_kwDOA", title: "A better title" });
3385 expect(variablesAt(2)).toEqual({
3386 pullRequestId: "PR_kwDOA",
3387 body: "A better description.",
3388 });
3389 expect(variablesAt(3)).toEqual({
3390 pullRequestId: "PR_kwDOA",
3391 title: "Both",
3392 body: "at once.",
3393 });
3394 // The reader's own words, so they travel the way every other body does.
3395 expect(callAt(3).args.join(" ")).not.toContain("at once.");
3458 assert.strictEqual(variablesOf(NODE_ID_QUERY).length, 1);
3459 expect(variablesOf("updatePullRequest(")).toEqual([
3460 { pullRequestId: "PR_kwDOA", title: "A better title" },
3461 { pullRequestId: "PR_kwDOA", body: "A better description." },
3462 { pullRequestId: "PR_kwDOA", title: "Both", body: "at once." },
3463 ]);
33963464 }),
33973465 );
33983466
33993467 it.effect("rewrites a remark through the mutation its kind needs", () =>
34003468 Effect.gen(function* () {
3401 mockedExecute.mockReturnValue(
3402 Effect.succeed(
3403 output(
3404 // @effect-diagnostics-next-line preferSchemaOverJson:off
3405 JSON.stringify({
3406 data: {
3407 repository: { pullRequest: { id: "PR_kwDOA" } },
3408 node: { id: "IC_1", pullRequest: { id: "PR_kwDOA" } },
3409 },
3410 }),
3411 ),
3412 ),
3413 );
3469 route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]);
34143470 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
34153471 const rewrite = (kind: "issue-comment" | "review-comment") =>
34163472 cli.updateComment({
......@@ -3426,16 +3482,16 @@ layer("GitHubPullRequestCli.layer", (it) => {
34263482 yield* rewrite("issue-comment");
34273483 yield* rewrite("review-comment");
34283484
3429 const parse = (index: number) =>
3430 JSON.parse(callAt(index).stdin ?? "") as {
3431 query: string;
3432 variables: Record<string, string>;
3433 };
3434 expect(callAt(0).args).toContain("subjectId=IC_1");
3435 expect(parse(1).query).toContain("updateIssueComment(");
3436 expect(parse(1).variables).toEqual({ commentId: "IC_1", body: "Reworded." });
3437 expect(parse(3).query).toContain("updatePullRequestReviewComment(");
3438 expect(parse(3).variables).toEqual({ commentId: "IC_1", body: "Reworded." });
3485 expect(variablesOf(SUBJECT_SCOPE_QUERY).map((variables) => variables["subjectId"])).toEqual([
3486 "IC_1",
3487 "IC_1",
3488 ]);
3489 expect(variablesOf("updateIssueComment(")).toEqual([
3490 { commentId: "IC_1", body: "Reworded." },
3491 ]);
3492 expect(variablesOf("updatePullRequestReviewComment(")).toEqual([
3493 { commentId: "IC_1", body: "Reworded." },
3494 ]);
34393495 }),
34403496 );
34413497
......@@ -3444,8 +3500,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
34443500 mockedExecute.mockReturnValueOnce(
34453501 Effect.succeed(
34463502 output(
3447 // @effect-diagnostics-next-line preferSchemaOverJson:off
3448 JSON.stringify({
3503 encodeJson({
34493504 data: {
34503505 repository: { pullRequest: { id: "PR_thisOne" } },
34513506 node: { id: "IC_99", pullRequest: { id: "PR_someOtherOne" } },
......@@ -3469,7 +3524,8 @@ layer("GitHubPullRequestCli.layer", (it) => {
34693524 );
34703525
34713526 assert.strictEqual(error._tag, "GitHubSubjectScopeError");
3472 expect(error.message).toContain("updateComment");
3527 if (error._tag === "GitHubSubjectScopeError")
3528 assert.strictEqual(error.operation, "updateComment");
34733529 // Refused before any mutation was sent.
34743530 assert.strictEqual(mockedExecute.mock.calls.length, 1);
34753531 }),
......@@ -3500,14 +3556,8 @@ layer("GitHubPullRequestCli.layer", (it) => {
35003556 output(
35013557 encodeJson(
35023558 coreResponse({
3503 number: 7,
35043559 title: "Progressive detail",
3505 url: "https://github.com/acme/web/pull/7",
35063560 author: { login: "octocat" },
3507 headRefName: "feature",
3508 baseRefName: "main",
3509 createdAt: "2026-07-01T00:00:00Z",
3510 updatedAt: "2026-07-02T00:00:00Z",
35113561 body: "Core body",
35123562 changedFiles: 2,
35133563 }),
......@@ -3518,12 +3568,17 @@ layer("GitHubPullRequestCli.layer", (it) => {
35183568 mockedExecute.mockReturnValueOnce(
35193569 Effect.succeed(
35203570 output(
3521 // @effect-diagnostics-next-line preferSchemaOverJson:off
3522 JSON.stringify({
3523 author: { login: "octocat" },
3524 comments: [],
3525 reviews: [],
3526 commits: [],
3571 encodeJson({
3572 data: {
3573 repository: {
3574 pullRequest: {
3575 author: { __typename: "User", login: "octocat", avatarUrl: "https://a/o" },
3576 commits: { nodes: [] },
3577 comments: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] },
3578 reviews: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] },
3579 },
3580 },
3581 },
35273582 }),
35283583 ),
35293584 ),
......@@ -3541,15 +3596,19 @@ layer("GitHubPullRequestCli.layer", (it) => {
35413596
35423597 expect(detail.body).toBe("Core body");
35433598 expect(activity.author?.login).toBe("octocat");
3544 expect(callAt(0).args).toContain("headRef=refs/pull/7/head");
3545 expect(callAt(0).args.at(-1)).toContain("viewerCanUpdateBranch");
3599 expect(varsAt(0)).toMatchObject({ number: 7, headRef: "refs/pull/7/head" });
3600 expect(queryAt(0)).toContain("viewerCanUpdateBranch");
3601 expect(queryAt(0)).not.toContain("reviews(");
35463602 expect(detail.viewerAccess.mergeCapabilities).toEqual({
35473603 merge: true,
35483604 squash: false,
35493605 rebase: true,
35503606 });
35513607 expect(detail.comparison).toEqual({ behindBy: 2, viewerCanUpdate: true });
3552 expect(callAt(1).args.at(-1)).toBe("author,comments,reviews,commits");
3608 // Conversation activity is its own read, and asks for the head of the conversation once.
3609 expect(queryAt(1)).toContain("reviews(");
3610 expect(varsAt(1)).toMatchObject({ head: true, withComments: true, withReviews: true });
3611 assert.strictEqual(mockedExecute.mock.calls.length, 2);
35533612 }),
35543613 );
35553614
......@@ -3621,35 +3680,40 @@ layer("GitHubPullRequestCli.layer", (it) => {
36213680
36223681 it.effect("reads every check when the combined response has another page", () =>
36233682 Effect.gen(function* () {
3624 const response = coreResponse({
3625 commits: {
3626 nodes: [
3627 {
3628 commit: {
3629 statusCheckRollup: {
3630 contexts: {
3631 nodes: [{ name: "first", status: "COMPLETED", conclusion: "SUCCESS" }],
3632 pageInfo: { hasNextPage: true },
3633 },
3683 mockedExecute.mockReturnValueOnce(
3684 Effect.succeed(
3685 output(
3686 encodeJson(
3687 coreResponse({
3688 commits: {
3689 nodes: [
3690 {
3691 commit: {
3692 statusCheckRollup: {
3693 contexts: {
3694 nodes: [{ name: "first", status: "COMPLETED", conclusion: "SUCCESS" }],
3695 pageInfo: { hasNextPage: true, endCursor: "c1" },
3696 },
3697 },
3698 },
3699 },
3700 ],
36343701 },
3635 },
3636 },
3637 ],
3638 },
3639 });
3640 mockedExecute.mockReturnValueOnce(Effect.succeed(output(encodeJson(response))));
3702 }),
3703 ),
3704 ),
3705 ),
3706 );
36413707 mockedExecute.mockReturnValueOnce(
36423708 Effect.succeed(
36433709 output(
3644 encodeJson({
3645 ...response.data.repository.pullRequest,
3646 reviewRequests: [],
3647 labels: [],
3648 statusCheckRollup: [
3710 checkContextsPage(
3711 [
36493712 { name: "first", status: "COMPLETED", conclusion: "SUCCESS" },
36503713 { name: "last", status: "COMPLETED", conclusion: "FAILURE" },
36513714 ],
3652 }),
3715 null,
3716 ),
36533717 ),
36543718 ),
36553719 );
......@@ -3663,7 +3727,9 @@ layer("GitHubPullRequestCli.layer", (it) => {
36633727 expect(detail.checks).toHaveLength(2);
36643728 expect(detail.checksState).toBe("failing");
36653729 expect(detail.checksTruncated).toBe(false);
3666 expect(callAt(1).args.slice(0, 2)).toEqual(["pr", "view"]);
3730 // The whole rollup is walked from its start, so no check is counted twice or skipped.
3731 expect(varsAt(1)).toMatchObject({ number: 7, after: null });
3732 assert.strictEqual(mockedExecute.mock.calls.length, 2);
36673733 }),
36683734 );
36693735
......@@ -3744,9 +3810,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
37443810 const error = yield* Effect.flip(cli.getPullRequestDetail(input));
37453811 expect(error._tag).toBe("SourceControlRateLimitPausedError");
37463812 expect(mockedExecute).toHaveBeenCalledOnce();
3747 yield* cli
3748 .getPullRequestDetail(input)
3749 .pipe(Effect.provideService(GitHubCli.AllowGitHubReserve, true));
3813 yield* cli.getPullRequestDetail(input).pipe(Effect.provideService(AllowGitHubReserve, true));
37503814 expect(mockedExecute).toHaveBeenCalledTimes(2);
37513815 }),
37523816 );
......@@ -3769,13 +3833,13 @@ layer("GitHubPullRequestCli.layer", (it) => {
37693833 // What matters is that it fails at all: an empty patch with no cursor would render as a
37703834 // change with no files and report the rest of it as already read. The refusal that sent
37713835 // the read down this road is the one reported, by design.
3772 assert.strictEqual(error._tag, "GitHubCliCommandError");
3836 assert.strictEqual(error, diffRefused);
37733837 }),
37743838 );
37753839
37763840 it.effect("pages an oversized patch by file rather than handing back a severed one", () =>
37773841 Effect.gen(function* () {
3778 // `gh pr diff` succeeded but its output was cut at a byte, which lands mid-file.
3842 // The whole diff came back, but cut at a byte, which lands mid-file.
37793843 mockedExecute.mockReturnValueOnce(
37803844 Effect.succeed(output("diff --git a/a b/a\n@@ -1 +1 @@", true)),
37813845 );
......@@ -3790,8 +3854,9 @@ layer("GitHubPullRequestCli.layer", (it) => {
37903854 });
37913855
37923856 // The severed patch is thrown away; what comes back is assembled from whole files.
3793 expect(callAt(1).args.join(" ")).toContain("/pulls/7/files");
3857 assert.strictEqual(pathAt(1), "repos/acme/web/pulls/7/files?per_page=100&page=1");
37943858 expect(slice.patch).toContain("src/file1.ts");
3859 expect(slice.patch).not.toContain("diff --git a/a b/a");
37953860 assert.strictEqual(mockedExecute.mock.calls.length, 2);
37963861 }),
37973862 );
......@@ -3813,9 +3878,9 @@ layer("GitHubPullRequestCli.layer", (it) => {
38133878 number: 7,
38143879 });
38153880
3816 // The first page asks from the beginning, which gh only sends as a typed JSON null.
3817 expect(callAt(0).args).toContain("cursor=null");
3818 expect(callAt(1).args).toContain("cursor=Y3Vyc29yOjE");
3881 // The first page asks from the beginning; the next carries on from where it stopped.
3882 expect(varsAt(0)["cursor"]).toBeNull();
3883 expect(varsAt(1)["cursor"]).toBe("Y3Vyc29yOjE");
38193884 expect(conversation.comments.map((comment) => comment.id)).toEqual(["c1", "c2"]);
38203885 assert.isFalse(conversation.truncated);
38213886 }),
......@@ -3897,11 +3962,13 @@ layer("GitHubPullRequestCli.layer", (it) => {
38973962 cursor: "Y3Vyc29yOjI",
38983963 });
38993964
3900 expect(callAt(0).args).toContain("owner=acme");
3901 expect(callAt(0).args).toContain("name=web");
3902 expect(callAt(0).args).toContain("number=7");
3903 expect(callAt(0).args).toContain("threadId=PRRT_1");
3904 expect(callAt(0).args).toContain("cursor=Y3Vyc29yOjI");
3965 expect(varsAt(0)).toMatchObject({
3966 owner: "acme",
3967 name: "web",
3968 number: 7,
3969 threadId: "PRRT_1",
3970 cursor: "Y3Vyc29yOjI",
3971 });
39053972 assert.strictEqual(mockedExecute.mock.calls.length, 1);
39063973 expect(page.comments.map((comment) => comment.id)).toEqual(["c2", "c3"]);
39073974 expect(page.nextCursor).toBeNull();
......@@ -3937,8 +4004,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
39374004 mockedExecute.mockReturnValue(
39384005 Effect.succeed(
39394006 output(
3940 // @effect-diagnostics-next-line preferSchemaOverJson:off
3941 JSON.stringify({
4007 encodeJson({
39424008 data: {
39434009 repository: {
39444010 mergeCommitAllowed: true,
......@@ -3963,10 +4029,8 @@ layer("GitHubPullRequestCli.layer", (it) => {
39634029
39644030 // One request, because both answers hang off the same repository object.
39654031 assert.strictEqual(mockedExecute.mock.calls.length, 1);
3966 expect(callAt(0).args).toContain("number=7");
3967 expect(callAt(0).args.at(-1)).toContain(
3968 "mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed",
3969 );
4032 expect(varsAt(0)).toMatchObject({ owner: "acme", name: "web", number: 7 });
4033 expect(queryAt(0)).toContain("mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed");
39704034 expect(access).toEqual({
39714035 mergeCapabilities: { merge: true, squash: false, rebase: true },
39724036 canWrite: false,
......@@ -3977,13 +4041,12 @@ layer("GitHubPullRequestCli.layer", (it) => {
39774041 }),
39784042 );
39794043
3980 it.effect("sends the base comparison's variables as gh flags, not as bare words", () =>
4044 it.effect("sends the base comparison's head as a variable, not inside the document", () =>
39814045 Effect.gen(function* () {
39824046 mockedExecute.mockReturnValue(
39834047 Effect.succeed(
39844048 output(
3985 // @effect-diagnostics-next-line preferSchemaOverJson:off
3986 JSON.stringify({
4049 encodeJson({
39874050 data: {
39884051 repository: {
39894052 pullRequest: {
......@@ -4006,26 +4069,14 @@ layer("GitHubPullRequestCli.layer", (it) => {
40064069 headRef: "fork:feat/page",
40074070 });
40084071
4009 // The tuples are flattened straight into argv, so a variable without its flag is a
4010 // positional argument gh refuses outright.
4011 const args = callAt(0).args;
4012 expect(args.slice(0, -2)).toEqual([
4013 "api",
4014 "graphql",
4015 "--hostname",
4016 "github.com",
4017 "-f",
4018 "owner=acme",
4019 "-f",
4020 "name=web",
4021 "-F",
4022 "number=7",
4023 "-f",
4024 "headRef=fork:feat/page",
4025 ]);
4072 expect(varsAt(0)).toEqual({
4073 owner: "acme",
4074 name: "web",
4075 number: 7,
4076 headRef: "fork:feat/page",
4077 });
40264078 expect(comparison).toEqual({ behindBy: 4, viewerCanUpdate: true });
4027 expect(args.at(-2)).toBe("-f");
4028 expect(args.at(-1)).toContain(`query=${BASE_COMPARISON_GRAPHQL_QUERY.slice(0, -2)}`);
4079 expect(queryAt(0)).toContain(BASE_COMPARISON_GRAPHQL_QUERY.slice(0, -2));
40294080 }),
40304081 );
40314082
......@@ -4034,8 +4085,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
40344085 mockedExecute.mockReturnValue(
40354086 Effect.succeed(
40364087 output(
4037 // @effect-diagnostics-next-line preferSchemaOverJson:off
4038 JSON.stringify({
4088 encodeJson({
40394089 data: {
40404090 repository: {
40414091 pullRequest: {
......@@ -4064,7 +4114,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
40644114 } as const;
40654115
40664116 yield* cli.getPullRequestBaseComparison(input);
4067 expect(callAt(0).args.at(-1)).toContain("rateLimit { cost limit remaining resetAt }");
4117 expect(queryAt(0)).toContain("rateLimit { cost limit remaining resetAt }");
40684118
40694119 const error = yield* Effect.flip(cli.getPullRequestBaseComparison(input));
40704120
......@@ -4083,8 +4133,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
40834133 .mockReturnValueOnce(
40844134 Effect.succeed(
40854135 output(
4086 // @effect-diagnostics-next-line preferSchemaOverJson:off
4087 JSON.stringify({
4136 encodeJson({
40884137 data: {
40894138 repository: {
40904139 pullRequest: {
......@@ -4106,8 +4155,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
41064155 .mockReturnValueOnce(
41074156 Effect.succeed(
41084157 output(
4109 // @effect-diagnostics-next-line preferSchemaOverJson:off
4110 JSON.stringify({
4158 encodeJson({
41114159 data: {
41124160 repository: {
41134161 mergeCommitAllowed: true,
......@@ -4167,21 +4215,11 @@ layer("GitHubPullRequestCli.layer", (it) => {
41674215 requested: true,
41684216 });
41694217
4170 const call = callAt(0);
4171 expect(call.args).toEqual([
4172 "api",
4173 "--method",
4174 "POST",
4175 "--hostname",
4176 "github.com",
4177 "repos/acme/web/pulls/7/requested_reviewers",
4178 "--input",
4179 "-",
4180 ]);
4181 // @effect-diagnostics-next-line preferSchemaOverJson:off
4182 expect(JSON.parse(call.stdin ?? "")).toEqual({
4183 reviewers: ["octocat"],
4184 team_reviewers: ["reviewers"],
4218 expect(callAt(0)).toMatchObject({
4219 kind: "rest",
4220 method: "POST",
4221 path: "repos/acme/web/pulls/7/requested_reviewers",
4222 body: { reviewers: ["octocat"], team_reviewers: ["reviewers"] },
41854223 });
41864224 }),
41874225 );
......@@ -4200,13 +4238,11 @@ layer("GitHubPullRequestCli.layer", (it) => {
42004238 requested: false,
42014239 });
42024240
4203 const call = callAt(0);
4204 expect(call.args).toContain("DELETE");
4205 expect(call.args).toContain("repos/acme/web/pulls/7/requested_reviewers");
4206 // @effect-diagnostics-next-line preferSchemaOverJson:off
4207 expect(JSON.parse(call.stdin ?? "")).toEqual({
4208 reviewers: ["octocat"],
4209 team_reviewers: [],
4241 expect(callAt(0)).toMatchObject({
4242 kind: "rest",
4243 method: "DELETE",
4244 path: "repos/acme/web/pulls/7/requested_reviewers",
4245 body: { reviewers: ["octocat"], team_reviewers: [] },
42104246 });
42114247 }),
42124248 );
......@@ -4216,8 +4252,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
42164252 mockedExecute.mockReturnValue(
42174253 Effect.succeed(
42184254 output(
4219 // @effect-diagnostics-next-line preferSchemaOverJson:off
4220 JSON.stringify({
4255 encodeJson({
42214256 data: {
42224257 repository: {
42234258 assignableUsers: {
......@@ -4246,7 +4281,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
42464281 // The people, who has been asked and who opened the pull request all hang off the same
42474282 // repository object, so the menu costs one request.
42484283 assert.strictEqual(mockedExecute.mock.calls.length, 1);
4249 expect(callAt(0).args).toContain("number=7");
4284 expect(varsAt(0)).toMatchObject({ owner: "acme", name: "web", number: 7 });
42504285 expect(list.candidates.map((candidate) => [candidate.login, candidate.isRequested])).toEqual([
42514286 ["octocat", true],
42524287 ["hubot", false],
......@@ -4269,19 +4304,12 @@ layer("GitHubPullRequestCli.layer", (it) => {
42694304 });
42704305
42714306 assert.strictEqual(mockedExecute.mock.calls.length, 1);
4272 const call = callAt(0);
4273 expect(call.args).toEqual([
4274 "api",
4275 "--method",
4276 "POST",
4277 "--hostname",
4278 "github.com",
4279 "repos/acme/web/issues/7/labels",
4280 "--input",
4281 "-",
4282 ]);
4283 // @effect-diagnostics-next-line preferSchemaOverJson:off - asserting the raw gh request body.
4284 expect(JSON.parse(call.stdin ?? "")).toEqual({ labels: ["bug", "size:XL"] });
4307 expect(callAt(0)).toMatchObject({
4308 kind: "rest",
4309 method: "POST",
4310 path: "repos/acme/web/issues/7/labels",
4311 body: { labels: ["bug", "size:XL"] },
4312 });
42854313 }),
42864314 );
42874315
......@@ -4300,9 +4328,14 @@ layer("GitHubPullRequestCli.layer", (it) => {
43004328 });
43014329
43024330 assert.strictEqual(mockedExecute.mock.calls.length, 2);
4303 expect(callAt(0).args).toContain("repos/acme/web/issues/7/labels/good%20first%20issue");
4304 expect(callAt(0).args).toContain("DELETE");
4305 expect(callAt(1).args).toContain("repos/acme/web/issues/7/labels/area%2Fweb");
4331 expect(callAt(0)).toMatchObject({
4332 method: "DELETE",
4333 path: "repos/acme/web/issues/7/labels/good%20first%20issue",
4334 });
4335 expect(callAt(1)).toMatchObject({
4336 method: "DELETE",
4337 path: "repos/acme/web/issues/7/labels/area%2Fweb",
4338 });
43064339 }),
43074340 );
43084341
......@@ -4311,7 +4344,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
43114344 const page = (index: number, hasNextPage: boolean) =>
43124345 Effect.succeed(
43134346 output(
4314 JSON.stringify({
4347 encodeJson({
43154348 data: {
43164349 repository: {
43174350 pullRequest: {
......@@ -4343,9 +4376,11 @@ layer("GitHubPullRequestCli.layer", (it) => {
43434376
43444377 assert.strictEqual(mockedExecute.mock.calls.length, 3);
43454378 // The first page asks from the start; each one after it carries the cursor before it.
4346 assert.isFalse(callAt(0).args.some((arg) => arg.startsWith("after=")));
4347 expect(callAt(1).args).toContain("after=cursor-0");
4348 expect(callAt(2).args).toContain("after=cursor-1");
4379 expect([varsAt(0)["after"], varsAt(1)["after"], varsAt(2)["after"]]).toEqual([
4380 null,
4381 "cursor-0",
4382 "cursor-1",
4383 ]);
43494384 assert.isFalse(viewed.truncated);
43504385 expect(viewed.files.map((file) => [file.path, file.state])).toEqual([
43514386 ["src/file0.ts", "viewed"],
......@@ -4363,8 +4398,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
43634398 mockedExecute.mockReturnValue(
43644399 Effect.succeed(
43654400 output(
4366 // @effect-diagnostics-next-line preferSchemaOverJson:off
4367 JSON.stringify({
4401 encodeJson({
43684402 data: {
43694403 repository: {
43704404 pullRequest: {
......@@ -4399,8 +4433,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
43994433 mockedExecute
44004434 .mockReturnValueOnce(
44014435 Effect.succeed(
4402 // @effect-diagnostics-next-line preferSchemaOverJson:off
4403 output(JSON.stringify({ data: { repository: { pullRequest: { id: "PR_1" } } } })),
4436 output(encodeJson({ data: { repository: { pullRequest: { id: "PR_1" } } } })),
44044437 ),
44054438 )
44064439 .mockReturnValueOnce(Effect.succeed(output("{}")));
......@@ -4419,14 +4452,9 @@ layer("GitHubPullRequestCli.layer", (it) => {
44194452
44204453 // One request to learn the pull request's node id, one for every press together.
44214454 assert.strictEqual(mockedExecute.mock.calls.length, 2);
4422 // @effect-diagnostics-next-line preferSchemaOverJson:off
4423 const sent = JSON.parse(callAt(1).stdin ?? "") as {
4424 query: string;
4425 variables: Record<string, string>;
4426 };
4427 expect(sent.query).toContain("f0: markFileAsViewed");
4428 expect(sent.query).toContain("f1: unmarkFileAsViewed");
4429 expect(sent.variables).toEqual({
4455 expect(queryAt(1)).toContain("f0: markFileAsViewed");
4456 expect(queryAt(1)).toContain("f1: unmarkFileAsViewed");
4457 expect(varsAt(1)).toEqual({
44304458 pullRequestId: "PR_1",
44314459 path0: "src/a.ts",
44324460 path1: "src/b.ts",
......@@ -4455,8 +4483,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
44554483 mockedExecute
44564484 .mockReturnValueOnce(
44574485 Effect.succeed(
4458 // @effect-diagnostics-next-line preferSchemaOverJson:off
4459 output(JSON.stringify({ data: { repository: { pullRequest: { id: "PR_24" } } } })),
4486 output(encodeJson({ data: { repository: { pullRequest: { id: "PR_24" } } } })),
44604487 ),
44614488 )
44624489 .mockReturnValue(Effect.succeed(output("{}")));
......@@ -4475,11 +4502,12 @@ layer("GitHubPullRequestCli.layer", (it) => {
44754502
44764503 // One lookup, then a mutation per write, every one of them addressed by the id it answered.
44774504 assert.strictEqual(mockedExecute.mock.calls.length, 4);
4478 expect(callAt(0).args).toContain("number=24");
4479 const idSentAt = (index: number) =>
4480 (JSON.parse(callAt(index).stdin ?? "") as { variables: { pullRequestId: string } })
4481 .variables.pullRequestId;
4482 expect([idSentAt(1), idSentAt(2), idSentAt(3)]).toEqual(["PR_24", "PR_24", "PR_24"]);
4505 expect(varsAt(0)).toEqual({ owner: "acme", name: "web", number: 24 });
4506 expect([1, 2, 3].map((index) => varsAt(index)["pullRequestId"])).toEqual([
4507 "PR_24",
4508 "PR_24",
4509 "PR_24",
4510 ]);
44834511 }),
44844512 );
44854513
......@@ -4489,8 +4517,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
44894517 .mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}')))
44904518 .mockReturnValueOnce(
44914519 Effect.succeed(
4492 // @effect-diagnostics-next-line preferSchemaOverJson:off
4493 output(JSON.stringify({ data: { repository: { pullRequest: { id: "PR_25" } } } })),
4520 output(encodeJson({ data: { repository: { pullRequest: { id: "PR_25" } } } })),
44944521 ),
44954522 )
44964523 .mockReturnValueOnce(Effect.succeed(output("{}")));
......@@ -4510,10 +4537,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
45104537 yield* write();
45114538
45124539 assert.strictEqual(mockedExecute.mock.calls.length, 3);
4513 const idSentAt = (index: number) =>
4514 (JSON.parse(callAt(index).stdin ?? "") as { variables: { pullRequestId: string } })
4515 .variables.pullRequestId;
4516 expect(idSentAt(2)).toEqual("PR_25");
4540 expect(varsAt(2)["pullRequestId"]).toEqual("PR_25");
45174541 }),
45184542 );
45194543 it.effect("keeps the pull request being ticked through, not the one looked up first", () =>
......@@ -4525,9 +4549,10 @@ layer("GitHubPullRequestCli.layer", (it) => {
45254549 const HOT = 9_000;
45264550 const lookupsOf = new Map<number, number>();
45274551 mockedExecute.mockImplementation((input) => {
4528 const asked = input.args.find((arg) => arg.startsWith("number="));
4529 if (asked === undefined) return Effect.succeed(output("{}"));
4530 const number = Number(asked.slice("number=".length));
4552 if (input.kind !== "graphql" || !input.query.includes(NODE_ID_QUERY)) {
4553 return Effect.succeed(output("{}"));
4554 }
4555 const number = Number(input.variables?.["number"]);
45314556 lookupsOf.set(number, (lookupsOf.get(number) ?? 0) + 1);
45324557 return Effect.succeed(
45334558 output(encodeJson({ data: { repository: { pullRequest: { id: `PR_${number}` } } } })),
apps/server/src/pullRequest/GitHubPullRequestCli.ts+814-936
......@@ -1,21 +1,19 @@
11import { removeAgentCredits } from "./mergeMessage.ts";
2import { makeChecksRevalidator } from "./gitHubConditionalChecks.ts";
2import { KnownWorkflowRuns, makeChecksRevalidator } from "./gitHubConditionalChecks.ts";
33import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts";
44import * as Cause from "effect/Cause";
55import * as Context from "effect/Context";
66import * as Clock from "effect/Clock";
7import * as Crypto from "effect/Crypto";
7import * as Duration from "effect/Duration";
88import * as Effect from "effect/Effect";
99import * as Layer from "effect/Layer";
1010import * as Exit from "effect/Exit";
1111import * as Option from "effect/Option";
12import * as Redacted from "effect/Redacted";
1312import * as Request from "effect/Request";
1413import * as RequestResolver from "effect/RequestResolver";
1514import * as Result from "effect/Result";
1615import * as Schema from "effect/Schema";
1716import * as Semaphore from "effect/Semaphore";
18import * as Hex from "effect/encoding/Hex";
1917import {
2018 resolvePullRequestAuthorFilter,
2119 PositiveInt,
......@@ -23,6 +21,8 @@ import {
2321 type PullRequestAction,
2422 type PullRequestStackHead,
2523 type PullRequestActor,
24 type PullRequestComment,
25 type PullRequestCommit,
2626 type PullRequestFileViewed,
2727 type PullRequestInvolvement,
2828 type PullRequestListFilters,
......@@ -41,19 +41,28 @@ import {
4141 type PullRequestPreview,
4242} from "@t3tools/contracts";
4343
44import * as GitHubCli from "../sourceControl/GitHubCli.ts";
45import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts";
44import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts";
45import * as GitHubApi from "../sourceControl/GitHubApi.ts";
4646import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts";
4747import {
4848 ACTOR_AVATARS_GRAPHQL_QUERY,
4949 ADD_REACTION_GRAPHQL_MUTATION,
50 buildReviewSubmissionJson,
51 buildReviewerRequestJson,
50 buildReviewSubmission,
51 buildReviewerRequest,
5252 buildSetFilesViewedGraphQlMutation,
5353 decodeActorAvatarsJson,
5454 decodePullRequestActivityJson,
55 decodePullRequestDetailJson,
55 decodePullRequestCheckContextsJson,
5656 decodePullRequestCoreJson,
57 decodeCommitFilesJson,
58 pullRequestCheckContextsGraphQlQuery,
59 pullRequestChecksFromContexts,
60 pullRequestListGraphQlQuery,
61 pullRequestSummaryGraphQlQuery,
62 PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY,
63 PULL_REQUEST_HEADS_GRAPHQL_QUERY,
64 decodeWorkflowRunsJson,
65 type GitHubCheckContext,
5766 pullRequestCoreGraphQlQuery,
5867 type GitHubPullRequestCore,
5968 type GitHubPullRequestSummary,
......@@ -73,7 +82,7 @@ import {
7382 decodeReactionSubjectScopeJson,
7483 decodeReviewerCandidatesJson,
7584 decodeLabelCandidatesJson,
76 buildLabelRequestJson,
85 buildLabelRequest,
7786 LABEL_CANDIDATES_GRAPHQL_QUERY,
7887 decodeReviewDismissalsJson,
7988 decodeReviewThreadCommentsJson,
......@@ -83,14 +92,10 @@ import {
8392 buildPullRequestWatchFingerprintsGraphQlQuery,
8493 buildPullRequestStackMembershipsGraphQlQuery,
8594 decodePullRequestStackMembershipsJson,
86 encodeGraphQlRequestJson,
8795 pullRequestSearchGraphQlQuery,
8896 PULL_REQUEST_SEARCH_MAX_ROWS,
89 PULL_REQUEST_ACTIVITY_JSON_FIELDS,
9097 BASE_COMPARISON_GRAPHQL_QUERY,
9198 decodeBaseComparisonJson,
92 PULL_REQUEST_DETAIL_JSON_FIELDS,
93 PULL_REQUEST_LIST_JSON_FIELDS,
9499 PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY,
95100 PULL_REQUEST_NODE_ID_GRAPHQL_QUERY,
96101 REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY,
......@@ -110,9 +115,10 @@ import {
110115 UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION,
111116 VIEWER_PERMISSIONS_GRAPHQL_QUERY,
112117 decodeViewerPermissionsJson,
113 decodeWorkflowRunApprovalsJson,
114118 type GitHubBaseComparison,
115119 type GitHubPullRequestActivity,
120 type GitHubPullRequestActivityPage,
121 type GitHubWorkflowRunPage,
116122 type GitHubPullRequestHead,
117123 type GitHubPullRequestListItem,
118124 type GitHubPullRequestSearchItem,
......@@ -139,12 +145,8 @@ export class GitHubPullRequestReadError extends Schema.TaggedError<GitHubPullReq
139145 cause: Schema.Defect(),
140146 },
141147) {
142 get detail(): string {
143 return `GitHub CLI returned an unreadable ${this.operation} response.`;
144 }
145
146148 override get message(): string {
147 return `GitHub CLI failed in ${this.operation}: ${this.detail}`;
149 return `GitHub CLI returned an unreadable ${this.operation} response.`;
148150 }
149151}
150152
......@@ -156,12 +158,8 @@ export class GitHubViewerLoginUnavailableError extends Schema.TaggedError<GitHub
156158 cwd: Schema.String,
157159 },
158160) {
159 get detail(): string {
160 return "GitHub CLI returned no login for the authenticated account.";
161 }
162
163161 override get message(): string {
164 return `GitHub CLI failed in getViewerLogin: ${this.detail}`;
162 return "GitHub CLI returned no login for the authenticated account.";
165163 }
166164}
167165
......@@ -175,12 +173,8 @@ export class GitHubPullRequestUpdatedAtUnavailableError extends Schema.TaggedErr
175173 number: Schema.Int,
176174 },
177175) {
178 get detail(): string {
179 return `Pull request ${this.repository}#${this.number} reported no update time.`;
180 }
181
182176 override get message(): string {
183 return `GitHub CLI failed in getPullRequestSummary: ${this.detail}`;
177 return `Pull request ${this.repository}#${this.number} reported no update time.`;
184178 }
185179}
186180
......@@ -192,12 +186,8 @@ export class GitHubDiffCursorError extends Schema.TaggedError<GitHubDiffCursorEr
192186 cwd: Schema.String,
193187 },
194188) {
195 get detail(): string {
196 return "The diff cursor was not one this pull request handed out.";
197 }
198
199189 override get message(): string {
200 return `GitHub CLI failed in getPullRequestDiff: ${this.detail}`;
190 return "The diff cursor was not one this pull request handed out.";
201191 }
202192}
203193
......@@ -209,12 +199,8 @@ export class GitHubDiffCommitError extends Schema.TaggedError<GitHubDiffCommitEr
209199 cwd: Schema.String,
210200 },
211201) {
212 get detail(): string {
213 return "The named commit was not a commit sha.";
214 }
215
216202 override get message(): string {
217 return `GitHub CLI failed in getPullRequestDiff: ${this.detail}`;
203 return "The named commit was not a commit sha.";
218204 }
219205}
220206
......@@ -228,15 +214,11 @@ export class GitHubDiffRevisionsUnavailableError extends Schema.TaggedError<GitH
228214 commit: Schema.optional(Schema.String),
229215 },
230216) {
231 get detail(): string {
217 override get message(): string {
232218 return this.commit === undefined
233219 ? `Pull request #${this.number} reported no usable base and head revisions.`
234220 : `Commit ${this.commit} reported no usable revisions for this file.`;
235221 }
236
237 override get message(): string {
238 return `GitHub CLI failed in getPullRequestDiffFileContents: ${this.detail}`;
239 }
240222}
241223
242224/** A blob exists, but expanding it would be unsafe or would not produce text. */
......@@ -249,15 +231,11 @@ export class GitHubDiffFileContentsUnavailableError extends Schema.TaggedError<G
249231 reason: Schema.Literals(["oversized", "binary"]),
250232 },
251233) {
252 get detail(): string {
234 override get message(): string {
253235 return this.reason === "oversized"
254236 ? `The diff file '${this.path}' exceeds the 1 MB expansion limit.`
255237 : `The diff file '${this.path}' is binary.`;
256238 }
257
258 override get message(): string {
259 return `GitHub CLI failed in getPullRequestDiffFileContents: ${this.detail}`;
260 }
261239}
262240
263241/**
......@@ -274,12 +252,8 @@ export class GitHubRepositorySelectorError extends Schema.TaggedError<GitHubRepo
274252 operation: Schema.String,
275253 },
276254) {
277 get detail(): string {
278 return "A repository was named that GitHub cannot address.";
279 }
280
281255 override get message(): string {
282 return `GitHub CLI failed in ${this.operation}: ${this.detail}`;
256 return "A repository was named that GitHub cannot address.";
283257 }
284258}
285259
......@@ -292,12 +266,8 @@ export class GitHubSubjectScopeError extends Schema.TaggedError<GitHubSubjectSco
292266 operation: Schema.String,
293267 },
294268) {
295 get detail(): string {
296 return "The named subject did not belong to the named pull request.";
297 }
298
299269 override get message(): string {
300 return `GitHub CLI failed in ${this.operation}: ${this.detail}`;
270 return "The named subject did not belong to the named pull request.";
301271 }
302272}
303273
......@@ -313,7 +283,7 @@ export class GitHubWorkflowApprovalRefusedError extends Schema.TaggedError<GitHu
313283 limit: Schema.Int,
314284 },
315285) {
316 get detail(): string {
286 override get message(): string {
317287 if (this.reason === "head-list-truncated") {
318288 return `GitHub returned more than ${this.limit} pull requests for this head branch.`;
319289 }
......@@ -322,10 +292,6 @@ export class GitHubWorkflowApprovalRefusedError extends Schema.TaggedError<GitHu
322292 }
323293 return `GitHub returned more than ${this.limit} workflow runs awaiting approval.`;
324294 }
325
326 override get message(): string {
327 return `GitHub CLI refused listWorkflowRunsRequiringApproval: ${this.detail}`;
328 }
329295}
330296
331297/** GitHub omitted the immutable head identity needed to scope an approval safely. */
......@@ -337,12 +303,8 @@ export class GitHubWorkflowApprovalHeadUnavailableError extends Schema.TaggedErr
337303 number: Schema.Int,
338304 },
339305) {
340 get detail(): string {
341 return `GitHub did not report a complete head revision for #${this.number}.`;
342 }
343
344306 override get message(): string {
345 return `GitHub CLI refused approve-workflows: ${this.detail}`;
307 return `GitHub did not report a complete head revision for #${this.number}.`;
346308 }
347309}
348310
......@@ -355,18 +317,14 @@ export class GitHubWorkflowApprovalHeadChangedError extends Schema.TaggedError<G
355317 number: Schema.Int,
356318 },
357319) {
358 get detail(): string {
359 return `The head revision of #${this.number} changed before its workflows could be approved.`;
360 }
361
362320 override get message(): string {
363 return `GitHub CLI refused approve-workflows: ${this.detail}`;
321 return `The head revision of #${this.number} changed before its workflows could be approved.`;
364322 }
365323}
366324
367325export type GitHubPullRequestCliError =
368326 | GitHubStackActionError
369 | GitHubCli.GitHubCliError
327 | GitHubApi.GitHubApiError
370328 | GitHubPullRequestReadError
371329 | GitHubDiffCursorError
372330 | GitHubDiffCommitError
......@@ -383,10 +341,26 @@ export type GitHubPullRequestCliError =
383341
384342/** A large pull request can produce a multi-megabyte patch; past this it is truncated. */
385343const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
386const DIFF_TIMEOUT_MS = 60_000;
344const DIFF_TIMEOUT = Duration.seconds(60);
387345/** Pierre expansion is for source files, not blobs large enough to stall a review surface. */
388346const DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024;
389347
348/** What `gh pr list --state` asked a repository's own list for, without search. */
349const LIST_STATES: Record<PullRequestListState, ReadonlyArray<"OPEN" | "CLOSED" | "MERGED">> = {
350 all: ["OPEN", "CLOSED", "MERGED"],
351 open: ["OPEN"],
352 // Closed includes merged here, the way GitHub's own list reads it; merged rows are filtered out
353 // locally by `matchesUnsortedListing`.
354 closed: ["CLOSED", "MERGED"],
355 merged: ["MERGED"],
356};
357
358interface GitHubPullRequestListPage {
359 readonly items: ReadonlyArray<GitHubPullRequestListItem>;
360 readonly rawCount: number;
361 readonly endCursor: string | null;
362}
363
390364/** A search-free fallback may scan older rows for local filters, but never the whole repository. */
391365const PULL_REQUEST_FALLBACK_MAX_ROWS = 1_000;
392366
......@@ -413,6 +387,9 @@ export const NODE_ID_CACHE_CAPACITY = 128;
413387 */
414388const REVIEW_THREAD_PAGES = 10;
415389
390/** Pages of a hundred check contexts to walk for one head before the rollup says it was cut. */
391const CHECK_CONTEXT_PAGES = 10;
392
416393export interface GitHubPullRequestListBatch {
417394 readonly items: ReadonlyArray<GitHubPullRequestListItem>;
418395 readonly truncated: boolean;
......@@ -960,50 +937,6 @@ function matchesFilters(
960937 );
961938}
962939
963function involvementArgs(input: {
964 readonly state: PullRequestListState;
965 readonly involvement: PullRequestInvolvement;
966 readonly viewer: string;
967 readonly query?: string | undefined;
968 /** Where to carry on from, which only a search can express. */
969 readonly cursor?: ProviderListCursor | undefined;
970 /**
971 * Ask GitHub for the order the page reads its rows in. False on the fallback read, which
972 * cannot use search at all and takes whatever order `gh pr list` answers in.
973 */
974 readonly sorted: boolean;
975 readonly filters?: PullRequestListFilters | undefined;
976}): ReadonlyArray<string> {
977 // `--state closed` includes merged pull requests, so the Closed tab additionally excludes
978 // them through search; `--author` and `review-requested:` are GitHub's own filters. `gh`
979 // takes one `--search`, so the reader's text joins the qualifiers rather than replacing them.
980 const query = input.query?.trim() ?? "";
981 // The fallback read exists because this repository's search index answered nothing, so it goes
982 // nowhere near search: no order, cursor or qualifiers. Its decoded rows are narrowed by state
983 // and involvement below, since widening either would put unrelated pull requests on the page.
984 const searchTerms = !input.sorted
985 ? []
986 : [
987 ...(input.involvement === "reviewing" ? [`review-requested:${input.viewer}`] : []),
988 ...(input.state === "closed" ? ["is:unmerged"] : []),
989 ...(query.length === 0 ? [] : [searchPhrase(query)]),
990 // The instant the last slice ended on, and everything before it. Inclusive, because rows
991 // sharing one instant are ordinary and the caller drops the ones it has already sent —
992 // asking for strictly older would lose the rest of them instead.
993 ...(input.cursor === undefined ? [] : [`updated:<=${input.cursor.updatedBefore}`]),
994 ...filterQualifiers(input.filters, input.viewer),
995 // `gh pr list` answers newest-created first, which is not the order the page reads rows in
996 // and not an order a continuation can carry on from: a change request opened last year and
997 // touched this morning belongs at the top of the list and at the front of the first slice.
998 // Free text would otherwise come back in best-match order, which is worse again.
999 "sort:updated-desc",
1000 ];
1001 return [
1002 ...(input.involvement === "authored" ? ["--author", input.viewer] : []),
1003 ...(searchTerms.length > 0 ? ["--search", searchTerms.join(" ")] : []),
1004 ];
1005}
1006
1007940/** The search-free fallback is wider than the request, so narrow its decoded rows locally. */
1008941function matchesUnsortedListing(
1009942 item: GitHubPullRequestListItem,
......@@ -1029,13 +962,11 @@ function matchesUnsortedListing(
1029962const SEARCH_REPOSITORY = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
1030963
1031964/**
1032 * The same listing as one GitHub search across several repositories, which is the only way to
1033 * read a whole host in one request.
965 * A listing as one GitHub search, across one repository or several: the only way to read a
966 * whole host in one request, and the only order (`updated`) a continuation can carry on from.
1034967 *
1035 * Every narrowing `involvementArgs` hands to `gh pr list` as a flag is a qualifier here instead,
1036 * because a search has no flags to borrow: `--author X` is `author:X`, `--state open` is
1037 * `is:open`, and `--state closed` — which includes merged pull requests — is `is:closed
1038 * is:unmerged`. The two belong together; a tab added to one wants adding to the other.
968 * `--state closed` in GitHub's own list includes merged pull requests, so the Closed tab is
969 * `is:closed is:unmerged` here.
1039970 *
1040971 * Null where a repository is not `owner/name`. A name is written into the query as itself, and a
1041972 * name holding a space could otherwise end the `repo:` qualifier and start a qualifier of its
......@@ -1073,15 +1004,6 @@ function searchQuery(input: {
10731004 ].join(" ");
10741005}
10751006
1076/**
1077 * The `after` a paged read carries. gh sends a JSON null only through a typed field, and an
1078 * untyped `cursor=` would send the empty string, which GitHub refuses as a cursor rather than
1079 * reading as "start at the beginning".
1080 */
1081function cursorVariable(cursor: string | null): readonly [string, string] {
1082 return cursor === null ? ["-F", "cursor=null"] : ["-f", `cursor=${cursor}`];
1083}
1084
10851007const MERGE_MESSAGE_GRAPHQL_QUERY = `
10861008query PullRequestMergeMessage($owner: String!, $name: String!, $number: Int!, $method: PullRequestMergeMethod!) {
10871009 repository(owner: $owner, name: $name) {
......@@ -1093,6 +1015,18 @@ query PullRequestMergeMessage($owner: String!, $name: String!, $number: Int!, $m
10931015 }
10941016}`;
10951017
1018/** The two revisions a file is expanded between: a pull request's base and head, or a commit's parent and itself. */
1019const decodeRevisionRefs = Schema.decodeUnknownOption(
1020 Schema.fromJsonString(
1021 Schema.Struct({
1022 sha: Schema.optional(Schema.String),
1023 parents: Schema.optional(Schema.Array(Schema.Struct({ sha: Schema.String }))),
1024 base: Schema.optional(Schema.Struct({ sha: Schema.String })),
1025 head: Schema.optional(Schema.Struct({ sha: Schema.String })),
1026 }),
1027 ),
1028);
1029
10961030const decodeMergeMessageResponse = Schema.decodeUnknownResult(
10971031 Schema.fromJsonString(
10981032 Schema.Struct({
......@@ -1111,45 +1045,93 @@ const decodeMergeMessageResponse = Schema.decodeUnknownResult(
11111045const decodeMergeMessage = (raw: string) =>
11121046 Result.map(decodeMergeMessageResponse(raw), (response) => response.data.repository.pullRequest);
11131047
1114function actionArgs(
1115 action: PullRequestAction,
1116 mergeMethod: PullRequestMergeMethod | undefined,
1117 updateMethod: PullRequestUpdateMethod | undefined,
1118): ReadonlyArray<string> {
1119 switch (action) {
1120 case "merge":
1121 return ["merge", `--${mergeMethod ?? "merge"}`];
1122 // `--auto` arms the same command instead of running it, and still needs the strategy: GitHub
1123 // stores the strategy with the standing instruction rather than choosing one at merge time.
1124 case "enable-auto-merge":
1125 return ["merge", "--auto", `--${mergeMethod ?? "merge"}`];
1126 case "disable-auto-merge":
1127 return ["merge", "--disable-auto"];
1128 // `gh` updates with a merge commit unless asked to rebase, which is GitHub's own default.
1129 case "update-branch":
1130 return ["update-branch", ...(updateMethod === "rebase" ? ["--rebase"] : [])];
1131 case "ready":
1132 return ["ready"];
1133 case "draft":
1134 return ["ready", "--undo"];
1135 case "close":
1136 return ["close"];
1137 case "reopen":
1138 return ["reopen"];
1139 case "revert":
1140 throw new Error("Revert requires a GraphQL mutation");
1141 // Handled separately because it may approve several workflow runs rather than mutate the
1142 // pull request itself.
1143 case "approve-workflows":
1144 throw new Error("Workflow approval requires run discovery");
1048/** What `gh pr merge` and `gh pr update-branch` read before they act. */
1049const ACTION_STATE_GRAPHQL_QUERY = `
1050query PullRequestActionState($owner: String!, $name: String!, $number: Int!, $headRef: String!) {
1051 repository(owner: $owner, name: $name) {
1052 pullRequest(number: $number) {
1053 id
1054 headRefOid
1055 isMergeQueueEnabled
1056 mergeStateStatus
1057 baseRef { compare(headRef: $headRef) { behindBy } }
1058 }
11451059 }
1146}
1060}`;
1061
1062const decodeActionStateResponse = Schema.decodeUnknownResult(
1063 Schema.fromJsonString(
1064 Schema.Struct({
1065 data: Schema.Struct({
1066 repository: Schema.Struct({
1067 pullRequest: Schema.Struct({
1068 id: Schema.String,
1069 headRefOid: Schema.String,
1070 isMergeQueueEnabled: Schema.optional(Schema.Boolean),
1071 mergeStateStatus: Schema.optional(Schema.NullOr(Schema.String)),
1072 baseRef: Schema.optional(
1073 Schema.NullOr(
1074 Schema.Struct({ compare: Schema.NullOr(Schema.Struct({ behindBy: Schema.Int })) }),
1075 ),
1076 ),
1077 }),
1078 }),
1079 }),
1080 }),
1081 ),
1082);
1083const decodeActionState = (raw: string) =>
1084 Result.map(decodeActionStateResponse(raw), (response) => response.data.repository.pullRequest);
1085
1086const MERGE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($input: MergePullRequestInput!) {
1087 mergePullRequest(input: $input) { clientMutationId }
1088}`;
1089const ENABLE_AUTO_MERGE_GRAPHQL_MUTATION = `mutation($input: EnablePullRequestAutoMergeInput!) {
1090 enablePullRequestAutoMerge(input: $input) { clientMutationId }
1091}`;
1092const DISABLE_AUTO_MERGE_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) {
1093 disablePullRequestAutoMerge(input: { pullRequestId: $pullRequestId }) { clientMutationId }
1094}`;
1095const UPDATE_BRANCH_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!, $expectedHeadOid: GitObjectID!, $updateMethod: PullRequestBranchUpdateMethod!) {
1096 updatePullRequestBranch(input: { pullRequestId: $pullRequestId, expectedHeadOid: $expectedHeadOid, updateMethod: $updateMethod }) { clientMutationId }
1097}`;
1098const READY_FOR_REVIEW_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) {
1099 markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) { clientMutationId }
1100}`;
1101const CONVERT_TO_DRAFT_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) {
1102 convertPullRequestToDraft(input: { pullRequestId: $pullRequestId }) { clientMutationId }
1103}`;
1104const CLOSE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) {
1105 closePullRequest(input: { pullRequestId: $pullRequestId }) { clientMutationId }
1106}`;
1107const REOPEN_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) {
1108 reopenPullRequest(input: { pullRequestId: $pullRequestId }) { clientMutationId }
1109}`;
1110const ADD_COMMENT_GRAPHQL_MUTATION = `mutation($subjectId: ID!, $body: String!) {
1111 addComment(input: { subjectId: $subjectId, body: $body }) { clientMutationId }
1112}`;
1113
1114const GRAPHQL_MERGE_METHODS = {
1115 merge: "MERGE",
1116 squash: "SQUASH",
1117 rebase: "REBASE",
1118} as const satisfies Record<PullRequestMergeMethod, string>;
1119
1120/** States in which `gh pr merge --auto` merges at once instead of arming auto-merge. */
1121const IMMEDIATELY_MERGEABLE = new Set(["CLEAN", "HAS_HOOKS", "UNSTABLE"]);
1122
1123/** The mutations that only need the pull request's node id. */
1124const SIMPLE_ACTION_MUTATIONS = {
1125 "disable-auto-merge": DISABLE_AUTO_MERGE_GRAPHQL_MUTATION,
1126 ready: READY_FOR_REVIEW_GRAPHQL_MUTATION,
1127 draft: CONVERT_TO_DRAFT_GRAPHQL_MUTATION,
1128 close: CLOSE_PULL_REQUEST_GRAPHQL_MUTATION,
1129 reopen: REOPEN_PULL_REQUEST_GRAPHQL_MUTATION,
1130} as const satisfies Partial<Record<PullRequestAction, string>>;
11471131
11481132/** @public Service construction is part of the canonical Effect module API. */
11491133export const make = Effect.gen(function* () {
1150 const github = yield* GitHubCli.GitHubCli;
1151 const graphQlBudget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;
1152 const crypto = yield* Crypto.Crypto;
1134 const api = yield* GitHubApi.GitHubApi;
11531135 const revalidateChecks = yield* makeChecksRevalidator;
11541136 const routingIdentities = new Map<
11551137 string,
......@@ -1172,25 +1154,9 @@ export const make = Effect.gen(function* () {
11721154 const unavailable = () =>
11731155 new GitHubViewerLoginUnavailableError({ command: "gh", cwd: input.cwd });
11741156 const host = input.host.toLowerCase();
1175 const pinned = yield* GitHubCli.PinnedGitHubCredential;
1176 if (pinned !== null && pinned.host !== host) return yield* unavailable();
1177 // Only the digest is retained. Never attach credential lookup output to an error.
1178 const token =
1179 pinned !== null
1180 ? Redacted.value(pinned.token)
1181 : (yield* github
1182 .execute({
1183 cwd: input.cwd,
1184 args: ["auth", "token", "--hostname", host],
1185 env: { GH_DEBUG: "" },
1186 })
1187 .pipe(Effect.mapError(unavailable))).stdout.trim();
1188 if (!token) return yield* unavailable();
1189 const tokenHash = yield* crypto
1190 .digest("SHA-256", new TextEncoder().encode(token))
1191 .pipe(Effect.map(Hex.encode), Effect.orDie);
1192 const key = `${host}:${tokenHash}`;
1193 const credential = { host, token: Redacted.make(token), credentialFingerprint: key };
1157 // A missing or signed-out credential keeps its own error, so the page can say which.
1158 const { token, fingerprint: key } = yield* api.credential(host);
1159 const credential = { host, token, credentialFingerprint: key };
11941160 // A cold page may ask several times. Wait per credential and check again after the
11951161 // first verification; cancellation releases the next waiter without losing its request.
11961162 return yield* Effect.acquireUseRelease(
......@@ -1208,21 +1174,13 @@ export const make = Effect.gen(function* () {
12081174 if (cached !== undefined && now - cached.at < 10 * 60_000)
12091175 return { ...credential, ...cached.value };
12101176 // Pin this read so an auth switch cannot poison its cache entry.
1211 const response = yield* github
1212 .execute({
1213 cwd: input.cwd,
1214 args: ["api", "user", "--hostname", host],
1215 env: {
1216 GH_HOST: host,
1217 GH_TOKEN: token,
1218 GITHUB_TOKEN: token,
1219 GH_ENTERPRISE_TOKEN: token,
1220 GITHUB_ENTERPRISE_TOKEN: token,
1221 GH_DEBUG: "",
1222 },
1223 })
1224 .pipe(Effect.mapError(unavailable));
1225 const identity = yield* decodeRoutingIdentity(response.stdout).pipe(
1177 const response = yield* api
1178 .rest({ host, operation: "getRoutingIdentity", path: "user", allowReserve: true })
1179 .pipe(
1180 Effect.provideService(GitHubApi.PinnedGitHubCredential, credential),
1181 Effect.provideService(SourceControlRateLimit.CredentialScope, key),
1182 );
1183 const identity = yield* decodeRoutingIdentity(response.body).pipe(
12261184 Effect.mapError(unavailable),
12271185 );
12281186 const value = { accountId: String(identity.id), viewer: identity.login };
......@@ -1248,7 +1206,7 @@ export const make = Effect.gen(function* () {
12481206 Effect.flatMap(({ host, token, accountId, viewer, credentialFingerprint }) =>
12491207 use({ accountId, viewer, credentialFingerprint }).pipe(
12501208 Effect.provideService(SourceControlRateLimit.CredentialScope, credentialFingerprint),
1251 Effect.provideService(GitHubCli.PinnedGitHubCredential, {
1209 Effect.provideService(GitHubApi.PinnedGitHubCredential, {
12521210 host,
12531211 token,
12541212 credentialFingerprint,
......@@ -1293,11 +1251,7 @@ export const make = Effect.gen(function* () {
12931251 host: input.host,
12941252 operation: input.operation,
12951253 allowReserve: true,
1296 variables: [
1297 ["-f", `owner=${owner}`],
1298 ["-f", `name=${name}`],
1299 ["-F", `number=${input.number}`],
1300 ],
1254 variables: { owner, name, number: input.number },
13011255 query: PULL_REQUEST_NODE_ID_GRAPHQL_QUERY,
13021256 decode: decodePullRequestNodeIdJson,
13031257 }).pipe(
......@@ -1333,46 +1287,35 @@ export const make = Effect.gen(function* () {
13331287 host: input.host,
13341288 operation: input.operation,
13351289 allowReserve: true,
1336 variables: [
1337 ["-f", `owner=${owner}`],
1338 ["-f", `name=${name}`],
1339 ["-F", `number=${input.number}`],
1340 ["-f", `subjectId=${input.subjectId}`],
1341 ],
1290 variables: { owner, name, number: input.number, subjectId: input.subjectId },
13421291 query: REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY,
13431292 decode: decodeReactionSubjectScopeJson,
13441293 });
13451294 };
13461295
1347 // `gh` resolves a bare `owner/repo` against whichever host it defaults to, which is
1348 // github.com. Naming the host makes a GitHub Enterprise repository resolve to its own
1349 // install rather than to a same-named repository on github.com.
1350 const repositoryArgs = (input: { readonly host: string; readonly repository: string }) => [
1351 "--repo",
1352 `${input.host}/${input.repository}`,
1353 ];
1354
1355 /**
1356 * A GraphQL mutation whose answer is not read back. `gh` exits non-zero on a GraphQL error,
1357 * so a failed mutation is already a failed command rather than a body to inspect.
1358 *
1359 * The query and its variables travel over stdin as one document: a variable can carry a
1360 * body the reader wrote, and argv is visible in process listings and echoed back inside
1361 * process-runner failure messages.
1362 */
1296 /** A GraphQL mutation whose answer is not read back; a GraphQL error fails it. */
13631297 const graphql = (input: {
1364 readonly cwd: string;
13651298 readonly host: string;
1299 readonly operation: string;
13661300 readonly query: string;
1367 readonly variables: Readonly<Record<string, string>>;
1368 }) =>
1369 github
1370 .execute({
1371 cwd: input.cwd,
1372 args: ["api", "graphql", "--hostname", input.host, "--input", "-"],
1373 stdin: encodeGraphQlRequestJson({ query: input.query, variables: input.variables }),
1374 })
1375 .pipe(Effect.asVoid);
1301 readonly variables: Readonly<Record<string, unknown>>;
1302 }) => api.graphql(input).pipe(Effect.asVoid);
1303
1304 const readError = (cwd: string, operation: string, cause: unknown) =>
1305 new GitHubPullRequestReadError({ command: "gh", cwd, operation, cause });
1306
1307 /** Decodes an answer, reporting a failure against the read that made it. */
1308 const decodeWith = <A>(
1309 cwd: string,
1310 operation: string,
1311 decode: (raw: string) => Result.Result<A, unknown>,
1312 raw: string,
1313 ): Effect.Effect<A, GitHubPullRequestReadError> => {
1314 const decoded = decode(raw.trim());
1315 return Result.isSuccess(decoded)
1316 ? Effect.succeed(decoded.success)
1317 : Effect.fail(readError(cwd, operation, decoded.failure));
1318 };
13761319
13771320 /** A GraphQL read whose answer is decoded, reporting a failure against the read that made it. */
13781321 const graphqlRead = <A>(input: {
......@@ -1380,75 +1323,45 @@ export const make = Effect.gen(function* () {
13801323 readonly host: string;
13811324 readonly operation: string;
13821325 readonly allowReserve?: boolean | undefined;
1383 /** Variables as `-f` flags, for values this module composed itself. */
1384 readonly variables?: ReadonlyArray<readonly [string, string]>;
1385 /**
1386 * Variables carrying words the reader typed. Document and variables travel over stdin
1387 * together, because argv is visible in process listings and is echoed back inside a
1388 * process-runner failure message.
1389 */
1390 readonly privateVariables?: Readonly<Record<string, string>>;
1326 readonly variables?: Readonly<Record<string, unknown>>;
13911327 readonly query: string;
13921328 readonly decode: (raw: string) => Result.Result<A, unknown>;
1393 }): Effect.Effect<A, GitHubPullRequestCliError> => {
1394 return graphQlBudget
1395 .query(
1396 input.host,
1397 input.query,
1398 input.allowReserve === true ? { allowReserve: true } : undefined,
1399 )
1329 }): Effect.Effect<A, GitHubPullRequestCliError> =>
1330 api
1331 .graphql({
1332 host: input.host,
1333 operation: input.operation,
1334 query: input.query,
1335 ...(input.variables === undefined ? {} : { variables: input.variables }),
1336 ...(input.allowReserve === true ? { allowReserve: true } : {}),
1337 })
1338 .pipe(Effect.flatMap((raw) => decodeWith(input.cwd, input.operation, input.decode, raw)));
1339
1340 /** A REST read whose JSON answer is decoded the same way. */
1341 const restRead = <A>(input: {
1342 readonly cwd: string;
1343 readonly host: string;
1344 readonly operation: string;
1345 readonly path: string;
1346 readonly decode: (raw: string) => Result.Result<A, unknown>;
1347 }): Effect.Effect<A, GitHubPullRequestCliError> =>
1348 api
1349 .rest({ host: input.host, operation: input.operation, path: input.path })
14001350 .pipe(
1401 Effect.flatMap((query) =>
1402 github.execute(
1403 input.privateVariables === undefined
1404 ? {
1405 cwd: input.cwd,
1406 args: [
1407 "api",
1408 "graphql",
1409 "--hostname",
1410 input.host,
1411 ...(input.variables ?? []).flat(),
1412 "-f",
1413 `query=${query}`,
1414 ],
1415 }
1416 : {
1417 cwd: input.cwd,
1418 args: ["api", "graphql", "--hostname", input.host, "--input", "-"],
1419 stdin: encodeGraphQlRequestJson({
1420 query,
1421 variables: input.privateVariables,
1422 }),
1423 },
1424 ),
1351 Effect.flatMap((response) =>
1352 decodeWith(input.cwd, input.operation, input.decode, response.body),
14251353 ),
1426 Effect.tap((result) => graphQlBudget.observe(input.host, result.stdout)),
1427 Effect.flatMap((result) => {
1428 const decoded = input.decode(result.stdout.trim());
1429 return Result.isSuccess(decoded)
1430 ? Effect.succeed(decoded.success)
1431 : Effect.fail(
1432 new GitHubPullRequestReadError({
1433 command: "gh",
1434 cwd: input.cwd,
1435 operation: input.operation,
1436 cause: decoded.failure,
1437 }),
1438 );
1439 }),
14401354 );
1441 };
14421355
14431356 /**
1444 * One page of the patch, read from the files API. GitHub refuses `pr diff` outright past 300
1445 * changed files, and still serves those files' hunks here.
1357 * One page of the patch, read from the files API. GitHub refuses a whole diff past 300 changed
1358 * files, and still serves those files' hunks here.
14461359 *
14471360 * A page is a whole number of files, so each one parses on its own; the caller carries on from
14481361 * `nextCursor` for as long as GitHub keeps handing pages back.
14491362 *
14501363 * A named commit is read from the commit endpoint, which lists the same file entries and pages
1451 * them the same way — only wrapped in an object, which jq unwraps before they are decoded.
1364 * them the same way, only wrapped in an object.
14521365 */
14531366 const diffFilesPage = (input: {
14541367 readonly cwd: string;
......@@ -1460,61 +1373,51 @@ export const make = Effect.gen(function* () {
14601373 }): Effect.Effect<GitHubPullRequestDiffSlice, GitHubPullRequestCliError> => {
14611374 const { owner, name } = parseRepositorySelector(input.repository);
14621375 const paging = `per_page=${DIFF_FILES_PAGE_SIZE}&page=${input.page}`;
1463 return github
1464 .execute({
1465 cwd: input.cwd,
1466 args: [
1467 "api",
1468 "--hostname",
1469 input.host,
1376 return api
1377 .rest({
1378 host: input.host,
1379 operation: "getPullRequestDiff",
1380 path:
14701381 input.commit === undefined
14711382 ? `repos/${owner}/${name}/pulls/${input.number}/files?${paging}`
14721383 : `repos/${owner}/${name}/commits/${input.commit}?${paging}`,
1473 // An empty commit carries no `files` at all, which is a commit with nothing in it
1474 // rather than an answer that could not be read.
1475 ...(input.commit === undefined ? [] : ["--jq", ".files // []"]),
1476 ],
1477 maxOutputBytes: DIFF_MAX_OUTPUT_BYTES,
1478 timeoutMs: DIFF_TIMEOUT_MS,
1384 maxResponseBytes: DIFF_MAX_OUTPUT_BYTES,
1385 timeout: DIFF_TIMEOUT,
14791386 })
14801387 .pipe(
1481 Effect.flatMap((result) => {
1388 Effect.flatMap((response) => {
14821389 // Checked before decoding: a byte-truncated response is a JSON prefix, which would
14831390 // fail to parse. Nothing of this page can be shown, and an empty patch would render
14841391 // as a change with no files rather than as the failure it is; slices already handed
14851392 // over stay with the reader either way.
1486 if (result.stdoutTruncated) {
1393 if (response.truncated) {
14871394 return Effect.fail(
1488 new GitHubPullRequestReadError({
1489 command: "gh",
1490 cwd: input.cwd,
1491 operation: "getPullRequestDiff",
1492 cause: new Error(`Page ${input.page} of the changed files was too large to read.`),
1493 }),
1494 );
1495 }
1496 const decoded = decodePullRequestFilesJson(result.stdout.trim());
1497 if (!Result.isSuccess(decoded)) {
1498 return Effect.fail(
1499 new GitHubPullRequestReadError({
1500 command: "gh",
1501 cwd: input.cwd,
1502 operation: "getPullRequestDiff",
1503 cause: decoded.failure,
1504 }),
1395 readError(
1396 input.cwd,
1397 "getPullRequestDiff",
1398 new Error(`Page ${input.page} of the changed files was too large to read.`),
1399 ),
15051400 );
15061401 }
1402 return decodeWith(
1403 input.cwd,
1404 "getPullRequestDiff",
1405 input.commit === undefined ? decodePullRequestFilesJson : decodeCommitFilesJson,
1406 response.body,
1407 );
1408 }),
1409 Effect.map((files) => {
15071410 // Counted before decoding, so a page whose files all failed to decode still moves on
15081411 // rather than pointing the reader back at the page it just read.
1509 const morePages = decoded.success.rawCount >= DIFF_FILES_PAGE_SIZE;
1510 return Effect.succeed({
1511 patch: decoded.success.patch,
1512 truncated: decoded.success.truncated,
1412 const morePages = files.rawCount >= DIFF_FILES_PAGE_SIZE;
1413 return {
1414 patch: files.patch,
1415 truncated: files.truncated,
15131416 nextCursor: morePages ? String(input.page + 1) : null,
1514 ...(decoded.success.omittedFileStats.length === 0
1417 ...(files.omittedFileStats.length === 0
15151418 ? {}
1516 : { omittedFileStats: decoded.success.omittedFileStats }),
1517 });
1419 : { omittedFileStats: files.omittedFileStats }),
1420 };
15181421 }),
15191422 );
15201423 };
......@@ -1526,33 +1429,32 @@ export const make = Effect.gen(function* () {
15261429 return yield* new GitHubDiffCommitError({ command: "gh", cwd: input.cwd });
15271430 }
15281431 const { owner, name } = parseRepositorySelector(input.repository);
1529 const refsResult = yield* github.execute({
1530 cwd: input.cwd,
1531 args: [
1532 "api",
1533 "--hostname",
1534 input.host,
1432 const refsResponse = yield* api.rest({
1433 host: input.host,
1434 operation: "getPullRequestDiffFileContents",
1435 path:
15351436 input.commit === undefined
15361437 ? `repos/${owner}/${name}/pulls/${input.number}`
15371438 : `repos/${owner}/${name}/commits/${input.commit}`,
1538 "--jq",
1539 input.commit === undefined
1540 ? "[.base.sha, .head.sha] | @tsv"
1541 : "[.parents[0].sha, .sha] | @tsv",
1542 ],
1543 maxOutputBytes: 1024,
1544 timeoutMs: DIFF_TIMEOUT_MS,
1439 timeout: DIFF_TIMEOUT,
15451440 });
1546 // Keep a leading tab: a root commit has no parent, and jq represents that absent old
1547 // revision as the empty field before the tab. Every file in it is new, so that is a
1548 // usable answer whenever the caller does not need the old side.
1549 const [baseRef, headRef, ...extraRefs] = refsResult.stdout.trimEnd().split("\t");
1441 const refs = decodeRevisionRefs(refsResponse.body);
1442 // A root commit has no parent, which is an absent old revision. Every file in it is new,
1443 // so that is a usable answer whenever the caller does not need the old side.
1444 const baseRef = Option.isSome(refs)
1445 ? input.commit === undefined
1446 ? refs.value.base?.sha
1447 : (refs.value.parents?.[0]?.sha ?? "")
1448 : undefined;
1449 const headRef = Option.isSome(refs)
1450 ? input.commit === undefined
1451 ? refs.value.head?.sha
1452 : refs.value.sha
1453 : undefined;
15501454 const rootCommitNewFile =
15511455 input.commit !== undefined && input.changeType === "new" && baseRef === "";
15521456 if (
1553 refsResult.stdoutTruncated ||
1554 !headRef ||
1555 extraRefs.length > 0 ||
1457 headRef === undefined ||
15561458 (!rootCommitNewFile && (baseRef === undefined || !isCommitSha(baseRef))) ||
15571459 !isCommitSha(headRef)
15581460 ) {
......@@ -1565,43 +1467,38 @@ export const make = Effect.gen(function* () {
15651467 }
15661468
15671469 const readFile = (revision: string, filePath: string) =>
1568 github
1569 .execute({
1570 cwd: input.cwd,
1571 args: [
1572 "api",
1573 "--hostname",
1574 input.host,
1575 "--header",
1576 "Accept: application/vnd.github.raw+json",
1577 `repos/${owner}/${name}/contents/${filePath
1578 .split("/")
1579 .map(encodeURIComponent)
1580 .join("/")}?ref=${encodeURIComponent(revision)}`,
1581 ],
1582 maxOutputBytes: DIFF_FILE_MAX_OUTPUT_BYTES,
1583 timeoutMs: DIFF_TIMEOUT_MS,
1470 api
1471 .rest({
1472 host: input.host,
1473 operation: "getPullRequestDiffFileContents",
1474 accept: "application/vnd.github.raw+json",
1475 path: `repos/${owner}/${name}/contents/${filePath
1476 .split("/")
1477 .map(encodeURIComponent)
1478 .join("/")}?ref=${encodeURIComponent(revision)}`,
1479 maxResponseBytes: DIFF_FILE_MAX_OUTPUT_BYTES,
1480 timeout: DIFF_TIMEOUT,
15841481 })
15851482 .pipe(
1586 Effect.flatMap((result) =>
1587 result.stdoutTruncated ||
1588 result.stdout.includes("\0") ||
1589 result.stdoutInvalidUtf8 === true
1483 Effect.flatMap((response) =>
1484 response.truncated || response.body.includes("\0") || response.invalidUtf8
15901485 ? Effect.fail(
15911486 new GitHubDiffFileContentsUnavailableError({
15921487 command: "gh",
15931488 cwd: input.cwd,
15941489 path: filePath,
1595 reason: result.stdoutTruncated ? "oversized" : "binary",
1490 reason: response.truncated ? "oversized" : "binary",
15961491 }),
15971492 )
1598 : Effect.succeed(result.stdout),
1493 : Effect.succeed(response.body),
15991494 ),
16001495 );
16011496
16021497 const [oldContents, newContents] = yield* Effect.all(
16031498 [
1604 input.changeType === "new" ? Effect.succeed("") : readFile(baseRef, input.oldPath),
1499 input.changeType === "new"
1500 ? Effect.succeed("")
1501 : readFile(baseRef ?? "", input.oldPath),
16051502 input.changeType === "deleted" ? Effect.succeed("") : readFile(headRef, input.newPath),
16061503 ],
16071504 { concurrency: 2 },
......@@ -1609,262 +1506,266 @@ export const make = Effect.gen(function* () {
16091506 return { oldContents, newContents };
16101507 });
16111508
1612 const readLegacyDetail = (
1509 /**
1510 * Every check context of the head commit, a page at a time, for a rollup the detail read cut at
1511 * its first hundred. Each page names the head it read, so a push mid-walk fails rather than
1512 * mixing two revisions' checks.
1513 */
1514 const readAllCheckContexts = (
16131515 input: Parameters<GitHubPullRequestCli["Service"]["getPullRequestDetail"]>[0],
1516 allowReserve: boolean,
16141517 ) =>
1615 github
1616 .execute({
1617 cwd: input.cwd,
1618 args: [
1619 "pr",
1620 "view",
1621 String(input.number),
1622 ...repositoryArgs(input),
1623 "--json",
1624 PULL_REQUEST_DETAIL_JSON_FIELDS,
1625 ],
1626 })
1627 .pipe(
1628 Effect.flatMap((result) => {
1629 const decoded = decodePullRequestDetailJson(result.stdout.trim());
1630 return Result.isSuccess(decoded)
1631 ? Effect.succeed(decoded.success)
1632 : Effect.fail(
1633 new GitHubPullRequestReadError({
1634 command: "gh",
1635 cwd: input.cwd,
1636 operation: "getPullRequestDetail",
1637 cause: decoded.failure,
1638 }),
1639 );
1640 }),
1641 );
1518 Effect.gen(function* () {
1519 const { owner, name } = parseRepositorySelector(input.repository);
1520 const contexts: GitHubCheckContext[] = [];
1521 let headSha: string | null = null;
1522 let after: string | null = null;
1523 for (let page = 0; page < CHECK_CONTEXT_PAGES; page++) {
1524 const read: {
1525 readonly headSha: string;
1526 readonly contexts: ReadonlyArray<GitHubCheckContext>;
1527 readonly nextCursor: string | null;
1528 } = yield* graphqlRead({
1529 cwd: input.cwd,
1530 host: input.host,
1531 operation: "getPullRequestDetail",
1532 allowReserve,
1533 variables: { owner, name, number: input.number, after },
1534 query: pullRequestCheckContextsGraphQlQuery(input.host),
1535 decode: decodePullRequestCheckContextsJson,
1536 });
1537 if (headSha !== null && read.headSha !== headSha) {
1538 return yield* readError(
1539 input.cwd,
1540 "getPullRequestDetail",
1541 new Error("Pull request head changed while reading checks."),
1542 );
1543 }
1544 headSha = read.headSha;
1545 contexts.push(...read.contexts);
1546 after = read.nextCursor;
1547 if (after === null) break;
1548 }
1549 return { headSha, contexts, truncated: after !== null };
1550 });
16421551
16431552 const getPullRequestDetail: GitHubPullRequestCli["Service"]["getPullRequestDetail"] = (input) => {
16441553 const { owner, name } = parseRepositorySelector(input.repository);
1645 return GitHubCli.AllowGitHubReserve.pipe(
1554 return AllowGitHubReserve.pipe(
16461555 Effect.flatMap((allowReserve) =>
16471556 graphqlRead({
16481557 allowReserve,
16491558 cwd: input.cwd,
16501559 host: input.host,
16511560 operation: "getPullRequestDetail",
1652 variables: [
1653 ["-f", `owner=${owner}`],
1654 ["-f", `name=${name}`],
1655 ["-F", `number=${input.number}`],
1656 ["-f", `headRef=refs/pull/${input.number}/head`],
1657 ],
1561 variables: {
1562 owner,
1563 name,
1564 number: input.number,
1565 headRef: `refs/pull/${input.number}/head`,
1566 },
16581567 query: pullRequestCoreGraphQlQuery(input.host),
16591568 decode: decodePullRequestCoreJson,
1660 }),
1661 ),
1662 // gh already pages check contexts. Keep its complete, deduplicated result for
1663 // large check suites instead of letting the first 100 checks imply success.
1664 Effect.filterOrElse(
1665 (core) => !core.checksTruncated,
1666 (core) =>
1667 readLegacyDetail(input).pipe(
1668 Effect.filterOrFail(
1669 (detail) => detail.headSha === core.headSha,
1670 () =>
1671 new GitHubPullRequestReadError({
1672 command: "gh",
1673 cwd: input.cwd,
1674 operation: "getPullRequestDetail",
1675 cause: new Error("Pull request head changed while reading checks."),
1676 }),
1677 ),
1678 Effect.map((detail) => ({
1679 ...core,
1680 checks: detail.checks,
1681 checksState: detail.checksState,
1682 checksTruncated: false,
1683 })),
1569 }).pipe(
1570 // Past a hundred checks the first page would let the rest imply success, so the whole
1571 // rollup is walked instead.
1572 Effect.filterOrElse(
1573 (core) => !core.checksTruncated,
1574 (core) =>
1575 readAllCheckContexts(input, allowReserve).pipe(
1576 Effect.filterOrFail(
1577 (all) => all.headSha === core.headSha,
1578 () =>
1579 readError(
1580 input.cwd,
1581 "getPullRequestDetail",
1582 new Error("Pull request head changed while reading checks."),
1583 ),
1584 ),
1585 Effect.map((all) => ({
1586 ...core,
1587 ...pullRequestChecksFromContexts(all.contexts),
1588 checksTruncated: all.truncated,
1589 })),
1590 ),
16841591 ),
1592 ),
16851593 ),
16861594 );
16871595 };
16881596
16891597 const workflowApprovalLimit = 1_000;
1690 const workflowApprovalProbeLimit = String(workflowApprovalLimit + 1);
16911598 const workflowApprovalReadError = (cwd: string, cause: unknown) =>
1692 new GitHubPullRequestReadError({
1693 command: "gh",
1694 cwd,
1695 operation: "listWorkflowRunsRequiringApproval",
1696 cause,
1599 readError(cwd, "listWorkflowRunsRequiringApproval", cause);
1600
1601 /** Every open pull request whose head branch carries this name, up to one past the limit. */
1602 const listHeadsByBranch = (input: {
1603 readonly cwd: string;
1604 readonly repository: string;
1605 readonly host: string;
1606 readonly headBranch: string;
1607 }) =>
1608 Effect.gen(function* () {
1609 const { owner, name } = parseRepositorySelector(input.repository);
1610 const heads: GitHubPullRequestHead[] = [];
1611 let after: string | null = null;
1612 do {
1613 const page: {
1614 readonly heads: ReadonlyArray<GitHubPullRequestHead>;
1615 readonly nextCursor: string | null;
1616 } = yield* graphqlRead({
1617 cwd: input.cwd,
1618 host: input.host,
1619 operation: "listWorkflowRunsRequiringApproval",
1620 allowReserve: true,
1621 variables: { owner, name, head: input.headBranch, after },
1622 query: PULL_REQUEST_HEADS_GRAPHQL_QUERY,
1623 decode: decodePullRequestHeadsJson,
1624 });
1625 heads.push(...page.heads);
1626 after = page.nextCursor;
1627 } while (after !== null && heads.length <= workflowApprovalLimit);
1628 return heads;
1629 });
1630
1631 /**
1632 * The runs waiting on a maintainer for this exact head, up to one past the limit. `head_sha` is
1633 * what scopes them; `head_branch` is checked as well, the way `gh run list --branch` did.
1634 */
1635 const listActionRequiredRuns = (input: {
1636 readonly cwd: string;
1637 readonly repository: string;
1638 readonly host: string;
1639 readonly headSha: string;
1640 readonly headBranch: string;
1641 }) =>
1642 Effect.gen(function* () {
1643 // The checks revalidator may have just confirmed every run of this head as current.
1644 const known = yield* KnownWorkflowRuns;
1645 if (known !== null && known.headSha === input.headSha) {
1646 return known.runs.flatMap((run) =>
1647 // A run waiting on a maintainer reports `completed` with an `action_required`
1648 // conclusion; the `status=action_required` query matches either.
1649 (run.conclusion === "action_required" || run.status === "action_required") &&
1650 run.head_branch === input.headBranch
1651 ? [
1652 {
1653 id: run.id,
1654 name: run.name?.trim() || `Workflow run ${run.id}`,
1655 url: run.html_url?.trim() || null,
1656 },
1657 ]
1658 : [],
1659 );
1660 }
1661 const { owner, name } = parseRepositorySelector(input.repository);
1662 const runs: GitHubWorkflowRunApproval[] = [];
1663 for (let page = 1; runs.length <= workflowApprovalLimit; page++) {
1664 const read: GitHubWorkflowRunPage = yield* restRead({
1665 cwd: input.cwd,
1666 host: input.host,
1667 operation: "listWorkflowRunsRequiringApproval",
1668 path: `repos/${owner}/${name}/actions/runs?head_sha=${encodeURIComponent(input.headSha)}&branch=${encodeURIComponent(input.headBranch)}&event=pull_request&status=action_required&per_page=100&page=${page}`,
1669 decode: decodeWorkflowRunsJson,
1670 }).pipe(Effect.mapError((error) => workflowApprovalReadError(input.cwd, error)));
1671 runs.push(...read.runs);
1672 if (read.rawCount < 100) break;
1673 }
1674 return runs;
16971675 });
1676
16981677 const listWorkflowRunsRequiringApproval: GitHubPullRequestCli["Service"]["listWorkflowRunsRequiringApproval"] =
16991678 (input) =>
17001679 Effect.all(
17011680 [
1702 github
1703 .execute({
1704 cwd: input.cwd,
1705 args: [
1706 "pr",
1707 "list",
1708 ...repositoryArgs(input),
1709 "--state",
1710 "open",
1711 "--head",
1712 input.headBranch,
1713 "--limit",
1714 workflowApprovalProbeLimit,
1715 "--json",
1716 "number,headRefOid,isCrossRepository,headRepositoryOwner",
1717 ],
1718 })
1719 .pipe(
1720 Effect.flatMap(
1721 (
1722 result,
1723 ): Effect.Effect<
1724 GitHubPullRequestHead,
1725 GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError
1726 > => {
1727 const decoded = decodePullRequestHeadsJson(result.stdout.trim());
1728 if (!Result.isSuccess(decoded)) {
1729 return Effect.fail(workflowApprovalReadError(input.cwd, decoded.failure));
1730 }
1731 const exactHeads = decoded.success.filter(
1732 (pullRequest) =>
1733 pullRequest.headSha === input.headSha &&
1734 pullRequest.isCrossRepository === true &&
1735 pullRequest.headRepositoryOwner?.toLowerCase() ===
1736 input.headRepositoryOwner.toLowerCase(),
1681 listHeadsByBranch(input).pipe(
1682 Effect.flatMap(
1683 (
1684 heads,
1685 ): Effect.Effect<
1686 GitHubPullRequestHead,
1687 GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError
1688 > => {
1689 const exactHeads = heads.filter(
1690 (pullRequest) =>
1691 pullRequest.headSha === input.headSha &&
1692 pullRequest.isCrossRepository === true &&
1693 pullRequest.headRepositoryOwner?.toLowerCase() ===
1694 input.headRepositoryOwner.toLowerCase(),
1695 );
1696 if (heads.length > workflowApprovalLimit) {
1697 return Effect.fail(
1698 new GitHubWorkflowApprovalRefusedError({
1699 command: "gh",
1700 cwd: input.cwd,
1701 number: input.number,
1702 reason: "head-list-truncated",
1703 observedCount: heads.length,
1704 limit: workflowApprovalLimit,
1705 }),
17371706 );
1738 if (decoded.success.length > workflowApprovalLimit) {
1739 return Effect.fail(
1740 new GitHubWorkflowApprovalRefusedError({
1741 command: "gh",
1742 cwd: input.cwd,
1743 number: input.number,
1744 reason: "head-list-truncated",
1745 observedCount: decoded.success.length,
1746 limit: workflowApprovalLimit,
1747 }),
1748 );
1749 }
1750 if (exactHeads.length !== 1 || exactHeads[0]?.number !== input.number) {
1751 return Effect.fail(
1752 new GitHubWorkflowApprovalRefusedError({
1753 command: "gh",
1754 cwd: input.cwd,
1755 number: input.number,
1756 reason: "head-not-unique",
1757 observedCount: exactHeads.length,
1758 limit: workflowApprovalLimit,
1759 }),
1760 );
1761 }
1762 return Effect.succeed(exactHeads[0]);
1763 },
1764 ),
1707 }
1708 if (exactHeads.length !== 1 || exactHeads[0]?.number !== input.number) {
1709 return Effect.fail(
1710 new GitHubWorkflowApprovalRefusedError({
1711 command: "gh",
1712 cwd: input.cwd,
1713 number: input.number,
1714 reason: "head-not-unique",
1715 observedCount: exactHeads.length,
1716 limit: workflowApprovalLimit,
1717 }),
1718 );
1719 }
1720 return Effect.succeed(exactHeads[0]);
1721 },
17651722 ),
1766 github
1767 .execute({
1768 cwd: input.cwd,
1769 args: [
1770 "run",
1771 "list",
1772 ...repositoryArgs(input),
1773 "--commit",
1774 input.headSha,
1775 "--branch",
1776 input.headBranch,
1777 "--event",
1778 "pull_request",
1779 "--status",
1780 "action_required",
1781 "--limit",
1782 workflowApprovalProbeLimit,
1783 "--json",
1784 "databaseId,workflowName,url",
1785 ],
1786 })
1787 .pipe(
1788 Effect.flatMap(
1789 (
1790 result,
1791 ): Effect.Effect<
1792 ReadonlyArray<GitHubWorkflowRunApproval>,
1793 GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError
1794 > => {
1795 const decoded = decodeWorkflowRunApprovalsJson(result.stdout.trim());
1796 if (!Result.isSuccess(decoded)) {
1797 return Effect.fail(workflowApprovalReadError(input.cwd, decoded.failure));
1798 }
1799 return decoded.success.length > workflowApprovalLimit
1800 ? Effect.fail(
1801 new GitHubWorkflowApprovalRefusedError({
1802 command: "gh",
1803 cwd: input.cwd,
1804 number: input.number,
1805 reason: "run-list-truncated",
1806 observedCount: decoded.success.length,
1807 limit: workflowApprovalLimit,
1808 }),
1809 )
1810 : Effect.succeed(decoded.success);
1811 },
1812 ),
1723 ),
1724 listActionRequiredRuns(input).pipe(
1725 Effect.flatMap((runs) =>
1726 runs.length > workflowApprovalLimit
1727 ? Effect.fail(
1728 new GitHubWorkflowApprovalRefusedError({
1729 command: "gh",
1730 cwd: input.cwd,
1731 number: input.number,
1732 reason: "run-list-truncated",
1733 observedCount: runs.length,
1734 limit: workflowApprovalLimit,
1735 }),
1736 )
1737 : Effect.succeed(runs),
18131738 ),
1739 ),
18141740 ],
18151741 { concurrency: 2 },
18161742 ).pipe(Effect.map(([, runs]) => runs));
18171743
1818 // One `gh pr view` either way; asking for the detail fields costs nothing extra and hands
1819 // the thread overview its author, diff stat, review decision and checks in the same read.
1820 const viewPullRequestSummary = (input: PullRequestSummaryRead) =>
1821 github
1822 .execute({
1823 cwd: input.cwd,
1824 args: [
1825 "pr",
1826 "view",
1827 String(input.number),
1828 ...repositoryArgs(input),
1829 "--json",
1830 PULL_REQUEST_DETAIL_JSON_FIELDS,
1831 ],
1832 })
1833 .pipe(
1834 Effect.flatMap((result) => {
1835 const decoded = decodePullRequestDetailJson(result.stdout.trim());
1836 if (!Result.isSuccess(decoded)) {
1837 return Effect.fail(
1838 new GitHubPullRequestReadError({
1839 command: "gh",
1840 cwd: input.cwd,
1841 operation: "getPullRequestSummary",
1842 cause: decoded.failure,
1843 }),
1844 );
1845 }
1846 const detail = decoded.success;
1847 return Effect.succeed({
1848 number: detail.number,
1849 title: detail.title,
1850 url: detail.url,
1851 headBranch: detail.headBranch,
1852 baseBranch: detail.baseBranch,
1853 state: detail.state,
1854 updatedAt: detail.updatedAt,
1855 closedAt: detail.closedAt ?? null,
1856 mergedAt: detail.mergedAt ?? null,
1857 isDraft: detail.isDraft,
1858 author: detail.author,
1859 additions: detail.additions,
1860 deletions: detail.deletions,
1861 changedFiles: detail.changedFiles,
1862 reviewDecision: detail.reviewDecision,
1863 checksState: detail.checksState,
1864 mergeability: detail.mergeability,
1865 });
1866 }),
1867 );
1744 /** One pull request's summary, through the same aliased query the batch uses. */
1745 const viewPullRequestSummary = (input: PullRequestSummaryRead) => {
1746 const { owner, name } = parseRepositorySelector(input.repository);
1747 return graphqlRead({
1748 cwd: input.cwd,
1749 host: input.host,
1750 operation: "getPullRequestSummary",
1751 variables: { owner, name, number: input.number },
1752 query: pullRequestSummaryGraphQlQuery(input.host === "github.com"),
1753 decode: decodePullRequestSummariesJson,
1754 }).pipe(
1755 Effect.flatMap((summaries) => {
1756 const summary = summaries.get(0);
1757 return summary === undefined
1758 ? Effect.fail(
1759 readError(
1760 input.cwd,
1761 "getPullRequestSummary",
1762 new Error(`GitHub answered nothing for ${input.repository}#${input.number}.`),
1763 ),
1764 )
1765 : Effect.succeed(summary);
1766 }),
1767 );
1768 };
18681769
18691770 /**
18701771 * Summaries asked for together, on one host under one credential, share aliased GraphQL reads
......@@ -1876,7 +1777,7 @@ export const make = Effect.gen(function* () {
18761777 key: ({ request, context }) =>
18771778 JSON.stringify([
18781779 request.host.toLowerCase(),
1879 Context.getOrElse(context, GitHubCli.PinnedGitHubCredential, () => null)
1780 Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null)
18801781 ?.credentialFingerprint ?? null,
18811782 Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""),
18821783 ]),
......@@ -1956,7 +1857,7 @@ export const make = Effect.gen(function* () {
19561857 key: ({ request, context }) =>
19571858 JSON.stringify([
19581859 request.host.toLowerCase(),
1959 Context.getOrElse(context, GitHubCli.PinnedGitHubCredential, () => null)
1860 Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null)
19601861 ?.credentialFingerprint ?? null,
19611862 Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""),
19621863 ]),
......@@ -2010,70 +1911,88 @@ export const make = Effect.gen(function* () {
20101911
20111912 listPullRequests: (input) => {
20121913 const fallbackMaxRows = Math.max(input.limit + 1, PULL_REQUEST_FALLBACK_MAX_ROWS);
1914 const { owner, name } = parseRepositorySelector(input.repository);
1915 const query = searchQuery({ ...input, repositories: [input.repository] });
1916 if (query === null) {
1917 return Effect.fail(
1918 new GitHubRepositorySelectorError({
1919 command: "gh",
1920 cwd: input.cwd,
1921 operation: "listPullRequests",
1922 }),
1923 );
1924 }
1925 /** Pages of up to a hundred until `rows` have arrived or GitHub has no more. */
1926 const collect = (
1927 rows: number,
1928 page: (
1929 after: string | null,
1930 rows: number,
1931 ) => Effect.Effect<GitHubPullRequestListPage, GitHubPullRequestCliError>,
1932 ) =>
1933 Effect.gen(function* () {
1934 const items: GitHubPullRequestListItem[] = [];
1935 let rawCount = 0;
1936 let after: string | null = null;
1937 do {
1938 const read: GitHubPullRequestListPage = yield* page(after, rows - rawCount);
1939 items.push(...read.items);
1940 rawCount += read.rawCount;
1941 after = read.endCursor;
1942 } while (after !== null && rawCount < rows);
1943 return { items, rawCount };
1944 });
20131945 const read = (
20141946 continues: boolean,
20151947 requestedRows = input.limit + 1,
20161948 ): Effect.Effect<GitHubPullRequestListBatch, GitHubPullRequestCliError> =>
2017 github
2018 .execute({
2019 cwd: input.cwd,
2020 args: [
2021 "pr",
2022 "list",
2023 ...repositoryArgs(input),
2024 ...involvementArgs({ ...input, sorted: continues }),
2025 "--state",
2026 input.state,
2027 "--limit",
2028 // One extra row reveals that the repository has more than the page shows.
2029 String(requestedRows),
2030 "--json",
2031 PULL_REQUEST_LIST_JSON_FIELDS,
2032 ],
2033 })
2034 .pipe(
2035 Effect.flatMap((result) => {
2036 const raw = result.stdout.trim();
2037 if (raw.length === 0) {
2038 return Effect.succeed({ items: [], truncated: false, continues });
2039 }
2040 const decoded = decodePullRequestListJson(raw);
2041 if (Result.isSuccess(decoded)) {
2042 const items = continues
2043 ? decoded.success.items
2044 : decoded.success.items.filter((item) => matchesUnsortedListing(item, input));
2045 if (
2046 !continues &&
2047 items.length < input.limit &&
2048 decoded.success.rawCount >= requestedRows &&
2049 requestedRows < fallbackMaxRows
2050 ) {
2051 const nextRows = Math.min(requestedRows * 2, fallbackMaxRows);
2052 if (nextRows > requestedRows) return read(false, nextRows);
2053 }
2054 return Effect.succeed({
2055 items: items.slice(0, input.limit),
2056 // One row over the page size is the probe for a next page, and it is
2057 // counted before decoding: a skipped malformed row must not end paging.
2058 truncated: continues
2059 ? decoded.success.rawCount > input.limit
2060 : items.length > input.limit || decoded.success.rawCount >= requestedRows,
2061 continues,
2062 });
2063 }
2064 return Effect.fail(
2065 new GitHubPullRequestReadError({
2066 command: "gh",
2067 cwd: input.cwd,
2068 operation: "listPullRequests",
2069 cause: decoded.failure,
2070 }),
2071 );
2072 }),
2073 );
1949 collect(requestedRows, (after, rows) =>
1950 continues
1951 ? graphqlRead({
1952 cwd: input.cwd,
1953 host: input.host,
1954 operation: "listPullRequests",
1955 // The reader's own words are in the query.
1956 variables: { q: query, after },
1957 query: pullRequestSearchGraphQlQuery(rows, false, true),
1958 decode: decodePullRequestSearchJson,
1959 })
1960 : graphqlRead({
1961 cwd: input.cwd,
1962 host: input.host,
1963 operation: "listPullRequests",
1964 variables: { owner, name, states: LIST_STATES[input.state], after },
1965 query: pullRequestListGraphQlQuery(rows),
1966 decode: decodePullRequestListJson,
1967 }),
1968 ).pipe(
1969 Effect.flatMap(({ items: rawItems, rawCount }) => {
1970 const items = continues
1971 ? rawItems
1972 : rawItems.filter((item) => matchesUnsortedListing(item, input));
1973 if (
1974 !continues &&
1975 items.length < input.limit &&
1976 rawCount >= requestedRows &&
1977 requestedRows < fallbackMaxRows
1978 ) {
1979 const nextRows = Math.min(requestedRows * 2, fallbackMaxRows);
1980 if (nextRows > requestedRows) return read(false, nextRows);
1981 }
1982 return Effect.succeed({
1983 items: items.slice(0, input.limit),
1984 // One row over the page size is the probe for a next page, and it is
1985 // counted before decoding: a skipped malformed row must not end paging.
1986 truncated: continues
1987 ? rawCount > input.limit
1988 : items.length > input.limit || rawCount >= requestedRows,
1989 continues,
1990 });
1991 }),
1992 );
20741993 // GitHub does not index every repository for search, and one it will not search answers
2075 // with no rows rather than with an error — so an empty listing is read again the way `gh`
2076 // lists without one. Those rows come back newest-created first, an order no `updated:`
1994 // with no rows rather than with an error — so an empty listing is read again from the
1995 // repository's own list. Those rows come back newest-created first, an order no `updated:`
20771996 // qualifier can carry on from, so that page says it cannot be continued and the reader
20781997 // reaches the rest of it by asking for a larger page, as every listing used to.
20791998 //
......@@ -2157,8 +2076,7 @@ export const make = Effect.gen(function* () {
21572076 cwd: input.cwd,
21582077 host: input.host,
21592078 operation: "searchPullRequests",
2160 // The reader's own words are in the query, so it travels over stdin rather than in argv.
2161 privateVariables: { q: query },
2079 variables: { q: query, after: null },
21622080 query: pullRequestSearchGraphQlQuery(rows, input.host === "github.com"),
21632081 decode: decodePullRequestSearchJson,
21642082 }).pipe(
......@@ -2217,11 +2135,7 @@ export const make = Effect.gen(function* () {
22172135 cwd: input.cwd,
22182136 host: input.host,
22192137 operation: "getPullRequestPreview",
2220 variables: [
2221 ["-f", `owner=${owner}`],
2222 ["-f", `name=${name}`],
2223 ["-F", `number=${input.number}`],
2224 ],
2138 variables: { owner, name, number: input.number },
22252139 query: PULL_REQUEST_PREVIEW_GRAPHQL_QUERY,
22262140 decode: decodePullRequestPreviewJson,
22272141 });
......@@ -2230,65 +2144,30 @@ export const make = Effect.gen(function* () {
22302144
22312145 getPullRequestStack: (input) => {
22322146 const { owner, name } = parseRepositorySelector(input.repository);
2233 return github
2234 .execute({
2235 cwd: input.cwd,
2236 args: [
2237 "api",
2238 "--hostname",
2239 input.host,
2240 `repos/${owner}/${name}/stacks?pull_request=${input.number}`,
2241 ],
2242 })
2243 .pipe(
2244 Effect.flatMap((result) => {
2245 const decoded = decodePullRequestStacksJson(result.stdout.trim());
2246 return Result.isSuccess(decoded)
2247 ? Effect.succeed(decoded.success)
2248 : Effect.fail(
2249 new GitHubPullRequestReadError({
2250 command: "gh",
2251 cwd: input.cwd,
2252 operation: "getPullRequestStack",
2253 cause: decoded.failure,
2254 }),
2255 );
2256 }),
2257 // @effect-diagnostics-next-line flatMapConditionalToFilterOrFail:off - the fallback needs a non-null stack, which a predicate that also reads includeDetails cannot refine.
2258 Effect.flatMap((stack) => {
2259 if (!input.includeDetails || stack === null) return Effect.succeed(stack);
2260 return github
2261 .execute({
2262 cwd: input.cwd,
2263 args: [
2264 "api",
2265 "--hostname",
2266 input.host,
2267 `repos/${owner}/${name}/stacks/${stack.number}`,
2268 ],
2269 })
2270 .pipe(
2271 Effect.flatMap((result) => {
2272 const decoded = decodePullRequestStacksJson(`[${result.stdout.trim()}]`);
2273 return Result.isSuccess(decoded)
2274 ? Effect.succeed(decoded.success)
2275 : Effect.fail(
2276 new GitHubPullRequestReadError({
2277 command: "gh",
2278 cwd: input.cwd,
2279 operation: "getPullRequestStack",
2280 cause: decoded.failure,
2281 }),
2282 );
2283 }),
2284 );
2285 }),
2286 // Hosts without the stacks preview return 404. Other failures must preserve the
2287 // previously synced stack and let the caller retry.
2288 Effect.catchTags({
2289 GitHubPullRequestNotFoundError: () => Effect.succeed(null),
2290 }),
2291 );
2147 return restRead({
2148 cwd: input.cwd,
2149 host: input.host,
2150 operation: "getPullRequestStack",
2151 path: `repos/${owner}/${name}/stacks?pull_request=${input.number}`,
2152 decode: decodePullRequestStacksJson,
2153 }).pipe(
2154 // @effect-diagnostics-next-line flatMapConditionalToFilterOrFail:off - the fallback needs a non-null stack, which a predicate that also reads includeDetails cannot refine.
2155 Effect.flatMap((stack) => {
2156 if (!input.includeDetails || stack === null) return Effect.succeed(stack);
2157 return restRead({
2158 cwd: input.cwd,
2159 host: input.host,
2160 operation: "getPullRequestStack",
2161 path: `repos/${owner}/${name}/stacks/${stack.number}`,
2162 decode: (raw) => decodePullRequestStacksJson(`[${raw}]`),
2163 });
2164 }),
2165 // Hosts without the stacks preview return 404. Other failures must preserve the
2166 // previously synced stack and let the caller retry.
2167 Effect.catchTags({
2168 GitHubApiNotFoundError: () => Effect.succeed(null),
2169 }),
2170 );
22922171 },
22932172
22942173 getPullRequestBaseComparison: (input) => {
......@@ -2298,45 +2177,59 @@ export const make = Effect.gen(function* () {
22982177 host: input.host,
22992178 operation: "getPullRequestBaseComparison",
23002179 ...(input.allowReserve === true ? { allowReserve: true } : {}),
2301 variables: [
2302 ["-f", `owner=${owner}`],
2303 ["-f", `name=${name}`],
2304 ["-F", `number=${input.number}`],
2305 ["-f", `headRef=${input.headRef}`],
2306 ],
2180 variables: { owner, name, number: input.number, headRef: input.headRef },
23072181 query: BASE_COMPARISON_GRAPHQL_QUERY,
23082182 decode: decodeBaseComparisonJson,
23092183 });
23102184 },
23112185
23122186 getPullRequestActivity: (input) =>
2313 github
2314 .execute({
2315 cwd: input.cwd,
2316 args: [
2317 "pr",
2318 "view",
2319 String(input.number),
2320 ...repositoryArgs(input),
2321 "--json",
2322 PULL_REQUEST_ACTIVITY_JSON_FIELDS,
2323 ],
2324 })
2325 .pipe(
2326 Effect.flatMap((result) => {
2327 const decoded = decodePullRequestActivityJson(result.stdout.trim());
2328 return Result.isSuccess(decoded)
2329 ? Effect.succeed(decoded.success)
2330 : Effect.fail(
2331 new GitHubPullRequestReadError({
2332 command: "gh",
2333 cwd: input.cwd,
2334 operation: "getPullRequestActivity",
2335 cause: decoded.failure,
2336 }),
2337 );
2338 }),
2339 ),
2187 Effect.gen(function* () {
2188 const { owner, name } = parseRepositorySelector(input.repository);
2189 let author: PullRequestActor | null = null;
2190 let commits: ReadonlyArray<PullRequestCommit> = [];
2191 const remarks: PullRequestComment[] = [];
2192 let commentsAfter: string | null = null;
2193 let reviewsAfter: string | null = null;
2194 let withComments = true;
2195 let withReviews = true;
2196 // Both remark lists page on their own; a page asks only for the ones with more to give.
2197 for (let page = 0; page < REVIEW_THREAD_PAGES && (withComments || withReviews); page++) {
2198 const read: GitHubPullRequestActivityPage = yield* graphqlRead({
2199 cwd: input.cwd,
2200 host: input.host,
2201 operation: "getPullRequestActivity",
2202 variables: {
2203 owner,
2204 name,
2205 number: input.number,
2206 head: page === 0,
2207 withComments,
2208 commentsAfter,
2209 withReviews,
2210 reviewsAfter,
2211 },
2212 query: PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY,
2213 decode: decodePullRequestActivityJson,
2214 });
2215 if (page === 0) {
2216 author = read.author ?? null;
2217 commits = read.commits ?? [];
2218 }
2219 remarks.push(...read.remarks);
2220 commentsAfter = read.nextCommentsCursor;
2221 reviewsAfter = read.nextReviewsCursor;
2222 withComments = withComments && commentsAfter !== null;
2223 withReviews = withReviews && reviewsAfter !== null;
2224 }
2225 return {
2226 author,
2227 comments: remarks.toSorted((left, right) =>
2228 left.createdAt.localeCompare(right.createdAt),
2229 ),
2230 commits,
2231 } satisfies GitHubPullRequestActivity;
2232 }),
23402233
23412234 getPullRequestDiff: (input) => {
23422235 const filesPage = (page: number) =>
......@@ -2363,31 +2256,34 @@ export const make = Effect.gen(function* () {
23632256 if (input.commit !== undefined) {
23642257 return filesPage(1);
23652258 }
2366 return github
2367 .execute({
2368 cwd: input.cwd,
2369 args: ["pr", "diff", String(input.number), ...repositoryArgs(input), "--color", "never"],
2370 maxOutputBytes: DIFF_MAX_OUTPUT_BYTES,
2371 timeoutMs: DIFF_TIMEOUT_MS,
2259 const { owner, name } = parseRepositorySelector(input.repository);
2260 return api
2261 .rest({
2262 host: input.host,
2263 operation: "getPullRequestDiff",
2264 path: `repos/${owner}/${name}/pulls/${input.number}`,
2265 accept: "application/vnd.github.diff",
2266 maxResponseBytes: DIFF_MAX_OUTPUT_BYTES,
2267 timeout: DIFF_TIMEOUT,
23722268 })
23732269 .pipe(
2374 Effect.flatMap((result) =>
2270 Effect.flatMap((response) =>
23752271 // A patch cut at a byte boundary ends mid-file, which is neither a whole slice nor
23762272 // something the reader can carry on from. The files API can serve the same change a
23772273 // whole number of files at a time, so an oversized patch takes that road as well.
2378 result.stdoutTruncated
2274 response.truncated
23792275 ? filesPage(1)
23802276 : // One read served the whole patch, so there is no next slice to ask for.
2381 Effect.succeed({ patch: result.stdout, truncated: false, nextCursor: null }),
2277 Effect.succeed({ patch: response.body, truncated: false, nextCursor: null }),
23822278 ),
23832279 // GitHub answers 406 rather than a diff past 300 changed files, so the patch is read
23842280 // from the files API instead, a page per call. Only once the direct read has failed: a
23852281 // pull request GitHub will serve a diff for must not pay for a second request. A
23862282 // fallback that fails too reports the original refusal, which is the one that explains
2387 // the page. Narrowed to a command that ran and was refused: a missing `gh` or a
2388 // signed-out one fails the same way for every request.
2283 // the page. Narrowed to a request GitHub answered and refused: a missing credential or
2284 // a rate limit fails the same way for every request.
23892285 Effect.catchTags({
2390 GitHubCliCommandError: (error) => filesPage(1).pipe(Effect.mapError(() => error)),
2286 GitHubApiResponseError: (error) => filesPage(1).pipe(Effect.mapError(() => error)),
23912287 }),
23922288 );
23932289 },
......@@ -2400,13 +2296,13 @@ export const make = Effect.gen(function* () {
24002296 cwd: input.cwd,
24012297 host: input.host,
24022298 operation: "getReviewThreadComments",
2403 variables: [
2404 ["-f", `owner=${owner}`],
2405 ["-f", `name=${name}`],
2406 ["-F", `number=${input.number}`],
2407 ["-f", `threadId=${input.threadId}`],
2408 cursorVariable(input.cursor),
2409 ],
2299 variables: {
2300 owner,
2301 name,
2302 number: input.number,
2303 threadId: input.threadId,
2304 cursor: input.cursor,
2305 },
24102306 query: REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY,
24112307 decode: decodeReviewThreadCommentsJson,
24122308 }).pipe(
......@@ -2434,12 +2330,7 @@ export const make = Effect.gen(function* () {
24342330 cwd: input.cwd,
24352331 host: input.host,
24362332 operation: "listReviewThreadComments",
2437 variables: [
2438 ["-f", `owner=${owner}`],
2439 ["-f", `name=${name}`],
2440 ["-F", `number=${input.number}`],
2441 cursorVariable(cursor),
2442 ],
2333 variables: { owner, name, number: input.number, cursor },
24432334 query: REVIEW_THREADS_GRAPHQL_QUERY,
24442335 decode: decodeReviewThreadsJson,
24452336 });
......@@ -2496,12 +2387,7 @@ export const make = Effect.gen(function* () {
24962387 cwd: input.cwd,
24972388 host: input.host,
24982389 operation: "listReviewThreadComments",
2499 variables: [
2500 ["-f", `owner=${owner}`],
2501 ["-f", `name=${name}`],
2502 ["-F", `number=${input.number}`],
2503 ["-f", `cursor=${dismissalCursor}`],
2504 ],
2390 variables: { owner, name, number: input.number, cursor: dismissalCursor },
25052391 query: REVIEW_DISMISSALS_GRAPHQL_QUERY,
25062392 decode: decodeReviewDismissalsJson,
25072393 });
......@@ -2547,7 +2433,7 @@ export const make = Effect.gen(function* () {
25472433 cwd: input.cwd,
25482434 host: input.host,
25492435 operation: "listActorAvatars",
2550 variables: input.ids.map((id) => ["-f", `ids[]=${id}`]),
2436 variables: { ids: input.ids },
25512437 query: ACTOR_AVATARS_GRAPHQL_QUERY,
25522438 decode: decodeActorAvatarsJson,
25532439 });
......@@ -2560,11 +2446,7 @@ export const make = Effect.gen(function* () {
25602446 host: input.host,
25612447 operation: "getViewerAccess",
25622448 ...(input.allowReserve === true ? { allowReserve: true } : {}),
2563 variables: [
2564 ["-f", `owner=${owner}`],
2565 ["-f", `name=${name}`],
2566 ["-F", `number=${input.number}`],
2567 ],
2449 variables: { owner, name, number: input.number },
25682450 query: VIEWER_PERMISSIONS_GRAPHQL_QUERY,
25692451 decode: decodeViewerPermissionsJson,
25702452 });
......@@ -2577,11 +2459,7 @@ export const make = Effect.gen(function* () {
25772459 host: input.host,
25782460 operation: "listReviewerCandidates",
25792461 allowReserve: true,
2580 variables: [
2581 ["-f", `owner=${owner}`],
2582 ["-f", `name=${name}`],
2583 ["-F", `number=${input.number}`],
2584 ],
2462 variables: { owner, name, number: input.number },
25852463 query: REVIEWER_CANDIDATES_GRAPHQL_QUERY,
25862464 decode: decodeReviewerCandidatesJson,
25872465 });
......@@ -2589,24 +2467,15 @@ export const make = Effect.gen(function* () {
25892467
25902468 setReviewerRequest: (input) => {
25912469 const { owner, name } = parseRepositorySelector(input.repository);
2592 return github
2593 .execute({
2594 cwd: input.cwd,
2595 // Posting to a login GitHub has already been asked about is what a re-request is, so
2596 // there is nothing to say here about somebody who has reviewed once already. The body
2597 // travels over stdin for the reason every other one does: argv is visible in process
2598 // listings and echoed back inside process-runner failure messages.
2599 args: [
2600 "api",
2601 "--method",
2602 input.requested ? "POST" : "DELETE",
2603 "--hostname",
2604 input.host,
2605 `repos/${owner}/${name}/pulls/${input.number}/requested_reviewers`,
2606 "--input",
2607 "-",
2608 ],
2609 stdin: buildReviewerRequestJson(input.reviewers),
2470 // Posting to a login GitHub has already been asked about is what a re-request is, so
2471 // there is nothing to say here about somebody who has reviewed once already.
2472 return api
2473 .rest({
2474 host: input.host,
2475 operation: "setReviewerRequest",
2476 method: input.requested ? "POST" : "DELETE",
2477 path: `repos/${owner}/${name}/pulls/${input.number}/requested_reviewers`,
2478 body: buildReviewerRequest(input.reviewers),
26102479 })
26112480 .pipe(Effect.asVoid);
26122481 },
......@@ -2618,11 +2487,7 @@ export const make = Effect.gen(function* () {
26182487 host: input.host,
26192488 operation: "listLabelCandidates",
26202489 allowReserve: true,
2621 variables: [
2622 ["-f", `owner=${owner}`],
2623 ["-f", `name=${name}`],
2624 ["-F", `number=${input.number}`],
2625 ],
2490 variables: { owner, name, number: input.number },
26262491 query: LABEL_CANDIDATES_GRAPHQL_QUERY,
26272492 decode: decodeLabelCandidatesJson,
26282493 });
......@@ -2635,27 +2500,24 @@ export const make = Effect.gen(function* () {
26352500 // path. The name goes into the path encoded, because a label may carry a space or a slash.
26362501 const issue = `repos/${owner}/${name}/issues/${input.number}/labels`;
26372502 if (input.applied) {
2638 return github
2639 .execute({
2640 cwd: input.cwd,
2641 args: ["api", "--method", "POST", "--hostname", input.host, issue, "--input", "-"],
2642 stdin: buildLabelRequestJson(input.labels),
2503 return api
2504 .rest({
2505 host: input.host,
2506 operation: "setLabels",
2507 method: "POST",
2508 path: issue,
2509 body: buildLabelRequest(input.labels),
26432510 })
26442511 .pipe(Effect.asVoid);
26452512 }
26462513 return Effect.forEach(
26472514 input.labels,
26482515 (label) =>
2649 github.execute({
2650 cwd: input.cwd,
2651 args: [
2652 "api",
2653 "--method",
2654 "DELETE",
2655 "--hostname",
2656 input.host,
2657 `${issue}/${encodeURIComponent(label)}`,
2658 ],
2516 api.rest({
2517 host: input.host,
2518 operation: "setLabels",
2519 method: "DELETE",
2520 path: `${issue}/${encodeURIComponent(label)}`,
26592521 }),
26602522 { concurrency: 1, discard: true },
26612523 );
......@@ -2664,14 +2526,14 @@ export const make = Effect.gen(function* () {
26642526 runPullRequestAction: (input) => {
26652527 if (input.stackNumber !== undefined)
26662528 return runGitHubStackAction({ ...input, stackNumber: input.stackNumber }).pipe(
2667 Effect.provideService(GitHubCli.GitHubCli, github),
2529 Effect.provideService(GitHubApi.GitHubApi, api),
26682530 );
26692531 if (input.action === "revert") {
26702532 return pullRequestNodeId({ ...input, operation: "revertPullRequest" }).pipe(
26712533 Effect.flatMap((pullRequestId) =>
26722534 graphql({
2673 cwd: input.cwd,
26742535 host: input.host,
2536 operation: "revertPullRequest",
26752537 query: REVERT_PULL_REQUEST_GRAPHQL_MUTATION,
26762538 variables: { pullRequestId },
26772539 }),
......@@ -2742,18 +2604,12 @@ export const make = Effect.gen(function* () {
27422604 }),
27432605 Effect.flatMap((currentRuns) =>
27442606 currentRuns.some((current) => current.id === run.id)
2745 ? github
2746 .execute({
2747 cwd: input.cwd,
2748 args: [
2749 "api",
2750 "--method",
2751 "POST",
2752 "--hostname",
2753 input.host,
2754 `repos/${owner}/${name}/actions/runs/${run.id}/approve`,
2755 "--silent",
2756 ],
2607 ? api
2608 .rest({
2609 host: input.host,
2610 operation: "approveWorkflowRun",
2611 method: "POST",
2612 path: `repos/${owner}/${name}/actions/runs/${run.id}/approve`,
27572613 })
27582614 .pipe(Effect.asVoid)
27592615 : Effect.void,
......@@ -2766,32 +2622,67 @@ export const make = Effect.gen(function* () {
27662622 }),
27672623 );
27682624 }
2769 const [subcommand, ...flags] = actionArgs(
2770 input.action,
2771 input.mergeMethod,
2772 input.updateMethod,
2773 );
2625 const action = input.action;
2626 if (action in SIMPLE_ACTION_MUTATIONS) {
2627 return pullRequestNodeId({ ...input, operation: "runPullRequestAction" }).pipe(
2628 Effect.flatMap((pullRequestId) =>
2629 graphql({
2630 host: input.host,
2631 operation: "runPullRequestAction",
2632 query: SIMPLE_ACTION_MUTATIONS[action as keyof typeof SIMPLE_ACTION_MUTATIONS],
2633 variables: { pullRequestId },
2634 }),
2635 ),
2636 );
2637 }
27742638 return Effect.gen(function* () {
2639 const { owner, name } = parseRepositorySelector(input.repository);
2640 // Read fresh rather than from the node id cache: merging and updating act on the head
2641 // as it stands now, and the merge queue decides which mutation a merge is.
2642 const state = yield* graphqlRead({
2643 cwd: input.cwd,
2644 host: input.host,
2645 operation: "runPullRequestAction",
2646 allowReserve: true,
2647 query: ACTION_STATE_GRAPHQL_QUERY,
2648 variables: {
2649 owner,
2650 name,
2651 number: input.number,
2652 headRef: `refs/pull/${input.number}/head`,
2653 },
2654 decode: decodeActionState,
2655 });
2656 if (action === "update-branch") {
2657 // Already current is done, the way `gh pr update-branch` reports it.
2658 if (state.baseRef?.compare?.behindBy === 0) return;
2659 // GitHub updates with a merge commit unless asked to rebase, which is its own default.
2660 return yield* graphql({
2661 host: input.host,
2662 operation: "runPullRequestAction",
2663 query: UPDATE_BRANCH_GRAPHQL_MUTATION,
2664 variables: {
2665 pullRequestId: state.id,
2666 expectedHeadOid: state.headRefOid,
2667 updateMethod: input.updateMethod === "rebase" ? "REBASE" : "MERGE",
2668 },
2669 });
2670 }
27752671 let body: string | undefined;
27762672 let expectedHead: string | undefined;
2777 if (
2778 input.removeAgentCreditsOnMerge === true &&
2779 (input.action === "merge" || input.action === "enable-auto-merge") &&
2780 input.mergeMethod !== "rebase"
2781 ) {
2782 const { owner, name } = parseRepositorySelector(input.repository);
2673 if (input.removeAgentCreditsOnMerge === true && input.mergeMethod !== "rebase") {
27832674 const message = yield* graphqlRead({
27842675 cwd: input.cwd,
27852676 host: input.host,
27862677 operation: "runPullRequestAction",
27872678 allowReserve: true,
27882679 query: MERGE_MESSAGE_GRAPHQL_QUERY,
2789 variables: [
2790 ["-f", `owner=${owner}`],
2791 ["-f", `name=${name}`],
2792 ["-F", `number=${input.number}`],
2793 ["-f", `method=${input.mergeMethod === "squash" ? "SQUASH" : "MERGE"}`],
2794 ],
2680 variables: {
2681 owner,
2682 name,
2683 number: input.number,
2684 method: input.mergeMethod === "squash" ? "SQUASH" : "MERGE",
2685 },
27952686 decode: decodeMergeMessage,
27962687 });
27972688 // GitHub's merge queue chooses its own message and ignores custom text.
......@@ -2803,59 +2694,53 @@ export const make = Effect.gen(function* () {
28032694 }
28042695 }
28052696 }
2806 yield* github.execute({
2807 cwd: input.cwd,
2808 args: [
2809 "pr",
2810 subcommand!,
2811 String(input.number),
2812 ...repositoryArgs(input),
2813 ...flags,
2814 ...(expectedHead === undefined ? [] : ["--match-head-commit", expectedHead]),
2815 ...(body === undefined ? [] : ["--body-file", "-"]),
2816 ],
2817 ...(body === undefined ? {} : { stdin: body }),
2697 // A merge queue takes a pull request through auto-merge rather than a direct merge, and
2698 // `--auto` on a pull request that is mergeable right now simply merges it, as `gh` does.
2699 // GitHub stores the strategy with a standing instruction rather than choosing one at
2700 // merge time, so arming still names it.
2701 const auto =
2702 state.isMergeQueueEnabled === true ||
2703 (action === "enable-auto-merge" &&
2704 !IMMEDIATELY_MERGEABLE.has(state.mergeStateStatus?.toUpperCase() ?? ""));
2705 yield* graphql({
2706 host: input.host,
2707 operation: "runPullRequestAction",
2708 query: auto ? ENABLE_AUTO_MERGE_GRAPHQL_MUTATION : MERGE_PULL_REQUEST_GRAPHQL_MUTATION,
2709 variables: {
2710 input: {
2711 pullRequestId: state.id,
2712 mergeMethod: GRAPHQL_MERGE_METHODS[input.mergeMethod ?? "merge"],
2713 ...(expectedHead === undefined ? {} : { expectedHeadOid: expectedHead }),
2714 ...(body === undefined ? {} : { commitBody: body }),
2715 },
2716 },
28182717 });
28192718 });
28202719 },
28212720
28222721 commentOnPullRequest: (input) =>
2823 github
2824 .execute({
2825 cwd: input.cwd,
2826 // The body travels over stdin: argv is visible in process listings and is echoed
2827 // back inside process-runner failure messages.
2828 args: [
2829 "pr",
2830 "comment",
2831 String(input.number),
2832 ...repositoryArgs(input),
2833 "--body-file",
2834 "-",
2835 ],
2836 stdin: input.body,
2837 })
2838 .pipe(Effect.asVoid),
2722 pullRequestNodeId({ ...input, operation: "commentOnPullRequest" }).pipe(
2723 Effect.flatMap((subjectId) =>
2724 graphql({
2725 host: input.host,
2726 operation: "commentOnPullRequest",
2727 query: ADD_COMMENT_GRAPHQL_MUTATION,
2728 variables: { subjectId, body: input.body },
2729 }),
2730 ),
2731 ),
28392732
28402733 submitReview: (input) => {
28412734 const { owner, name } = parseRepositorySelector(input.repository);
2842 return github
2843 .execute({
2844 cwd: input.cwd,
2845 // The whole review is one request, so nothing is visible to anyone else until the
2846 // verdict is sent. The payload travels over stdin for the same reason a comment
2847 // body does: argv is visible in process listings and echoed back in failures.
2848 args: [
2849 "api",
2850 "--method",
2851 "POST",
2852 "--hostname",
2853 input.host,
2854 `repos/${owner}/${name}/pulls/${input.number}/reviews`,
2855 "--input",
2856 "-",
2857 ],
2858 stdin: buildReviewSubmissionJson({
2735 // The whole review is one request, so nothing is visible to anyone else until the verdict
2736 // is sent.
2737 return api
2738 .rest({
2739 host: input.host,
2740 operation: "submitReview",
2741 method: "POST",
2742 path: `repos/${owner}/${name}/pulls/${input.number}/reviews`,
2743 body: buildReviewSubmission({
28592744 verdict: input.verdict,
28602745 body: input.body,
28612746 comments: input.comments,
......@@ -2866,8 +2751,8 @@ export const make = Effect.gen(function* () {
28662751
28672752 replyToReviewThread: (input) =>
28682753 graphql({
2869 cwd: input.cwd,
28702754 host: input.host,
2755 operation: "replyToReviewThread",
28712756 query: REVIEW_THREAD_REPLY_GRAPHQL_MUTATION,
28722757 variables: { threadId: input.threadId, body: input.body },
28732758 }),
......@@ -2883,14 +2768,7 @@ export const make = Effect.gen(function* () {
28832768 cwd: input.cwd,
28842769 host: input.host,
28852770 operation: "getPullRequestFilesViewed",
2886 variables: [
2887 ["-f", `owner=${owner}`],
2888 ["-f", `name=${name}`],
2889 ["-F", `number=${input.number}`],
2890 ...(after === null
2891 ? []
2892 : ([["-f", `after=${after}`]] as ReadonlyArray<readonly [string, string]>)),
2893 ],
2771 variables: { owner, name, number: input.number, after },
28942772 query: PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY,
28952773 decode: decodePullRequestFilesViewedJson,
28962774 }).pipe(
......@@ -2913,8 +2791,8 @@ export const make = Effect.gen(function* () {
29132791 return pullRequestNodeId({ ...input, operation: "setPullRequestFilesViewed" }).pipe(
29142792 Effect.flatMap((pullRequestId) =>
29152793 graphql({
2916 cwd: input.cwd,
29172794 host: input.host,
2795 operation: "setPullRequestFilesViewed",
29182796 query: mutation.query,
29192797 variables: { pullRequestId, ...mutation.variables },
29202798 }),
......@@ -2924,8 +2802,8 @@ export const make = Effect.gen(function* () {
29242802
29252803 setReviewThreadResolution: (input) =>
29262804 graphql({
2927 cwd: input.cwd,
29282805 host: input.host,
2806 operation: "setReviewThreadResolution",
29292807 query: input.resolved
29302808 ? RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION
29312809 : UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION,
......@@ -2957,8 +2835,8 @@ export const make = Effect.gen(function* () {
29572835 return subjectId.pipe(
29582836 Effect.flatMap((subjectId) =>
29592837 graphql({
2960 cwd: input.cwd,
29612838 host: input.host,
2839 operation: "setReaction",
29622840 query: input.reacted ? ADD_REACTION_GRAPHQL_MUTATION : REMOVE_REACTION_GRAPHQL_MUTATION,
29632841 variables: { subjectId, content: gitHubReactionContent(input.content) },
29642842 }),
......@@ -2970,8 +2848,8 @@ export const make = Effect.gen(function* () {
29702848 pullRequestNodeId({ ...input, operation: "updatePullRequest" }).pipe(
29712849 Effect.flatMap((pullRequestId) =>
29722850 graphql({
2973 cwd: input.cwd,
29742851 host: input.host,
2852 operation: "updatePullRequest",
29752853 query: UPDATE_PULL_REQUEST_GRAPHQL_MUTATION,
29762854 // A field the caller did not name is left out of the request entirely, so GitHub
29772855 // keeps the words that are there rather than being asked for an empty one.
......@@ -3006,8 +2884,8 @@ export const make = Effect.gen(function* () {
30062884 ),
30072885 Effect.flatMap((commentId) =>
30082886 graphql({
3009 cwd: input.cwd,
30102887 host: input.host,
2888 operation: "updateComment",
30112889 query:
30122890 input.kind === "issue-comment"
30132891 ? UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION
apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts+6-7
......@@ -6,7 +6,7 @@ import * as Result from "effect/Result";
66import type { PullRequestReaction } from "@t3tools/contracts";
77
88import { decodePullRequestDetailJson } from "./gitHubPullRequestJson.ts";
9import * as GitHubCli from "../sourceControl/GitHubCli.ts";
9import * as GitHubApi from "../sourceControl/GitHubApi.ts";
1010import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";
1111import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts";
1212import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts";
......@@ -331,7 +331,7 @@ describe("gitHubViewerPermissions", () => {
331331 number: 7,
332332 })
333333 .pipe(
334 Effect.provideService(GitHubCli.PinnedGitHubCredential, {
334 Effect.provideService(GitHubApi.PinnedGitHubCredential, {
335335 host: "github.com",
336336 token: Redacted.make("credential"),
337337 credentialFingerprint: fingerprint,
......@@ -378,7 +378,7 @@ describe("gitHubViewerPermissions", () => {
378378 commits: [],
379379 }).pipe(
380380 Effect.flatMap((detail) =>
381 GitHubCli.PinnedGitHubCredential.pipe(
381 GitHubApi.PinnedGitHubCredential.pipe(
382382 Effect.map((credential) => ({
383383 ...detail,
384384 viewerAccess: {
......@@ -672,10 +672,9 @@ it.effect("propagates workflow discovery rate limits", () =>
672672 getPullRequestBaseComparison: () => Effect.succeed({ behindBy: 0, viewerCanUpdate: true }),
673673 listWorkflowRunsRequiringApproval: () =>
674674 Effect.fail(
675 new GitHubCli.GitHubCliRateLimitError({
676 command: "gh",
677 cwd: "/w",
678 cause: new Error("rate limited"),
675 new GitHubApi.GitHubApiRateLimitError({
676 host: "github.com",
677 operation: "listWorkflowRunsRequiringApproval",
679678 }),
680679 ),
681680 getViewerAccess: () =>
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+20-14
......@@ -101,22 +101,28 @@ export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequest
101101 };
102102}
103103
104/** The CLI tags that mean the tool itself is unusable, rather than one request failing. */
104/** The tags that mean GitHub is out of reach for this account, rather than one request failing. */
105105export function gitHubProviderFailure(
106106 error: GitHubPullRequestCli.GitHubPullRequestCliError,
107107): PullRequestProviderFailure {
108 if (error._tag === "GitHubCliUnavailableError") return { reason: "missing-tool" };
109 if (error._tag === "GitHubCliAuthenticationError") return { reason: "unauthenticated" };
110 if (error._tag === "GitHubCliRateLimitError")
111 return {
112 reason: "rate-limited",
113 ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }),
114 };
115 if (error._tag === "SourceControlRateLimitPausedError") {
116 return { reason: "rate-limited", retryAt: error.retryAt };
108 switch (error._tag) {
109 case "GitHubCliMissingError":
110 return { reason: "missing-tool" };
111 case "GitHubNotSignedInError":
112 case "GitHubApiAuthenticationError":
113 return { reason: "unauthenticated" };
114 case "GitHubApiRateLimitError":
115 return {
116 reason: "rate-limited",
117 ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }),
118 };
119 case "SourceControlRateLimitPausedError":
120 return { reason: "rate-limited", retryAt: error.retryAt };
121 case "GitHubApiNotFoundError":
122 return { reason: "not-found" };
123 default:
124 return { reason: "failed" };
117125 }
118 if (error._tag === "GitHubPullRequestNotFoundError") return { reason: "not-found" };
119 return { reason: "failed" };
120126}
121127
122128/**
......@@ -196,7 +202,7 @@ export const make = Effect.gen(function* () {
196202 provider: "github",
197203 operation,
198204 ...gitHubProviderFailure(error),
199 detail: error.detail,
205 detail: error.message,
200206 cause: error,
201207 });
202208
......@@ -224,7 +230,7 @@ export const make = Effect.gen(function* () {
224230 .pipe(
225231 Effect.matchEffect({
226232 onFailure: (error) =>
227 error._tag === "GitHubCliRateLimitError" ||
233 error._tag === "GitHubApiRateLimitError" ||
228234 error._tag === "SourceControlRateLimitPausedError"
229235 ? Effect.fail(error)
230236 : Effect.succeed({
apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts+2-2
......@@ -2,7 +2,7 @@ import { assert, it } from "@effect/vitest";
22
33import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts";
44import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";
5import * as GitHubCli from "../sourceControl/GitHubCli.ts";
5import * as GitHubApi from "../sourceControl/GitHubApi.ts";
66import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts";
77import * as GitLabCli from "../sourceControl/GitLabCli.ts";
88import { azureDevOpsProviderFailure } from "./AzureDevOpsPullRequestProvider.ts";
......@@ -15,7 +15,7 @@ const cause = new Error("redacted provider failure");
1515it("classifies rate limits from every pull-request provider", () => {
1616 assert.deepStrictEqual(
1717 gitHubProviderFailure(
18 new GitHubCli.GitHubCliRateLimitError({ command: "gh", cwd: "/repo", cause }),
18 new GitHubApi.GitHubApiRateLimitError({ host: "github.com", operation: "execute" }),
1919 ),
2020 { reason: "rate-limited" },
2121 );
apps/server/src/pullRequest/PullRequestProviderRegistry.ts+11-3
......@@ -5,8 +5,10 @@ import type { SourceControlProviderKind } from "@t3tools/contracts";
55
66import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts";
77import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";
8import * as GitHubCli from "../sourceControl/GitHubCli.ts";
8import * as GitHubApi from "../sourceControl/GitHubApi.ts";
9import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts";
910import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts";
11import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts";
1012import * as GitLabCli from "../sourceControl/GitLabCli.ts";
1113import * as ForgejoCli from "../sourceControl/ForgejoCli.ts";
1214import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts";
......@@ -60,8 +62,14 @@ export const make = Effect.map(
6062export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe(
6163 Layer.provide(
6264 GitHubPullRequestCli.layer.pipe(
63 Layer.provide(GitHubCli.layer),
64 Layer.provide(GitHubGraphQlBudget.layer),
65 Layer.provide(
66 GitHubApi.layer.pipe(
67 Layer.provide(GitHubCredentials.layer),
68 // The same layers GitHubCli merges, so both share one budget and one pause per host.
69 Layer.provide(GitHubGraphQlBudget.layer),
70 Layer.provide(SourceControlRateLimit.layer),
71 ),
72 ),
6573 ),
6674 ),
6775 Layer.provide(GitLabPullRequestCli.layer.pipe(Layer.provide(GitLabCli.layer))),
apps/server/src/pullRequest/gitHubConditionalChecks.test.ts+72-46
......@@ -4,11 +4,10 @@ import * as Effect from "effect/Effect";
44import * as Layer from "effect/Layer";
55import * as Schema from "effect/Schema";
66import * as Redacted from "effect/Redacted";
7import { ChildProcessSpawner } from "effect/process";
87import type { PullRequestCheck } from "@t3tools/contracts";
98
10import * as GitHubCli from "../sourceControl/GitHubCli.ts";
11import { makeChecksRevalidator } from "./gitHubConditionalChecks.ts";
9import * as GitHubApi from "../sourceControl/GitHubApi.ts";
10import { KnownWorkflowRuns, makeChecksRevalidator } from "./gitHubConditionalChecks.ts";
1211
1312const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
1413
......@@ -27,31 +26,46 @@ it.effect(
2726 let changed = "";
2827 let reads = 0;
2928 const requests: string[] = [];
29 const known: Array<unknown> = [];
3030 const revalidate = yield* makeChecksRevalidator.pipe(
3131 Effect.provide(
32 Layer.mock(GitHubCli.GitHubCli)({
33 execute: ({ args }) =>
32 Layer.mock(GitHubApi.GitHubApi)({
33 rest: ({ path, ifNoneMatch }) =>
3434 Effect.sync(() => {
35 const endpoint = args[1]!;
36 requests.push(endpoint);
37 const head = endpoint.endsWith("/pulls/1");
35 requests.push(path);
36 const head = path.endsWith("/pulls/1");
3837 const modified =
39 !args.includes("-H") || (endpoint.includes(changed) && changed !== "");
40 const next = endpoint.includes("check-runs") && endpoint.endsWith("page=1");
38 ifNoneMatch === undefined || (path.includes(changed) && changed !== "");
39 const next = path.includes("check-runs") && path.endsWith("page=1");
40 const runs = path.includes("/actions/runs");
4141 return {
42 exitCode: ChildProcessSpawner.ExitCode(modified ? 0 : 1),
43 stdout: modified
44 ? `HTTP/2.0 200 OK\r\nEtag: "${sha}-${changed}"\r\n${next ? 'Link: <https://api.github.com/next>; rel="next"\r\n' : ""}\r\n${head ? encodeJson({ head: { sha }, base: { repo: { id: 1 } }, headRepositoryId: 2 }) : ""}`
45 : "HTTP/2.0 304 Not Modified\r\n\r\n",
46 stderr: "",
47 stdoutTruncated: false,
48 stderrTruncated: false,
42 status: modified ? 200 : 304,
43 headers: modified
44 ? {
45 etag: `"${sha}-${changed}"`,
46 ...(next ? { link: '<https://api.github.com/next>; rel="next"' } : {}),
47 }
48 : {},
49 body: !modified
50 ? ""
51 : head
52 ? encodeJson({ head: { sha, repo: { id: 2 } }, base: { repo: { id: 1 } } })
53 : runs
54 ? encodeJson({
55 workflow_runs: [
56 { id: 9, status: "completed", conclusion: "action_required" },
57 ],
58 })
59 : "{}",
60 truncated: false,
61 invalidUtf8: false,
4962 };
5063 }),
5164 }),
5265 ),
5366 );
54 const read = Effect.sync(() => {
67 const read = Effect.gen(function* () {
68 known.push(yield* KnownWorkflowRuns);
5569 reads++;
5670 return {
5771 state: "open" as const,
......@@ -64,10 +78,15 @@ it.effect(
6478 });
6579 const poll = (identity = credential) =>
6680 revalidate(reference, read).pipe(
67 Effect.provideService(GitHubCli.PinnedGitHubCredential, identity),
81 Effect.provideService(GitHubApi.PinnedGitHubCredential, identity),
6882 );
6983 yield* poll();
7084 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 });
7190 requests.length = 0;
7291 yield* poll();
7392 expect(reads).toBe(1);
......@@ -113,27 +132,32 @@ it.effect("does not retain failed or incomplete reads, and supports hosts withou
113132 let reads = 0;
114133 const revalidate = yield* makeChecksRevalidator.pipe(
115134 Effect.provide(
116 Layer.mock(GitHubCli.GitHubCli)({
117 execute: ({ args }) =>
135 Layer.mock(GitHubApi.GitHubApi)({
136 rest: ({ path, ifNoneMatch }) =>
118137 unavailable
119138 ? Effect.fail(
120 new GitHubCli.GitHubCliCommandError({
121 command: "gh",
122 cwd: "/repo",
123 cause: undefined,
124 httpStatus: 502,
139 new GitHubApi.GitHubApiResponseError({
140 host: "github.com",
141 operation: "revalidateChecks",
142 status: 502,
125143 }),
126144 )
127 : Effect.succeed({
128 exitCode: ChildProcessSpawner.ExitCode(0),
129 stdout:
130 args.includes("-H") && etags
131 ? "HTTP/2.0 304 Not Modified\n\n"
132 : `HTTP/2.0 200 OK\n${etags ? 'Etag: "one"\n' : ""}\n${args[1]!.endsWith("/pulls/1") ? encodeJson({ head: { sha }, base: { repo: { id: 1 } }, headRepositoryId: 1 }) : ""}`,
133 stderr: "",
134 stdoutTruncated: false,
135 stderrTruncated: false,
136 }),
145 : Effect.succeed(
146 ifNoneMatch !== undefined && etags
147 ? { status: 304, headers: {}, body: "", truncated: false, invalidUtf8: false }
148 : {
149 status: 200,
150 headers: etags ? { etag: '"one"' } : {},
151 body: path.endsWith("/pulls/1")
152 ? encodeJson({
153 head: { sha, repo: { id: 1 } },
154 base: { repo: { id: 1 } },
155 })
156 : "{}",
157 truncated: false,
158 invalidUtf8: false,
159 },
160 ),
137161 }),
138162 ),
139163 );
......@@ -141,7 +165,11 @@ it.effect("does not retain failed or incomplete reads, and supports hosts withou
141165 reads++;
142166 return fail
143167 ? Effect.fail(
144 new GitHubCli.GitHubCliCommandError({ command: "gh", cwd: "/repo", cause: undefined }),
168 new GitHubApi.GitHubApiResponseError({
169 host: "github.com",
170 operation: "read",
171 status: 500,
172 }),
145173 )
146174 : Effect.succeed({
147175 state: "open" as const,
......@@ -152,7 +180,7 @@ it.effect("does not retain failed or incomplete reads, and supports hosts withou
152180 });
153181 const poll = () =>
154182 revalidate(reference, read).pipe(
155 Effect.provideService(GitHubCli.PinnedGitHubCredential, credential),
183 Effect.provideService(GitHubApi.PinnedGitHubCredential, credential),
156184 );
157185 yield* poll().pipe(Effect.flip);
158186 fail = false;
......@@ -190,15 +218,13 @@ it.effect("falls back when REST checks are unavailable without retrying unsuppor
190218 let reads = 0;
191219 const revalidate = yield* makeChecksRevalidator.pipe(
192220 Effect.provide(
193 Layer.mock(GitHubCli.GitHubCli)({
194 execute: () => {
221 Layer.mock(GitHubApi.GitHubApi)({
222 rest: () => {
195223 probes++;
196224 return Effect.fail(
197 new GitHubCli.GitHubCliCommandError({
198 command: "gh",
199 cwd: "/repo",
200 cause: undefined,
201 httpStatus: 404,
225 new GitHubApi.GitHubApiNotFoundError({
226 host: "github.com",
227 operation: "revalidateChecks",
202228 }),
203229 );
204230 },
......@@ -211,7 +237,7 @@ it.effect("falls back when REST checks are unavailable without retrying unsuppor
211237 });
212238 for (let tick = 0; tick < 2; tick++)
213239 yield* revalidate(reference, read).pipe(
214 Effect.provideService(GitHubCli.PinnedGitHubCredential, credential),
240 Effect.provideService(GitHubApi.PinnedGitHubCredential, credential),
215241 );
216242 expect(probes).toBe(1);
217243 expect(reads).toBe(2);
apps/server/src/pullRequest/gitHubConditionalChecks.ts+95-54
......@@ -1,32 +1,63 @@
11import * as Clock from "effect/Clock";
22import * as Cache from "effect/Cache";
3import * as Context from "effect/Context";
34import * as Effect from "effect/Effect";
5import * as Option from "effect/Option";
46import * as Schema from "effect/Schema";
57import * as Semaphore from "effect/Semaphore";
68import { PositiveInt, type PullRequestChecks } from "@t3tools/contracts";
79
8import * as GitHubCli from "../sourceControl/GitHubCli.ts";
10import * as GitHubApi from "../sourceControl/GitHubApi.ts";
911import type { GitHubPullRequestDetail } from "./gitHubPullRequestJson.ts";
1012import type { GitHubPullRequestCliError } from "./GitHubPullRequestCli.ts";
1113import type { ProviderRepositoryRef } from "./PullRequestProvider.ts";
1214
13const decodeHeadSchema = Schema.Struct({
14 head: Schema.Struct({ sha: Schema.String.check(Schema.isPattern(/^[a-f0-9]{40,64}$/)) }),
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 }),
1521 base: Schema.Struct({ repo: Schema.Struct({ id: PositiveInt }) }),
16 headRepositoryId: Schema.NullOr(PositiveInt),
1722});
18const decodeHead = Schema.decodeUnknownEffect(Schema.fromJsonString(decodeHeadSchema));
23const decodeHead = Schema.decodeUnknownEffect(Schema.fromJsonString(HeadSchema));
1924
20type Validator = { etag: string | undefined; next: boolean };
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]);
2152
2253export const makeChecksRevalidator = Effect.gen(function* () {
23 const github = yield* GitHubCli.GitHubCli;
54 const api = yield* GitHubApi.GitHubApi;
2455 const entries = yield* Cache.makeWith(
2556 (_key: string) =>
2657 Effect.sync(() => ({
2758 gate: Semaphore.makeUnsafe(1),
2859 validators: new Map<string, Validator>(),
29 head: null as Schema.Schema.Type<typeof decodeHeadSchema> | null,
60 head: null as typeof HeadSchema.Type | null,
3061 value: null as PullRequestChecks | null,
3162 readAt: 0,
3263 supported: true,
......@@ -43,71 +74,64 @@ export const makeChecksRevalidator = Effect.gen(function* () {
4374 >,
4475 ) =>
4576 Effect.gen(function* () {
46 const credential = yield* GitHubCli.PinnedGitHubCredential;
77 const credential = yield* GitHubApi.PinnedGitHubCredential;
4778 if (credential === null) return yield* read;
4879 const key = `${credential.credentialFingerprint}\0${input.host}\0${input.repository}\0${input.number}`;
4980 const entry = yield* Cache.get(entries, key);
5081 return yield* entry.gate.withPermit(
5182 Effect.gen(function* () {
5283 if (!entry.supported) return yield* read;
53 const fail = () =>
54 new GitHubCli.GitHubCliCommandError({
55 command: "gh",
56 cwd: input.cwd,
57 cause: new Error("GitHub returned an invalid conditional checks response."),
84 const fail = (status: number) =>
85 new GitHubApi.GitHubApiResponseError({
86 host: input.host,
87 operation: "revalidateChecks",
88 status,
5889 });
90 /** Whether the endpoint has a next page, or null where the host cannot say. */
5991 const get = (endpoint: string, head = false) =>
6092 Effect.gen(function* () {
6193 const previous = entry.validators.get(endpoint);
62 const result = yield* github
63 .execute({
64 cwd: input.cwd,
65 args: [
66 "api",
67 endpoint,
68 "--hostname",
69 input.host,
70 "--include",
71 ...(head
72 ? [
73 "--jq",
74 "{head:{sha:.head.sha},base:{repo:{id:.base.repo.id}},headRepositoryId:.head.repo.id}",
75 ]
76 : ["--silent"]),
77 ...(previous?.etag ? ["-H", `If-None-Match: ${previous.etag}`] : []),
78 ],
79 acceptNotModified: true,
94 const response = yield* api
95 .rest({
96 host: input.host,
97 operation: "revalidateChecks",
98 path: endpoint,
99 ...(previous?.etag ? { ifNoneMatch: previous.etag } : {}),
80100 })
81101 .pipe(
82102 Effect.catchTags({
83 GitHubCliCommandError: (error) =>
103 GitHubApiNotFoundError: () =>
84104 Effect.sync(() => {
85 entry.supported = ![404, 405, 501].includes(error.httpStatus ?? 0);
105 entry.supported = false;
106 return null;
107 }),
108 GitHubApiResponseError: (error) =>
109 Effect.sync(() => {
110 entry.supported = !UNSUPPORTED_STATUSES.has(error.status);
86111 return null;
87112 }),
88113 }),
89114 );
90 if (result === null) {
115 if (response === null) {
91116 entry.value = null;
92117 return null;
93118 }
94 if (result.stdoutTruncated || result.stdoutInvalidUtf8) return yield* fail();
95 const split = result.stdout.search(/\r?\n\r?\n/);
96 const headers = split < 0 ? result.stdout : result.stdout.slice(0, split);
97 const status = /^HTTP\/\S+ (\d+)/.exec(headers)?.[1];
98 if (status === "304" && previous) return previous.next;
99 if (status !== "200") return yield* fail();
119 if (response.status === 304 && previous) return previous.next;
120 if (response.status !== 200 || response.truncated) {
121 return yield* fail(response.status);
122 }
100123 entry.value = null;
101124 if (head) {
102 const decoded = yield* decodeHead(result.stdout.slice(split).trim()).pipe(
103 Effect.mapError(fail),
125 const decoded = yield* decodeHead(response.body).pipe(
126 Effect.mapError(() => fail(response.status)),
104127 );
105128 if (entry.head?.head.sha !== decoded.head.sha) entry.validators.clear();
106129 entry.head = decoded;
107130 }
108131 const validator = {
109 etag: /^etag:\s*(.+)$/im.exec(headers)?.[1]?.trim(),
110 next: /^link:.*rel="next"/im.test(headers),
132 etag: response.headers["etag"]?.trim(),
133 next: /rel="next"/.test(response.headers["link"] ?? ""),
134 body: response.body,
111135 };
112136 entry.validators.set(endpoint, validator);
113137 return validator.next;
......@@ -115,27 +139,44 @@ export const makeChecksRevalidator = Effect.gen(function* () {
115139 const root = `repos/${input.repository}`;
116140 if ((yield* get(`${root}/pulls/${input.number}`, true)) === null) return yield* read;
117141 const head = entry.head;
118 if (head === null) return yield* fail();
142 if (head === null) return yield* fail(200);
143 const runsEndpoint =
144 head.head.repo?.id !== head.base.repo.id
145 ? `${root}/actions/runs?head_sha=${head.head.sha}&event=pull_request`
146 : null;
119147 const endpoints = [
120148 `${root}/commits/${head.head.sha}/check-runs?filter=all`,
121149 `${root}/commits/${head.head.sha}/status`,
122 ...(head.headRepositoryId !== head.base.repo.id
123 ? [`${root}/actions/runs?head_sha=${head.head.sha}&event=pull_request`]
124 : []),
150 ...(runsEndpoint === null ? [] : [runsEndpoint]),
125151 ];
152 const runPages: Array<string> = [];
126153 for (const endpoint of endpoints) {
127154 for (let page = 1; ; page++) {
128 if (page > 100) return yield* fail();
129 const next = yield* get(
130 `${endpoint}${endpoint.includes("?") ? "&" : "?"}per_page=100&page=${page}`,
131 );
155 if (page > 100) return yield* fail(200);
156 const paged = `${endpoint}${endpoint.includes("?") ? "&" : "?"}per_page=100&page=${page}`;
157 const next = yield* get(paged);
132158 if (next === null) return yield* read;
159 if (endpoint === runsEndpoint) runPages.push(paged);
133160 if (!next) break;
134161 }
135162 }
136163 const now = yield* Clock.currentTimeMillis;
137164 if (entry.value !== null && now - entry.readAt < 5 * 60_000) return entry.value;
138 const fresh = yield* read;
165 // Every page of the head's runs was just confirmed current, so the read filters them
166 // rather than listing the runs a second time.
167 const pages = runPages.map((endpoint) =>
168 decodeWorkflowRuns(entry.validators.get(endpoint)?.body ?? ""),
169 );
170 const known =
171 runsEndpoint !== null && pages.every(Option.isSome)
172 ? {
173 headSha: head.head.sha,
174 runs: pages.flatMap((page) =>
175 Option.isSome(page) ? page.value.workflow_runs : [],
176 ),
177 }
178 : null;
179 const fresh = yield* read.pipe(Effect.provideService(KnownWorkflowRuns, known));
139180 const value = {
140181 state: fresh.state,
141182 checks: fresh.checks,
apps/server/src/pullRequest/gitHubPullRequestJson.test.ts+145-114
......@@ -6,10 +6,10 @@ import {
66 buildPullRequestWatchFingerprintsGraphQlQuery,
77 decodePullRequestSummariesJson,
88 decodePullRequestWatchFingerprintsJson,
9 buildReviewSubmissionJson,
9 buildReviewSubmission,
1010 buildPullRequestStackMembershipsGraphQlQuery,
1111 decodePullRequestStackMembershipsJson,
12 buildReviewerRequestJson,
12 buildReviewerRequest,
1313 buildSetFilesViewedGraphQlMutation,
1414 decodeBaseComparisonJson,
1515 decodePullRequestActivityJson,
......@@ -25,26 +25,50 @@ import {
2525 decodeReviewThreadCommentsJson,
2626 decodeReviewThreadsJson,
2727 decodeViewerPermissionsJson,
28 decodeWorkflowRunApprovalsJson,
28 decodeWorkflowRunsJson,
2929 reviewThreadConversation,
3030 REVIEW_THREADS_GRAPHQL_QUERY,
3131 pullRequestCoreGraphQlQuery,
3232 pullRequestSearchGraphQlQuery,
3333} from "./gitHubPullRequestJson.ts";
3434
35/**
36 * Rows as a repository's own GraphQL list answers them. Entries may use flat shapes for brevity:
37 * `reviewRequests` and `latestReviews` as arrays, and `checks` as the rollup state.
38 */
3539function listJson(entries: ReadonlyArray<Record<string, unknown>>): string {
36 return JSON.stringify(
37 entries.map((entry) => ({
38 number: 1,
39 title: "Add the pull requests page",
40 url: "https://github.com/pingdotgg/t3code/pull/1",
41 headRefName: "feat/page",
42 baseRefName: "main",
43 createdAt: "2026-07-01T00:00:00Z",
44 updatedAt: "2026-07-02T00:00:00Z",
45 ...entry,
46 })),
47 );
40 return JSON.stringify({
41 data: {
42 repository: {
43 pullRequests: {
44 pageInfo: { hasNextPage: false },
45 nodes: entries.map(({ reviewRequests, latestReviews, checks, ...entry }) => ({
46 number: 1,
47 title: "Add the pull requests page",
48 url: "https://github.com/pingdotgg/t3code/pull/1",
49 headRefName: "feat/page",
50 baseRefName: "main",
51 createdAt: "2026-07-01T00:00:00Z",
52 updatedAt: "2026-07-02T00:00:00Z",
53 ...(reviewRequests === undefined
54 ? {}
55 : {
56 reviewRequests: {
57 nodes: (reviewRequests as ReadonlyArray<unknown>).map((requestedReviewer) => ({
58 requestedReviewer,
59 })),
60 },
61 }),
62 ...(latestReviews === undefined ? {} : { latestReviews: { nodes: latestReviews } }),
63 ...(checks === undefined
64 ? {}
65 : { commits: { nodes: [{ commit: { statusCheckRollup: { state: checks } } }] } }),
66 ...entry,
67 })),
68 },
69 },
70 },
71 });
4872}
4973
5074function expectSuccess<A>(result: Result.Result<A, unknown>): A {
......@@ -136,38 +160,15 @@ describe("pull request list decoding", () => {
136160 ]);
137161 });
138162
139 it("rolls the head commit's checks up to the one word a row has space for", () => {
163 it("rolls the head commit's rollup up to the one word a row has space for", () => {
140164 const batch = expectSuccess(
141165 decodePullRequestListJson(
142166 listJson([
143 // A failure outranks a run still going, and a completed run has to be read through its
144 // conclusion rather than its status.
145 {
146 statusCheckRollup: [
147 { name: "lint", status: "COMPLETED", conclusion: "SUCCESS" },
148 { name: "build", status: "IN_PROGRESS" },
149 { name: "test", status: "COMPLETED", conclusion: "FAILURE" },
150 ],
151 },
152 {
153 statusCheckRollup: [
154 { name: "lint", status: "COMPLETED", conclusion: "SUCCESS" },
155 { name: "build", status: "QUEUED" },
156 ],
157 },
158 { statusCheckRollup: [{ name: "lint", status: "COMPLETED", conclusion: "SUCCESS" }] },
159 // A commit status reports one `state` and no `status` at all.
160 { statusCheckRollup: [{ context: "ci/legacy", state: "ERROR" }] },
161 // Neither a pass, a failure nor a wait is no verdict rather than a green tick.
162 { statusCheckRollup: [{ name: "lint", status: "COMPLETED", conclusion: "SKIPPED" }] },
163 // Cancelled reads as failing here and in the detail header, so the two never flap.
164 {
165 statusCheckRollup: [
166 { name: "lint", status: "COMPLETED", conclusion: "SUCCESS" },
167 { name: "test", status: "COMPLETED", conclusion: "CANCELLED" },
168 ],
169 },
170 { statusCheckRollup: [] },
167 { checks: "FAILURE" },
168 { checks: "PENDING" },
169 { checks: "SUCCESS" },
170 { checks: "ERROR" },
171 { checks: "EXPECTED" },
171172 {},
172173 ]),
173174 ),
......@@ -177,16 +178,15 @@ describe("pull request list decoding", () => {
177178 "pending",
178179 "passing",
179180 "failing",
180 null,
181 "failing",
182 null,
181 "pending",
183182 null,
184183 ]);
185184 });
186185
187186 it("skips malformed entries but still counts them, so paging does not stop early", () => {
188 const raw = `[${listJson([{}]).slice(1, -1)},{"number":"not-a-number"}]`;
189 const batch = expectSuccess(decodePullRequestListJson(raw));
187 const batch = expectSuccess(
188 decodePullRequestListJson(listJson([{}, { number: "not-a-number" }])),
189 );
190190 expect(batch.items).toHaveLength(1);
191191 expect(batch.rawCount).toBe(2);
192192 });
......@@ -279,6 +279,43 @@ describe("pull request detail decoding", () => {
279279 ],
280280 });
281281
282 /** The same conversation as the GraphQL activity query answers it. */
283 const activityJson = (raw: Record<string, unknown>) =>
284 JSON.stringify({
285 data: {
286 repository: {
287 pullRequest: {
288 comments: { nodes: raw["comments"] ?? [] },
289 reviews: { nodes: raw["reviews"] ?? [] },
290 commits: {
291 nodes: ((raw["commits"] ?? []) as ReadonlyArray<Record<string, unknown>>).map(
292 ({ authors, ...commit }) => ({
293 commit: {
294 ...commit,
295 authors: {
296 nodes: ((authors ?? []) as ReadonlyArray<Record<string, unknown>>).map(
297 ({ login, ...author }) => ({
298 ...author,
299 ...(login === undefined ? {} : { user: { login } }),
300 }),
301 ),
302 },
303 },
304 }),
305 ),
306 },
307 },
308 },
309 },
310 });
311 const activity = (raw: Record<string, unknown>) =>
312 Result.map(decodePullRequestActivityJson(activityJson(raw)), (page) => ({
313 comments: page.remarks.toSorted((left, right) =>
314 left.createdAt.localeCompare(right.createdAt),
315 ),
316 commits: page.commits ?? [],
317 }));
318
282319 it("maps check-run status and commit-status state onto one vocabulary", () => {
283320 const detail = expectSuccess(decodePullRequestDetailJson(detailJson));
284321 expect(detail.checks.map((check) => [check.name, check.status])).toEqual([
......@@ -333,13 +370,15 @@ describe("pull request detail decoding", () => {
333370 it("decodes workflow runs that can be approved", () => {
334371 expect(
335372 expectSuccess(
336 decodeWorkflowRunApprovalsJson(
337 JSON.stringify([
338 { databaseId: 10, workflowName: "contributor tests", url: "https://example.com/10" },
339 { databaseId: 11, workflowName: null, url: null },
340 ]),
373 decodeWorkflowRunsJson(
374 JSON.stringify({
375 workflow_runs: [
376 { id: 10, name: "contributor tests", html_url: "https://example.com/10" },
377 { id: 11, name: null, html_url: null },
378 ],
379 }),
341380 ),
342 ),
381 ).runs,
343382 ).toEqual([
344383 { id: 10, name: "contributor tests", url: "https://example.com/10" },
345384 { id: 11, name: "Workflow run 11", url: null },
......@@ -398,14 +437,14 @@ describe("pull request detail decoding", () => {
398437 });
399438
400439 it("merges reviews with comments in time order and keeps a bodyless approval", () => {
401 const detail = expectSuccess(decodePullRequestActivityJson(detailJson));
440 const detail = expectSuccess(activity(JSON.parse(detailJson) as Record<string, unknown>));
402441 // r2 approved without writing anything, which is still the event worth seeing.
403442 expect(detail.comments.map((comment) => comment.id)).toEqual(["r1", "c1", "r2"]);
404443 expect(detail.comments.at(-1)?.reviewState).toBe("APPROVED");
405444 });
406445
407446 it("keeps every attributed commit author, including an unlinked signature", () => {
408 const detail = expectSuccess(decodePullRequestActivityJson(detailJson));
447 const detail = expectSuccess(activity(JSON.parse(detailJson) as Record<string, unknown>));
409448 expect(detail.commits[0]?.authors).toEqual([
410449 { login: "octocat", name: "Octo Cat", avatarUrl: null },
411450 { login: "Pair Author", name: "Pair Author", avatarUrl: null },
......@@ -415,22 +454,20 @@ describe("pull request detail decoding", () => {
415454 it("drops the bodyless review GitHub opens to hold line comments", () => {
416455 const raw = JSON.parse(detailJson) as Record<string, unknown>;
417456 const detail = expectSuccess(
418 decodePullRequestActivityJson(
419 JSON.stringify({
420 ...raw,
421 reviews: [
422 // What a reviewer leaving inline comments produces: a container with a state but
423 // nothing to read. Its comments come from the review threads instead.
424 { id: "r4", body: "", state: "COMMENTED", submittedAt: "2026-07-07T00:00:00Z" },
425 {
426 id: "r5",
427 body: "Looks good.",
428 state: "COMMENTED",
429 submittedAt: "2026-07-08T00:00:00Z",
430 },
431 ],
432 }),
433 ),
457 activity({
458 ...raw,
459 reviews: [
460 // What a reviewer leaving inline comments produces: a container with a state but
461 // nothing to read. Its comments come from the review threads instead.
462 { id: "r4", body: "", state: "COMMENTED", submittedAt: "2026-07-07T00:00:00Z" },
463 {
464 id: "r5",
465 body: "Looks good.",
466 state: "COMMENTED",
467 submittedAt: "2026-07-08T00:00:00Z",
468 },
469 ],
470 }),
434471 );
435472
436473 expect(detail.comments.map((comment) => comment.id)).toEqual(["c1", "r5"]);
......@@ -441,12 +478,10 @@ describe("pull request detail decoding", () => {
441478 (state) => {
442479 const raw = JSON.parse(detailJson) as Record<string, unknown>;
443480 const detail = expectSuccess(
444 decodePullRequestActivityJson(
445 JSON.stringify({
446 ...raw,
447 reviews: [{ id: "r6", body: "", state, submittedAt: "2026-07-07T00:00:00Z" }],
448 }),
449 ),
481 activity({
482 ...raw,
483 reviews: [{ id: "r6", body: "", state, submittedAt: "2026-07-07T00:00:00Z" }],
484 }),
450485 );
451486
452487 expect(detail.comments.map((comment) => comment.id)).toContain("r6");
......@@ -456,12 +491,10 @@ describe("pull request detail decoding", () => {
456491 it("drops a review that carries neither a body nor a state", () => {
457492 const raw = JSON.parse(detailJson) as Record<string, unknown>;
458493 const detail = expectSuccess(
459 decodePullRequestActivityJson(
460 JSON.stringify({
461 ...raw,
462 reviews: [{ id: "r3", body: " ", submittedAt: "2026-07-07T00:00:00Z" }],
463 }),
464 ),
494 activity({
495 ...raw,
496 reviews: [{ id: "r3", body: " ", submittedAt: "2026-07-07T00:00:00Z" }],
497 }),
465498 );
466499 expect(detail.comments.map((comment) => comment.id)).toEqual(["c1"]);
467500 });
......@@ -1432,18 +1465,16 @@ describe("reviewer candidate decoding", () => {
14321465describe("reviewer request payload", () => {
14331466 it("sends people and teams in the two lists GitHub keeps them in", () => {
14341467 expect(
1435 JSON.parse(
1436 buildReviewerRequestJson([
1437 { id: "octocat", kind: "user" },
1438 { id: "reviewers", kind: "team" },
1439 { id: "hubot", kind: "user" },
1440 ]),
1441 ),
1468 buildReviewerRequest([
1469 { id: "octocat", kind: "user" },
1470 { id: "reviewers", kind: "team" },
1471 { id: "hubot", kind: "user" },
1472 ]),
14421473 ).toEqual({ reviewers: ["octocat", "hubot"], team_reviewers: ["reviewers"] });
14431474 });
14441475
14451476 it("sends both lists even where one of them is empty, which is what GitHub reads", () => {
1446 expect(JSON.parse(buildReviewerRequestJson([{ id: "octocat", kind: "user" }]))).toEqual({
1477 expect(buildReviewerRequest([{ id: "octocat", kind: "user" }])).toEqual({
14471478 reviewers: ["octocat"],
14481479 team_reviewers: [],
14491480 });
......@@ -1452,24 +1483,22 @@ describe("reviewer request payload", () => {
14521483
14531484describe("review submission payload", () => {
14541485 it("sends the verdict, the summary and every line comment in one body", () => {
1455 const payload = JSON.parse(
1456 buildReviewSubmissionJson({
1457 verdict: "request-changes",
1458 body: "Two things.",
1459 comments: [
1460 {
1461 path: "src/a.ts",
1462 position: { kind: "added", newLine: 12 },
1463 body: "rename this",
1464 },
1465 {
1466 path: "src/b.ts",
1467 position: { kind: "deleted", oldLine: 3 },
1468 body: "why remove?",
1469 },
1470 ],
1471 }),
1472 ) as Record<string, unknown>;
1486 const payload = buildReviewSubmission({
1487 verdict: "request-changes",
1488 body: "Two things.",
1489 comments: [
1490 {
1491 path: "src/a.ts",
1492 position: { kind: "added", newLine: 12 },
1493 body: "rename this",
1494 },
1495 {
1496 path: "src/b.ts",
1497 position: { kind: "deleted", oldLine: 3 },
1498 body: "why remove?",
1499 },
1500 ],
1501 });
14731502 expect(payload).toEqual({
14741503 event: "REQUEST_CHANGES",
14751504 body: "Two things.",
......@@ -1481,9 +1510,11 @@ describe("review submission payload", () => {
14811510 });
14821511
14831512 it("sends an approval with no words and no comments", () => {
1484 expect(
1485 JSON.parse(buildReviewSubmissionJson({ verdict: "approve", body: "", comments: [] })),
1486 ).toEqual({ event: "APPROVE", body: "", comments: [] });
1513 expect(buildReviewSubmission({ verdict: "approve", body: "", comments: [] })).toEqual({
1514 event: "APPROVE",
1515 body: "",
1516 comments: [],
1517 });
14871518 });
14881519});
14891520
apps/server/src/pullRequest/gitHubPullRequestJson.ts+485-174
......@@ -155,6 +155,9 @@ const RawSearchItemSchema = Schema.Struct({
155155 latestReviews: Schema.optional(
156156 Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.NullOr(RawLatestReviewSchema)) })),
157157 ),
158 /** Asked for by the per-repository listing, and left out of the cross-repository search. */
159 additions: Schema.optional(Schema.Int),
160 deletions: Schema.optional(Schema.Int),
158161 createdAt: Schema.String,
159162 updatedAt: Schema.String,
160163 mergedAt: Schema.optional(Schema.NullOr(Schema.String)),
......@@ -167,7 +170,14 @@ const RawSearchItemSchema = Schema.Struct({
167170 Schema.Array(
168171 Schema.NullOr(
169172 Schema.Struct({
170 requestedReviewer: Schema.optional(Schema.NullOr(RawActorSchema)),
173 requestedReviewer: Schema.optional(
174 Schema.NullOr(
175 Schema.Struct({
176 ...RawActorSchema.fields,
177 slug: Schema.optional(Schema.NullOr(Schema.String)),
178 }),
179 ),
180 ),
171181 }),
172182 ),
173183 ),
......@@ -215,14 +225,27 @@ const RawSearchItemSchema = Schema.Struct({
215225 ),
216226});
217227
228const RawRowConnectionSchema = Schema.Struct({
229 pageInfo: Schema.optional(
230 Schema.NullOr(
231 Schema.Struct({
232 hasNextPage: Schema.Boolean,
233 endCursor: Schema.optional(Schema.NullOr(Schema.String)),
234 }),
235 ),
236 ),
237 // Row by row: a node that is not a pull request — or one field GitHub changes — is skipped
238 // rather than blanking every repository at once.
239 nodes: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))),
240});
241
218242const RawSearchSchema = Schema.Struct({
243 data: Schema.Struct({ search: RawRowConnectionSchema }),
244});
245
246const RawRepositoryPullRequestsSchema = Schema.Struct({
219247 data: Schema.Struct({
220 search: Schema.Struct({
221 pageInfo: Schema.optional(Schema.NullOr(Schema.Struct({ hasNextPage: Schema.Boolean }))),
222 // Row by row, like the listing's own: a node that is not a pull request — or one field
223 // GitHub changes — is skipped rather than blanking every repository at once.
224 nodes: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))),
225 }),
248 repository: Schema.NullOr(Schema.Struct({ pullRequests: RawRowConnectionSchema })),
226249 }),
227250});
228251
......@@ -414,31 +437,103 @@ const RawDetailSchema = Schema.Struct({
414437 ),
415438});
416439
417const RawWorkflowRunApprovalSchema = Schema.Struct({
418 databaseId: Schema.Int,
419 workflowName: Schema.optional(Schema.NullOr(Schema.String)),
420 url: Schema.optional(Schema.NullOr(Schema.String)),
440/** `GET /repos/{owner}/{repo}/actions/runs`, one page of it. */
441const RawWorkflowRunsSchema = Schema.Struct({
442 workflow_runs: Schema.Array(
443 Schema.Struct({
444 id: Schema.Int,
445 name: Schema.optional(Schema.NullOr(Schema.String)),
446 html_url: Schema.optional(Schema.NullOr(Schema.String)),
447 }),
448 ),
421449});
422450
423const RawPullRequestHeadSchema = Schema.Struct({
424 number: Schema.Int,
425 headRefOid: Schema.String,
426 isCrossRepository: Schema.optional(Schema.Boolean),
427 headRepositoryOwner: Schema.optional(Schema.NullOr(Schema.Struct({ login: Schema.String }))),
451const RawPullRequestHeadsSchema = Schema.Struct({
452 data: Schema.Struct({
453 repository: Schema.NullOr(
454 Schema.Struct({
455 pullRequests: Schema.Struct({
456 pageInfo: Schema.optional(RawPageInfoSchemaFields()),
457 nodes: Schema.Array(
458 Schema.Struct({
459 number: Schema.Int,
460 headRefOid: Schema.String,
461 isCrossRepository: Schema.optional(Schema.Boolean),
462 headRepositoryOwner: Schema.optional(
463 Schema.NullOr(Schema.Struct({ login: Schema.String })),
464 ),
465 }),
466 ),
467 }),
468 }),
469 ),
470 }),
428471});
429472
473const RawActivityConnection = <S extends Schema.Top>(node: S) =>
474 Schema.optional(
475 Schema.NullOr(
476 Schema.Struct({
477 pageInfo: Schema.optional(RawPageInfoSchemaFields()),
478 nodes: Schema.Array(node),
479 }),
480 ),
481 );
482
430483const RawActivitySchema = Schema.Struct({
431 author: Schema.optional(Schema.NullOr(RawActorSchema)),
432 comments: Schema.optional(Schema.Array(RawCommentSchema)),
433 reviews: Schema.optional(Schema.Array(RawReviewSchema)),
434 commits: Schema.optional(Schema.Array(RawCommitSchema)),
484 data: Schema.Struct({
485 repository: Schema.Struct({
486 pullRequest: Schema.Struct({
487 author: Schema.optional(Schema.NullOr(RawActorSchema)),
488 comments: RawActivityConnection(RawCommentSchema),
489 reviews: RawActivityConnection(RawReviewSchema),
490 commits: Schema.optional(
491 Schema.NullOr(
492 Schema.Struct({
493 nodes: Schema.Array(
494 Schema.Struct({
495 commit: Schema.Struct({
496 oid: Schema.String,
497 messageHeadline: Schema.optional(Schema.String),
498 committedDate: Schema.String,
499 authors: Schema.optional(
500 Schema.NullOr(
501 Schema.Struct({
502 nodes: Schema.Array(
503 Schema.Struct({
504 email: Schema.optional(Schema.NullOr(Schema.String)),
505 name: Schema.optional(Schema.NullOr(Schema.String)),
506 user: Schema.optional(
507 Schema.NullOr(
508 Schema.Struct({
509 login: Schema.optional(Schema.NullOr(Schema.String)),
510 }),
511 ),
512 ),
513 }),
514 ),
515 }),
516 ),
517 ),
518 }),
519 }),
520 ),
521 }),
522 ),
523 ),
524 }),
525 }),
526 }),
435527});
436528
437529/** Where a connection carries on from, which is what every paged read below follows. */
438const RawPageInfoSchema = Schema.Struct({
439 hasNextPage: Schema.optional(Schema.Boolean),
440 endCursor: Schema.optional(Schema.NullOr(Schema.String)),
441});
530function RawPageInfoSchemaFields() {
531 return Schema.Struct({
532 hasNextPage: Schema.optional(Schema.Boolean),
533 endCursor: Schema.optional(Schema.NullOr(Schema.String)),
534 });
535}
536const RawPageInfoSchema = RawPageInfoSchemaFields();
442537
443538/**
444539 * What GitHub says the viewer may do with a pull request. Both are optional so that an install
......@@ -707,19 +802,25 @@ export function decodeActorAvatarsJson(
707802 return Result.succeed(avatarsByLogin);
708803}
709804
710export const PULL_REQUEST_LIST_JSON_FIELDS =
711 "number,title,url,author,headRefName,baseRefName,state,isDraft,mergeable,reviewDecision,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,latestReviews,labels,statusCheckRollup";
712
713export const PULL_REQUEST_DETAIL_JSON_FIELDS = `${PULL_REQUEST_LIST_JSON_FIELDS},body,changedFiles,closedAt,isCrossRepository,headRepositoryOwner,headRefOid,autoMergeRequest`;
714
715805/**
716806 * Pull refs let the comparison share the detail read without first resolving a fork branch.
717807 * `isRequired` is asked for on github.com only: an older Enterprise server may not know it, and
718808 * an unknown field fails the whole read.
719809 */
720export const pullRequestCoreGraphQlQuery = (host: string) => {
810function checkContextNodesSelection(host: string): string {
721811 const required =
722812 host.toLowerCase() === "github.com" ? " isRequired(pullRequestNumber: $number)" : "";
813 return `nodes {
814 __typename
815 ... on StatusContext { context state targetUrl createdAt description${required} }
816 ... on CheckRun {
817 name status conclusion startedAt completedAt detailsUrl${required}
818 checkSuite { workflowRun { workflow { name } } }
819 }
820 }`;
821}
822
823export const pullRequestCoreGraphQlQuery = (host: string) => {
723824 return `query($owner: String!, $name: String!, $number: Int!, $headRef: String!) {
724825 repository(owner: $owner, name: $name) {
725826 mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed viewerPermission
......@@ -738,14 +839,7 @@ export const pullRequestCoreGraphQlQuery = (host: string) => {
738839 labels(first: 100) { nodes { name color } }
739840 commits(last: 1) {
740841 nodes { commit { statusCheckRollup { contexts(first: 100) {
741 nodes {
742 __typename
743 ... on StatusContext { context state targetUrl createdAt description${required} }
744 ... on CheckRun {
745 name status conclusion startedAt completedAt detailsUrl${required}
746 checkSuite { workflowRun { workflow { name } } }
747 }
748 }
842 ${checkContextNodesSelection(host)}
749843 pageInfo { hasNextPage }
750844 } } } }
751845 }
......@@ -791,7 +885,29 @@ export function decodePullRequestPreviewJson(
791885 }));
792886}
793887
794export const PULL_REQUEST_ACTIVITY_JSON_FIELDS = "author,comments,reviews,commits";
888/**
889 * The conversation a pull request carries outside its review threads: who opened it, its issue
890 * comments, its reviews and its newest commits. The two remark connections page independently, so
891 * each is switched on only while it has more to give; the first read asks for everything.
892 */
893export const PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $head: Boolean!, $withComments: Boolean!, $commentsAfter: String, $withReviews: Boolean!, $reviewsAfter: String) {
894 repository(owner: $owner, name: $name) {
895 pullRequest(number: $number) {
896 author @include(if: $head) { __typename login avatarUrl ... on User { name } }
897 commits(last: 100) @include(if: $head) {
898 nodes { commit { oid messageHeadline committedDate authors(first: 10) { nodes { name email user { login } } } } }
899 }
900 comments(first: 100, after: $commentsAfter) @include(if: $withComments) {
901 pageInfo { hasNextPage endCursor }
902 nodes { id body createdAt lastEditedAt url author { __typename login avatarUrl ... on User { name } } }
903 }
904 reviews(first: 100, after: $reviewsAfter) @include(if: $withReviews) {
905 pageInfo { hasNextPage endCursor }
906 nodes { id body state submittedAt lastEditedAt url author { __typename login avatarUrl ... on User { name } } }
907 }
908 }
909 }
910}`;
795911
796912/** GitHub's own ceiling on a connection page, which is what both thread reads ask for. */
797913const GRAPHQL_PAGE_SIZE = 100;
......@@ -818,13 +934,45 @@ export const PULL_REQUEST_SEARCH_MAX_ROWS = GRAPHQL_PAGE_SIZE;
818934 * than twenty labels shows twenty, and one that has asked more than twenty people for a review
819935 * is already past what a row can say.
820936 */
821export function pullRequestSearchGraphQlQuery(rows: number, includeStacks = false): string {
822 return `query($q: String!) {
823 search(query: $q, type: ISSUE, first: ${Math.min(Math.max(Math.trunc(rows), 1), PULL_REQUEST_SEARCH_MAX_ROWS)}) {
824 pageInfo { hasNextPage }
937export function pullRequestSearchGraphQlQuery(
938 rows: number,
939 includeStacks = false,
940 includeStats = false,
941): string {
942 return `query($q: String!, $after: String) {
943 search(query: $q, type: ISSUE, first: ${pageRows(rows)}, after: $after) {
944 pageInfo { hasNextPage endCursor }
825945 nodes {
826946 ... on PullRequest {
827 ${includeStacks ? "stack { number size baseRefName } stackEntry { position }" : ""}
947 ${pullRequestRowSelection(includeStacks, includeStats)}
948 }
949 }
950 }
951}`;
952}
953
954/**
955 * A repository's pull requests without search, newest created first: the order `gh pr list` lists
956 * in when it does not search, for a repository GitHub's search index does not cover.
957 */
958export function pullRequestListGraphQlQuery(rows: number, includeStacks = false): string {
959 return `query($owner: String!, $name: String!, $states: [PullRequestState!], $after: String) {
960 repository(owner: $owner, name: $name) {
961 pullRequests(first: ${pageRows(rows)}, after: $after, states: $states, orderBy: { field: CREATED_AT, direction: DESC }) {
962 pageInfo { hasNextPage endCursor }
963 nodes { ${pullRequestRowSelection(includeStacks, true)} }
964 }
965 }
966}`;
967}
968
969function pageRows(rows: number): number {
970 return Math.min(Math.max(Math.trunc(rows), 1), PULL_REQUEST_SEARCH_MAX_ROWS);
971}
972
973/** One listing row, the same whether it came from a search or from a repository's own list. */
974function pullRequestRowSelection(includeStacks: boolean, includeStats: boolean): string {
975 return `${includeStacks ? "stack { number size baseRefName } stackEntry { position }" : ""}
828976 number
829977 title
830978 url
......@@ -836,17 +984,14 @@ export function pullRequestSearchGraphQlQuery(rows: number, includeStacks = fals
836984 mergeable
837985 reviewDecision
838986 latestReviews(first: 20) { nodes { state author { login } } }
987 ${includeStats ? "additions deletions" : ""}
839988 createdAt
840989 updatedAt
841990 mergedAt
842991 repository { nameWithOwner }
843 reviewRequests(first: 20) { nodes { requestedReviewer { ... on User { login } } } }
992 reviewRequests(first: 20) { nodes { requestedReviewer { ... on User { login } ... on Team { slug } } } }
844993 labels(first: 20) { nodes { name color } }
845 commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }
846 }
847 }
848 }
849}`;
994 commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }`;
850995}
851996
852997/**
......@@ -1094,24 +1239,6 @@ export const UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION = `mutation($commentId: ID!,
10941239 }
10951240}`;
10961241
1097/**
1098 * A GraphQL request as `gh api graphql --input -` takes it. Variables travel in the document
1099 * rather than as `-f name=value` flags, so a reader's own words never reach argv.
1100 */
1101const GraphQlRequestSchema = Schema.Struct({
1102 query: Schema.String,
1103 variables: Schema.Record(Schema.String, Schema.String),
1104});
1105
1106const encodeGraphQlRequest = Schema.encodeSync(Schema.fromJsonString(GraphQlRequestSchema));
1107
1108export function encodeGraphQlRequestJson(input: {
1109 readonly query: string;
1110 readonly variables: Readonly<Record<string, string>>;
1111}): string {
1112 return encodeGraphQlRequest({ query: input.query, variables: { ...input.variables } });
1113}
1114
11151242/** The body of `POST /repos/{owner}/{repo}/pulls/{number}/reviews`, which sends a review whole. */
11161243const ReviewSubmissionSchema = Schema.Struct({
11171244 event: Schema.Literals(["COMMENT", "APPROVE", "REQUEST_CHANGES"]),
......@@ -1126,8 +1253,6 @@ const ReviewSubmissionSchema = Schema.Struct({
11261253 ),
11271254});
11281255
1129const encodeReviewSubmission = Schema.encodeSync(Schema.fromJsonString(ReviewSubmissionSchema));
1130
11311256const REVIEW_EVENTS: Record<PullRequestReviewVerdict, "COMMENT" | "APPROVE" | "REQUEST_CHANGES"> = {
11321257 comment: "COMMENT",
11331258 approve: "APPROVE",
......@@ -1166,12 +1291,12 @@ function gitHubReviewPosition(position: PullRequestReviewPosition): {
11661291}
11671292
11681293/** The whole review as one request body, which is how GitHub keeps it invisible until sent. */
1169export function buildReviewSubmissionJson(input: {
1294export function buildReviewSubmission(input: {
11701295 readonly verdict: PullRequestReviewVerdict;
11711296 readonly body: string;
11721297 readonly comments: ReadonlyArray<PullRequestReviewCommentDraft>;
1173}): string {
1174 return encodeReviewSubmission({
1298}): typeof ReviewSubmissionSchema.Type {
1299 return {
11751300 event: REVIEW_EVENTS[input.verdict],
11761301 body: input.body,
11771302 comments: input.comments.map((comment) => ({
......@@ -1179,7 +1304,7 @@ export function buildReviewSubmissionJson(input: {
11791304 ...gitHubReviewPosition(comment.position),
11801305 body: comment.body,
11811306 })),
1182 });
1307 };
11831308}
11841309
11851310export interface GitHubPullRequestListItem {
......@@ -1644,23 +1769,15 @@ function toDetail(raw: Schema.Schema.Type<typeof RawDetailSchema>): GitHubPullRe
16441769 };
16451770}
16461771
1647function toActivity(raw: Schema.Schema.Type<typeof RawActivitySchema>): GitHubPullRequestActivity {
1648 return {
1649 author: toActor(raw.author),
1650 comments: toComments(raw),
1651 commits: toCommits(raw.commits),
1652 };
1653}
1654
16551772const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown));
1656const decodeListEntry = Schema.decodeUnknownExit(RawListItemSchema);
16571773const decodeSearch = decodeJsonResult(RawSearchSchema);
16581774const decodeSearchItem = Schema.decodeUnknownExit(RawSearchItemSchema);
16591775const decodeStats = decodeJsonResult(RawStatsSchema);
16601776const decodeDetail = decodeJsonResult(RawDetailSchema);
1661const decodeWorkflowRunApprovals = decodeJsonResult(Schema.Array(RawWorkflowRunApprovalSchema));
1662const decodePullRequestHeads = decodeJsonResult(Schema.Array(RawPullRequestHeadSchema));
1777const decodeWorkflowRuns = decodeJsonResult(RawWorkflowRunsSchema);
1778const decodePullRequestHeads = decodeJsonResult(RawPullRequestHeadsSchema);
16631779const decodeActivity = decodeJsonResult(RawActivitySchema);
1780const decodeRepositoryPullRequests = decodeJsonResult(RawRepositoryPullRequestsSchema);
16641781const decodeFileEntry = Schema.decodeUnknownExit(RawPullRequestFileSchema);
16651782const decodeReviewThreads = decodeJsonResult(RawReviewThreadsSchema);
16661783const decodeReviewThreadComments = decodeJsonResult(RawReviewThreadCommentsSchema);
......@@ -1669,27 +1786,78 @@ type DecodeFailure = Cause.Cause<Schema.SchemaError>;
16691786
16701787export interface GitHubPullRequestListBatch {
16711788 readonly items: ReadonlyArray<GitHubPullRequestListItem>;
1672 /** Rows gh returned, counted before decoding, so a skipped row cannot hide a next page. */
1789 /** Rows GitHub returned, counted before decoding, so a skipped row cannot hide a next page. */
16731790 readonly rawCount: number;
1791 /** Where the next page starts, or null once GitHub has handed over every row. */
1792 readonly endCursor: string | null;
16741793}
16751794
1676/** Malformed entries are skipped rather than failing the batch: one unexpected pull request
1677 * must not blank the whole list. */
1795/**
1796 * One page of a repository's own pull request list. Malformed rows are skipped rather than
1797 * failing the page: one unexpected pull request must not blank the whole list. A repository the
1798 * viewer cannot see answers as an empty page, which is what `gh pr list` printed for it too.
1799 */
16781800export function decodePullRequestListJson(
16791801 raw: string,
16801802): Result.Result<GitHubPullRequestListBatch, DecodeFailure> {
1681 const decoded = decodeUnknownList(raw);
1682 if (!Result.isSuccess(decoded)) {
1683 return Result.fail(decoded.failure);
1684 }
1803 const decoded = decodeRepositoryPullRequests(raw);
1804 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
1805 const connection = decoded.success.data.repository?.pullRequests;
1806 const nodes = connection?.nodes ?? [];
16851807 const items: GitHubPullRequestListItem[] = [];
1686 for (const entry of decoded.success) {
1687 const item = decodeListEntry(entry);
1688 if (Exit.isSuccess(item)) {
1689 items.push(toListItem(item.value));
1690 }
1808 for (const entry of nodes) {
1809 const row = toRow(entry);
1810 if (row !== null) items.push(row.item);
16911811 }
1692 return Result.succeed({ items, rawCount: decoded.success.length });
1812 return Result.succeed({
1813 items,
1814 rawCount: nodes.length,
1815 endCursor:
1816 connection?.pageInfo?.hasNextPage === true ? trimmed(connection.pageInfo.endCursor) : null,
1817 });
1818}
1819
1820/**
1821 * A row as GraphQL answers it: reviewers and labels arrive as connections, and the checks as
1822 * GitHub's one-word rollup rather than the whole check list. Null for a node that is not a pull
1823 * request, or one whose fields no longer decode.
1824 */
1825function toRow(
1826 entry: unknown,
1827): { readonly item: GitHubPullRequestListItem; readonly repository: string | null } | null {
1828 const decodedNode = decodeSearchItem(entry);
1829 if (!Exit.isSuccess(decodedNode)) return null;
1830 const node = decodedNode.value;
1831 const stack = toStackMembership(node);
1832 const reviewRequests = (node.reviewRequests?.nodes ?? []).flatMap(
1833 (request): ReadonlyArray<{ readonly login?: string; readonly slug?: string }> => {
1834 const reviewer = request?.requestedReviewer;
1835 const login = trimmed(reviewer?.login);
1836 if (login !== null) return [{ login }];
1837 const slug = trimmed(reviewer?.slug);
1838 return slug === null ? [] : [{ slug }];
1839 },
1840 );
1841 return {
1842 item: {
1843 ...toListItem({
1844 ...node,
1845 latestReviews: (node.latestReviews?.nodes ?? []).flatMap((review) =>
1846 review === null ? [] : [review],
1847 ),
1848 reviewRequests,
1849 labels: (node.labels?.nodes ?? []).flatMap((label) => (label === null ? [] : [label])),
1850 // The rollup arrives as one enum. Dressed as a single check here so it is read the same
1851 // way the detail's checks are.
1852 statusCheckRollup: (node.commits?.nodes ?? []).flatMap((commitNode) => {
1853 const state = trimmed(commitNode?.commit?.statusCheckRollup?.state);
1854 return state === null ? [] : [{ state }];
1855 }),
1856 }),
1857 ...(stack === undefined ? {} : { stack }),
1858 },
1859 repository: trimmed(node.repository?.nameWithOwner),
1860 };
16931861}
16941862
16951863export interface GitHubPullRequestSearchItem extends GitHubPullRequestListItem {
......@@ -1703,12 +1871,12 @@ export interface GitHubPullRequestSearchBatch {
17031871 readonly rawCount: number;
17041872 /** More rows than this slice asked for, which is truncation for every repository in it. */
17051873 readonly hasNextPage: boolean;
1874 /** Where the next page of the same search starts. */
1875 readonly endCursor: string | null;
17061876}
17071877
17081878/**
1709 * A search answers with the same pull request the listing does, one connection deeper: reviewers
1710 * and labels arrive as connections, and the row names the repository it came from. Flattened to
1711 * the shape `gh pr list --json` hands over so both reads decode into one type.
1879 * A search answers with the same row a repository's list does, each naming its repository.
17121880 *
17131881 * Rows that are not pull requests decode as empty and are skipped, the way a malformed listing
17141882 * row is — `is:pr` already excludes them, and one surprise must not blank a whole host.
......@@ -1720,41 +1888,19 @@ export function decodePullRequestSearchJson(
17201888 if (!Result.isSuccess(decoded)) {
17211889 return Result.fail(decoded.failure);
17221890 }
1723 const nodes = decoded.success.data.search.nodes ?? [];
1891 const search = decoded.success.data.search;
1892 const nodes = search.nodes ?? [];
17241893 const items: GitHubPullRequestSearchItem[] = [];
17251894 for (const entry of nodes) {
1726 const decodedNode = decodeSearchItem(entry);
1727 if (!Exit.isSuccess(decodedNode)) continue;
1728 const node = decodedNode.value;
1729 const repository = trimmed(node.repository?.nameWithOwner);
1730 if (repository === null) continue;
1731 const stack = toStackMembership(node);
1732 items.push({
1733 ...toListItem({
1734 ...node,
1735 latestReviews: (node.latestReviews?.nodes ?? []).flatMap((review) =>
1736 review === null ? [] : [review],
1737 ),
1738 reviewRequests: (node.reviewRequests?.nodes ?? []).flatMap((request) => {
1739 const login = trimmed(request?.requestedReviewer?.login);
1740 return login === null ? [] : [{ login }];
1741 }),
1742 labels: (node.labels?.nodes ?? []).flatMap((label) => (label === null ? [] : [label])),
1743 // The search asks for the verdict rather than the checks behind it, so it arrives as one
1744 // enum. Dressed as a single check here so the rollup is read the same way on both paths.
1745 statusCheckRollup: (node.commits?.nodes ?? []).flatMap((commitNode) => {
1746 const state = trimmed(commitNode?.commit?.statusCheckRollup?.state);
1747 return state === null ? [] : [{ state }];
1748 }),
1749 }),
1750 ...(stack === undefined ? {} : { stack }),
1751 repository,
1752 });
1895 const row = toRow(entry);
1896 if (row === null || row.repository === null) continue;
1897 items.push({ ...row.item, repository: row.repository });
17531898 }
17541899 return Result.succeed({
17551900 items,
17561901 rawCount: nodes.length,
1757 hasNextPage: decoded.success.data.search.pageInfo?.hasNextPage ?? false,
1902 hasNextPage: search.pageInfo?.hasNextPage ?? false,
1903 endCursor: search.pageInfo?.hasNextPage === true ? trimmed(search.pageInfo.endCursor) : null,
17581904 });
17591905}
17601906
......@@ -1900,6 +2046,13 @@ export function buildPullRequestSummariesGraphQlQuery(
19002046 return `query PullRequestSummaries {\n${selections.join("\n")}\n}`;
19012047}
19022048
2049/** One pull request's summary, aliased the way the batch is so the same decoder reads it. */
2050export function pullRequestSummaryGraphQlQuery(includeStacks = false): string {
2051 return `query($owner: String!, $name: String!, $number: Int!) {
2052 s0: repository(owner: $owner, name: $name) { pullRequest(number: $number) { ${PULL_REQUEST_SUMMARY_SELECTION}${includeStacks ? ` ${STACK_MEMBERSHIP_SELECTION}` : ""} } }
2053}`;
2054}
2055
19032056const RawSummarySchema = Schema.Struct({
19042057 ...RawSearchItemSchema.fields,
19052058 changedFiles: Schema.optional(Schema.NullOr(Schema.Int)),
......@@ -2147,11 +2300,7 @@ export function decodePullRequestCoreJson(
21472300 requestedReviewer === null ? [] : [requestedReviewer],
21482301 ),
21492302 labels: pr.labels.nodes,
2150 statusCheckRollup:
2151 contexts?.nodes.map((check) => ({
2152 ...check,
2153 workflowName: check.checkSuite?.workflowRun?.workflow?.name ?? null,
2154 })) ?? [],
2303 statusCheckRollup: toCheckContexts(contexts?.nodes ?? []),
21552304 }),
21562305 viewerAccess: {
21572306 canWrite: toCanWrite(repository.viewerPermission),
......@@ -2183,27 +2332,50 @@ export function decodePullRequestDetailJson(
21832332 : Result.fail(decoded.failure);
21842333}
21852334
2186export function decodeWorkflowRunApprovalsJson(
2335export interface GitHubWorkflowRunPage {
2336 readonly runs: ReadonlyArray<GitHubWorkflowRunApproval>;
2337 /** Runs on the page, counted before decoding, which is what decides whether to page on. */
2338 readonly rawCount: number;
2339}
2340
2341/** One page of `actions/runs`. */
2342export function decodeWorkflowRunsJson(
21872343 raw: string,
2188): Result.Result<ReadonlyArray<GitHubWorkflowRunApproval>, DecodeFailure> {
2189 const decoded = decodeWorkflowRunApprovals(raw);
2344): Result.Result<GitHubWorkflowRunPage, DecodeFailure> {
2345 const decoded = decodeWorkflowRuns(raw);
21902346 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
2191 return Result.succeed(
2192 decoded.success.map((run) => ({
2193 id: run.databaseId,
2194 name: trimmed(run.workflowName) ?? `Workflow run ${run.databaseId}`,
2195 url: trimmed(run.url),
2347 return Result.succeed({
2348 runs: decoded.success.workflow_runs.map((run) => ({
2349 id: run.id,
2350 name: trimmed(run.name) ?? `Workflow run ${run.id}`,
2351 url: trimmed(run.html_url),
21962352 })),
2197 );
2353 rawCount: decoded.success.workflow_runs.length,
2354 });
21982355}
21992356
2200export function decodePullRequestHeadsJson(
2201 raw: string,
2202): Result.Result<ReadonlyArray<GitHubPullRequestHead>, DecodeFailure> {
2357/** Open pull requests whose head branch has one name, from whichever repository it lives in. */
2358export const PULL_REQUEST_HEADS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $head: String!, $after: String) {
2359 repository(owner: $owner, name: $name) {
2360 pullRequests(first: 100, after: $after, states: [OPEN], headRefName: $head) {
2361 pageInfo { hasNextPage endCursor }
2362 nodes { number headRefOid isCrossRepository headRepositoryOwner { login } }
2363 }
2364 }
2365}`;
2366
2367export function decodePullRequestHeadsJson(raw: string): Result.Result<
2368 {
2369 readonly heads: ReadonlyArray<GitHubPullRequestHead>;
2370 readonly nextCursor: string | null;
2371 },
2372 DecodeFailure
2373> {
22032374 const decoded = decodePullRequestHeads(raw);
22042375 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
2205 return Result.succeed(
2206 decoded.success.map((pullRequest) => ({
2376 const connection = decoded.success.data.repository?.pullRequests;
2377 return Result.succeed({
2378 heads: (connection?.nodes ?? []).map((pullRequest) => ({
22072379 number: pullRequest.number,
22082380 headSha: pullRequest.headRefOid,
22092381 ...(typeof pullRequest.isCrossRepository === "boolean"
......@@ -2211,16 +2383,145 @@ export function decodePullRequestHeadsJson(
22112383 : {}),
22122384 headRepositoryOwner: trimmed(pullRequest.headRepositoryOwner?.login),
22132385 })),
2214 );
2386 nextCursor: nextCursorOf(connection?.pageInfo),
2387 });
2388}
2389
2390export interface GitHubPullRequestActivityPage {
2391 /** Only on the first page, which is the one that asks for them. */
2392 readonly author?: PullRequestActor | null;
2393 readonly commits?: ReadonlyArray<PullRequestCommit>;
2394 /** Issue comments and reviews, each list unsorted. */
2395 readonly remarks: ReadonlyArray<PullRequestComment>;
2396 readonly nextCommentsCursor: string | null;
2397 readonly nextReviewsCursor: string | null;
22152398}
22162399
2400/** One page of `PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY`. */
22172401export function decodePullRequestActivityJson(
22182402 raw: string,
2219): Result.Result<GitHubPullRequestActivity, DecodeFailure> {
2403): Result.Result<GitHubPullRequestActivityPage, DecodeFailure> {
22202404 const decoded = decodeActivity(raw);
2221 return Result.isSuccess(decoded)
2222 ? Result.succeed(toActivity(decoded.success))
2223 : Result.fail(decoded.failure);
2405 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
2406 const pr = decoded.success.data.repository.pullRequest;
2407 return Result.succeed({
2408 ...(pr.author === undefined ? {} : { author: toActor(pr.author) }),
2409 ...(pr.commits === undefined
2410 ? {}
2411 : {
2412 commits: toCommits(
2413 (pr.commits?.nodes ?? []).map(({ commit }) => ({
2414 ...commit,
2415 authors: (commit.authors?.nodes ?? []).map((author) => ({
2416 ...author,
2417 login: author.user?.login ?? null,
2418 })),
2419 })),
2420 ),
2421 }),
2422 remarks: toComments({
2423 comments: pr.comments?.nodes ?? [],
2424 reviews: pr.reviews?.nodes ?? [],
2425 }),
2426 nextCommentsCursor: nextCursorOf(pr.comments?.pageInfo),
2427 nextReviewsCursor: nextCursorOf(pr.reviews?.pageInfo),
2428 });
2429}
2430
2431/** Every check context of the head commit, a page at a time, for a rollup past one page. */
2432export const pullRequestCheckContextsGraphQlQuery = (host: string) =>
2433 `query($owner: String!, $name: String!, $number: Int!, $after: String) {
2434 repository(owner: $owner, name: $name) {
2435 pullRequest(number: $number) {
2436 headRefOid
2437 commits(last: 1) {
2438 nodes { commit { statusCheckRollup { contexts(first: 100, after: $after) {
2439 ${checkContextNodesSelection(host)}
2440 pageInfo { hasNextPage endCursor }
2441 } } } }
2442 }
2443 }
2444 }
2445}`;
2446
2447const RawCheckContextNodeSchema = Schema.Struct({
2448 ...RawCheckSchema.fields,
2449 checkSuite: Schema.optional(
2450 Schema.NullOr(
2451 Schema.Struct({
2452 workflowRun: Schema.NullOr(
2453 Schema.Struct({ workflow: Schema.NullOr(Schema.Struct({ name: Schema.String })) }),
2454 ),
2455 }),
2456 ),
2457 ),
2458});
2459
2460const decodeCheckContexts = decodeJsonResult(
2461 Schema.Struct({
2462 data: Schema.Struct({
2463 repository: Schema.Struct({
2464 pullRequest: Schema.Struct({
2465 headRefOid: Schema.String,
2466 commits: Schema.Struct({
2467 nodes: Schema.Array(
2468 Schema.Struct({
2469 commit: Schema.Struct({
2470 statusCheckRollup: Schema.NullOr(
2471 Schema.Struct({
2472 contexts: Schema.Struct({
2473 nodes: Schema.Array(RawCheckContextNodeSchema),
2474 pageInfo: RawPageInfoSchema,
2475 }),
2476 }),
2477 ),
2478 }),
2479 }),
2480 ),
2481 }),
2482 }),
2483 }),
2484 }),
2485 }),
2486);
2487
2488/** A check context as the rollup reports it; opaque outside this module. */
2489export type GitHubCheckContext = Schema.Schema.Type<typeof RawCheckSchema>;
2490
2491function toCheckContexts(
2492 nodes: ReadonlyArray<Schema.Schema.Type<typeof RawCheckContextNodeSchema>>,
2493): ReadonlyArray<GitHubCheckContext> {
2494 return nodes.map((check) => ({
2495 ...check,
2496 workflowName: check.checkSuite?.workflowRun?.workflow?.name ?? null,
2497 }));
2498}
2499
2500export function decodePullRequestCheckContextsJson(raw: string): Result.Result<
2501 {
2502 readonly headSha: string;
2503 readonly contexts: ReadonlyArray<GitHubCheckContext>;
2504 readonly nextCursor: string | null;
2505 },
2506 DecodeFailure
2507> {
2508 const decoded = decodeCheckContexts(raw);
2509 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
2510 const pr = decoded.success.data.repository.pullRequest;
2511 const contexts = pr.commits.nodes[0]?.commit.statusCheckRollup?.contexts;
2512 return Result.succeed({
2513 headSha: pr.headRefOid,
2514 contexts: toCheckContexts(contexts?.nodes ?? []),
2515 nextCursor: nextCursorOf(contexts?.pageInfo),
2516 });
2517}
2518
2519/** The checks and their one-word verdict, from every context of the head commit. */
2520export function pullRequestChecksFromContexts(contexts: ReadonlyArray<GitHubCheckContext>): {
2521 readonly checks: ReadonlyArray<PullRequestCheck>;
2522 readonly checksState: PullRequestChecksState | null;
2523} {
2524 return { checks: toChecks(contexts), checksState: rollupChecksState(contexts) };
22242525}
22252526
22262527export interface GitHubReviewThreadComments {
......@@ -2778,17 +3079,15 @@ const ReviewerRequestSchema = Schema.Struct({
27783079 team_reviewers: Schema.Array(Schema.String),
27793080});
27803081
2781const encodeReviewerRequest = Schema.encodeSync(Schema.fromJsonString(ReviewerRequestSchema));
2782
2783export function buildReviewerRequestJson(
3082export function buildReviewerRequest(
27843083 reviewers: ReadonlyArray<{ readonly id: string; readonly kind: PullRequestReviewerKind }>,
2785): string {
2786 return encodeReviewerRequest({
3084): typeof ReviewerRequestSchema.Type {
3085 return {
27873086 reviewers: reviewers.flatMap((reviewer) => (reviewer.kind === "user" ? [reviewer.id] : [])),
27883087 team_reviewers: reviewers.flatMap((reviewer) =>
27893088 reviewer.kind === "team" ? [reviewer.id] : [],
27903089 ),
2791 });
3090 };
27923091}
27933092
27943093export const LABEL_CANDIDATES_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) {
......@@ -2876,12 +3175,10 @@ export function decodeLabelCandidatesJson(
28763175}
28773176
28783177/** The body of `POST /repos/{owner}/{repo}/issues/{number}/labels`, which adds to what is there. */
2879const LabelRequestSchema = Schema.Struct({ labels: Schema.Array(Schema.String) });
2880
2881const encodeLabelRequest = Schema.encodeSync(Schema.fromJsonString(LabelRequestSchema));
2882
2883export function buildLabelRequestJson(labels: ReadonlyArray<string>): string {
2884 return encodeLabelRequest({ labels });
3178export function buildLabelRequest(labels: ReadonlyArray<string>): {
3179 readonly labels: ReadonlyArray<string>;
3180} {
3181 return { labels };
28853182}
28863183
28873184/**
......@@ -2965,14 +3262,28 @@ export interface GitHubPullRequestFilesPatch {
29653262export function decodePullRequestFilesJson(
29663263 raw: string,
29673264): Result.Result<GitHubPullRequestFilesPatch, DecodeFailure> {
2968 const decoded = decodeUnknownList(raw);
2969 if (!Result.isSuccess(decoded)) {
2970 return Result.fail(decoded.failure);
2971 }
3265 return Result.map(decodeUnknownList(raw), toFilesPatch);
3266}
3267
3268/**
3269 * The files of one commit, which the commit endpoint lists and pages the same way, only wrapped
3270 * in an object. An empty commit carries no `files` at all, which is a commit with nothing in it.
3271 */
3272export function decodeCommitFilesJson(
3273 raw: string,
3274): Result.Result<GitHubPullRequestFilesPatch, DecodeFailure> {
3275 return Result.map(decodeCommitFiles(raw), (commit) => toFilesPatch(commit.files ?? []));
3276}
3277
3278const decodeCommitFiles = decodeJsonResult(
3279 Schema.Struct({ files: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))) }),
3280);
3281
3282function toFilesPatch(entries: ReadonlyArray<unknown>): GitHubPullRequestFilesPatch {
29723283 const sections: string[] = [];
29733284 const omittedFileStats: PullRequestOmittedFileStat[] = [];
29743285 let truncated = false;
2975 for (const entry of decoded.success) {
3286 for (const entry of entries) {
29763287 const file = decodeFileEntry(entry);
29773288 if (Exit.isFailure(file)) continue;
29783289 const value = file.value;
......@@ -3009,12 +3320,12 @@ export function decodePullRequestFilesJson(
30093320 ].join("\n");
30103321 sections.push(hunks.length === 0 ? `${header}\n` : `${header}\n${hunks.replace(/\n?$/, "\n")}`);
30113322 }
3012 return Result.succeed({
3323 return {
30133324 patch: sections.join(""),
30143325 truncated,
3015 rawCount: decoded.success.length,
3326 rawCount: entries.length,
30163327 omittedFileStats,
3017 });
3328 };
30183329}
30193330
30203331/**
apps/server/src/pullRequest/githubStackActions.test.ts+68-42
......@@ -2,15 +2,62 @@ import { expect, it } from "@effect/vitest";
22import * as Layer from "effect/Layer";
33import * as Effect from "effect/Effect";
44import * as Fiber from "effect/Fiber";
5import * as Schema from "effect/Schema";
56import * as TestClock from "effect/testing/TestClock";
6import { ChildProcessSpawner } from "effect/process";
7import * as GitHubCli from "../sourceControl/GitHubCli.ts";
7import * as GitHubApi from "../sourceControl/GitHubApi.ts";
88import { runGitHubStackAction as runStackAction } from "./githubStackActions.ts";
99
10const runGitHubStackAction = (
11 execute: GitHubCli.GitHubCli["Service"]["execute"],
12 input: Parameters<typeof runStackAction>[0],
13) => runStackAction(input).pipe(Effect.provide(Layer.mock(GitHubCli.GitHubCli)({ execute })));
10/**
11 * One request as the fake saw it, flattened to words: the path or document, then each variable
12 * or body field as `name=value`, so an assertion reads as "this request carried that value".
13 */
14type Call = ReadonlyArray<string>;
15type Send = (
16 call: Call,
17 kind: "graphql" | "rest",
18) => Effect.Effect<string, GitHubApi.GitHubApiError>;
19
20const runGitHubStackAction = (send: Send, input: Parameters<typeof runStackAction>[0]) =>
21 runStackAction(input).pipe(
22 Effect.provide(
23 Layer.mock(GitHubApi.GitHubApi)({
24 graphql: (request) => {
25 // GitHub refuses a document that declares a variable it never uses.
26 const declared = [...request.query.matchAll(/\$(\w+)\s*:/g)].map((match) => match[1]!);
27 const unused = declared.filter(
28 (name) => !new RegExp(`\\$${name}(?!\\w)(?!\\s*:)`).test(request.query),
29 );
30 if (unused.length > 0) {
31 return Effect.die(new Error(`Variables declared but not used: ${unused.join(", ")}`));
32 }
33 return send(words(request.query, request.variables), "graphql");
34 },
35 rest: (request) =>
36 send(words(request.path, request.body), "rest").pipe(
37 Effect.map((body) => ({
38 status: 200,
39 headers: {},
40 body,
41 truncated: false,
42 invalidUtf8: false,
43 })),
44 ),
45 }),
46 ),
47 );
48
49const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
50
51function words(head: string, fields: unknown): Call {
52 return [
53 head,
54 ...Object.entries((fields ?? {}) as Record<string, unknown>).map(
55 ([key, value]) => `${key}=${typeof value === "string" ? value : encodeJson(value)}`,
56 ),
57 ];
58}
59
60const isMutation = (call: Call) => call[0]!.startsWith("mutation");
1461
1562const stack = [
1663 {
......@@ -71,21 +118,13 @@ const rebased = {
71118const rebaseResponses = [branch(2, "bbb"), rebased, branch(3, "ccc", 1, ["rebased-sha"]), rebased];
72119
73120function fake(responses: readonly unknown[]) {
74 const calls: ReadonlyArray<string>[] = [];
75 const execute: GitHubCli.GitHubCli["Service"]["execute"] = (request) =>
121 const calls: Call[] = [];
122 const execute: Send = (call) =>
76123 Effect.sync(() => {
77 calls.push(request.args);
124 calls.push(call);
78125 const value = responses[calls.length - 1];
79126 if (value === undefined) throw new Error("Unexpected GitHub request");
80 return {
81 exitCode: ChildProcessSpawner.ExitCode(0),
82 // @effect-diagnostics-next-line preferSchemaOverJson:off
83 stdout: JSON.stringify(value),
84 stderr: "",
85 stdoutTruncated: false,
86 stderrTruncated: false,
87 stdoutInvalidUtf8: false,
88 };
127 return encodeJson(value);
89128 });
90129 return { execute, calls };
91130}
......@@ -215,29 +254,25 @@ it.effect("rebases unmerged layers bottom to top without local git commands", ()
215254 Effect.gen(function* () {
216255 const api = fake([stack, access, ...rebaseResponses]);
217256 yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" });
218 const mutations = api.calls.filter((args) =>
219 args.some((arg) => arg.startsWith("query=mutation")),
220 );
257 const mutations = api.calls.filter(isMutation);
221258 expect(mutations).toHaveLength(2);
222259 expect(mutations[0]).toContain("id=PR_2");
223260 expect(mutations[0]).toContain("sha=bbb");
224261 expect(mutations[1]).toContain("id=PR_3");
225262 expect(mutations[1]).toContain("sha=ccc");
226 expect(api.calls.every((args) => args[0] === "api")).toBe(true);
227263 }),
228264);
229265
230266it.effect("does not update later layers after a rebase failure", () =>
231267 Effect.gen(function* () {
232268 const api = fake([stack, access, branch(2, "bbb")]);
233 const execute: typeof api.execute = (request) =>
234 !request.args.some((arg) => arg.startsWith("query=mutation"))
235 ? api.execute(request)
269 const execute: Send = (call, kind) =>
270 !isMutation(call)
271 ? api.execute(call, kind)
236272 : Effect.fail(
237 new GitHubCli.GitHubCliAuthenticationError({
238 command: "gh",
239 cwd: "/repo",
240 cause: new Error("denied"),
273 new GitHubApi.GitHubApiAuthenticationError({
274 host: "github.com",
275 operation: "runGitHubStackAction",
241276 }),
242277 );
243278 const result = yield* runGitHubStackAction(execute, { ...input, action: "update-branch" }).pipe(
......@@ -272,7 +307,6 @@ it.effect("refuses the entire rebase before mutation when a later fork denies wr
272307 failure: { _tag: "GitHubStackPermissionError" },
273308 });
274309 expect(api.calls).toHaveLength(2);
275 expect(api.calls.every((args) => args[0] === "api")).toBe(true);
276310 }),
277311);
278312
......@@ -333,9 +367,7 @@ it.effect("skips current layers without submitting a rebase mutation", () =>
333367 Effect.gen(function* () {
334368 const api = fake([stack, access, branch(2, "bbb", 0), branch(3, "ccc", 0, ["bbb"])]);
335369 yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" });
336 expect(api.calls.some((args) => args.some((arg) => arg.startsWith("query=mutation")))).toBe(
337 false,
338 );
370 expect(api.calls.some((args) => isMutation(args))).toBe(false);
339371 }),
340372);
341373
......@@ -380,9 +412,7 @@ it.effect("reports partial progress when a later head changes during the rebase"
380412 if (result._tag === "Failure") {
381413 expect(result.failure.message).toContain("Earlier updates remain on GitHub");
382414 }
383 expect(
384 api.calls.filter((args) => args.some((arg) => arg.startsWith("query=mutation"))),
385 ).toHaveLength(1);
415 expect(api.calls.filter((args) => isMutation(args))).toHaveLength(1);
386416 }),
387417);
388418
......@@ -403,11 +433,7 @@ it.effect.each([false, true])("rejects a push to a processed layer, rebased=%s",
403433 _tag: "Failure",
404434 failure: { _tag: "GitHubStackChangedError", number: 2, completed: 1 },
405435 });
406 expect(api.calls.at(-1)?.some((arg) => arg.includes('processed:nodes(ids:["PR_2"])'))).toBe(
407 true,
408 );
409 expect(
410 api.calls.filter((args) => args.some((arg) => arg.startsWith("query=mutation"))),
411 ).toHaveLength(rebasedParent ? 1 : 0);
436 expect(api.calls.at(-1)?.includes('ids=["PR_2"]')).toBe(true);
437 expect(api.calls.filter((args) => isMutation(args))).toHaveLength(rebasedParent ? 1 : 0);
412438 }),
413439);
apps/server/src/pullRequest/githubStackActions.ts+57-120
......@@ -8,7 +8,7 @@ import * as Clock from "effect/Clock";
88import * as Result from "effect/Result";
99import * as Schema from "effect/Schema";
1010
11import * as GitHubCli from "../sourceControl/GitHubCli.ts";
11import * as GitHubApi from "../sourceControl/GitHubApi.ts";
1212import { decodePullRequestStacksJson } from "./gitHubPullRequestJson.ts";
1313
1414const stackErrorIdentity = {
......@@ -21,10 +21,6 @@ export class GitHubStackChangedError extends Schema.TaggedError<GitHubStackChang
2121 "GitHubStackChangedError",
2222 { ...stackErrorIdentity, completed: Schema.Int },
2323) {
24 get detail(): string {
25 return this.message;
26 }
27
2824 override get message(): string {
2925 return this.completed > 0
3026 ? `The stack changed at PR #${this.number} after ${this.completed} layers. Earlier updates remain on GitHub. Refresh it before trying again.`
......@@ -36,10 +32,6 @@ export class GitHubStackUnsupportedError extends Schema.TaggedError<GitHubStackU
3632 "GitHubStackUnsupportedError",
3733 stackErrorIdentity,
3834) {
39 get detail(): string {
40 return this.message;
41 }
42
4335 override get message(): string {
4436 return "This operation is not supported for this stack.";
4537 }
......@@ -49,10 +41,6 @@ export class GitHubStackResponseInvalidError extends Schema.TaggedError<GitHubSt
4941 "GitHubStackResponseInvalidError",
5042 { ...stackErrorIdentity, cause: Schema.optional(Schema.Defect()) },
5143) {
52 get detail(): string {
53 return this.message;
54 }
55
5644 override get message(): string {
5745 return "GitHub returned an unreadable stack operation response.";
5846 }
......@@ -62,10 +50,6 @@ export class GitHubStackMergeRejectedError extends Schema.TaggedError<GitHubStac
6250 "GitHubStackMergeRejectedError",
6351 { ...stackErrorIdentity, cause: Schema.Defect() },
6452) {
65 get detail(): string {
66 return this.message;
67 }
68
6953 override get message(): string {
7054 return "GitHub refused the stack merge. Check the stack's branch rules and merge requirements.";
7155 }
......@@ -75,10 +59,6 @@ export class GitHubStackMergePendingError extends Schema.TaggedError<GitHubStack
7559 "GitHubStackMergePendingError",
7660 stackErrorIdentity,
7761) {
78 get detail(): string {
79 return this.message;
80 }
81
8262 override get message(): string {
8363 return "The merge is still running on GitHub. Check its status there before submitting another request.";
8464 }
......@@ -88,10 +68,6 @@ export class GitHubStackPermissionError extends Schema.TaggedError<GitHubStackPe
8868 "GitHubStackPermissionError",
8969 stackErrorIdentity,
9070) {
91 get detail(): string {
92 return this.message;
93 }
94
9571 override get message(): string {
9672 return "You cannot update every branch in this stack. Check write access and fork maintainer permissions before retrying.";
9773 }
......@@ -101,10 +77,6 @@ export class GitHubStackRebaseFailedError extends Schema.TaggedError<GitHubStack
10177 "GitHubStackRebaseFailedError",
10278 { ...stackErrorIdentity, completed: Schema.Int, cause: Schema.Defect() },
10379) {
104 get detail(): string {
105 return this.message;
106 }
107
10880 override get message(): string {
10981 return `Stack rebase stopped at PR #${this.number} after ${this.completed} layers. Earlier updates remain on GitHub; resolve the failing layer before retrying.`;
11082 }
......@@ -179,8 +151,6 @@ const decodeRebaseResponse = Schema.decodeEffect(
179151 ),
180152);
181153
182const encodeNodeIds = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String)));
183
184154const decodeMergeResponse = Schema.decodeEffect(Schema.fromJsonString(MergeResponse));
185155
186156/** Remote-only updates: a stack rebase never switches or rewrites the environment's checkout. */
......@@ -194,7 +164,7 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function*
194164 action: PullRequestAction;
195165 mergeMethod?: PullRequestMergeMethod;
196166}) {
197 const github = yield* GitHubCli.GitHubCli;
167 const api = yield* GitHubApi.GitHubApi;
198168 const identity = {
199169 repository: input.repository,
200170 number: input.number,
......@@ -203,11 +173,12 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function*
203173 if (input.action !== "merge" && input.action !== "update-branch")
204174 return yield* new GitHubStackUnsupportedError({ ...identity });
205175 const endpoint = `repos/${input.repository}`;
206 const read = yield* github.execute({
207 cwd: input.cwd,
208 args: ["api", "--hostname", input.host, `${endpoint}/stacks?pull_request=${input.number}`],
176 const read = yield* api.rest({
177 host: input.host,
178 operation: "runGitHubStackAction",
179 path: `${endpoint}/stacks?pull_request=${input.number}`,
209180 });
210 const decoded = decodePullRequestStacksJson(read.stdout);
181 const decoded = decodePullRequestStacksJson(read.body);
211182 if (Result.isFailure(decoded))
212183 return yield* new GitHubStackResponseInvalidError({ ...identity, cause: decoded.failure });
213184 const stack = decoded.success;
......@@ -243,27 +214,19 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function*
243214 return yield* new GitHubStackUnsupportedError({ ...identity });
244215 if (input.action === "update-branch") {
245216 const [owner, name] = input.repository.split("/");
246 const permissions = yield* github.execute({
247 cwd: input.cwd,
248 args: [
249 "api",
250 "--hostname",
251 input.host,
252 "graphql",
253 "-f",
254 `owner=${owner}`,
255 "-f",
256 `name=${name}`,
257 "-f",
258 `query=query($owner:String!,$name:String!){repository(owner:$owner,name:$name){${open
259 .map(
260 (layer) =>
261 `pr${layer.number}:pullRequest(number:${layer.number}){headRepository{viewerPermission} maintainerCanModify}`,
262 )
263 .join(" ")}}}`,
264 ],
217 const permissions = yield* api.graphql({
218 host: input.host,
219 operation: "runGitHubStackAction",
220 allowReserve: true,
221 variables: { owner, name },
222 query: `query($owner:String!,$name:String!){repository(owner:$owner,name:$name){${open
223 .map(
224 (layer) =>
225 `pr${layer.number}:pullRequest(number:${layer.number}){headRepository{viewerPermission} maintainerCanModify}`,
226 )
227 .join(" ")}}}`,
265228 });
266 const access = yield* decodeBranchAccess(permissions.stdout).pipe(
229 const access = yield* decodeBranchAccess(permissions).pipe(
267230 Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })),
268231 );
269232 // viewerCanUpdateBranch is false for an already-current layer, even if rebasing its parent
......@@ -282,35 +245,27 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function*
282245 const processed: Array<{ id: string; number: number; headSha: string }> = [];
283246 for (const [index, layer] of open.entries()) {
284247 yield* Effect.gen(function* () {
285 const read = yield* github.execute({
286 cwd: input.cwd,
287 args: [
288 "api",
289 "--hostname",
290 input.host,
291 "graphql",
292 "-f",
293 `owner=${owner}`,
294 "-f",
295 `name=${name}`,
296 "-F",
297 `number=${layer.number}`,
298 "-f",
299 `sha=${layer.headSha}`,
300 "-f",
301 `query=query($owner:String!,$name:String!,$number:Int!,$sha:String!){${
302 processed.length === 0
303 ? ""
304 : `processed:nodes(ids:${encodeNodeIds(processed.map((head) => head.id))}){... on PullRequest{headRefOid}}`
305 } repository(owner:$owner,name:$name){pullRequest(number:$number){id headRefOid baseRef{compare(headRef:$sha){behindBy}}}}}`,
306 ],
248 const read = yield* api.graphql({
249 host: input.host,
250 operation: "runGitHubStackAction",
251 allowReserve: true,
252 variables: {
253 owner,
254 name,
255 number: layer.number,
256 sha: layer.headSha,
257 ids: processed.map((head) => head.id),
258 },
259 // GitHub rejects a declared variable the document never uses, so `$ids` is always
260 // selected; an empty list asks for nothing.
261 query: `query($owner:String!,$name:String!,$number:Int!,$sha:String!,$ids:[ID!]!){processed:nodes(ids:$ids){... on PullRequest{headRefOid}} repository(owner:$owner,name:$name){pullRequest(number:$number){id headRefOid baseRef{compare(headRef:$sha){behindBy}}}}}`,
307262 });
308263 const {
309264 data: {
310265 processed: observed,
311266 repository: { pullRequest: pr },
312267 },
313 } = yield* decodeRebaseBranch(read.stdout);
268 } = yield* decodeRebaseBranch(read);
314269 // A push to an earlier layer must not silently become the next layer's new base.
315270 const changed = processed.find(
316271 (head, index) => observed?.[index]?.headRefOid !== head.headSha,
......@@ -332,22 +287,14 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function*
332287 return;
333288 }
334289 // Pass the reviewed revision to GitHub, including when a push races this read.
335 const updated = yield* github.execute({
336 cwd: input.cwd,
337 args: [
338 "api",
339 "--hostname",
340 input.host,
341 "graphql",
342 "-f",
343 `id=${pr.id}`,
344 "-f",
345 `sha=${layer.headSha}`,
346 "-f",
347 "query=mutation($id:ID!,$sha:GitObjectID!){updatePullRequestBranch(input:{pullRequestId:$id,expectedHeadOid:$sha,updateMethod:REBASE}){pullRequest{headRefOid}}}",
348 ],
290 const updated = yield* api.graphql({
291 host: input.host,
292 operation: "runGitHubStackAction",
293 variables: { id: pr.id, sha: layer.headSha },
294 query:
295 "mutation($id:ID!,$sha:GitObjectID!){updatePullRequestBranch(input:{pullRequestId:$id,expectedHeadOid:$sha,updateMethod:REBASE}){pullRequest{headRefOid}}}",
349296 });
350 const response = yield* decodeRebaseResponse(updated.stdout);
297 const response = yield* decodeRebaseResponse(updated);
351298 processed.push({
352299 id: pr.id,
353300 number: layer.number,
......@@ -374,24 +321,18 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function*
374321 decodeMergeResponse(raw).pipe(
375322 Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })),
376323 );
377 const request = yield* github.execute({
378 cwd: input.cwd,
379 args: [
380 "api",
381 "--hostname",
382 input.host,
383 "--method",
384 "PUT",
385 `${endpoint}/pulls/${input.number}/merge-async`,
386 "-f",
387 `merge_method=${input.mergeMethod ?? "merge"}`,
388 "-f",
389 "merge_action=default",
390 "-f",
391 `sha=${target.headSha}`,
392 ],
324 const request = yield* api.rest({
325 host: input.host,
326 operation: "runGitHubStackAction",
327 method: "PUT",
328 path: `${endpoint}/pulls/${input.number}/merge-async`,
329 body: {
330 merge_method: input.mergeMethod ?? "merge",
331 merge_action: "default",
332 sha: target.headSha,
333 },
393334 });
394 let result = yield* decode(request.stdout);
335 let result = yield* decode(request.body);
395336 const deadline = (yield* Clock.currentTimeMillis) + 5 * 60_000;
396337 for (
397338 let attempt = 0;
......@@ -401,16 +342,12 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function*
401342 const uuid = result.details.uuid;
402343 if (!uuid) return yield* new GitHubStackResponseInvalidError({ ...identity });
403344 yield* Effect.sleep(Math.min(1_000 * 2 ** attempt, 10_000));
404 const poll = yield* github.execute({
405 cwd: input.cwd,
406 args: [
407 "api",
408 "--hostname",
409 input.host,
410 `${endpoint}/pulls/${input.number}/merge-async/${encodeURIComponent(uuid)}`,
411 ],
345 const poll = yield* api.rest({
346 host: input.host,
347 operation: "runGitHubStackAction",
348 path: `${endpoint}/pulls/${input.number}/merge-async/${encodeURIComponent(uuid)}`,
412349 });
413 result = yield* decode(poll.stdout);
350 result = yield* decode(poll.body);
414351 }
415352 if (result.status === "pending") return yield* new GitHubStackMergePendingError({ ...identity });
416353 if (result.status === "failed")
apps/server/src/sourceControl/GitHubApi.ts+8-2
......@@ -117,6 +117,8 @@ export interface GitHubRestResponse {
117117 readonly headers: Readonly<Record<string, string | undefined>>;
118118 readonly body: string;
119119 readonly truncated: boolean;
120 /** The body was not valid UTF-8, which a raw file read takes to mean binary. */
121 readonly invalidUtf8: boolean;
120122}
121123
122124export interface GitHubRestInput {
......@@ -131,6 +133,8 @@ export interface GitHubRestInput {
131133 /** Revalidates a cached answer. A 304 is returned rather than failed, and is free. */
132134 readonly ifNoneMatch?: string;
133135 readonly maxResponseBytes?: number;
136 /** Defaults to 30 seconds; a whole pull request's patch may need longer. */
137 readonly timeout?: Duration.Input;
134138 /** Overrides `AllowGitHubReserve` for this one request. */
135139 readonly allowReserve?: boolean;
136140}
......@@ -381,11 +385,11 @@ export const make = Effect.gen(function* () {
381385 readonly operation: string;
382386 readonly request: HttpClientRequest.HttpClientRequest;
383387 readonly maxResponseBytes: number;
388 readonly timeout?: Duration.Input | undefined;
384389 readonly allowReserve: boolean;
385390 readonly acceptNotModified: boolean;
386391 /** Reads the body for GraphQL `errors`, which GitHub sends with HTTP 200. */
387392 readonly graphql?: boolean;
388 readonly timeout?: Duration.Duration | undefined;
389393 }) {
390394 const host = normalizeHost(input.host);
391395 // Only the path: a query string can carry a SHA or a branch, and never needs to be in a trace.
......@@ -435,7 +439,7 @@ export const make = Effect.gen(function* () {
435439 // 204, 304 and many refusals carry no body at all, which is an empty answer.
436440 Effect.catchIf(
437441 (error) => error.reason._tag === "EmptyBodyError",
438 () => Effect.succeed({ text: "", truncated: false }),
442 () => Effect.succeed({ text: "", truncated: false, invalidUtf8: false }),
439443 ),
440444 );
441445 return { response, collected };
......@@ -468,6 +472,7 @@ export const make = Effect.gen(function* () {
468472 headers,
469473 body: collected.text,
470474 truncated: collected.truncated,
475 invalidUtf8: collected.invalidUtf8,
471476 }),
472477 ),
473478 RateLimited: () =>
......@@ -526,6 +531,7 @@ export const make = Effect.gen(function* () {
526531 operation: input.operation,
527532 request,
528533 maxResponseBytes: input.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES,
534 timeout: input.timeout,
529535 allowReserve: input.allowReserve ?? interactive,
530536 acceptNotModified: input.ifNoneMatch !== undefined,
531537 }),
apps/server/src/sourceControl/GitHubCli.ts+1-4
......@@ -38,10 +38,7 @@ const DEFAULT_TIMEOUT_MS = 30_000;
3838/** Server-local credential scope; never put its value in RPC payloads or cache keys. */
3939export const PinnedGitHubCredential = GitHubApi.PinnedGitHubCredential;
4040
41export const AllowGitHubReserve = Context.Reference<boolean>(
42 "t3/sourceControl/AllowGitHubReserve",
43 { defaultValue: () => false },
44);
41export const AllowGitHubReserve = GitHubApi.AllowGitHubReserve;
4542
4643function commandHosts(args: ReadonlyArray<string>): Array<string | null> {
4744 const hosts: Array<string | null> = [];