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 @@...@@ -1,18 +1,21 @@
1// Run with: node apps/server/scripts/measure-pr-preview.ts owner/repo 123 4561// Run with: node apps/server/scripts/measure-pr-preview.ts owner/repo 123 456
2// Numbers form a session with shared repository-permission caches. Browser and2// Numbers form a session with shared repository-permission caches. Browser and
3// service caches are excluded. Uses real GitHub reads, without a server or database.3// service caches are excluded. Uses real GitHub reads, without a server or database.
4// CLI-generated GraphQL4// Every GraphQL read carries `rateLimit`, so its cost is read off its own answer.
5// queries are replayed with rateLimit.cost, outside the timed section, to measure
6// their cost without confusing other applications' traffic with this process's.
7import * as NodeServices from "@effect/platform-node/NodeServices";5import * as NodeServices from "@effect/platform-node/NodeServices";
8import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
9import * as Console from "effect/Console";7import * as Console from "effect/Console";
10import * as Layer from "effect/Layer";8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
11import * as Schema from "effect/Schema";10import * as Schema from "effect/Schema";
11import { FetchHttpClient } from "effect/http";
1212
13import * as GitHubPullRequestCli from "../src/pullRequest/GitHubPullRequestCli.ts";13import * as GitHubPullRequestCli from "../src/pullRequest/GitHubPullRequestCli.ts";
14import * as GitHubPullRequestProvider from "../src/pullRequest/GitHubPullRequestProvider.ts";14import * 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";
16import * as VcsProcess from "../src/vcs/VcsProcess.ts";19import * as VcsProcess from "../src/vcs/VcsProcess.ts";
1720
18const [repository, ...numbers] = process.argv.slice(2);21const [repository, ...numbers] = process.argv.slice(2);
...@@ -20,77 +23,58 @@ if (!repository || numbers.length === 0 || numbers.some((number) => !/^\d+$/.tes...@@ -20,77 +23,58 @@ if (!repository || numbers.length === 0 || numbers.some((number) => !/^\d+$/.tes
20 throw new Error("Usage: node apps/server/scripts/measure-pr-preview.ts owner/repo number...");23 throw new Error("Usage: node apps/server/scripts/measure-pr-preview.ts owner/repo number...");
21}24}
2225
23type Read = { graphqlRequests: number; restRequests: number; cost: number; debug: string };26type Read = { graphqlRequests: number; restRequests: number; cost: number };
24const reads: Read[] = [];27const reads: Read[] = [];
25const layerMeasuredProcess = Layer.effect(28const decodeCost = Schema.decodeUnknownOption(
26 VcsProcess.VcsProcess,29 Schema.fromJsonString(
30 Schema.Struct({
31 data: Schema.Struct({ rateLimit: Schema.Struct({ cost: Schema.Number }) }),
32 }),
33 ),
34);
35const measuredApi = Layer.effect(
36 GitHubApi.GitHubApi,
27 Effect.gen(function* () {37 Effect.gen(function* () {
28 const vcs = yield* VcsProcess.VcsProcess;38 const api = yield* GitHubApi.make;
29 return VcsProcess.VcsProcess.of({39 return GitHubApi.GitHubApi.of({
30 run: (input) =>40 ...api,
31 vcs.run({ ...input, env: { ...input.env, GH_DEBUG: input.env?.GH_DEBUG ?? "api" } }).pipe(41 graphql: (input) =>
32 Effect.tap((output) =>42 api.graphql(input).pipe(
33 Effect.sync(() => {43 Effect.tap((body) =>
34 const graphqlRequests = [...output.stderr.matchAll(/^> POST \/graphql /gm)].length;44 Effect.sync(() =>
35 const requests = [...output.stderr.matchAll(/^> (?:GET|POST) /gm)].length;
36 const cost = /"rateLimit"\s*:\s*\{[^}]*"cost"\s*:\s*(\d+)/.exec(output.stdout)?.[1];
37 reads.push({45 reads.push({
38 graphqlRequests,46 graphqlRequests: 1,
39 restRequests: requests - graphqlRequests,47 restRequests: 0,
40 cost: Number(cost ?? 0),48 cost: Option.match(decodeCost(body), {
41 debug: output.stderr,49 onNone: () => 0,
42 });50 onSome: (decoded) => decoded.data.rateLimit.cost,
43 }),51 }),
52 }),
53 ),
44 ),54 ),
45 ),55 ),
56 rest: (input) =>
57 api
58 .rest(input)
59 .pipe(
60 Effect.tap(() =>
61 Effect.sync(() => reads.push({ graphqlRequests: 0, restRequests: 1, cost: 0 })),
62 ),
63 ),
46 });64 });
47 }),65 }),
48).pipe(Layer.provide(VcsProcess.layer), Layer.provide(NodeServices.layer));66).pipe(
4967 Layer.provide(GitHubCredentials.layer),
50const layerServices = GitHubPullRequestCli.layer.pipe(68 Layer.provide(GitHubGraphQlBudget.layer),
51 Layer.provide(GitHubCli.layer),69 Layer.provide(SourceControlRateLimit.layer),
52 Layer.provideMerge(layerMeasuredProcess),70 Layer.provide(FetchHttpClient.layer),
71 Layer.provide(VcsProcess.layer),
53 Layer.provide(NodeServices.layer),72 Layer.provide(NodeServices.layer),
54);73);
5574
56const decodeJson = Schema.decodeSync(Schema.fromJsonString(Schema.Unknown));75const services = GitHubPullRequestCli.layer.pipe(Layer.provideMerge(measuredApi));
76
57const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));77const 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
95for (const mode of ["detail", "preview"] as const) {79for (const mode of ["detail", "preview"] as const) {
96 // Start each side cold, then retain the caches shared across PRs in one session.80 // Start each side cold, then retain the caches shared across PRs in one session.
...@@ -108,8 +92,7 @@ for (const mode of ["detail", "preview"] as const) {...@@ -108,8 +92,7 @@ for (const mode of ["detail", "preview"] as const) {
108 : provider.getChangeRequestPreview!(input);92 : provider.getChangeRequestPreview!(input);
109 const elapsedMs = Math.round(performance.now() - start);93 const elapsedMs = Math.round(performance.now() - start);
110 const measured = [...reads];94 const measured = [...reads];
111 const costs = yield* Effect.forEach(measured, graphqlCost);95 const points = measured.reduce((total, read) => total + read.cost, 0);
112 const points = costs.reduce((total, cost) => total + cost, 0);
113 return {96 return {
114 repository,97 repository,
115 number,98 number,
...@@ -123,7 +106,7 @@ for (const mode of ["detail", "preview"] as const) {...@@ -123,7 +106,7 @@ for (const mode of ["detail", "preview"] as const) {
123 }),106 }),
124 );107 );
125 }),108 }),
126 ).pipe(Effect.provide(layerServices)),109 ).pipe(Effect.provide(services)),
127 );110 );
128 for (const row of rows) await Effect.runPromise(Console.log(encodeJson(row)));111 for (const row of rows) await Effect.runPromise(Console.log(encodeJson(row)));
129}112}
apps/server/src/pullRequest/GitHubPullRequestCli.test.ts+1033-1008
...@@ -7,13 +7,17 @@ import * as Fiber from "effect/Fiber";...@@ -7,13 +7,17 @@ import * as Fiber from "effect/Fiber";
7import * as Layer from "effect/Layer";7import * as Layer from "effect/Layer";
8import * as Schema from "effect/Schema";8import * as Schema from "effect/Schema";
9import * as TestClock from "effect/testing/TestClock";9import * 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";12import { HttpClient, HttpClientResponse } from "effect/http";
13import * as VcsProcess from "../vcs/VcsProcess.ts";13
14import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts";14import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts";
15import * as GitHubApi from "../sourceControl/GitHubApi.ts";
16import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts";
15import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts";17import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts";
18import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts";
16import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";19import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";
20import { KnownWorkflowRuns } from "./gitHubConditionalChecks.ts";
17import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts";21import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts";
1822
19const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));23const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
...@@ -48,60 +52,141 @@ const coreResponse = (pullRequest: Readonly<Record<string, unknown>> = {}) => ({...@@ -48,60 +52,141 @@ const coreResponse = (pullRequest: Readonly<Record<string, unknown>> = {}) => ({
48 },52 },
49});53});
5054
51const mockedExecute = vi.fn<GitHubCli.GitHubCli["Service"]["execute"]>();55/** One request the CLI made, as the transport received it. */
52const mockedStackMemberships = vi.fn<GitHubCli.GitHubCli["Service"]["execute"]>(() =>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>(() =>
53 Effect.succeed(output('{"data":{}}')),67 Effect.succeed(output('{"data":{}}')),
54);68);
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
57const layer = it.layer(97const layer = it.layer(
58 GitHubPullRequestCli.layer.pipe(98 GitHubPullRequestCli.layer.pipe(
59 Layer.provide(99 Layer.provideMerge(mockApi),
60 Layer.mock(GitHubCli.GitHubCli)({
61 execute: (input) =>
62 input.args.some((arg) => arg.includes("query PullRequestStackMemberships"))
63 ? mockedStackMemberships(input)
64 : mockedExecute(input),
65 getPullRequest: mockedGetPullRequest,
66 }),
67 ),
68 Layer.provideMerge(GitHubGraphQlBudget.layer),100 Layer.provideMerge(GitHubGraphQlBudget.layer),
69 Layer.provide(NodeCrypto.layer),101 Layer.provide(NodeCrypto.layer),
70 ),102 ),
71);103);
72104
73function output(stdout: string, stdoutTruncated = false, stdoutInvalidUtf8 = false) {105/** A successful answer; GraphQL reads take its body. */
74 return {106function output(
75 exitCode: ChildProcessSpawner.ExitCode(0),107 body: string,
76 stdout,108 truncated = false,
77 stderr: "",109 invalidUtf8 = false,
78 stdoutTruncated,110): GitHubApi.GitHubRestResponse {
79 stderrTruncated: false,111 return { status: 200, headers: {}, body, truncated, invalidUtf8 };
80 stdoutInvalidUtf8,
81 };
82}112}
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(
85 count: number,119 count: number,
86 firstNumber: number,120 firstNumber: number,
87 overrides: (number: number) => Readonly<Record<string, unknown>> = () => ({}),121 overrides: (number: number) => Readonly<Record<string, unknown>> = () => ({}),
88): string {122): ReadonlyArray<Record<string, unknown>> {
89 return JSON.stringify(123 return Array.from({ length: count }, (_, index) => {
90 Array.from({ length: count }, (_, index) => ({124 const number = firstNumber + index;
91 number: firstNumber + index,125 const { reviewRequests, checks, ...rest } = overrides(number) as {
92 title: `Pull request ${firstNumber + index}`,126 reviewRequests?: ReadonlyArray<Record<string, string>>;
93 url: `https://github.com/acme/web/pull/${firstNumber + index}`,127 checks?: string;
128 } & Record<string, unknown>;
129 return {
130 number,
131 title: `Pull request ${number}`,
132 url: `https://github.com/acme/web/pull/${number}`,
94 headRefName: "feat/page",133 headRefName: "feat/page",
95 baseRefName: "main",134 baseRefName: "main",
96 createdAt: "2026-07-01T00:00:00Z",135 createdAt: "2026-07-01T00:00:00Z",
97 updatedAt: "2026-07-02T00:00:00Z",136 updatedAt: "2026-07-02T00:00:00Z",
98 ...overrides(firstNumber + index),137 repository: { nameWithOwner: "acme/web" },
99 })),138 ...(reviewRequests === undefined
100 );139 ? {}
140 : {
141 reviewRequests: {
142 nodes: reviewRequests.map((requestedReviewer) => ({ requestedReviewer })),
143 },
144 }),
145 ...(checks === undefined
146 ? {}
147 : { commits: { nodes: [{ commit: { statusCheckRollup: { state: checks } } }] } }),
148 ...rest,
149 };
150 });
151}
152
153/** A page of the searched listing. */
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 });
101}182}
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
103function pullRequestFiles(count: number, firstIndex: number): string {188function pullRequestFiles(count: number, firstIndex: number): string {
104 return JSON.stringify(189 return encodeJson(
105 Array.from({ length: count }, (_, index) => ({190 Array.from({ length: count }, (_, index) => ({
106 filename: `src/file${firstIndex + index}.ts`,191 filename: `src/file${firstIndex + index}.ts`,
107 status: "modified",192 status: "modified",
...@@ -139,7 +224,7 @@ function reviewThreadsPage(...@@ -139,7 +224,7 @@ function reviewThreadsPage(
139 nodes: ReadonlyArray<Record<string, unknown>>,224 nodes: ReadonlyArray<Record<string, unknown>>,
140 endCursor: string | null,225 endCursor: string | null,
141): string {226): string {
142 return JSON.stringify({227 return encodeJson({
143 data: {228 data: {
144 repository: {229 repository: {
145 pullRequest: {230 pullRequest: {
...@@ -160,7 +245,7 @@ function threadCommentsPage(...@@ -160,7 +245,7 @@ function threadCommentsPage(
160 totalCount: number,245 totalCount: number,
161 pullRequestId = "PR_7",246 pullRequestId = "PR_7",
162): string {247): string {
163 return JSON.stringify({248 return encodeJson({
164 data: {249 data: {
165 repository: { pullRequest: { id: "PR_7" } },250 repository: { pullRequest: { id: "PR_7" } },
166 node: {251 node: {
...@@ -171,26 +256,43 @@ function threadCommentsPage(...@@ -171,26 +256,43 @@ function threadCommentsPage(
171 });256 });
172}257}
173258
174/** What `gh pr diff` answers on a pull request GitHub will not serve a diff for. */259/** What GitHub answers for a pull request it will not serve a whole diff for. */
175const diffRefused = new GitHubCli.GitHubCliCommandError({260const diffRefused = new GitHubApi.GitHubApiResponseError({
176 command: "gh",261 host: "github.com",
177 cwd: "/w",262 operation: "getPullRequestDiff",
178 cause: new Error("HTTP 406: the diff exceeded the maximum number of files (300)"),263 status: 406,
179});264});
180265
181/** The whole invocation the nth call made, so both argv and stdin can be asserted. */266/** The nth request the CLI made. */
182function callAt(index: number) {267function callAt(index: number): ApiCall {
183 const call = mockedExecute.mock.calls[index];268 const call = mockedExecute.mock.calls[index];
184 assert.isDefined(call);269 assert.isDefined(call);
185 return call[0];270 return call[0];
186}271}
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. */
189function searchOfCall(index: number): string | undefined {293function searchOfCall(index: number): string | undefined {
190 const args = callAt(index).args;294 const q = callAt(index).kind === "graphql" ? varsAt(index)["q"] : undefined;
191 const flag = args.indexOf("--search");295 return typeof q === "string" ? q : undefined;
192 // Absent is its own answer: a read that carries no `--search` at all is what the fallback is.
193 return flag === -1 ? undefined : args[flag + 1];
194}296}
195297
196/** One row as a search answers it, which is the listing's row one connection deeper. */298/** One row as a search answers it, which is the listing's row one connection deeper. */
...@@ -214,19 +316,143 @@ function searchItem(number: number, repository: string, updatedAt: string) {...@@ -214,19 +316,143 @@ function searchItem(number: number, repository: string, updatedAt: string) {
214}316}
215317
216function searchPage(nodes: ReadonlyArray<unknown>, hasNextPage = false) {318function searchPage(nodes: ReadonlyArray<unknown>, hasNextPage = false) {
217 return output(JSON.stringify({ data: { search: { pageInfo: { hasNextPage }, nodes } } }));319 return output(encodeJson({ data: { search: { pageInfo: { hasNextPage }, nodes } } }));
218}320}
219321
220/** The search a batched read sent, which travels in the request body rather than in argv. */322const searchQueryOfCall = searchOfCall;
221function searchQueryOfCall(index: number): string | undefined {323
222 const body = JSON.parse(callAt(index).stdin ?? "{}") as { variables?: { q?: string } };324/**
223 return body.variables?.q;325 * Answers every request with the body of the first route whose needle its GraphQL document or
326 * REST path contains, and anything unrouted with an empty object, which a mutation never reads.
327 */
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 );
224}348}
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
226afterEach(() => {451afterEach(() => {
227 mockedExecute.mockReset();452 mockedExecute.mockReset();
228 mockedStackMemberships.mockReset();453 mockedStackMemberships.mockReset();
229 mockedGetPullRequest.mockReset();454 mockedCredential.mockReset();
455 mockedCredential.mockImplementation(defaultCredential);
230});456});
231457
232it.effect(458it.effect(
...@@ -234,22 +460,41 @@ it.effect(...@@ -234,22 +460,41 @@ it.effect(
234 () =>460 () =>
235 Effect.gen(function* () {461 Effect.gen(function* () {
236 let activeToken = "broad-credential";462 let activeToken = "broad-credential";
237 const commands: VcsProcess.VcsProcessInput[] = [];463 const requests: Array<{ readonly url: string; readonly authorization: string | undefined }> =
238 const github = yield* GitHubCli.make.pipe(464 [];
239 Effect.provide(Layer.merge(GitHubGraphQlBudget.layer, SourceControlRateLimit.layer)),465 const credentials = Layer.succeed(
240 Effect.provideService(VcsProcess.VcsProcess, {466 GitHubCredentials.GitHubCredentials,
241 run: (input) =>467 GitHubCredentials.GitHubCredentials.of({
242 Effect.sync(() => {468 get: (host) =>
243 commands.push(input);469 Effect.sync(() => ({
244 if (input.args[0] === "auth") return output(activeToken);470 host,
245 if (input.args[0] === "api") return output('{"id":123,"login":"same-account"}');471 token: Redacted.make(activeToken),
246 return output("");472 source: "gh" as const,
247 }),473 fingerprint: `${host}:${activeToken.length}${activeToken.at(-1)}`,
474 })),
475 invalidate: () => Effect.void,
248 }),476 }),
249 );477 );
478 const http = Layer.succeed(
479 HttpClient.HttpClient,
480 HttpClient.make((request) =>
481 Effect.sync(() => {
482 requests.push({ url: request.url, authorization: request.headers["authorization"] });
483 const body = request.url.endsWith("/user")
484 ? { id: 123, login: "same-account" }
485 : { data: { repository: { pullRequest: { id: "PR_1" } }, addComment: {} } };
486 return HttpClientResponse.fromWeb(request, new Response(encodeJson(body)));
487 }),
488 ),
489 );
250 const cli = yield* GitHubPullRequestCli.make.pipe(490 const cli = yield* GitHubPullRequestCli.make.pipe(
251 Effect.provideService(GitHubCli.GitHubCli, github),491 Effect.provide(
252 Effect.provide(Layer.merge(GitHubGraphQlBudget.layer, NodeCrypto.layer)),492 GitHubApi.layer.pipe(
493 Layer.provide(Layer.mergeAll(credentials, http)),
494 Layer.provide(GitHubGraphQlBudget.layer),
495 Layer.provide(SourceControlRateLimit.layer),
496 ),
497 ),
253 );498 );
254 const input = { cwd: "/repo", host: "github.com" };499 const input = { cwd: "/repo", host: "github.com" };
255 const first = yield* cli.withVerifiedCredential(input, (identity) =>500 const first = yield* cli.withVerifiedCredential(input, (identity) =>
...@@ -270,16 +515,17 @@ it.effect(...@@ -270,16 +515,17 @@ it.effect(
270 expect(first.credentialFingerprint).not.toBe(second.credentialFingerprint);515 expect(first.credentialFingerprint).not.toBe(second.credentialFingerprint);
271 expect(encodeJson([first, second])).not.toContain("broad-credential");516 expect(encodeJson([first, second])).not.toContain("broad-credential");
272 expect(encodeJson([first, second])).not.toContain("restricted-credential");517 expect(encodeJson([first, second])).not.toContain("restricted-credential");
273 expect(commands.find((command) => command.args[0] === "pr")?.env).toMatchObject({518 // The comment was written under the credential the page verified, not the switched one.
274 GH_TOKEN: "broad-credential",519 expect(
275 GITHUB_TOKEN: "broad-credential",520 requests
276 GH_DEBUG: "",521 .filter((request) => request.url.endsWith("/graphql"))
277 });522 .map((request) => request.authorization),
523 ).toEqual(["Bearer broad-credential", "Bearer broad-credential"]);
278 expect(524 expect(
279 commands525 requests
280 .filter((command) => command.args[0] === "api")526 .filter((request) => request.url.endsWith("/user"))
281 .map((command) => command.env?.GH_TOKEN),527 .map((request) => request.authorization),
282 ).toEqual(["broad-credential", "restricted-credential"]);528 ).toEqual(["Bearer broad-credential", "Bearer restricted-credential"]);
283 expect(yield* cli.getRoutingIdentity(input)).toEqual({529 expect(yield* cli.getRoutingIdentity(input)).toEqual({
284 accountId: "123",530 accountId: "123",
285 viewer: "same-account",531 viewer: "same-account",
...@@ -394,18 +640,14 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -394,18 +640,14 @@ layer("GitHubPullRequestCli.layer", (it) => {
394 },640 },
395 });641 });
396 expect(mockedExecute).toHaveBeenCalledTimes(1);642 expect(mockedExecute).toHaveBeenCalledTimes(1);
397 expect(callAt(0).args).toEqual(643 expect(callAt(0)).toMatchObject({ kind: "graphql", host: "github.example" });
398 expect.arrayContaining(["api", "graphql", "--hostname", "github.example"]),
399 );
400 }),644 }),
401 );645 );
402646
403 it.effect("coalesces concurrent identity verification for the same host and credential", () =>647 it.effect("coalesces concurrent identity verification for the same host and credential", () =>
404 Effect.gen(function* () {648 Effect.gen(function* () {
405 mockedExecute.mockImplementation((input) =>649 mockedExecute.mockImplementation(() =>
406 input.args[0] === "auth"650 Effect.yieldNow.pipe(Effect.as(output('{"id":123,"login":"viewer"}'))),
407 ? Effect.succeed(output("shared-credential"))
408 : Effect.yieldNow.pipe(Effect.as(output('{"id":123,"login":"viewer"}'))),
409 );651 );
410 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;652 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
411 const results = yield* Effect.all(653 const results = yield* Effect.all(
...@@ -417,7 +659,8 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -417,7 +659,8 @@ layer("GitHubPullRequestCli.layer", (it) => {
417 expect(results).toEqual(659 expect(results).toEqual(
418 Array.from({ length: 4 }, () => ({ accountId: "123", viewer: "viewer" })),660 Array.from({ length: 4 }, () => ({ accountId: "123", viewer: "viewer" })),
419 );661 );
420 expect(mockedExecute.mock.calls.filter(([input]) => input.args[0] === "api")).toHaveLength(1);662 expect(mockedExecute).toHaveBeenCalledTimes(1);
663 expect(pathAt(0)).toBe("user");
421 }),664 }),
422 );665 );
423666
...@@ -426,15 +669,9 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -426,15 +669,9 @@ layer("GitHubPullRequestCli.layer", (it) => {
426 () =>669 () =>
427 Effect.gen(function* () {670 Effect.gen(function* () {
428 const firstStarted = yield* Deferred.make<void>();671 const firstStarted = yield* Deferred.make<void>();
429 const secondStarted = yield* Deferred.make<void>();
430 let tokens = 0;
431 let verifications = 0;672 let verifications = 0;
432 mockedExecute.mockImplementation((input) =>673 mockedExecute.mockImplementation(() =>
433 Effect.gen(function* () {674 Effect.gen(function* () {
434 if (input.args[0] === "auth") {
435 if (++tokens === 2) yield* Deferred.succeed(secondStarted, undefined);
436 return output("cancel-credential");
437 }
438 if (++verifications === 1) {675 if (++verifications === 1) {
439 yield* Deferred.succeed(firstStarted, undefined);676 yield* Deferred.succeed(firstStarted, undefined);
440 return yield* Effect.never;677 return yield* Effect.never;
...@@ -446,8 +683,9 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -446,8 +683,9 @@ layer("GitHubPullRequestCli.layer", (it) => {
446 const input = { cwd: "/w", host: "github.identity-cancel.test" };683 const input = { cwd: "/w", host: "github.identity-cancel.test" };
447 const first = yield* cli.getRoutingIdentity(input).pipe(Effect.forkChild);684 const first = yield* cli.getRoutingIdentity(input).pipe(Effect.forkChild);
448 yield* Deferred.await(firstStarted);685 yield* Deferred.await(firstStarted);
686 // The second reader is queued on the same credential's lock behind the first.
449 const second = yield* cli.getRoutingIdentity(input).pipe(Effect.forkChild);687 const second = yield* cli.getRoutingIdentity(input).pipe(Effect.forkChild);
450 yield* Deferred.await(secondStarted);688 yield* Effect.yieldNow;
451 yield* Fiber.interrupt(first);689 yield* Fiber.interrupt(first);
452 expect(yield* Fiber.join(second)).toEqual({ accountId: "123", viewer: "viewer" });690 expect(yield* Fiber.join(second)).toEqual({ accountId: "123", viewer: "viewer" });
453 expect(verifications).toBe(2);691 expect(verifications).toBe(2);
...@@ -479,8 +717,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -479,8 +717,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
479 mockedExecute.mockReturnValueOnce(717 mockedExecute.mockReturnValueOnce(
480 Effect.succeed(718 Effect.succeed(
481 output(719 output(
482 // @effect-diagnostics-next-line preferSchemaOverJson:off720 encodeJson({
483 JSON.stringify({
484 data: { s0: { pullRequest: node(7) }, s1: { pullRequest: node(8) } },721 data: { s0: { pullRequest: node(7) }, s1: { pullRequest: node(8) } },
485 }),722 }),
486 ),723 ),
...@@ -526,7 +763,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -526,7 +763,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
526 );763 );
527 assert.strictEqual(eight?.headBranch, "feat/8");764 assert.strictEqual(eight?.headBranch, "feat/8");
528 expect(mockedExecute).toHaveBeenCalledOnce();765 expect(mockedExecute).toHaveBeenCalledOnce();
529 const document = callAt(0).args.at(-1) ?? "";766 const document = queryAt(0);
530 expect(document).toContain(767 expect(document).toContain(
531 's0: repository(owner: "acme", name: "web") { pullRequest(number: 7)',768 's0: repository(owner: "acme", name: "web") { pullRequest(number: 7)',
532 );769 );
...@@ -575,7 +812,8 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -575,7 +812,8 @@ layer("GitHubPullRequestCli.layer", (it) => {
575 // GitHub had no answer for #8, so its watch reads it in full.812 // GitHub had no answer for #8, so its watch reads it in full.
576 expect(eight).toBeNull();813 expect(eight).toBeNull();
577 expect(mockedExecute).toHaveBeenCalledOnce();814 expect(mockedExecute).toHaveBeenCalledOnce();
578 expect(callAt(0).args.at(-1) ?? "").toContain(815 const call = callAt(0);
816 expect(call.kind === "graphql" ? call.query : "").toContain(
579 'w1: repository(owner: "acme", name: "web") { pullRequest(number: 8)',817 'w1: repository(owner: "acme", name: "web") { pullRequest(number: 8)',
580 );818 );
581 }),819 }),
...@@ -588,34 +826,28 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -588,34 +826,28 @@ layer("GitHubPullRequestCli.layer", (it) => {
588 .mockReturnValueOnce(826 .mockReturnValueOnce(
589 Effect.succeed(827 Effect.succeed(
590 output(828 output(
591 // @effect-diagnostics-next-line preferSchemaOverJson:off829 encodeJson({
592 JSON.stringify({830 data: {
593 number: 7,831 s0: {
594 title: "Reuse the summary",832 pullRequest: {
595 url: "https://github.com/acme/web/pull/7",833 number: 7,
596 author: { login: "octocat", name: "Octo Cat" },834 title: "Reuse the summary",
597 baseRefName: "main",835 url: "https://github.com/acme/web/pull/7",
598 headRefName: "feat/summary",836 author: { login: "octocat", name: "Octo Cat" },
599 state: "OPEN",837 baseRefName: "main",
600 isDraft: false,838 headRefName: "feat/summary",
601 mergeable: "MERGEABLE",839 state: "OPEN",
602 reviewDecision: "APPROVED",840 isDraft: false,
603 additions: 12,841 mergeable: "MERGEABLE",
604 deletions: 3,842 reviewDecision: "APPROVED",
605 changedFiles: 2,843 additions: 12,
606 createdAt: "2026-08-20T00:00:00.000Z",844 deletions: 3,
607 updatedAt: "2026-08-24T12:34:56.000Z",845 changedFiles: 2,
608 reviewRequests: [],846 updatedAt: "2026-08-24T12:34:56.000Z",
609 labels: [],847 commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] },
610 statusCheckRollup: [848 },
611 {
612 __typename: "CheckRun",
613 status: "COMPLETED",
614 conclusion: "SUCCESS",
615 name: "ci",
616 },849 },
617 ],850 },
618 body: "",
619 }),851 }),
620 ),852 ),
621 ),853 ),
...@@ -631,15 +863,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -631,15 +863,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
631 assert.strictEqual(summary.headBranch, "feat/summary");863 assert.strictEqual(summary.headBranch, "feat/summary");
632 assert.strictEqual(summary.checksState, "passing");864 assert.strictEqual(summary.checksState, "passing");
633 assert.strictEqual(mockedExecute.mock.calls.length, 2);865 assert.strictEqual(mockedExecute.mock.calls.length, 2);
634 expect(callAt(1).args).toEqual([866 expect(varsAt(1)).toEqual({ owner: "acme", name: "web", number: 7 });
635 "pr",
636 "view",
637 "7",
638 "--repo",
639 "github.com/acme/web",
640 "--json",
641 expect.stringContaining("statusCheckRollup"),
642 ]);
643 }),867 }),
644 );868 );
645869
...@@ -648,8 +872,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -648,8 +872,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
648 mockedExecute.mockReturnValueOnce(872 mockedExecute.mockReturnValueOnce(
649 Effect.succeed(873 Effect.succeed(
650 output(874 output(
651 // @effect-diagnostics-next-line preferSchemaOverJson:off875 encodeJson([
652 JSON.stringify([
653 {876 {
654 id: 42,877 id: 42,
655 number: 3,878 number: 3,
...@@ -688,12 +911,11 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -688,12 +911,11 @@ layer("GitHubPullRequestCli.layer", (it) => {
688 { number: 7, headBranch: "feat/two", state: "open" },911 { number: 7, headBranch: "feat/two", state: "open" },
689 ],912 ],
690 });913 });
691 assert.deepStrictEqual(callAt(0).args, [914 expect(callAt(0)).toMatchObject({
692 "api",915 kind: "rest",
693 "--hostname",916 host: "ghe.example.com",
694 "ghe.example.com",917 path: "repos/acme/web/stacks?pull_request=7",
695 "repos/acme/web/stacks?pull_request=7",918 });
696 ]);
697 }),919 }),
698 );920 );
699921
...@@ -707,13 +929,11 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -707,13 +929,11 @@ layer("GitHubPullRequestCli.layer", (it) => {
707 { number: 7, head: { ref: "feat/two", sha: "abc123" }, state: "open", merged_at: null },929 { number: 7, head: { ref: "feat/two", sha: "abc123" }, state: "open", merged_at: null },
708 ],930 ],
709 };931 };
710 // @effect-diagnostics-next-line preferSchemaOverJson:off932 mockedExecute.mockReturnValueOnce(Effect.succeed(output(encodeJson([minimal]))));
711 mockedExecute.mockReturnValueOnce(Effect.succeed(output(JSON.stringify([minimal]))));
712 mockedExecute.mockReturnValueOnce(933 mockedExecute.mockReturnValueOnce(
713 Effect.succeed(934 Effect.succeed(
714 output(935 output(
715 // @effect-diagnostics-next-line preferSchemaOverJson:off936 encodeJson({
716 JSON.stringify({
717 ...minimal,937 ...minimal,
718 pull_requests: [{ ...minimal.pull_requests[0], title: "Second layer", draft: false }],938 pull_requests: [{ ...minimal.pull_requests[0], title: "Second layer", draft: false }],
719 }),939 }),
...@@ -733,12 +953,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -733,12 +953,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
733 headSha: "abc123",953 headSha: "abc123",
734 isDraft: false,954 isDraft: false,
735 });955 });
736 expect(callAt(1).args).toEqual([956 expect(pathAt(1)).toBe("repos/acme/web/stacks/3");
737 "api",
738 "--hostname",
739 "github.com",
740 "repos/acme/web/stacks/3",
741 ]);
742 }),957 }),
743 );958 );
744959
...@@ -760,13 +975,11 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -760,13 +975,11 @@ layer("GitHubPullRequestCli.layer", (it) => {
760975
761 it.effect("reads a host that refuses the stacks preview as not stacked", () =>976 it.effect("reads a host that refuses the stacks preview as not stacked", () =>
762 Effect.gen(function* () {977 Effect.gen(function* () {
763 // The CLI classifies a missing preview endpoint as not found.
764 mockedExecute.mockReturnValueOnce(978 mockedExecute.mockReturnValueOnce(
765 Effect.fail(979 Effect.fail(
766 new GitHubCli.GitHubPullRequestNotFoundError({980 new GitHubApi.GitHubApiNotFoundError({
767 command: "gh",981 host: "github.com",
768 cwd: "/w",982 operation: "getPullRequestStack",
769 cause: new Error("HTTP 404: Not Found (https://api.github.com/repos/acme/web/stacks)"),
770 }),983 }),
771 ),984 ),
772 );985 );
...@@ -783,14 +996,13 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -783,14 +996,13 @@ layer("GitHubPullRequestCli.layer", (it) => {
783 }),996 }),
784 );997 );
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", () =>
787 Effect.gen(function* () {1000 Effect.gen(function* () {
788 mockedExecute.mockReturnValueOnce(1001 mockedExecute.mockReturnValueOnce(
789 Effect.fail(1002 Effect.fail(
790 new GitHubCli.GitHubCliAuthenticationError({1003 new GitHubApi.GitHubApiAuthenticationError({
791 command: "gh",1004 host: "github.com",
792 cwd: "/w",1005 operation: "getPullRequestStack",
793 cause: new Error("gh auth login"),
794 }),1006 }),
795 ),1007 ),
796 );1008 );
...@@ -805,16 +1017,16 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -805,16 +1017,16 @@ layer("GitHubPullRequestCli.layer", (it) => {
805 }),1017 }),
806 );1018 );
8071019
808 assert.strictEqual(error._tag, "GitHubCliAuthenticationError");1020 assert.strictEqual(error._tag, "GitHubApiAuthenticationError");
809 }),1021 }),
810 );1022 );
8111023
812 it.effect("preserves transient stack failures instead of reporting no stack", () =>1024 it.effect("preserves transient stack failures instead of reporting no stack", () =>
813 Effect.gen(function* () {1025 Effect.gen(function* () {
814 const failure = new GitHubCli.GitHubCliCommandError({1026 const failure = new GitHubApi.GitHubApiResponseError({
815 command: "gh",1027 host: "github.com",
816 cwd: "/w",1028 operation: "getPullRequestStack",
817 cause: new Error("HTTP 503"),1029 status: 503,
818 });1030 });
819 mockedExecute.mockReturnValueOnce(Effect.fail(failure));1031 mockedExecute.mockReturnValueOnce(Effect.fail(failure));
820 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;1032 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
...@@ -867,13 +1079,9 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -867,13 +1079,9 @@ layer("GitHubPullRequestCli.layer", (it) => {
8671079
868 assert.strictEqual(batch.items.length, 3);1080 assert.strictEqual(batch.items.length, 3);
869 assert.isFalse(batch.truncated);1081 assert.isFalse(batch.truncated);
870 const args = callAt(0).args;1082 expect(callAt(0)).toMatchObject({ kind: "graphql", host: "github.com" });
871 expect(args).toContain("--repo");1083 expect(searchOfCall(0)).toBe("is:pr is:open sort:updated-desc repo:acme/web");
872 expect(args).toContain("github.com/acme/web");1084 expect(queryAt(0)).toContain("first: 11");
873 expect(args).toContain("--state");
874 expect(args).toContain("open");
875 expect(args).toContain("--limit");
876 expect(args).toContain("11");
877 }),1085 }),
878 );1086 );
8791087
...@@ -899,7 +1107,13 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -899,7 +1107,13 @@ layer("GitHubPullRequestCli.layer", (it) => {
8991107
900 it.effect("excludes merged pull requests from the Closed tab", () =>1108 it.effect("excludes merged pull requests from the Closed tab", () =>
901 Effect.gen(function* () {1109 Effect.gen(function* () {
902 mockedExecute.mockReturnValue(Effect.succeed(output("[]")));1110 mockedExecute.mockImplementation((call) =>
1111 Effect.succeed(
1112 call.kind === "graphql" && call.query.includes("pullRequests(")
1113 ? emptyList()
1114 : emptySearch(),
1115 ),
1116 );
903 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;1117 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
9041118
905 yield* cli.listPullRequests({1119 yield* cli.listPullRequests({
...@@ -912,14 +1126,20 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -912,14 +1126,20 @@ layer("GitHubPullRequestCli.layer", (it) => {
912 limit: 10,1126 limit: 10,
913 });1127 });
9141128
915 // `--state closed` includes merged pull requests, so the tab narrows through search.1129 // A closed pull request may also be a merged one, so the tab narrows through search.
916 expect(searchOfCall(0)).toBe("is:unmerged sort:updated-desc");1130 expect(searchOfCall(0)).toBe("is:pr is:closed is:unmerged sort:updated-desc repo:acme/web");
917 }),1131 }),
918 );1132 );
9191133
920 it.effect("narrows to the author on the authored tab", () =>1134 it.effect("narrows to the author on the authored tab", () =>
921 Effect.gen(function* () {1135 Effect.gen(function* () {
922 mockedExecute.mockReturnValue(Effect.succeed(output("[]")));1136 mockedExecute.mockImplementation((call) =>
1137 Effect.succeed(
1138 call.kind === "graphql" && call.query.includes("pullRequests(")
1139 ? emptyList()
1140 : emptySearch(),
1141 ),
1142 );
923 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;1143 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
9241144
925 yield* cli.listPullRequests({1145 yield* cli.listPullRequests({
...@@ -932,15 +1152,19 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -932,15 +1152,19 @@ layer("GitHubPullRequestCli.layer", (it) => {
932 limit: 10,1152 limit: 10,
933 });1153 });
9341154
935 const args = callAt(0).args;1155 expect(searchOfCall(0)).toBe("is:pr is:open author:bilal sort:updated-desc repo:acme/web");
936 expect(args).toContain("--author");
937 expect(args).toContain("bilal");
938 }),1156 }),
939 );1157 );
9401158
941 it.effect("narrows through search on the reviewing tab", () =>1159 it.effect("narrows through search on the reviewing tab", () =>
942 Effect.gen(function* () {1160 Effect.gen(function* () {
943 mockedExecute.mockReturnValue(Effect.succeed(output("[]")));1161 mockedExecute.mockImplementation((call) =>
1162 Effect.succeed(
1163 call.kind === "graphql" && call.query.includes("pullRequests(")
1164 ? emptyList()
1165 : emptySearch(),
1166 ),
1167 );
944 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;1168 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
9451169
946 yield* cli.listPullRequests({1170 yield* cli.listPullRequests({
...@@ -953,7 +1177,9 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -953,7 +1177,9 @@ layer("GitHubPullRequestCli.layer", (it) => {
953 limit: 10,1177 limit: 10,
954 });1178 });
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 );
957 }),1183 }),
958 );1184 );
9591185
...@@ -1008,7 +1234,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1008,7 +1234,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
1008 }),1234 }),
1009 );1235 );
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", () =>
1012 Effect.gen(function* () {1238 Effect.gen(function* () {
1013 mockedExecute.mockReturnValue(Effect.succeed(searchPage([])));1239 mockedExecute.mockReturnValue(Effect.succeed(searchPage([])));
1014 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;1240 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
...@@ -1024,13 +1250,11 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1024,13 +1250,11 @@ layer("GitHubPullRequestCli.layer", (it) => {
1024 query: 'x" is:merged repo:evil/repo',1250 query: 'x" is:merged repo:evil/repo',
1025 });1251 });
10261252
1027 // Quoted and escaped, so the words a reader typed narrow the listing rather than widening1253 // Quoted and escaped, so the words a reader typed narrow the listing rather than widening it.
1028 // it — and the whole document travels over stdin rather than in a visible argv.
1029 assert.strictEqual(1254 assert.strictEqual(
1030 searchQueryOfCall(0),1255 searchQueryOfCall(0),
1031 'is:pr is:open "x\\" is:merged repo:evil/repo" sort:updated-desc repo:acme/web',1256 'is:pr is:open "x\\" is:merged repo:evil/repo" sort:updated-desc repo:acme/web',
1032 );1257 );
1033 expect(callAt(0).args).not.toContain("-f");
1034 }),1258 }),
1035 );1259 );
10361260
...@@ -1139,12 +1363,12 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1139,12 +1363,12 @@ layer("GitHubPullRequestCli.layer", (it) => {
11391363
1140 it.effect("enriches only the visible fallback rows after filtering and widening", () =>1364 it.effect("enriches only the visible fallback rows after filtering and widening", () =>
1141 Effect.gen(function* () {1365 Effect.gen(function* () {
1142 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));1366 mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch()));
1143 mockedExecute.mockReturnValueOnce(1367 mockedExecute.mockReturnValueOnce(
1144 Effect.succeed(output(pullRequests(3, 1, () => ({ isDraft: true })))),1368 Effect.succeed(output(listedPullRequests(3, 1, () => ({ isDraft: true })))),
1145 );1369 );
1146 mockedExecute.mockReturnValueOnce(1370 mockedExecute.mockReturnValueOnce(
1147 Effect.succeed(output(pullRequests(6, 1, (number) => ({ isDraft: number < 4 })))),1371 Effect.succeed(output(listedPullRequests(6, 1, (number) => ({ isDraft: number < 4 })))),
1148 );1372 );
1149 mockedStackMemberships.mockReturnValueOnce(1373 mockedStackMemberships.mockReturnValueOnce(
1150 Effect.succeed(1374 Effect.succeed(
...@@ -1180,7 +1404,8 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1180,7 +1404,8 @@ layer("GitHubPullRequestCli.layer", (it) => {
1180 expect(batch.truncated).toBe(true);1404 expect(batch.truncated).toBe(true);
1181 expect(batch.continues).toBe(false);1405 expect(batch.continues).toBe(false);
1182 expect(mockedStackMemberships).toHaveBeenCalledTimes(1);1406 expect(mockedStackMemberships).toHaveBeenCalledTimes(1);
1183 const query = mockedStackMemberships.mock.calls[0]?.[0].args.at(-1) ?? "";1407 const membership = mockedStackMemberships.mock.calls[0]?.[0];
1408 const query = membership?.kind === "graphql" ? membership.query : "";
1184 expect(query).toContain("pullRequest(number: 4)");1409 expect(query).toContain("pullRequest(number: 4)");
1185 expect(query).toContain("pullRequest(number: 5)");1410 expect(query).toContain("pullRequest(number: 5)");
1186 expect(query).not.toContain("pullRequest(number: 1)");1411 expect(query).not.toContain("pullRequest(number: 1)");
...@@ -1192,12 +1417,12 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1192,12 +1417,12 @@ layer("GitHubPullRequestCli.layer", (it) => {
1192 Effect.gen(function* () {1417 Effect.gen(function* () {
1193 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(27, 1))));1418 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(27, 1))));
1194 mockedStackMemberships.mockImplementation((input) =>1419 mockedStackMemberships.mockImplementation((input) =>
1195 input.args.at(-1)?.includes("pullRequest(number: 26)")1420 input.kind === "graphql" && input.query.includes("pullRequest(number: 26)")
1196 ? Effect.fail(1421 ? Effect.fail(
1197 new GitHubCli.GitHubCliCommandError({1422 new GitHubApi.GitHubApiResponseError({
1198 command: "gh",1423 host: "github.com",
1199 cwd: "/w",1424 operation: "listPullRequestStackMemberships",
1200 cause: new Error("HTTP 502"),1425 status: 502,
1201 }),1426 }),
1202 )1427 )
1203 : Effect.succeed(1428 : Effect.succeed(
...@@ -1238,8 +1463,8 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1238,8 +1463,8 @@ layer("GitHubPullRequestCli.layer", (it) => {
12381463
1239 it.effect("skips membership enrichment for empty pages and enterprise hosts", () =>1464 it.effect("skips membership enrichment for empty pages and enterprise hosts", () =>
1240 Effect.gen(function* () {1465 Effect.gen(function* () {
1241 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));1466 mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch()));
1242 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));1467 mockedExecute.mockReturnValueOnce(Effect.succeed(emptyList()));
1243 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(1, 7))));1468 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(1, 7))));
1244 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;1469 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
1245 const input = {1470 const input = {
...@@ -1268,7 +1493,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1268,7 +1493,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
1268 // Every chunk answers for its first alias only, so a row GitHub said nothing about is1493 // Every chunk answers for its first alias only, so a row GitHub said nothing about is
1269 // dropped rather than shown as a change of no size.1494 // dropped rather than shown as a change of no size.
1270 Effect.succeed(1495 Effect.succeed(
1271 output(JSON.stringify({ data: { s0: { pullRequest: { additions: 4, deletions: 1 } } } })),1496 output(encodeJson({ data: { s0: { pullRequest: { additions: 4, deletions: 1 } } } })),
1272 ),1497 ),
1273 );1498 );
1274 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;1499 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
...@@ -1285,7 +1510,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1285,7 +1510,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
1285 { repository: "acme/web", number: 1, additions: 4, deletions: 1 },1510 { repository: "acme/web", number: 1, additions: 4, deletions: 1 },
1286 { repository: "acme/web", number: 26, additions: 4, deletions: 1 },1511 { repository: "acme/web", number: 26, additions: 4, deletions: 1 },
1287 ]);1512 ]);
1288 const document = callAt(0).args.at(-1) ?? "";1513 const document = queryAt(0);
1289 expect(document).toContain('s0: repository(owner: "acme", name: "web")');1514 expect(document).toContain('s0: repository(owner: "acme", name: "web")');
1290 expect(document).toContain("pullRequest(number: 25)");1515 expect(document).toContain("pullRequest(number: 25)");
1291 }),1516 }),
...@@ -1310,7 +1535,13 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1310,7 +1535,13 @@ layer("GitHubPullRequestCli.layer", (it) => {
13101535
1311 it.effect("hands a search to GitHub rather than to the rows already read", () =>1536 it.effect("hands a search to GitHub rather than to the rows already read", () =>
1312 Effect.gen(function* () {1537 Effect.gen(function* () {
1313 mockedExecute.mockReturnValue(Effect.succeed(output("[]")));1538 mockedExecute.mockImplementation((call) =>
1539 Effect.succeed(
1540 call.kind === "graphql" && call.query.includes("pullRequests(")
1541 ? emptyList()
1542 : emptySearch(),
1543 ),
1544 );
1314 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;1545 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
13151546
1316 yield* cli.listPullRequests({1547 yield* cli.listPullRequests({
...@@ -1326,13 +1557,21 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1326,13 +1557,21 @@ layer("GitHubPullRequestCli.layer", (it) => {
13261557
1327 // The recency qualifier rides along, because free text would otherwise reorder the page1558 // The recency qualifier rides along, because free text would otherwise reorder the page
1328 // by relevance and truncation would drop the newest matches.1559 // by relevance and truncation would drop the newest matches.
1329 expect(searchOfCall(0)).toBe('"pull requests page" sort:updated-desc');1560 expect(searchOfCall(0)).toBe(
1561 'is:pr is:open "pull requests page" sort:updated-desc repo:acme/web',
1562 );
1330 }),1563 }),
1331 );1564 );
13321565
1333 it.effect("joins a search onto the tab's own qualifiers instead of replacing them", () =>1566 it.effect("joins a search onto the tab's own qualifiers instead of replacing them", () =>
1334 Effect.gen(function* () {1567 Effect.gen(function* () {
1335 mockedExecute.mockReturnValue(Effect.succeed(output("[]")));1568 mockedExecute.mockImplementation((call) =>
1569 Effect.succeed(
1570 call.kind === "graphql" && call.query.includes("pullRequests(")
1571 ? emptyList()
1572 : emptySearch(),
1573 ),
1574 );
1336 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;1575 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
13371576
1338 yield* cli.listPullRequests({1577 yield* cli.listPullRequests({
...@@ -1346,16 +1585,21 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1346,16 +1585,21 @@ layer("GitHubPullRequestCli.layer", (it) => {
1346 query: "page",1585 query: "page",
1347 });1586 });
13481587
1349 // One `--search` is all gh reads, so a second would silently drop the first.1588 expect(searchOfCall(0)).toBe(
1350 const args = callAt(0).args;1589 'is:pr is:closed is:unmerged review-requested:bilal "page" sort:updated-desc repo:acme/web',
1351 assert.strictEqual(args.filter((arg) => arg === "--search").length, 1);1590 );
1352 expect(searchOfCall(0)).toBe('review-requested:bilal is:unmerged "page" sort:updated-desc');
1353 }),1591 }),
1354 );1592 );
13551593
1356 it.effect("carries the further narrowings into the search as qualifiers", () =>1594 it.effect("carries the further narrowings into the search as qualifiers", () =>
1357 Effect.gen(function* () {1595 Effect.gen(function* () {
1358 mockedExecute.mockReturnValue(Effect.succeed(output("[]")));1596 mockedExecute.mockImplementation((call) =>
1597 Effect.succeed(
1598 call.kind === "graphql" && call.query.includes("pullRequests(")
1599 ? emptyList()
1600 : emptySearch(),
1601 ),
1602 );
1359 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;1603 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
13601604
1361 yield* cli.listPullRequests({1605 yield* cli.listPullRequests({
...@@ -1379,15 +1623,21 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1379,15 +1623,21 @@ layer("GitHubPullRequestCli.layer", (it) => {
1379 // Quotes around anything a reader typed, and the one character that could end a quoted1623 // Quotes around anything a reader typed, and the one character that could end a quoted
1380 // value early dropped rather than escaped.1624 // value early dropped rather than escaped.
1381 expect(searchOfCall(0)).toBe(1625 expect(searchOfCall(0)).toBe(
1382 'label:"needs design" label:"quote" -label:"wip" author:"octocat" draft:false ' +1626 'is:pr is:open label:"needs design" label:"quote" -label:"wip" author:"octocat" ' +
1383 "review:changes_requested status:failure sort:updated-desc",1627 "draft:false review:changes_requested status:failure sort:updated-desc repo:acme/web",
1384 );1628 );
1385 }),1629 }),
1386 );1630 );
13871631
1388 it.effect('resolves an author filter of "me" to the viewer, not the literal word', () =>1632 it.effect('resolves an author filter of "me" to the viewer, not the literal word', () =>
1389 Effect.gen(function* () {1633 Effect.gen(function* () {
1390 mockedExecute.mockReturnValue(Effect.succeed(output("[]")));1634 mockedExecute.mockImplementation((call) =>
1635 Effect.succeed(
1636 call.kind === "graphql" && call.query.includes("pullRequests(")
1637 ? emptyList()
1638 : emptySearch(),
1639 ),
1640 );
1391 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;1641 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
13921642
1393 yield* cli.listPullRequests({1643 yield* cli.listPullRequests({
...@@ -1401,13 +1651,19 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1401,13 +1651,19 @@ layer("GitHubPullRequestCli.layer", (it) => {
1401 filters: { author: "me" },1651 filters: { author: "me" },
1402 });1652 });
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');
1405 }),1655 }),
1406 );1656 );
14071657
1408 it.effect("sends one label qualifier per group, its names joined the way GitHub ors them", () =>1658 it.effect("sends one label qualifier per group, its names joined the way GitHub ors them", () =>
1409 Effect.gen(function* () {1659 Effect.gen(function* () {
1410 mockedExecute.mockReturnValue(Effect.succeed(output("[]")));1660 mockedExecute.mockImplementation((call) =>
1661 Effect.succeed(
1662 call.kind === "graphql" && call.query.includes("pullRequests(")
1663 ? emptyList()
1664 : emptySearch(),
1665 ),
1666 );
1411 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;1667 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
14121668
1413 yield* cli.listPullRequests({1669 yield* cli.listPullRequests({
...@@ -1422,8 +1678,9 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1422,8 +1678,9 @@ layer("GitHubPullRequestCli.layer", (it) => {
1422 });1678 });
14231679
1424 // One qualifier satisfied by either size, and a second one that must hold as well.1680 // One qualifier satisfied by either size, and a second one that must hold as well.
1425 expect(searchOfCall(0)).toBe('label:"size:S","size:XS" label:"bug" sort:updated-desc');1681 expect(searchOfCall(0)).toBe(
1426 expect(callAt(0).args).toContain('label:"size:S","size:XS" label:"bug" sort:updated-desc');1682 'is:pr is:open label:"size:S","size:XS" label:"bug" sort:updated-desc repo:acme/web',
1683 );
1427 }),1684 }),
1428 );1685 );
14291686
...@@ -1431,15 +1688,12 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1431,15 +1688,12 @@ layer("GitHubPullRequestCli.layer", (it) => {
1431 "falls back for a repository the index does not cover under a checks filter, keeping only the matching rows",1688 "falls back for a repository the index does not cover under a checks filter, keeping only the matching rows",
1432 () =>1689 () =>
1433 Effect.gen(function* () {1690 Effect.gen(function* () {
1434 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));1691 mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch()));
1435 mockedExecute.mockReturnValueOnce(1692 mockedExecute.mockReturnValueOnce(
1436 Effect.succeed(1693 Effect.succeed(
1437 output(1694 output(
1438 pullRequests(2, 1, (number) => ({1695 listedPullRequests(2, 1, (number) => ({
1439 statusCheckRollup:1696 checks: number === 1 ? "SUCCESS" : "FAILURE",
1440 number === 1
1441 ? [{ name: "lint", status: "COMPLETED", conclusion: "SUCCESS" }]
1442 : [{ name: "test", status: "COMPLETED", conclusion: "FAILURE" }],
1443 })),1697 })),
1444 ),1698 ),
1445 ),1699 ),
...@@ -1471,15 +1725,9 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1471,15 +1725,9 @@ layer("GitHubPullRequestCli.layer", (it) => {
14711725
1472 it.effect("fails a checks filter for a row whose checks are still pending", () =>1726 it.effect("fails a checks filter for a row whose checks are still pending", () =>
1473 Effect.gen(function* () {1727 Effect.gen(function* () {
1474 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));1728 mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch()));
1475 mockedExecute.mockReturnValueOnce(1729 mockedExecute.mockReturnValueOnce(
1476 Effect.succeed(1730 Effect.succeed(output(listedPullRequests(1, 1, () => ({ checks: "PENDING" })))),
1477 output(
1478 pullRequests(1, 1, () => ({
1479 statusCheckRollup: [{ name: "build", status: "IN_PROGRESS" }],
1480 })),
1481 ),
1482 ),
1483 );1731 );
1484 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;1732 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
14851733
...@@ -1504,9 +1752,9 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1504,9 +1752,9 @@ layer("GitHubPullRequestCli.layer", (it) => {
1504 "falls back for a repository the index does not cover even under a judgeable filter",1752 "falls back for a repository the index does not cover even under a judgeable filter",
1505 () =>1753 () =>
1506 Effect.gen(function* () {1754 Effect.gen(function* () {
1507 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));1755 mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch()));
1508 mockedExecute.mockReturnValueOnce(1756 mockedExecute.mockReturnValueOnce(
1509 Effect.succeed(output(pullRequests(2, 1, (number) => ({ isDraft: number === 1 })))),1757 Effect.succeed(output(listedPullRequests(2, 1, (number) => ({ isDraft: number === 1 })))),
1510 );1758 );
1511 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;1759 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
15121760
...@@ -1555,7 +1803,13 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1555,7 +1803,13 @@ layer("GitHubPullRequestCli.layer", (it) => {
15551803
1556 it.effect("quotes a search, so it cannot add a qualifier or a flag of its own", () =>1804 it.effect("quotes a search, so it cannot add a qualifier or a flag of its own", () =>
1557 Effect.gen(function* () {1805 Effect.gen(function* () {
1558 mockedExecute.mockReturnValue(Effect.succeed(output("[]")));1806 mockedExecute.mockImplementation((call) =>
1807 Effect.succeed(
1808 call.kind === "graphql" && call.query.includes("pullRequests(")
1809 ? emptyList()
1810 : emptySearch(),
1811 ),
1812 );
1559 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;1813 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
15601814
1561 yield* cli.listPullRequests({1815 yield* cli.listPullRequests({
...@@ -1572,15 +1826,20 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1572,15 +1826,20 @@ layer("GitHubPullRequestCli.layer", (it) => {
1572 // Every word stays inside one phrase: nothing before it, nothing after it, and the1826 // Every word stays inside one phrase: nothing before it, nothing after it, and the
1573 // leading dashes are text rather than the start of another argument.1827 // leading dashes are text rather than the start of another argument.
1574 expect(searchOfCall(0)).toBe(1828 expect(searchOfCall(0)).toBe(
1575 String.raw`"-- is:merged label:secret \"widen me\"" sort:updated-desc`,1829 String.raw`is:pr is:open "-- is:merged label:secret \"widen me\"" sort:updated-desc repo:acme/web`,
1576 );1830 );
1577 expect(callAt(0).args).not.toContain("is:merged");
1578 }),1831 }),
1579 );1832 );
15801833
1581 it.effect("escapes a backslash before the quote it would otherwise let out", () =>1834 it.effect("escapes a backslash before the quote it would otherwise let out", () =>
1582 Effect.gen(function* () {1835 Effect.gen(function* () {
1583 mockedExecute.mockReturnValue(Effect.succeed(output("[]")));1836 mockedExecute.mockImplementation((call) =>
1837 Effect.succeed(
1838 call.kind === "graphql" && call.query.includes("pullRequests(")
1839 ? emptyList()
1840 : emptySearch(),
1841 ),
1842 );
1584 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;1843 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
15851844
1586 yield* cli.listPullRequests({1845 yield* cli.listPullRequests({
...@@ -1596,13 +1855,21 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1596,13 +1855,21 @@ layer("GitHubPullRequestCli.layer", (it) => {
15961855
1597 // GitHub reads `\\` as one backslash and `\"` as one quote, so the phrase ends where1856 // GitHub reads `\\` as one backslash and `\"` as one quote, so the phrase ends where
1598 // this says it does; escaping the quote alone would have closed it early.1857 // this says it does; escaping the quote alone would have closed it early.
1599 expect(searchOfCall(0)).toBe(String.raw`"a\\\" is:merged" sort:updated-desc`);1858 expect(searchOfCall(0)).toBe(
1859 String.raw`is:pr is:open "a\\\" is:merged" sort:updated-desc repo:acme/web`,
1860 );
1600 }),1861 }),
1601 );1862 );
16021863
1603 it.effect("asks for nothing but the order when the reader typed only spaces", () =>1864 it.effect("asks for nothing but the order when the reader typed only spaces", () =>
1604 Effect.gen(function* () {1865 Effect.gen(function* () {
1605 mockedExecute.mockReturnValue(Effect.succeed(output("[]")));1866 mockedExecute.mockImplementation((call) =>
1867 Effect.succeed(
1868 call.kind === "graphql" && call.query.includes("pullRequests(")
1869 ? emptyList()
1870 : emptySearch(),
1871 ),
1872 );
1606 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;1873 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
16071874
1608 yield* cli.listPullRequests({1875 yield* cli.listPullRequests({
...@@ -1618,7 +1885,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1618,7 +1885,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
16181885
1619 // An empty phrase would match nothing rather than everything, so it is left out; the1886 // An empty phrase would match nothing rather than everything, so it is left out; the
1620 // order the page reads rows in is asked for whether or not anything was typed.1887 // order the page reads rows in is asked for whether or not anything was typed.
1621 expect(searchOfCall(0)).toBe("sort:updated-desc");1888 expect(searchOfCall(0)).toBe("is:pr is:open sort:updated-desc repo:acme/web");
1622 }),1889 }),
1623 );1890 );
16241891
...@@ -1640,7 +1907,9 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1640,7 +1907,9 @@ layer("GitHubPullRequestCli.layer", (it) => {
16401907
1641 // Inclusive, so the rows already sent at that instant come back for the caller to drop —1908 // Inclusive, so the rows already sent at that instant come back for the caller to drop —
1642 // which is what keeps the ones beside them from being skipped.1909 // which is what keeps the ones beside them from being skipped.
1643 expect(searchOfCall(0)).toBe("updated:<=2026-07-02T00:00:00Z sort:updated-desc");1910 expect(searchOfCall(0)).toBe(
1911 "is:pr is:open updated:<=2026-07-02T00:00:00Z sort:updated-desc repo:acme/web",
1912 );
1644 assert.isTrue(batch.continues);1913 assert.isTrue(batch.continues);
1645 }),1914 }),
1646 );1915 );
...@@ -1650,7 +1919,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1650,7 +1919,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
1650 // The fallback is for a repository the index does not cover. Under a text search an empty1919 // The fallback is for a repository the index does not cover. Under a text search an empty
1651 // answer means the text matched nothing, and listing everything instead would fill the1920 // answer means the text matched nothing, and listing everything instead would fill the
1652 // page with rows the reader did not search for.1921 // page with rows the reader did not search for.
1653 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));1922 mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch()));
1654 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;1923 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
16551924
1656 const batch = yield* cli.listPullRequests({1925 const batch = yield* cli.listPullRequests({
...@@ -1669,12 +1938,12 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1669,12 +1938,12 @@ layer("GitHubPullRequestCli.layer", (it) => {
1669 }),1938 }),
1670 );1939 );
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", () =>
1673 Effect.gen(function* () {1942 Effect.gen(function* () {
1674 // GitHub answers for a repository outside its search index with no rows and no error.1943 // GitHub answers for a repository outside its search index with no rows and no error.
1675 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));1944 mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch()));
1676 mockedExecute.mockReturnValueOnce(1945 mockedExecute.mockReturnValueOnce(
1677 Effect.succeed(output(pullRequests(3, 1, () => ({ state: "CLOSED" })))),1946 Effect.succeed(output(listedPullRequests(3, 1, () => ({ state: "CLOSED" })))),
1678 );1947 );
1679 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;1948 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
16801949
...@@ -1689,20 +1958,21 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1689,20 +1958,21 @@ layer("GitHubPullRequestCli.layer", (it) => {
1689 });1958 });
16901959
1691 assert.strictEqual(batch.items.length, 3);1960 assert.strictEqual(batch.items.length, 3);
1692 // The fallback itself uses no search, then narrows the decoded rows locally. They still1961 // The fallback itself uses no search, then narrows the decoded rows locally. They arrive
1693 // arrive in gh's own order, so nothing can carry on from them.1962 // newest-created first, so nothing can carry on from them.
1694 expect(searchOfCall(1)).toBeUndefined();1963 expect(searchOfCall(1)).toBeUndefined();
1964 expect(varsAt(1)).toMatchObject({ owner: "acme", name: "web", states: ["CLOSED", "MERGED"] });
1695 assert.isFalse(batch.continues);1965 assert.isFalse(batch.continues);
1696 }),1966 }),
1697 );1967 );
16981968
1699 it.effect("keeps state and involvement filters on the search-free fallback", () =>1969 it.effect("keeps state and involvement filters on the search-free fallback", () =>
1700 Effect.gen(function* () {1970 Effect.gen(function* () {
1701 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));1971 mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch()));
1702 mockedExecute.mockReturnValueOnce(1972 mockedExecute.mockReturnValueOnce(
1703 Effect.succeed(1973 Effect.succeed(
1704 output(1974 output(
1705 pullRequests(4, 1, (number) => ({1975 listedPullRequests(4, 1, (number) => ({
1706 state: number === 4 ? "OPEN" : "CLOSED",1976 state: number === 4 ? "OPEN" : "CLOSED",
1707 ...(number === 3 ? { mergedAt: "2026-07-03T00:00:00Z" } : {}),1977 ...(number === 3 ? { mergedAt: "2026-07-03T00:00:00Z" } : {}),
1708 reviewRequests:1978 reviewRequests:
...@@ -1734,12 +2004,14 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1734,12 +2004,14 @@ layer("GitHubPullRequestCli.layer", (it) => {
1734 it.effect("grows the search-free fallback until it fills the filtered page", () =>2004 it.effect("grows the search-free fallback until it fills the filtered page", () =>
1735 Effect.gen(function* () {2005 Effect.gen(function* () {
1736 const unrelated = () => ({ reviewRequests: [{ login: "somebody-else" }] });2006 const unrelated = () => ({ reviewRequests: [{ login: "somebody-else" }] });
1737 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));2007 mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch()));
1738 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1, unrelated))));2008 mockedExecute.mockReturnValueOnce(
2009 Effect.succeed(output(listedPullRequests(3, 1, unrelated))),
2010 );
1739 mockedExecute.mockReturnValueOnce(2011 mockedExecute.mockReturnValueOnce(
1740 Effect.succeed(2012 Effect.succeed(
1741 output(2013 output(
1742 pullRequests(4, 1, (number) =>2014 listedPullRequests(4, 1, (number) =>
1743 number === 4 ? { reviewRequests: [{ login: "bilal" }] } : unrelated(),2015 number === 4 ? { reviewRequests: [{ login: "bilal" }] } : unrelated(),
1744 ),2016 ),
1745 ),2017 ),
...@@ -1758,25 +2030,34 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1758,25 +2030,34 @@ layer("GitHubPullRequestCli.layer", (it) => {
1758 });2030 });
17592031
1760 expect(batch.items.map((item) => item.number)).toEqual([4]);2032 expect(batch.items.map((item) => item.number)).toEqual([4]);
1761 const firstFallbackArgs = callAt(1).args;2033 expect(queryAt(1)).toContain("first: 3,");
1762 const secondFallbackArgs = callAt(2).args;2034 expect(queryAt(2)).toContain("first: 6,");
1763 expect(firstFallbackArgs[firstFallbackArgs.indexOf("--limit") + 1]).toBe("3");
1764 expect(secondFallbackArgs[secondFallbackArgs.indexOf("--limit") + 1]).toBe("6");
1765 assert.isFalse(batch.truncated);2035 assert.isFalse(batch.truncated);
1766 }),2036 }),
1767 );2037 );
17682038
1769 it.effect("bounds a sparse search-free fallback and reports the unread tail", () =>2039 it.effect("bounds a sparse search-free fallback and reports the unread tail", () =>
1770 Effect.gen(function* () {2040 Effect.gen(function* () {
1771 mockedExecute.mockImplementation((_input) => {2041 // A repository with far more pull requests than the bound, none of them for this reader.
1772 if (mockedExecute.mock.calls.length === 1) return Effect.succeed(output("[]"));2042 let listed = 0;
1773 const args = callAt(mockedExecute.mock.calls.length - 1).args;2043 mockedExecute.mockImplementation((call) => {
1774 const limit = Number(args[args.indexOf("--limit") + 1]);2044 if (call.kind !== "graphql" || !call.query.includes("pullRequests(")) {
2045 return Effect.succeed(emptySearch());
2046 }
2047 const first = Number(/first: (\d+)/.exec(call.query)?.[1]);
2048 const nodes = rows(first, listed + 1, () => ({
2049 reviewRequests: [{ login: "somebody-else" }],
2050 }));
2051 listed += first;
1775 return Effect.succeed(2052 return Effect.succeed(
1776 output(2053 output(
1777 pullRequests(limit, 1, () => ({2054 encodeJson({
1778 reviewRequests: [{ login: "somebody-else" }],2055 data: {
1779 })),2056 repository: {
2057 pullRequests: { pageInfo: { hasNextPage: true, endCursor: `c${listed}` }, nodes },
2058 },
2059 },
2060 }),
1780 ),2061 ),
1781 );2062 );
1782 });2063 });
...@@ -1792,8 +2073,10 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1792,8 +2073,10 @@ layer("GitHubPullRequestCli.layer", (it) => {
1792 limit: 2,2073 limit: 2,
1793 });2074 });
17942075
1795 const finalArgs = callAt(mockedExecute.mock.calls.length - 1).args;2076 // The last widening reads a thousand rows, a page of a hundred at a time, and stops there.
1796 expect(finalArgs[finalArgs.indexOf("--limit") + 1]).toBe("1000");2077 const last = mockedExecute.mock.calls.length - 1;
2078 expect(varsAt(last)["after"]).toBe(`c${listed - 100}`);
2079 expect(listed - (3 + 6 + 12 + 24 + 48 + 96 + 192 + 384 + 768)).toBe(1000);
1797 assert.strictEqual(batch.items.length, 0);2080 assert.strictEqual(batch.items.length, 0);
1798 assert.isTrue(batch.truncated);2081 assert.isTrue(batch.truncated);
1799 }),2082 }),
...@@ -1801,7 +2084,13 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1801,7 +2084,13 @@ layer("GitHubPullRequestCli.layer", (it) => {
18012084
1802 it.effect("takes an empty slice for a repository that has run out, not one to read again", () =>2085 it.effect("takes an empty slice for a repository that has run out, not one to read again", () =>
1803 Effect.gen(function* () {2086 Effect.gen(function* () {
1804 mockedExecute.mockReturnValue(Effect.succeed(output("[]")));2087 mockedExecute.mockImplementation((call) =>
2088 Effect.succeed(
2089 call.kind === "graphql" && call.query.includes("pullRequests(")
2090 ? emptyList()
2091 : emptySearch(),
2092 ),
2093 );
1805 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;2094 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
18062095
1807 yield* cli.listPullRequests({2096 yield* cli.listPullRequests({
...@@ -1821,9 +2110,32 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1821,9 +2110,32 @@ layer("GitHubPullRequestCli.layer", (it) => {
1821 }),2110 }),
1822 );2111 );
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
1824 it.effect("updates a stale branch with a merge commit unless asked to rebase", () =>2136 it.effect("updates a stale branch with a merge commit unless asked to rebase", () =>
1825 Effect.gen(function* () {2137 Effect.gen(function* () {
1826 mockedExecute.mockReturnValue(Effect.succeed(output("")));2138 route(["query PullRequestActionState", actionState()]);
1827 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;2139 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
18282140
1829 yield* cli.runPullRequestAction({2141 yield* cli.runPullRequestAction({
...@@ -1833,9 +2145,6 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1833,9 +2145,6 @@ layer("GitHubPullRequestCli.layer", (it) => {
1833 number: 7,2145 number: 7,
1834 action: "update-branch",2146 action: "update-branch",
1835 });2147 });
1836 // GitHub's own default, and `gh`'s: a merge commit unless the rebase flag says otherwise.
1837 expect(callAt(0).args).toEqual(["pr", "update-branch", "7", "--repo", "github.com/acme/web"]);
1838
1839 yield* cli.runPullRequestAction({2148 yield* cli.runPullRequestAction({
1840 cwd: "/w",2149 cwd: "/w",
1841 repository: "acme/web",2150 repository: "acme/web",
...@@ -1844,20 +2153,18 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1844,20 +2153,18 @@ layer("GitHubPullRequestCli.layer", (it) => {
1844 action: "update-branch",2153 action: "update-branch",
1845 updateMethod: "rebase",2154 updateMethod: "rebase",
1846 });2155 });
1847 expect(callAt(1).args).toEqual([2156
1848 "pr",2157 // GitHub's own default: a merge commit unless asked to rebase, pinned to the head it read.
1849 "update-branch",2158 expect(variablesOf("updatePullRequestBranch(")).toEqual([
1850 "7",2159 { pullRequestId: "PR_7", expectedHeadOid: "abc123", updateMethod: "MERGE" },
1851 "--repo",2160 { pullRequestId: "PR_7", expectedHeadOid: "abc123", updateMethod: "REBASE" },
1852 "github.com/acme/web",
1853 "--rebase",
1854 ]);2161 ]);
1855 }),2162 }),
1856 );2163 );
18572164
1858 it.effect("merges with the strategy it was asked for", () =>2165 it.effect("merges with the strategy it was asked for", () =>
1859 Effect.gen(function* () {2166 Effect.gen(function* () {
1860 mockedExecute.mockReturnValue(Effect.succeed(output("")));2167 route(["query PullRequestActionState", actionState()]);
1861 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;2168 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
18622169
1863 yield* cli.runPullRequestAction({2170 yield* cli.runPullRequestAction({
...@@ -1869,14 +2176,10 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1869,14 +2176,10 @@ layer("GitHubPullRequestCli.layer", (it) => {
1869 mergeMethod: "squash",2176 mergeMethod: "squash",
1870 });2177 });
18712178
1872 expect(callAt(0).args).toEqual([2179 expect(variablesOf("mergePullRequest(")).toEqual([
1873 "pr",2180 { input: { pullRequestId: "PR_7", mergeMethod: "SQUASH" } },
1874 "merge",
1875 "7",
1876 "--repo",
1877 "github.com/acme/web",
1878 "--squash",
1879 ]);2181 ]);
2182 expect(variablesOf("enablePullRequestAutoMerge(")).toEqual([]);
1880 }),2183 }),
1881 );2184 );
18822185
...@@ -1884,26 +2187,15 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1884,26 +2187,15 @@ layer("GitHubPullRequestCli.layer", (it) => {
1884 "removes agent credits from the proposed message for %s",2187 "removes agent credits from the proposed message for %s",
1885 (action) =>2188 (action) =>
1886 Effect.gen(function* () {2189 Effect.gen(function* () {
1887 mockedExecute2190 route(
1888 .mockReturnValueOnce(2191 [
1889 Effect.succeed(2192 "query PullRequestMergeMessage",
1890 output(2193 mergeMessage(
1891 encodeJson({2194 "Details\n\nCo-authored-by: Alice <alice@example.com>\nCo-authored-by: Claude <noreply@anthropic.com>",
1892 data: {
1893 repository: {
1894 pullRequest: {
1895 isMergeQueueEnabled: false,
1896 headRefOid: "abc123",
1897 viewerMergeBodyText:
1898 "Details\n\nCo-authored-by: Alice <alice@example.com>\nCo-authored-by: Claude <noreply@anthropic.com>",
1899 },
1900 },
1901 },
1902 }),
1903 ),
1904 ),2195 ),
1905 )2196 ],
1906 .mockReturnValueOnce(Effect.succeed(output("")));2197 ["query PullRequestActionState", actionState()],
2198 );
1907 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;2199 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
1908 yield* cli.runPullRequestAction({2200 yield* cli.runPullRequestAction({
1909 cwd: "/w",2201 cwd: "/w",
...@@ -1914,12 +2206,20 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1914,12 +2206,20 @@ layer("GitHubPullRequestCli.layer", (it) => {
1914 mergeMethod: "squash",2206 mergeMethod: "squash",
1915 removeAgentCreditsOnMerge: true,2207 removeAgentCreditsOnMerge: true,
1916 });2208 });
1917 expect(callAt(0).args).toContain("method=SQUASH");2209 expect(variablesOf("query PullRequestMergeMessage")[0]?.["method"]).toBe("SQUASH");
1918 expect(callAt(1).args.slice(-2)).toEqual(["--body-file", "-"]);2210 expect(
1919 expect(callAt(1).args).toContain("--match-head-commit");2211 variablesOf(action === "merge" ? "mergePullRequest(" : "enablePullRequestAutoMerge("),
1920 expect(callAt(1).args).toContain("abc123");2212 ).toEqual([
1921 expect(callAt(1).stdin).toBe("Details\n\nCo-authored-by: Alice <alice@example.com>");2213 {
1922 expect(callAt(1).args.join(" ")).not.toContain("alice@example.com");2214 input: {
2215 pullRequestId: "PR_7",
2216 mergeMethod: "SQUASH",
2217 // Pinned to the head the message was read from, so it cannot describe other commits.
2218 expectedHeadOid: "abc123",
2219 commitBody: "Details\n\nCo-authored-by: Alice <alice@example.com>",
2220 },
2221 },
2222 ]);
1923 }),2223 }),
1924 );2224 );
19252225
...@@ -1936,25 +2236,10 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1936,25 +2236,10 @@ layer("GitHubPullRequestCli.layer", (it) => {
1936 },2236 },
1937 ] as const)("keeps GitHub's default message for $description", ({ body, queued }) =>2237 ] as const)("keeps GitHub's default message for $description", ({ body, queued }) =>
1938 Effect.gen(function* () {2238 Effect.gen(function* () {
1939 mockedExecute2239 route(
1940 .mockReturnValueOnce(2240 ["query PullRequestMergeMessage", mergeMessage(body, queued)],
1941 Effect.succeed(2241 ["query PullRequestActionState", actionState({ isMergeQueueEnabled: queued })],
1942 output(2242 );
1943 encodeJson({
1944 data: {
1945 repository: {
1946 pullRequest: {
1947 isMergeQueueEnabled: queued,
1948 headRefOid: "abc123",
1949 viewerMergeBodyText: body,
1950 },
1951 },
1952 },
1953 }),
1954 ),
1955 ),
1956 )
1957 .mockReturnValueOnce(Effect.succeed(output("")));
1958 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;2243 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
1959 yield* cli.runPullRequestAction({2244 yield* cli.runPullRequestAction({
1960 cwd: "/w",2245 cwd: "/w",
...@@ -1964,33 +2249,23 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1964,33 +2249,23 @@ layer("GitHubPullRequestCli.layer", (it) => {
1964 action: "merge",2249 action: "merge",
1965 removeAgentCreditsOnMerge: true,2250 removeAgentCreditsOnMerge: true,
1966 });2251 });
1967 expect(callAt(0).args).toContain("method=MERGE");2252 expect(variablesOf("query PullRequestMergeMessage")[0]?.["method"]).toBe("MERGE");
1968 expect(callAt(1).args).not.toContain("--body-file");2253 // A merge queue takes the pull request through auto-merge, with its own message.
1969 expect(callAt(1).stdin).toBeUndefined();2254 expect(variablesOf(queued ? "enablePullRequestAutoMerge(" : "mergePullRequest(")).toEqual([
2255 { input: { pullRequestId: "PR_7", mergeMethod: "MERGE" } },
2256 ]);
1970 }),2257 }),
1971 );2258 );
19722259
1973 it.effect("passes an explicitly empty body when the proposed message only credits an agent", () =>2260 it.effect("passes an explicitly empty body when the proposed message only credits an agent", () =>
1974 Effect.gen(function* () {2261 Effect.gen(function* () {
1975 mockedExecute2262 route(
1976 .mockReturnValueOnce(2263 [
1977 Effect.succeed(2264 "query PullRequestMergeMessage",
1978 output(2265 mergeMessage("Co-authored-by: Claude <noreply@anthropic.com>"),
1979 encodeJson({2266 ],
1980 data: {2267 ["query PullRequestActionState", actionState()],
1981 repository: {2268 );
1982 pullRequest: {
1983 isMergeQueueEnabled: false,
1984 headRefOid: "abc123",
1985 viewerMergeBodyText: "Co-authored-by: Claude <noreply@anthropic.com>",
1986 },
1987 },
1988 },
1989 }),
1990 ),
1991 ),
1992 )
1993 .mockReturnValueOnce(Effect.succeed(output("")));
1994 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;2269 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
1995 yield* cli.runPullRequestAction({2270 yield* cli.runPullRequestAction({
1996 cwd: "/w",2271 cwd: "/w",
...@@ -2000,14 +2275,22 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -2000,14 +2275,22 @@ layer("GitHubPullRequestCli.layer", (it) => {
2000 action: "merge",2275 action: "merge",
2001 removeAgentCreditsOnMerge: true,2276 removeAgentCreditsOnMerge: true,
2002 });2277 });
2003 expect(callAt(1).stdin).toBe("");2278 expect(variablesOf("mergePullRequest(")).toEqual([
2004 expect(callAt(1).args).toContain("--body-file");2279 {
2280 input: {
2281 pullRequestId: "PR_7",
2282 mergeMethod: "MERGE",
2283 expectedHeadOid: "abc123",
2284 commitBody: "",
2285 },
2286 },
2287 ]);
2005 }),2288 }),
2006 );2289 );
20072290
2008 it.effect("does not fetch a message for rebase merges", () =>2291 it.effect("does not fetch a message for rebase merges", () =>
2009 Effect.gen(function* () {2292 Effect.gen(function* () {
2010 mockedExecute.mockReturnValue(Effect.succeed(output("")));2293 route(["query PullRequestActionState", actionState()]);
2011 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;2294 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
2012 yield* cli.runPullRequestAction({2295 yield* cli.runPullRequestAction({
2013 cwd: "/w",2296 cwd: "/w",
...@@ -2018,15 +2301,18 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -2018,15 +2301,18 @@ layer("GitHubPullRequestCli.layer", (it) => {
2018 mergeMethod: "rebase",2301 mergeMethod: "rebase",
2019 removeAgentCreditsOnMerge: true,2302 removeAgentCreditsOnMerge: true,
2020 });2303 });
2021 expect(mockedExecute).toHaveBeenCalledTimes(1);2304 expect(variablesOf("query PullRequestMergeMessage")).toEqual([]);
2022 expect(callAt(0).args).toContain("--rebase");2305 expect(variablesOf("mergePullRequest(")).toEqual([
2306 { input: { pullRequestId: "PR_7", mergeMethod: "REBASE" } },
2307 ]);
2023 }),2308 }),
2024 );2309 );
20252310
2026 it.effect("refuses to merge when the proposed message cannot be read", () =>2311 it.effect("refuses to merge when the proposed message cannot be read", () =>
2027 Effect.gen(function* () {2312 Effect.gen(function* () {
2028 mockedExecute.mockReturnValue(2313 route(
2029 Effect.succeed(output('{"data":{"repository":{"pullRequest":null}}}')),2314 ["query PullRequestMergeMessage", { data: { repository: { pullRequest: null } } }],
2315 ["query PullRequestActionState", actionState()],
2030 );2316 );
2031 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;2317 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
2032 const result = yield* Effect.result(2318 const result = yield* Effect.result(
...@@ -2040,13 +2326,13 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -2040,13 +2326,13 @@ layer("GitHubPullRequestCli.layer", (it) => {
2040 }),2326 }),
2041 );2327 );
2042 expect(result._tag).toBe("Failure");2328 expect(result._tag).toBe("Failure");
2043 expect(mockedExecute).toHaveBeenCalledTimes(1);2329 expect(variablesOf("mergePullRequest(")).toEqual([]);
2044 }),2330 }),
2045 );2331 );
20462332
2047 it.effect("arms auto-merge with the same strategy a merge would have used", () =>2333 it.effect("arms auto-merge with the same strategy a merge would have used", () =>
2048 Effect.gen(function* () {2334 Effect.gen(function* () {
2049 mockedExecute.mockReturnValue(Effect.succeed(output("")));2335 route(["query PullRequestActionState", actionState()]);
2050 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;2336 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
20512337
2052 yield* cli.runPullRequestAction({2338 yield* cli.runPullRequestAction({
...@@ -2057,39 +2343,47 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -2057,39 +2343,47 @@ layer("GitHubPullRequestCli.layer", (it) => {
2057 action: "enable-auto-merge",2343 action: "enable-auto-merge",
2058 mergeMethod: "squash",2344 mergeMethod: "squash",
2059 });2345 });
2060 expect(callAt(0).args).toEqual([2346 // No strategy asked for is GitHub's own default, exactly as it is for a merge now.
2061 "pr",2347 yield* cli.runPullRequestAction({
2062 "merge",2348 cwd: "/w",
2063 "7",2349 repository: "acme/web",
2064 "--repo",2350 host: "github.com",
2065 "github.com/acme/web",2351 number: 7,
2066 "--auto",2352 action: "enable-auto-merge",
2067 "--squash",2353 });
2354
2355 expect(variablesOf("enablePullRequestAutoMerge(")).toEqual([
2356 { input: { pullRequestId: "PR_7", mergeMethod: "SQUASH" } },
2357 { input: { pullRequestId: "PR_7", mergeMethod: "MERGE" } },
2068 ]);2358 ]);
2359 expect(variablesOf("mergePullRequest(")).toEqual([]);
2360 }),
2361 );
2362
2363 it.effect("merges at once when auto-merge is asked of a pull request that is ready now", () =>
2364 Effect.gen(function* () {
2365 route(["query PullRequestActionState", actionState({ mergeStateStatus: "CLEAN" })]);
2366 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
20692367
2070 // No strategy asked for is GitHub's own default, exactly as it is for a merge now.
2071 yield* cli.runPullRequestAction({2368 yield* cli.runPullRequestAction({
2072 cwd: "/w",2369 cwd: "/w",
2073 repository: "acme/web",2370 repository: "acme/web",
2074 host: "github.com",2371 host: "github.com",
2075 number: 7,2372 number: 7,
2076 action: "enable-auto-merge",2373 action: "enable-auto-merge",
2374 mergeMethod: "squash",
2077 });2375 });
2078 expect(callAt(1).args).toEqual([2376
2079 "pr",2377 expect(variablesOf("mergePullRequest(")).toEqual([
2080 "merge",2378 { input: { pullRequestId: "PR_7", mergeMethod: "SQUASH" } },
2081 "7",
2082 "--repo",
2083 "github.com/acme/web",
2084 "--auto",
2085 "--merge",
2086 ]);2379 ]);
2380 expect(variablesOf("enablePullRequestAutoMerge(")).toEqual([]);
2087 }),2381 }),
2088 );2382 );
20892383
2090 it.effect("takes auto-merge back off without naming a strategy", () =>2384 it.effect("takes auto-merge back off without naming a strategy", () =>
2091 Effect.gen(function* () {2385 Effect.gen(function* () {
2092 mockedExecute.mockReturnValue(Effect.succeed(output("")));2386 route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]);
2093 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;2387 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
20942388
2095 yield* cli.runPullRequestAction({2389 yield* cli.runPullRequestAction({
...@@ -2101,30 +2395,13 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -2101,30 +2395,13 @@ layer("GitHubPullRequestCli.layer", (it) => {
2101 mergeMethod: "squash",2395 mergeMethod: "squash",
2102 });2396 });
21032397
2104 expect(callAt(0).args).toEqual([2398 expect(variablesOf("disablePullRequestAutoMerge(")).toEqual([{ pullRequestId: "PR_7" }]);
2105 "pr",
2106 "merge",
2107 "7",
2108 "--repo",
2109 "github.com/acme/web",
2110 "--disable-auto",
2111 ]);
2112 }),2399 }),
2113 );2400 );
21142401
2115 it.effect("opens a pull request that reverts a merged pull request", () =>2402 it.effect("opens a pull request that reverts a merged pull request", () =>
2116 Effect.gen(function* () {2403 Effect.gen(function* () {
2117 mockedExecute.mockReturnValueOnce(2404 route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]);
2118 Effect.succeed(
2119 output(
2120 // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh GraphQL response.
2121 JSON.stringify({
2122 data: { repository: { pullRequest: { id: "PR_7" } } },
2123 }),
2124 ),
2125 ),
2126 );
2127 mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}")));
2128 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;2405 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
21292406
2130 yield* cli.runPullRequestAction({2407 yield* cli.runPullRequestAction({
...@@ -2135,19 +2412,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -2135,19 +2412,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
2135 action: "revert",2412 action: "revert",
2136 });2413 });
21372414
2138 expect(callAt(0).args).toContain("owner=acme");2415 expect(variablesOf("revertPullRequest(")).toEqual([{ pullRequestId: "PR_7" }]);
2139 expect(callAt(0).args).toContain("name=web");
2140 expect(callAt(0).args).toContain("number=7");
2141 expect(callAt(1).args).toEqual([
2142 "api",
2143 "graphql",
2144 "--hostname",
2145 "github.com",
2146 "--input",
2147 "-",
2148 ]);
2149 expect(callAt(1).stdin).toContain("revertPullRequest");
2150 expect(callAt(1).stdin).toContain('"pullRequestId":"PR_7"');
2151 }),2416 }),
2152 );2417 );
21532418
...@@ -2189,55 +2454,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -2189,55 +2454,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
21892454
2190 it.effect("finds and approves every workflow waiting on a maintainer", () =>2455 it.effect("finds and approves every workflow waiting on a maintainer", () =>
2191 Effect.gen(function* () {2456 Effect.gen(function* () {
2192 const detail = output(2457 workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7]), workflowRuns([10, 11]));
2193 encodeJson(
2194 coreResponse({
2195 number: 7,
2196 title: "Pull request 7",
2197 url: "https://github.com/acme/web/pull/7",
2198 headRefName: "feat/page",
2199 headRefOid: "abc123",
2200 isCrossRepository: true,
2201 headRepositoryOwner: { login: "octocat" },
2202 baseRefName: "main",
2203 createdAt: "2026-07-01T00:00:00Z",
2204 updatedAt: "2026-07-02T00:00:00Z",
2205 }),
2206 ),
2207 );
2208 const heads = output(
2209 // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response.
2210 JSON.stringify([
2211 {
2212 number: 7,
2213 headRefOid: "abc123",
2214 isCrossRepository: true,
2215 headRepositoryOwner: { login: "octocat" },
2216 },
2217 ]),
2218 );
2219 const runs = output(
2220 // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response.
2221 JSON.stringify([
2222 { databaseId: 10, workflowName: "build", url: "https://example.com/10" },
2223 { databaseId: 11, workflowName: "test", url: "https://example.com/11" },
2224 ]),
2225 );
2226 for (const result of [
2227 detail,
2228 heads,
2229 runs,
2230 detail,
2231 heads,
2232 runs,
2233 output(""),
2234 detail,
2235 heads,
2236 runs,
2237 output(""),
2238 ]) {
2239 mockedExecute.mockReturnValueOnce(Effect.succeed(result));
2240 }
2241 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;2458 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
22422459
2243 yield* cli.runPullRequestAction({2460 yield* cli.runPullRequestAction({
...@@ -2248,98 +2465,70 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -2248,98 +2465,70 @@ layer("GitHubPullRequestCli.layer", (it) => {
2248 action: "approve-workflows",2465 action: "approve-workflows",
2249 });2466 });
22502467
2251 expect(callAt(1).args).toEqual([2468 // Open pull requests on this head branch, and runs scoped to this exact head.
2252 "pr",2469 expect(variablesOf("headRefName: $head")[0]).toEqual({
2253 "list",2470 owner: "acme",
2254 "--repo",2471 name: "web",
2255 "github.com/acme/web",2472 head: "feat/page",
2256 "--state",2473 after: null,
2257 "open",2474 });
2258 "--head",2475 expect(restCallsTo("actions/runs?")[0]?.path).toBe(
2259 "feat/page",2476 "repos/acme/web/actions/runs?head_sha=abc123&branch=feat%2Fpage&event=pull_request&status=action_required&per_page=100&page=1",
2260 "--limit",2477 );
2261 "1001",2478 // Each run is approved only after the head is read again and still lists it.
2262 "--json",2479 expect(restCallsTo("/approve").map((call) => [call.method, call.path])).toEqual([
2263 "number,headRefOid,isCrossRepository,headRepositoryOwner",2480 ["POST", "repos/acme/web/actions/runs/10/approve"],
2264 ]);2481 ["POST", "repos/acme/web/actions/runs/11/approve"],
2265 expect(callAt(2).args).toEqual([
2266 "run",
2267 "list",
2268 "--repo",
2269 "github.com/acme/web",
2270 "--commit",
2271 "abc123",
2272 "--branch",
2273 "feat/page",
2274 "--event",
2275 "pull_request",
2276 "--status",
2277 "action_required",
2278 "--limit",
2279 "1001",
2280 "--json",
2281 "databaseId,workflowName,url",
2282 ]);
2283 expect([callAt(6).args, callAt(10).args]).toEqual([
2284 [
2285 "api",
2286 "--method",
2287 "POST",
2288 "--hostname",
2289 "github.com",
2290 "repos/acme/web/actions/runs/10/approve",
2291 "--silent",
2292 ],
2293 [
2294 "api",
2295 "--method",
2296 "POST",
2297 "--hostname",
2298 "github.com",
2299 "repos/acme/web/actions/runs/11/approve",
2300 "--silent",
2301 ],
2302 ]);2482 ]);
2303 expect(mockedExecute).toHaveBeenCalledTimes(11);2483 assert.strictEqual(restCallsTo("actions/runs?").length, 3);
2304 }),2484 }),
2305 );2485 );
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", () =>
2308 Effect.gen(function* () {2488 Effect.gen(function* () {
2309 const detail = {2489 workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7]), workflowRuns([]));
2310 number: 7,2490 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
2311 title: "Pull request 7",2491
2312 url: "https://github.com/acme/web/pull/7",2492 const runs = yield* cli
2313 headRefName: "feat/page",2493 .listWorkflowRunsRequiringApproval({
2314 headRefOid: "abc123",2494 cwd: "/w",
2315 isCrossRepository: true,2495 repository: "acme/web",
2316 headRepositoryOwner: { login: "octocat" },2496 host: "github.com",
2317 baseRefName: "main",2497 number: 7,
2318 createdAt: "2026-07-01T00:00:00Z",2498 headSha: "abc123",
2319 updatedAt: "2026-07-02T00:00:00Z",2499 headBranch: "feat/page",
2320 };2500 headRepositoryOwner: "octocat",
2321 for (const value of [2501 isCrossRepository: true,
2322 coreResponse(detail),2502 })
2323 [2503 .pipe(
2324 {2504 Effect.provideService(KnownWorkflowRuns, {
2325 number: 7,2505 headSha: "abc123",
2326 headRefOid: "abc123",2506 runs: [
2327 isCrossRepository: true,2507 // Live shape: waiting on approval is completed + action_required.
2328 headRepositoryOwner: { login: "octocat" },2508 {
2329 },2509 id: 10,
2330 ],2510 status: "completed",
2331 [{ databaseId: 10, workflowName: "build", url: "https://example.com/10" }],2511 conclusion: "action_required",
2332 coreResponse({ ...detail, headRefOid: "def456" }),2512 head_branch: "feat/page",
2333 ]) {2513 },
2334 mockedExecute.mockReturnValueOnce(2514 { id: 11, status: "completed", conclusion: "success", head_branch: "feat/page" },
2335 Effect.succeed(2515 ],
2336 output(2516 }),
2337 // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response.
2338 JSON.stringify(value),
2339 ),
2340 ),
2341 );2517 );
2342 }2518
2519 expect(runs.map((run) => run.id)).toEqual([10]);
2520 assert.strictEqual(restCallsTo("actions/runs?").length, 0);
2521 }),
2522 );
2523
2524 it.effect("refuses a stale workflow approval after the pull request head changes", () =>
2525 Effect.gen(function* () {
2526 let detailReads = 0;
2527 workflowApprovalRoutes(
2528 () => crossRepositoryDetail(++detailReads === 1 ? "abc123" : "def456"),
2529 heads([7]),
2530 workflowRuns([10]),
2531 );
2343 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;2532 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
23442533
2345 const error = yield* Effect.flip(2534 const error = yield* Effect.flip(
...@@ -2356,7 +2545,8 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -2356,7 +2545,8 @@ layer("GitHubPullRequestCli.layer", (it) => {
2356 _tag: "GitHubWorkflowApprovalHeadChangedError",2545 _tag: "GitHubWorkflowApprovalHeadChangedError",
2357 number: 7,2546 number: 7,
2358 });2547 });
2359 expect(mockedExecute).toHaveBeenCalledTimes(4);2548 assert.strictEqual(detailReads, 2);
2549 expect(restCallsTo("/approve")).toEqual([]);
2360 }),2550 }),
2361 );2551 );
23622552
...@@ -2364,21 +2554,15 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -2364,21 +2554,15 @@ layer("GitHubPullRequestCli.layer", (it) => {
2364 Effect.gen(function* () {2554 Effect.gen(function* () {
2365 const headsStarted = yield* Deferred.make<void>();2555 const headsStarted = yield* Deferred.make<void>();
2366 const runsStarted = yield* Deferred.make<void>();2556 const runsStarted = yield* Deferred.make<void>();
2367 mockedExecute.mockImplementation(({ args }) =>2557 mockedExecute.mockImplementation((call) =>
2368 args[0] === "pr"2558 call.kind === "graphql"
2369 ? Deferred.succeed(headsStarted, undefined).pipe(2559 ? Deferred.succeed(headsStarted, undefined).pipe(
2370 Effect.andThen(Deferred.await(runsStarted)),2560 Effect.andThen(Deferred.await(runsStarted)),
2371 Effect.as(2561 Effect.as(output(encodeJson(heads([7])))),
2372 output(
2373 '[{"number":7,"headRefOid":"abc123","isCrossRepository":true,"headRepositoryOwner":{"login":"octocat"}}]',
2374 ),
2375 ),
2376 )2562 )
2377 : Deferred.succeed(runsStarted, undefined).pipe(2563 : Deferred.succeed(runsStarted, undefined).pipe(
2378 Effect.andThen(Deferred.await(headsStarted)),2564 Effect.andThen(Deferred.await(headsStarted)),
2379 Effect.as(2565 Effect.as(output(encodeJson(workflowRuns([10])))),
2380 output('[{"databaseId":10,"workflowName":"build","url":"https://example.com/10"}]'),
2381 ),
2382 ),2566 ),
2383 );2567 );
2384 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;2568 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
...@@ -2400,22 +2584,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -2400,22 +2584,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
24002584
2401 it.effect("refuses workflow approval when one head belongs to several pull requests", () =>2585 it.effect("refuses workflow approval when one head belongs to several pull requests", () =>
2402 Effect.gen(function* () {2586 Effect.gen(function* () {
2403 mockedExecute.mockReturnValue(Effect.succeed(output("[]")));2587 workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7, 8]), workflowRuns([]));
2404 mockedExecute.mockReturnValueOnce(
2405 Effect.succeed(
2406 output(
2407 // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response.
2408 JSON.stringify(
2409 [7, 8].map((number) => ({
2410 number,
2411 headRefOid: "abc123",
2412 isCrossRepository: true,
2413 headRepositoryOwner: { login: "octocat" },
2414 })),
2415 ),
2416 ),
2417 ),
2418 );
2419 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;2588 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
24202589
2421 const error = yield* Effect.flip(2590 const error = yield* Effect.flip(
...@@ -2438,8 +2607,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -2438,8 +2607,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
2438 observedCount: 2,2607 observedCount: 2,
2439 limit: 1_000,2608 limit: 1_000,
2440 });2609 });
2441 expect(error.detail).toContain("instead of uniquely matching #7");2610 expect(error.message).toContain("instead of uniquely matching #7");
2442 expect(mockedExecute).toHaveBeenCalledTimes(2);
2443 }),2611 }),
2444 );2612 );
24452613
...@@ -2487,29 +2655,18 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -2487,29 +2655,18 @@ layer("GitHubPullRequestCli.layer", (it) => {
24872655
2488 it.effect("surfaces a workflow run list beyond the safe approval bound", () =>2656 it.effect("surfaces a workflow run list beyond the safe approval bound", () =>
2489 Effect.gen(function* () {2657 Effect.gen(function* () {
2490 mockedExecute.mockReturnValueOnce(2658 let runPages = 0;
2491 Effect.succeed(2659 mockedExecute.mockImplementation((call) => {
2492 output(2660 if (call.kind === "graphql") return Effect.succeed(output(encodeJson(heads([7]))));
2493 // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response.2661 const page = runPages++;
2494 JSON.stringify([2662 return Effect.succeed(
2495 {
2496 number: 7,
2497 headRefOid: "abc123",
2498 isCrossRepository: true,
2499 headRepositoryOwner: { login: "octocat" },
2500 },
2501 ]),
2502 ),
2503 ),
2504 );
2505 mockedExecute.mockReturnValueOnce(
2506 Effect.succeed(
2507 output(2663 output(
2508 // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response.2664 encodeJson(
2509 JSON.stringify(Array.from({ length: 1_001 }, (_, id) => ({ databaseId: id + 1 }))),2665 workflowRuns(Array.from({ length: 100 }, (_, index) => page * 100 + index + 1)),
2666 ),
2510 ),2667 ),
2511 ),2668 );
2512 );2669 });
2513 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;2670 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
25142671
2515 const error = yield* Effect.flip(2672 const error = yield* Effect.flip(
...@@ -2525,21 +2682,22 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -2525,21 +2682,22 @@ layer("GitHubPullRequestCli.layer", (it) => {
2525 }),2682 }),
2526 );2683 );
25272684
2685 // Paged a hundred at a time, it stops at the first page past the bound.
2528 expect(error).toMatchObject({2686 expect(error).toMatchObject({
2529 _tag: "GitHubWorkflowApprovalRefusedError",2687 _tag: "GitHubWorkflowApprovalRefusedError",
2530 reason: "run-list-truncated",2688 reason: "run-list-truncated",
2531 number: 7,2689 number: 7,
2532 observedCount: 1_001,2690 observedCount: 1_100,
2533 limit: 1_000,2691 limit: 1_000,
2534 });2692 });
2535 expect(error.detail).toContain("more than 1000 workflow runs");2693 expect(error.message).toContain("more than 1000 workflow runs");
2536 expect(mockedExecute).toHaveBeenCalledTimes(2);2694 assert.strictEqual(runPages, 11);
2537 }),2695 }),
2538 );2696 );
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", () =>
2541 Effect.gen(function* () {2699 Effect.gen(function* () {
2542 mockedExecute.mockReturnValue(Effect.succeed(output("")));2700 route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]);
2543 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;2701 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
25442702
2545 yield* cli.runPullRequestAction({2703 yield* cli.runPullRequestAction({
...@@ -2550,21 +2708,14 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -2550,21 +2708,14 @@ layer("GitHubPullRequestCli.layer", (it) => {
2550 action: "draft",2708 action: "draft",
2551 });2709 });
25522710
2553 // gh has no `draft` command; going back is `ready --undo`.2711 expect(variablesOf("convertPullRequestToDraft(")).toEqual([{ pullRequestId: "PR_7" }]);
2554 expect(callAt(0).args).toEqual([2712 expect(variablesOf("markPullRequestReadyForReview(")).toEqual([]);
2555 "pr",
2556 "ready",
2557 "7",
2558 "--repo",
2559 "github.com/acme/web",
2560 "--undo",
2561 ]);
2562 }),2713 }),
2563 );2714 );
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", () =>
2566 Effect.gen(function* () {2717 Effect.gen(function* () {
2567 mockedExecute.mockReturnValue(Effect.succeed(output("")));2718 route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]);
2568 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;2719 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
25692720
2570 yield* cli.commentOnPullRequest({2721 yield* cli.commentOnPullRequest({
...@@ -2575,24 +2726,20 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -2575,24 +2726,20 @@ layer("GitHubPullRequestCli.layer", (it) => {
2575 body: "Looks good.",2726 body: "Looks good.",
2576 });2727 });
25772728
2578 // argv shows up in process listings and in process-runner failure messages.2729 expect(variablesOf("addComment(")).toEqual([{ subjectId: "PR_7", body: "Looks good." }]);
2579 expect(callAt(0).args).toEqual([2730 expect(queryAt(mockedExecute.mock.calls.length - 1)).not.toContain("Looks good.");
2580 "pr",
2581 "comment",
2582 "7",
2583 "--repo",
2584 "github.com/acme/web",
2585 "--body-file",
2586 "-",
2587 ]);
2588 expect(callAt(0).stdin).toBe("Looks good.");
2589 expect(callAt(0).args).not.toContain("Looks good.");
2590 }),2731 }),
2591 );2732 );
25922733
2593 it.effect("names the host on every repository it addresses", () =>2734 it.effect("names the host on every repository it addresses", () =>
2594 Effect.gen(function* () {2735 Effect.gen(function* () {
2595 mockedExecute.mockReturnValue(Effect.succeed(output("[]")));2736 mockedExecute.mockImplementation((call) =>
2737 Effect.succeed(
2738 call.kind === "graphql" && call.query.includes("pullRequests(")
2739 ? emptyList()
2740 : emptySearch(),
2741 ),
2742 );
2596 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;2743 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
25972744
2598 yield* cli.listPullRequests({2745 yield* cli.listPullRequests({
...@@ -2605,8 +2752,12 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -2605,8 +2752,12 @@ layer("GitHubPullRequestCli.layer", (it) => {
2605 limit: 10,2752 limit: 10,
2606 });2753 });
26072754
2608 // A bare `owner/repo` resolves against github.com, which is a different repository.2755 // A request sent to github.com would read a different repository of the same name.
2609 expect(callAt(0).args).toContain("github.acme.dev/acme/web");2756 assert.isAbove(mockedExecute.mock.calls.length, 0);
2757 expect(new Set(mockedExecute.mock.calls.map(([call]) => call.host))).toEqual(
2758 new Set(["github.acme.dev"]),
2759 );
2760 expect(searchOfCall(0)).toContain("repo:acme/web");
2610 }),2761 }),
2611 );2762 );
26122763
...@@ -2615,8 +2766,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -2615,8 +2766,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
2615 mockedExecute.mockReturnValueOnce(2766 mockedExecute.mockReturnValueOnce(
2616 Effect.succeed(2767 Effect.succeed(
2617 output(2768 output(
2618 // @effect-diagnostics-next-line preferSchemaOverJson:off2769 encodeJson({
2619 JSON.stringify({
2620 data: {2770 data: {
2621 repository: { pullRequest: { reviewThreads: { totalCount: 0, nodes: [] } } },2771 repository: { pullRequest: { reviewThreads: { totalCount: 0, nodes: [] } } },
2622 },2772 },
...@@ -2633,11 +2783,8 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -2633,11 +2783,8 @@ layer("GitHubPullRequestCli.layer", (it) => {
2633 number: 7,2783 number: 7,
2634 });2784 });
26352785
2636 const args = callAt(0).args;2786 expect(callAt(0).host).toBe("github.acme.dev");
2637 expect(args).toContain("--hostname");2787 expect(varsAt(0)).toMatchObject({ owner: "acme", name: "web", number: 7 });
2638 expect(args).toContain("github.acme.dev");
2639 expect(args).toContain("owner=acme");
2640 expect(args).toContain("name=web");
2641 }),2788 }),
2642 );2789 );
26432790
...@@ -2653,13 +2800,18 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -2653,13 +2800,18 @@ layer("GitHubPullRequestCli.layer", (it) => {
2653 number: 7,2800 number: 7,
2654 });2801 });
26552802
2803 assert.strictEqual(diff.patch, "diff --git a/a b/a");
2656 assert.isNull(diff.nextCursor);2804 assert.isNull(diff.nextCursor);
2657 assert.isFalse(diff.truncated);2805 assert.isFalse(diff.truncated);
2658 // The common case pays for one request and not the files API on top of it.2806 // The common case pays for one request and not the files API on top of it.
2659 assert.strictEqual(mockedExecute.mock.calls.length, 1);2807 assert.strictEqual(mockedExecute.mock.calls.length, 1);
2660 // `--patch` asks gh for a format-patch stream, which repeats a file once per commit.2808 // GitHub's combined pull-request diff: one section per changed file, not one per commit.
2661 // The review needs GitHub's combined pull-request diff: one section per changed file.2809 const call = callAt(0);
2662 expect(callAt(0).args).not.toContain("--patch");2810 assert.strictEqual(call.kind, "rest");
2811 if (call.kind === "rest") {
2812 assert.strictEqual(call.path, "repos/acme/web/pulls/7");
2813 assert.strictEqual(call.accept, "application/vnd.github.diff");
2814 }
2663 }),2815 }),
2664 );2816 );
26652817
...@@ -2682,10 +2834,8 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -2682,10 +2834,8 @@ layer("GitHubPullRequestCli.layer", (it) => {
2682 assert.isNull(diff.nextCursor);2834 assert.isNull(diff.nextCursor);
2683 expect(diff.patch).toContain("diff --git a/src/file1.ts b/src/file1.ts");2835 expect(diff.patch).toContain("diff --git a/src/file1.ts b/src/file1.ts");
2684 expect(diff.patch).toContain("diff --git a/src/file2.ts b/src/file2.ts");2836 expect(diff.patch).toContain("diff --git a/src/file2.ts b/src/file2.ts");
2685 const args = callAt(1).args;2837 assert.strictEqual(callAt(1).host, "github.acme.dev");
2686 expect(args).toContain("--hostname");2838 assert.strictEqual(pathAt(1), "repos/acme/web/pulls/7/files?per_page=100&page=1");
2687 expect(args).toContain("github.acme.dev");
2688 expect(args).toContain("repos/acme/web/pulls/7/files?per_page=100&page=1");
2689 }),2839 }),
2690 );2840 );
26912841
...@@ -2709,7 +2859,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -2709,7 +2859,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
2709 }),2859 }),
2710 );2860 );
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", () =>
2713 Effect.gen(function* () {2863 Effect.gen(function* () {
2714 mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused));2864 mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused));
2715 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(100, 0))));2865 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(100, 0))));
...@@ -2725,7 +2875,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -2725,7 +2875,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
2725 expect(second.patch).toContain("diff --git a/src/file100.ts b/src/file100.ts");2875 expect(second.patch).toContain("diff --git a/src/file100.ts b/src/file100.ts");
2726 // The second slice is one request: the cursor already says where to read.2876 // The second slice is one request: the cursor already says where to read.
2727 assert.strictEqual(mockedExecute.mock.calls.length, 3);2877 assert.strictEqual(mockedExecute.mock.calls.length, 3);
2728 expect(callAt(2).args).toContain("repos/acme/web/pulls/7/files?per_page=100&page=2");2878 assert.strictEqual(pathAt(2), "repos/acme/web/pulls/7/files?per_page=100&page=2");
2729 }),2879 }),
2730 );2880 );
27312881
...@@ -2748,9 +2898,11 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -2748,9 +2898,11 @@ layer("GitHubPullRequestCli.layer", (it) => {
2748 }),2898 }),
2749 );2899 );
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", () =>
2752 Effect.gen(function* () {2902 Effect.gen(function* () {
2753 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(2, 1))));2903 mockedExecute.mockReturnValueOnce(
2904 Effect.succeed(output(`{"files":${pullRequestFiles(2, 1)}}`)),
2905 );
2754 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;2906 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
27552907
2756 const diff = yield* cli.getPullRequestDiff({2908 const diff = yield* cli.getPullRequestDiff({
...@@ -2761,22 +2913,23 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -2761,22 +2913,23 @@ layer("GitHubPullRequestCli.layer", (it) => {
2761 commit: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0",2913 commit: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0",
2762 });2914 });
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.
2765 assert.strictEqual(mockedExecute.mock.calls.length, 1);2917 assert.strictEqual(mockedExecute.mock.calls.length, 1);
2766 assert.isNull(diff.nextCursor);2918 assert.isNull(diff.nextCursor);
2919 // The commit endpoint wraps its files in an object, which the decoder unwraps.
2767 expect(diff.patch).toContain("diff --git a/src/file1.ts b/src/file1.ts");2920 expect(diff.patch).toContain("diff --git a/src/file1.ts b/src/file1.ts");
2768 const args = callAt(0).args;2921 assert.strictEqual(
2769 expect(args).toContain(2922 pathAt(0),
2770 "repos/acme/web/commits/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0?per_page=100&page=1",2923 "repos/acme/web/commits/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0?per_page=100&page=1",
2771 );2924 );
2772 // The commit endpoint wraps its files in an object, which jq unwraps for the decoder.
2773 expect(args).toContain(".files // []");
2774 }),2925 }),
2775 );2926 );
27762927
2777 it.effect("pages inside a commit the way it pages the pull request's own files", () =>2928 it.effect("pages inside a commit the way it pages the pull request's own files", () =>
2778 Effect.gen(function* () {2929 Effect.gen(function* () {
2779 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(100, 0))));2930 mockedExecute.mockReturnValueOnce(
2931 Effect.succeed(output(`{"files":${pullRequestFiles(100, 0)}}`)),
2932 );
2780 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;2933 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
2781 const target = {2934 const target = {
2782 cwd: "/w",2935 cwd: "/w",
...@@ -2788,11 +2941,14 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -2788,11 +2941,14 @@ layer("GitHubPullRequestCli.layer", (it) => {
27882941
2789 const first = yield* cli.getPullRequestDiff(target);2942 const first = yield* cli.getPullRequestDiff(target);
2790 assert.isNotNull(first.nextCursor);2943 assert.isNotNull(first.nextCursor);
2791 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(4, 100))));2944 mockedExecute.mockReturnValueOnce(
2945 Effect.succeed(output(`{"files":${pullRequestFiles(4, 100)}}`)),
2946 );
2792 const second = yield* cli.getPullRequestDiff({ ...target, cursor: first.nextCursor });2947 const second = yield* cli.getPullRequestDiff({ ...target, cursor: first.nextCursor });
27932948
2794 assert.isNull(second.nextCursor);2949 assert.isNull(second.nextCursor);
2795 expect(callAt(1).args).toContain("repos/acme/web/commits/a1b2c3d?per_page=100&page=2");2950 expect(second.patch).toContain("src/file100.ts");
2951 assert.strictEqual(pathAt(1), "repos/acme/web/commits/a1b2c3d?per_page=100&page=2");
2796 }),2952 }),
2797 );2953 );
27982954
...@@ -2817,7 +2973,9 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -2817,7 +2973,9 @@ layer("GitHubPullRequestCli.layer", (it) => {
28172973
2818 it.effect("expands a new file from a root commit without requiring a parent", () =>2974 it.effect("expands a new file from a root commit without requiring a parent", () =>
2819 Effect.gen(function* () {2975 Effect.gen(function* () {
2820 mockedExecute.mockReturnValueOnce(Effect.succeed(output("\ta1b2c3d\n")));2976 mockedExecute.mockReturnValueOnce(
2977 Effect.succeed(output(encodeJson({ sha: "a1b2c3d", parents: [] }))),
2978 );
2821 mockedExecute.mockReturnValueOnce(Effect.succeed(output("root contents\n")));2979 mockedExecute.mockReturnValueOnce(Effect.succeed(output("root contents\n")));
2822 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;2980 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
28232981
...@@ -2834,7 +2992,8 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -2834,7 +2992,8 @@ layer("GitHubPullRequestCli.layer", (it) => {
28342992
2835 expect(contents).toEqual({ oldContents: "", newContents: "root contents\n" });2993 expect(contents).toEqual({ oldContents: "", newContents: "root contents\n" });
2836 assert.strictEqual(mockedExecute.mock.calls.length, 2);2994 assert.strictEqual(mockedExecute.mock.calls.length, 2);
2837 expect(callAt(1).args.join(" ")).toContain("contents/src/root.ts?ref=a1b2c3d");2995 assert.strictEqual(pathAt(0), "repos/acme/web/commits/a1b2c3d");
2996 assert.strictEqual(pathAt(1), "repos/acme/web/contents/src/root.ts?ref=a1b2c3d");
2838 }),2997 }),
2839 );2998 );
28402999
...@@ -2866,7 +3025,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -2866,7 +3025,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
28663025
2867 it.effect("reports an oversized diff file with its path and reason", () =>3026 it.effect("reports an oversized diff file with its path and reason", () =>
2868 Effect.gen(function* () {3027 Effect.gen(function* () {
2869 mockedExecute.mockReturnValueOnce(Effect.succeed(output("a1b2c3d\tb1c2d3e\n")));3028 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs)));
2870 mockedExecute.mockReturnValueOnce(Effect.succeed(output("partial", true)));3029 mockedExecute.mockReturnValueOnce(Effect.succeed(output("partial", true)));
2871 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;3030 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
28723031
...@@ -2887,15 +3046,15 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -2887,15 +3046,15 @@ layer("GitHubPullRequestCli.layer", (it) => {
2887 assert.strictEqual(error.path, "src/large.ts");3046 assert.strictEqual(error.path, "src/large.ts");
2888 assert.strictEqual(error.reason, "oversized");3047 assert.strictEqual(error.reason, "oversized");
2889 }3048 }
3049 // A deleted file is read at the base revision only.
3050 assert.strictEqual(pathAt(1), "repos/acme/web/contents/src/large.ts?ref=a1b2c3d");
2890 }),3051 }),
2891 );3052 );
28923053
2893 it.effect("reports undecodable diff file contents as binary", () =>3054 it.effect("reports undecodable diff file contents as binary", () =>
2894 Effect.gen(function* () {3055 Effect.gen(function* () {
2895 mockedExecute.mockReturnValueOnce(Effect.succeed(output("a1b2c3d\tb1c2d3e\n")));3056 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs)));
2896 mockedExecute.mockReturnValueOnce(3057 mockedExecute.mockReturnValueOnce(Effect.succeed(output("binary�contents", false, true)));
2897 Effect.succeed(output("binary\uFFFDcontents", false, true)),
2898 );
2899 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;3058 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
29003059
2901 const error = yield* Effect.flip(3060 const error = yield* Effect.flip(
...@@ -2920,8 +3079,8 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -2920,8 +3079,8 @@ layer("GitHubPullRequestCli.layer", (it) => {
29203079
2921 it.effect("returns valid text containing a literal replacement character", () =>3080 it.effect("returns valid text containing a literal replacement character", () =>
2922 Effect.gen(function* () {3081 Effect.gen(function* () {
2923 mockedExecute.mockReturnValueOnce(Effect.succeed(output("a1b2c3d\tb1c2d3e\n")));3082 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs)));
2924 mockedExecute.mockReturnValueOnce(Effect.succeed(output("before\uFFFDafter")));3083 mockedExecute.mockReturnValueOnce(Effect.succeed(output("before�after")));
2925 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;3084 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
29263085
2927 const contents = yield* cli.getPullRequestDiffFileContents({3086 const contents = yield* cli.getPullRequestDiffFileContents({
...@@ -2934,7 +3093,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -2934,7 +3093,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
2934 newPath: "docs/encoding.md",3093 newPath: "docs/encoding.md",
2935 });3094 });
29363095
2937 assert.strictEqual(contents.oldContents, "before\uFFFDafter");3096 assert.strictEqual(contents.oldContents, "before�after");
2938 }),3097 }),
2939 );3098 );
29403099
...@@ -2996,8 +3155,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -2996,8 +3155,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
2996 mockedExecute.mockReturnValueOnce(3155 mockedExecute.mockReturnValueOnce(
2997 Effect.succeed(3156 Effect.succeed(
2998 output(3157 output(
2999 // @effect-diagnostics-next-line preferSchemaOverJson:off3158 encodeJson({
3000 JSON.stringify({
3001 data: {3159 data: {
3002 nodes: [{ login: "octocat", avatarUrl: "https://avatars/octocat" }],3160 nodes: [{ login: "octocat", avatarUrl: "https://avatars/octocat" }],
3003 rateLimit: {3161 rateLimit: {
...@@ -3020,8 +3178,8 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -3020,8 +3178,8 @@ layer("GitHubPullRequestCli.layer", (it) => {
3020 ids: ["MDQ6VXNlcjE="],3178 ids: ["MDQ6VXNlcjE="],
3021 });3179 });
30223180
3023 expect(callAt(0).args).toContain("ids[]=MDQ6VXNlcjE=");3181 expect(varsAt(0)).toEqual({ ids: ["MDQ6VXNlcjE="] });
3024 expect(callAt(0).args.at(-1)).toContain("rateLimit { cost limit remaining resetAt }");3182 expect(queryAt(0)).toContain("rateLimit { cost limit remaining resetAt }");
3025 expect(avatars.get("octocat")).toBe("https://avatars/octocat");3183 expect(avatars.get("octocat")).toBe("https://avatars/octocat");
3026 }),3184 }),
3027 );3185 );
...@@ -3039,16 +3197,16 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -3039,16 +3197,16 @@ layer("GitHubPullRequestCli.layer", (it) => {
30393197
3040 it.effect("looks up the authenticated account on the requested enterprise host", () =>3198 it.effect("looks up the authenticated account on the requested enterprise host", () =>
3041 Effect.gen(function* () {3199 Effect.gen(function* () {
3042 mockedExecute3200 mockedExecute.mockReturnValueOnce(
3043 .mockReturnValueOnce(Effect.succeed(output("enterprise-test-credential")))3201 Effect.succeed(output('{"id":456,"login":"enterprise-user"}')),
3044 .mockReturnValueOnce(Effect.succeed(output('{"id":456,"login":"enterprise-user"}')));3202 );
3045 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;3203 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
30463204
3047 const login = yield* cli.getViewerLogin({ cwd: "/w", host: "github.acme.com" });3205 const login = yield* cli.getViewerLogin({ cwd: "/w", host: "github.acme.com" });
30483206
3049 expect(login).toBe("enterprise-user");3207 expect(login).toBe("enterprise-user");
3050 expect(callAt(0).args).toEqual(["auth", "token", "--hostname", "github.acme.com"]);3208 expect(mockedCredential).toHaveBeenCalledWith("github.acme.com");
3051 expect(callAt(1).args).toEqual(["api", "user", "--hostname", "github.acme.com"]);3209 expect(callAt(0)).toMatchObject({ kind: "rest", host: "github.acme.com", path: "user" });
3052 }),3210 }),
3053 );3211 );
30543212
...@@ -3056,47 +3214,42 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -3056,47 +3214,42 @@ layer("GitHubPullRequestCli.layer", (it) => {
3056 Effect.gen(function* () {3214 Effect.gen(function* () {
3057 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;3215 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
3058 const input = { cwd: "/w", host: "github.identity-cache.test" };3216 const input = { cwd: "/w", host: "github.identity-cache.test" };
3059 mockedExecute3217 mockedCredential.mockImplementation((host) =>
3060 .mockReturnValueOnce(Effect.succeed(output("test-credential-a")))3218 Effect.succeed({ token: Redacted.make("test-credential-a"), fingerprint: `${host}:a` }),
3061 .mockReturnValueOnce(Effect.succeed(output('{"id":123,"login":"maria-rcks"}')));3219 );
3220 mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"id":123,"login":"maria-rcks"}')));
3062 expect(yield* cli.getRoutingIdentity(input)).toEqual({3221 expect(yield* cli.getRoutingIdentity(input)).toEqual({
3063 accountId: "123",3222 accountId: "123",
3064 viewer: "maria-rcks",3223 viewer: "maria-rcks",
3065 });3224 });
3066 expect(callAt(1).env).toMatchObject({
3067 GH_ENTERPRISE_TOKEN: "test-credential-a",
3068 GH_DEBUG: "",
3069 });
30703225
3071 mockedExecute.mockReturnValueOnce(Effect.succeed(output("test-credential-a")));3226 // The same credential again is answered from what was verified, without asking GitHub.
3072 expect(yield* cli.getRoutingIdentity(input)).toEqual({3227 expect(yield* cli.getRoutingIdentity(input)).toEqual({
3073 accountId: "123",3228 accountId: "123",
3074 viewer: "maria-rcks",3229 viewer: "maria-rcks",
3075 });3230 });
3076 expect(mockedExecute).toHaveBeenCalledTimes(3);3231 expect(mockedExecute).toHaveBeenCalledTimes(1);
30773232
3078 mockedExecute3233 // A switched credential is not trusted on the strength of the old one's answer.
3079 .mockReturnValueOnce(Effect.succeed(output("test-credential-b")))3234 mockedCredential.mockImplementation((host) =>
3080 .mockReturnValueOnce(3235 Effect.succeed({ token: Redacted.make("test-credential-b"), fingerprint: `${host}:b` }),
3081 Effect.fail(3236 );
3082 new GitHubCli.GitHubCliCommandError({3237 mockedExecute.mockReturnValueOnce(
3083 command: "gh",3238 Effect.fail(
3084 cwd: "/w",3239 new GitHubApi.GitHubApiResponseError({
3085 cause: new Error("upstream failed with test-credential-b"),3240 host: "github.com",
3086 }),3241 operation: "getRoutingIdentity",
3087 ),3242 status: 502,
3088 );3243 }),
3244 ),
3245 );
3246 // GitHub's own refusal is reported as itself, so the page can say what went wrong.
3089 const failure = yield* cli.getRoutingIdentity(input).pipe(Effect.flip);3247 const failure = yield* cli.getRoutingIdentity(input).pipe(Effect.flip);
3090 expect(failure._tag).toBe("GitHubViewerLoginUnavailableError");3248 expect(failure._tag).toBe("GitHubApiResponseError");
3091 expect(String(failure)).not.toContain("test-credential-b");3249 expect(String(failure)).not.toContain("test-credential-b");
3092 expect(callAt(4).env).toMatchObject({3250 expect(mockedExecute).toHaveBeenCalledTimes(2);
3093 GH_ENTERPRISE_TOKEN: "test-credential-b",
3094 GH_DEBUG: "",
3095 });
30963251
3097 mockedExecute3252 mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"id":456,"login":"maria-rcks"}')));
3098 .mockReturnValueOnce(Effect.succeed(output("test-credential-b")))
3099 .mockReturnValueOnce(Effect.succeed(output('{"id":456,"login":"maria-rcks"}')));
3100 expect(yield* cli.getRoutingIdentity(input)).toEqual({3253 expect(yield* cli.getRoutingIdentity(input)).toEqual({
3101 accountId: "456",3254 accountId: "456",
3102 viewer: "maria-rcks",3255 viewer: "maria-rcks",
...@@ -3104,7 +3257,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -3104,7 +3257,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
3104 }),3257 }),
3105 );3258 );
31063259
3107 it.effect("sends a whole review as one request body over stdin", () =>3260 it.effect("sends a whole review as one request", () =>
3108 Effect.gen(function* () {3261 Effect.gen(function* () {
3109 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));3262 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
3110 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;3263 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
...@@ -3119,28 +3272,23 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -3119,28 +3272,23 @@ layer("GitHubPullRequestCli.layer", (it) => {
3119 comments: [{ path: "src/a.ts", position: { kind: "added", newLine: 4 }, body: "nit" }],3272 comments: [{ path: "src/a.ts", position: { kind: "added", newLine: 4 }, body: "nit" }],
3120 });3273 });
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 ]);
3132 // One request, so nothing is on the pull request until the verdict is.3275 // One request, so nothing is on the pull request until the verdict is.
3133 assert.strictEqual(mockedExecute.mock.calls.length, 1);3276 assert.strictEqual(mockedExecute.mock.calls.length, 1);
3134 // @effect-diagnostics-next-line preferSchemaOverJson:off3277 expect(restCallsTo("/reviews")).toMatchObject([
3135 expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({3278 {
3136 event: "APPROVE",3279 method: "POST",
3137 body: "Looks right.",3280 path: "repos/acme/web/pulls/7/reviews",
3138 comments: [{ path: "src/a.ts", line: 4, side: "RIGHT", body: "nit" }],3281 body: {
3139 });3282 event: "APPROVE",
3283 body: "Looks right.",
3284 comments: [{ path: "src/a.ts", line: 4, side: "RIGHT", body: "nit" }],
3285 },
3286 },
3287 ]);
3140 }),3288 }),
3141 );3289 );
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", () =>
3144 Effect.gen(function* () {3292 Effect.gen(function* () {
3145 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));3293 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
3146 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;3294 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
...@@ -3153,23 +3301,10 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -3153,23 +3301,10 @@ layer("GitHubPullRequestCli.layer", (it) => {
3153 body: "Fixed in 42ff8ec.",3301 body: "Fixed in 42ff8ec.",
3154 });3302 });
31553303
3156 // A reply is the reader's own words, so it travels the same way a comment body does.3304 expect(variablesOf("addPullRequestReviewThreadReply(")).toEqual([
3157 expect(callAt(0).args).toEqual([3305 { threadId: "PRRT_1", body: "Fixed in 42ff8ec." },
3158 "api",
3159 "graphql",
3160 "--hostname",
3161 "github.com",
3162 "--input",
3163 "-",
3164 ]);3306 ]);
3165 // @effect-diagnostics-next-line preferSchemaOverJson:off3307 expect(queryAt(0)).not.toContain("Fixed in 42ff8ec.");
3166 const request = JSON.parse(callAt(0).stdin ?? "") as {
3167 query: string;
3168 variables: Record<string, string>;
3169 };
3170 expect(request.query).toContain("addPullRequestReviewThreadReply");
3171 expect(request.variables).toEqual({ threadId: "PRRT_1", body: "Fixed in 42ff8ec." });
3172 expect(callAt(0).args.join(" ")).not.toContain("Fixed in 42ff8ec.");
3173 }),3308 }),
3174 );3309 );
31753310
...@@ -3193,30 +3328,18 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -3193,30 +3328,18 @@ layer("GitHubPullRequestCli.layer", (it) => {
3193 resolved: false,3328 resolved: false,
3194 });3329 });
31953330
3196 const parse = (index: number) => JSON.parse(callAt(index).stdin ?? "") as { query: string };3331 expect(queryAt(0)).toContain("resolveReviewThread(");
3197 expect(parse(0).query).toContain("resolveReviewThread(");3332 expect(queryAt(0)).not.toContain("unresolveReviewThread(");
3198 expect(parse(1).query).toContain("unresolveReviewThread(");3333 expect(queryAt(1)).toContain("unresolveReviewThread(");
3334 expect([varsAt(0), varsAt(1)]).toEqual([{ threadId: "PRRT_1" }, { threadId: "PRRT_1" }]);
3199 // A GitHub Enterprise thread is resolved on its own host, not on github.com.3335 // A GitHub Enterprise thread is resolved on its own host, not on github.com.
3200 expect(callAt(0).args).toContain("github.acme.dev");3336 expect([callAt(0).host, callAt(1).host]).toEqual(["github.acme.dev", "github.acme.dev"]);
3201 }),3337 }),
3202 );3338 );
32033339
3204 it.effect("confirms a given subject belongs to the named pull request, then reacts to it", () =>3340 it.effect("confirms a given subject belongs to the named pull request, then reacts to it", () =>
3205 Effect.gen(function* () {3341 Effect.gen(function* () {
3206 mockedExecute.mockReturnValueOnce(3342 route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]);
3207 Effect.succeed(
3208 output(
3209 // @effect-diagnostics-next-line preferSchemaOverJson:off
3210 JSON.stringify({
3211 data: {
3212 repository: { pullRequest: { id: "PR_kwDOA" } },
3213 node: { id: "IC_1", pullRequest: { id: "PR_kwDOA" } },
3214 },
3215 }),
3216 ),
3217 ),
3218 );
3219 mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}")));
3220 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;3343 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
32213344
3222 yield* cli.setReaction({3345 yield* cli.setReaction({
...@@ -3230,18 +3353,10 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -3230,18 +3353,10 @@ layer("GitHubPullRequestCli.layer", (it) => {
3230 });3353 });
32313354
3232 assert.strictEqual(mockedExecute.mock.calls.length, 2);3355 assert.strictEqual(mockedExecute.mock.calls.length, 2);
3233 const scopeCheck = callAt(0).args;3356 expect(variablesOf(SUBJECT_SCOPE_QUERY)).toEqual([
3234 expect(scopeCheck).toContain("owner=acme");3357 { owner: "acme", name: "web", number: 7, subjectId: "IC_1" },
3235 expect(scopeCheck).toContain("name=web");3358 ]);
3236 expect(scopeCheck).toContain("number=7");3359 expect(variablesOf("addReaction(")).toEqual([{ subjectId: "IC_1", content: "HEART" }]);
3237 expect(scopeCheck).toContain("subjectId=IC_1");
3238 // @effect-diagnostics-next-line preferSchemaOverJson:off
3239 const request = JSON.parse(callAt(1).stdin ?? "") as {
3240 query: string;
3241 variables: Record<string, string>;
3242 };
3243 expect(request.query).toContain("addReaction(");
3244 expect(request.variables).toEqual({ subjectId: "IC_1", content: "HEART" });
3245 }),3360 }),
3246 );3361 );
32473362
...@@ -3250,8 +3365,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -3250,8 +3365,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
3250 mockedExecute.mockReturnValueOnce(3365 mockedExecute.mockReturnValueOnce(
3251 Effect.succeed(3366 Effect.succeed(
3252 output(3367 output(
3253 // @effect-diagnostics-next-line preferSchemaOverJson:off3368 encodeJson({
3254 JSON.stringify({
3255 data: {3369 data: {
3256 repository: { pullRequest: { id: "PR_thisOne" } },3370 repository: { pullRequest: { id: "PR_thisOne" } },
3257 // A comment on pull request #99 of a different repository, named as though it3371 // A comment on pull request #99 of a different repository, named as though it
...@@ -3284,15 +3398,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -3284,15 +3398,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
32843398
3285 it.effect("looks up the pull request's own node id when no subject was given", () =>3399 it.effect("looks up the pull request's own node id when no subject was given", () =>
3286 Effect.gen(function* () {3400 Effect.gen(function* () {
3287 mockedExecute.mockReturnValueOnce(3401 route([NODE_ID_QUERY, nodeIdAnswer("PR_kwDOA")]);
3288 Effect.succeed(
3289 output(
3290 // @effect-diagnostics-next-line preferSchemaOverJson:off
3291 JSON.stringify({ data: { repository: { pullRequest: { id: "PR_kwDOA" } } } }),
3292 ),
3293 ),
3294 );
3295 mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}")));
3296 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;3402 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
32973403
3298 // Its own pull request: a node id looked up once is remembered for the life of the service.3404 // Its own pull request: a node id looked up once is remembered for the life of the service.
...@@ -3306,36 +3412,14 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -3306,36 +3412,14 @@ layer("GitHubPullRequestCli.layer", (it) => {
3306 });3412 });
33073413
3308 assert.strictEqual(mockedExecute.mock.calls.length, 2);3414 assert.strictEqual(mockedExecute.mock.calls.length, 2);
3309 const lookup = callAt(0).args;3415 expect(variablesOf(NODE_ID_QUERY)).toEqual([{ owner: "acme", name: "web", number: 21 }]);
3310 expect(lookup).toContain("owner=acme");3416 expect(variablesOf("addReaction(")).toEqual([{ subjectId: "PR_kwDOA", content: "ROCKET" }]);
3311 expect(lookup).toContain("name=web");
3312 expect(lookup).toContain("number=21");
3313 // @effect-diagnostics-next-line preferSchemaOverJson:off
3314 const request = JSON.parse(callAt(1).stdin ?? "") as {
3315 query: string;
3316 variables: Record<string, string>;
3317 };
3318 expect(request.query).toContain("addReaction(");
3319 expect(request.variables).toEqual({ subjectId: "PR_kwDOA", content: "ROCKET" });
3320 }),3417 }),
3321 );3418 );
33223419
3323 it.effect("takes a reaction back through the remove mutation", () =>3420 it.effect("takes a reaction back through the remove mutation", () =>
3324 Effect.gen(function* () {3421 Effect.gen(function* () {
3325 mockedExecute.mockReturnValueOnce(3422 route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]);
3326 Effect.succeed(
3327 output(
3328 // @effect-diagnostics-next-line preferSchemaOverJson:off
3329 JSON.stringify({
3330 data: {
3331 repository: { pullRequest: { id: "PR_kwDOA" } },
3332 node: { id: "IC_1", pullRequest: { id: "PR_kwDOA" } },
3333 },
3334 }),
3335 ),
3336 ),
3337 );
3338 mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}")));
3339 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;3423 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
33403424
3341 yield* cli.setReaction({3425 yield* cli.setReaction({
...@@ -3348,22 +3432,14 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -3348,22 +3432,14 @@ layer("GitHubPullRequestCli.layer", (it) => {
3348 reacted: false,3432 reacted: false,
3349 });3433 });
33503434
3351 // @effect-diagnostics-next-line preferSchemaOverJson:off3435 expect(variablesOf("removeReaction(")).toEqual([{ subjectId: "IC_1", content: "HEART" }]);
3352 const request = JSON.parse(callAt(1).stdin ?? "") as { query: string };3436 expect(variablesOf("addReaction(")).toEqual([]);
3353 expect(request.query).toContain("removeReaction(");
3354 }),3437 }),
3355 );3438 );
33563439
3357 it.effect("rewrites only the words a request named", () =>3440 it.effect("rewrites only the words a request named", () =>
3358 Effect.gen(function* () {3441 Effect.gen(function* () {
3359 mockedExecute.mockReturnValue(3442 route([NODE_ID_QUERY, nodeIdAnswer("PR_kwDOA")]);
3360 Effect.succeed(
3361 output(
3362 // @effect-diagnostics-next-line preferSchemaOverJson:off
3363 JSON.stringify({ data: { repository: { pullRequest: { id: "PR_kwDOA" } } } }),
3364 ),
3365 ),
3366 );
3367 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;3443 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
3368 const rewrite = (fields: { readonly title?: string; readonly body?: string }) =>3444 const rewrite = (fields: { readonly title?: string; readonly body?: string }) =>
3369 cli.updatePullRequest({3445 cli.updatePullRequest({
...@@ -3379,38 +3455,18 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -3379,38 +3455,18 @@ layer("GitHubPullRequestCli.layer", (it) => {
3379 yield* rewrite({ title: "Both", body: "at once." });3455 yield* rewrite({ title: "Both", body: "at once." });
33803456
3381 // One node id lookup for the pull request, then a mutation per rewrite.3457 // One node id lookup for the pull request, then a mutation per rewrite.
3382 const variablesAt = (index: number) =>3458 assert.strictEqual(variablesOf(NODE_ID_QUERY).length, 1);
3383 (JSON.parse(callAt(index).stdin ?? "") as { variables: Record<string, string> }).variables;3459 expect(variablesOf("updatePullRequest(")).toEqual([
3384 expect(variablesAt(1)).toEqual({ pullRequestId: "PR_kwDOA", title: "A better title" });3460 { pullRequestId: "PR_kwDOA", title: "A better title" },
3385 expect(variablesAt(2)).toEqual({3461 { pullRequestId: "PR_kwDOA", body: "A better description." },
3386 pullRequestId: "PR_kwDOA",3462 { pullRequestId: "PR_kwDOA", title: "Both", body: "at once." },
3387 body: "A better description.",3463 ]);
3388 });
3389 expect(variablesAt(3)).toEqual({
3390 pullRequestId: "PR_kwDOA",
3391 title: "Both",
3392 body: "at once.",
3393 });
3394 // The reader's own words, so they travel the way every other body does.
3395 expect(callAt(3).args.join(" ")).not.toContain("at once.");
3396 }),3464 }),
3397 );3465 );
33983466
3399 it.effect("rewrites a remark through the mutation its kind needs", () =>3467 it.effect("rewrites a remark through the mutation its kind needs", () =>
3400 Effect.gen(function* () {3468 Effect.gen(function* () {
3401 mockedExecute.mockReturnValue(3469 route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]);
3402 Effect.succeed(
3403 output(
3404 // @effect-diagnostics-next-line preferSchemaOverJson:off
3405 JSON.stringify({
3406 data: {
3407 repository: { pullRequest: { id: "PR_kwDOA" } },
3408 node: { id: "IC_1", pullRequest: { id: "PR_kwDOA" } },
3409 },
3410 }),
3411 ),
3412 ),
3413 );
3414 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;3470 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
3415 const rewrite = (kind: "issue-comment" | "review-comment") =>3471 const rewrite = (kind: "issue-comment" | "review-comment") =>
3416 cli.updateComment({3472 cli.updateComment({
...@@ -3426,16 +3482,16 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -3426,16 +3482,16 @@ layer("GitHubPullRequestCli.layer", (it) => {
3426 yield* rewrite("issue-comment");3482 yield* rewrite("issue-comment");
3427 yield* rewrite("review-comment");3483 yield* rewrite("review-comment");
34283484
3429 const parse = (index: number) =>3485 expect(variablesOf(SUBJECT_SCOPE_QUERY).map((variables) => variables["subjectId"])).toEqual([
3430 JSON.parse(callAt(index).stdin ?? "") as {3486 "IC_1",
3431 query: string;3487 "IC_1",
3432 variables: Record<string, string>;3488 ]);
3433 };3489 expect(variablesOf("updateIssueComment(")).toEqual([
3434 expect(callAt(0).args).toContain("subjectId=IC_1");3490 { commentId: "IC_1", body: "Reworded." },
3435 expect(parse(1).query).toContain("updateIssueComment(");3491 ]);
3436 expect(parse(1).variables).toEqual({ commentId: "IC_1", body: "Reworded." });3492 expect(variablesOf("updatePullRequestReviewComment(")).toEqual([
3437 expect(parse(3).query).toContain("updatePullRequestReviewComment(");3493 { commentId: "IC_1", body: "Reworded." },
3438 expect(parse(3).variables).toEqual({ commentId: "IC_1", body: "Reworded." });3494 ]);
3439 }),3495 }),
3440 );3496 );
34413497
...@@ -3444,8 +3500,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -3444,8 +3500,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
3444 mockedExecute.mockReturnValueOnce(3500 mockedExecute.mockReturnValueOnce(
3445 Effect.succeed(3501 Effect.succeed(
3446 output(3502 output(
3447 // @effect-diagnostics-next-line preferSchemaOverJson:off3503 encodeJson({
3448 JSON.stringify({
3449 data: {3504 data: {
3450 repository: { pullRequest: { id: "PR_thisOne" } },3505 repository: { pullRequest: { id: "PR_thisOne" } },
3451 node: { id: "IC_99", pullRequest: { id: "PR_someOtherOne" } },3506 node: { id: "IC_99", pullRequest: { id: "PR_someOtherOne" } },
...@@ -3469,7 +3524,8 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -3469,7 +3524,8 @@ layer("GitHubPullRequestCli.layer", (it) => {
3469 );3524 );
34703525
3471 assert.strictEqual(error._tag, "GitHubSubjectScopeError");3526 assert.strictEqual(error._tag, "GitHubSubjectScopeError");
3472 expect(error.message).toContain("updateComment");3527 if (error._tag === "GitHubSubjectScopeError")
3528 assert.strictEqual(error.operation, "updateComment");
3473 // Refused before any mutation was sent.3529 // Refused before any mutation was sent.
3474 assert.strictEqual(mockedExecute.mock.calls.length, 1);3530 assert.strictEqual(mockedExecute.mock.calls.length, 1);
3475 }),3531 }),
...@@ -3500,14 +3556,8 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -3500,14 +3556,8 @@ layer("GitHubPullRequestCli.layer", (it) => {
3500 output(3556 output(
3501 encodeJson(3557 encodeJson(
3502 coreResponse({3558 coreResponse({
3503 number: 7,
3504 title: "Progressive detail",3559 title: "Progressive detail",
3505 url: "https://github.com/acme/web/pull/7",
3506 author: { login: "octocat" },3560 author: { login: "octocat" },
3507 headRefName: "feature",
3508 baseRefName: "main",
3509 createdAt: "2026-07-01T00:00:00Z",
3510 updatedAt: "2026-07-02T00:00:00Z",
3511 body: "Core body",3561 body: "Core body",
3512 changedFiles: 2,3562 changedFiles: 2,
3513 }),3563 }),
...@@ -3518,12 +3568,17 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -3518,12 +3568,17 @@ layer("GitHubPullRequestCli.layer", (it) => {
3518 mockedExecute.mockReturnValueOnce(3568 mockedExecute.mockReturnValueOnce(
3519 Effect.succeed(3569 Effect.succeed(
3520 output(3570 output(
3521 // @effect-diagnostics-next-line preferSchemaOverJson:off3571 encodeJson({
3522 JSON.stringify({3572 data: {
3523 author: { login: "octocat" },3573 repository: {
3524 comments: [],3574 pullRequest: {
3525 reviews: [],3575 author: { __typename: "User", login: "octocat", avatarUrl: "https://a/o" },
3526 commits: [],3576 commits: { nodes: [] },
3577 comments: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] },
3578 reviews: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] },
3579 },
3580 },
3581 },
3527 }),3582 }),
3528 ),3583 ),
3529 ),3584 ),
...@@ -3541,15 +3596,19 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -3541,15 +3596,19 @@ layer("GitHubPullRequestCli.layer", (it) => {
35413596
3542 expect(detail.body).toBe("Core body");3597 expect(detail.body).toBe("Core body");
3543 expect(activity.author?.login).toBe("octocat");3598 expect(activity.author?.login).toBe("octocat");
3544 expect(callAt(0).args).toContain("headRef=refs/pull/7/head");3599 expect(varsAt(0)).toMatchObject({ number: 7, headRef: "refs/pull/7/head" });
3545 expect(callAt(0).args.at(-1)).toContain("viewerCanUpdateBranch");3600 expect(queryAt(0)).toContain("viewerCanUpdateBranch");
3601 expect(queryAt(0)).not.toContain("reviews(");
3546 expect(detail.viewerAccess.mergeCapabilities).toEqual({3602 expect(detail.viewerAccess.mergeCapabilities).toEqual({
3547 merge: true,3603 merge: true,
3548 squash: false,3604 squash: false,
3549 rebase: true,3605 rebase: true,
3550 });3606 });
3551 expect(detail.comparison).toEqual({ behindBy: 2, viewerCanUpdate: true });3607 expect(detail.comparison).toEqual({ behindBy: 2, viewerCanUpdate: true });
3552 expect(callAt(1).args.at(-1)).toBe("author,comments,reviews,commits");3608 // Conversation activity is its own read, and asks for the head of the conversation once.
3609 expect(queryAt(1)).toContain("reviews(");
3610 expect(varsAt(1)).toMatchObject({ head: true, withComments: true, withReviews: true });
3611 assert.strictEqual(mockedExecute.mock.calls.length, 2);
3553 }),3612 }),
3554 );3613 );
35553614
...@@ -3621,35 +3680,40 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -3621,35 +3680,40 @@ layer("GitHubPullRequestCli.layer", (it) => {
36213680
3622 it.effect("reads every check when the combined response has another page", () =>3681 it.effect("reads every check when the combined response has another page", () =>
3623 Effect.gen(function* () {3682 Effect.gen(function* () {
3624 const response = coreResponse({3683 mockedExecute.mockReturnValueOnce(
3625 commits: {3684 Effect.succeed(
3626 nodes: [3685 output(
3627 {3686 encodeJson(
3628 commit: {3687 coreResponse({
3629 statusCheckRollup: {3688 commits: {
3630 contexts: {3689 nodes: [
3631 nodes: [{ name: "first", status: "COMPLETED", conclusion: "SUCCESS" }],3690 {
3632 pageInfo: { hasNextPage: true },3691 commit: {
3633 },3692 statusCheckRollup: {
3693 contexts: {
3694 nodes: [{ name: "first", status: "COMPLETED", conclusion: "SUCCESS" }],
3695 pageInfo: { hasNextPage: true, endCursor: "c1" },
3696 },
3697 },
3698 },
3699 },
3700 ],
3634 },3701 },
3635 },3702 }),
3636 },3703 ),
3637 ],3704 ),
3638 },3705 ),
3639 });3706 );
3640 mockedExecute.mockReturnValueOnce(Effect.succeed(output(encodeJson(response))));
3641 mockedExecute.mockReturnValueOnce(3707 mockedExecute.mockReturnValueOnce(
3642 Effect.succeed(3708 Effect.succeed(
3643 output(3709 output(
3644 encodeJson({3710 checkContextsPage(
3645 ...response.data.repository.pullRequest,3711 [
3646 reviewRequests: [],
3647 labels: [],
3648 statusCheckRollup: [
3649 { name: "first", status: "COMPLETED", conclusion: "SUCCESS" },3712 { name: "first", status: "COMPLETED", conclusion: "SUCCESS" },
3650 { name: "last", status: "COMPLETED", conclusion: "FAILURE" },3713 { name: "last", status: "COMPLETED", conclusion: "FAILURE" },
3651 ],3714 ],
3652 }),3715 null,
3716 ),
3653 ),3717 ),
3654 ),3718 ),
3655 );3719 );
...@@ -3663,7 +3727,9 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -3663,7 +3727,9 @@ layer("GitHubPullRequestCli.layer", (it) => {
3663 expect(detail.checks).toHaveLength(2);3727 expect(detail.checks).toHaveLength(2);
3664 expect(detail.checksState).toBe("failing");3728 expect(detail.checksState).toBe("failing");
3665 expect(detail.checksTruncated).toBe(false);3729 expect(detail.checksTruncated).toBe(false);
3666 expect(callAt(1).args.slice(0, 2)).toEqual(["pr", "view"]);3730 // The whole rollup is walked from its start, so no check is counted twice or skipped.
3731 expect(varsAt(1)).toMatchObject({ number: 7, after: null });
3732 assert.strictEqual(mockedExecute.mock.calls.length, 2);
3667 }),3733 }),
3668 );3734 );
36693735
...@@ -3744,9 +3810,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -3744,9 +3810,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
3744 const error = yield* Effect.flip(cli.getPullRequestDetail(input));3810 const error = yield* Effect.flip(cli.getPullRequestDetail(input));
3745 expect(error._tag).toBe("SourceControlRateLimitPausedError");3811 expect(error._tag).toBe("SourceControlRateLimitPausedError");
3746 expect(mockedExecute).toHaveBeenCalledOnce();3812 expect(mockedExecute).toHaveBeenCalledOnce();
3747 yield* cli3813 yield* cli.getPullRequestDetail(input).pipe(Effect.provideService(AllowGitHubReserve, true));
3748 .getPullRequestDetail(input)
3749 .pipe(Effect.provideService(GitHubCli.AllowGitHubReserve, true));
3750 expect(mockedExecute).toHaveBeenCalledTimes(2);3814 expect(mockedExecute).toHaveBeenCalledTimes(2);
3751 }),3815 }),
3752 );3816 );
...@@ -3769,13 +3833,13 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -3769,13 +3833,13 @@ layer("GitHubPullRequestCli.layer", (it) => {
3769 // What matters is that it fails at all: an empty patch with no cursor would render as a3833 // What matters is that it fails at all: an empty patch with no cursor would render as a
3770 // change with no files and report the rest of it as already read. The refusal that sent3834 // change with no files and report the rest of it as already read. The refusal that sent
3771 // the read down this road is the one reported, by design.3835 // the read down this road is the one reported, by design.
3772 assert.strictEqual(error._tag, "GitHubCliCommandError");3836 assert.strictEqual(error, diffRefused);
3773 }),3837 }),
3774 );3838 );
37753839
3776 it.effect("pages an oversized patch by file rather than handing back a severed one", () =>3840 it.effect("pages an oversized patch by file rather than handing back a severed one", () =>
3777 Effect.gen(function* () {3841 Effect.gen(function* () {
3778 // `gh pr diff` succeeded but its output was cut at a byte, which lands mid-file.3842 // The whole diff came back, but cut at a byte, which lands mid-file.
3779 mockedExecute.mockReturnValueOnce(3843 mockedExecute.mockReturnValueOnce(
3780 Effect.succeed(output("diff --git a/a b/a\n@@ -1 +1 @@", true)),3844 Effect.succeed(output("diff --git a/a b/a\n@@ -1 +1 @@", true)),
3781 );3845 );
...@@ -3790,8 +3854,9 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -3790,8 +3854,9 @@ layer("GitHubPullRequestCli.layer", (it) => {
3790 });3854 });
37913855
3792 // The severed patch is thrown away; what comes back is assembled from whole files.3856 // The severed patch is thrown away; what comes back is assembled from whole files.
3793 expect(callAt(1).args.join(" ")).toContain("/pulls/7/files");3857 assert.strictEqual(pathAt(1), "repos/acme/web/pulls/7/files?per_page=100&page=1");
3794 expect(slice.patch).toContain("src/file1.ts");3858 expect(slice.patch).toContain("src/file1.ts");
3859 expect(slice.patch).not.toContain("diff --git a/a b/a");
3795 assert.strictEqual(mockedExecute.mock.calls.length, 2);3860 assert.strictEqual(mockedExecute.mock.calls.length, 2);
3796 }),3861 }),
3797 );3862 );
...@@ -3813,9 +3878,9 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -3813,9 +3878,9 @@ layer("GitHubPullRequestCli.layer", (it) => {
3813 number: 7,3878 number: 7,
3814 });3879 });
38153880
3816 // The first page asks from the beginning, which gh only sends as a typed JSON null.3881 // The first page asks from the beginning; the next carries on from where it stopped.
3817 expect(callAt(0).args).toContain("cursor=null");3882 expect(varsAt(0)["cursor"]).toBeNull();
3818 expect(callAt(1).args).toContain("cursor=Y3Vyc29yOjE");3883 expect(varsAt(1)["cursor"]).toBe("Y3Vyc29yOjE");
3819 expect(conversation.comments.map((comment) => comment.id)).toEqual(["c1", "c2"]);3884 expect(conversation.comments.map((comment) => comment.id)).toEqual(["c1", "c2"]);
3820 assert.isFalse(conversation.truncated);3885 assert.isFalse(conversation.truncated);
3821 }),3886 }),
...@@ -3897,11 +3962,13 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -3897,11 +3962,13 @@ layer("GitHubPullRequestCli.layer", (it) => {
3897 cursor: "Y3Vyc29yOjI",3962 cursor: "Y3Vyc29yOjI",
3898 });3963 });
38993964
3900 expect(callAt(0).args).toContain("owner=acme");3965 expect(varsAt(0)).toMatchObject({
3901 expect(callAt(0).args).toContain("name=web");3966 owner: "acme",
3902 expect(callAt(0).args).toContain("number=7");3967 name: "web",
3903 expect(callAt(0).args).toContain("threadId=PRRT_1");3968 number: 7,
3904 expect(callAt(0).args).toContain("cursor=Y3Vyc29yOjI");3969 threadId: "PRRT_1",
3970 cursor: "Y3Vyc29yOjI",
3971 });
3905 assert.strictEqual(mockedExecute.mock.calls.length, 1);3972 assert.strictEqual(mockedExecute.mock.calls.length, 1);
3906 expect(page.comments.map((comment) => comment.id)).toEqual(["c2", "c3"]);3973 expect(page.comments.map((comment) => comment.id)).toEqual(["c2", "c3"]);
3907 expect(page.nextCursor).toBeNull();3974 expect(page.nextCursor).toBeNull();
...@@ -3937,8 +4004,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -3937,8 +4004,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
3937 mockedExecute.mockReturnValue(4004 mockedExecute.mockReturnValue(
3938 Effect.succeed(4005 Effect.succeed(
3939 output(4006 output(
3940 // @effect-diagnostics-next-line preferSchemaOverJson:off4007 encodeJson({
3941 JSON.stringify({
3942 data: {4008 data: {
3943 repository: {4009 repository: {
3944 mergeCommitAllowed: true,4010 mergeCommitAllowed: true,
...@@ -3963,10 +4029,8 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -3963,10 +4029,8 @@ layer("GitHubPullRequestCli.layer", (it) => {
39634029
3964 // One request, because both answers hang off the same repository object.4030 // One request, because both answers hang off the same repository object.
3965 assert.strictEqual(mockedExecute.mock.calls.length, 1);4031 assert.strictEqual(mockedExecute.mock.calls.length, 1);
3966 expect(callAt(0).args).toContain("number=7");4032 expect(varsAt(0)).toMatchObject({ owner: "acme", name: "web", number: 7 });
3967 expect(callAt(0).args.at(-1)).toContain(4033 expect(queryAt(0)).toContain("mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed");
3968 "mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed",
3969 );
3970 expect(access).toEqual({4034 expect(access).toEqual({
3971 mergeCapabilities: { merge: true, squash: false, rebase: true },4035 mergeCapabilities: { merge: true, squash: false, rebase: true },
3972 canWrite: false,4036 canWrite: false,
...@@ -3977,13 +4041,12 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -3977,13 +4041,12 @@ layer("GitHubPullRequestCli.layer", (it) => {
3977 }),4041 }),
3978 );4042 );
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", () =>
3981 Effect.gen(function* () {4045 Effect.gen(function* () {
3982 mockedExecute.mockReturnValue(4046 mockedExecute.mockReturnValue(
3983 Effect.succeed(4047 Effect.succeed(
3984 output(4048 output(
3985 // @effect-diagnostics-next-line preferSchemaOverJson:off4049 encodeJson({
3986 JSON.stringify({
3987 data: {4050 data: {
3988 repository: {4051 repository: {
3989 pullRequest: {4052 pullRequest: {
...@@ -4006,26 +4069,14 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -4006,26 +4069,14 @@ layer("GitHubPullRequestCli.layer", (it) => {
4006 headRef: "fork:feat/page",4069 headRef: "fork:feat/page",
4007 });4070 });
40084071
4009 // The tuples are flattened straight into argv, so a variable without its flag is a4072 expect(varsAt(0)).toEqual({
4010 // positional argument gh refuses outright.4073 owner: "acme",
4011 const args = callAt(0).args;4074 name: "web",
4012 expect(args.slice(0, -2)).toEqual([4075 number: 7,
4013 "api",4076 headRef: "fork:feat/page",
4014 "graphql",4077 });
4015 "--hostname",
4016 "github.com",
4017 "-f",
4018 "owner=acme",
4019 "-f",
4020 "name=web",
4021 "-F",
4022 "number=7",
4023 "-f",
4024 "headRef=fork:feat/page",
4025 ]);
4026 expect(comparison).toEqual({ behindBy: 4, viewerCanUpdate: true });4078 expect(comparison).toEqual({ behindBy: 4, viewerCanUpdate: true });
4027 expect(args.at(-2)).toBe("-f");4079 expect(queryAt(0)).toContain(BASE_COMPARISON_GRAPHQL_QUERY.slice(0, -2));
4028 expect(args.at(-1)).toContain(`query=${BASE_COMPARISON_GRAPHQL_QUERY.slice(0, -2)}`);
4029 }),4080 }),
4030 );4081 );
40314082
...@@ -4034,8 +4085,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -4034,8 +4085,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
4034 mockedExecute.mockReturnValue(4085 mockedExecute.mockReturnValue(
4035 Effect.succeed(4086 Effect.succeed(
4036 output(4087 output(
4037 // @effect-diagnostics-next-line preferSchemaOverJson:off4088 encodeJson({
4038 JSON.stringify({
4039 data: {4089 data: {
4040 repository: {4090 repository: {
4041 pullRequest: {4091 pullRequest: {
...@@ -4064,7 +4114,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -4064,7 +4114,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
4064 } as const;4114 } as const;
40654115
4066 yield* cli.getPullRequestBaseComparison(input);4116 yield* cli.getPullRequestBaseComparison(input);
4067 expect(callAt(0).args.at(-1)).toContain("rateLimit { cost limit remaining resetAt }");4117 expect(queryAt(0)).toContain("rateLimit { cost limit remaining resetAt }");
40684118
4069 const error = yield* Effect.flip(cli.getPullRequestBaseComparison(input));4119 const error = yield* Effect.flip(cli.getPullRequestBaseComparison(input));
40704120
...@@ -4083,8 +4133,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -4083,8 +4133,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
4083 .mockReturnValueOnce(4133 .mockReturnValueOnce(
4084 Effect.succeed(4134 Effect.succeed(
4085 output(4135 output(
4086 // @effect-diagnostics-next-line preferSchemaOverJson:off4136 encodeJson({
4087 JSON.stringify({
4088 data: {4137 data: {
4089 repository: {4138 repository: {
4090 pullRequest: {4139 pullRequest: {
...@@ -4106,8 +4155,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -4106,8 +4155,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
4106 .mockReturnValueOnce(4155 .mockReturnValueOnce(
4107 Effect.succeed(4156 Effect.succeed(
4108 output(4157 output(
4109 // @effect-diagnostics-next-line preferSchemaOverJson:off4158 encodeJson({
4110 JSON.stringify({
4111 data: {4159 data: {
4112 repository: {4160 repository: {
4113 mergeCommitAllowed: true,4161 mergeCommitAllowed: true,
...@@ -4167,21 +4215,11 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -4167,21 +4215,11 @@ layer("GitHubPullRequestCli.layer", (it) => {
4167 requested: true,4215 requested: true,
4168 });4216 });
41694217
4170 const call = callAt(0);4218 expect(callAt(0)).toMatchObject({
4171 expect(call.args).toEqual([4219 kind: "rest",
4172 "api",4220 method: "POST",
4173 "--method",4221 path: "repos/acme/web/pulls/7/requested_reviewers",
4174 "POST",4222 body: { reviewers: ["octocat"], team_reviewers: ["reviewers"] },
4175 "--hostname",
4176 "github.com",
4177 "repos/acme/web/pulls/7/requested_reviewers",
4178 "--input",
4179 "-",
4180 ]);
4181 // @effect-diagnostics-next-line preferSchemaOverJson:off
4182 expect(JSON.parse(call.stdin ?? "")).toEqual({
4183 reviewers: ["octocat"],
4184 team_reviewers: ["reviewers"],
4185 });4223 });
4186 }),4224 }),
4187 );4225 );
...@@ -4200,13 +4238,11 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -4200,13 +4238,11 @@ layer("GitHubPullRequestCli.layer", (it) => {
4200 requested: false,4238 requested: false,
4201 });4239 });
42024240
4203 const call = callAt(0);4241 expect(callAt(0)).toMatchObject({
4204 expect(call.args).toContain("DELETE");4242 kind: "rest",
4205 expect(call.args).toContain("repos/acme/web/pulls/7/requested_reviewers");4243 method: "DELETE",
4206 // @effect-diagnostics-next-line preferSchemaOverJson:off4244 path: "repos/acme/web/pulls/7/requested_reviewers",
4207 expect(JSON.parse(call.stdin ?? "")).toEqual({4245 body: { reviewers: ["octocat"], team_reviewers: [] },
4208 reviewers: ["octocat"],
4209 team_reviewers: [],
4210 });4246 });
4211 }),4247 }),
4212 );4248 );
...@@ -4216,8 +4252,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -4216,8 +4252,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
4216 mockedExecute.mockReturnValue(4252 mockedExecute.mockReturnValue(
4217 Effect.succeed(4253 Effect.succeed(
4218 output(4254 output(
4219 // @effect-diagnostics-next-line preferSchemaOverJson:off4255 encodeJson({
4220 JSON.stringify({
4221 data: {4256 data: {
4222 repository: {4257 repository: {
4223 assignableUsers: {4258 assignableUsers: {
...@@ -4246,7 +4281,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -4246,7 +4281,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
4246 // The people, who has been asked and who opened the pull request all hang off the same4281 // The people, who has been asked and who opened the pull request all hang off the same
4247 // repository object, so the menu costs one request.4282 // repository object, so the menu costs one request.
4248 assert.strictEqual(mockedExecute.mock.calls.length, 1);4283 assert.strictEqual(mockedExecute.mock.calls.length, 1);
4249 expect(callAt(0).args).toContain("number=7");4284 expect(varsAt(0)).toMatchObject({ owner: "acme", name: "web", number: 7 });
4250 expect(list.candidates.map((candidate) => [candidate.login, candidate.isRequested])).toEqual([4285 expect(list.candidates.map((candidate) => [candidate.login, candidate.isRequested])).toEqual([
4251 ["octocat", true],4286 ["octocat", true],
4252 ["hubot", false],4287 ["hubot", false],
...@@ -4269,19 +4304,12 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -4269,19 +4304,12 @@ layer("GitHubPullRequestCli.layer", (it) => {
4269 });4304 });
42704305
4271 assert.strictEqual(mockedExecute.mock.calls.length, 1);4306 assert.strictEqual(mockedExecute.mock.calls.length, 1);
4272 const call = callAt(0);4307 expect(callAt(0)).toMatchObject({
4273 expect(call.args).toEqual([4308 kind: "rest",
4274 "api",4309 method: "POST",
4275 "--method",4310 path: "repos/acme/web/issues/7/labels",
4276 "POST",4311 body: { labels: ["bug", "size:XL"] },
4277 "--hostname",4312 });
4278 "github.com",
4279 "repos/acme/web/issues/7/labels",
4280 "--input",
4281 "-",
4282 ]);
4283 // @effect-diagnostics-next-line preferSchemaOverJson:off - asserting the raw gh request body.
4284 expect(JSON.parse(call.stdin ?? "")).toEqual({ labels: ["bug", "size:XL"] });
4285 }),4313 }),
4286 );4314 );
42874315
...@@ -4300,9 +4328,14 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -4300,9 +4328,14 @@ layer("GitHubPullRequestCli.layer", (it) => {
4300 });4328 });
43014329
4302 assert.strictEqual(mockedExecute.mock.calls.length, 2);4330 assert.strictEqual(mockedExecute.mock.calls.length, 2);
4303 expect(callAt(0).args).toContain("repos/acme/web/issues/7/labels/good%20first%20issue");4331 expect(callAt(0)).toMatchObject({
4304 expect(callAt(0).args).toContain("DELETE");4332 method: "DELETE",
4305 expect(callAt(1).args).toContain("repos/acme/web/issues/7/labels/area%2Fweb");4333 path: "repos/acme/web/issues/7/labels/good%20first%20issue",
4334 });
4335 expect(callAt(1)).toMatchObject({
4336 method: "DELETE",
4337 path: "repos/acme/web/issues/7/labels/area%2Fweb",
4338 });
4306 }),4339 }),
4307 );4340 );
43084341
...@@ -4311,7 +4344,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -4311,7 +4344,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
4311 const page = (index: number, hasNextPage: boolean) =>4344 const page = (index: number, hasNextPage: boolean) =>
4312 Effect.succeed(4345 Effect.succeed(
4313 output(4346 output(
4314 JSON.stringify({4347 encodeJson({
4315 data: {4348 data: {
4316 repository: {4349 repository: {
4317 pullRequest: {4350 pullRequest: {
...@@ -4343,9 +4376,11 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -4343,9 +4376,11 @@ layer("GitHubPullRequestCli.layer", (it) => {
43434376
4344 assert.strictEqual(mockedExecute.mock.calls.length, 3);4377 assert.strictEqual(mockedExecute.mock.calls.length, 3);
4345 // The first page asks from the start; each one after it carries the cursor before it.4378 // The first page asks from the start; each one after it carries the cursor before it.
4346 assert.isFalse(callAt(0).args.some((arg) => arg.startsWith("after=")));4379 expect([varsAt(0)["after"], varsAt(1)["after"], varsAt(2)["after"]]).toEqual([
4347 expect(callAt(1).args).toContain("after=cursor-0");4380 null,
4348 expect(callAt(2).args).toContain("after=cursor-1");4381 "cursor-0",
4382 "cursor-1",
4383 ]);
4349 assert.isFalse(viewed.truncated);4384 assert.isFalse(viewed.truncated);
4350 expect(viewed.files.map((file) => [file.path, file.state])).toEqual([4385 expect(viewed.files.map((file) => [file.path, file.state])).toEqual([
4351 ["src/file0.ts", "viewed"],4386 ["src/file0.ts", "viewed"],
...@@ -4363,8 +4398,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -4363,8 +4398,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
4363 mockedExecute.mockReturnValue(4398 mockedExecute.mockReturnValue(
4364 Effect.succeed(4399 Effect.succeed(
4365 output(4400 output(
4366 // @effect-diagnostics-next-line preferSchemaOverJson:off4401 encodeJson({
4367 JSON.stringify({
4368 data: {4402 data: {
4369 repository: {4403 repository: {
4370 pullRequest: {4404 pullRequest: {
...@@ -4399,8 +4433,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -4399,8 +4433,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
4399 mockedExecute4433 mockedExecute
4400 .mockReturnValueOnce(4434 .mockReturnValueOnce(
4401 Effect.succeed(4435 Effect.succeed(
4402 // @effect-diagnostics-next-line preferSchemaOverJson:off4436 output(encodeJson({ data: { repository: { pullRequest: { id: "PR_1" } } } })),
4403 output(JSON.stringify({ data: { repository: { pullRequest: { id: "PR_1" } } } })),
4404 ),4437 ),
4405 )4438 )
4406 .mockReturnValueOnce(Effect.succeed(output("{}")));4439 .mockReturnValueOnce(Effect.succeed(output("{}")));
...@@ -4419,14 +4452,9 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -4419,14 +4452,9 @@ layer("GitHubPullRequestCli.layer", (it) => {
44194452
4420 // One request to learn the pull request's node id, one for every press together.4453 // One request to learn the pull request's node id, one for every press together.
4421 assert.strictEqual(mockedExecute.mock.calls.length, 2);4454 assert.strictEqual(mockedExecute.mock.calls.length, 2);
4422 // @effect-diagnostics-next-line preferSchemaOverJson:off4455 expect(queryAt(1)).toContain("f0: markFileAsViewed");
4423 const sent = JSON.parse(callAt(1).stdin ?? "") as {4456 expect(queryAt(1)).toContain("f1: unmarkFileAsViewed");
4424 query: string;4457 expect(varsAt(1)).toEqual({
4425 variables: Record<string, string>;
4426 };
4427 expect(sent.query).toContain("f0: markFileAsViewed");
4428 expect(sent.query).toContain("f1: unmarkFileAsViewed");
4429 expect(sent.variables).toEqual({
4430 pullRequestId: "PR_1",4458 pullRequestId: "PR_1",
4431 path0: "src/a.ts",4459 path0: "src/a.ts",
4432 path1: "src/b.ts",4460 path1: "src/b.ts",
...@@ -4455,8 +4483,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -4455,8 +4483,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
4455 mockedExecute4483 mockedExecute
4456 .mockReturnValueOnce(4484 .mockReturnValueOnce(
4457 Effect.succeed(4485 Effect.succeed(
4458 // @effect-diagnostics-next-line preferSchemaOverJson:off4486 output(encodeJson({ data: { repository: { pullRequest: { id: "PR_24" } } } })),
4459 output(JSON.stringify({ data: { repository: { pullRequest: { id: "PR_24" } } } })),
4460 ),4487 ),
4461 )4488 )
4462 .mockReturnValue(Effect.succeed(output("{}")));4489 .mockReturnValue(Effect.succeed(output("{}")));
...@@ -4475,11 +4502,12 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -4475,11 +4502,12 @@ layer("GitHubPullRequestCli.layer", (it) => {
44754502
4476 // One lookup, then a mutation per write, every one of them addressed by the id it answered.4503 // One lookup, then a mutation per write, every one of them addressed by the id it answered.
4477 assert.strictEqual(mockedExecute.mock.calls.length, 4);4504 assert.strictEqual(mockedExecute.mock.calls.length, 4);
4478 expect(callAt(0).args).toContain("number=24");4505 expect(varsAt(0)).toEqual({ owner: "acme", name: "web", number: 24 });
4479 const idSentAt = (index: number) =>4506 expect([1, 2, 3].map((index) => varsAt(index)["pullRequestId"])).toEqual([
4480 (JSON.parse(callAt(index).stdin ?? "") as { variables: { pullRequestId: string } })4507 "PR_24",
4481 .variables.pullRequestId;4508 "PR_24",
4482 expect([idSentAt(1), idSentAt(2), idSentAt(3)]).toEqual(["PR_24", "PR_24", "PR_24"]);4509 "PR_24",
4510 ]);
4483 }),4511 }),
4484 );4512 );
44854513
...@@ -4489,8 +4517,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -4489,8 +4517,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
4489 .mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}')))4517 .mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}')))
4490 .mockReturnValueOnce(4518 .mockReturnValueOnce(
4491 Effect.succeed(4519 Effect.succeed(
4492 // @effect-diagnostics-next-line preferSchemaOverJson:off4520 output(encodeJson({ data: { repository: { pullRequest: { id: "PR_25" } } } })),
4493 output(JSON.stringify({ data: { repository: { pullRequest: { id: "PR_25" } } } })),
4494 ),4521 ),
4495 )4522 )
4496 .mockReturnValueOnce(Effect.succeed(output("{}")));4523 .mockReturnValueOnce(Effect.succeed(output("{}")));
...@@ -4510,10 +4537,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -4510,10 +4537,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
4510 yield* write();4537 yield* write();
45114538
4512 assert.strictEqual(mockedExecute.mock.calls.length, 3);4539 assert.strictEqual(mockedExecute.mock.calls.length, 3);
4513 const idSentAt = (index: number) =>4540 expect(varsAt(2)["pullRequestId"]).toEqual("PR_25");
4514 (JSON.parse(callAt(index).stdin ?? "") as { variables: { pullRequestId: string } })
4515 .variables.pullRequestId;
4516 expect(idSentAt(2)).toEqual("PR_25");
4517 }),4541 }),
4518 );4542 );
4519 it.effect("keeps the pull request being ticked through, not the one looked up first", () =>4543 it.effect("keeps the pull request being ticked through, not the one looked up first", () =>
...@@ -4525,9 +4549,10 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -4525,9 +4549,10 @@ layer("GitHubPullRequestCli.layer", (it) => {
4525 const HOT = 9_000;4549 const HOT = 9_000;
4526 const lookupsOf = new Map<number, number>();4550 const lookupsOf = new Map<number, number>();
4527 mockedExecute.mockImplementation((input) => {4551 mockedExecute.mockImplementation((input) => {
4528 const asked = input.args.find((arg) => arg.startsWith("number="));4552 if (input.kind !== "graphql" || !input.query.includes(NODE_ID_QUERY)) {
4529 if (asked === undefined) return Effect.succeed(output("{}"));4553 return Effect.succeed(output("{}"));
4530 const number = Number(asked.slice("number=".length));4554 }
4555 const number = Number(input.variables?.["number"]);
4531 lookupsOf.set(number, (lookupsOf.get(number) ?? 0) + 1);4556 lookupsOf.set(number, (lookupsOf.get(number) ?? 0) + 1);
4532 return Effect.succeed(4557 return Effect.succeed(
4533 output(encodeJson({ data: { repository: { pullRequest: { id: `PR_${number}` } } } })),4558 output(encodeJson({ data: { repository: { pullRequest: { id: `PR_${number}` } } } })),
apps/server/src/pullRequest/GitHubPullRequestCli.ts+814-936
...@@ -1,21 +1,19 @@...@@ -1,21 +1,19 @@
1import { removeAgentCredits } from "./mergeMessage.ts";1import { removeAgentCredits } from "./mergeMessage.ts";
2import { makeChecksRevalidator } from "./gitHubConditionalChecks.ts";2import { KnownWorkflowRuns, makeChecksRevalidator } from "./gitHubConditionalChecks.ts";
3import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts";3import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts";
4import * as Cause from "effect/Cause";4import * as Cause from "effect/Cause";
5import * as Context from "effect/Context";5import * as Context from "effect/Context";
6import * as Clock from "effect/Clock";6import * as Clock from "effect/Clock";
7import * as Crypto from "effect/Crypto";7import * as Duration from "effect/Duration";
8import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";9import * as Layer from "effect/Layer";
10import * as Exit from "effect/Exit";10import * as Exit from "effect/Exit";
11import * as Option from "effect/Option";11import * as Option from "effect/Option";
12import * as Redacted from "effect/Redacted";
13import * as Request from "effect/Request";12import * as Request from "effect/Request";
14import * as RequestResolver from "effect/RequestResolver";13import * as RequestResolver from "effect/RequestResolver";
15import * as Result from "effect/Result";14import * as Result from "effect/Result";
16import * as Schema from "effect/Schema";15import * as Schema from "effect/Schema";
17import * as Semaphore from "effect/Semaphore";16import * as Semaphore from "effect/Semaphore";
18import * as Hex from "effect/encoding/Hex";
19import {17import {
20 resolvePullRequestAuthorFilter,18 resolvePullRequestAuthorFilter,
21 PositiveInt,19 PositiveInt,
...@@ -23,6 +21,8 @@ import {...@@ -23,6 +21,8 @@ import {
23 type PullRequestAction,21 type PullRequestAction,
24 type PullRequestStackHead,22 type PullRequestStackHead,
25 type PullRequestActor,23 type PullRequestActor,
24 type PullRequestComment,
25 type PullRequestCommit,
26 type PullRequestFileViewed,26 type PullRequestFileViewed,
27 type PullRequestInvolvement,27 type PullRequestInvolvement,
28 type PullRequestListFilters,28 type PullRequestListFilters,
...@@ -41,19 +41,28 @@ import {...@@ -41,19 +41,28 @@ import {
41 type PullRequestPreview,41 type PullRequestPreview,
42} from "@t3tools/contracts";42} from "@t3tools/contracts";
4343
44import * as GitHubCli from "../sourceControl/GitHubCli.ts";44import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts";
45import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts";45import * as GitHubApi from "../sourceControl/GitHubApi.ts";
46import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts";46import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts";
47import {47import {
48 ACTOR_AVATARS_GRAPHQL_QUERY,48 ACTOR_AVATARS_GRAPHQL_QUERY,
49 ADD_REACTION_GRAPHQL_MUTATION,49 ADD_REACTION_GRAPHQL_MUTATION,
50 buildReviewSubmissionJson,50 buildReviewSubmission,
51 buildReviewerRequestJson,51 buildReviewerRequest,
52 buildSetFilesViewedGraphQlMutation,52 buildSetFilesViewedGraphQlMutation,
53 decodeActorAvatarsJson,53 decodeActorAvatarsJson,
54 decodePullRequestActivityJson,54 decodePullRequestActivityJson,
55 decodePullRequestDetailJson,55 decodePullRequestCheckContextsJson,
56 decodePullRequestCoreJson,56 decodePullRequestCoreJson,
57 decodeCommitFilesJson,
58 pullRequestCheckContextsGraphQlQuery,
59 pullRequestChecksFromContexts,
60 pullRequestListGraphQlQuery,
61 pullRequestSummaryGraphQlQuery,
62 PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY,
63 PULL_REQUEST_HEADS_GRAPHQL_QUERY,
64 decodeWorkflowRunsJson,
65 type GitHubCheckContext,
57 pullRequestCoreGraphQlQuery,66 pullRequestCoreGraphQlQuery,
58 type GitHubPullRequestCore,67 type GitHubPullRequestCore,
59 type GitHubPullRequestSummary,68 type GitHubPullRequestSummary,
...@@ -73,7 +82,7 @@ import {...@@ -73,7 +82,7 @@ import {
73 decodeReactionSubjectScopeJson,82 decodeReactionSubjectScopeJson,
74 decodeReviewerCandidatesJson,83 decodeReviewerCandidatesJson,
75 decodeLabelCandidatesJson,84 decodeLabelCandidatesJson,
76 buildLabelRequestJson,85 buildLabelRequest,
77 LABEL_CANDIDATES_GRAPHQL_QUERY,86 LABEL_CANDIDATES_GRAPHQL_QUERY,
78 decodeReviewDismissalsJson,87 decodeReviewDismissalsJson,
79 decodeReviewThreadCommentsJson,88 decodeReviewThreadCommentsJson,
...@@ -83,14 +92,10 @@ import {...@@ -83,14 +92,10 @@ import {
83 buildPullRequestWatchFingerprintsGraphQlQuery,92 buildPullRequestWatchFingerprintsGraphQlQuery,
84 buildPullRequestStackMembershipsGraphQlQuery,93 buildPullRequestStackMembershipsGraphQlQuery,
85 decodePullRequestStackMembershipsJson,94 decodePullRequestStackMembershipsJson,
86 encodeGraphQlRequestJson,
87 pullRequestSearchGraphQlQuery,95 pullRequestSearchGraphQlQuery,
88 PULL_REQUEST_SEARCH_MAX_ROWS,96 PULL_REQUEST_SEARCH_MAX_ROWS,
89 PULL_REQUEST_ACTIVITY_JSON_FIELDS,
90 BASE_COMPARISON_GRAPHQL_QUERY,97 BASE_COMPARISON_GRAPHQL_QUERY,
91 decodeBaseComparisonJson,98 decodeBaseComparisonJson,
92 PULL_REQUEST_DETAIL_JSON_FIELDS,
93 PULL_REQUEST_LIST_JSON_FIELDS,
94 PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY,99 PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY,
95 PULL_REQUEST_NODE_ID_GRAPHQL_QUERY,100 PULL_REQUEST_NODE_ID_GRAPHQL_QUERY,
96 REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY,101 REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY,
...@@ -110,9 +115,10 @@ import {...@@ -110,9 +115,10 @@ import {
110 UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION,115 UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION,
111 VIEWER_PERMISSIONS_GRAPHQL_QUERY,116 VIEWER_PERMISSIONS_GRAPHQL_QUERY,
112 decodeViewerPermissionsJson,117 decodeViewerPermissionsJson,
113 decodeWorkflowRunApprovalsJson,
114 type GitHubBaseComparison,118 type GitHubBaseComparison,
115 type GitHubPullRequestActivity,119 type GitHubPullRequestActivity,
120 type GitHubPullRequestActivityPage,
121 type GitHubWorkflowRunPage,
116 type GitHubPullRequestHead,122 type GitHubPullRequestHead,
117 type GitHubPullRequestListItem,123 type GitHubPullRequestListItem,
118 type GitHubPullRequestSearchItem,124 type GitHubPullRequestSearchItem,
...@@ -139,12 +145,8 @@ export class GitHubPullRequestReadError extends Schema.TaggedError<GitHubPullReq...@@ -139,12 +145,8 @@ export class GitHubPullRequestReadError extends Schema.TaggedError<GitHubPullReq
139 cause: Schema.Defect(),145 cause: Schema.Defect(),
140 },146 },
141) {147) {
142 get detail(): string {
143 return `GitHub CLI returned an unreadable ${this.operation} response.`;
144 }
145
146 override get message(): string {148 override get message(): string {
147 return `GitHub CLI failed in ${this.operation}: ${this.detail}`;149 return `GitHub CLI returned an unreadable ${this.operation} response.`;
148 }150 }
149}151}
150152
...@@ -156,12 +158,8 @@ export class GitHubViewerLoginUnavailableError extends Schema.TaggedError<GitHub...@@ -156,12 +158,8 @@ export class GitHubViewerLoginUnavailableError extends Schema.TaggedError<GitHub
156 cwd: Schema.String,158 cwd: Schema.String,
157 },159 },
158) {160) {
159 get detail(): string {
160 return "GitHub CLI returned no login for the authenticated account.";
161 }
162
163 override get message(): string {161 override get message(): string {
164 return `GitHub CLI failed in getViewerLogin: ${this.detail}`;162 return "GitHub CLI returned no login for the authenticated account.";
165 }163 }
166}164}
167165
...@@ -175,12 +173,8 @@ export class GitHubPullRequestUpdatedAtUnavailableError extends Schema.TaggedErr...@@ -175,12 +173,8 @@ export class GitHubPullRequestUpdatedAtUnavailableError extends Schema.TaggedErr
175 number: Schema.Int,173 number: Schema.Int,
176 },174 },
177) {175) {
178 get detail(): string {
179 return `Pull request ${this.repository}#${this.number} reported no update time.`;
180 }
181
182 override get message(): string {176 override get message(): string {
183 return `GitHub CLI failed in getPullRequestSummary: ${this.detail}`;177 return `Pull request ${this.repository}#${this.number} reported no update time.`;
184 }178 }
185}179}
186180
...@@ -192,12 +186,8 @@ export class GitHubDiffCursorError extends Schema.TaggedError<GitHubDiffCursorEr...@@ -192,12 +186,8 @@ export class GitHubDiffCursorError extends Schema.TaggedError<GitHubDiffCursorEr
192 cwd: Schema.String,186 cwd: Schema.String,
193 },187 },
194) {188) {
195 get detail(): string {
196 return "The diff cursor was not one this pull request handed out.";
197 }
198
199 override get message(): string {189 override get message(): string {
200 return `GitHub CLI failed in getPullRequestDiff: ${this.detail}`;190 return "The diff cursor was not one this pull request handed out.";
201 }191 }
202}192}
203193
...@@ -209,12 +199,8 @@ export class GitHubDiffCommitError extends Schema.TaggedError<GitHubDiffCommitEr...@@ -209,12 +199,8 @@ export class GitHubDiffCommitError extends Schema.TaggedError<GitHubDiffCommitEr
209 cwd: Schema.String,199 cwd: Schema.String,
210 },200 },
211) {201) {
212 get detail(): string {
213 return "The named commit was not a commit sha.";
214 }
215
216 override get message(): string {202 override get message(): string {
217 return `GitHub CLI failed in getPullRequestDiff: ${this.detail}`;203 return "The named commit was not a commit sha.";
218 }204 }
219}205}
220206
...@@ -228,15 +214,11 @@ export class GitHubDiffRevisionsUnavailableError extends Schema.TaggedError<GitH...@@ -228,15 +214,11 @@ export class GitHubDiffRevisionsUnavailableError extends Schema.TaggedError<GitH
228 commit: Schema.optional(Schema.String),214 commit: Schema.optional(Schema.String),
229 },215 },
230) {216) {
231 get detail(): string {217 override get message(): string {
232 return this.commit === undefined218 return this.commit === undefined
233 ? `Pull request #${this.number} reported no usable base and head revisions.`219 ? `Pull request #${this.number} reported no usable base and head revisions.`
234 : `Commit ${this.commit} reported no usable revisions for this file.`;220 : `Commit ${this.commit} reported no usable revisions for this file.`;
235 }221 }
236
237 override get message(): string {
238 return `GitHub CLI failed in getPullRequestDiffFileContents: ${this.detail}`;
239 }
240}222}
241223
242/** A blob exists, but expanding it would be unsafe or would not produce text. */224/** A blob exists, but expanding it would be unsafe or would not produce text. */
...@@ -249,15 +231,11 @@ export class GitHubDiffFileContentsUnavailableError extends Schema.TaggedError<G...@@ -249,15 +231,11 @@ export class GitHubDiffFileContentsUnavailableError extends Schema.TaggedError<G
249 reason: Schema.Literals(["oversized", "binary"]),231 reason: Schema.Literals(["oversized", "binary"]),
250 },232 },
251) {233) {
252 get detail(): string {234 override get message(): string {
253 return this.reason === "oversized"235 return this.reason === "oversized"
254 ? `The diff file '${this.path}' exceeds the 1 MB expansion limit.`236 ? `The diff file '${this.path}' exceeds the 1 MB expansion limit.`
255 : `The diff file '${this.path}' is binary.`;237 : `The diff file '${this.path}' is binary.`;
256 }238 }
257
258 override get message(): string {
259 return `GitHub CLI failed in getPullRequestDiffFileContents: ${this.detail}`;
260 }
261}239}
262240
263/**241/**
...@@ -274,12 +252,8 @@ export class GitHubRepositorySelectorError extends Schema.TaggedError<GitHubRepo...@@ -274,12 +252,8 @@ export class GitHubRepositorySelectorError extends Schema.TaggedError<GitHubRepo
274 operation: Schema.String,252 operation: Schema.String,
275 },253 },
276) {254) {
277 get detail(): string {
278 return "A repository was named that GitHub cannot address.";
279 }
280
281 override get message(): string {255 override get message(): string {
282 return `GitHub CLI failed in ${this.operation}: ${this.detail}`;256 return "A repository was named that GitHub cannot address.";
283 }257 }
284}258}
285259
...@@ -292,12 +266,8 @@ export class GitHubSubjectScopeError extends Schema.TaggedError<GitHubSubjectSco...@@ -292,12 +266,8 @@ export class GitHubSubjectScopeError extends Schema.TaggedError<GitHubSubjectSco
292 operation: Schema.String,266 operation: Schema.String,
293 },267 },
294) {268) {
295 get detail(): string {
296 return "The named subject did not belong to the named pull request.";
297 }
298
299 override get message(): string {269 override get message(): string {
300 return `GitHub CLI failed in ${this.operation}: ${this.detail}`;270 return "The named subject did not belong to the named pull request.";
301 }271 }
302}272}
303273
...@@ -313,7 +283,7 @@ export class GitHubWorkflowApprovalRefusedError extends Schema.TaggedError<GitHu...@@ -313,7 +283,7 @@ export class GitHubWorkflowApprovalRefusedError extends Schema.TaggedError<GitHu
313 limit: Schema.Int,283 limit: Schema.Int,
314 },284 },
315) {285) {
316 get detail(): string {286 override get message(): string {
317 if (this.reason === "head-list-truncated") {287 if (this.reason === "head-list-truncated") {
318 return `GitHub returned more than ${this.limit} pull requests for this head branch.`;288 return `GitHub returned more than ${this.limit} pull requests for this head branch.`;
319 }289 }
...@@ -322,10 +292,6 @@ export class GitHubWorkflowApprovalRefusedError extends Schema.TaggedError<GitHu...@@ -322,10 +292,6 @@ export class GitHubWorkflowApprovalRefusedError extends Schema.TaggedError<GitHu
322 }292 }
323 return `GitHub returned more than ${this.limit} workflow runs awaiting approval.`;293 return `GitHub returned more than ${this.limit} workflow runs awaiting approval.`;
324 }294 }
325
326 override get message(): string {
327 return `GitHub CLI refused listWorkflowRunsRequiringApproval: ${this.detail}`;
328 }
329}295}
330296
331/** GitHub omitted the immutable head identity needed to scope an approval safely. */297/** GitHub omitted the immutable head identity needed to scope an approval safely. */
...@@ -337,12 +303,8 @@ export class GitHubWorkflowApprovalHeadUnavailableError extends Schema.TaggedErr...@@ -337,12 +303,8 @@ export class GitHubWorkflowApprovalHeadUnavailableError extends Schema.TaggedErr
337 number: Schema.Int,303 number: Schema.Int,
338 },304 },
339) {305) {
340 get detail(): string {
341 return `GitHub did not report a complete head revision for #${this.number}.`;
342 }
343
344 override get message(): string {306 override get message(): string {
345 return `GitHub CLI refused approve-workflows: ${this.detail}`;307 return `GitHub did not report a complete head revision for #${this.number}.`;
346 }308 }
347}309}
348310
...@@ -355,18 +317,14 @@ export class GitHubWorkflowApprovalHeadChangedError extends Schema.TaggedError<G...@@ -355,18 +317,14 @@ export class GitHubWorkflowApprovalHeadChangedError extends Schema.TaggedError<G
355 number: Schema.Int,317 number: Schema.Int,
356 },318 },
357) {319) {
358 get detail(): string {
359 return `The head revision of #${this.number} changed before its workflows could be approved.`;
360 }
361
362 override get message(): string {320 override get message(): string {
363 return `GitHub CLI refused approve-workflows: ${this.detail}`;321 return `The head revision of #${this.number} changed before its workflows could be approved.`;
364 }322 }
365}323}
366324
367export type GitHubPullRequestCliError =325export type GitHubPullRequestCliError =
368 | GitHubStackActionError326 | GitHubStackActionError
369 | GitHubCli.GitHubCliError327 | GitHubApi.GitHubApiError
370 | GitHubPullRequestReadError328 | GitHubPullRequestReadError
371 | GitHubDiffCursorError329 | GitHubDiffCursorError
372 | GitHubDiffCommitError330 | GitHubDiffCommitError
...@@ -383,10 +341,26 @@ export type GitHubPullRequestCliError =...@@ -383,10 +341,26 @@ export type GitHubPullRequestCliError =
383341
384/** A large pull request can produce a multi-megabyte patch; past this it is truncated. */342/** A large pull request can produce a multi-megabyte patch; past this it is truncated. */
385const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;343const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
386const DIFF_TIMEOUT_MS = 60_000;344const DIFF_TIMEOUT = Duration.seconds(60);
387/** Pierre expansion is for source files, not blobs large enough to stall a review surface. */345/** Pierre expansion is for source files, not blobs large enough to stall a review surface. */
388const DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024;346const 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
390/** A search-free fallback may scan older rows for local filters, but never the whole repository. */364/** A search-free fallback may scan older rows for local filters, but never the whole repository. */
391const PULL_REQUEST_FALLBACK_MAX_ROWS = 1_000;365const PULL_REQUEST_FALLBACK_MAX_ROWS = 1_000;
392366
...@@ -413,6 +387,9 @@ export const NODE_ID_CACHE_CAPACITY = 128;...@@ -413,6 +387,9 @@ export const NODE_ID_CACHE_CAPACITY = 128;
413 */387 */
414const REVIEW_THREAD_PAGES = 10;388const 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
416export interface GitHubPullRequestListBatch {393export interface GitHubPullRequestListBatch {
417 readonly items: ReadonlyArray<GitHubPullRequestListItem>;394 readonly items: ReadonlyArray<GitHubPullRequestListItem>;
418 readonly truncated: boolean;395 readonly truncated: boolean;
...@@ -960,50 +937,6 @@ function matchesFilters(...@@ -960,50 +937,6 @@ function matchesFilters(
960 );937 );
961}938}
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
1007/** The search-free fallback is wider than the request, so narrow its decoded rows locally. */940/** The search-free fallback is wider than the request, so narrow its decoded rows locally. */
1008function matchesUnsortedListing(941function matchesUnsortedListing(
1009 item: GitHubPullRequestListItem,942 item: GitHubPullRequestListItem,
...@@ -1029,13 +962,11 @@ function matchesUnsortedListing(...@@ -1029,13 +962,11 @@ function matchesUnsortedListing(
1029const SEARCH_REPOSITORY = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;962const SEARCH_REPOSITORY = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
1030963
1031/**964/**
1032 * The same listing as one GitHub search across several repositories, which is the only way to965 * A listing as one GitHub search, across one repository or several: the only way to read a
1033 * read a whole host in one request.966 * whole host in one request, and the only order (`updated`) a continuation can carry on from.
1034 *967 *
1035 * Every narrowing `involvementArgs` hands to `gh pr list` as a flag is a qualifier here instead,968 * `--state closed` in GitHub's own list includes merged pull requests, so the Closed tab is
1036 * because a search has no flags to borrow: `--author X` is `author:X`, `--state open` is969 * `is:closed is:unmerged` here.
1037 * `is:open`, and `--state closed` — which includes merged pull requests — is `is:closed
1038 * is:unmerged`. The two belong together; a tab added to one wants adding to the other.
1039 *970 *
1040 * Null where a repository is not `owner/name`. A name is written into the query as itself, and a971 * Null where a repository is not `owner/name`. A name is written into the query as itself, and a
1041 * name holding a space could otherwise end the `repo:` qualifier and start a qualifier of its972 * name holding a space could otherwise end the `repo:` qualifier and start a qualifier of its
...@@ -1073,15 +1004,6 @@ function searchQuery(input: {...@@ -1073,15 +1004,6 @@ function searchQuery(input: {
1073 ].join(" ");1004 ].join(" ");
1074}1005}
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
1085const MERGE_MESSAGE_GRAPHQL_QUERY = `1007const MERGE_MESSAGE_GRAPHQL_QUERY = `
1086query PullRequestMergeMessage($owner: String!, $name: String!, $number: Int!, $method: PullRequestMergeMethod!) {1008query PullRequestMergeMessage($owner: String!, $name: String!, $number: Int!, $method: PullRequestMergeMethod!) {
1087 repository(owner: $owner, name: $name) {1009 repository(owner: $owner, name: $name) {
...@@ -1093,6 +1015,18 @@ query PullRequestMergeMessage($owner: String!, $name: String!, $number: Int!, $m...@@ -1093,6 +1015,18 @@ query PullRequestMergeMessage($owner: String!, $name: String!, $number: Int!, $m
1093 }1015 }
1094}`;1016}`;
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
1096const decodeMergeMessageResponse = Schema.decodeUnknownResult(1030const decodeMergeMessageResponse = Schema.decodeUnknownResult(
1097 Schema.fromJsonString(1031 Schema.fromJsonString(
1098 Schema.Struct({1032 Schema.Struct({
...@@ -1111,45 +1045,93 @@ const decodeMergeMessageResponse = Schema.decodeUnknownResult(...@@ -1111,45 +1045,93 @@ const decodeMergeMessageResponse = Schema.decodeUnknownResult(
1111const decodeMergeMessage = (raw: string) =>1045const decodeMergeMessage = (raw: string) =>
1112 Result.map(decodeMergeMessageResponse(raw), (response) => response.data.repository.pullRequest);1046 Result.map(decodeMergeMessageResponse(raw), (response) => response.data.repository.pullRequest);
11131047
1114function actionArgs(1048/** What `gh pr merge` and `gh pr update-branch` read before they act. */
1115 action: PullRequestAction,1049const ACTION_STATE_GRAPHQL_QUERY = `
1116 mergeMethod: PullRequestMergeMethod | undefined,1050query PullRequestActionState($owner: String!, $name: String!, $number: Int!, $headRef: String!) {
1117 updateMethod: PullRequestUpdateMethod | undefined,1051 repository(owner: $owner, name: $name) {
1118): ReadonlyArray<string> {1052 pullRequest(number: $number) {
1119 switch (action) {1053 id
1120 case "merge":1054 headRefOid
1121 return ["merge", `--${mergeMethod ?? "merge"}`];1055 isMergeQueueEnabled
1122 // `--auto` arms the same command instead of running it, and still needs the strategy: GitHub1056 mergeStateStatus
1123 // stores the strategy with the standing instruction rather than choosing one at merge time.1057 baseRef { compare(headRef: $headRef) { behindBy } }
1124 case "enable-auto-merge":1058 }
1125 return ["merge", "--auto", `--${mergeMethod ?? "merge"}`];
1126 case "disable-auto-merge":
1127 return ["merge", "--disable-auto"];
1128 // `gh` updates with a merge commit unless asked to rebase, which is GitHub's own default.
1129 case "update-branch":
1130 return ["update-branch", ...(updateMethod === "rebase" ? ["--rebase"] : [])];
1131 case "ready":
1132 return ["ready"];
1133 case "draft":
1134 return ["ready", "--undo"];
1135 case "close":
1136 return ["close"];
1137 case "reopen":
1138 return ["reopen"];
1139 case "revert":
1140 throw new Error("Revert requires a GraphQL mutation");
1141 // Handled separately because it may approve several workflow runs rather than mutate the
1142 // pull request itself.
1143 case "approve-workflows":
1144 throw new Error("Workflow approval requires run discovery");
1145 }1059 }
1146}1060}`;
1061
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
1148/** @public Service construction is part of the canonical Effect module API. */1132/** @public Service construction is part of the canonical Effect module API. */
1149export const make = Effect.gen(function* () {1133export const make = Effect.gen(function* () {
1150 const github = yield* GitHubCli.GitHubCli;1134 const api = yield* GitHubApi.GitHubApi;
1151 const graphQlBudget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;
1152 const crypto = yield* Crypto.Crypto;
1153 const revalidateChecks = yield* makeChecksRevalidator;1135 const revalidateChecks = yield* makeChecksRevalidator;
1154 const routingIdentities = new Map<1136 const routingIdentities = new Map<
1155 string,1137 string,
...@@ -1172,25 +1154,9 @@ export const make = Effect.gen(function* () {...@@ -1172,25 +1154,9 @@ export const make = Effect.gen(function* () {
1172 const unavailable = () =>1154 const unavailable = () =>
1173 new GitHubViewerLoginUnavailableError({ command: "gh", cwd: input.cwd });1155 new GitHubViewerLoginUnavailableError({ command: "gh", cwd: input.cwd });
1174 const host = input.host.toLowerCase();1156 const host = input.host.toLowerCase();
1175 const pinned = yield* GitHubCli.PinnedGitHubCredential;1157 // A missing or signed-out credential keeps its own error, so the page can say which.
1176 if (pinned !== null && pinned.host !== host) return yield* unavailable();1158 const { token, fingerprint: key } = yield* api.credential(host);
1177 // Only the digest is retained. Never attach credential lookup output to an error.1159 const credential = { host, token, credentialFingerprint: key };
1178 const token =
1179 pinned !== null
1180 ? Redacted.value(pinned.token)
1181 : (yield* github
1182 .execute({
1183 cwd: input.cwd,
1184 args: ["auth", "token", "--hostname", host],
1185 env: { GH_DEBUG: "" },
1186 })
1187 .pipe(Effect.mapError(unavailable))).stdout.trim();
1188 if (!token) return yield* unavailable();
1189 const tokenHash = yield* crypto
1190 .digest("SHA-256", new TextEncoder().encode(token))
1191 .pipe(Effect.map(Hex.encode), Effect.orDie);
1192 const key = `${host}:${tokenHash}`;
1193 const credential = { host, token: Redacted.make(token), credentialFingerprint: key };
1194 // A cold page may ask several times. Wait per credential and check again after the1160 // A cold page may ask several times. Wait per credential and check again after the
1195 // first verification; cancellation releases the next waiter without losing its request.1161 // first verification; cancellation releases the next waiter without losing its request.
1196 return yield* Effect.acquireUseRelease(1162 return yield* Effect.acquireUseRelease(
...@@ -1208,21 +1174,13 @@ export const make = Effect.gen(function* () {...@@ -1208,21 +1174,13 @@ export const make = Effect.gen(function* () {
1208 if (cached !== undefined && now - cached.at < 10 * 60_000)1174 if (cached !== undefined && now - cached.at < 10 * 60_000)
1209 return { ...credential, ...cached.value };1175 return { ...credential, ...cached.value };
1210 // Pin this read so an auth switch cannot poison its cache entry.1176 // Pin this read so an auth switch cannot poison its cache entry.
1211 const response = yield* github1177 const response = yield* api
1212 .execute({1178 .rest({ host, operation: "getRoutingIdentity", path: "user", allowReserve: true })
1213 cwd: input.cwd,1179 .pipe(
1214 args: ["api", "user", "--hostname", host],1180 Effect.provideService(GitHubApi.PinnedGitHubCredential, credential),
1215 env: {1181 Effect.provideService(SourceControlRateLimit.CredentialScope, key),
1216 GH_HOST: host,1182 );
1217 GH_TOKEN: token,1183 const identity = yield* decodeRoutingIdentity(response.body).pipe(
1218 GITHUB_TOKEN: token,
1219 GH_ENTERPRISE_TOKEN: token,
1220 GITHUB_ENTERPRISE_TOKEN: token,
1221 GH_DEBUG: "",
1222 },
1223 })
1224 .pipe(Effect.mapError(unavailable));
1225 const identity = yield* decodeRoutingIdentity(response.stdout).pipe(
1226 Effect.mapError(unavailable),1184 Effect.mapError(unavailable),
1227 );1185 );
1228 const value = { accountId: String(identity.id), viewer: identity.login };1186 const value = { accountId: String(identity.id), viewer: identity.login };
...@@ -1248,7 +1206,7 @@ export const make = Effect.gen(function* () {...@@ -1248,7 +1206,7 @@ export const make = Effect.gen(function* () {
1248 Effect.flatMap(({ host, token, accountId, viewer, credentialFingerprint }) =>1206 Effect.flatMap(({ host, token, accountId, viewer, credentialFingerprint }) =>
1249 use({ accountId, viewer, credentialFingerprint }).pipe(1207 use({ accountId, viewer, credentialFingerprint }).pipe(
1250 Effect.provideService(SourceControlRateLimit.CredentialScope, credentialFingerprint),1208 Effect.provideService(SourceControlRateLimit.CredentialScope, credentialFingerprint),
1251 Effect.provideService(GitHubCli.PinnedGitHubCredential, {1209 Effect.provideService(GitHubApi.PinnedGitHubCredential, {
1252 host,1210 host,
1253 token,1211 token,
1254 credentialFingerprint,1212 credentialFingerprint,
...@@ -1293,11 +1251,7 @@ export const make = Effect.gen(function* () {...@@ -1293,11 +1251,7 @@ export const make = Effect.gen(function* () {
1293 host: input.host,1251 host: input.host,
1294 operation: input.operation,1252 operation: input.operation,
1295 allowReserve: true,1253 allowReserve: true,
1296 variables: [1254 variables: { owner, name, number: input.number },
1297 ["-f", `owner=${owner}`],
1298 ["-f", `name=${name}`],
1299 ["-F", `number=${input.number}`],
1300 ],
1301 query: PULL_REQUEST_NODE_ID_GRAPHQL_QUERY,1255 query: PULL_REQUEST_NODE_ID_GRAPHQL_QUERY,
1302 decode: decodePullRequestNodeIdJson,1256 decode: decodePullRequestNodeIdJson,
1303 }).pipe(1257 }).pipe(
...@@ -1333,46 +1287,35 @@ export const make = Effect.gen(function* () {...@@ -1333,46 +1287,35 @@ export const make = Effect.gen(function* () {
1333 host: input.host,1287 host: input.host,
1334 operation: input.operation,1288 operation: input.operation,
1335 allowReserve: true,1289 allowReserve: true,
1336 variables: [1290 variables: { owner, name, number: input.number, subjectId: input.subjectId },
1337 ["-f", `owner=${owner}`],
1338 ["-f", `name=${name}`],
1339 ["-F", `number=${input.number}`],
1340 ["-f", `subjectId=${input.subjectId}`],
1341 ],
1342 query: REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY,1291 query: REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY,
1343 decode: decodeReactionSubjectScopeJson,1292 decode: decodeReactionSubjectScopeJson,
1344 });1293 });
1345 };1294 };
13461295
1347 // `gh` resolves a bare `owner/repo` against whichever host it defaults to, which is1296 /** A GraphQL mutation whose answer is not read back; a GraphQL error fails it. */
1348 // github.com. Naming the host makes a GitHub Enterprise repository resolve to its own
1349 // install rather than to a same-named repository on github.com.
1350 const repositoryArgs = (input: { readonly host: string; readonly repository: string }) => [
1351 "--repo",
1352 `${input.host}/${input.repository}`,
1353 ];
1354
1355 /**
1356 * A GraphQL mutation whose answer is not read back. `gh` exits non-zero on a GraphQL error,
1357 * so a failed mutation is already a failed command rather than a body to inspect.
1358 *
1359 * The query and its variables travel over stdin as one document: a variable can carry a
1360 * body the reader wrote, and argv is visible in process listings and echoed back inside
1361 * process-runner failure messages.
1362 */
1363 const graphql = (input: {1297 const graphql = (input: {
1364 readonly cwd: string;
1365 readonly host: string;1298 readonly host: string;
1299 readonly operation: string;
1366 readonly query: string;1300 readonly query: string;
1367 readonly variables: Readonly<Record<string, string>>;1301 readonly variables: Readonly<Record<string, unknown>>;
1368 }) =>1302 }) => api.graphql(input).pipe(Effect.asVoid);
1369 github1303
1370 .execute({1304 const readError = (cwd: string, operation: string, cause: unknown) =>
1371 cwd: input.cwd,1305 new GitHubPullRequestReadError({ command: "gh", cwd, operation, cause });
1372 args: ["api", "graphql", "--hostname", input.host, "--input", "-"],1306
1373 stdin: encodeGraphQlRequestJson({ query: input.query, variables: input.variables }),1307 /** Decodes an answer, reporting a failure against the read that made it. */
1374 })1308 const decodeWith = <A>(
1375 .pipe(Effect.asVoid);1309 cwd: string,
1310 operation: string,
1311 decode: (raw: string) => Result.Result<A, unknown>,
1312 raw: string,
1313 ): Effect.Effect<A, GitHubPullRequestReadError> => {
1314 const decoded = decode(raw.trim());
1315 return Result.isSuccess(decoded)
1316 ? Effect.succeed(decoded.success)
1317 : Effect.fail(readError(cwd, operation, decoded.failure));
1318 };
13761319
1377 /** A GraphQL read whose answer is decoded, reporting a failure against the read that made it. */1320 /** A GraphQL read whose answer is decoded, reporting a failure against the read that made it. */
1378 const graphqlRead = <A>(input: {1321 const graphqlRead = <A>(input: {
...@@ -1380,75 +1323,45 @@ export const make = Effect.gen(function* () {...@@ -1380,75 +1323,45 @@ export const make = Effect.gen(function* () {
1380 readonly host: string;1323 readonly host: string;
1381 readonly operation: string;1324 readonly operation: string;
1382 readonly allowReserve?: boolean | undefined;1325 readonly allowReserve?: boolean | undefined;
1383 /** Variables as `-f` flags, for values this module composed itself. */1326 readonly variables?: Readonly<Record<string, unknown>>;
1384 readonly variables?: ReadonlyArray<readonly [string, string]>;
1385 /**
1386 * Variables carrying words the reader typed. Document and variables travel over stdin
1387 * together, because argv is visible in process listings and is echoed back inside a
1388 * process-runner failure message.
1389 */
1390 readonly privateVariables?: Readonly<Record<string, string>>;
1391 readonly query: string;1327 readonly query: string;
1392 readonly decode: (raw: string) => Result.Result<A, unknown>;1328 readonly decode: (raw: string) => Result.Result<A, unknown>;
1393 }): Effect.Effect<A, GitHubPullRequestCliError> => {1329 }): Effect.Effect<A, GitHubPullRequestCliError> =>
1394 return graphQlBudget1330 api
1395 .query(1331 .graphql({
1396 input.host,1332 host: input.host,
1397 input.query,1333 operation: input.operation,
1398 input.allowReserve === true ? { allowReserve: true } : undefined,1334 query: input.query,
1399 )1335 ...(input.variables === undefined ? {} : { variables: input.variables }),
1336 ...(input.allowReserve === true ? { allowReserve: true } : {}),
1337 })
1338 .pipe(Effect.flatMap((raw) => decodeWith(input.cwd, input.operation, input.decode, raw)));
1339
1340 /** A REST read whose JSON answer is decoded the same way. */
1341 const restRead = <A>(input: {
1342 readonly cwd: string;
1343 readonly host: string;
1344 readonly operation: string;
1345 readonly path: string;
1346 readonly decode: (raw: string) => Result.Result<A, unknown>;
1347 }): Effect.Effect<A, GitHubPullRequestCliError> =>
1348 api
1349 .rest({ host: input.host, operation: input.operation, path: input.path })
1400 .pipe(1350 .pipe(
1401 Effect.flatMap((query) =>1351 Effect.flatMap((response) =>
1402 github.execute(1352 decodeWith(input.cwd, input.operation, input.decode, response.body),
1403 input.privateVariables === undefined
1404 ? {
1405 cwd: input.cwd,
1406 args: [
1407 "api",
1408 "graphql",
1409 "--hostname",
1410 input.host,
1411 ...(input.variables ?? []).flat(),
1412 "-f",
1413 `query=${query}`,
1414 ],
1415 }
1416 : {
1417 cwd: input.cwd,
1418 args: ["api", "graphql", "--hostname", input.host, "--input", "-"],
1419 stdin: encodeGraphQlRequestJson({
1420 query,
1421 variables: input.privateVariables,
1422 }),
1423 },
1424 ),
1425 ),1353 ),
1426 Effect.tap((result) => graphQlBudget.observe(input.host, result.stdout)),
1427 Effect.flatMap((result) => {
1428 const decoded = input.decode(result.stdout.trim());
1429 return Result.isSuccess(decoded)
1430 ? Effect.succeed(decoded.success)
1431 : Effect.fail(
1432 new GitHubPullRequestReadError({
1433 command: "gh",
1434 cwd: input.cwd,
1435 operation: input.operation,
1436 cause: decoded.failure,
1437 }),
1438 );
1439 }),
1440 );1354 );
1441 };
14421355
1443 /**1356 /**
1444 * One page of the patch, read from the files API. GitHub refuses `pr diff` outright past 3001357 * One page of the patch, read from the files API. GitHub refuses a whole diff past 300 changed
1445 * changed files, and still serves those files' hunks here.1358 * files, and still serves those files' hunks here.
1446 *1359 *
1447 * A page is a whole number of files, so each one parses on its own; the caller carries on from1360 * A page is a whole number of files, so each one parses on its own; the caller carries on from
1448 * `nextCursor` for as long as GitHub keeps handing pages back.1361 * `nextCursor` for as long as GitHub keeps handing pages back.
1449 *1362 *
1450 * A named commit is read from the commit endpoint, which lists the same file entries and pages1363 * A named commit is read from the commit endpoint, which lists the same file entries and pages
1451 * them the same way — only wrapped in an object, which jq unwraps before they are decoded.1364 * them the same way, only wrapped in an object.
1452 */1365 */
1453 const diffFilesPage = (input: {1366 const diffFilesPage = (input: {
1454 readonly cwd: string;1367 readonly cwd: string;
...@@ -1460,61 +1373,51 @@ export const make = Effect.gen(function* () {...@@ -1460,61 +1373,51 @@ export const make = Effect.gen(function* () {
1460 }): Effect.Effect<GitHubPullRequestDiffSlice, GitHubPullRequestCliError> => {1373 }): Effect.Effect<GitHubPullRequestDiffSlice, GitHubPullRequestCliError> => {
1461 const { owner, name } = parseRepositorySelector(input.repository);1374 const { owner, name } = parseRepositorySelector(input.repository);
1462 const paging = `per_page=${DIFF_FILES_PAGE_SIZE}&page=${input.page}`;1375 const paging = `per_page=${DIFF_FILES_PAGE_SIZE}&page=${input.page}`;
1463 return github1376 return api
1464 .execute({1377 .rest({
1465 cwd: input.cwd,1378 host: input.host,
1466 args: [1379 operation: "getPullRequestDiff",
1467 "api",1380 path:
1468 "--hostname",
1469 input.host,
1470 input.commit === undefined1381 input.commit === undefined
1471 ? `repos/${owner}/${name}/pulls/${input.number}/files?${paging}`1382 ? `repos/${owner}/${name}/pulls/${input.number}/files?${paging}`
1472 : `repos/${owner}/${name}/commits/${input.commit}?${paging}`,1383 : `repos/${owner}/${name}/commits/${input.commit}?${paging}`,
1473 // An empty commit carries no `files` at all, which is a commit with nothing in it1384 maxResponseBytes: DIFF_MAX_OUTPUT_BYTES,
1474 // rather than an answer that could not be read.1385 timeout: DIFF_TIMEOUT,
1475 ...(input.commit === undefined ? [] : ["--jq", ".files // []"]),
1476 ],
1477 maxOutputBytes: DIFF_MAX_OUTPUT_BYTES,
1478 timeoutMs: DIFF_TIMEOUT_MS,
1479 })1386 })
1480 .pipe(1387 .pipe(
1481 Effect.flatMap((result) => {1388 Effect.flatMap((response) => {
1482 // Checked before decoding: a byte-truncated response is a JSON prefix, which would1389 // Checked before decoding: a byte-truncated response is a JSON prefix, which would
1483 // fail to parse. Nothing of this page can be shown, and an empty patch would render1390 // fail to parse. Nothing of this page can be shown, and an empty patch would render
1484 // as a change with no files rather than as the failure it is; slices already handed1391 // as a change with no files rather than as the failure it is; slices already handed
1485 // over stay with the reader either way.1392 // over stay with the reader either way.
1486 if (result.stdoutTruncated) {1393 if (response.truncated) {
1487 return Effect.fail(1394 return Effect.fail(
1488 new GitHubPullRequestReadError({1395 readError(
1489 command: "gh",1396 input.cwd,
1490 cwd: input.cwd,1397 "getPullRequestDiff",
1491 operation: "getPullRequestDiff",1398 new Error(`Page ${input.page} of the changed files was too large to read.`),
1492 cause: new Error(`Page ${input.page} of the changed files was too large to read.`),1399 ),
1493 }),
1494 );
1495 }
1496 const decoded = decodePullRequestFilesJson(result.stdout.trim());
1497 if (!Result.isSuccess(decoded)) {
1498 return Effect.fail(
1499 new GitHubPullRequestReadError({
1500 command: "gh",
1501 cwd: input.cwd,
1502 operation: "getPullRequestDiff",
1503 cause: decoded.failure,
1504 }),
1505 );1400 );
1506 }1401 }
1402 return decodeWith(
1403 input.cwd,
1404 "getPullRequestDiff",
1405 input.commit === undefined ? decodePullRequestFilesJson : decodeCommitFilesJson,
1406 response.body,
1407 );
1408 }),
1409 Effect.map((files) => {
1507 // Counted before decoding, so a page whose files all failed to decode still moves on1410 // Counted before decoding, so a page whose files all failed to decode still moves on
1508 // rather than pointing the reader back at the page it just read.1411 // rather than pointing the reader back at the page it just read.
1509 const morePages = decoded.success.rawCount >= DIFF_FILES_PAGE_SIZE;1412 const morePages = files.rawCount >= DIFF_FILES_PAGE_SIZE;
1510 return Effect.succeed({1413 return {
1511 patch: decoded.success.patch,1414 patch: files.patch,
1512 truncated: decoded.success.truncated,1415 truncated: files.truncated,
1513 nextCursor: morePages ? String(input.page + 1) : null,1416 nextCursor: morePages ? String(input.page + 1) : null,
1514 ...(decoded.success.omittedFileStats.length === 01417 ...(files.omittedFileStats.length === 0
1515 ? {}1418 ? {}
1516 : { omittedFileStats: decoded.success.omittedFileStats }),1419 : { omittedFileStats: files.omittedFileStats }),
1517 });1420 };
1518 }),1421 }),
1519 );1422 );
1520 };1423 };
...@@ -1526,33 +1429,32 @@ export const make = Effect.gen(function* () {...@@ -1526,33 +1429,32 @@ export const make = Effect.gen(function* () {
1526 return yield* new GitHubDiffCommitError({ command: "gh", cwd: input.cwd });1429 return yield* new GitHubDiffCommitError({ command: "gh", cwd: input.cwd });
1527 }1430 }
1528 const { owner, name } = parseRepositorySelector(input.repository);1431 const { owner, name } = parseRepositorySelector(input.repository);
1529 const refsResult = yield* github.execute({1432 const refsResponse = yield* api.rest({
1530 cwd: input.cwd,1433 host: input.host,
1531 args: [1434 operation: "getPullRequestDiffFileContents",
1532 "api",1435 path:
1533 "--hostname",
1534 input.host,
1535 input.commit === undefined1436 input.commit === undefined
1536 ? `repos/${owner}/${name}/pulls/${input.number}`1437 ? `repos/${owner}/${name}/pulls/${input.number}`
1537 : `repos/${owner}/${name}/commits/${input.commit}`,1438 : `repos/${owner}/${name}/commits/${input.commit}`,
1538 "--jq",1439 timeout: DIFF_TIMEOUT,
1539 input.commit === undefined
1540 ? "[.base.sha, .head.sha] | @tsv"
1541 : "[.parents[0].sha, .sha] | @tsv",
1542 ],
1543 maxOutputBytes: 1024,
1544 timeoutMs: DIFF_TIMEOUT_MS,
1545 });1440 });
1546 // Keep a leading tab: a root commit has no parent, and jq represents that absent old1441 const refs = decodeRevisionRefs(refsResponse.body);
1547 // revision as the empty field before the tab. Every file in it is new, so that is a1442 // A root commit has no parent, which is an absent old revision. Every file in it is new,
1548 // usable answer whenever the caller does not need the old side.1443 // so that is a usable answer whenever the caller does not need the old side.
1549 const [baseRef, headRef, ...extraRefs] = refsResult.stdout.trimEnd().split("\t");1444 const baseRef = Option.isSome(refs)
1445 ? input.commit === undefined
1446 ? refs.value.base?.sha
1447 : (refs.value.parents?.[0]?.sha ?? "")
1448 : undefined;
1449 const headRef = Option.isSome(refs)
1450 ? input.commit === undefined
1451 ? refs.value.head?.sha
1452 : refs.value.sha
1453 : undefined;
1550 const rootCommitNewFile =1454 const rootCommitNewFile =
1551 input.commit !== undefined && input.changeType === "new" && baseRef === "";1455 input.commit !== undefined && input.changeType === "new" && baseRef === "";
1552 if (1456 if (
1553 refsResult.stdoutTruncated ||1457 headRef === undefined ||
1554 !headRef ||
1555 extraRefs.length > 0 ||
1556 (!rootCommitNewFile && (baseRef === undefined || !isCommitSha(baseRef))) ||1458 (!rootCommitNewFile && (baseRef === undefined || !isCommitSha(baseRef))) ||
1557 !isCommitSha(headRef)1459 !isCommitSha(headRef)
1558 ) {1460 ) {
...@@ -1565,43 +1467,38 @@ export const make = Effect.gen(function* () {...@@ -1565,43 +1467,38 @@ export const make = Effect.gen(function* () {
1565 }1467 }
15661468
1567 const readFile = (revision: string, filePath: string) =>1469 const readFile = (revision: string, filePath: string) =>
1568 github1470 api
1569 .execute({1471 .rest({
1570 cwd: input.cwd,1472 host: input.host,
1571 args: [1473 operation: "getPullRequestDiffFileContents",
1572 "api",1474 accept: "application/vnd.github.raw+json",
1573 "--hostname",1475 path: `repos/${owner}/${name}/contents/${filePath
1574 input.host,1476 .split("/")
1575 "--header",1477 .map(encodeURIComponent)
1576 "Accept: application/vnd.github.raw+json",1478 .join("/")}?ref=${encodeURIComponent(revision)}`,
1577 `repos/${owner}/${name}/contents/${filePath1479 maxResponseBytes: DIFF_FILE_MAX_OUTPUT_BYTES,
1578 .split("/")1480 timeout: DIFF_TIMEOUT,
1579 .map(encodeURIComponent)
1580 .join("/")}?ref=${encodeURIComponent(revision)}`,
1581 ],
1582 maxOutputBytes: DIFF_FILE_MAX_OUTPUT_BYTES,
1583 timeoutMs: DIFF_TIMEOUT_MS,
1584 })1481 })
1585 .pipe(1482 .pipe(
1586 Effect.flatMap((result) =>1483 Effect.flatMap((response) =>
1587 result.stdoutTruncated ||1484 response.truncated || response.body.includes("\0") || response.invalidUtf8
1588 result.stdout.includes("\0") ||
1589 result.stdoutInvalidUtf8 === true
1590 ? Effect.fail(1485 ? Effect.fail(
1591 new GitHubDiffFileContentsUnavailableError({1486 new GitHubDiffFileContentsUnavailableError({
1592 command: "gh",1487 command: "gh",
1593 cwd: input.cwd,1488 cwd: input.cwd,
1594 path: filePath,1489 path: filePath,
1595 reason: result.stdoutTruncated ? "oversized" : "binary",1490 reason: response.truncated ? "oversized" : "binary",
1596 }),1491 }),
1597 )1492 )
1598 : Effect.succeed(result.stdout),1493 : Effect.succeed(response.body),
1599 ),1494 ),
1600 );1495 );
16011496
1602 const [oldContents, newContents] = yield* Effect.all(1497 const [oldContents, newContents] = yield* Effect.all(
1603 [1498 [
1604 input.changeType === "new" ? Effect.succeed("") : readFile(baseRef, input.oldPath),1499 input.changeType === "new"
1500 ? Effect.succeed("")
1501 : readFile(baseRef ?? "", input.oldPath),
1605 input.changeType === "deleted" ? Effect.succeed("") : readFile(headRef, input.newPath),1502 input.changeType === "deleted" ? Effect.succeed("") : readFile(headRef, input.newPath),
1606 ],1503 ],
1607 { concurrency: 2 },1504 { concurrency: 2 },
...@@ -1609,262 +1506,266 @@ export const make = Effect.gen(function* () {...@@ -1609,262 +1506,266 @@ export const make = Effect.gen(function* () {
1609 return { oldContents, newContents };1506 return { oldContents, newContents };
1610 });1507 });
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 = (
1613 input: Parameters<GitHubPullRequestCli["Service"]["getPullRequestDetail"]>[0],1515 input: Parameters<GitHubPullRequestCli["Service"]["getPullRequestDetail"]>[0],
1516 allowReserve: boolean,
1614 ) =>1517 ) =>
1615 github1518 Effect.gen(function* () {
1616 .execute({1519 const { owner, name } = parseRepositorySelector(input.repository);
1617 cwd: input.cwd,1520 const contexts: GitHubCheckContext[] = [];
1618 args: [1521 let headSha: string | null = null;
1619 "pr",1522 let after: string | null = null;
1620 "view",1523 for (let page = 0; page < CHECK_CONTEXT_PAGES; page++) {
1621 String(input.number),1524 const read: {
1622 ...repositoryArgs(input),1525 readonly headSha: string;
1623 "--json",1526 readonly contexts: ReadonlyArray<GitHubCheckContext>;
1624 PULL_REQUEST_DETAIL_JSON_FIELDS,1527 readonly nextCursor: string | null;
1625 ],1528 } = yield* graphqlRead({
1626 })1529 cwd: input.cwd,
1627 .pipe(1530 host: input.host,
1628 Effect.flatMap((result) => {1531 operation: "getPullRequestDetail",
1629 const decoded = decodePullRequestDetailJson(result.stdout.trim());1532 allowReserve,
1630 return Result.isSuccess(decoded)1533 variables: { owner, name, number: input.number, after },
1631 ? Effect.succeed(decoded.success)1534 query: pullRequestCheckContextsGraphQlQuery(input.host),
1632 : Effect.fail(1535 decode: decodePullRequestCheckContextsJson,
1633 new GitHubPullRequestReadError({1536 });
1634 command: "gh",1537 if (headSha !== null && read.headSha !== headSha) {
1635 cwd: input.cwd,1538 return yield* readError(
1636 operation: "getPullRequestDetail",1539 input.cwd,
1637 cause: decoded.failure,1540 "getPullRequestDetail",
1638 }),1541 new Error("Pull request head changed while reading checks."),
1639 );1542 );
1640 }),1543 }
1641 );1544 headSha = read.headSha;
1545 contexts.push(...read.contexts);
1546 after = read.nextCursor;
1547 if (after === null) break;
1548 }
1549 return { headSha, contexts, truncated: after !== null };
1550 });
16421551
1643 const getPullRequestDetail: GitHubPullRequestCli["Service"]["getPullRequestDetail"] = (input) => {1552 const getPullRequestDetail: GitHubPullRequestCli["Service"]["getPullRequestDetail"] = (input) => {
1644 const { owner, name } = parseRepositorySelector(input.repository);1553 const { owner, name } = parseRepositorySelector(input.repository);
1645 return GitHubCli.AllowGitHubReserve.pipe(1554 return AllowGitHubReserve.pipe(
1646 Effect.flatMap((allowReserve) =>1555 Effect.flatMap((allowReserve) =>
1647 graphqlRead({1556 graphqlRead({
1648 allowReserve,1557 allowReserve,
1649 cwd: input.cwd,1558 cwd: input.cwd,
1650 host: input.host,1559 host: input.host,
1651 operation: "getPullRequestDetail",1560 operation: "getPullRequestDetail",
1652 variables: [1561 variables: {
1653 ["-f", `owner=${owner}`],1562 owner,
1654 ["-f", `name=${name}`],1563 name,
1655 ["-F", `number=${input.number}`],1564 number: input.number,
1656 ["-f", `headRef=refs/pull/${input.number}/head`],1565 headRef: `refs/pull/${input.number}/head`,
1657 ],1566 },
1658 query: pullRequestCoreGraphQlQuery(input.host),1567 query: pullRequestCoreGraphQlQuery(input.host),
1659 decode: decodePullRequestCoreJson,1568 decode: decodePullRequestCoreJson,
1660 }),1569 }).pipe(
1661 ),1570 // Past a hundred checks the first page would let the rest imply success, so the whole
1662 // gh already pages check contexts. Keep its complete, deduplicated result for1571 // rollup is walked instead.
1663 // large check suites instead of letting the first 100 checks imply success.1572 Effect.filterOrElse(
1664 Effect.filterOrElse(1573 (core) => !core.checksTruncated,
1665 (core) => !core.checksTruncated,1574 (core) =>
1666 (core) =>1575 readAllCheckContexts(input, allowReserve).pipe(
1667 readLegacyDetail(input).pipe(1576 Effect.filterOrFail(
1668 Effect.filterOrFail(1577 (all) => all.headSha === core.headSha,
1669 (detail) => detail.headSha === core.headSha,1578 () =>
1670 () =>1579 readError(
1671 new GitHubPullRequestReadError({1580 input.cwd,
1672 command: "gh",1581 "getPullRequestDetail",
1673 cwd: input.cwd,1582 new Error("Pull request head changed while reading checks."),
1674 operation: "getPullRequestDetail",1583 ),
1675 cause: new Error("Pull request head changed while reading checks."),1584 ),
1676 }),1585 Effect.map((all) => ({
1677 ),1586 ...core,
1678 Effect.map((detail) => ({1587 ...pullRequestChecksFromContexts(all.contexts),
1679 ...core,1588 checksTruncated: all.truncated,
1680 checks: detail.checks,1589 })),
1681 checksState: detail.checksState,1590 ),
1682 checksTruncated: false,
1683 })),
1684 ),1591 ),
1592 ),
1685 ),1593 ),
1686 );1594 );
1687 };1595 };
16881596
1689 const workflowApprovalLimit = 1_000;1597 const workflowApprovalLimit = 1_000;
1690 const workflowApprovalProbeLimit = String(workflowApprovalLimit + 1);
1691 const workflowApprovalReadError = (cwd: string, cause: unknown) =>1598 const workflowApprovalReadError = (cwd: string, cause: unknown) =>
1692 new GitHubPullRequestReadError({1599 readError(cwd, "listWorkflowRunsRequiringApproval", cause);
1693 command: "gh",1600
1694 cwd,1601 /** Every open pull request whose head branch carries this name, up to one past the limit. */
1695 operation: "listWorkflowRunsRequiringApproval",1602 const listHeadsByBranch = (input: {
1696 cause,1603 readonly cwd: string;
1604 readonly repository: string;
1605 readonly host: string;
1606 readonly headBranch: string;
1607 }) =>
1608 Effect.gen(function* () {
1609 const { owner, name } = parseRepositorySelector(input.repository);
1610 const heads: GitHubPullRequestHead[] = [];
1611 let after: string | null = null;
1612 do {
1613 const page: {
1614 readonly heads: ReadonlyArray<GitHubPullRequestHead>;
1615 readonly nextCursor: string | null;
1616 } = yield* graphqlRead({
1617 cwd: input.cwd,
1618 host: input.host,
1619 operation: "listWorkflowRunsRequiringApproval",
1620 allowReserve: true,
1621 variables: { owner, name, head: input.headBranch, after },
1622 query: PULL_REQUEST_HEADS_GRAPHQL_QUERY,
1623 decode: decodePullRequestHeadsJson,
1624 });
1625 heads.push(...page.heads);
1626 after = page.nextCursor;
1627 } while (after !== null && heads.length <= workflowApprovalLimit);
1628 return heads;
1629 });
1630
1631 /**
1632 * The runs waiting on a maintainer for this exact head, up to one past the limit. `head_sha` is
1633 * what scopes them; `head_branch` is checked as well, the way `gh run list --branch` did.
1634 */
1635 const listActionRequiredRuns = (input: {
1636 readonly cwd: string;
1637 readonly repository: string;
1638 readonly host: string;
1639 readonly headSha: string;
1640 readonly headBranch: string;
1641 }) =>
1642 Effect.gen(function* () {
1643 // The checks revalidator may have just confirmed every run of this head as current.
1644 const known = yield* KnownWorkflowRuns;
1645 if (known !== null && known.headSha === input.headSha) {
1646 return known.runs.flatMap((run) =>
1647 // A run waiting on a maintainer reports `completed` with an `action_required`
1648 // conclusion; the `status=action_required` query matches either.
1649 (run.conclusion === "action_required" || run.status === "action_required") &&
1650 run.head_branch === input.headBranch
1651 ? [
1652 {
1653 id: run.id,
1654 name: run.name?.trim() || `Workflow run ${run.id}`,
1655 url: run.html_url?.trim() || null,
1656 },
1657 ]
1658 : [],
1659 );
1660 }
1661 const { owner, name } = parseRepositorySelector(input.repository);
1662 const runs: GitHubWorkflowRunApproval[] = [];
1663 for (let page = 1; runs.length <= workflowApprovalLimit; page++) {
1664 const read: GitHubWorkflowRunPage = yield* restRead({
1665 cwd: input.cwd,
1666 host: input.host,
1667 operation: "listWorkflowRunsRequiringApproval",
1668 path: `repos/${owner}/${name}/actions/runs?head_sha=${encodeURIComponent(input.headSha)}&branch=${encodeURIComponent(input.headBranch)}&event=pull_request&status=action_required&per_page=100&page=${page}`,
1669 decode: decodeWorkflowRunsJson,
1670 }).pipe(Effect.mapError((error) => workflowApprovalReadError(input.cwd, error)));
1671 runs.push(...read.runs);
1672 if (read.rawCount < 100) break;
1673 }
1674 return runs;
1697 });1675 });
1676
1698 const listWorkflowRunsRequiringApproval: GitHubPullRequestCli["Service"]["listWorkflowRunsRequiringApproval"] =1677 const listWorkflowRunsRequiringApproval: GitHubPullRequestCli["Service"]["listWorkflowRunsRequiringApproval"] =
1699 (input) =>1678 (input) =>
1700 Effect.all(1679 Effect.all(
1701 [1680 [
1702 github1681 listHeadsByBranch(input).pipe(
1703 .execute({1682 Effect.flatMap(
1704 cwd: input.cwd,1683 (
1705 args: [1684 heads,
1706 "pr",1685 ): Effect.Effect<
1707 "list",1686 GitHubPullRequestHead,
1708 ...repositoryArgs(input),1687 GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError
1709 "--state",1688 > => {
1710 "open",1689 const exactHeads = heads.filter(
1711 "--head",1690 (pullRequest) =>
1712 input.headBranch,1691 pullRequest.headSha === input.headSha &&
1713 "--limit",1692 pullRequest.isCrossRepository === true &&
1714 workflowApprovalProbeLimit,1693 pullRequest.headRepositoryOwner?.toLowerCase() ===
1715 "--json",1694 input.headRepositoryOwner.toLowerCase(),
1716 "number,headRefOid,isCrossRepository,headRepositoryOwner",1695 );
1717 ],1696 if (heads.length > workflowApprovalLimit) {
1718 })1697 return Effect.fail(
1719 .pipe(1698 new GitHubWorkflowApprovalRefusedError({
1720 Effect.flatMap(1699 command: "gh",
1721 (1700 cwd: input.cwd,
1722 result,1701 number: input.number,
1723 ): Effect.Effect<1702 reason: "head-list-truncated",
1724 GitHubPullRequestHead,1703 observedCount: heads.length,
1725 GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError1704 limit: workflowApprovalLimit,
1726 > => {1705 }),
1727 const decoded = decodePullRequestHeadsJson(result.stdout.trim());
1728 if (!Result.isSuccess(decoded)) {
1729 return Effect.fail(workflowApprovalReadError(input.cwd, decoded.failure));
1730 }
1731 const exactHeads = decoded.success.filter(
1732 (pullRequest) =>
1733 pullRequest.headSha === input.headSha &&
1734 pullRequest.isCrossRepository === true &&
1735 pullRequest.headRepositoryOwner?.toLowerCase() ===
1736 input.headRepositoryOwner.toLowerCase(),
1737 );1706 );
1738 if (decoded.success.length > workflowApprovalLimit) {1707 }
1739 return Effect.fail(1708 if (exactHeads.length !== 1 || exactHeads[0]?.number !== input.number) {
1740 new GitHubWorkflowApprovalRefusedError({1709 return Effect.fail(
1741 command: "gh",1710 new GitHubWorkflowApprovalRefusedError({
1742 cwd: input.cwd,1711 command: "gh",
1743 number: input.number,1712 cwd: input.cwd,
1744 reason: "head-list-truncated",1713 number: input.number,
1745 observedCount: decoded.success.length,1714 reason: "head-not-unique",
1746 limit: workflowApprovalLimit,1715 observedCount: exactHeads.length,
1747 }),1716 limit: workflowApprovalLimit,
1748 );1717 }),
1749 }1718 );
1750 if (exactHeads.length !== 1 || exactHeads[0]?.number !== input.number) {1719 }
1751 return Effect.fail(1720 return Effect.succeed(exactHeads[0]);
1752 new GitHubWorkflowApprovalRefusedError({1721 },
1753 command: "gh",
1754 cwd: input.cwd,
1755 number: input.number,
1756 reason: "head-not-unique",
1757 observedCount: exactHeads.length,
1758 limit: workflowApprovalLimit,
1759 }),
1760 );
1761 }
1762 return Effect.succeed(exactHeads[0]);
1763 },
1764 ),
1765 ),1722 ),
1766 github1723 ),
1767 .execute({1724 listActionRequiredRuns(input).pipe(
1768 cwd: input.cwd,1725 Effect.flatMap((runs) =>
1769 args: [1726 runs.length > workflowApprovalLimit
1770 "run",1727 ? Effect.fail(
1771 "list",1728 new GitHubWorkflowApprovalRefusedError({
1772 ...repositoryArgs(input),1729 command: "gh",
1773 "--commit",1730 cwd: input.cwd,
1774 input.headSha,1731 number: input.number,
1775 "--branch",1732 reason: "run-list-truncated",
1776 input.headBranch,1733 observedCount: runs.length,
1777 "--event",1734 limit: workflowApprovalLimit,
1778 "pull_request",1735 }),
1779 "--status",1736 )
1780 "action_required",1737 : Effect.succeed(runs),
1781 "--limit",
1782 workflowApprovalProbeLimit,
1783 "--json",
1784 "databaseId,workflowName,url",
1785 ],
1786 })
1787 .pipe(
1788 Effect.flatMap(
1789 (
1790 result,
1791 ): Effect.Effect<
1792 ReadonlyArray<GitHubWorkflowRunApproval>,
1793 GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError
1794 > => {
1795 const decoded = decodeWorkflowRunApprovalsJson(result.stdout.trim());
1796 if (!Result.isSuccess(decoded)) {
1797 return Effect.fail(workflowApprovalReadError(input.cwd, decoded.failure));
1798 }
1799 return decoded.success.length > workflowApprovalLimit
1800 ? Effect.fail(
1801 new GitHubWorkflowApprovalRefusedError({
1802 command: "gh",
1803 cwd: input.cwd,
1804 number: input.number,
1805 reason: "run-list-truncated",
1806 observedCount: decoded.success.length,
1807 limit: workflowApprovalLimit,
1808 }),
1809 )
1810 : Effect.succeed(decoded.success);
1811 },
1812 ),
1813 ),1738 ),
1739 ),
1814 ],1740 ],
1815 { concurrency: 2 },1741 { concurrency: 2 },
1816 ).pipe(Effect.map(([, runs]) => runs));1742 ).pipe(Effect.map(([, runs]) => runs));
18171743
1818 // One `gh pr view` either way; asking for the detail fields costs nothing extra and hands1744 /** One pull request's summary, through the same aliased query the batch uses. */
1819 // the thread overview its author, diff stat, review decision and checks in the same read.1745 const viewPullRequestSummary = (input: PullRequestSummaryRead) => {
1820 const viewPullRequestSummary = (input: PullRequestSummaryRead) =>1746 const { owner, name } = parseRepositorySelector(input.repository);
1821 github1747 return graphqlRead({
1822 .execute({1748 cwd: input.cwd,
1823 cwd: input.cwd,1749 host: input.host,
1824 args: [1750 operation: "getPullRequestSummary",
1825 "pr",1751 variables: { owner, name, number: input.number },
1826 "view",1752 query: pullRequestSummaryGraphQlQuery(input.host === "github.com"),
1827 String(input.number),1753 decode: decodePullRequestSummariesJson,
1828 ...repositoryArgs(input),1754 }).pipe(
1829 "--json",1755 Effect.flatMap((summaries) => {
1830 PULL_REQUEST_DETAIL_JSON_FIELDS,1756 const summary = summaries.get(0);
1831 ],1757 return summary === undefined
1832 })1758 ? Effect.fail(
1833 .pipe(1759 readError(
1834 Effect.flatMap((result) => {1760 input.cwd,
1835 const decoded = decodePullRequestDetailJson(result.stdout.trim());1761 "getPullRequestSummary",
1836 if (!Result.isSuccess(decoded)) {1762 new Error(`GitHub answered nothing for ${input.repository}#${input.number}.`),
1837 return Effect.fail(1763 ),
1838 new GitHubPullRequestReadError({1764 )
1839 command: "gh",1765 : Effect.succeed(summary);
1840 cwd: input.cwd,1766 }),
1841 operation: "getPullRequestSummary",1767 );
1842 cause: decoded.failure,1768 };
1843 }),
1844 );
1845 }
1846 const detail = decoded.success;
1847 return Effect.succeed({
1848 number: detail.number,
1849 title: detail.title,
1850 url: detail.url,
1851 headBranch: detail.headBranch,
1852 baseBranch: detail.baseBranch,
1853 state: detail.state,
1854 updatedAt: detail.updatedAt,
1855 closedAt: detail.closedAt ?? null,
1856 mergedAt: detail.mergedAt ?? null,
1857 isDraft: detail.isDraft,
1858 author: detail.author,
1859 additions: detail.additions,
1860 deletions: detail.deletions,
1861 changedFiles: detail.changedFiles,
1862 reviewDecision: detail.reviewDecision,
1863 checksState: detail.checksState,
1864 mergeability: detail.mergeability,
1865 });
1866 }),
1867 );
18681769
1869 /**1770 /**
1870 * Summaries asked for together, on one host under one credential, share aliased GraphQL reads1771 * Summaries asked for together, on one host under one credential, share aliased GraphQL reads
...@@ -1876,7 +1777,7 @@ export const make = Effect.gen(function* () {...@@ -1876,7 +1777,7 @@ export const make = Effect.gen(function* () {
1876 key: ({ request, context }) =>1777 key: ({ request, context }) =>
1877 JSON.stringify([1778 JSON.stringify([
1878 request.host.toLowerCase(),1779 request.host.toLowerCase(),
1879 Context.getOrElse(context, GitHubCli.PinnedGitHubCredential, () => null)1780 Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null)
1880 ?.credentialFingerprint ?? null,1781 ?.credentialFingerprint ?? null,
1881 Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""),1782 Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""),
1882 ]),1783 ]),
...@@ -1956,7 +1857,7 @@ export const make = Effect.gen(function* () {...@@ -1956,7 +1857,7 @@ export const make = Effect.gen(function* () {
1956 key: ({ request, context }) =>1857 key: ({ request, context }) =>
1957 JSON.stringify([1858 JSON.stringify([
1958 request.host.toLowerCase(),1859 request.host.toLowerCase(),
1959 Context.getOrElse(context, GitHubCli.PinnedGitHubCredential, () => null)1860 Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null)
1960 ?.credentialFingerprint ?? null,1861 ?.credentialFingerprint ?? null,
1961 Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""),1862 Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""),
1962 ]),1863 ]),
...@@ -2010,70 +1911,88 @@ export const make = Effect.gen(function* () {...@@ -2010,70 +1911,88 @@ export const make = Effect.gen(function* () {
20101911
2011 listPullRequests: (input) => {1912 listPullRequests: (input) => {
2012 const fallbackMaxRows = Math.max(input.limit + 1, PULL_REQUEST_FALLBACK_MAX_ROWS);1913 const fallbackMaxRows = Math.max(input.limit + 1, PULL_REQUEST_FALLBACK_MAX_ROWS);
1914 const { owner, name } = parseRepositorySelector(input.repository);
1915 const query = searchQuery({ ...input, repositories: [input.repository] });
1916 if (query === null) {
1917 return Effect.fail(
1918 new GitHubRepositorySelectorError({
1919 command: "gh",
1920 cwd: input.cwd,
1921 operation: "listPullRequests",
1922 }),
1923 );
1924 }
1925 /** Pages of up to a hundred until `rows` have arrived or GitHub has no more. */
1926 const collect = (
1927 rows: number,
1928 page: (
1929 after: string | null,
1930 rows: number,
1931 ) => Effect.Effect<GitHubPullRequestListPage, GitHubPullRequestCliError>,
1932 ) =>
1933 Effect.gen(function* () {
1934 const items: GitHubPullRequestListItem[] = [];
1935 let rawCount = 0;
1936 let after: string | null = null;
1937 do {
1938 const read: GitHubPullRequestListPage = yield* page(after, rows - rawCount);
1939 items.push(...read.items);
1940 rawCount += read.rawCount;
1941 after = read.endCursor;
1942 } while (after !== null && rawCount < rows);
1943 return { items, rawCount };
1944 });
2013 const read = (1945 const read = (
2014 continues: boolean,1946 continues: boolean,
2015 requestedRows = input.limit + 1,1947 requestedRows = input.limit + 1,
2016 ): Effect.Effect<GitHubPullRequestListBatch, GitHubPullRequestCliError> =>1948 ): Effect.Effect<GitHubPullRequestListBatch, GitHubPullRequestCliError> =>
2017 github1949 collect(requestedRows, (after, rows) =>
2018 .execute({1950 continues
2019 cwd: input.cwd,1951 ? graphqlRead({
2020 args: [1952 cwd: input.cwd,
2021 "pr",1953 host: input.host,
2022 "list",1954 operation: "listPullRequests",
2023 ...repositoryArgs(input),1955 // The reader's own words are in the query.
2024 ...involvementArgs({ ...input, sorted: continues }),1956 variables: { q: query, after },
2025 "--state",1957 query: pullRequestSearchGraphQlQuery(rows, false, true),
2026 input.state,1958 decode: decodePullRequestSearchJson,
2027 "--limit",1959 })
2028 // One extra row reveals that the repository has more than the page shows.1960 : graphqlRead({
2029 String(requestedRows),1961 cwd: input.cwd,
2030 "--json",1962 host: input.host,
2031 PULL_REQUEST_LIST_JSON_FIELDS,1963 operation: "listPullRequests",
2032 ],1964 variables: { owner, name, states: LIST_STATES[input.state], after },
2033 })1965 query: pullRequestListGraphQlQuery(rows),
2034 .pipe(1966 decode: decodePullRequestListJson,
2035 Effect.flatMap((result) => {1967 }),
2036 const raw = result.stdout.trim();1968 ).pipe(
2037 if (raw.length === 0) {1969 Effect.flatMap(({ items: rawItems, rawCount }) => {
2038 return Effect.succeed({ items: [], truncated: false, continues });1970 const items = continues
2039 }1971 ? rawItems
2040 const decoded = decodePullRequestListJson(raw);1972 : rawItems.filter((item) => matchesUnsortedListing(item, input));
2041 if (Result.isSuccess(decoded)) {1973 if (
2042 const items = continues1974 !continues &&
2043 ? decoded.success.items1975 items.length < input.limit &&
2044 : decoded.success.items.filter((item) => matchesUnsortedListing(item, input));1976 rawCount >= requestedRows &&
2045 if (1977 requestedRows < fallbackMaxRows
2046 !continues &&1978 ) {
2047 items.length < input.limit &&1979 const nextRows = Math.min(requestedRows * 2, fallbackMaxRows);
2048 decoded.success.rawCount >= requestedRows &&1980 if (nextRows > requestedRows) return read(false, nextRows);
2049 requestedRows < fallbackMaxRows1981 }
2050 ) {1982 return Effect.succeed({
2051 const nextRows = Math.min(requestedRows * 2, fallbackMaxRows);1983 items: items.slice(0, input.limit),
2052 if (nextRows > requestedRows) return read(false, nextRows);1984 // One row over the page size is the probe for a next page, and it is
2053 }1985 // counted before decoding: a skipped malformed row must not end paging.
2054 return Effect.succeed({1986 truncated: continues
2055 items: items.slice(0, input.limit),1987 ? rawCount > input.limit
2056 // One row over the page size is the probe for a next page, and it is1988 : items.length > input.limit || rawCount >= requestedRows,
2057 // counted before decoding: a skipped malformed row must not end paging.1989 continues,
2058 truncated: continues1990 });
2059 ? decoded.success.rawCount > input.limit1991 }),
2060 : items.length > input.limit || decoded.success.rawCount >= requestedRows,1992 );
2061 continues,
2062 });
2063 }
2064 return Effect.fail(
2065 new GitHubPullRequestReadError({
2066 command: "gh",
2067 cwd: input.cwd,
2068 operation: "listPullRequests",
2069 cause: decoded.failure,
2070 }),
2071 );
2072 }),
2073 );
2074 // GitHub does not index every repository for search, and one it will not search answers1993 // GitHub does not index every repository for search, and one it will not search answers
2075 // with no rows rather than with an error — so an empty listing is read again the way `gh`1994 // with no rows rather than with an error — so an empty listing is read again from the
2076 // lists without one. Those rows come back newest-created first, an order no `updated:`1995 // repository's own list. Those rows come back newest-created first, an order no `updated:`
2077 // qualifier can carry on from, so that page says it cannot be continued and the reader1996 // qualifier can carry on from, so that page says it cannot be continued and the reader
2078 // reaches the rest of it by asking for a larger page, as every listing used to.1997 // reaches the rest of it by asking for a larger page, as every listing used to.
2079 //1998 //
...@@ -2157,8 +2076,7 @@ export const make = Effect.gen(function* () {...@@ -2157,8 +2076,7 @@ export const make = Effect.gen(function* () {
2157 cwd: input.cwd,2076 cwd: input.cwd,
2158 host: input.host,2077 host: input.host,
2159 operation: "searchPullRequests",2078 operation: "searchPullRequests",
2160 // The reader's own words are in the query, so it travels over stdin rather than in argv.2079 variables: { q: query, after: null },
2161 privateVariables: { q: query },
2162 query: pullRequestSearchGraphQlQuery(rows, input.host === "github.com"),2080 query: pullRequestSearchGraphQlQuery(rows, input.host === "github.com"),
2163 decode: decodePullRequestSearchJson,2081 decode: decodePullRequestSearchJson,
2164 }).pipe(2082 }).pipe(
...@@ -2217,11 +2135,7 @@ export const make = Effect.gen(function* () {...@@ -2217,11 +2135,7 @@ export const make = Effect.gen(function* () {
2217 cwd: input.cwd,2135 cwd: input.cwd,
2218 host: input.host,2136 host: input.host,
2219 operation: "getPullRequestPreview",2137 operation: "getPullRequestPreview",
2220 variables: [2138 variables: { owner, name, number: input.number },
2221 ["-f", `owner=${owner}`],
2222 ["-f", `name=${name}`],
2223 ["-F", `number=${input.number}`],
2224 ],
2225 query: PULL_REQUEST_PREVIEW_GRAPHQL_QUERY,2139 query: PULL_REQUEST_PREVIEW_GRAPHQL_QUERY,
2226 decode: decodePullRequestPreviewJson,2140 decode: decodePullRequestPreviewJson,
2227 });2141 });
...@@ -2230,65 +2144,30 @@ export const make = Effect.gen(function* () {...@@ -2230,65 +2144,30 @@ export const make = Effect.gen(function* () {
22302144
2231 getPullRequestStack: (input) => {2145 getPullRequestStack: (input) => {
2232 const { owner, name } = parseRepositorySelector(input.repository);2146 const { owner, name } = parseRepositorySelector(input.repository);
2233 return github2147 return restRead({
2234 .execute({2148 cwd: input.cwd,
2235 cwd: input.cwd,2149 host: input.host,
2236 args: [2150 operation: "getPullRequestStack",
2237 "api",2151 path: `repos/${owner}/${name}/stacks?pull_request=${input.number}`,
2238 "--hostname",2152 decode: decodePullRequestStacksJson,
2239 input.host,2153 }).pipe(
2240 `repos/${owner}/${name}/stacks?pull_request=${input.number}`,2154 // @effect-diagnostics-next-line flatMapConditionalToFilterOrFail:off - the fallback needs a non-null stack, which a predicate that also reads includeDetails cannot refine.
2241 ],2155 Effect.flatMap((stack) => {
2242 })2156 if (!input.includeDetails || stack === null) return Effect.succeed(stack);
2243 .pipe(2157 return restRead({
2244 Effect.flatMap((result) => {2158 cwd: input.cwd,
2245 const decoded = decodePullRequestStacksJson(result.stdout.trim());2159 host: input.host,
2246 return Result.isSuccess(decoded)2160 operation: "getPullRequestStack",
2247 ? Effect.succeed(decoded.success)2161 path: `repos/${owner}/${name}/stacks/${stack.number}`,
2248 : Effect.fail(2162 decode: (raw) => decodePullRequestStacksJson(`[${raw}]`),
2249 new GitHubPullRequestReadError({2163 });
2250 command: "gh",2164 }),
2251 cwd: input.cwd,2165 // Hosts without the stacks preview return 404. Other failures must preserve the
2252 operation: "getPullRequestStack",2166 // previously synced stack and let the caller retry.
2253 cause: decoded.failure,2167 Effect.catchTags({
2254 }),2168 GitHubApiNotFoundError: () => Effect.succeed(null),
2255 );2169 }),
2256 }),2170 );
2257 // @effect-diagnostics-next-line flatMapConditionalToFilterOrFail:off - the fallback needs a non-null stack, which a predicate that also reads includeDetails cannot refine.
2258 Effect.flatMap((stack) => {
2259 if (!input.includeDetails || stack === null) return Effect.succeed(stack);
2260 return github
2261 .execute({
2262 cwd: input.cwd,
2263 args: [
2264 "api",
2265 "--hostname",
2266 input.host,
2267 `repos/${owner}/${name}/stacks/${stack.number}`,
2268 ],
2269 })
2270 .pipe(
2271 Effect.flatMap((result) => {
2272 const decoded = decodePullRequestStacksJson(`[${result.stdout.trim()}]`);
2273 return Result.isSuccess(decoded)
2274 ? Effect.succeed(decoded.success)
2275 : Effect.fail(
2276 new GitHubPullRequestReadError({
2277 command: "gh",
2278 cwd: input.cwd,
2279 operation: "getPullRequestStack",
2280 cause: decoded.failure,
2281 }),
2282 );
2283 }),
2284 );
2285 }),
2286 // Hosts without the stacks preview return 404. Other failures must preserve the
2287 // previously synced stack and let the caller retry.
2288 Effect.catchTags({
2289 GitHubPullRequestNotFoundError: () => Effect.succeed(null),
2290 }),
2291 );
2292 },2171 },
22932172
2294 getPullRequestBaseComparison: (input) => {2173 getPullRequestBaseComparison: (input) => {
...@@ -2298,45 +2177,59 @@ export const make = Effect.gen(function* () {...@@ -2298,45 +2177,59 @@ export const make = Effect.gen(function* () {
2298 host: input.host,2177 host: input.host,
2299 operation: "getPullRequestBaseComparison",2178 operation: "getPullRequestBaseComparison",
2300 ...(input.allowReserve === true ? { allowReserve: true } : {}),2179 ...(input.allowReserve === true ? { allowReserve: true } : {}),
2301 variables: [2180 variables: { owner, name, number: input.number, headRef: input.headRef },
2302 ["-f", `owner=${owner}`],
2303 ["-f", `name=${name}`],
2304 ["-F", `number=${input.number}`],
2305 ["-f", `headRef=${input.headRef}`],
2306 ],
2307 query: BASE_COMPARISON_GRAPHQL_QUERY,2181 query: BASE_COMPARISON_GRAPHQL_QUERY,
2308 decode: decodeBaseComparisonJson,2182 decode: decodeBaseComparisonJson,
2309 });2183 });
2310 },2184 },
23112185
2312 getPullRequestActivity: (input) =>2186 getPullRequestActivity: (input) =>
2313 github2187 Effect.gen(function* () {
2314 .execute({2188 const { owner, name } = parseRepositorySelector(input.repository);
2315 cwd: input.cwd,2189 let author: PullRequestActor | null = null;
2316 args: [2190 let commits: ReadonlyArray<PullRequestCommit> = [];
2317 "pr",2191 const remarks: PullRequestComment[] = [];
2318 "view",2192 let commentsAfter: string | null = null;
2319 String(input.number),2193 let reviewsAfter: string | null = null;
2320 ...repositoryArgs(input),2194 let withComments = true;
2321 "--json",2195 let withReviews = true;
2322 PULL_REQUEST_ACTIVITY_JSON_FIELDS,2196 // Both remark lists page on their own; a page asks only for the ones with more to give.
2323 ],2197 for (let page = 0; page < REVIEW_THREAD_PAGES && (withComments || withReviews); page++) {
2324 })2198 const read: GitHubPullRequestActivityPage = yield* graphqlRead({
2325 .pipe(2199 cwd: input.cwd,
2326 Effect.flatMap((result) => {2200 host: input.host,
2327 const decoded = decodePullRequestActivityJson(result.stdout.trim());2201 operation: "getPullRequestActivity",
2328 return Result.isSuccess(decoded)2202 variables: {
2329 ? Effect.succeed(decoded.success)2203 owner,
2330 : Effect.fail(2204 name,
2331 new GitHubPullRequestReadError({2205 number: input.number,
2332 command: "gh",2206 head: page === 0,
2333 cwd: input.cwd,2207 withComments,
2334 operation: "getPullRequestActivity",2208 commentsAfter,
2335 cause: decoded.failure,2209 withReviews,
2336 }),2210 reviewsAfter,
2337 );2211 },
2338 }),2212 query: PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY,
2339 ),2213 decode: decodePullRequestActivityJson,
2214 });
2215 if (page === 0) {
2216 author = read.author ?? null;
2217 commits = read.commits ?? [];
2218 }
2219 remarks.push(...read.remarks);
2220 commentsAfter = read.nextCommentsCursor;
2221 reviewsAfter = read.nextReviewsCursor;
2222 withComments = withComments && commentsAfter !== null;
2223 withReviews = withReviews && reviewsAfter !== null;
2224 }
2225 return {
2226 author,
2227 comments: remarks.toSorted((left, right) =>
2228 left.createdAt.localeCompare(right.createdAt),
2229 ),
2230 commits,
2231 } satisfies GitHubPullRequestActivity;
2232 }),
23402233
2341 getPullRequestDiff: (input) => {2234 getPullRequestDiff: (input) => {
2342 const filesPage = (page: number) =>2235 const filesPage = (page: number) =>
...@@ -2363,31 +2256,34 @@ export const make = Effect.gen(function* () {...@@ -2363,31 +2256,34 @@ export const make = Effect.gen(function* () {
2363 if (input.commit !== undefined) {2256 if (input.commit !== undefined) {
2364 return filesPage(1);2257 return filesPage(1);
2365 }2258 }
2366 return github2259 const { owner, name } = parseRepositorySelector(input.repository);
2367 .execute({2260 return api
2368 cwd: input.cwd,2261 .rest({
2369 args: ["pr", "diff", String(input.number), ...repositoryArgs(input), "--color", "never"],2262 host: input.host,
2370 maxOutputBytes: DIFF_MAX_OUTPUT_BYTES,2263 operation: "getPullRequestDiff",
2371 timeoutMs: DIFF_TIMEOUT_MS,2264 path: `repos/${owner}/${name}/pulls/${input.number}`,
2265 accept: "application/vnd.github.diff",
2266 maxResponseBytes: DIFF_MAX_OUTPUT_BYTES,
2267 timeout: DIFF_TIMEOUT,
2372 })2268 })
2373 .pipe(2269 .pipe(
2374 Effect.flatMap((result) =>2270 Effect.flatMap((response) =>
2375 // A patch cut at a byte boundary ends mid-file, which is neither a whole slice nor2271 // A patch cut at a byte boundary ends mid-file, which is neither a whole slice nor
2376 // something the reader can carry on from. The files API can serve the same change a2272 // something the reader can carry on from. The files API can serve the same change a
2377 // whole number of files at a time, so an oversized patch takes that road as well.2273 // whole number of files at a time, so an oversized patch takes that road as well.
2378 result.stdoutTruncated2274 response.truncated
2379 ? filesPage(1)2275 ? filesPage(1)
2380 : // One read served the whole patch, so there is no next slice to ask for.2276 : // One read served the whole patch, so there is no next slice to ask for.
2381 Effect.succeed({ patch: result.stdout, truncated: false, nextCursor: null }),2277 Effect.succeed({ patch: response.body, truncated: false, nextCursor: null }),
2382 ),2278 ),
2383 // GitHub answers 406 rather than a diff past 300 changed files, so the patch is read2279 // GitHub answers 406 rather than a diff past 300 changed files, so the patch is read
2384 // from the files API instead, a page per call. Only once the direct read has failed: a2280 // from the files API instead, a page per call. Only once the direct read has failed: a
2385 // pull request GitHub will serve a diff for must not pay for a second request. A2281 // pull request GitHub will serve a diff for must not pay for a second request. A
2386 // fallback that fails too reports the original refusal, which is the one that explains2282 // 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 a2283 // the page. Narrowed to a request GitHub answered and refused: a missing credential or
2388 // signed-out one fails the same way for every request.2284 // a rate limit fails the same way for every request.
2389 Effect.catchTags({2285 Effect.catchTags({
2390 GitHubCliCommandError: (error) => filesPage(1).pipe(Effect.mapError(() => error)),2286 GitHubApiResponseError: (error) => filesPage(1).pipe(Effect.mapError(() => error)),
2391 }),2287 }),
2392 );2288 );
2393 },2289 },
...@@ -2400,13 +2296,13 @@ export const make = Effect.gen(function* () {...@@ -2400,13 +2296,13 @@ export const make = Effect.gen(function* () {
2400 cwd: input.cwd,2296 cwd: input.cwd,
2401 host: input.host,2297 host: input.host,
2402 operation: "getReviewThreadComments",2298 operation: "getReviewThreadComments",
2403 variables: [2299 variables: {
2404 ["-f", `owner=${owner}`],2300 owner,
2405 ["-f", `name=${name}`],2301 name,
2406 ["-F", `number=${input.number}`],2302 number: input.number,
2407 ["-f", `threadId=${input.threadId}`],2303 threadId: input.threadId,
2408 cursorVariable(input.cursor),2304 cursor: input.cursor,
2409 ],2305 },
2410 query: REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY,2306 query: REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY,
2411 decode: decodeReviewThreadCommentsJson,2307 decode: decodeReviewThreadCommentsJson,
2412 }).pipe(2308 }).pipe(
...@@ -2434,12 +2330,7 @@ export const make = Effect.gen(function* () {...@@ -2434,12 +2330,7 @@ export const make = Effect.gen(function* () {
2434 cwd: input.cwd,2330 cwd: input.cwd,
2435 host: input.host,2331 host: input.host,
2436 operation: "listReviewThreadComments",2332 operation: "listReviewThreadComments",
2437 variables: [2333 variables: { owner, name, number: input.number, cursor },
2438 ["-f", `owner=${owner}`],
2439 ["-f", `name=${name}`],
2440 ["-F", `number=${input.number}`],
2441 cursorVariable(cursor),
2442 ],
2443 query: REVIEW_THREADS_GRAPHQL_QUERY,2334 query: REVIEW_THREADS_GRAPHQL_QUERY,
2444 decode: decodeReviewThreadsJson,2335 decode: decodeReviewThreadsJson,
2445 });2336 });
...@@ -2496,12 +2387,7 @@ export const make = Effect.gen(function* () {...@@ -2496,12 +2387,7 @@ export const make = Effect.gen(function* () {
2496 cwd: input.cwd,2387 cwd: input.cwd,
2497 host: input.host,2388 host: input.host,
2498 operation: "listReviewThreadComments",2389 operation: "listReviewThreadComments",
2499 variables: [2390 variables: { owner, name, number: input.number, cursor: dismissalCursor },
2500 ["-f", `owner=${owner}`],
2501 ["-f", `name=${name}`],
2502 ["-F", `number=${input.number}`],
2503 ["-f", `cursor=${dismissalCursor}`],
2504 ],
2505 query: REVIEW_DISMISSALS_GRAPHQL_QUERY,2391 query: REVIEW_DISMISSALS_GRAPHQL_QUERY,
2506 decode: decodeReviewDismissalsJson,2392 decode: decodeReviewDismissalsJson,
2507 });2393 });
...@@ -2547,7 +2433,7 @@ export const make = Effect.gen(function* () {...@@ -2547,7 +2433,7 @@ export const make = Effect.gen(function* () {
2547 cwd: input.cwd,2433 cwd: input.cwd,
2548 host: input.host,2434 host: input.host,
2549 operation: "listActorAvatars",2435 operation: "listActorAvatars",
2550 variables: input.ids.map((id) => ["-f", `ids[]=${id}`]),2436 variables: { ids: input.ids },
2551 query: ACTOR_AVATARS_GRAPHQL_QUERY,2437 query: ACTOR_AVATARS_GRAPHQL_QUERY,
2552 decode: decodeActorAvatarsJson,2438 decode: decodeActorAvatarsJson,
2553 });2439 });
...@@ -2560,11 +2446,7 @@ export const make = Effect.gen(function* () {...@@ -2560,11 +2446,7 @@ export const make = Effect.gen(function* () {
2560 host: input.host,2446 host: input.host,
2561 operation: "getViewerAccess",2447 operation: "getViewerAccess",
2562 ...(input.allowReserve === true ? { allowReserve: true } : {}),2448 ...(input.allowReserve === true ? { allowReserve: true } : {}),
2563 variables: [2449 variables: { owner, name, number: input.number },
2564 ["-f", `owner=${owner}`],
2565 ["-f", `name=${name}`],
2566 ["-F", `number=${input.number}`],
2567 ],
2568 query: VIEWER_PERMISSIONS_GRAPHQL_QUERY,2450 query: VIEWER_PERMISSIONS_GRAPHQL_QUERY,
2569 decode: decodeViewerPermissionsJson,2451 decode: decodeViewerPermissionsJson,
2570 });2452 });
...@@ -2577,11 +2459,7 @@ export const make = Effect.gen(function* () {...@@ -2577,11 +2459,7 @@ export const make = Effect.gen(function* () {
2577 host: input.host,2459 host: input.host,
2578 operation: "listReviewerCandidates",2460 operation: "listReviewerCandidates",
2579 allowReserve: true,2461 allowReserve: true,
2580 variables: [2462 variables: { owner, name, number: input.number },
2581 ["-f", `owner=${owner}`],
2582 ["-f", `name=${name}`],
2583 ["-F", `number=${input.number}`],
2584 ],
2585 query: REVIEWER_CANDIDATES_GRAPHQL_QUERY,2463 query: REVIEWER_CANDIDATES_GRAPHQL_QUERY,
2586 decode: decodeReviewerCandidatesJson,2464 decode: decodeReviewerCandidatesJson,
2587 });2465 });
...@@ -2589,24 +2467,15 @@ export const make = Effect.gen(function* () {...@@ -2589,24 +2467,15 @@ export const make = Effect.gen(function* () {
25892467
2590 setReviewerRequest: (input) => {2468 setReviewerRequest: (input) => {
2591 const { owner, name } = parseRepositorySelector(input.repository);2469 const { owner, name } = parseRepositorySelector(input.repository);
2592 return github2470 // Posting to a login GitHub has already been asked about is what a re-request is, so
2593 .execute({2471 // there is nothing to say here about somebody who has reviewed once already.
2594 cwd: input.cwd,2472 return api
2595 // Posting to a login GitHub has already been asked about is what a re-request is, so2473 .rest({
2596 // there is nothing to say here about somebody who has reviewed once already. The body2474 host: input.host,
2597 // travels over stdin for the reason every other one does: argv is visible in process2475 operation: "setReviewerRequest",
2598 // listings and echoed back inside process-runner failure messages.2476 method: input.requested ? "POST" : "DELETE",
2599 args: [2477 path: `repos/${owner}/${name}/pulls/${input.number}/requested_reviewers`,
2600 "api",2478 body: buildReviewerRequest(input.reviewers),
2601 "--method",
2602 input.requested ? "POST" : "DELETE",
2603 "--hostname",
2604 input.host,
2605 `repos/${owner}/${name}/pulls/${input.number}/requested_reviewers`,
2606 "--input",
2607 "-",
2608 ],
2609 stdin: buildReviewerRequestJson(input.reviewers),
2610 })2479 })
2611 .pipe(Effect.asVoid);2480 .pipe(Effect.asVoid);
2612 },2481 },
...@@ -2618,11 +2487,7 @@ export const make = Effect.gen(function* () {...@@ -2618,11 +2487,7 @@ export const make = Effect.gen(function* () {
2618 host: input.host,2487 host: input.host,
2619 operation: "listLabelCandidates",2488 operation: "listLabelCandidates",
2620 allowReserve: true,2489 allowReserve: true,
2621 variables: [2490 variables: { owner, name, number: input.number },
2622 ["-f", `owner=${owner}`],
2623 ["-f", `name=${name}`],
2624 ["-F", `number=${input.number}`],
2625 ],
2626 query: LABEL_CANDIDATES_GRAPHQL_QUERY,2491 query: LABEL_CANDIDATES_GRAPHQL_QUERY,
2627 decode: decodeLabelCandidatesJson,2492 decode: decodeLabelCandidatesJson,
2628 });2493 });
...@@ -2635,27 +2500,24 @@ export const make = Effect.gen(function* () {...@@ -2635,27 +2500,24 @@ export const make = Effect.gen(function* () {
2635 // path. The name goes into the path encoded, because a label may carry a space or a slash.2500 // path. The name goes into the path encoded, because a label may carry a space or a slash.
2636 const issue = `repos/${owner}/${name}/issues/${input.number}/labels`;2501 const issue = `repos/${owner}/${name}/issues/${input.number}/labels`;
2637 if (input.applied) {2502 if (input.applied) {
2638 return github2503 return api
2639 .execute({2504 .rest({
2640 cwd: input.cwd,2505 host: input.host,
2641 args: ["api", "--method", "POST", "--hostname", input.host, issue, "--input", "-"],2506 operation: "setLabels",
2642 stdin: buildLabelRequestJson(input.labels),2507 method: "POST",
2508 path: issue,
2509 body: buildLabelRequest(input.labels),
2643 })2510 })
2644 .pipe(Effect.asVoid);2511 .pipe(Effect.asVoid);
2645 }2512 }
2646 return Effect.forEach(2513 return Effect.forEach(
2647 input.labels,2514 input.labels,
2648 (label) =>2515 (label) =>
2649 github.execute({2516 api.rest({
2650 cwd: input.cwd,2517 host: input.host,
2651 args: [2518 operation: "setLabels",
2652 "api",2519 method: "DELETE",
2653 "--method",2520 path: `${issue}/${encodeURIComponent(label)}`,
2654 "DELETE",
2655 "--hostname",
2656 input.host,
2657 `${issue}/${encodeURIComponent(label)}`,
2658 ],
2659 }),2521 }),
2660 { concurrency: 1, discard: true },2522 { concurrency: 1, discard: true },
2661 );2523 );
...@@ -2664,14 +2526,14 @@ export const make = Effect.gen(function* () {...@@ -2664,14 +2526,14 @@ export const make = Effect.gen(function* () {
2664 runPullRequestAction: (input) => {2526 runPullRequestAction: (input) => {
2665 if (input.stackNumber !== undefined)2527 if (input.stackNumber !== undefined)
2666 return runGitHubStackAction({ ...input, stackNumber: input.stackNumber }).pipe(2528 return runGitHubStackAction({ ...input, stackNumber: input.stackNumber }).pipe(
2667 Effect.provideService(GitHubCli.GitHubCli, github),2529 Effect.provideService(GitHubApi.GitHubApi, api),
2668 );2530 );
2669 if (input.action === "revert") {2531 if (input.action === "revert") {
2670 return pullRequestNodeId({ ...input, operation: "revertPullRequest" }).pipe(2532 return pullRequestNodeId({ ...input, operation: "revertPullRequest" }).pipe(
2671 Effect.flatMap((pullRequestId) =>2533 Effect.flatMap((pullRequestId) =>
2672 graphql({2534 graphql({
2673 cwd: input.cwd,
2674 host: input.host,2535 host: input.host,
2536 operation: "revertPullRequest",
2675 query: REVERT_PULL_REQUEST_GRAPHQL_MUTATION,2537 query: REVERT_PULL_REQUEST_GRAPHQL_MUTATION,
2676 variables: { pullRequestId },2538 variables: { pullRequestId },
2677 }),2539 }),
...@@ -2742,18 +2604,12 @@ export const make = Effect.gen(function* () {...@@ -2742,18 +2604,12 @@ export const make = Effect.gen(function* () {
2742 }),2604 }),
2743 Effect.flatMap((currentRuns) =>2605 Effect.flatMap((currentRuns) =>
2744 currentRuns.some((current) => current.id === run.id)2606 currentRuns.some((current) => current.id === run.id)
2745 ? github2607 ? api
2746 .execute({2608 .rest({
2747 cwd: input.cwd,2609 host: input.host,
2748 args: [2610 operation: "approveWorkflowRun",
2749 "api",2611 method: "POST",
2750 "--method",2612 path: `repos/${owner}/${name}/actions/runs/${run.id}/approve`,
2751 "POST",
2752 "--hostname",
2753 input.host,
2754 `repos/${owner}/${name}/actions/runs/${run.id}/approve`,
2755 "--silent",
2756 ],
2757 })2613 })
2758 .pipe(Effect.asVoid)2614 .pipe(Effect.asVoid)
2759 : Effect.void,2615 : Effect.void,
...@@ -2766,32 +2622,67 @@ export const make = Effect.gen(function* () {...@@ -2766,32 +2622,67 @@ export const make = Effect.gen(function* () {
2766 }),2622 }),
2767 );2623 );
2768 }2624 }
2769 const [subcommand, ...flags] = actionArgs(2625 const action = input.action;
2770 input.action,2626 if (action in SIMPLE_ACTION_MUTATIONS) {
2771 input.mergeMethod,2627 return pullRequestNodeId({ ...input, operation: "runPullRequestAction" }).pipe(
2772 input.updateMethod,2628 Effect.flatMap((pullRequestId) =>
2773 );2629 graphql({
2630 host: input.host,
2631 operation: "runPullRequestAction",
2632 query: SIMPLE_ACTION_MUTATIONS[action as keyof typeof SIMPLE_ACTION_MUTATIONS],
2633 variables: { pullRequestId },
2634 }),
2635 ),
2636 );
2637 }
2774 return Effect.gen(function* () {2638 return Effect.gen(function* () {
2639 const { owner, name } = parseRepositorySelector(input.repository);
2640 // Read fresh rather than from the node id cache: merging and updating act on the head
2641 // as it stands now, and the merge queue decides which mutation a merge is.
2642 const state = yield* graphqlRead({
2643 cwd: input.cwd,
2644 host: input.host,
2645 operation: "runPullRequestAction",
2646 allowReserve: true,
2647 query: ACTION_STATE_GRAPHQL_QUERY,
2648 variables: {
2649 owner,
2650 name,
2651 number: input.number,
2652 headRef: `refs/pull/${input.number}/head`,
2653 },
2654 decode: decodeActionState,
2655 });
2656 if (action === "update-branch") {
2657 // Already current is done, the way `gh pr update-branch` reports it.
2658 if (state.baseRef?.compare?.behindBy === 0) return;
2659 // GitHub updates with a merge commit unless asked to rebase, which is its own default.
2660 return yield* graphql({
2661 host: input.host,
2662 operation: "runPullRequestAction",
2663 query: UPDATE_BRANCH_GRAPHQL_MUTATION,
2664 variables: {
2665 pullRequestId: state.id,
2666 expectedHeadOid: state.headRefOid,
2667 updateMethod: input.updateMethod === "rebase" ? "REBASE" : "MERGE",
2668 },
2669 });
2670 }
2775 let body: string | undefined;2671 let body: string | undefined;
2776 let expectedHead: string | undefined;2672 let expectedHead: string | undefined;
2777 if (2673 if (input.removeAgentCreditsOnMerge === true && input.mergeMethod !== "rebase") {
2778 input.removeAgentCreditsOnMerge === true &&
2779 (input.action === "merge" || input.action === "enable-auto-merge") &&
2780 input.mergeMethod !== "rebase"
2781 ) {
2782 const { owner, name } = parseRepositorySelector(input.repository);
2783 const message = yield* graphqlRead({2674 const message = yield* graphqlRead({
2784 cwd: input.cwd,2675 cwd: input.cwd,
2785 host: input.host,2676 host: input.host,
2786 operation: "runPullRequestAction",2677 operation: "runPullRequestAction",
2787 allowReserve: true,2678 allowReserve: true,
2788 query: MERGE_MESSAGE_GRAPHQL_QUERY,2679 query: MERGE_MESSAGE_GRAPHQL_QUERY,
2789 variables: [2680 variables: {
2790 ["-f", `owner=${owner}`],2681 owner,
2791 ["-f", `name=${name}`],2682 name,
2792 ["-F", `number=${input.number}`],2683 number: input.number,
2793 ["-f", `method=${input.mergeMethod === "squash" ? "SQUASH" : "MERGE"}`],2684 method: input.mergeMethod === "squash" ? "SQUASH" : "MERGE",
2794 ],2685 },
2795 decode: decodeMergeMessage,2686 decode: decodeMergeMessage,
2796 });2687 });
2797 // GitHub's merge queue chooses its own message and ignores custom text.2688 // GitHub's merge queue chooses its own message and ignores custom text.
...@@ -2803,59 +2694,53 @@ export const make = Effect.gen(function* () {...@@ -2803,59 +2694,53 @@ export const make = Effect.gen(function* () {
2803 }2694 }
2804 }2695 }
2805 }2696 }
2806 yield* github.execute({2697 // A merge queue takes a pull request through auto-merge rather than a direct merge, and
2807 cwd: input.cwd,2698 // `--auto` on a pull request that is mergeable right now simply merges it, as `gh` does.
2808 args: [2699 // GitHub stores the strategy with a standing instruction rather than choosing one at
2809 "pr",2700 // merge time, so arming still names it.
2810 subcommand!,2701 const auto =
2811 String(input.number),2702 state.isMergeQueueEnabled === true ||
2812 ...repositoryArgs(input),2703 (action === "enable-auto-merge" &&
2813 ...flags,2704 !IMMEDIATELY_MERGEABLE.has(state.mergeStateStatus?.toUpperCase() ?? ""));
2814 ...(expectedHead === undefined ? [] : ["--match-head-commit", expectedHead]),2705 yield* graphql({
2815 ...(body === undefined ? [] : ["--body-file", "-"]),2706 host: input.host,
2816 ],2707 operation: "runPullRequestAction",
2817 ...(body === undefined ? {} : { stdin: body }),2708 query: auto ? ENABLE_AUTO_MERGE_GRAPHQL_MUTATION : MERGE_PULL_REQUEST_GRAPHQL_MUTATION,
2709 variables: {
2710 input: {
2711 pullRequestId: state.id,
2712 mergeMethod: GRAPHQL_MERGE_METHODS[input.mergeMethod ?? "merge"],
2713 ...(expectedHead === undefined ? {} : { expectedHeadOid: expectedHead }),
2714 ...(body === undefined ? {} : { commitBody: body }),
2715 },
2716 },
2818 });2717 });
2819 });2718 });
2820 },2719 },
28212720
2822 commentOnPullRequest: (input) =>2721 commentOnPullRequest: (input) =>
2823 github2722 pullRequestNodeId({ ...input, operation: "commentOnPullRequest" }).pipe(
2824 .execute({2723 Effect.flatMap((subjectId) =>
2825 cwd: input.cwd,2724 graphql({
2826 // The body travels over stdin: argv is visible in process listings and is echoed2725 host: input.host,
2827 // back inside process-runner failure messages.2726 operation: "commentOnPullRequest",
2828 args: [2727 query: ADD_COMMENT_GRAPHQL_MUTATION,
2829 "pr",2728 variables: { subjectId, body: input.body },
2830 "comment",2729 }),
2831 String(input.number),2730 ),
2832 ...repositoryArgs(input),2731 ),
2833 "--body-file",
2834 "-",
2835 ],
2836 stdin: input.body,
2837 })
2838 .pipe(Effect.asVoid),
28392732
2840 submitReview: (input) => {2733 submitReview: (input) => {
2841 const { owner, name } = parseRepositorySelector(input.repository);2734 const { owner, name } = parseRepositorySelector(input.repository);
2842 return github2735 // The whole review is one request, so nothing is visible to anyone else until the verdict
2843 .execute({2736 // is sent.
2844 cwd: input.cwd,2737 return api
2845 // The whole review is one request, so nothing is visible to anyone else until the2738 .rest({
2846 // verdict is sent. The payload travels over stdin for the same reason a comment2739 host: input.host,
2847 // body does: argv is visible in process listings and echoed back in failures.2740 operation: "submitReview",
2848 args: [2741 method: "POST",
2849 "api",2742 path: `repos/${owner}/${name}/pulls/${input.number}/reviews`,
2850 "--method",2743 body: buildReviewSubmission({
2851 "POST",
2852 "--hostname",
2853 input.host,
2854 `repos/${owner}/${name}/pulls/${input.number}/reviews`,
2855 "--input",
2856 "-",
2857 ],
2858 stdin: buildReviewSubmissionJson({
2859 verdict: input.verdict,2744 verdict: input.verdict,
2860 body: input.body,2745 body: input.body,
2861 comments: input.comments,2746 comments: input.comments,
...@@ -2866,8 +2751,8 @@ export const make = Effect.gen(function* () {...@@ -2866,8 +2751,8 @@ export const make = Effect.gen(function* () {
28662751
2867 replyToReviewThread: (input) =>2752 replyToReviewThread: (input) =>
2868 graphql({2753 graphql({
2869 cwd: input.cwd,
2870 host: input.host,2754 host: input.host,
2755 operation: "replyToReviewThread",
2871 query: REVIEW_THREAD_REPLY_GRAPHQL_MUTATION,2756 query: REVIEW_THREAD_REPLY_GRAPHQL_MUTATION,
2872 variables: { threadId: input.threadId, body: input.body },2757 variables: { threadId: input.threadId, body: input.body },
2873 }),2758 }),
...@@ -2883,14 +2768,7 @@ export const make = Effect.gen(function* () {...@@ -2883,14 +2768,7 @@ export const make = Effect.gen(function* () {
2883 cwd: input.cwd,2768 cwd: input.cwd,
2884 host: input.host,2769 host: input.host,
2885 operation: "getPullRequestFilesViewed",2770 operation: "getPullRequestFilesViewed",
2886 variables: [2771 variables: { owner, name, number: input.number, after },
2887 ["-f", `owner=${owner}`],
2888 ["-f", `name=${name}`],
2889 ["-F", `number=${input.number}`],
2890 ...(after === null
2891 ? []
2892 : ([["-f", `after=${after}`]] as ReadonlyArray<readonly [string, string]>)),
2893 ],
2894 query: PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY,2772 query: PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY,
2895 decode: decodePullRequestFilesViewedJson,2773 decode: decodePullRequestFilesViewedJson,
2896 }).pipe(2774 }).pipe(
...@@ -2913,8 +2791,8 @@ export const make = Effect.gen(function* () {...@@ -2913,8 +2791,8 @@ export const make = Effect.gen(function* () {
2913 return pullRequestNodeId({ ...input, operation: "setPullRequestFilesViewed" }).pipe(2791 return pullRequestNodeId({ ...input, operation: "setPullRequestFilesViewed" }).pipe(
2914 Effect.flatMap((pullRequestId) =>2792 Effect.flatMap((pullRequestId) =>
2915 graphql({2793 graphql({
2916 cwd: input.cwd,
2917 host: input.host,2794 host: input.host,
2795 operation: "setPullRequestFilesViewed",
2918 query: mutation.query,2796 query: mutation.query,
2919 variables: { pullRequestId, ...mutation.variables },2797 variables: { pullRequestId, ...mutation.variables },
2920 }),2798 }),
...@@ -2924,8 +2802,8 @@ export const make = Effect.gen(function* () {...@@ -2924,8 +2802,8 @@ export const make = Effect.gen(function* () {
29242802
2925 setReviewThreadResolution: (input) =>2803 setReviewThreadResolution: (input) =>
2926 graphql({2804 graphql({
2927 cwd: input.cwd,
2928 host: input.host,2805 host: input.host,
2806 operation: "setReviewThreadResolution",
2929 query: input.resolved2807 query: input.resolved
2930 ? RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION2808 ? RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION
2931 : UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION,2809 : UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION,
...@@ -2957,8 +2835,8 @@ export const make = Effect.gen(function* () {...@@ -2957,8 +2835,8 @@ export const make = Effect.gen(function* () {
2957 return subjectId.pipe(2835 return subjectId.pipe(
2958 Effect.flatMap((subjectId) =>2836 Effect.flatMap((subjectId) =>
2959 graphql({2837 graphql({
2960 cwd: input.cwd,
2961 host: input.host,2838 host: input.host,
2839 operation: "setReaction",
2962 query: input.reacted ? ADD_REACTION_GRAPHQL_MUTATION : REMOVE_REACTION_GRAPHQL_MUTATION,2840 query: input.reacted ? ADD_REACTION_GRAPHQL_MUTATION : REMOVE_REACTION_GRAPHQL_MUTATION,
2963 variables: { subjectId, content: gitHubReactionContent(input.content) },2841 variables: { subjectId, content: gitHubReactionContent(input.content) },
2964 }),2842 }),
...@@ -2970,8 +2848,8 @@ export const make = Effect.gen(function* () {...@@ -2970,8 +2848,8 @@ export const make = Effect.gen(function* () {
2970 pullRequestNodeId({ ...input, operation: "updatePullRequest" }).pipe(2848 pullRequestNodeId({ ...input, operation: "updatePullRequest" }).pipe(
2971 Effect.flatMap((pullRequestId) =>2849 Effect.flatMap((pullRequestId) =>
2972 graphql({2850 graphql({
2973 cwd: input.cwd,
2974 host: input.host,2851 host: input.host,
2852 operation: "updatePullRequest",
2975 query: UPDATE_PULL_REQUEST_GRAPHQL_MUTATION,2853 query: UPDATE_PULL_REQUEST_GRAPHQL_MUTATION,
2976 // A field the caller did not name is left out of the request entirely, so GitHub2854 // A field the caller did not name is left out of the request entirely, so GitHub
2977 // keeps the words that are there rather than being asked for an empty one.2855 // keeps the words that are there rather than being asked for an empty one.
...@@ -3006,8 +2884,8 @@ export const make = Effect.gen(function* () {...@@ -3006,8 +2884,8 @@ export const make = Effect.gen(function* () {
3006 ),2884 ),
3007 Effect.flatMap((commentId) =>2885 Effect.flatMap((commentId) =>
3008 graphql({2886 graphql({
3009 cwd: input.cwd,
3010 host: input.host,2887 host: input.host,
2888 operation: "updateComment",
3011 query:2889 query:
3012 input.kind === "issue-comment"2890 input.kind === "issue-comment"
3013 ? UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION2891 ? UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION
apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts+6-7
...@@ -6,7 +6,7 @@ import * as Result from "effect/Result";...@@ -6,7 +6,7 @@ import * as Result from "effect/Result";
6import type { PullRequestReaction } from "@t3tools/contracts";6import type { PullRequestReaction } from "@t3tools/contracts";
77
8import { decodePullRequestDetailJson } from "./gitHubPullRequestJson.ts";8import { decodePullRequestDetailJson } from "./gitHubPullRequestJson.ts";
9import * as GitHubCli from "../sourceControl/GitHubCli.ts";9import * as GitHubApi from "../sourceControl/GitHubApi.ts";
10import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";10import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";
11import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts";11import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts";
12import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts";12import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts";
...@@ -331,7 +331,7 @@ describe("gitHubViewerPermissions", () => {...@@ -331,7 +331,7 @@ describe("gitHubViewerPermissions", () => {
331 number: 7,331 number: 7,
332 })332 })
333 .pipe(333 .pipe(
334 Effect.provideService(GitHubCli.PinnedGitHubCredential, {334 Effect.provideService(GitHubApi.PinnedGitHubCredential, {
335 host: "github.com",335 host: "github.com",
336 token: Redacted.make("credential"),336 token: Redacted.make("credential"),
337 credentialFingerprint: fingerprint,337 credentialFingerprint: fingerprint,
...@@ -378,7 +378,7 @@ describe("gitHubViewerPermissions", () => {...@@ -378,7 +378,7 @@ describe("gitHubViewerPermissions", () => {
378 commits: [],378 commits: [],
379 }).pipe(379 }).pipe(
380 Effect.flatMap((detail) =>380 Effect.flatMap((detail) =>
381 GitHubCli.PinnedGitHubCredential.pipe(381 GitHubApi.PinnedGitHubCredential.pipe(
382 Effect.map((credential) => ({382 Effect.map((credential) => ({
383 ...detail,383 ...detail,
384 viewerAccess: {384 viewerAccess: {
...@@ -672,10 +672,9 @@ it.effect("propagates workflow discovery rate limits", () =>...@@ -672,10 +672,9 @@ it.effect("propagates workflow discovery rate limits", () =>
672 getPullRequestBaseComparison: () => Effect.succeed({ behindBy: 0, viewerCanUpdate: true }),672 getPullRequestBaseComparison: () => Effect.succeed({ behindBy: 0, viewerCanUpdate: true }),
673 listWorkflowRunsRequiringApproval: () =>673 listWorkflowRunsRequiringApproval: () =>
674 Effect.fail(674 Effect.fail(
675 new GitHubCli.GitHubCliRateLimitError({675 new GitHubApi.GitHubApiRateLimitError({
676 command: "gh",676 host: "github.com",
677 cwd: "/w",677 operation: "listWorkflowRunsRequiringApproval",
678 cause: new Error("rate limited"),
679 }),678 }),
680 ),679 ),
681 getViewerAccess: () =>680 getViewerAccess: () =>
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+20-14
...@@ -101,22 +101,28 @@ export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequest...@@ -101,22 +101,28 @@ export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequest
101 };101 };
102}102}
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. */
105export function gitHubProviderFailure(105export function gitHubProviderFailure(
106 error: GitHubPullRequestCli.GitHubPullRequestCliError,106 error: GitHubPullRequestCli.GitHubPullRequestCliError,
107): PullRequestProviderFailure {107): PullRequestProviderFailure {
108 if (error._tag === "GitHubCliUnavailableError") return { reason: "missing-tool" };108 switch (error._tag) {
109 if (error._tag === "GitHubCliAuthenticationError") return { reason: "unauthenticated" };109 case "GitHubCliMissingError":
110 if (error._tag === "GitHubCliRateLimitError")110 return { reason: "missing-tool" };
111 return {111 case "GitHubNotSignedInError":
112 reason: "rate-limited",112 case "GitHubApiAuthenticationError":
113 ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }),113 return { reason: "unauthenticated" };
114 };114 case "GitHubApiRateLimitError":
115 if (error._tag === "SourceControlRateLimitPausedError") {115 return {
116 return { reason: "rate-limited", retryAt: error.retryAt };116 reason: "rate-limited",
117 ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }),
118 };
119 case "SourceControlRateLimitPausedError":
120 return { reason: "rate-limited", retryAt: error.retryAt };
121 case "GitHubApiNotFoundError":
122 return { reason: "not-found" };
123 default:
124 return { reason: "failed" };
117 }125 }
118 if (error._tag === "GitHubPullRequestNotFoundError") return { reason: "not-found" };
119 return { reason: "failed" };
120}126}
121127
122/**128/**
...@@ -196,7 +202,7 @@ export const make = Effect.gen(function* () {...@@ -196,7 +202,7 @@ export const make = Effect.gen(function* () {
196 provider: "github",202 provider: "github",
197 operation,203 operation,
198 ...gitHubProviderFailure(error),204 ...gitHubProviderFailure(error),
199 detail: error.detail,205 detail: error.message,
200 cause: error,206 cause: error,
201 });207 });
202208
...@@ -224,7 +230,7 @@ export const make = Effect.gen(function* () {...@@ -224,7 +230,7 @@ export const make = Effect.gen(function* () {
224 .pipe(230 .pipe(
225 Effect.matchEffect({231 Effect.matchEffect({
226 onFailure: (error) =>232 onFailure: (error) =>
227 error._tag === "GitHubCliRateLimitError" ||233 error._tag === "GitHubApiRateLimitError" ||
228 error._tag === "SourceControlRateLimitPausedError"234 error._tag === "SourceControlRateLimitPausedError"
229 ? Effect.fail(error)235 ? Effect.fail(error)
230 : Effect.succeed({236 : Effect.succeed({
apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts+2-2
...@@ -2,7 +2,7 @@ import { assert, it } from "@effect/vitest";...@@ -2,7 +2,7 @@ import { assert, it } from "@effect/vitest";
22
3import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts";3import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts";
4import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";4import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";
5import * as GitHubCli from "../sourceControl/GitHubCli.ts";5import * as GitHubApi from "../sourceControl/GitHubApi.ts";
6import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts";6import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts";
7import * as GitLabCli from "../sourceControl/GitLabCli.ts";7import * as GitLabCli from "../sourceControl/GitLabCli.ts";
8import { azureDevOpsProviderFailure } from "./AzureDevOpsPullRequestProvider.ts";8import { azureDevOpsProviderFailure } from "./AzureDevOpsPullRequestProvider.ts";
...@@ -15,7 +15,7 @@ const cause = new Error("redacted provider failure");...@@ -15,7 +15,7 @@ const cause = new Error("redacted provider failure");
15it("classifies rate limits from every pull-request provider", () => {15it("classifies rate limits from every pull-request provider", () => {
16 assert.deepStrictEqual(16 assert.deepStrictEqual(
17 gitHubProviderFailure(17 gitHubProviderFailure(
18 new GitHubCli.GitHubCliRateLimitError({ command: "gh", cwd: "/repo", cause }),18 new GitHubApi.GitHubApiRateLimitError({ host: "github.com", operation: "execute" }),
19 ),19 ),
20 { reason: "rate-limited" },20 { reason: "rate-limited" },
21 );21 );
apps/server/src/pullRequest/PullRequestProviderRegistry.ts+11-3
...@@ -5,8 +5,10 @@ import type { SourceControlProviderKind } from "@t3tools/contracts";...@@ -5,8 +5,10 @@ import type { SourceControlProviderKind } from "@t3tools/contracts";
55
6import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts";6import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts";
7import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";7import * 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";
9import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts";10import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts";
11import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts";
10import * as GitLabCli from "../sourceControl/GitLabCli.ts";12import * as GitLabCli from "../sourceControl/GitLabCli.ts";
11import * as ForgejoCli from "../sourceControl/ForgejoCli.ts";13import * as ForgejoCli from "../sourceControl/ForgejoCli.ts";
12import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts";14import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts";
...@@ -60,8 +62,14 @@ export const make = Effect.map(...@@ -60,8 +62,14 @@ export const make = Effect.map(
60export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe(62export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe(
61 Layer.provide(63 Layer.provide(
62 GitHubPullRequestCli.layer.pipe(64 GitHubPullRequestCli.layer.pipe(
63 Layer.provide(GitHubCli.layer),65 Layer.provide(
64 Layer.provide(GitHubGraphQlBudget.layer),66 GitHubApi.layer.pipe(
67 Layer.provide(GitHubCredentials.layer),
68 // The same layers GitHubCli merges, so both share one budget and one pause per host.
69 Layer.provide(GitHubGraphQlBudget.layer),
70 Layer.provide(SourceControlRateLimit.layer),
71 ),
72 ),
65 ),73 ),
66 ),74 ),
67 Layer.provide(GitLabPullRequestCli.layer.pipe(Layer.provide(GitLabCli.layer))),75 Layer.provide(GitLabPullRequestCli.layer.pipe(Layer.provide(GitLabCli.layer))),
apps/server/src/pullRequest/gitHubConditionalChecks.test.ts+72-46
...@@ -4,11 +4,10 @@ import * as Effect from "effect/Effect";...@@ -4,11 +4,10 @@ import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
5import * as Schema from "effect/Schema";5import * as Schema from "effect/Schema";
6import * as Redacted from "effect/Redacted";6import * as Redacted from "effect/Redacted";
7import { ChildProcessSpawner } from "effect/process";
8import type { PullRequestCheck } from "@t3tools/contracts";7import type { PullRequestCheck } from "@t3tools/contracts";
98
10import * as GitHubCli from "../sourceControl/GitHubCli.ts";9import * as GitHubApi from "../sourceControl/GitHubApi.ts";
11import { makeChecksRevalidator } from "./gitHubConditionalChecks.ts";10import { KnownWorkflowRuns, makeChecksRevalidator } from "./gitHubConditionalChecks.ts";
1211
13const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));12const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
1413
...@@ -27,31 +26,46 @@ it.effect(...@@ -27,31 +26,46 @@ it.effect(
27 let changed = "";26 let changed = "";
28 let reads = 0;27 let reads = 0;
29 const requests: string[] = [];28 const requests: string[] = [];
29 const known: Array<unknown> = [];
30 const revalidate = yield* makeChecksRevalidator.pipe(30 const revalidate = yield* makeChecksRevalidator.pipe(
31 Effect.provide(31 Effect.provide(
32 Layer.mock(GitHubCli.GitHubCli)({32 Layer.mock(GitHubApi.GitHubApi)({
33 execute: ({ args }) =>33 rest: ({ path, ifNoneMatch }) =>
34 Effect.sync(() => {34 Effect.sync(() => {
35 const endpoint = args[1]!;35 requests.push(path);
36 requests.push(endpoint);36 const head = path.endsWith("/pulls/1");
37 const head = endpoint.endsWith("/pulls/1");
38 const modified =37 const modified =
39 !args.includes("-H") || (endpoint.includes(changed) && changed !== "");38 ifNoneMatch === undefined || (path.includes(changed) && changed !== "");
40 const next = endpoint.includes("check-runs") && endpoint.endsWith("page=1");39 const next = path.includes("check-runs") && path.endsWith("page=1");
40 const runs = path.includes("/actions/runs");
41 return {41 return {
42 exitCode: ChildProcessSpawner.ExitCode(modified ? 0 : 1),42 status: modified ? 200 : 304,
43 stdout: modified43 headers: modified
44 ? `HTTP/2.0 200 OK\r\nEtag: "${sha}-${changed}"\r\n${next ? 'Link: <https://api.github.com/next>; rel="next"\r\n' : ""}\r\n${head ? encodeJson({ head: { sha }, base: { repo: { id: 1 } }, headRepositoryId: 2 }) : ""}`44 ? {
45 : "HTTP/2.0 304 Not Modified\r\n\r\n",45 etag: `"${sha}-${changed}"`,
46 stderr: "",46 ...(next ? { link: '<https://api.github.com/next>; rel="next"' } : {}),
47 stdoutTruncated: false,47 }
48 stderrTruncated: false,48 : {},
49 body: !modified
50 ? ""
51 : head
52 ? encodeJson({ head: { sha, repo: { id: 2 } }, base: { repo: { id: 1 } } })
53 : runs
54 ? encodeJson({
55 workflow_runs: [
56 { id: 9, status: "completed", conclusion: "action_required" },
57 ],
58 })
59 : "{}",
60 truncated: false,
61 invalidUtf8: false,
49 };62 };
50 }),63 }),
51 }),64 }),
52 ),65 ),
53 );66 );
54 const read = Effect.sync(() => {67 const read = Effect.gen(function* () {
68 known.push(yield* KnownWorkflowRuns);
55 reads++;69 reads++;
56 return {70 return {
57 state: "open" as const,71 state: "open" as const,
...@@ -64,10 +78,15 @@ it.effect(...@@ -64,10 +78,15 @@ it.effect(
64 });78 });
65 const poll = (identity = credential) =>79 const poll = (identity = credential) =>
66 revalidate(reference, read).pipe(80 revalidate(reference, read).pipe(
67 Effect.provideService(GitHubCli.PinnedGitHubCredential, identity),81 Effect.provideService(GitHubApi.PinnedGitHubCredential, identity),
68 );82 );
69 yield* poll();83 yield* poll();
70 expect(reads).toBe(1);84 expect(reads).toBe(1);
85 // The fork's runs were just confirmed, so the read is handed them instead of listing them.
86 expect(known[0]).toEqual({
87 headSha: sha,
88 runs: [{ id: 9, status: "completed", conclusion: "action_required" }],
89 });
71 requests.length = 0;90 requests.length = 0;
72 yield* poll();91 yield* poll();
73 expect(reads).toBe(1);92 expect(reads).toBe(1);
...@@ -113,27 +132,32 @@ it.effect("does not retain failed or incomplete reads, and supports hosts withou...@@ -113,27 +132,32 @@ it.effect("does not retain failed or incomplete reads, and supports hosts withou
113 let reads = 0;132 let reads = 0;
114 const revalidate = yield* makeChecksRevalidator.pipe(133 const revalidate = yield* makeChecksRevalidator.pipe(
115 Effect.provide(134 Effect.provide(
116 Layer.mock(GitHubCli.GitHubCli)({135 Layer.mock(GitHubApi.GitHubApi)({
117 execute: ({ args }) =>136 rest: ({ path, ifNoneMatch }) =>
118 unavailable137 unavailable
119 ? Effect.fail(138 ? Effect.fail(
120 new GitHubCli.GitHubCliCommandError({139 new GitHubApi.GitHubApiResponseError({
121 command: "gh",140 host: "github.com",
122 cwd: "/repo",141 operation: "revalidateChecks",
123 cause: undefined,142 status: 502,
124 httpStatus: 502,
125 }),143 }),
126 )144 )
127 : Effect.succeed({145 : Effect.succeed(
128 exitCode: ChildProcessSpawner.ExitCode(0),146 ifNoneMatch !== undefined && etags
129 stdout:147 ? { status: 304, headers: {}, body: "", truncated: false, invalidUtf8: false }
130 args.includes("-H") && etags148 : {
131 ? "HTTP/2.0 304 Not Modified\n\n"149 status: 200,
132 : `HTTP/2.0 200 OK\n${etags ? 'Etag: "one"\n' : ""}\n${args[1]!.endsWith("/pulls/1") ? encodeJson({ head: { sha }, base: { repo: { id: 1 } }, headRepositoryId: 1 }) : ""}`,150 headers: etags ? { etag: '"one"' } : {},
133 stderr: "",151 body: path.endsWith("/pulls/1")
134 stdoutTruncated: false,152 ? encodeJson({
135 stderrTruncated: false,153 head: { sha, repo: { id: 1 } },
136 }),154 base: { repo: { id: 1 } },
155 })
156 : "{}",
157 truncated: false,
158 invalidUtf8: false,
159 },
160 ),
137 }),161 }),
138 ),162 ),
139 );163 );
...@@ -141,7 +165,11 @@ it.effect("does not retain failed or incomplete reads, and supports hosts withou...@@ -141,7 +165,11 @@ it.effect("does not retain failed or incomplete reads, and supports hosts withou
141 reads++;165 reads++;
142 return fail166 return fail
143 ? Effect.fail(167 ? Effect.fail(
144 new GitHubCli.GitHubCliCommandError({ command: "gh", cwd: "/repo", cause: undefined }),168 new GitHubApi.GitHubApiResponseError({
169 host: "github.com",
170 operation: "read",
171 status: 500,
172 }),
145 )173 )
146 : Effect.succeed({174 : Effect.succeed({
147 state: "open" as const,175 state: "open" as const,
...@@ -152,7 +180,7 @@ it.effect("does not retain failed or incomplete reads, and supports hosts withou...@@ -152,7 +180,7 @@ it.effect("does not retain failed or incomplete reads, and supports hosts withou
152 });180 });
153 const poll = () =>181 const poll = () =>
154 revalidate(reference, read).pipe(182 revalidate(reference, read).pipe(
155 Effect.provideService(GitHubCli.PinnedGitHubCredential, credential),183 Effect.provideService(GitHubApi.PinnedGitHubCredential, credential),
156 );184 );
157 yield* poll().pipe(Effect.flip);185 yield* poll().pipe(Effect.flip);
158 fail = false;186 fail = false;
...@@ -190,15 +218,13 @@ it.effect("falls back when REST checks are unavailable without retrying unsuppor...@@ -190,15 +218,13 @@ it.effect("falls back when REST checks are unavailable without retrying unsuppor
190 let reads = 0;218 let reads = 0;
191 const revalidate = yield* makeChecksRevalidator.pipe(219 const revalidate = yield* makeChecksRevalidator.pipe(
192 Effect.provide(220 Effect.provide(
193 Layer.mock(GitHubCli.GitHubCli)({221 Layer.mock(GitHubApi.GitHubApi)({
194 execute: () => {222 rest: () => {
195 probes++;223 probes++;
196 return Effect.fail(224 return Effect.fail(
197 new GitHubCli.GitHubCliCommandError({225 new GitHubApi.GitHubApiNotFoundError({
198 command: "gh",226 host: "github.com",
199 cwd: "/repo",227 operation: "revalidateChecks",
200 cause: undefined,
201 httpStatus: 404,
202 }),228 }),
203 );229 );
204 },230 },
...@@ -211,7 +237,7 @@ it.effect("falls back when REST checks are unavailable without retrying unsuppor...@@ -211,7 +237,7 @@ it.effect("falls back when REST checks are unavailable without retrying unsuppor
211 });237 });
212 for (let tick = 0; tick < 2; tick++)238 for (let tick = 0; tick < 2; tick++)
213 yield* revalidate(reference, read).pipe(239 yield* revalidate(reference, read).pipe(
214 Effect.provideService(GitHubCli.PinnedGitHubCredential, credential),240 Effect.provideService(GitHubApi.PinnedGitHubCredential, credential),
215 );241 );
216 expect(probes).toBe(1);242 expect(probes).toBe(1);
217 expect(reads).toBe(2);243 expect(reads).toBe(2);
apps/server/src/pullRequest/gitHubConditionalChecks.ts+95-54
...@@ -1,32 +1,63 @@...@@ -1,32 +1,63 @@
1import * as Clock from "effect/Clock";1import * as Clock from "effect/Clock";
2import * as Cache from "effect/Cache";2import * as Cache from "effect/Cache";
3import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5import * as Option from "effect/Option";
4import * as Schema from "effect/Schema";6import * as Schema from "effect/Schema";
5import * as Semaphore from "effect/Semaphore";7import * as Semaphore from "effect/Semaphore";
6import { PositiveInt, type PullRequestChecks } from "@t3tools/contracts";8import { PositiveInt, type PullRequestChecks } from "@t3tools/contracts";
79
8import * as GitHubCli from "../sourceControl/GitHubCli.ts";10import * as GitHubApi from "../sourceControl/GitHubApi.ts";
9import type { GitHubPullRequestDetail } from "./gitHubPullRequestJson.ts";11import type { GitHubPullRequestDetail } from "./gitHubPullRequestJson.ts";
10import type { GitHubPullRequestCliError } from "./GitHubPullRequestCli.ts";12import type { GitHubPullRequestCliError } from "./GitHubPullRequestCli.ts";
11import type { ProviderRepositoryRef } from "./PullRequestProvider.ts";13import type { ProviderRepositoryRef } from "./PullRequestProvider.ts";
1214
13const decodeHeadSchema = Schema.Struct({15const HeadSchema = Schema.Struct({
14 head: Schema.Struct({ sha: Schema.String.check(Schema.isPattern(/^[a-f0-9]{40,64}$/)) }),16 head: Schema.Struct({
17 sha: Schema.String.check(Schema.isPattern(/^[a-f0-9]{40,64}$/)),
18 // Null once the fork a pull request came from has been deleted.
19 repo: Schema.NullOr(Schema.Struct({ id: PositiveInt })),
20 }),
15 base: Schema.Struct({ repo: Schema.Struct({ id: PositiveInt }) }),21 base: Schema.Struct({ repo: Schema.Struct({ id: PositiveInt }) }),
16 headRepositoryId: Schema.NullOr(PositiveInt),
17});22});
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
22export const makeChecksRevalidator = Effect.gen(function* () {53export const makeChecksRevalidator = Effect.gen(function* () {
23 const github = yield* GitHubCli.GitHubCli;54 const api = yield* GitHubApi.GitHubApi;
24 const entries = yield* Cache.makeWith(55 const entries = yield* Cache.makeWith(
25 (_key: string) =>56 (_key: string) =>
26 Effect.sync(() => ({57 Effect.sync(() => ({
27 gate: Semaphore.makeUnsafe(1),58 gate: Semaphore.makeUnsafe(1),
28 validators: new Map<string, Validator>(),59 validators: new Map<string, Validator>(),
29 head: null as Schema.Schema.Type<typeof decodeHeadSchema> | null,60 head: null as typeof HeadSchema.Type | null,
30 value: null as PullRequestChecks | null,61 value: null as PullRequestChecks | null,
31 readAt: 0,62 readAt: 0,
32 supported: true,63 supported: true,
...@@ -43,71 +74,64 @@ export const makeChecksRevalidator = Effect.gen(function* () {...@@ -43,71 +74,64 @@ export const makeChecksRevalidator = Effect.gen(function* () {
43 >,74 >,
44 ) =>75 ) =>
45 Effect.gen(function* () {76 Effect.gen(function* () {
46 const credential = yield* GitHubCli.PinnedGitHubCredential;77 const credential = yield* GitHubApi.PinnedGitHubCredential;
47 if (credential === null) return yield* read;78 if (credential === null) return yield* read;
48 const key = `${credential.credentialFingerprint}\0${input.host}\0${input.repository}\0${input.number}`;79 const key = `${credential.credentialFingerprint}\0${input.host}\0${input.repository}\0${input.number}`;
49 const entry = yield* Cache.get(entries, key);80 const entry = yield* Cache.get(entries, key);
50 return yield* entry.gate.withPermit(81 return yield* entry.gate.withPermit(
51 Effect.gen(function* () {82 Effect.gen(function* () {
52 if (!entry.supported) return yield* read;83 if (!entry.supported) return yield* read;
53 const fail = () =>84 const fail = (status: number) =>
54 new GitHubCli.GitHubCliCommandError({85 new GitHubApi.GitHubApiResponseError({
55 command: "gh",86 host: input.host,
56 cwd: input.cwd,87 operation: "revalidateChecks",
57 cause: new Error("GitHub returned an invalid conditional checks response."),88 status,
58 });89 });
90 /** Whether the endpoint has a next page, or null where the host cannot say. */
59 const get = (endpoint: string, head = false) =>91 const get = (endpoint: string, head = false) =>
60 Effect.gen(function* () {92 Effect.gen(function* () {
61 const previous = entry.validators.get(endpoint);93 const previous = entry.validators.get(endpoint);
62 const result = yield* github94 const response = yield* api
63 .execute({95 .rest({
64 cwd: input.cwd,96 host: input.host,
65 args: [97 operation: "revalidateChecks",
66 "api",98 path: endpoint,
67 endpoint,99 ...(previous?.etag ? { ifNoneMatch: previous.etag } : {}),
68 "--hostname",
69 input.host,
70 "--include",
71 ...(head
72 ? [
73 "--jq",
74 "{head:{sha:.head.sha},base:{repo:{id:.base.repo.id}},headRepositoryId:.head.repo.id}",
75 ]
76 : ["--silent"]),
77 ...(previous?.etag ? ["-H", `If-None-Match: ${previous.etag}`] : []),
78 ],
79 acceptNotModified: true,
80 })100 })
81 .pipe(101 .pipe(
82 Effect.catchTags({102 Effect.catchTags({
83 GitHubCliCommandError: (error) =>103 GitHubApiNotFoundError: () =>
84 Effect.sync(() => {104 Effect.sync(() => {
85 entry.supported = ![404, 405, 501].includes(error.httpStatus ?? 0);105 entry.supported = false;
106 return null;
107 }),
108 GitHubApiResponseError: (error) =>
109 Effect.sync(() => {
110 entry.supported = !UNSUPPORTED_STATUSES.has(error.status);
86 return null;111 return null;
87 }),112 }),
88 }),113 }),
89 );114 );
90 if (result === null) {115 if (response === null) {
91 entry.value = null;116 entry.value = null;
92 return null;117 return null;
93 }118 }
94 if (result.stdoutTruncated || result.stdoutInvalidUtf8) return yield* fail();119 if (response.status === 304 && previous) return previous.next;
95 const split = result.stdout.search(/\r?\n\r?\n/);120 if (response.status !== 200 || response.truncated) {
96 const headers = split < 0 ? result.stdout : result.stdout.slice(0, split);121 return yield* fail(response.status);
97 const status = /^HTTP\/\S+ (\d+)/.exec(headers)?.[1];122 }
98 if (status === "304" && previous) return previous.next;
99 if (status !== "200") return yield* fail();
100 entry.value = null;123 entry.value = null;
101 if (head) {124 if (head) {
102 const decoded = yield* decodeHead(result.stdout.slice(split).trim()).pipe(125 const decoded = yield* decodeHead(response.body).pipe(
103 Effect.mapError(fail),126 Effect.mapError(() => fail(response.status)),
104 );127 );
105 if (entry.head?.head.sha !== decoded.head.sha) entry.validators.clear();128 if (entry.head?.head.sha !== decoded.head.sha) entry.validators.clear();
106 entry.head = decoded;129 entry.head = decoded;
107 }130 }
108 const validator = {131 const validator = {
109 etag: /^etag:\s*(.+)$/im.exec(headers)?.[1]?.trim(),132 etag: response.headers["etag"]?.trim(),
110 next: /^link:.*rel="next"/im.test(headers),133 next: /rel="next"/.test(response.headers["link"] ?? ""),
134 body: response.body,
111 };135 };
112 entry.validators.set(endpoint, validator);136 entry.validators.set(endpoint, validator);
113 return validator.next;137 return validator.next;
...@@ -115,27 +139,44 @@ export const makeChecksRevalidator = Effect.gen(function* () {...@@ -115,27 +139,44 @@ export const makeChecksRevalidator = Effect.gen(function* () {
115 const root = `repos/${input.repository}`;139 const root = `repos/${input.repository}`;
116 if ((yield* get(`${root}/pulls/${input.number}`, true)) === null) return yield* read;140 if ((yield* get(`${root}/pulls/${input.number}`, true)) === null) return yield* read;
117 const head = entry.head;141 const head = entry.head;
118 if (head === null) return yield* fail();142 if (head === null) return yield* fail(200);
143 const runsEndpoint =
144 head.head.repo?.id !== head.base.repo.id
145 ? `${root}/actions/runs?head_sha=${head.head.sha}&event=pull_request`
146 : null;
119 const endpoints = [147 const endpoints = [
120 `${root}/commits/${head.head.sha}/check-runs?filter=all`,148 `${root}/commits/${head.head.sha}/check-runs?filter=all`,
121 `${root}/commits/${head.head.sha}/status`,149 `${root}/commits/${head.head.sha}/status`,
122 ...(head.headRepositoryId !== head.base.repo.id150 ...(runsEndpoint === null ? [] : [runsEndpoint]),
123 ? [`${root}/actions/runs?head_sha=${head.head.sha}&event=pull_request`]
124 : []),
125 ];151 ];
152 const runPages: Array<string> = [];
126 for (const endpoint of endpoints) {153 for (const endpoint of endpoints) {
127 for (let page = 1; ; page++) {154 for (let page = 1; ; page++) {
128 if (page > 100) return yield* fail();155 if (page > 100) return yield* fail(200);
129 const next = yield* get(156 const paged = `${endpoint}${endpoint.includes("?") ? "&" : "?"}per_page=100&page=${page}`;
130 `${endpoint}${endpoint.includes("?") ? "&" : "?"}per_page=100&page=${page}`,157 const next = yield* get(paged);
131 );
132 if (next === null) return yield* read;158 if (next === null) return yield* read;
159 if (endpoint === runsEndpoint) runPages.push(paged);
133 if (!next) break;160 if (!next) break;
134 }161 }
135 }162 }
136 const now = yield* Clock.currentTimeMillis;163 const now = yield* Clock.currentTimeMillis;
137 if (entry.value !== null && now - entry.readAt < 5 * 60_000) return entry.value;164 if (entry.value !== null && now - entry.readAt < 5 * 60_000) return entry.value;
138 const fresh = yield* read;165 // Every page of the head's runs was just confirmed current, so the read filters them
166 // rather than listing the runs a second time.
167 const pages = runPages.map((endpoint) =>
168 decodeWorkflowRuns(entry.validators.get(endpoint)?.body ?? ""),
169 );
170 const known =
171 runsEndpoint !== null && pages.every(Option.isSome)
172 ? {
173 headSha: head.head.sha,
174 runs: pages.flatMap((page) =>
175 Option.isSome(page) ? page.value.workflow_runs : [],
176 ),
177 }
178 : null;
179 const fresh = yield* read.pipe(Effect.provideService(KnownWorkflowRuns, known));
139 const value = {180 const value = {
140 state: fresh.state,181 state: fresh.state,
141 checks: fresh.checks,182 checks: fresh.checks,
apps/server/src/pullRequest/gitHubPullRequestJson.test.ts+145-114
...@@ -6,10 +6,10 @@ import {...@@ -6,10 +6,10 @@ import {
6 buildPullRequestWatchFingerprintsGraphQlQuery,6 buildPullRequestWatchFingerprintsGraphQlQuery,
7 decodePullRequestSummariesJson,7 decodePullRequestSummariesJson,
8 decodePullRequestWatchFingerprintsJson,8 decodePullRequestWatchFingerprintsJson,
9 buildReviewSubmissionJson,9 buildReviewSubmission,
10 buildPullRequestStackMembershipsGraphQlQuery,10 buildPullRequestStackMembershipsGraphQlQuery,
11 decodePullRequestStackMembershipsJson,11 decodePullRequestStackMembershipsJson,
12 buildReviewerRequestJson,12 buildReviewerRequest,
13 buildSetFilesViewedGraphQlMutation,13 buildSetFilesViewedGraphQlMutation,
14 decodeBaseComparisonJson,14 decodeBaseComparisonJson,
15 decodePullRequestActivityJson,15 decodePullRequestActivityJson,
...@@ -25,26 +25,50 @@ import {...@@ -25,26 +25,50 @@ import {
25 decodeReviewThreadCommentsJson,25 decodeReviewThreadCommentsJson,
26 decodeReviewThreadsJson,26 decodeReviewThreadsJson,
27 decodeViewerPermissionsJson,27 decodeViewerPermissionsJson,
28 decodeWorkflowRunApprovalsJson,28 decodeWorkflowRunsJson,
29 reviewThreadConversation,29 reviewThreadConversation,
30 REVIEW_THREADS_GRAPHQL_QUERY,30 REVIEW_THREADS_GRAPHQL_QUERY,
31 pullRequestCoreGraphQlQuery,31 pullRequestCoreGraphQlQuery,
32 pullRequestSearchGraphQlQuery,32 pullRequestSearchGraphQlQuery,
33} from "./gitHubPullRequestJson.ts";33} from "./gitHubPullRequestJson.ts";
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 */
35function listJson(entries: ReadonlyArray<Record<string, unknown>>): string {39function listJson(entries: ReadonlyArray<Record<string, unknown>>): string {
36 return JSON.stringify(40 return JSON.stringify({
37 entries.map((entry) => ({41 data: {
38 number: 1,42 repository: {
39 title: "Add the pull requests page",43 pullRequests: {
40 url: "https://github.com/pingdotgg/t3code/pull/1",44 pageInfo: { hasNextPage: false },
41 headRefName: "feat/page",45 nodes: entries.map(({ reviewRequests, latestReviews, checks, ...entry }) => ({
42 baseRefName: "main",46 number: 1,
43 createdAt: "2026-07-01T00:00:00Z",47 title: "Add the pull requests page",
44 updatedAt: "2026-07-02T00:00:00Z",48 url: "https://github.com/pingdotgg/t3code/pull/1",
45 ...entry,49 headRefName: "feat/page",
46 })),50 baseRefName: "main",
47 );51 createdAt: "2026-07-01T00:00:00Z",
52 updatedAt: "2026-07-02T00:00:00Z",
53 ...(reviewRequests === undefined
54 ? {}
55 : {
56 reviewRequests: {
57 nodes: (reviewRequests as ReadonlyArray<unknown>).map((requestedReviewer) => ({
58 requestedReviewer,
59 })),
60 },
61 }),
62 ...(latestReviews === undefined ? {} : { latestReviews: { nodes: latestReviews } }),
63 ...(checks === undefined
64 ? {}
65 : { commits: { nodes: [{ commit: { statusCheckRollup: { state: checks } } }] } }),
66 ...entry,
67 })),
68 },
69 },
70 },
71 });
48}72}
4973
50function expectSuccess<A>(result: Result.Result<A, unknown>): A {74function expectSuccess<A>(result: Result.Result<A, unknown>): A {
...@@ -136,38 +160,15 @@ describe("pull request list decoding", () => {...@@ -136,38 +160,15 @@ describe("pull request list decoding", () => {
136 ]);160 ]);
137 });161 });
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", () => {
140 const batch = expectSuccess(164 const batch = expectSuccess(
141 decodePullRequestListJson(165 decodePullRequestListJson(
142 listJson([166 listJson([
143 // A failure outranks a run still going, and a completed run has to be read through its167 { checks: "FAILURE" },
144 // conclusion rather than its status.168 { checks: "PENDING" },
145 {169 { checks: "SUCCESS" },
146 statusCheckRollup: [170 { checks: "ERROR" },
147 { name: "lint", status: "COMPLETED", conclusion: "SUCCESS" },171 { checks: "EXPECTED" },
148 { name: "build", status: "IN_PROGRESS" },
149 { name: "test", status: "COMPLETED", conclusion: "FAILURE" },
150 ],
151 },
152 {
153 statusCheckRollup: [
154 { name: "lint", status: "COMPLETED", conclusion: "SUCCESS" },
155 { name: "build", status: "QUEUED" },
156 ],
157 },
158 { statusCheckRollup: [{ name: "lint", status: "COMPLETED", conclusion: "SUCCESS" }] },
159 // A commit status reports one `state` and no `status` at all.
160 { statusCheckRollup: [{ context: "ci/legacy", state: "ERROR" }] },
161 // Neither a pass, a failure nor a wait is no verdict rather than a green tick.
162 { statusCheckRollup: [{ name: "lint", status: "COMPLETED", conclusion: "SKIPPED" }] },
163 // Cancelled reads as failing here and in the detail header, so the two never flap.
164 {
165 statusCheckRollup: [
166 { name: "lint", status: "COMPLETED", conclusion: "SUCCESS" },
167 { name: "test", status: "COMPLETED", conclusion: "CANCELLED" },
168 ],
169 },
170 { statusCheckRollup: [] },
171 {},172 {},
172 ]),173 ]),
173 ),174 ),
...@@ -177,16 +178,15 @@ describe("pull request list decoding", () => {...@@ -177,16 +178,15 @@ describe("pull request list decoding", () => {
177 "pending",178 "pending",
178 "passing",179 "passing",
179 "failing",180 "failing",
180 null,181 "pending",
181 "failing",
182 null,
183 null,182 null,
184 ]);183 ]);
185 });184 });
186185
187 it("skips malformed entries but still counts them, so paging does not stop early", () => {186 it("skips malformed entries but still counts them, so paging does not stop early", () => {
188 const raw = `[${listJson([{}]).slice(1, -1)},{"number":"not-a-number"}]`;187 const batch = expectSuccess(
189 const batch = expectSuccess(decodePullRequestListJson(raw));188 decodePullRequestListJson(listJson([{}, { number: "not-a-number" }])),
189 );
190 expect(batch.items).toHaveLength(1);190 expect(batch.items).toHaveLength(1);
191 expect(batch.rawCount).toBe(2);191 expect(batch.rawCount).toBe(2);
192 });192 });
...@@ -279,6 +279,43 @@ describe("pull request detail decoding", () => {...@@ -279,6 +279,43 @@ describe("pull request detail decoding", () => {
279 ],279 ],
280 });280 });
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
282 it("maps check-run status and commit-status state onto one vocabulary", () => {319 it("maps check-run status and commit-status state onto one vocabulary", () => {
283 const detail = expectSuccess(decodePullRequestDetailJson(detailJson));320 const detail = expectSuccess(decodePullRequestDetailJson(detailJson));
284 expect(detail.checks.map((check) => [check.name, check.status])).toEqual([321 expect(detail.checks.map((check) => [check.name, check.status])).toEqual([
...@@ -333,13 +370,15 @@ describe("pull request detail decoding", () => {...@@ -333,13 +370,15 @@ describe("pull request detail decoding", () => {
333 it("decodes workflow runs that can be approved", () => {370 it("decodes workflow runs that can be approved", () => {
334 expect(371 expect(
335 expectSuccess(372 expectSuccess(
336 decodeWorkflowRunApprovalsJson(373 decodeWorkflowRunsJson(
337 JSON.stringify([374 JSON.stringify({
338 { databaseId: 10, workflowName: "contributor tests", url: "https://example.com/10" },375 workflow_runs: [
339 { databaseId: 11, workflowName: null, url: null },376 { id: 10, name: "contributor tests", html_url: "https://example.com/10" },
340 ]),377 { id: 11, name: null, html_url: null },
378 ],
379 }),
341 ),380 ),
342 ),381 ).runs,
343 ).toEqual([382 ).toEqual([
344 { id: 10, name: "contributor tests", url: "https://example.com/10" },383 { id: 10, name: "contributor tests", url: "https://example.com/10" },
345 { id: 11, name: "Workflow run 11", url: null },384 { id: 11, name: "Workflow run 11", url: null },
...@@ -398,14 +437,14 @@ describe("pull request detail decoding", () => {...@@ -398,14 +437,14 @@ describe("pull request detail decoding", () => {
398 });437 });
399438
400 it("merges reviews with comments in time order and keeps a bodyless approval", () => {439 it("merges reviews with comments in time order and keeps a bodyless approval", () => {
401 const detail = expectSuccess(decodePullRequestActivityJson(detailJson));440 const detail = expectSuccess(activity(JSON.parse(detailJson) as Record<string, unknown>));
402 // r2 approved without writing anything, which is still the event worth seeing.441 // r2 approved without writing anything, which is still the event worth seeing.
403 expect(detail.comments.map((comment) => comment.id)).toEqual(["r1", "c1", "r2"]);442 expect(detail.comments.map((comment) => comment.id)).toEqual(["r1", "c1", "r2"]);
404 expect(detail.comments.at(-1)?.reviewState).toBe("APPROVED");443 expect(detail.comments.at(-1)?.reviewState).toBe("APPROVED");
405 });444 });
406445
407 it("keeps every attributed commit author, including an unlinked signature", () => {446 it("keeps every attributed commit author, including an unlinked signature", () => {
408 const detail = expectSuccess(decodePullRequestActivityJson(detailJson));447 const detail = expectSuccess(activity(JSON.parse(detailJson) as Record<string, unknown>));
409 expect(detail.commits[0]?.authors).toEqual([448 expect(detail.commits[0]?.authors).toEqual([
410 { login: "octocat", name: "Octo Cat", avatarUrl: null },449 { login: "octocat", name: "Octo Cat", avatarUrl: null },
411 { login: "Pair Author", name: "Pair Author", avatarUrl: null },450 { login: "Pair Author", name: "Pair Author", avatarUrl: null },
...@@ -415,22 +454,20 @@ describe("pull request detail decoding", () => {...@@ -415,22 +454,20 @@ describe("pull request detail decoding", () => {
415 it("drops the bodyless review GitHub opens to hold line comments", () => {454 it("drops the bodyless review GitHub opens to hold line comments", () => {
416 const raw = JSON.parse(detailJson) as Record<string, unknown>;455 const raw = JSON.parse(detailJson) as Record<string, unknown>;
417 const detail = expectSuccess(456 const detail = expectSuccess(
418 decodePullRequestActivityJson(457 activity({
419 JSON.stringify({458 ...raw,
420 ...raw,459 reviews: [
421 reviews: [460 // What a reviewer leaving inline comments produces: a container with a state but
422 // What a reviewer leaving inline comments produces: a container with a state but461 // nothing to read. Its comments come from the review threads instead.
423 // nothing to read. Its comments come from the review threads instead.462 { id: "r4", body: "", state: "COMMENTED", submittedAt: "2026-07-07T00:00:00Z" },
424 { id: "r4", body: "", state: "COMMENTED", submittedAt: "2026-07-07T00:00:00Z" },463 {
425 {464 id: "r5",
426 id: "r5",465 body: "Looks good.",
427 body: "Looks good.",466 state: "COMMENTED",
428 state: "COMMENTED",467 submittedAt: "2026-07-08T00:00:00Z",
429 submittedAt: "2026-07-08T00:00:00Z",468 },
430 },469 ],
431 ],470 }),
432 }),
433 ),
434 );471 );
435472
436 expect(detail.comments.map((comment) => comment.id)).toEqual(["c1", "r5"]);473 expect(detail.comments.map((comment) => comment.id)).toEqual(["c1", "r5"]);
...@@ -441,12 +478,10 @@ describe("pull request detail decoding", () => {...@@ -441,12 +478,10 @@ describe("pull request detail decoding", () => {
441 (state) => {478 (state) => {
442 const raw = JSON.parse(detailJson) as Record<string, unknown>;479 const raw = JSON.parse(detailJson) as Record<string, unknown>;
443 const detail = expectSuccess(480 const detail = expectSuccess(
444 decodePullRequestActivityJson(481 activity({
445 JSON.stringify({482 ...raw,
446 ...raw,483 reviews: [{ id: "r6", body: "", state, submittedAt: "2026-07-07T00:00:00Z" }],
447 reviews: [{ id: "r6", body: "", state, submittedAt: "2026-07-07T00:00:00Z" }],484 }),
448 }),
449 ),
450 );485 );
451486
452 expect(detail.comments.map((comment) => comment.id)).toContain("r6");487 expect(detail.comments.map((comment) => comment.id)).toContain("r6");
...@@ -456,12 +491,10 @@ describe("pull request detail decoding", () => {...@@ -456,12 +491,10 @@ describe("pull request detail decoding", () => {
456 it("drops a review that carries neither a body nor a state", () => {491 it("drops a review that carries neither a body nor a state", () => {
457 const raw = JSON.parse(detailJson) as Record<string, unknown>;492 const raw = JSON.parse(detailJson) as Record<string, unknown>;
458 const detail = expectSuccess(493 const detail = expectSuccess(
459 decodePullRequestActivityJson(494 activity({
460 JSON.stringify({495 ...raw,
461 ...raw,496 reviews: [{ id: "r3", body: " ", submittedAt: "2026-07-07T00:00:00Z" }],
462 reviews: [{ id: "r3", body: " ", submittedAt: "2026-07-07T00:00:00Z" }],497 }),
463 }),
464 ),
465 );498 );
466 expect(detail.comments.map((comment) => comment.id)).toEqual(["c1"]);499 expect(detail.comments.map((comment) => comment.id)).toEqual(["c1"]);
467 });500 });
...@@ -1432,18 +1465,16 @@ describe("reviewer candidate decoding", () => {...@@ -1432,18 +1465,16 @@ describe("reviewer candidate decoding", () => {
1432describe("reviewer request payload", () => {1465describe("reviewer request payload", () => {
1433 it("sends people and teams in the two lists GitHub keeps them in", () => {1466 it("sends people and teams in the two lists GitHub keeps them in", () => {
1434 expect(1467 expect(
1435 JSON.parse(1468 buildReviewerRequest([
1436 buildReviewerRequestJson([1469 { id: "octocat", kind: "user" },
1437 { id: "octocat", kind: "user" },1470 { id: "reviewers", kind: "team" },
1438 { id: "reviewers", kind: "team" },1471 { id: "hubot", kind: "user" },
1439 { id: "hubot", kind: "user" },1472 ]),
1440 ]),
1441 ),
1442 ).toEqual({ reviewers: ["octocat", "hubot"], team_reviewers: ["reviewers"] });1473 ).toEqual({ reviewers: ["octocat", "hubot"], team_reviewers: ["reviewers"] });
1443 });1474 });
14441475
1445 it("sends both lists even where one of them is empty, which is what GitHub reads", () => {1476 it("sends both lists even where one of them is empty, which is what GitHub reads", () => {
1446 expect(JSON.parse(buildReviewerRequestJson([{ id: "octocat", kind: "user" }]))).toEqual({1477 expect(buildReviewerRequest([{ id: "octocat", kind: "user" }])).toEqual({
1447 reviewers: ["octocat"],1478 reviewers: ["octocat"],
1448 team_reviewers: [],1479 team_reviewers: [],
1449 });1480 });
...@@ -1452,24 +1483,22 @@ describe("reviewer request payload", () => {...@@ -1452,24 +1483,22 @@ describe("reviewer request payload", () => {
14521483
1453describe("review submission payload", () => {1484describe("review submission payload", () => {
1454 it("sends the verdict, the summary and every line comment in one body", () => {1485 it("sends the verdict, the summary and every line comment in one body", () => {
1455 const payload = JSON.parse(1486 const payload = buildReviewSubmission({
1456 buildReviewSubmissionJson({1487 verdict: "request-changes",
1457 verdict: "request-changes",1488 body: "Two things.",
1458 body: "Two things.",1489 comments: [
1459 comments: [1490 {
1460 {1491 path: "src/a.ts",
1461 path: "src/a.ts",1492 position: { kind: "added", newLine: 12 },
1462 position: { kind: "added", newLine: 12 },1493 body: "rename this",
1463 body: "rename this",1494 },
1464 },1495 {
1465 {1496 path: "src/b.ts",
1466 path: "src/b.ts",1497 position: { kind: "deleted", oldLine: 3 },
1467 position: { kind: "deleted", oldLine: 3 },1498 body: "why remove?",
1468 body: "why remove?",1499 },
1469 },1500 ],
1470 ],1501 });
1471 }),
1472 ) as Record<string, unknown>;
1473 expect(payload).toEqual({1502 expect(payload).toEqual({
1474 event: "REQUEST_CHANGES",1503 event: "REQUEST_CHANGES",
1475 body: "Two things.",1504 body: "Two things.",
...@@ -1481,9 +1510,11 @@ describe("review submission payload", () => {...@@ -1481,9 +1510,11 @@ describe("review submission payload", () => {
1481 });1510 });
14821511
1483 it("sends an approval with no words and no comments", () => {1512 it("sends an approval with no words and no comments", () => {
1484 expect(1513 expect(buildReviewSubmission({ verdict: "approve", body: "", comments: [] })).toEqual({
1485 JSON.parse(buildReviewSubmissionJson({ verdict: "approve", body: "", comments: [] })),1514 event: "APPROVE",
1486 ).toEqual({ event: "APPROVE", body: "", comments: [] });1515 body: "",
1516 comments: [],
1517 });
1487 });1518 });
1488});1519});
14891520
apps/server/src/pullRequest/gitHubPullRequestJson.ts+485-174
...@@ -155,6 +155,9 @@ const RawSearchItemSchema = Schema.Struct({...@@ -155,6 +155,9 @@ const RawSearchItemSchema = Schema.Struct({
155 latestReviews: Schema.optional(155 latestReviews: Schema.optional(
156 Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.NullOr(RawLatestReviewSchema)) })),156 Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.NullOr(RawLatestReviewSchema)) })),
157 ),157 ),
158 /** Asked for by the per-repository listing, and left out of the cross-repository search. */
159 additions: Schema.optional(Schema.Int),
160 deletions: Schema.optional(Schema.Int),
158 createdAt: Schema.String,161 createdAt: Schema.String,
159 updatedAt: Schema.String,162 updatedAt: Schema.String,
160 mergedAt: Schema.optional(Schema.NullOr(Schema.String)),163 mergedAt: Schema.optional(Schema.NullOr(Schema.String)),
...@@ -167,7 +170,14 @@ const RawSearchItemSchema = Schema.Struct({...@@ -167,7 +170,14 @@ const RawSearchItemSchema = Schema.Struct({
167 Schema.Array(170 Schema.Array(
168 Schema.NullOr(171 Schema.NullOr(
169 Schema.Struct({172 Schema.Struct({
170 requestedReviewer: Schema.optional(Schema.NullOr(RawActorSchema)),173 requestedReviewer: Schema.optional(
174 Schema.NullOr(
175 Schema.Struct({
176 ...RawActorSchema.fields,
177 slug: Schema.optional(Schema.NullOr(Schema.String)),
178 }),
179 ),
180 ),
171 }),181 }),
172 ),182 ),
173 ),183 ),
...@@ -215,14 +225,27 @@ const RawSearchItemSchema = Schema.Struct({...@@ -215,14 +225,27 @@ const RawSearchItemSchema = Schema.Struct({
215 ),225 ),
216});226});
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
218const RawSearchSchema = Schema.Struct({242const RawSearchSchema = Schema.Struct({
243 data: Schema.Struct({ search: RawRowConnectionSchema }),
244});
245
246const RawRepositoryPullRequestsSchema = Schema.Struct({
219 data: Schema.Struct({247 data: Schema.Struct({
220 search: Schema.Struct({248 repository: Schema.NullOr(Schema.Struct({ pullRequests: RawRowConnectionSchema })),
221 pageInfo: Schema.optional(Schema.NullOr(Schema.Struct({ hasNextPage: Schema.Boolean }))),
222 // Row by row, like the listing's own: a node that is not a pull request — or one field
223 // GitHub changes — is skipped rather than blanking every repository at once.
224 nodes: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))),
225 }),
226 }),249 }),
227});250});
228251
...@@ -414,31 +437,103 @@ const RawDetailSchema = Schema.Struct({...@@ -414,31 +437,103 @@ const RawDetailSchema = Schema.Struct({
414 ),437 ),
415});438});
416439
417const RawWorkflowRunApprovalSchema = Schema.Struct({440/** `GET /repos/{owner}/{repo}/actions/runs`, one page of it. */
418 databaseId: Schema.Int,441const RawWorkflowRunsSchema = Schema.Struct({
419 workflowName: Schema.optional(Schema.NullOr(Schema.String)),442 workflow_runs: Schema.Array(
420 url: Schema.optional(Schema.NullOr(Schema.String)),443 Schema.Struct({
444 id: Schema.Int,
445 name: Schema.optional(Schema.NullOr(Schema.String)),
446 html_url: Schema.optional(Schema.NullOr(Schema.String)),
447 }),
448 ),
421});449});
422450
423const RawPullRequestHeadSchema = Schema.Struct({451const RawPullRequestHeadsSchema = Schema.Struct({
424 number: Schema.Int,452 data: Schema.Struct({
425 headRefOid: Schema.String,453 repository: Schema.NullOr(
426 isCrossRepository: Schema.optional(Schema.Boolean),454 Schema.Struct({
427 headRepositoryOwner: Schema.optional(Schema.NullOr(Schema.Struct({ login: Schema.String }))),455 pullRequests: Schema.Struct({
456 pageInfo: Schema.optional(RawPageInfoSchemaFields()),
457 nodes: Schema.Array(
458 Schema.Struct({
459 number: Schema.Int,
460 headRefOid: Schema.String,
461 isCrossRepository: Schema.optional(Schema.Boolean),
462 headRepositoryOwner: Schema.optional(
463 Schema.NullOr(Schema.Struct({ login: Schema.String })),
464 ),
465 }),
466 ),
467 }),
468 }),
469 ),
470 }),
428});471});
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
430const RawActivitySchema = Schema.Struct({483const RawActivitySchema = Schema.Struct({
431 author: Schema.optional(Schema.NullOr(RawActorSchema)),484 data: Schema.Struct({
432 comments: Schema.optional(Schema.Array(RawCommentSchema)),485 repository: Schema.Struct({
433 reviews: Schema.optional(Schema.Array(RawReviewSchema)),486 pullRequest: Schema.Struct({
434 commits: Schema.optional(Schema.Array(RawCommitSchema)),487 author: Schema.optional(Schema.NullOr(RawActorSchema)),
488 comments: RawActivityConnection(RawCommentSchema),
489 reviews: RawActivityConnection(RawReviewSchema),
490 commits: Schema.optional(
491 Schema.NullOr(
492 Schema.Struct({
493 nodes: Schema.Array(
494 Schema.Struct({
495 commit: Schema.Struct({
496 oid: Schema.String,
497 messageHeadline: Schema.optional(Schema.String),
498 committedDate: Schema.String,
499 authors: Schema.optional(
500 Schema.NullOr(
501 Schema.Struct({
502 nodes: Schema.Array(
503 Schema.Struct({
504 email: Schema.optional(Schema.NullOr(Schema.String)),
505 name: Schema.optional(Schema.NullOr(Schema.String)),
506 user: Schema.optional(
507 Schema.NullOr(
508 Schema.Struct({
509 login: Schema.optional(Schema.NullOr(Schema.String)),
510 }),
511 ),
512 ),
513 }),
514 ),
515 }),
516 ),
517 ),
518 }),
519 }),
520 ),
521 }),
522 ),
523 ),
524 }),
525 }),
526 }),
435});527});
436528
437/** Where a connection carries on from, which is what every paged read below follows. */529/** Where a connection carries on from, which is what every paged read below follows. */
438const RawPageInfoSchema = Schema.Struct({530function RawPageInfoSchemaFields() {
439 hasNextPage: Schema.optional(Schema.Boolean),531 return Schema.Struct({
440 endCursor: Schema.optional(Schema.NullOr(Schema.String)),532 hasNextPage: Schema.optional(Schema.Boolean),
441});533 endCursor: Schema.optional(Schema.NullOr(Schema.String)),
534 });
535}
536const RawPageInfoSchema = RawPageInfoSchemaFields();
442537
443/**538/**
444 * What GitHub says the viewer may do with a pull request. Both are optional so that an install539 * What GitHub says the viewer may do with a pull request. Both are optional so that an install
...@@ -707,19 +802,25 @@ export function decodeActorAvatarsJson(...@@ -707,19 +802,25 @@ export function decodeActorAvatarsJson(
707 return Result.succeed(avatarsByLogin);802 return Result.succeed(avatarsByLogin);
708}803}
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
715/**805/**
716 * Pull refs let the comparison share the detail read without first resolving a fork branch.806 * Pull refs let the comparison share the detail read without first resolving a fork branch.
717 * `isRequired` is asked for on github.com only: an older Enterprise server may not know it, and807 * `isRequired` is asked for on github.com only: an older Enterprise server may not know it, and
718 * an unknown field fails the whole read.808 * an unknown field fails the whole read.
719 */809 */
720export const pullRequestCoreGraphQlQuery = (host: string) => {810function checkContextNodesSelection(host: string): string {
721 const required =811 const required =
722 host.toLowerCase() === "github.com" ? " isRequired(pullRequestNumber: $number)" : "";812 host.toLowerCase() === "github.com" ? " isRequired(pullRequestNumber: $number)" : "";
813 return `nodes {
814 __typename
815 ... on StatusContext { context state targetUrl createdAt description${required} }
816 ... on CheckRun {
817 name status conclusion startedAt completedAt detailsUrl${required}
818 checkSuite { workflowRun { workflow { name } } }
819 }
820 }`;
821}
822
823export const pullRequestCoreGraphQlQuery = (host: string) => {
723 return `query($owner: String!, $name: String!, $number: Int!, $headRef: String!) {824 return `query($owner: String!, $name: String!, $number: Int!, $headRef: String!) {
724 repository(owner: $owner, name: $name) {825 repository(owner: $owner, name: $name) {
725 mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed viewerPermission826 mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed viewerPermission
...@@ -738,14 +839,7 @@ export const pullRequestCoreGraphQlQuery = (host: string) => {...@@ -738,14 +839,7 @@ export const pullRequestCoreGraphQlQuery = (host: string) => {
738 labels(first: 100) { nodes { name color } }839 labels(first: 100) { nodes { name color } }
739 commits(last: 1) {840 commits(last: 1) {
740 nodes { commit { statusCheckRollup { contexts(first: 100) {841 nodes { commit { statusCheckRollup { contexts(first: 100) {
741 nodes {842 ${checkContextNodesSelection(host)}
742 __typename
743 ... on StatusContext { context state targetUrl createdAt description${required} }
744 ... on CheckRun {
745 name status conclusion startedAt completedAt detailsUrl${required}
746 checkSuite { workflowRun { workflow { name } } }
747 }
748 }
749 pageInfo { hasNextPage }843 pageInfo { hasNextPage }
750 } } } }844 } } } }
751 }845 }
...@@ -791,7 +885,29 @@ export function decodePullRequestPreviewJson(...@@ -791,7 +885,29 @@ export function decodePullRequestPreviewJson(
791 }));885 }));
792}886}
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
796/** GitHub's own ceiling on a connection page, which is what both thread reads ask for. */912/** GitHub's own ceiling on a connection page, which is what both thread reads ask for. */
797const GRAPHQL_PAGE_SIZE = 100;913const GRAPHQL_PAGE_SIZE = 100;
...@@ -818,13 +934,45 @@ export const PULL_REQUEST_SEARCH_MAX_ROWS = GRAPHQL_PAGE_SIZE;...@@ -818,13 +934,45 @@ export const PULL_REQUEST_SEARCH_MAX_ROWS = GRAPHQL_PAGE_SIZE;
818 * than twenty labels shows twenty, and one that has asked more than twenty people for a review934 * than twenty labels shows twenty, and one that has asked more than twenty people for a review
819 * is already past what a row can say.935 * is already past what a row can say.
820 */936 */
821export function pullRequestSearchGraphQlQuery(rows: number, includeStacks = false): string {937export function pullRequestSearchGraphQlQuery(
822 return `query($q: String!) {938 rows: number,
823 search(query: $q, type: ISSUE, first: ${Math.min(Math.max(Math.trunc(rows), 1), PULL_REQUEST_SEARCH_MAX_ROWS)}) {939 includeStacks = false,
824 pageInfo { hasNextPage }940 includeStats = false,
941): string {
942 return `query($q: String!, $after: String) {
943 search(query: $q, type: ISSUE, first: ${pageRows(rows)}, after: $after) {
944 pageInfo { hasNextPage endCursor }
825 nodes {945 nodes {
826 ... on PullRequest {946 ... on PullRequest {
827 ${includeStacks ? "stack { number size baseRefName } stackEntry { position }" : ""}947 ${pullRequestRowSelection(includeStacks, includeStats)}
948 }
949 }
950 }
951}`;
952}
953
954/**
955 * A repository's pull requests without search, newest created first: the order `gh pr list` lists
956 * in when it does not search, for a repository GitHub's search index does not cover.
957 */
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 }" : ""}
828 number976 number
829 title977 title
830 url978 url
...@@ -836,17 +984,14 @@ export function pullRequestSearchGraphQlQuery(rows: number, includeStacks = fals...@@ -836,17 +984,14 @@ export function pullRequestSearchGraphQlQuery(rows: number, includeStacks = fals
836 mergeable984 mergeable
837 reviewDecision985 reviewDecision
838 latestReviews(first: 20) { nodes { state author { login } } }986 latestReviews(first: 20) { nodes { state author { login } } }
987 ${includeStats ? "additions deletions" : ""}
839 createdAt988 createdAt
840 updatedAt989 updatedAt
841 mergedAt990 mergedAt
842 repository { nameWithOwner }991 repository { nameWithOwner }
843 reviewRequests(first: 20) { nodes { requestedReviewer { ... on User { login } } } }992 reviewRequests(first: 20) { nodes { requestedReviewer { ... on User { login } ... on Team { slug } } } }
844 labels(first: 20) { nodes { name color } }993 labels(first: 20) { nodes { name color } }
845 commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }994 commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }`;
846 }
847 }
848 }
849}`;
850}995}
851996
852/**997/**
...@@ -1094,24 +1239,6 @@ export const UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION = `mutation($commentId: ID!,...@@ -1094,24 +1239,6 @@ export const UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION = `mutation($commentId: ID!,
1094 }1239 }
1095}`;1240}`;
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
1115/** The body of `POST /repos/{owner}/{repo}/pulls/{number}/reviews`, which sends a review whole. */1242/** The body of `POST /repos/{owner}/{repo}/pulls/{number}/reviews`, which sends a review whole. */
1116const ReviewSubmissionSchema = Schema.Struct({1243const ReviewSubmissionSchema = Schema.Struct({
1117 event: Schema.Literals(["COMMENT", "APPROVE", "REQUEST_CHANGES"]),1244 event: Schema.Literals(["COMMENT", "APPROVE", "REQUEST_CHANGES"]),
...@@ -1126,8 +1253,6 @@ const ReviewSubmissionSchema = Schema.Struct({...@@ -1126,8 +1253,6 @@ const ReviewSubmissionSchema = Schema.Struct({
1126 ),1253 ),
1127});1254});
11281255
1129const encodeReviewSubmission = Schema.encodeSync(Schema.fromJsonString(ReviewSubmissionSchema));
1130
1131const REVIEW_EVENTS: Record<PullRequestReviewVerdict, "COMMENT" | "APPROVE" | "REQUEST_CHANGES"> = {1256const REVIEW_EVENTS: Record<PullRequestReviewVerdict, "COMMENT" | "APPROVE" | "REQUEST_CHANGES"> = {
1132 comment: "COMMENT",1257 comment: "COMMENT",
1133 approve: "APPROVE",1258 approve: "APPROVE",
...@@ -1166,12 +1291,12 @@ function gitHubReviewPosition(position: PullRequestReviewPosition): {...@@ -1166,12 +1291,12 @@ function gitHubReviewPosition(position: PullRequestReviewPosition): {
1166}1291}
11671292
1168/** The whole review as one request body, which is how GitHub keeps it invisible until sent. */1293/** The whole review as one request body, which is how GitHub keeps it invisible until sent. */
1169export function buildReviewSubmissionJson(input: {1294export function buildReviewSubmission(input: {
1170 readonly verdict: PullRequestReviewVerdict;1295 readonly verdict: PullRequestReviewVerdict;
1171 readonly body: string;1296 readonly body: string;
1172 readonly comments: ReadonlyArray<PullRequestReviewCommentDraft>;1297 readonly comments: ReadonlyArray<PullRequestReviewCommentDraft>;
1173}): string {1298}): typeof ReviewSubmissionSchema.Type {
1174 return encodeReviewSubmission({1299 return {
1175 event: REVIEW_EVENTS[input.verdict],1300 event: REVIEW_EVENTS[input.verdict],
1176 body: input.body,1301 body: input.body,
1177 comments: input.comments.map((comment) => ({1302 comments: input.comments.map((comment) => ({
...@@ -1179,7 +1304,7 @@ export function buildReviewSubmissionJson(input: {...@@ -1179,7 +1304,7 @@ export function buildReviewSubmissionJson(input: {
1179 ...gitHubReviewPosition(comment.position),1304 ...gitHubReviewPosition(comment.position),
1180 body: comment.body,1305 body: comment.body,
1181 })),1306 })),
1182 });1307 };
1183}1308}
11841309
1185export interface GitHubPullRequestListItem {1310export interface GitHubPullRequestListItem {
...@@ -1644,23 +1769,15 @@ function toDetail(raw: Schema.Schema.Type<typeof RawDetailSchema>): GitHubPullRe...@@ -1644,23 +1769,15 @@ function toDetail(raw: Schema.Schema.Type<typeof RawDetailSchema>): GitHubPullRe
1644 };1769 };
1645}1770}
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
1655const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown));1772const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown));
1656const decodeListEntry = Schema.decodeUnknownExit(RawListItemSchema);
1657const decodeSearch = decodeJsonResult(RawSearchSchema);1773const decodeSearch = decodeJsonResult(RawSearchSchema);
1658const decodeSearchItem = Schema.decodeUnknownExit(RawSearchItemSchema);1774const decodeSearchItem = Schema.decodeUnknownExit(RawSearchItemSchema);
1659const decodeStats = decodeJsonResult(RawStatsSchema);1775const decodeStats = decodeJsonResult(RawStatsSchema);
1660const decodeDetail = decodeJsonResult(RawDetailSchema);1776const decodeDetail = decodeJsonResult(RawDetailSchema);
1661const decodeWorkflowRunApprovals = decodeJsonResult(Schema.Array(RawWorkflowRunApprovalSchema));1777const decodeWorkflowRuns = decodeJsonResult(RawWorkflowRunsSchema);
1662const decodePullRequestHeads = decodeJsonResult(Schema.Array(RawPullRequestHeadSchema));1778const decodePullRequestHeads = decodeJsonResult(RawPullRequestHeadsSchema);
1663const decodeActivity = decodeJsonResult(RawActivitySchema);1779const decodeActivity = decodeJsonResult(RawActivitySchema);
1780const decodeRepositoryPullRequests = decodeJsonResult(RawRepositoryPullRequestsSchema);
1664const decodeFileEntry = Schema.decodeUnknownExit(RawPullRequestFileSchema);1781const decodeFileEntry = Schema.decodeUnknownExit(RawPullRequestFileSchema);
1665const decodeReviewThreads = decodeJsonResult(RawReviewThreadsSchema);1782const decodeReviewThreads = decodeJsonResult(RawReviewThreadsSchema);
1666const decodeReviewThreadComments = decodeJsonResult(RawReviewThreadCommentsSchema);1783const decodeReviewThreadComments = decodeJsonResult(RawReviewThreadCommentsSchema);
...@@ -1669,27 +1786,78 @@ type DecodeFailure = Cause.Cause<Schema.SchemaError>;...@@ -1669,27 +1786,78 @@ type DecodeFailure = Cause.Cause<Schema.SchemaError>;
16691786
1670export interface GitHubPullRequestListBatch {1787export interface GitHubPullRequestListBatch {
1671 readonly items: ReadonlyArray<GitHubPullRequestListItem>;1788 readonly items: ReadonlyArray<GitHubPullRequestListItem>;
1672 /** Rows gh returned, counted before decoding, so a skipped row cannot hide a next page. */1789 /** Rows GitHub returned, counted before decoding, so a skipped row cannot hide a next page. */
1673 readonly rawCount: number;1790 readonly rawCount: number;
1791 /** Where the next page starts, or null once GitHub has handed over every row. */
1792 readonly endCursor: string | null;
1674}1793}
16751794
1676/** Malformed entries are skipped rather than failing the batch: one unexpected pull request1795/**
1677 * must not blank the whole list. */1796 * One page of a repository's own pull request list. Malformed rows are skipped rather than
1797 * failing the page: one unexpected pull request must not blank the whole list. A repository the
1798 * viewer cannot see answers as an empty page, which is what `gh pr list` printed for it too.
1799 */
1678export function decodePullRequestListJson(1800export function decodePullRequestListJson(
1679 raw: string,1801 raw: string,
1680): Result.Result<GitHubPullRequestListBatch, DecodeFailure> {1802): Result.Result<GitHubPullRequestListBatch, DecodeFailure> {
1681 const decoded = decodeUnknownList(raw);1803 const decoded = decodeRepositoryPullRequests(raw);
1682 if (!Result.isSuccess(decoded)) {1804 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
1683 return Result.fail(decoded.failure);1805 const connection = decoded.success.data.repository?.pullRequests;
1684 }1806 const nodes = connection?.nodes ?? [];
1685 const items: GitHubPullRequestListItem[] = [];1807 const items: GitHubPullRequestListItem[] = [];
1686 for (const entry of decoded.success) {1808 for (const entry of nodes) {
1687 const item = decodeListEntry(entry);1809 const row = toRow(entry);
1688 if (Exit.isSuccess(item)) {1810 if (row !== null) items.push(row.item);
1689 items.push(toListItem(item.value));
1690 }
1691 }1811 }
1692 return Result.succeed({ items, rawCount: decoded.success.length });1812 return Result.succeed({
1813 items,
1814 rawCount: nodes.length,
1815 endCursor:
1816 connection?.pageInfo?.hasNextPage === true ? trimmed(connection.pageInfo.endCursor) : null,
1817 });
1818}
1819
1820/**
1821 * A row as GraphQL answers it: reviewers and labels arrive as connections, and the checks as
1822 * GitHub's one-word rollup rather than the whole check list. Null for a node that is not a pull
1823 * request, or one whose fields no longer decode.
1824 */
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 };
1693}1861}
16941862
1695export interface GitHubPullRequestSearchItem extends GitHubPullRequestListItem {1863export interface GitHubPullRequestSearchItem extends GitHubPullRequestListItem {
...@@ -1703,12 +1871,12 @@ export interface GitHubPullRequestSearchBatch {...@@ -1703,12 +1871,12 @@ export interface GitHubPullRequestSearchBatch {
1703 readonly rawCount: number;1871 readonly rawCount: number;
1704 /** More rows than this slice asked for, which is truncation for every repository in it. */1872 /** More rows than this slice asked for, which is truncation for every repository in it. */
1705 readonly hasNextPage: boolean;1873 readonly hasNextPage: boolean;
1874 /** Where the next page of the same search starts. */
1875 readonly endCursor: string | null;
1706}1876}
17071877
1708/**1878/**
1709 * A search answers with the same pull request the listing does, one connection deeper: reviewers1879 * A search answers with the same row a repository's list does, each naming its repository.
1710 * and labels arrive as connections, and the row names the repository it came from. Flattened to
1711 * the shape `gh pr list --json` hands over so both reads decode into one type.
1712 *1880 *
1713 * Rows that are not pull requests decode as empty and are skipped, the way a malformed listing1881 * Rows that are not pull requests decode as empty and are skipped, the way a malformed listing
1714 * row is — `is:pr` already excludes them, and one surprise must not blank a whole host.1882 * row is — `is:pr` already excludes them, and one surprise must not blank a whole host.
...@@ -1720,41 +1888,19 @@ export function decodePullRequestSearchJson(...@@ -1720,41 +1888,19 @@ export function decodePullRequestSearchJson(
1720 if (!Result.isSuccess(decoded)) {1888 if (!Result.isSuccess(decoded)) {
1721 return Result.fail(decoded.failure);1889 return Result.fail(decoded.failure);
1722 }1890 }
1723 const nodes = decoded.success.data.search.nodes ?? [];1891 const search = decoded.success.data.search;
1892 const nodes = search.nodes ?? [];
1724 const items: GitHubPullRequestSearchItem[] = [];1893 const items: GitHubPullRequestSearchItem[] = [];
1725 for (const entry of nodes) {1894 for (const entry of nodes) {
1726 const decodedNode = decodeSearchItem(entry);1895 const row = toRow(entry);
1727 if (!Exit.isSuccess(decodedNode)) continue;1896 if (row === null || row.repository === null) continue;
1728 const node = decodedNode.value;1897 items.push({ ...row.item, repository: row.repository });
1729 const repository = trimmed(node.repository?.nameWithOwner);
1730 if (repository === null) continue;
1731 const stack = toStackMembership(node);
1732 items.push({
1733 ...toListItem({
1734 ...node,
1735 latestReviews: (node.latestReviews?.nodes ?? []).flatMap((review) =>
1736 review === null ? [] : [review],
1737 ),
1738 reviewRequests: (node.reviewRequests?.nodes ?? []).flatMap((request) => {
1739 const login = trimmed(request?.requestedReviewer?.login);
1740 return login === null ? [] : [{ login }];
1741 }),
1742 labels: (node.labels?.nodes ?? []).flatMap((label) => (label === null ? [] : [label])),
1743 // The search asks for the verdict rather than the checks behind it, so it arrives as one
1744 // enum. Dressed as a single check here so the rollup is read the same way on both paths.
1745 statusCheckRollup: (node.commits?.nodes ?? []).flatMap((commitNode) => {
1746 const state = trimmed(commitNode?.commit?.statusCheckRollup?.state);
1747 return state === null ? [] : [{ state }];
1748 }),
1749 }),
1750 ...(stack === undefined ? {} : { stack }),
1751 repository,
1752 });
1753 }1898 }
1754 return Result.succeed({1899 return Result.succeed({
1755 items,1900 items,
1756 rawCount: nodes.length,1901 rawCount: nodes.length,
1757 hasNextPage: decoded.success.data.search.pageInfo?.hasNextPage ?? false,1902 hasNextPage: search.pageInfo?.hasNextPage ?? false,
1903 endCursor: search.pageInfo?.hasNextPage === true ? trimmed(search.pageInfo.endCursor) : null,
1758 });1904 });
1759}1905}
17601906
...@@ -1900,6 +2046,13 @@ export function buildPullRequestSummariesGraphQlQuery(...@@ -1900,6 +2046,13 @@ export function buildPullRequestSummariesGraphQlQuery(
1900 return `query PullRequestSummaries {\n${selections.join("\n")}\n}`;2046 return `query PullRequestSummaries {\n${selections.join("\n")}\n}`;
1901}2047}
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
1903const RawSummarySchema = Schema.Struct({2056const RawSummarySchema = Schema.Struct({
1904 ...RawSearchItemSchema.fields,2057 ...RawSearchItemSchema.fields,
1905 changedFiles: Schema.optional(Schema.NullOr(Schema.Int)),2058 changedFiles: Schema.optional(Schema.NullOr(Schema.Int)),
...@@ -2147,11 +2300,7 @@ export function decodePullRequestCoreJson(...@@ -2147,11 +2300,7 @@ export function decodePullRequestCoreJson(
2147 requestedReviewer === null ? [] : [requestedReviewer],2300 requestedReviewer === null ? [] : [requestedReviewer],
2148 ),2301 ),
2149 labels: pr.labels.nodes,2302 labels: pr.labels.nodes,
2150 statusCheckRollup:2303 statusCheckRollup: toCheckContexts(contexts?.nodes ?? []),
2151 contexts?.nodes.map((check) => ({
2152 ...check,
2153 workflowName: check.checkSuite?.workflowRun?.workflow?.name ?? null,
2154 })) ?? [],
2155 }),2304 }),
2156 viewerAccess: {2305 viewerAccess: {
2157 canWrite: toCanWrite(repository.viewerPermission),2306 canWrite: toCanWrite(repository.viewerPermission),
...@@ -2183,27 +2332,50 @@ export function decodePullRequestDetailJson(...@@ -2183,27 +2332,50 @@ export function decodePullRequestDetailJson(
2183 : Result.fail(decoded.failure);2332 : Result.fail(decoded.failure);
2184}2333}
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(
2187 raw: string,2343 raw: string,
2188): Result.Result<ReadonlyArray<GitHubWorkflowRunApproval>, DecodeFailure> {2344): Result.Result<GitHubWorkflowRunPage, DecodeFailure> {
2189 const decoded = decodeWorkflowRunApprovals(raw);2345 const decoded = decodeWorkflowRuns(raw);
2190 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);2346 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
2191 return Result.succeed(2347 return Result.succeed({
2192 decoded.success.map((run) => ({2348 runs: decoded.success.workflow_runs.map((run) => ({
2193 id: run.databaseId,2349 id: run.id,
2194 name: trimmed(run.workflowName) ?? `Workflow run ${run.databaseId}`,2350 name: trimmed(run.name) ?? `Workflow run ${run.id}`,
2195 url: trimmed(run.url),2351 url: trimmed(run.html_url),
2196 })),2352 })),
2197 );2353 rawCount: decoded.success.workflow_runs.length,
2354 });
2198}2355}
21992356
2200export function decodePullRequestHeadsJson(2357/** Open pull requests whose head branch has one name, from whichever repository it lives in. */
2201 raw: string,2358export const PULL_REQUEST_HEADS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $head: String!, $after: String) {
2202): Result.Result<ReadonlyArray<GitHubPullRequestHead>, DecodeFailure> {2359 repository(owner: $owner, name: $name) {
2360 pullRequests(first: 100, after: $after, states: [OPEN], headRefName: $head) {
2361 pageInfo { hasNextPage endCursor }
2362 nodes { number headRefOid isCrossRepository headRepositoryOwner { login } }
2363 }
2364 }
2365}`;
2366
2367export function decodePullRequestHeadsJson(raw: string): Result.Result<
2368 {
2369 readonly heads: ReadonlyArray<GitHubPullRequestHead>;
2370 readonly nextCursor: string | null;
2371 },
2372 DecodeFailure
2373> {
2203 const decoded = decodePullRequestHeads(raw);2374 const decoded = decodePullRequestHeads(raw);
2204 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);2375 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
2205 return Result.succeed(2376 const connection = decoded.success.data.repository?.pullRequests;
2206 decoded.success.map((pullRequest) => ({2377 return Result.succeed({
2378 heads: (connection?.nodes ?? []).map((pullRequest) => ({
2207 number: pullRequest.number,2379 number: pullRequest.number,
2208 headSha: pullRequest.headRefOid,2380 headSha: pullRequest.headRefOid,
2209 ...(typeof pullRequest.isCrossRepository === "boolean"2381 ...(typeof pullRequest.isCrossRepository === "boolean"
...@@ -2211,16 +2383,145 @@ export function decodePullRequestHeadsJson(...@@ -2211,16 +2383,145 @@ export function decodePullRequestHeadsJson(
2211 : {}),2383 : {}),
2212 headRepositoryOwner: trimmed(pullRequest.headRepositoryOwner?.login),2384 headRepositoryOwner: trimmed(pullRequest.headRepositoryOwner?.login),
2213 })),2385 })),
2214 );2386 nextCursor: nextCursorOf(connection?.pageInfo),
2387 });
2388}
2389
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;
2215}2398}
22162399
2400/** One page of `PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY`. */
2217export function decodePullRequestActivityJson(2401export function decodePullRequestActivityJson(
2218 raw: string,2402 raw: string,
2219): Result.Result<GitHubPullRequestActivity, DecodeFailure> {2403): Result.Result<GitHubPullRequestActivityPage, DecodeFailure> {
2220 const decoded = decodeActivity(raw);2404 const decoded = decodeActivity(raw);
2221 return Result.isSuccess(decoded)2405 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
2222 ? Result.succeed(toActivity(decoded.success))2406 const pr = decoded.success.data.repository.pullRequest;
2223 : Result.fail(decoded.failure);2407 return Result.succeed({
2408 ...(pr.author === undefined ? {} : { author: toActor(pr.author) }),
2409 ...(pr.commits === undefined
2410 ? {}
2411 : {
2412 commits: toCommits(
2413 (pr.commits?.nodes ?? []).map(({ commit }) => ({
2414 ...commit,
2415 authors: (commit.authors?.nodes ?? []).map((author) => ({
2416 ...author,
2417 login: author.user?.login ?? null,
2418 })),
2419 })),
2420 ),
2421 }),
2422 remarks: toComments({
2423 comments: pr.comments?.nodes ?? [],
2424 reviews: pr.reviews?.nodes ?? [],
2425 }),
2426 nextCommentsCursor: nextCursorOf(pr.comments?.pageInfo),
2427 nextReviewsCursor: nextCursorOf(pr.reviews?.pageInfo),
2428 });
2429}
2430
2431/** Every check context of the head commit, a page at a time, for a rollup past one page. */
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) };
2224}2525}
22252526
2226export interface GitHubReviewThreadComments {2527export interface GitHubReviewThreadComments {
...@@ -2778,17 +3079,15 @@ const ReviewerRequestSchema = Schema.Struct({...@@ -2778,17 +3079,15 @@ const ReviewerRequestSchema = Schema.Struct({
2778 team_reviewers: Schema.Array(Schema.String),3079 team_reviewers: Schema.Array(Schema.String),
2779});3080});
27803081
2781const encodeReviewerRequest = Schema.encodeSync(Schema.fromJsonString(ReviewerRequestSchema));3082export function buildReviewerRequest(
2782
2783export function buildReviewerRequestJson(
2784 reviewers: ReadonlyArray<{ readonly id: string; readonly kind: PullRequestReviewerKind }>,3083 reviewers: ReadonlyArray<{ readonly id: string; readonly kind: PullRequestReviewerKind }>,
2785): string {3084): typeof ReviewerRequestSchema.Type {
2786 return encodeReviewerRequest({3085 return {
2787 reviewers: reviewers.flatMap((reviewer) => (reviewer.kind === "user" ? [reviewer.id] : [])),3086 reviewers: reviewers.flatMap((reviewer) => (reviewer.kind === "user" ? [reviewer.id] : [])),
2788 team_reviewers: reviewers.flatMap((reviewer) =>3087 team_reviewers: reviewers.flatMap((reviewer) =>
2789 reviewer.kind === "team" ? [reviewer.id] : [],3088 reviewer.kind === "team" ? [reviewer.id] : [],
2790 ),3089 ),
2791 });3090 };
2792}3091}
27933092
2794export const LABEL_CANDIDATES_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) {3093export const LABEL_CANDIDATES_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) {
...@@ -2876,12 +3175,10 @@ export function decodeLabelCandidatesJson(...@@ -2876,12 +3175,10 @@ export function decodeLabelCandidatesJson(
2876}3175}
28773176
2878/** The body of `POST /repos/{owner}/{repo}/issues/{number}/labels`, which adds to what is there. */3177/** The body of `POST /repos/{owner}/{repo}/issues/{number}/labels`, which adds to what is there. */
2879const LabelRequestSchema = Schema.Struct({ labels: Schema.Array(Schema.String) });3178export function buildLabelRequest(labels: ReadonlyArray<string>): {
28803179 readonly labels: ReadonlyArray<string>;
2881const encodeLabelRequest = Schema.encodeSync(Schema.fromJsonString(LabelRequestSchema));3180} {
28823181 return { labels };
2883export function buildLabelRequestJson(labels: ReadonlyArray<string>): string {
2884 return encodeLabelRequest({ labels });
2885}3182}
28863183
2887/**3184/**
...@@ -2965,14 +3262,28 @@ export interface GitHubPullRequestFilesPatch {...@@ -2965,14 +3262,28 @@ export interface GitHubPullRequestFilesPatch {
2965export function decodePullRequestFilesJson(3262export function decodePullRequestFilesJson(
2966 raw: string,3263 raw: string,
2967): Result.Result<GitHubPullRequestFilesPatch, DecodeFailure> {3264): Result.Result<GitHubPullRequestFilesPatch, DecodeFailure> {
2968 const decoded = decodeUnknownList(raw);3265 return Result.map(decodeUnknownList(raw), toFilesPatch);
2969 if (!Result.isSuccess(decoded)) {3266}
2970 return Result.fail(decoded.failure);3267
2971 }3268/**
3269 * The files of one commit, which the commit endpoint lists and pages the same way, only wrapped
3270 * in an object. An empty commit carries no `files` at all, which is a commit with nothing in it.
3271 */
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 {
2972 const sections: string[] = [];3283 const sections: string[] = [];
2973 const omittedFileStats: PullRequestOmittedFileStat[] = [];3284 const omittedFileStats: PullRequestOmittedFileStat[] = [];
2974 let truncated = false;3285 let truncated = false;
2975 for (const entry of decoded.success) {3286 for (const entry of entries) {
2976 const file = decodeFileEntry(entry);3287 const file = decodeFileEntry(entry);
2977 if (Exit.isFailure(file)) continue;3288 if (Exit.isFailure(file)) continue;
2978 const value = file.value;3289 const value = file.value;
...@@ -3009,12 +3320,12 @@ export function decodePullRequestFilesJson(...@@ -3009,12 +3320,12 @@ export function decodePullRequestFilesJson(
3009 ].join("\n");3320 ].join("\n");
3010 sections.push(hunks.length === 0 ? `${header}\n` : `${header}\n${hunks.replace(/\n?$/, "\n")}`);3321 sections.push(hunks.length === 0 ? `${header}\n` : `${header}\n${hunks.replace(/\n?$/, "\n")}`);
3011 }3322 }
3012 return Result.succeed({3323 return {
3013 patch: sections.join(""),3324 patch: sections.join(""),
3014 truncated,3325 truncated,
3015 rawCount: decoded.success.length,3326 rawCount: entries.length,
3016 omittedFileStats,3327 omittedFileStats,
3017 });3328 };
3018}3329}
30193330
3020/**3331/**
apps/server/src/pullRequest/githubStackActions.test.ts+68-42
...@@ -2,15 +2,62 @@ import { expect, it } from "@effect/vitest";...@@ -2,15 +2,62 @@ import { expect, it } from "@effect/vitest";
2import * as Layer from "effect/Layer";2import * as Layer from "effect/Layer";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";4import * as Fiber from "effect/Fiber";
5import * as Schema from "effect/Schema";
5import * as TestClock from "effect/testing/TestClock";6import * as TestClock from "effect/testing/TestClock";
6import { ChildProcessSpawner } from "effect/process";7import * as GitHubApi from "../sourceControl/GitHubApi.ts";
7import * as GitHubCli from "../sourceControl/GitHubCli.ts";
8import { runGitHubStackAction as runStackAction } from "./githubStackActions.ts";8import { runGitHubStackAction as runStackAction } from "./githubStackActions.ts";
99
10const runGitHubStackAction = (10/**
11 execute: GitHubCli.GitHubCli["Service"]["execute"],11 * One request as the fake saw it, flattened to words: the path or document, then each variable
12 input: Parameters<typeof runStackAction>[0],12 * or body field as `name=value`, so an assertion reads as "this request carried that value".
13) => runStackAction(input).pipe(Effect.provide(Layer.mock(GitHubCli.GitHubCli)({ execute })));13 */
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
15const stack = [62const stack = [
16 {63 {
...@@ -71,21 +118,13 @@ const rebased = {...@@ -71,21 +118,13 @@ const rebased = {
71const rebaseResponses = [branch(2, "bbb"), rebased, branch(3, "ccc", 1, ["rebased-sha"]), rebased];118const rebaseResponses = [branch(2, "bbb"), rebased, branch(3, "ccc", 1, ["rebased-sha"]), rebased];
72119
73function fake(responses: readonly unknown[]) {120function fake(responses: readonly unknown[]) {
74 const calls: ReadonlyArray<string>[] = [];121 const calls: Call[] = [];
75 const execute: GitHubCli.GitHubCli["Service"]["execute"] = (request) =>122 const execute: Send = (call) =>
76 Effect.sync(() => {123 Effect.sync(() => {
77 calls.push(request.args);124 calls.push(call);
78 const value = responses[calls.length - 1];125 const value = responses[calls.length - 1];
79 if (value === undefined) throw new Error("Unexpected GitHub request");126 if (value === undefined) throw new Error("Unexpected GitHub request");
80 return {127 return encodeJson(value);
81 exitCode: ChildProcessSpawner.ExitCode(0),
82 // @effect-diagnostics-next-line preferSchemaOverJson:off
83 stdout: JSON.stringify(value),
84 stderr: "",
85 stdoutTruncated: false,
86 stderrTruncated: false,
87 stdoutInvalidUtf8: false,
88 };
89 });128 });
90 return { execute, calls };129 return { execute, calls };
91}130}
...@@ -215,29 +254,25 @@ it.effect("rebases unmerged layers bottom to top without local git commands", ()...@@ -215,29 +254,25 @@ it.effect("rebases unmerged layers bottom to top without local git commands", ()
215 Effect.gen(function* () {254 Effect.gen(function* () {
216 const api = fake([stack, access, ...rebaseResponses]);255 const api = fake([stack, access, ...rebaseResponses]);
217 yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" });256 yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" });
218 const mutations = api.calls.filter((args) =>257 const mutations = api.calls.filter(isMutation);
219 args.some((arg) => arg.startsWith("query=mutation")),
220 );
221 expect(mutations).toHaveLength(2);258 expect(mutations).toHaveLength(2);
222 expect(mutations[0]).toContain("id=PR_2");259 expect(mutations[0]).toContain("id=PR_2");
223 expect(mutations[0]).toContain("sha=bbb");260 expect(mutations[0]).toContain("sha=bbb");
224 expect(mutations[1]).toContain("id=PR_3");261 expect(mutations[1]).toContain("id=PR_3");
225 expect(mutations[1]).toContain("sha=ccc");262 expect(mutations[1]).toContain("sha=ccc");
226 expect(api.calls.every((args) => args[0] === "api")).toBe(true);
227 }),263 }),
228);264);
229265
230it.effect("does not update later layers after a rebase failure", () =>266it.effect("does not update later layers after a rebase failure", () =>
231 Effect.gen(function* () {267 Effect.gen(function* () {
232 const api = fake([stack, access, branch(2, "bbb")]);268 const api = fake([stack, access, branch(2, "bbb")]);
233 const execute: typeof api.execute = (request) =>269 const execute: Send = (call, kind) =>
234 !request.args.some((arg) => arg.startsWith("query=mutation"))270 !isMutation(call)
235 ? api.execute(request)271 ? api.execute(call, kind)
236 : Effect.fail(272 : Effect.fail(
237 new GitHubCli.GitHubCliAuthenticationError({273 new GitHubApi.GitHubApiAuthenticationError({
238 command: "gh",274 host: "github.com",
239 cwd: "/repo",275 operation: "runGitHubStackAction",
240 cause: new Error("denied"),
241 }),276 }),
242 );277 );
243 const result = yield* runGitHubStackAction(execute, { ...input, action: "update-branch" }).pipe(278 const result = yield* runGitHubStackAction(execute, { ...input, action: "update-branch" }).pipe(
...@@ -272,7 +307,6 @@ it.effect("refuses the entire rebase before mutation when a later fork denies wr...@@ -272,7 +307,6 @@ it.effect("refuses the entire rebase before mutation when a later fork denies wr
272 failure: { _tag: "GitHubStackPermissionError" },307 failure: { _tag: "GitHubStackPermissionError" },
273 });308 });
274 expect(api.calls).toHaveLength(2);309 expect(api.calls).toHaveLength(2);
275 expect(api.calls.every((args) => args[0] === "api")).toBe(true);
276 }),310 }),
277);311);
278312
...@@ -333,9 +367,7 @@ it.effect("skips current layers without submitting a rebase mutation", () =>...@@ -333,9 +367,7 @@ it.effect("skips current layers without submitting a rebase mutation", () =>
333 Effect.gen(function* () {367 Effect.gen(function* () {
334 const api = fake([stack, access, branch(2, "bbb", 0), branch(3, "ccc", 0, ["bbb"])]);368 const api = fake([stack, access, branch(2, "bbb", 0), branch(3, "ccc", 0, ["bbb"])]);
335 yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" });369 yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" });
336 expect(api.calls.some((args) => args.some((arg) => arg.startsWith("query=mutation")))).toBe(370 expect(api.calls.some((args) => isMutation(args))).toBe(false);
337 false,
338 );
339 }),371 }),
340);372);
341373
...@@ -380,9 +412,7 @@ it.effect("reports partial progress when a later head changes during the rebase"...@@ -380,9 +412,7 @@ it.effect("reports partial progress when a later head changes during the rebase"
380 if (result._tag === "Failure") {412 if (result._tag === "Failure") {
381 expect(result.failure.message).toContain("Earlier updates remain on GitHub");413 expect(result.failure.message).toContain("Earlier updates remain on GitHub");
382 }414 }
383 expect(415 expect(api.calls.filter((args) => isMutation(args))).toHaveLength(1);
384 api.calls.filter((args) => args.some((arg) => arg.startsWith("query=mutation"))),
385 ).toHaveLength(1);
386 }),416 }),
387);417);
388418
...@@ -403,11 +433,7 @@ it.effect.each([false, true])("rejects a push to a processed layer, rebased=%s",...@@ -403,11 +433,7 @@ it.effect.each([false, true])("rejects a push to a processed layer, rebased=%s",
403 _tag: "Failure",433 _tag: "Failure",
404 failure: { _tag: "GitHubStackChangedError", number: 2, completed: 1 },434 failure: { _tag: "GitHubStackChangedError", number: 2, completed: 1 },
405 });435 });
406 expect(api.calls.at(-1)?.some((arg) => arg.includes('processed:nodes(ids:["PR_2"])'))).toBe(436 expect(api.calls.at(-1)?.includes('ids=["PR_2"]')).toBe(true);
407 true,437 expect(api.calls.filter((args) => isMutation(args))).toHaveLength(rebasedParent ? 1 : 0);
408 );
409 expect(
410 api.calls.filter((args) => args.some((arg) => arg.startsWith("query=mutation"))),
411 ).toHaveLength(rebasedParent ? 1 : 0);
412 }),438 }),
413);439);
apps/server/src/pullRequest/githubStackActions.ts+57-120
...@@ -8,7 +8,7 @@ import * as Clock from "effect/Clock";...@@ -8,7 +8,7 @@ import * as Clock from "effect/Clock";
8import * as Result from "effect/Result";8import * as Result from "effect/Result";
9import * as Schema from "effect/Schema";9import * as Schema from "effect/Schema";
1010
11import * as GitHubCli from "../sourceControl/GitHubCli.ts";11import * as GitHubApi from "../sourceControl/GitHubApi.ts";
12import { decodePullRequestStacksJson } from "./gitHubPullRequestJson.ts";12import { decodePullRequestStacksJson } from "./gitHubPullRequestJson.ts";
1313
14const stackErrorIdentity = {14const stackErrorIdentity = {
...@@ -21,10 +21,6 @@ export class GitHubStackChangedError extends Schema.TaggedError<GitHubStackChang...@@ -21,10 +21,6 @@ export class GitHubStackChangedError extends Schema.TaggedError<GitHubStackChang
21 "GitHubStackChangedError",21 "GitHubStackChangedError",
22 { ...stackErrorIdentity, completed: Schema.Int },22 { ...stackErrorIdentity, completed: Schema.Int },
23) {23) {
24 get detail(): string {
25 return this.message;
26 }
27
28 override get message(): string {24 override get message(): string {
29 return this.completed > 025 return this.completed > 0
30 ? `The stack changed at PR #${this.number} after ${this.completed} layers. Earlier updates remain on GitHub. Refresh it before trying again.`26 ? `The stack changed at PR #${this.number} after ${this.completed} layers. Earlier updates remain on GitHub. Refresh it before trying again.`
...@@ -36,10 +32,6 @@ export class GitHubStackUnsupportedError extends Schema.TaggedError<GitHubStackU...@@ -36,10 +32,6 @@ export class GitHubStackUnsupportedError extends Schema.TaggedError<GitHubStackU
36 "GitHubStackUnsupportedError",32 "GitHubStackUnsupportedError",
37 stackErrorIdentity,33 stackErrorIdentity,
38) {34) {
39 get detail(): string {
40 return this.message;
41 }
42
43 override get message(): string {35 override get message(): string {
44 return "This operation is not supported for this stack.";36 return "This operation is not supported for this stack.";
45 }37 }
...@@ -49,10 +41,6 @@ export class GitHubStackResponseInvalidError extends Schema.TaggedError<GitHubSt...@@ -49,10 +41,6 @@ export class GitHubStackResponseInvalidError extends Schema.TaggedError<GitHubSt
49 "GitHubStackResponseInvalidError",41 "GitHubStackResponseInvalidError",
50 { ...stackErrorIdentity, cause: Schema.optional(Schema.Defect()) },42 { ...stackErrorIdentity, cause: Schema.optional(Schema.Defect()) },
51) {43) {
52 get detail(): string {
53 return this.message;
54 }
55
56 override get message(): string {44 override get message(): string {
57 return "GitHub returned an unreadable stack operation response.";45 return "GitHub returned an unreadable stack operation response.";
58 }46 }
...@@ -62,10 +50,6 @@ export class GitHubStackMergeRejectedError extends Schema.TaggedError<GitHubStac...@@ -62,10 +50,6 @@ export class GitHubStackMergeRejectedError extends Schema.TaggedError<GitHubStac
62 "GitHubStackMergeRejectedError",50 "GitHubStackMergeRejectedError",
63 { ...stackErrorIdentity, cause: Schema.Defect() },51 { ...stackErrorIdentity, cause: Schema.Defect() },
64) {52) {
65 get detail(): string {
66 return this.message;
67 }
68
69 override get message(): string {53 override get message(): string {
70 return "GitHub refused the stack merge. Check the stack's branch rules and merge requirements.";54 return "GitHub refused the stack merge. Check the stack's branch rules and merge requirements.";
71 }55 }
...@@ -75,10 +59,6 @@ export class GitHubStackMergePendingError extends Schema.TaggedError<GitHubStack...@@ -75,10 +59,6 @@ export class GitHubStackMergePendingError extends Schema.TaggedError<GitHubStack
75 "GitHubStackMergePendingError",59 "GitHubStackMergePendingError",
76 stackErrorIdentity,60 stackErrorIdentity,
77) {61) {
78 get detail(): string {
79 return this.message;
80 }
81
82 override get message(): string {62 override get message(): string {
83 return "The merge is still running on GitHub. Check its status there before submitting another request.";63 return "The merge is still running on GitHub. Check its status there before submitting another request.";
84 }64 }
...@@ -88,10 +68,6 @@ export class GitHubStackPermissionError extends Schema.TaggedError<GitHubStackPe...@@ -88,10 +68,6 @@ export class GitHubStackPermissionError extends Schema.TaggedError<GitHubStackPe
88 "GitHubStackPermissionError",68 "GitHubStackPermissionError",
89 stackErrorIdentity,69 stackErrorIdentity,
90) {70) {
91 get detail(): string {
92 return this.message;
93 }
94
95 override get message(): string {71 override get message(): string {
96 return "You cannot update every branch in this stack. Check write access and fork maintainer permissions before retrying.";72 return "You cannot update every branch in this stack. Check write access and fork maintainer permissions before retrying.";
97 }73 }
...@@ -101,10 +77,6 @@ export class GitHubStackRebaseFailedError extends Schema.TaggedError<GitHubStack...@@ -101,10 +77,6 @@ export class GitHubStackRebaseFailedError extends Schema.TaggedError<GitHubStack
101 "GitHubStackRebaseFailedError",77 "GitHubStackRebaseFailedError",
102 { ...stackErrorIdentity, completed: Schema.Int, cause: Schema.Defect() },78 { ...stackErrorIdentity, completed: Schema.Int, cause: Schema.Defect() },
103) {79) {
104 get detail(): string {
105 return this.message;
106 }
107
108 override get message(): string {80 override get message(): string {
109 return `Stack rebase stopped at PR #${this.number} after ${this.completed} layers. Earlier updates remain on GitHub; resolve the failing layer before retrying.`;81 return `Stack rebase stopped at PR #${this.number} after ${this.completed} layers. Earlier updates remain on GitHub; resolve the failing layer before retrying.`;
110 }82 }
...@@ -179,8 +151,6 @@ const decodeRebaseResponse = Schema.decodeEffect(...@@ -179,8 +151,6 @@ const decodeRebaseResponse = Schema.decodeEffect(
179 ),151 ),
180);152);
181153
182const encodeNodeIds = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String)));
183
184const decodeMergeResponse = Schema.decodeEffect(Schema.fromJsonString(MergeResponse));154const decodeMergeResponse = Schema.decodeEffect(Schema.fromJsonString(MergeResponse));
185155
186/** Remote-only updates: a stack rebase never switches or rewrites the environment's checkout. */156/** Remote-only updates: a stack rebase never switches or rewrites the environment's checkout. */
...@@ -194,7 +164,7 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function*...@@ -194,7 +164,7 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function*
194 action: PullRequestAction;164 action: PullRequestAction;
195 mergeMethod?: PullRequestMergeMethod;165 mergeMethod?: PullRequestMergeMethod;
196}) {166}) {
197 const github = yield* GitHubCli.GitHubCli;167 const api = yield* GitHubApi.GitHubApi;
198 const identity = {168 const identity = {
199 repository: input.repository,169 repository: input.repository,
200 number: input.number,170 number: input.number,
...@@ -203,11 +173,12 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function*...@@ -203,11 +173,12 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function*
203 if (input.action !== "merge" && input.action !== "update-branch")173 if (input.action !== "merge" && input.action !== "update-branch")
204 return yield* new GitHubStackUnsupportedError({ ...identity });174 return yield* new GitHubStackUnsupportedError({ ...identity });
205 const endpoint = `repos/${input.repository}`;175 const endpoint = `repos/${input.repository}`;
206 const read = yield* github.execute({176 const read = yield* api.rest({
207 cwd: input.cwd,177 host: input.host,
208 args: ["api", "--hostname", input.host, `${endpoint}/stacks?pull_request=${input.number}`],178 operation: "runGitHubStackAction",
179 path: `${endpoint}/stacks?pull_request=${input.number}`,
209 });180 });
210 const decoded = decodePullRequestStacksJson(read.stdout);181 const decoded = decodePullRequestStacksJson(read.body);
211 if (Result.isFailure(decoded))182 if (Result.isFailure(decoded))
212 return yield* new GitHubStackResponseInvalidError({ ...identity, cause: decoded.failure });183 return yield* new GitHubStackResponseInvalidError({ ...identity, cause: decoded.failure });
213 const stack = decoded.success;184 const stack = decoded.success;
...@@ -243,27 +214,19 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function*...@@ -243,27 +214,19 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function*
243 return yield* new GitHubStackUnsupportedError({ ...identity });214 return yield* new GitHubStackUnsupportedError({ ...identity });
244 if (input.action === "update-branch") {215 if (input.action === "update-branch") {
245 const [owner, name] = input.repository.split("/");216 const [owner, name] = input.repository.split("/");
246 const permissions = yield* github.execute({217 const permissions = yield* api.graphql({
247 cwd: input.cwd,218 host: input.host,
248 args: [219 operation: "runGitHubStackAction",
249 "api",220 allowReserve: true,
250 "--hostname",221 variables: { owner, name },
251 input.host,222 query: `query($owner:String!,$name:String!){repository(owner:$owner,name:$name){${open
252 "graphql",223 .map(
253 "-f",224 (layer) =>
254 `owner=${owner}`,225 `pr${layer.number}:pullRequest(number:${layer.number}){headRepository{viewerPermission} maintainerCanModify}`,
255 "-f",226 )
256 `name=${name}`,227 .join(" ")}}}`,
257 "-f",
258 `query=query($owner:String!,$name:String!){repository(owner:$owner,name:$name){${open
259 .map(
260 (layer) =>
261 `pr${layer.number}:pullRequest(number:${layer.number}){headRepository{viewerPermission} maintainerCanModify}`,
262 )
263 .join(" ")}}}`,
264 ],
265 });228 });
266 const access = yield* decodeBranchAccess(permissions.stdout).pipe(229 const access = yield* decodeBranchAccess(permissions).pipe(
267 Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })),230 Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })),
268 );231 );
269 // viewerCanUpdateBranch is false for an already-current layer, even if rebasing its parent232 // viewerCanUpdateBranch is false for an already-current layer, even if rebasing its parent
...@@ -282,35 +245,27 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function*...@@ -282,35 +245,27 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function*
282 const processed: Array<{ id: string; number: number; headSha: string }> = [];245 const processed: Array<{ id: string; number: number; headSha: string }> = [];
283 for (const [index, layer] of open.entries()) {246 for (const [index, layer] of open.entries()) {
284 yield* Effect.gen(function* () {247 yield* Effect.gen(function* () {
285 const read = yield* github.execute({248 const read = yield* api.graphql({
286 cwd: input.cwd,249 host: input.host,
287 args: [250 operation: "runGitHubStackAction",
288 "api",251 allowReserve: true,
289 "--hostname",252 variables: {
290 input.host,253 owner,
291 "graphql",254 name,
292 "-f",255 number: layer.number,
293 `owner=${owner}`,256 sha: layer.headSha,
294 "-f",257 ids: processed.map((head) => head.id),
295 `name=${name}`,258 },
296 "-F",259 // GitHub rejects a declared variable the document never uses, so `$ids` is always
297 `number=${layer.number}`,260 // selected; an empty list asks for nothing.
298 "-f",261 query: `query($owner:String!,$name:String!,$number:Int!,$sha:String!,$ids:[ID!]!){processed:nodes(ids:$ids){... on PullRequest{headRefOid}} repository(owner:$owner,name:$name){pullRequest(number:$number){id headRefOid baseRef{compare(headRef:$sha){behindBy}}}}}`,
299 `sha=${layer.headSha}`,
300 "-f",
301 `query=query($owner:String!,$name:String!,$number:Int!,$sha:String!){${
302 processed.length === 0
303 ? ""
304 : `processed:nodes(ids:${encodeNodeIds(processed.map((head) => head.id))}){... on PullRequest{headRefOid}}`
305 } repository(owner:$owner,name:$name){pullRequest(number:$number){id headRefOid baseRef{compare(headRef:$sha){behindBy}}}}}`,
306 ],
307 });262 });
308 const {263 const {
309 data: {264 data: {
310 processed: observed,265 processed: observed,
311 repository: { pullRequest: pr },266 repository: { pullRequest: pr },
312 },267 },
313 } = yield* decodeRebaseBranch(read.stdout);268 } = yield* decodeRebaseBranch(read);
314 // A push to an earlier layer must not silently become the next layer's new base.269 // A push to an earlier layer must not silently become the next layer's new base.
315 const changed = processed.find(270 const changed = processed.find(
316 (head, index) => observed?.[index]?.headRefOid !== head.headSha,271 (head, index) => observed?.[index]?.headRefOid !== head.headSha,
...@@ -332,22 +287,14 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function*...@@ -332,22 +287,14 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function*
332 return;287 return;
333 }288 }
334 // Pass the reviewed revision to GitHub, including when a push races this read.289 // Pass the reviewed revision to GitHub, including when a push races this read.
335 const updated = yield* github.execute({290 const updated = yield* api.graphql({
336 cwd: input.cwd,291 host: input.host,
337 args: [292 operation: "runGitHubStackAction",
338 "api",293 variables: { id: pr.id, sha: layer.headSha },
339 "--hostname",294 query:
340 input.host,295 "mutation($id:ID!,$sha:GitObjectID!){updatePullRequestBranch(input:{pullRequestId:$id,expectedHeadOid:$sha,updateMethod:REBASE}){pullRequest{headRefOid}}}",
341 "graphql",
342 "-f",
343 `id=${pr.id}`,
344 "-f",
345 `sha=${layer.headSha}`,
346 "-f",
347 "query=mutation($id:ID!,$sha:GitObjectID!){updatePullRequestBranch(input:{pullRequestId:$id,expectedHeadOid:$sha,updateMethod:REBASE}){pullRequest{headRefOid}}}",
348 ],
349 });296 });
350 const response = yield* decodeRebaseResponse(updated.stdout);297 const response = yield* decodeRebaseResponse(updated);
351 processed.push({298 processed.push({
352 id: pr.id,299 id: pr.id,
353 number: layer.number,300 number: layer.number,
...@@ -374,24 +321,18 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function*...@@ -374,24 +321,18 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function*
374 decodeMergeResponse(raw).pipe(321 decodeMergeResponse(raw).pipe(
375 Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })),322 Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })),
376 );323 );
377 const request = yield* github.execute({324 const request = yield* api.rest({
378 cwd: input.cwd,325 host: input.host,
379 args: [326 operation: "runGitHubStackAction",
380 "api",327 method: "PUT",
381 "--hostname",328 path: `${endpoint}/pulls/${input.number}/merge-async`,
382 input.host,329 body: {
383 "--method",330 merge_method: input.mergeMethod ?? "merge",
384 "PUT",331 merge_action: "default",
385 `${endpoint}/pulls/${input.number}/merge-async`,332 sha: target.headSha,
386 "-f",333 },
387 `merge_method=${input.mergeMethod ?? "merge"}`,
388 "-f",
389 "merge_action=default",
390 "-f",
391 `sha=${target.headSha}`,
392 ],
393 });334 });
394 let result = yield* decode(request.stdout);335 let result = yield* decode(request.body);
395 const deadline = (yield* Clock.currentTimeMillis) + 5 * 60_000;336 const deadline = (yield* Clock.currentTimeMillis) + 5 * 60_000;
396 for (337 for (
397 let attempt = 0;338 let attempt = 0;
...@@ -401,16 +342,12 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function*...@@ -401,16 +342,12 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function*
401 const uuid = result.details.uuid;342 const uuid = result.details.uuid;
402 if (!uuid) return yield* new GitHubStackResponseInvalidError({ ...identity });343 if (!uuid) return yield* new GitHubStackResponseInvalidError({ ...identity });
403 yield* Effect.sleep(Math.min(1_000 * 2 ** attempt, 10_000));344 yield* Effect.sleep(Math.min(1_000 * 2 ** attempt, 10_000));
404 const poll = yield* github.execute({345 const poll = yield* api.rest({
405 cwd: input.cwd,346 host: input.host,
406 args: [347 operation: "runGitHubStackAction",
407 "api",348 path: `${endpoint}/pulls/${input.number}/merge-async/${encodeURIComponent(uuid)}`,
408 "--hostname",
409 input.host,
410 `${endpoint}/pulls/${input.number}/merge-async/${encodeURIComponent(uuid)}`,
411 ],
412 });349 });
413 result = yield* decode(poll.stdout);350 result = yield* decode(poll.body);
414 }351 }
415 if (result.status === "pending") return yield* new GitHubStackMergePendingError({ ...identity });352 if (result.status === "pending") return yield* new GitHubStackMergePendingError({ ...identity });
416 if (result.status === "failed")353 if (result.status === "failed")
apps/server/src/sourceControl/GitHubApi.ts+8-2
...@@ -117,6 +117,8 @@ export interface GitHubRestResponse {...@@ -117,6 +117,8 @@ export interface GitHubRestResponse {
117 readonly headers: Readonly<Record<string, string | undefined>>;117 readonly headers: Readonly<Record<string, string | undefined>>;
118 readonly body: string;118 readonly body: string;
119 readonly truncated: boolean;119 readonly truncated: boolean;
120 /** The body was not valid UTF-8, which a raw file read takes to mean binary. */
121 readonly invalidUtf8: boolean;
120}122}
121123
122export interface GitHubRestInput {124export interface GitHubRestInput {
...@@ -131,6 +133,8 @@ export interface GitHubRestInput {...@@ -131,6 +133,8 @@ export interface GitHubRestInput {
131 /** Revalidates a cached answer. A 304 is returned rather than failed, and is free. */133 /** Revalidates a cached answer. A 304 is returned rather than failed, and is free. */
132 readonly ifNoneMatch?: string;134 readonly ifNoneMatch?: string;
133 readonly maxResponseBytes?: number;135 readonly maxResponseBytes?: number;
136 /** Defaults to 30 seconds; a whole pull request's patch may need longer. */
137 readonly timeout?: Duration.Input;
134 /** Overrides `AllowGitHubReserve` for this one request. */138 /** Overrides `AllowGitHubReserve` for this one request. */
135 readonly allowReserve?: boolean;139 readonly allowReserve?: boolean;
136}140}
...@@ -381,11 +385,11 @@ export const make = Effect.gen(function* () {...@@ -381,11 +385,11 @@ export const make = Effect.gen(function* () {
381 readonly operation: string;385 readonly operation: string;
382 readonly request: HttpClientRequest.HttpClientRequest;386 readonly request: HttpClientRequest.HttpClientRequest;
383 readonly maxResponseBytes: number;387 readonly maxResponseBytes: number;
388 readonly timeout?: Duration.Input | undefined;
384 readonly allowReserve: boolean;389 readonly allowReserve: boolean;
385 readonly acceptNotModified: boolean;390 readonly acceptNotModified: boolean;
386 /** Reads the body for GraphQL `errors`, which GitHub sends with HTTP 200. */391 /** Reads the body for GraphQL `errors`, which GitHub sends with HTTP 200. */
387 readonly graphql?: boolean;392 readonly graphql?: boolean;
388 readonly timeout?: Duration.Duration | undefined;
389 }) {393 }) {
390 const host = normalizeHost(input.host);394 const host = normalizeHost(input.host);
391 // Only the path: a query string can carry a SHA or a branch, and never needs to be in a trace.395 // Only the path: a query string can carry a SHA or a branch, and never needs to be in a trace.
...@@ -435,7 +439,7 @@ export const make = Effect.gen(function* () {...@@ -435,7 +439,7 @@ export const make = Effect.gen(function* () {
435 // 204, 304 and many refusals carry no body at all, which is an empty answer.439 // 204, 304 and many refusals carry no body at all, which is an empty answer.
436 Effect.catchIf(440 Effect.catchIf(
437 (error) => error.reason._tag === "EmptyBodyError",441 (error) => error.reason._tag === "EmptyBodyError",
438 () => Effect.succeed({ text: "", truncated: false }),442 () => Effect.succeed({ text: "", truncated: false, invalidUtf8: false }),
439 ),443 ),
440 );444 );
441 return { response, collected };445 return { response, collected };
...@@ -468,6 +472,7 @@ export const make = Effect.gen(function* () {...@@ -468,6 +472,7 @@ export const make = Effect.gen(function* () {
468 headers,472 headers,
469 body: collected.text,473 body: collected.text,
470 truncated: collected.truncated,474 truncated: collected.truncated,
475 invalidUtf8: collected.invalidUtf8,
471 }),476 }),
472 ),477 ),
473 RateLimited: () =>478 RateLimited: () =>
...@@ -526,6 +531,7 @@ export const make = Effect.gen(function* () {...@@ -526,6 +531,7 @@ export const make = Effect.gen(function* () {
526 operation: input.operation,531 operation: input.operation,
527 request,532 request,
528 maxResponseBytes: input.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES,533 maxResponseBytes: input.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES,
534 timeout: input.timeout,
529 allowReserve: input.allowReserve ?? interactive,535 allowReserve: input.allowReserve ?? interactive,
530 acceptNotModified: input.ifNoneMatch !== undefined,536 acceptNotModified: input.ifNoneMatch !== undefined,
531 }),537 }),
apps/server/src/sourceControl/GitHubCli.ts+1-4
...@@ -38,10 +38,7 @@ const DEFAULT_TIMEOUT_MS = 30_000;...@@ -38,10 +38,7 @@ const DEFAULT_TIMEOUT_MS = 30_000;
38/** Server-local credential scope; never put its value in RPC payloads or cache keys. */38/** Server-local credential scope; never put its value in RPC payloads or cache keys. */
39export const PinnedGitHubCredential = GitHubApi.PinnedGitHubCredential;39export const PinnedGitHubCredential = GitHubApi.PinnedGitHubCredential;
4040
41export const AllowGitHubReserve = Context.Reference<boolean>(41export const AllowGitHubReserve = GitHubApi.AllowGitHubReserve;
42 "t3/sourceControl/AllowGitHubReserve",
43 { defaultValue: () => false },
44);
4542
46function commandHosts(args: ReadonlyArray<string>): Array<string | null> {43function commandHosts(args: ReadonlyArray<string>): Array<string | null> {
47 const hosts: Array<string | null> = [];44 const hosts: Array<string | null> = [];