authorgravatar for dominic@sdko.orgDominic Roy <dominic@sdko.org> 2026-09-18 14:35:47-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-18 11:35:47-07:00
loge84f23a97d587e0e58215f1fba2eab12717bec28
treef767a237d2aa35b0ff8e83a8012786c1d9f895f5
parent8ebb6112377a110afb956ac01fb44fee68f5709c
signature Signed by PGP key B5690EEEBB952194

fix(web): speed up PR previews with fewer GitHub requests (#11825)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

20 files changed, 1632 insertions(+), 471 deletions(-)

apps/server/scripts/measure-pr-preview.ts created+128
...@@ -0,0 +1,128 @@
1// 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 and
3// service caches are excluded. Uses real GitHub reads, without a server or database.
4// CLI-generated GraphQL
5// queries are replayed with rateLimit.cost, outside the timed section, to measure
6// their cost without confusing other applications' traffic with this process's.
7import * as NodeServices from "@effect/platform-node/NodeServices";
8import * as Effect from "effect/Effect";
9import * as Console from "effect/Console";
10import * as Layer from "effect/Layer";
11import * as Schema from "effect/Schema";
12
13import * as GitHubPullRequestCli from "../src/pullRequest/GitHubPullRequestCli.ts";
14import * as GitHubPullRequestProvider from "../src/pullRequest/GitHubPullRequestProvider.ts";
15import * as GitHubCli from "../src/sourceControl/GitHubCli.ts";
16import * as VcsProcess from "../src/vcs/VcsProcess.ts";
17
18const [repository, ...numbers] = process.argv.slice(2);
19if (!repository || numbers.length === 0 || numbers.some((number) => !/^\d+$/.test(number))) {
20 throw new Error("Usage: node apps/server/scripts/measure-pr-preview.ts owner/repo number...");
21}
22
23type Read = { graphqlRequests: number; restRequests: number; cost: number; debug: string };
24const reads: Read[] = [];
25const measuredProcess = Layer.effect(
26 VcsProcess.VcsProcess,
27 Effect.gen(function* () {
28 const vcs = yield* VcsProcess.VcsProcess;
29 return VcsProcess.VcsProcess.of({
30 run: (input) =>
31 vcs.run({ ...input, env: { ...input.env, GH_DEBUG: input.env?.GH_DEBUG ?? "api" } }).pipe(
32 Effect.tap((output) =>
33 Effect.sync(() => {
34 const graphqlRequests = [...output.stderr.matchAll(/^> POST \/graphql /gm)].length;
35 const requests = [...output.stderr.matchAll(/^> (?:GET|POST) /gm)].length;
36 const cost = /"rateLimit"\s*:\s*\{[^}]*"cost"\s*:\s*(\d+)/.exec(output.stdout)?.[1];
37 reads.push({
38 graphqlRequests,
39 restRequests: requests - graphqlRequests,
40 cost: Number(cost ?? 0),
41 debug: output.stderr,
42 });
43 }),
44 ),
45 ),
46 });
47 }),
48).pipe(Layer.provide(VcsProcess.layer), Layer.provide(NodeServices.layer));
49
50const services = GitHubPullRequestCli.layer.pipe(
51 Layer.provide(GitHubCli.layer),
52 Layer.provideMerge(measuredProcess),
53);
54
55const decodeJson = Schema.decodeSync(Schema.fromJsonString(Schema.Unknown));
56const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
57const graphqlCost = Effect.fn("measurePrPreview.graphqlCost")(function* (read: Read) {
58 // The REST quota probe synthesizes rateLimit.cost for budget admission.
59 // It does not spend GraphQL points.
60 if (!read.graphqlRequests) return 0;
61 if (read.cost) return read.cost;
62 const vcs = yield* VcsProcess.VcsProcess;
63 let cost = 0;
64 let found = 0;
65 for (const match of read.debug.matchAll(
66 /GraphQL query:\n([\s\S]*?)\nGraphQL variables: (\{[^\n]*\})/g,
67 )) {
68 const query = match[1]!.replace(/(\bquery\b[^{]*\{)/, "$1 rateLimit { cost }");
69 const output = yield* vcs.run({
70 operation: "measurePrPreview.cost",
71 command: "gh",
72 cwd: process.cwd(),
73 args: [
74 "api",
75 "graphql",
76 "--hostname",
77 "github.com",
78 "--input",
79 "-",
80 "--jq",
81 ".data.rateLimit.cost",
82 ],
83 stdin: encodeJson({ query, variables: decodeJson(match[2]!) }),
84 env: { GH_DEBUG: "" },
85 });
86 cost += Number(output.stdout.trim());
87 found++;
88 }
89 if (found !== read.graphqlRequests)
90 throw new Error("Could not account for every GraphQL request");
91 return cost;
92});
93
94for (const mode of ["detail", "preview"] as const) {
95 // Start each side cold, then retain the caches shared across PRs in one session.
96 const rows = await Effect.runPromise(
97 Effect.scoped(
98 Effect.gen(function* () {
99 const provider = yield* GitHubPullRequestProvider.make;
100 return yield* Effect.forEach(numbers.map(Number), (number) =>
101 Effect.gen(function* () {
102 const input = { cwd: process.cwd(), repository, host: "github.com", number };
103 reads.length = 0;
104 const start = performance.now();
105 const value = yield* mode === "detail"
106 ? provider.getChangeRequest(input)
107 : provider.getChangeRequestPreview!(input);
108 const elapsedMs = Math.round(performance.now() - start);
109 const measured = [...reads];
110 const costs = yield* Effect.forEach(measured, graphqlCost);
111 const points = costs.reduce((total, cost) => total + cost, 0);
112 return {
113 repository,
114 number,
115 mode,
116 state: value.state,
117 elapsedMs,
118 points,
119 graphqlRequests: measured.reduce((total, read) => total + read.graphqlRequests, 0),
120 restRequests: measured.reduce((total, read) => total + read.restRequests, 0),
121 };
122 }),
123 );
124 }),
125 ).pipe(Effect.provide(services)),
126 );
127 for (const row of rows) await Effect.runPromise(Console.log(encodeJson(row)));
128}
apps/server/src/auth/RpcAuthorization.ts+1
...@@ -73,6 +73,7 @@ export const RPC_REQUIRED_SCOPES = {...@@ -73,6 +73,7 @@ export const RPC_REQUIRED_SCOPES = {
73 [WS_METHODS.pullRequestsStack]: AuthOrchestrationReadScope,73 [WS_METHODS.pullRequestsStack]: AuthOrchestrationReadScope,
74 [WS_METHODS.pullRequestsLinkedThreads]: AuthOrchestrationReadScope,74 [WS_METHODS.pullRequestsLinkedThreads]: AuthOrchestrationReadScope,
75 [WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope,75 [WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope,
76 [WS_METHODS.pullRequestsPreview]: AuthOrchestrationReadScope,
76 [WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope,77 [WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope,
77 [WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope,78 [WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope,
78 [WS_METHODS.pullRequestsDiffFileContents]: AuthOrchestrationReadScope,79 [WS_METHODS.pullRequestsDiffFileContents]: AuthOrchestrationReadScope,
apps/server/src/pullRequest/GitHubPullRequestCli.test.ts+459-91
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import { afterEach, assert, expect, it, vi } from "@effect/vitest";1import { afterEach, assert, expect, it, vi } from "@effect/vitest";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as Clock from "effect/Clock";
3import * as Deferred from "effect/Deferred";4import * as Deferred from "effect/Deferred";
4import * as Fiber from "effect/Fiber";5import * as Fiber from "effect/Fiber";
5import * as Layer from "effect/Layer";6import * as Layer from "effect/Layer";
...@@ -16,6 +17,36 @@ import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts";...@@ -16,6 +17,36 @@ import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts";
1617
17const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));18const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
1819
20const coreResponse = (pullRequest: Readonly<Record<string, unknown>> = {}) => ({
21 data: {
22 repository: {
23 mergeCommitAllowed: true,
24 squashMergeAllowed: false,
25 rebaseMergeAllowed: true,
26 viewerPermission: "WRITE",
27 pullRequest: {
28 number: 7,
29 title: "Pull request 7",
30 url: "https://github.com/acme/web/pull/7",
31 headRefName: "feature",
32 baseRefName: "main",
33 headRefOid: "abc123",
34 state: "OPEN",
35 createdAt: "2026-07-01T00:00:00Z",
36 updatedAt: "2026-07-02T00:00:00Z",
37 viewerCanUpdate: true,
38 viewerDidAuthor: false,
39 viewerCanUpdateBranch: true,
40 baseRef: { compare: { behindBy: 2 } },
41 reviewRequests: { nodes: [] },
42 labels: { nodes: [] },
43 commits: { nodes: [] },
44 ...pullRequest,
45 },
46 },
47 },
48});
49
19const mockedExecute = vi.fn<GitHubCli.GitHubCli["Service"]["execute"]>();50const mockedExecute = vi.fn<GitHubCli.GitHubCli["Service"]["execute"]>();
20const mockedStackMemberships = vi.fn<GitHubCli.GitHubCli["Service"]["execute"]>(() =>51const mockedStackMemberships = vi.fn<GitHubCli.GitHubCli["Service"]["execute"]>(() =>
21 Effect.succeed(output('{"data":{}}')),52 Effect.succeed(output('{"data":{}}')),
...@@ -33,7 +64,7 @@ const layer = it.layer(...@@ -33,7 +64,7 @@ const layer = it.layer(
33 getPullRequest: mockedGetPullRequest,64 getPullRequest: mockedGetPullRequest,
34 }),65 }),
35 ),66 ),
36 Layer.provide(GitHubGraphQlBudget.layer),67 Layer.provideMerge(GitHubGraphQlBudget.layer),
37 ),68 ),
38);69);
3970
...@@ -255,6 +286,118 @@ it.effect(...@@ -255,6 +286,118 @@ it.effect(
255);286);
256287
257layer("GitHubPullRequestCli.layer", (it) => {288layer("GitHubPullRequestCli.layer", (it) => {
289 it.effect("admits only one concurrent preview above the reserve and resumes after reset", () =>
290 Effect.gen(function* () {
291 const now = yield* Clock.currentTimeMillis;
292 const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;
293 const resetAt = "2099-08-13T14:00:00Z";
294 yield* budget.observe(
295 "preview-budget.example",
296 encodeJson({ data: { rateLimit: { cost: 1, limit: 5_000, remaining: 501, resetAt } } }),
297 );
298 mockedExecute.mockReturnValue(
299 Effect.succeed(
300 output(
301 encodeJson({
302 data: {
303 repository: {
304 pullRequest: {
305 number: 7,
306 title: "Fast previews",
307 url: "https://preview-budget.example/acme/web/pull/7",
308 state: "OPEN",
309 isDraft: false,
310 createdAt: "2026-07-01T00:00:00Z",
311 author: null,
312 },
313 },
314 rateLimit: { cost: 1, limit: 5_000, remaining: 500, resetAt },
315 },
316 }),
317 ),
318 ),
319 );
320 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
321 const input = {
322 cwd: "/w",
323 repository: "acme/web",
324 host: "preview-budget.example",
325 number: 7,
326 };
327 const results = yield* Effect.all(
328 Array.from({ length: 20 }, (_, index) =>
329 cli.getPullRequestPreview({ ...input, number: index + 1 }).pipe(Effect.result),
330 ),
331 { concurrency: "unbounded" },
332 );
333 expect(results.filter((result) => result._tag === "Success")).toHaveLength(1);
334 for (const result of results) {
335 if (result._tag === "Failure") {
336 expect(result.failure._tag).toBe("SourceControlRateLimitPausedError");
337 }
338 }
339 expect(mockedExecute).toHaveBeenCalledTimes(1);
340 yield* TestClock.setTime(Date.parse(resetAt));
341 yield* cli.getPullRequestPreview(input);
342 expect(mockedExecute).toHaveBeenCalledTimes(2);
343 yield* TestClock.setTime(now);
344 }),
345 );
346
347 it.effect("loads the complete hover card with one GraphQL request", () =>
348 Effect.gen(function* () {
349 mockedExecute.mockReturnValueOnce(
350 Effect.succeed(
351 output(
352 encodeJson({
353 data: {
354 repository: {
355 pullRequest: {
356 number: 7,
357 title: "Fast previews",
358 url: "https://github.example/acme/web/pull/7",
359 state: "MERGED",
360 isDraft: false,
361 createdAt: "2026-07-01T00:00:00Z",
362 author: {
363 login: "octocat",
364 name: "Octo Cat",
365 avatarUrl: "https://github.example/avatar.png",
366 },
367 },
368 },
369 },
370 }),
371 ),
372 ),
373 );
374 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
375 const preview = yield* cli.getPullRequestPreview({
376 cwd: "/w",
377 repository: "acme/web",
378 host: "github.example",
379 number: 7,
380 });
381 expect(preview).toEqual({
382 number: 7,
383 title: "Fast previews",
384 url: "https://github.example/acme/web/pull/7",
385 state: "merged",
386 isDraft: false,
387 createdAt: "2026-07-01T00:00:00Z",
388 author: {
389 login: "octocat",
390 name: "Octo Cat",
391 avatarUrl: "https://github.example/avatar.png",
392 },
393 });
394 expect(mockedExecute).toHaveBeenCalledTimes(1);
395 expect(callAt(0).args).toEqual(
396 expect.arrayContaining(["api", "graphql", "--hostname", "github.example"]),
397 );
398 }),
399 );
400
258 it.effect("coalesces concurrent identity verification for the same host and credential", () =>401 it.effect("coalesces concurrent identity verification for the same host and credential", () =>
259 Effect.gen(function* () {402 Effect.gen(function* () {
260 mockedExecute.mockImplementation((input) =>403 mockedExecute.mockImplementation((input) =>
...@@ -1741,19 +1884,20 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1741,19 +1884,20 @@ layer("GitHubPullRequestCli.layer", (it) => {
1741 mockedExecute.mockReturnValueOnce(1884 mockedExecute.mockReturnValueOnce(
1742 Effect.succeed(1885 Effect.succeed(
1743 output(1886 output(
1744 // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response.1887 encodeJson(
1745 JSON.stringify({1888 coreResponse({
1746 number: 7,1889 number: 7,
1747 title: "Pull request 7",1890 title: "Pull request 7",
1748 url: "https://github.com/acme/web/pull/7",1891 url: "https://github.com/acme/web/pull/7",
1749 headRefName: "feat/page",1892 headRefName: "feat/page",
1750 headRefOid: "abc123",1893 headRefOid: "abc123",
1751 isCrossRepository: false,1894 isCrossRepository: false,
1752 headRepositoryOwner: { login: "acme" },1895 headRepositoryOwner: { login: "acme" },
1753 baseRefName: "main",1896 baseRefName: "main",
1754 createdAt: "2026-07-01T00:00:00Z",1897 createdAt: "2026-07-01T00:00:00Z",
1755 updatedAt: "2026-07-02T00:00:00Z",1898 updatedAt: "2026-07-02T00:00:00Z",
1756 }),1899 }),
1900 ),
1757 ),1901 ),
1758 ),1902 ),
1759 );1903 );
...@@ -1774,19 +1918,20 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1774,19 +1918,20 @@ layer("GitHubPullRequestCli.layer", (it) => {
1774 it.effect("finds and approves every workflow waiting on a maintainer", () =>1918 it.effect("finds and approves every workflow waiting on a maintainer", () =>
1775 Effect.gen(function* () {1919 Effect.gen(function* () {
1776 const detail = output(1920 const detail = output(
1777 // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response.1921 encodeJson(
1778 JSON.stringify({1922 coreResponse({
1779 number: 7,1923 number: 7,
1780 title: "Pull request 7",1924 title: "Pull request 7",
1781 url: "https://github.com/acme/web/pull/7",1925 url: "https://github.com/acme/web/pull/7",
1782 headRefName: "feat/page",1926 headRefName: "feat/page",
1783 headRefOid: "abc123",1927 headRefOid: "abc123",
1784 isCrossRepository: true,1928 isCrossRepository: true,
1785 headRepositoryOwner: { login: "octocat" },1929 headRepositoryOwner: { login: "octocat" },
1786 baseRefName: "main",1930 baseRefName: "main",
1787 createdAt: "2026-07-01T00:00:00Z",1931 createdAt: "2026-07-01T00:00:00Z",
1788 updatedAt: "2026-07-02T00:00:00Z",1932 updatedAt: "2026-07-02T00:00:00Z",
1789 }),1933 }),
1934 ),
1790 );1935 );
1791 const heads = output(1936 const heads = output(
1792 // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response.1937 // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response.
...@@ -1902,7 +2047,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1902,7 +2047,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
1902 updatedAt: "2026-07-02T00:00:00Z",2047 updatedAt: "2026-07-02T00:00:00Z",
1903 };2048 };
1904 for (const value of [2049 for (const value of [
1905 detail,2050 coreResponse(detail),
1906 [2051 [
1907 {2052 {
1908 number: 7,2053 number: 7,
...@@ -1912,7 +2057,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1912,7 +2057,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
1912 },2057 },
1913 ],2058 ],
1914 [{ databaseId: 10, workflowName: "build", url: "https://example.com/10" }],2059 [{ databaseId: 10, workflowName: "build", url: "https://example.com/10" }],
1915 { ...detail, headRefOid: "def456" },2060 coreResponse({ ...detail, headRefOid: "def456" }),
1916 ]) {2061 ]) {
1917 mockedExecute.mockReturnValueOnce(2062 mockedExecute.mockReturnValueOnce(
1918 Effect.succeed(2063 Effect.succeed(
...@@ -1943,8 +2088,47 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1943,8 +2088,47 @@ layer("GitHubPullRequestCli.layer", (it) => {
1943 }),2088 }),
1944 );2089 );
19452090
2091 it.effect("reads workflow runs and their pull request scope concurrently", () =>
2092 Effect.gen(function* () {
2093 const headsStarted = yield* Deferred.make<void>();
2094 const runsStarted = yield* Deferred.make<void>();
2095 mockedExecute.mockImplementation(({ args }) =>
2096 args[0] === "pr"
2097 ? Deferred.succeed(headsStarted, undefined).pipe(
2098 Effect.andThen(Deferred.await(runsStarted)),
2099 Effect.as(
2100 output(
2101 '[{"number":7,"headRefOid":"abc123","isCrossRepository":true,"headRepositoryOwner":{"login":"octocat"}}]',
2102 ),
2103 ),
2104 )
2105 : Deferred.succeed(runsStarted, undefined).pipe(
2106 Effect.andThen(Deferred.await(headsStarted)),
2107 Effect.as(
2108 output('[{"databaseId":10,"workflowName":"build","url":"https://example.com/10"}]'),
2109 ),
2110 ),
2111 );
2112 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
2113
2114 const runs = yield* cli.listWorkflowRunsRequiringApproval({
2115 cwd: "/w",
2116 repository: "acme/web",
2117 host: "github.com",
2118 number: 7,
2119 headSha: "abc123",
2120 headBranch: "feat/page",
2121 headRepositoryOwner: "octocat",
2122 isCrossRepository: true,
2123 });
2124
2125 expect(runs).toEqual([{ id: 10, name: "build", url: "https://example.com/10" }]);
2126 }),
2127 );
2128
1946 it.effect("refuses workflow approval when one head belongs to several pull requests", () =>2129 it.effect("refuses workflow approval when one head belongs to several pull requests", () =>
1947 Effect.gen(function* () {2130 Effect.gen(function* () {
2131 mockedExecute.mockReturnValue(Effect.succeed(output("[]")));
1948 mockedExecute.mockReturnValueOnce(2132 mockedExecute.mockReturnValueOnce(
1949 Effect.succeed(2133 Effect.succeed(
1950 output(2134 output(
...@@ -1983,7 +2167,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1983,7 +2167,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
1983 limit: 1_000,2167 limit: 1_000,
1984 });2168 });
1985 expect(error.detail).toContain("instead of uniquely matching #7");2169 expect(error.detail).toContain("instead of uniquely matching #7");
1986 expect(mockedExecute).toHaveBeenCalledTimes(1);2170 expect(mockedExecute).toHaveBeenCalledTimes(2);
1987 }),2171 }),
1988 );2172 );
19892173
...@@ -1992,19 +2176,20 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1992,19 +2176,20 @@ layer("GitHubPullRequestCli.layer", (it) => {
1992 mockedExecute.mockReturnValueOnce(2176 mockedExecute.mockReturnValueOnce(
1993 Effect.succeed(2177 Effect.succeed(
1994 output(2178 output(
1995 // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response.2179 encodeJson(
1996 JSON.stringify({2180 coreResponse({
1997 number: 7,2181 number: 7,
1998 title: "Pull request 7",2182 title: "Pull request 7",
1999 url: "https://github.com/acme/web/pull/7",2183 url: "https://github.com/acme/web/pull/7",
2000 headRefName: "feat/page",2184 headRefName: "feat/page",
2001 headRefOid: "abc123",2185 headRefOid: "abc123",
2002 isCrossRepository: true,2186 isCrossRepository: true,
2003 headRepositoryOwner: null,2187 headRepositoryOwner: null,
2004 baseRefName: "main",2188 baseRefName: "main",
2005 createdAt: "2026-07-01T00:00:00Z",2189 createdAt: "2026-07-01T00:00:00Z",
2006 updatedAt: "2026-07-02T00:00:00Z",2190 updatedAt: "2026-07-02T00:00:00Z",
2007 }),2191 }),
2192 ),
2008 ),2193 ),
2009 ),2194 ),
2010 );2195 );
...@@ -3041,19 +3226,20 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -3041,19 +3226,20 @@ layer("GitHubPullRequestCli.layer", (it) => {
3041 mockedExecute.mockReturnValueOnce(3226 mockedExecute.mockReturnValueOnce(
3042 Effect.succeed(3227 Effect.succeed(
3043 output(3228 output(
3044 // @effect-diagnostics-next-line preferSchemaOverJson:off3229 encodeJson(
3045 JSON.stringify({3230 coreResponse({
3046 number: 7,3231 number: 7,
3047 title: "Progressive detail",3232 title: "Progressive detail",
3048 url: "https://github.com/acme/web/pull/7",3233 url: "https://github.com/acme/web/pull/7",
3049 author: { login: "octocat" },3234 author: { login: "octocat" },
3050 headRefName: "feature",3235 headRefName: "feature",
3051 baseRefName: "main",3236 baseRefName: "main",
3052 createdAt: "2026-07-01T00:00:00Z",3237 createdAt: "2026-07-01T00:00:00Z",
3053 updatedAt: "2026-07-02T00:00:00Z",3238 updatedAt: "2026-07-02T00:00:00Z",
3054 body: "Core body",3239 body: "Core body",
3055 changedFiles: 2,3240 changedFiles: 2,
3056 }),3241 }),
3242 ),
3057 ),3243 ),
3058 ),3244 ),
3059 );3245 );
...@@ -3083,13 +3269,216 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -3083,13 +3269,216 @@ layer("GitHubPullRequestCli.layer", (it) => {
30833269
3084 expect(detail.body).toBe("Core body");3270 expect(detail.body).toBe("Core body");
3085 expect(activity.author?.login).toBe("octocat");3271 expect(activity.author?.login).toBe("octocat");
3086 expect(callAt(0).args.at(-1)).toBe(3272 expect(callAt(0).args).toContain("headRef=refs/pull/7/head");
3087 "number,title,url,author,headRefName,baseRefName,state,isDraft,mergeable,reviewDecision,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,labels,statusCheckRollup,body,changedFiles,closedAt,isCrossRepository,headRepositoryOwner,headRefOid,autoMergeRequest",3273 expect(callAt(0).args.at(-1)).toContain("viewerCanUpdateBranch");
3088 );3274 expect(detail.viewerAccess.mergeCapabilities).toEqual({
3275 merge: true,
3276 squash: false,
3277 rebase: true,
3278 });
3279 expect(detail.comparison).toEqual({ behindBy: 2, viewerCanUpdate: true });
3089 expect(callAt(1).args.at(-1)).toBe("author,comments,reviews,commits");3280 expect(callAt(1).args.at(-1)).toBe("author,comments,reviews,commits");
3090 }),3281 }),
3091 );3282 );
30923283
3284 it.effect("decodes reviewers, labels and workflow checks without another detail read", () =>
3285 Effect.gen(function* () {
3286 mockedExecute.mockReturnValueOnce(
3287 Effect.succeed(
3288 output(
3289 encodeJson(
3290 coreResponse({
3291 baseRef: null,
3292 reviewRequests: {
3293 nodes: [
3294 { requestedReviewer: { login: "reviewer" } },
3295 { requestedReviewer: { slug: "maintainers", name: "Maintainers" } },
3296 ],
3297 },
3298 labels: { nodes: [{ name: "bug", color: "ff0000" }] },
3299 commits: {
3300 nodes: [
3301 {
3302 commit: {
3303 statusCheckRollup: {
3304 contexts: {
3305 nodes: [
3306 {
3307 __typename: "CheckRun",
3308 name: "build",
3309 status: "COMPLETED",
3310 conclusion: "SUCCESS",
3311 checkSuite: { workflowRun: { workflow: { name: "linux" } } },
3312 },
3313 {
3314 __typename: "CheckRun",
3315 name: "build",
3316 status: "COMPLETED",
3317 conclusion: "FAILURE",
3318 checkSuite: { workflowRun: { workflow: { name: "windows" } } },
3319 },
3320 ],
3321 pageInfo: { hasNextPage: false },
3322 },
3323 },
3324 },
3325 },
3326 ],
3327 },
3328 }),
3329 ),
3330 ),
3331 ),
3332 );
3333 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
3334 const detail = yield* cli.getPullRequestDetail({
3335 cwd: "/w",
3336 repository: "acme/web",
3337 host: "github.com",
3338 number: 7,
3339 });
3340 expect(mockedExecute).toHaveBeenCalledOnce();
3341 expect(detail.comparison).toBeNull();
3342 expect(detail.reviewRequestLogins).toEqual(["reviewer"]);
3343 expect(detail.hasTeamReviewRequest).toBe(true);
3344 expect(detail.labels).toEqual([{ name: "bug", color: "ff0000" }]);
3345 expect(detail.checks).toHaveLength(2);
3346 expect(detail.checksState).toBe("failing");
3347 }),
3348 );
3349
3350 it.effect("reads every check when the combined response has another page", () =>
3351 Effect.gen(function* () {
3352 const response = coreResponse({
3353 commits: {
3354 nodes: [
3355 {
3356 commit: {
3357 statusCheckRollup: {
3358 contexts: {
3359 nodes: [{ name: "first", status: "COMPLETED", conclusion: "SUCCESS" }],
3360 pageInfo: { hasNextPage: true },
3361 },
3362 },
3363 },
3364 },
3365 ],
3366 },
3367 });
3368 mockedExecute.mockReturnValueOnce(Effect.succeed(output(encodeJson(response))));
3369 mockedExecute.mockReturnValueOnce(
3370 Effect.succeed(
3371 output(
3372 encodeJson({
3373 ...response.data.repository.pullRequest,
3374 reviewRequests: [],
3375 labels: [],
3376 statusCheckRollup: [
3377 { name: "first", status: "COMPLETED", conclusion: "SUCCESS" },
3378 { name: "last", status: "COMPLETED", conclusion: "FAILURE" },
3379 ],
3380 }),
3381 ),
3382 ),
3383 );
3384 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
3385 const detail = yield* cli.getPullRequestDetail({
3386 cwd: "/w",
3387 repository: "acme/web",
3388 host: "github.com",
3389 number: 7,
3390 });
3391 expect(detail.checks).toHaveLength(2);
3392 expect(detail.checksState).toBe("failing");
3393 expect(detail.checksTruncated).toBe(false);
3394 expect(callAt(1).args.slice(0, 2)).toEqual(["pr", "view"]);
3395 }),
3396 );
3397
3398 it.effect("refuses to combine checks from different head revisions", () =>
3399 Effect.gen(function* () {
3400 const response = coreResponse({
3401 commits: {
3402 nodes: [
3403 {
3404 commit: {
3405 statusCheckRollup: {
3406 contexts: {
3407 nodes: [],
3408 pageInfo: { hasNextPage: true },
3409 },
3410 },
3411 },
3412 },
3413 ],
3414 },
3415 });
3416 mockedExecute.mockReturnValueOnce(Effect.succeed(output(encodeJson(response))));
3417 mockedExecute.mockReturnValueOnce(
3418 Effect.succeed(
3419 output(
3420 encodeJson({
3421 ...response.data.repository.pullRequest,
3422 headRefOid: "new-head",
3423 reviewRequests: [],
3424 labels: [],
3425 statusCheckRollup: [],
3426 }),
3427 ),
3428 ),
3429 );
3430 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
3431 const error = yield* Effect.flip(
3432 cli.getPullRequestDetail({
3433 cwd: "/w",
3434 repository: "acme/web",
3435 host: "github.com",
3436 number: 7,
3437 }),
3438 );
3439 expect(error._tag).toBe("GitHubPullRequestReadError");
3440 }),
3441 );
3442
3443 it.effect("preserves the reserve for automatic detail reads and allows manual checks", () =>
3444 Effect.gen(function* () {
3445 const response = coreResponse();
3446 mockedExecute.mockReturnValue(
3447 Effect.succeed(
3448 output(
3449 encodeJson({
3450 ...response,
3451 data: {
3452 ...response.data,
3453 rateLimit: {
3454 cost: 1,
3455 limit: 5000,
3456 remaining: 500,
3457 resetAt: "2099-08-13T14:00:00Z",
3458 },
3459 },
3460 }),
3461 ),
3462 ),
3463 );
3464 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
3465 const input = {
3466 cwd: "/w",
3467 repository: "acme/web",
3468 host: "github.core-reserve.test",
3469 number: 7,
3470 };
3471 yield* cli.getPullRequestDetail(input);
3472 const error = yield* Effect.flip(cli.getPullRequestDetail(input));
3473 expect(error._tag).toBe("SourceControlRateLimitPausedError");
3474 expect(mockedExecute).toHaveBeenCalledOnce();
3475 yield* cli
3476 .getPullRequestDetail(input)
3477 .pipe(Effect.provideService(GitHubCli.AllowGitHubReserve, true));
3478 expect(mockedExecute).toHaveBeenCalledTimes(2);
3479 }),
3480 );
3481
3093 it.effect("fails a files page too large to read rather than calling the diff whole", () =>3482 it.effect("fails a files page too large to read rather than calling the diff whole", () =>
3094 Effect.gen(function* () {3483 Effect.gen(function* () {
3095 mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused));3484 mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused));
...@@ -3271,6 +3660,9 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -3271,6 +3660,9 @@ layer("GitHubPullRequestCli.layer", (it) => {
3271 JSON.stringify({3660 JSON.stringify({
3272 data: {3661 data: {
3273 repository: {3662 repository: {
3663 mergeCommitAllowed: true,
3664 squashMergeAllowed: false,
3665 rebaseMergeAllowed: true,
3274 viewerPermission: "READ",3666 viewerPermission: "READ",
3275 pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true },3667 pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true },
3276 },3668 },
...@@ -3291,7 +3683,11 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -3291,7 +3683,11 @@ layer("GitHubPullRequestCli.layer", (it) => {
3291 // One request, because both answers hang off the same repository object.3683 // One request, because both answers hang off the same repository object.
3292 assert.strictEqual(mockedExecute.mock.calls.length, 1);3684 assert.strictEqual(mockedExecute.mock.calls.length, 1);
3293 expect(callAt(0).args).toContain("number=7");3685 expect(callAt(0).args).toContain("number=7");
3686 expect(callAt(0).args.at(-1)).toContain(
3687 "mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed",
3688 );
3294 expect(access).toEqual({3689 expect(access).toEqual({
3690 mergeCapabilities: { merge: true, squash: false, rebase: true },
3295 canWrite: false,3691 canWrite: false,
3296 canTriage: false,3692 canTriage: false,
3297 canUpdate: true,3693 canUpdate: true,
...@@ -3433,6 +3829,9 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -3433,6 +3829,9 @@ layer("GitHubPullRequestCli.layer", (it) => {
3433 JSON.stringify({3829 JSON.stringify({
3434 data: {3830 data: {
3435 repository: {3831 repository: {
3832 mergeCommitAllowed: true,
3833 squashMergeAllowed: false,
3834 rebaseMergeAllowed: true,
3436 viewerPermission: "READ",3835 viewerPermission: "READ",
3437 pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true },3836 pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true },
3438 },3837 },
...@@ -3460,6 +3859,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -3460,6 +3859,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
34603859
3461 assert.strictEqual(mockedExecute.mock.calls.length, 2);3860 assert.strictEqual(mockedExecute.mock.calls.length, 2);
3462 expect(access).toEqual({3861 expect(access).toEqual({
3862 mergeCapabilities: { merge: true, squash: false, rebase: true },
3463 canWrite: false,3863 canWrite: false,
3464 canTriage: false,3864 canTriage: false,
3465 canUpdate: true,3865 canUpdate: true,
...@@ -3469,38 +3869,6 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -3469,38 +3869,6 @@ layer("GitHubPullRequestCli.layer", (it) => {
3469 }),3869 }),
3470 );3870 );
34713871
3472 it.effect("reads the viewer's role off the same call as the merge settings", () =>
3473 Effect.gen(function* () {
3474 mockedExecute.mockReturnValue(
3475 Effect.succeed(
3476 output(
3477 // @effect-diagnostics-next-line preferSchemaOverJson:off
3478 JSON.stringify({
3479 mergeCommitAllowed: false,
3480 squashMergeAllowed: true,
3481 rebaseMergeAllowed: true,
3482 viewerPermission: "WRITE",
3483 }),
3484 ),
3485 ),
3486 );
3487 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
3488
3489 const access = yield* cli.getRepositoryAccess({
3490 cwd: "/w",
3491 repository: "acme/web",
3492 host: "github.com",
3493 });
3494
3495 assert.strictEqual(mockedExecute.mock.calls.length, 1);
3496 expect(callAt(0).args).toContain(
3497 "mergeCommitAllowed,squashMergeAllowed,rebaseMergeAllowed,viewerPermission",
3498 );
3499 assert.isTrue(access.canWrite);
3500 expect(access.mergeCapabilities).toEqual({ merge: false, squash: true, rebase: true });
3501 }),
3502 );
3503
3504 it.effect("asks GitHub to review, naming the collection a request is added to", () =>3872 it.effect("asks GitHub to review, naming the collection a request is added to", () =>
3505 Effect.gen(function* () {3873 Effect.gen(function* () {
3506 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));3874 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
apps/server/src/pullRequest/GitHubPullRequestCli.ts+182-134
...@@ -30,6 +30,7 @@ import {...@@ -30,6 +30,7 @@ import {
30 type PullRequestLabelCandidateList,30 type PullRequestLabelCandidateList,
31 type PullRequestThreadCommentsResult,31 type PullRequestThreadCommentsResult,
32 type PullRequestUpdateMethod,32 type PullRequestUpdateMethod,
33 type PullRequestPreview,
33} from "@t3tools/contracts";34} from "@t3tools/contracts";
3435
35import * as GitHubCli from "../sourceControl/GitHubCli.ts";36import * as GitHubCli from "../sourceControl/GitHubCli.ts";
...@@ -44,6 +45,11 @@ import {...@@ -44,6 +45,11 @@ import {
44 decodeActorAvatarsJson,45 decodeActorAvatarsJson,
45 decodePullRequestActivityJson,46 decodePullRequestActivityJson,
46 decodePullRequestDetailJson,47 decodePullRequestDetailJson,
48 decodePullRequestCoreJson,
49 PULL_REQUEST_CORE_GRAPHQL_QUERY,
50 type GitHubPullRequestCore,
51 decodePullRequestPreviewJson,
52 PULL_REQUEST_PREVIEW_GRAPHQL_QUERY,
47 decodePullRequestFilesJson,53 decodePullRequestFilesJson,
48 decodePullRequestFilesViewedJson,54 decodePullRequestFilesViewedJson,
49 decodePullRequestHeadsJson,55 decodePullRequestHeadsJson,
...@@ -53,7 +59,6 @@ import {...@@ -53,7 +59,6 @@ import {
53 decodePullRequestStacksJson,59 decodePullRequestStacksJson,
54 decodePullRequestStatsJson,60 decodePullRequestStatsJson,
55 decodeReactionSubjectScopeJson,61 decodeReactionSubjectScopeJson,
56 decodeRepositoryAccessJson,
57 decodeReviewerCandidatesJson,62 decodeReviewerCandidatesJson,
58 decodeLabelCandidatesJson,63 decodeLabelCandidatesJson,
59 buildLabelRequestJson,64 buildLabelRequestJson,
...@@ -78,7 +83,6 @@ import {...@@ -78,7 +83,6 @@ import {
78 REMOVE_REACTION_GRAPHQL_MUTATION,83 REMOVE_REACTION_GRAPHQL_MUTATION,
79 REVERT_PULL_REQUEST_GRAPHQL_MUTATION,84 REVERT_PULL_REQUEST_GRAPHQL_MUTATION,
80 gitHubReactionContent,85 gitHubReactionContent,
81 REPOSITORY_ACCESS_JSON_FIELDS,
82 RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION,86 RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION,
83 REVIEWER_CANDIDATES_GRAPHQL_QUERY,87 REVIEWER_CANDIDATES_GRAPHQL_QUERY,
84 REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY,88 REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY,
...@@ -94,7 +98,6 @@ import {...@@ -94,7 +98,6 @@ import {
94 decodeViewerPermissionsJson,98 decodeViewerPermissionsJson,
95 decodeWorkflowRunApprovalsJson,99 decodeWorkflowRunApprovalsJson,
96 type GitHubBaseComparison,100 type GitHubBaseComparison,
97 type GitHubPullRequestDetail,
98 type GitHubPullRequestActivity,101 type GitHubPullRequestActivity,
99 type GitHubPullRequestHead,102 type GitHubPullRequestHead,
100 type GitHubPullRequestListItem,103 type GitHubPullRequestListItem,
...@@ -519,7 +522,17 @@ export class GitHubPullRequestCli extends Context.Service<...@@ -519,7 +522,17 @@ export class GitHubPullRequestCli extends Context.Service<
519 readonly repository: string;522 readonly repository: string;
520 readonly host: string;523 readonly host: string;
521 readonly number: number;524 readonly number: number;
522 }) => Effect.Effect<GitHubPullRequestDetail, GitHubPullRequestCliError>;525 }) => Effect.Effect<GitHubPullRequestCore, GitHubPullRequestCliError>;
526
527 readonly getPullRequestPreview: (input: {
528 readonly cwd: string;
529 readonly repository: string;
530 readonly host: string;
531 readonly number: number;
532 }) => Effect.Effect<
533 Omit<PullRequestPreview, "projectId" | "repository">,
534 GitHubPullRequestCliError
535 >;
523536
524 readonly listWorkflowRunsRequiringApproval: (input: {537 readonly listWorkflowRunsRequiringApproval: (input: {
525 readonly cwd: string;538 readonly cwd: string;
...@@ -639,13 +652,6 @@ export class GitHubPullRequestCli extends Context.Service<...@@ -639,13 +652,6 @@ export class GitHubPullRequestCli extends Context.Service<
639 readonly cursor: string;652 readonly cursor: string;
640 }) => Effect.Effect<PullRequestThreadCommentsResult, GitHubPullRequestCliError>;653 }) => Effect.Effect<PullRequestThreadCommentsResult, GitHubPullRequestCliError>;
641654
642 /** One `gh repo view`, which answers what the repository allows and where the viewer stands. */
643 readonly getRepositoryAccess: (input: {
644 readonly cwd: string;
645 readonly repository: string;
646 readonly host: string;
647 }) => Effect.Effect<GitHubRepositoryAccess, GitHubPullRequestCliError>;
648
649 /** The viewer's standing on its own, for deciding a write without reading the whole detail. */655 /** The viewer's standing on its own, for deciding a write without reading the whole detail. */
650 readonly getViewerAccess: (input: {656 readonly getViewerAccess: (input: {
651 readonly cwd: string;657 readonly cwd: string;
...@@ -654,7 +660,7 @@ export class GitHubPullRequestCli extends Context.Service<...@@ -654,7 +660,7 @@ export class GitHubPullRequestCli extends Context.Service<
654 readonly number: number;660 readonly number: number;
655 /** Manual action checks may use the quota held back from automatic reads. */661 /** Manual action checks may use the quota held back from automatic reads. */
656 readonly allowReserve?: boolean | undefined;662 readonly allowReserve?: boolean | undefined;
657 }) => Effect.Effect<GitHubViewerAccess, GitHubPullRequestCliError>;663 }) => Effect.Effect<GitHubViewerAccess & GitHubRepositoryAccess, GitHubPullRequestCliError>;
658664
659 /** Who this pull request may be sent to, and who it has already been sent to. */665 /** Who this pull request may be sent to, and who it has already been sent to. */
660 readonly listReviewerCandidates: (input: {666 readonly listReviewerCandidates: (input: {
...@@ -1514,7 +1520,9 @@ export const make = Effect.gen(function* () {...@@ -1514,7 +1520,9 @@ export const make = Effect.gen(function* () {
1514 return { oldContents, newContents };1520 return { oldContents, newContents };
1515 });1521 });
15161522
1517 const getPullRequestDetail: GitHubPullRequestCli["Service"]["getPullRequestDetail"] = (input) =>1523 const readLegacyDetail = (
1524 input: Parameters<GitHubPullRequestCli["Service"]["getPullRequestDetail"]>[0],
1525 ) =>
1518 github1526 github
1519 .execute({1527 .execute({
1520 cwd: input.cwd,1528 cwd: input.cwd,
...@@ -1543,6 +1551,53 @@ export const make = Effect.gen(function* () {...@@ -1543,6 +1551,53 @@ export const make = Effect.gen(function* () {
1543 }),1551 }),
1544 );1552 );
15451553
1554 const getPullRequestDetail: GitHubPullRequestCli["Service"]["getPullRequestDetail"] = (input) => {
1555 const { owner, name } = parseRepositorySelector(input.repository);
1556 return GitHubCli.AllowGitHubReserve.pipe(
1557 Effect.flatMap((allowReserve) =>
1558 graphqlRead({
1559 allowReserve,
1560 cwd: input.cwd,
1561 host: input.host,
1562 operation: "getPullRequestDetail",
1563 variables: [
1564 ["-f", `owner=${owner}`],
1565 ["-f", `name=${name}`],
1566 ["-F", `number=${input.number}`],
1567 ["-f", `headRef=refs/pull/${input.number}/head`],
1568 ],
1569 query: PULL_REQUEST_CORE_GRAPHQL_QUERY,
1570 decode: decodePullRequestCoreJson,
1571 }),
1572 ),
1573 ).pipe(
1574 Effect.flatMap((core) => {
1575 if (!core.checksTruncated) return Effect.succeed(core);
1576 // gh already pages check contexts. Keep its complete, deduplicated result for
1577 // large check suites instead of letting the first 100 checks imply success.
1578 return readLegacyDetail(input).pipe(
1579 Effect.flatMap((detail) =>
1580 detail.headSha !== core.headSha
1581 ? Effect.fail(
1582 new GitHubPullRequestReadError({
1583 command: "gh",
1584 cwd: input.cwd,
1585 operation: "getPullRequestDetail",
1586 cause: new Error("Pull request head changed while reading checks."),
1587 }),
1588 )
1589 : Effect.succeed({
1590 ...core,
1591 checks: detail.checks,
1592 checksState: detail.checksState,
1593 checksTruncated: false,
1594 }),
1595 ),
1596 );
1597 }),
1598 );
1599 };
1600
1546 const workflowApprovalLimit = 1_000;1601 const workflowApprovalLimit = 1_000;
1547 const workflowApprovalProbeLimit = String(workflowApprovalLimit + 1);1602 const workflowApprovalProbeLimit = String(workflowApprovalLimit + 1);
1548 const workflowApprovalReadError = (cwd: string, cause: unknown) =>1603 const workflowApprovalReadError = (cwd: string, cause: unknown) =>
...@@ -1554,71 +1609,74 @@ export const make = Effect.gen(function* () {...@@ -1554,71 +1609,74 @@ export const make = Effect.gen(function* () {
1554 });1609 });
1555 const listWorkflowRunsRequiringApproval: GitHubPullRequestCli["Service"]["listWorkflowRunsRequiringApproval"] =1610 const listWorkflowRunsRequiringApproval: GitHubPullRequestCli["Service"]["listWorkflowRunsRequiringApproval"] =
1556 (input) =>1611 (input) =>
1557 github1612 Effect.all(
1558 .execute({1613 [
1559 cwd: input.cwd,1614 github
1560 args: [1615 .execute({
1561 "pr",1616 cwd: input.cwd,
1562 "list",1617 args: [
1563 ...repositoryArgs(input),1618 "pr",
1564 "--state",1619 "list",
1565 "open",1620 ...repositoryArgs(input),
1566 "--head",1621 "--state",
1567 input.headBranch,1622 "open",
1568 "--limit",1623 "--head",
1569 workflowApprovalProbeLimit,1624 input.headBranch,
1570 "--json",1625 "--limit",
1571 "number,headRefOid,isCrossRepository,headRepositoryOwner",1626 workflowApprovalProbeLimit,
1572 ],1627 "--json",
1573 })1628 "number,headRefOid,isCrossRepository,headRepositoryOwner",
1574 .pipe(1629 ],
1575 Effect.flatMap(1630 })
1576 (1631 .pipe(
1577 result,1632 Effect.flatMap(
1578 ): Effect.Effect<1633 (
1579 GitHubPullRequestHead,1634 result,
1580 GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError1635 ): Effect.Effect<
1581 > => {1636 GitHubPullRequestHead,
1582 const decoded = decodePullRequestHeadsJson(result.stdout.trim());1637 GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError
1583 if (!Result.isSuccess(decoded)) {1638 > => {
1584 return Effect.fail(workflowApprovalReadError(input.cwd, decoded.failure));1639 const decoded = decodePullRequestHeadsJson(result.stdout.trim());
1585 }1640 if (!Result.isSuccess(decoded)) {
1586 const exactHeads = decoded.success.filter(1641 return Effect.fail(workflowApprovalReadError(input.cwd, decoded.failure));
1587 (pullRequest) =>1642 }
1588 pullRequest.headSha === input.headSha &&1643 const exactHeads = decoded.success.filter(
1589 pullRequest.isCrossRepository === true &&1644 (pullRequest) =>
1590 pullRequest.headRepositoryOwner?.toLowerCase() ===1645 pullRequest.headSha === input.headSha &&
1591 input.headRepositoryOwner.toLowerCase(),1646 pullRequest.isCrossRepository === true &&
1592 );1647 pullRequest.headRepositoryOwner?.toLowerCase() ===
1593 if (decoded.success.length > workflowApprovalLimit) {1648 input.headRepositoryOwner.toLowerCase(),
1594 return Effect.fail(1649 );
1595 new GitHubWorkflowApprovalRefusedError({1650 if (decoded.success.length > workflowApprovalLimit) {
1596 command: "gh",1651 return Effect.fail(
1597 cwd: input.cwd,1652 new GitHubWorkflowApprovalRefusedError({
1598 number: input.number,1653 command: "gh",
1599 reason: "head-list-truncated",1654 cwd: input.cwd,
1600 observedCount: decoded.success.length,1655 number: input.number,
1601 limit: workflowApprovalLimit,1656 reason: "head-list-truncated",
1602 }),1657 observedCount: decoded.success.length,
1603 );1658 limit: workflowApprovalLimit,
1604 }1659 }),
1605 if (exactHeads.length !== 1 || exactHeads[0]?.number !== input.number) {1660 );
1606 return Effect.fail(1661 }
1607 new GitHubWorkflowApprovalRefusedError({1662 if (exactHeads.length !== 1 || exactHeads[0]?.number !== input.number) {
1608 command: "gh",1663 return Effect.fail(
1609 cwd: input.cwd,1664 new GitHubWorkflowApprovalRefusedError({
1610 number: input.number,1665 command: "gh",
1611 reason: "head-not-unique",1666 cwd: input.cwd,
1612 observedCount: exactHeads.length,1667 number: input.number,
1613 limit: workflowApprovalLimit,1668 reason: "head-not-unique",
1614 }),1669 observedCount: exactHeads.length,
1615 );1670 limit: workflowApprovalLimit,
1616 }1671 }),
1617 return Effect.succeed(exactHeads[0]);1672 );
1618 },1673 }
1619 ),1674 return Effect.succeed(exactHeads[0]);
1620 Effect.flatMap(() =>1675 },
1621 github.execute({1676 ),
1677 ),
1678 github
1679 .execute({
1622 cwd: input.cwd,1680 cwd: input.cwd,
1623 args: [1681 args: [
1624 "run",1682 "run",
...@@ -1637,34 +1695,37 @@ export const make = Effect.gen(function* () {...@@ -1637,34 +1695,37 @@ export const make = Effect.gen(function* () {
1637 "--json",1695 "--json",
1638 "databaseId,workflowName,url",1696 "databaseId,workflowName,url",
1639 ],1697 ],
1640 }),1698 })
1641 ),1699 .pipe(
1642 Effect.flatMap(1700 Effect.flatMap(
1643 (1701 (
1644 result,1702 result,
1645 ): Effect.Effect<1703 ): Effect.Effect<
1646 ReadonlyArray<GitHubWorkflowRunApproval>,1704 ReadonlyArray<GitHubWorkflowRunApproval>,
1647 GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError1705 GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError
1648 > => {1706 > => {
1649 const decoded = decodeWorkflowRunApprovalsJson(result.stdout.trim());1707 const decoded = decodeWorkflowRunApprovalsJson(result.stdout.trim());
1650 if (!Result.isSuccess(decoded)) {1708 if (!Result.isSuccess(decoded)) {
1651 return Effect.fail(workflowApprovalReadError(input.cwd, decoded.failure));1709 return Effect.fail(workflowApprovalReadError(input.cwd, decoded.failure));
1652 }1710 }
1653 return decoded.success.length > workflowApprovalLimit1711 return decoded.success.length > workflowApprovalLimit
1654 ? Effect.fail(1712 ? Effect.fail(
1655 new GitHubWorkflowApprovalRefusedError({1713 new GitHubWorkflowApprovalRefusedError({
1656 command: "gh",1714 command: "gh",
1657 cwd: input.cwd,1715 cwd: input.cwd,
1658 number: input.number,1716 number: input.number,
1659 reason: "run-list-truncated",1717 reason: "run-list-truncated",
1660 observedCount: decoded.success.length,1718 observedCount: decoded.success.length,
1661 limit: workflowApprovalLimit,1719 limit: workflowApprovalLimit,
1662 }),1720 }),
1663 )1721 )
1664 : Effect.succeed(decoded.success);1722 : Effect.succeed(decoded.success);
1665 },1723 },
1666 ),1724 ),
1667 );1725 ),
1726 ],
1727 { concurrency: 2 },
1728 ).pipe(Effect.map(([, runs]) => runs));
16681729
1669 return GitHubPullRequestCli.of({1730 return GitHubPullRequestCli.of({
1670 withVerifiedCredential,1731 withVerifiedCredential,
...@@ -1924,6 +1985,21 @@ export const make = Effect.gen(function* () {...@@ -1924,6 +1985,21 @@ export const make = Effect.gen(function* () {
1924 ),1985 ),
19251986
1926 getPullRequestDetail,1987 getPullRequestDetail,
1988 getPullRequestPreview: (input) => {
1989 const { owner, name } = parseRepositorySelector(input.repository);
1990 return graphqlRead({
1991 cwd: input.cwd,
1992 host: input.host,
1993 operation: "getPullRequestPreview",
1994 variables: [
1995 ["-f", `owner=${owner}`],
1996 ["-f", `name=${name}`],
1997 ["-F", `number=${input.number}`],
1998 ],
1999 query: PULL_REQUEST_PREVIEW_GRAPHQL_QUERY,
2000 decode: decodePullRequestPreviewJson,
2001 });
2002 },
1927 listWorkflowRunsRequiringApproval,2003 listWorkflowRunsRequiringApproval,
19282004
1929 getPullRequestStack: (input) => {2005 getPullRequestStack: (input) => {
...@@ -2246,34 +2322,6 @@ export const make = Effect.gen(function* () {...@@ -2246,34 +2322,6 @@ export const make = Effect.gen(function* () {
2246 });2322 });
2247 },2323 },
22482324
2249 getRepositoryAccess: (input) =>
2250 github
2251 .execute({
2252 cwd: input.cwd,
2253 args: [
2254 "repo",
2255 "view",
2256 `${input.host}/${input.repository}`,
2257 "--json",
2258 REPOSITORY_ACCESS_JSON_FIELDS,
2259 ],
2260 })
2261 .pipe(
2262 Effect.flatMap((result) => {
2263 const decoded = decodeRepositoryAccessJson(result.stdout.trim());
2264 return Result.isSuccess(decoded)
2265 ? Effect.succeed(decoded.success)
2266 : Effect.fail(
2267 new GitHubPullRequestReadError({
2268 command: "gh",
2269 cwd: input.cwd,
2270 operation: "getRepositoryAccess",
2271 cause: decoded.failure,
2272 }),
2273 );
2274 }),
2275 ),
2276
2277 getViewerAccess: (input) => {2325 getViewerAccess: (input) => {
2278 const { owner, name } = parseRepositorySelector(input.repository);2326 const { owner, name } = parseRepositorySelector(input.repository);
2279 return graphqlRead({2327 return graphqlRead({
apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts+112-37
...@@ -9,6 +9,18 @@ import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";...@@ -9,6 +9,18 @@ import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";
9import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts";9import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts";
10import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts";10import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts";
1111
12const coreFields = {
13 checksTruncated: false,
14 comparison: { behindBy: 0, viewerCanUpdate: true },
15 viewerAccess: {
16 canWrite: true,
17 canTriage: true,
18 canUpdate: true,
19 didAuthor: false,
20 mergeCapabilities: { merge: true, squash: true, rebase: true },
21 },
22};
23
12it.effect("maps credential verification failures without relabeling operation failures", () =>24it.effect("maps credential verification failures without relabeling operation failures", () =>
13 Effect.gen(function* () {25 Effect.gen(function* () {
14 let verificationFails = true;26 let verificationFails = true;
...@@ -229,7 +241,7 @@ describe("gitHubViewerPermissions", () => {...@@ -229,7 +241,7 @@ describe("gitHubViewerPermissions", () => {
229 });241 });
230 });242 });
231243
232 it.effect("uses the small viewer-access read for core permissions", () =>244 it.effect("uses the permissions carried by the core read without a second request", () =>
233 Effect.gen(function* () {245 Effect.gen(function* () {
234 const provider = yield* make;246 const provider = yield* make;
235 const detail = yield* provider.getChangeRequest({247 const detail = yield* provider.getChangeRequest({
...@@ -276,6 +288,9 @@ describe("gitHubViewerPermissions", () => {...@@ -276,6 +288,9 @@ describe("gitHubViewerPermissions", () => {
276 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({288 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
277 getPullRequestDetail: () =>289 getPullRequestDetail: () =>
278 Effect.succeed({290 Effect.succeed({
291 ...coreFields,
292 comparison: null,
293 viewerAccess: { ...coreFields.viewerAccess, canWrite: false, canTriage: false },
279 authorId: null,294 authorId: null,
280 number: 7,295 number: 7,
281 title: "Pull request 7",296 title: "Pull request 7",
...@@ -285,9 +300,9 @@ describe("gitHubViewerPermissions", () => {...@@ -285,9 +300,9 @@ describe("gitHubViewerPermissions", () => {
285 headRepositoryOwner: null,300 headRepositoryOwner: null,
286 headBranch: "feat/page",301 headBranch: "feat/page",
287 baseBranch: "main",302 baseBranch: "main",
288 state: "open",303 state: "open" as const,
289 isDraft: false,304 isDraft: false,
290 mergeability: "mergeable",305 mergeability: "mergeable" as const,
291 reviewDecision: null,306 reviewDecision: null,
292 additions: 1,307 additions: 1,
293 deletions: 1,308 deletions: 1,
...@@ -304,25 +319,22 @@ describe("gitHubViewerPermissions", () => {...@@ -304,25 +319,22 @@ describe("gitHubViewerPermissions", () => {
304 checks: [],319 checks: [],
305 comments: [],320 comments: [],
306 commits: [],321 commits: [],
307 }),322 }).pipe(
308 getRepositoryAccess: () =>323 Effect.flatMap((detail) =>
309 GitHubCli.PinnedGitHubCredential.pipe(324 GitHubCli.PinnedGitHubCredential.pipe(
310 Effect.map((credential) => ({325 Effect.map((credential) => ({
311 canWrite: false,326 ...detail,
312 mergeCapabilities: {327 viewerAccess: {
313 merge: true,328 ...detail.viewerAccess,
314 squash: credential?.credentialFingerprint !== "restricted",329 mergeCapabilities: {
315 rebase: true,330 ...detail.viewerAccess.mergeCapabilities,
316 },331 squash: credential?.credentialFingerprint !== "restricted",
317 })),332 },
333 },
334 })),
335 ),
336 ),
318 ),337 ),
319 getViewerAccess: () =>
320 Effect.succeed({
321 canWrite: false,
322 canTriage: false,
323 canUpdate: true,
324 didAuthor: false,
325 }),
326 }),338 }),
327 ),339 ),
328 ),340 ),
...@@ -364,6 +376,7 @@ describe("gitHubViewerPermissions", () => {...@@ -364,6 +376,7 @@ describe("gitHubViewerPermissions", () => {
364 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({376 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
365 getPullRequestDetail: () =>377 getPullRequestDetail: () =>
366 Effect.succeed({378 Effect.succeed({
379 ...coreFields,
367 authorId: null,380 authorId: null,
368 number: 7,381 number: 7,
369 title: "Pull request 7",382 title: "Pull request 7",
...@@ -412,13 +425,14 @@ describe("gitHubViewerPermissions", () => {...@@ -412,13 +425,14 @@ describe("gitHubViewerPermissions", () => {
412 ]),425 ]),
413 getPullRequestBaseComparison: () =>426 getPullRequestBaseComparison: () =>
414 Effect.succeed({ behindBy: 0, viewerCanUpdate: true }),427 Effect.succeed({ behindBy: 0, viewerCanUpdate: true }),
415 getRepositoryAccess: () =>428 getViewerAccess: () =>
416 Effect.succeed({429 Effect.succeed({
417 canWrite: true,430 canWrite: true,
431 canTriage: true,
432 canUpdate: true,
433 didAuthor: false,
418 mergeCapabilities: { merge: true, squash: true, rebase: true },434 mergeCapabilities: { merge: true, squash: true, rebase: true },
419 }),435 }),
420 getViewerAccess: () =>
421 Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }),
422 }),436 }),
423 ),437 ),
424 ),438 ),
...@@ -426,6 +440,7 @@ describe("gitHubViewerPermissions", () => {...@@ -426,6 +440,7 @@ describe("gitHubViewerPermissions", () => {
426});440});
427441
428const openDetail = {442const openDetail = {
443 ...coreFields,
429 authorId: null,444 authorId: null,
430 number: 7,445 number: 7,
431 title: "Pull request 7",446 title: "Pull request 7",
...@@ -457,6 +472,39 @@ const openDetail = {...@@ -457,6 +472,39 @@ const openDetail = {
457 commits: [],472 commits: [],
458};473};
459474
475it.effect(
476 "uses the core comparison and permissions while preserving workflow approval checks",
477 () =>
478 Effect.gen(function* () {
479 const provider = yield* make.pipe(
480 Effect.provide(
481 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
482 getPullRequestDetail: () =>
483 Effect.succeed({ ...openDetail, comparison: { behindBy: 2, viewerCanUpdate: true } }),
484 listWorkflowRunsRequiringApproval: () =>
485 Effect.succeed([{ id: 123, name: "tests", url: "https://example.com/runs/123" }]),
486 }),
487 ),
488 );
489
490 const detail = yield* provider.getChangeRequest({
491 cwd: "/w",
492 repository: "acme/web",
493 host: "github.com",
494 number: 7,
495 });
496
497 expect(detail.behindBy).toBe(2);
498 expect(detail.workflowApprovalsRequired).toBe(1);
499 expect(detail.checks).toContainEqual({
500 name: "tests",
501 status: "action-required",
502 description: "A maintainer must approve this workflow before it can run.",
503 url: "https://example.com/runs/123",
504 });
505 }),
506);
507
460it.effect("does not classify same-repository gates as fork workflow approvals", () =>508it.effect("does not classify same-repository gates as fork workflow approvals", () =>
461 Effect.gen(function* () {509 Effect.gen(function* () {
462 const provider = yield* make;510 const provider = yield* make;
...@@ -476,13 +524,14 @@ it.effect("does not classify same-repository gates as fork workflow approvals",...@@ -476,13 +524,14 @@ it.effect("does not classify same-repository gates as fork workflow approvals",
476 getPullRequestBaseComparison: () => Effect.succeed({ behindBy: 0, viewerCanUpdate: true }),524 getPullRequestBaseComparison: () => Effect.succeed({ behindBy: 0, viewerCanUpdate: true }),
477 listWorkflowRunsRequiringApproval: () =>525 listWorkflowRunsRequiringApproval: () =>
478 Effect.die("same-repository pull requests must not probe fork workflow approvals"),526 Effect.die("same-repository pull requests must not probe fork workflow approvals"),
479 getRepositoryAccess: () =>527 getViewerAccess: () =>
480 Effect.succeed({528 Effect.succeed({
481 canWrite: true,529 canWrite: true,
530 canTriage: true,
531 canUpdate: true,
532 didAuthor: false,
482 mergeCapabilities: { merge: true, squash: true, rebase: true },533 mergeCapabilities: { merge: true, squash: true, rebase: true },
483 }),534 }),
484 getViewerAccess: () =>
485 Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }),
486 }),535 }),
487 ),536 ),
488 ),537 ),
...@@ -523,13 +572,14 @@ it.effect("keeps an unsafe workflow approval scope visible as unknown", () =>...@@ -523,13 +572,14 @@ it.effect("keeps an unsafe workflow approval scope visible as unknown", () =>
523 limit: 1_000,572 limit: 1_000,
524 }),573 }),
525 ),574 ),
526 getRepositoryAccess: () =>575 getViewerAccess: () =>
527 Effect.succeed({576 Effect.succeed({
528 canWrite: true,577 canWrite: true,
578 canTriage: true,
579 canUpdate: true,
580 didAuthor: false,
529 mergeCapabilities: { merge: true, squash: true, rebase: true },581 mergeCapabilities: { merge: true, squash: true, rebase: true },
530 }),582 }),
531 getViewerAccess: () =>
532 Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }),
533 }),583 }),
534 ),584 ),
535 ),585 ),
...@@ -562,13 +612,14 @@ it.effect("propagates workflow discovery rate limits", () =>...@@ -562,13 +612,14 @@ it.effect("propagates workflow discovery rate limits", () =>
562 cause: new Error("rate limited"),612 cause: new Error("rate limited"),
563 }),613 }),
564 ),614 ),
565 getRepositoryAccess: () =>615 getViewerAccess: () =>
566 Effect.succeed({616 Effect.succeed({
567 canWrite: true,617 canWrite: true,
618 canTriage: true,
619 canUpdate: true,
620 didAuthor: false,
568 mergeCapabilities: { merge: true, squash: true, rebase: true },621 mergeCapabilities: { merge: true, squash: true, rebase: true },
569 }),622 }),
570 getViewerAccess: () =>
571 Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }),
572 }),623 }),
573 ),624 ),
574 ),625 ),
...@@ -598,7 +649,13 @@ describe("getViewerPermissions", () => {...@@ -598,7 +649,13 @@ describe("getViewerPermissions", () => {
598 getViewerAccess: () =>649 getViewerAccess: () =>
599 Effect.sync(() => {650 Effect.sync(() => {
600 accessReads++;651 accessReads++;
601 return { canWrite: true, canTriage: true, canUpdate: true, didAuthor: false };652 return {
653 canWrite: true,
654 canTriage: true,
655 canUpdate: true,
656 didAuthor: false,
657 mergeCapabilities: { merge: true, squash: true, rebase: true },
658 };
602 }),659 }),
603 }),660 }),
604 ),661 ),
...@@ -615,7 +672,13 @@ describe("getViewerPermissions", () => {...@@ -615,7 +672,13 @@ describe("getViewerPermissions", () => {
615 getPullRequestDetail: () => Effect.succeed(openDetail),672 getPullRequestDetail: () => Effect.succeed(openDetail),
616 getPullRequestBaseComparison: () => comparison,673 getPullRequestBaseComparison: () => comparison,
617 getViewerAccess: () =>674 getViewerAccess: () =>
618 Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }),675 Effect.succeed({
676 canWrite: true,
677 canTriage: true,
678 canUpdate: true,
679 didAuthor: false,
680 mergeCapabilities: { merge: true, squash: true, rebase: true },
681 }),
619 });682 });
620683
621 it.effect("offers update-branch when the comparison grants it", () =>684 it.effect("offers update-branch when the comparison grants it", () =>
...@@ -661,7 +724,13 @@ describe("getViewerPermissions", () => {...@@ -661,7 +724,13 @@ describe("getViewerPermissions", () => {
661 getViewerAccess: (input) =>724 getViewerAccess: (input) =>
662 Effect.sync(() => {725 Effect.sync(() => {
663 viewerAllowReserve = input.allowReserve;726 viewerAllowReserve = input.allowReserve;
664 return { canWrite: true, canTriage: true, canUpdate: true, didAuthor: false };727 return {
728 canWrite: true,
729 canTriage: true,
730 canUpdate: true,
731 didAuthor: false,
732 mergeCapabilities: { merge: true, squash: true, rebase: true },
733 };
665 }),734 }),
666 }),735 }),
667 ),736 ),
...@@ -696,7 +765,13 @@ describe("getViewerPermissions", () => {...@@ -696,7 +765,13 @@ describe("getViewerPermissions", () => {
696 }),765 }),
697 ),766 ),
698 getViewerAccess: () =>767 getViewerAccess: () =>
699 Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }),768 Effect.succeed({
769 canWrite: true,
770 canTriage: true,
771 canUpdate: true,
772 didAuthor: false,
773 mergeCapabilities: { merge: true, squash: true, rebase: true },
774 }),
700 }),775 }),
701 ),776 ),
702 ),777 ),
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+72-127
...@@ -1,7 +1,4 @@...@@ -1,7 +1,4 @@
1import * as Cache from "effect/Cache";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";1import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";
5import type {2import type {
6 PullRequestActor,3 PullRequestActor,
7 PullRequestCapabilities,4 PullRequestCapabilities,
...@@ -11,7 +8,6 @@ import type {...@@ -11,7 +8,6 @@ import type {
11} from "@t3tools/contracts";8} from "@t3tools/contracts";
129
13import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";10import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";
14import { PinnedGitHubCredential } from "../sourceControl/GitHubCli.ts";
15import {11import {
16 PullRequestProviderError,12 PullRequestProviderError,
17 type PullRequestProviderFailure,13 type PullRequestProviderFailure,
...@@ -193,35 +189,6 @@ const rendersEmpty = (body: string): boolean =>...@@ -193,35 +189,6 @@ const rendersEmpty = (body: string): boolean =>
193export const make = Effect.gen(function* () {189export const make = Effect.gen(function* () {
194 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;190 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
195191
196 const repositoryAccessCache = yield* Cache.makeWith(
197 (key: string) => {
198 const [cwd, repository, host] = JSON.parse(key) as [string, string, string];
199 return cli.getRepositoryAccess({ cwd, repository, host });
200 },
201 {
202 capacity: 128,
203 timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.minutes(10) : Duration.zero),
204 },
205 );
206 const getRepositoryAccess = (input: {
207 readonly cwd: string;
208 readonly repository: string;
209 readonly host: string;
210 }) =>
211 PinnedGitHubCredential.pipe(
212 Effect.flatMap((credential) =>
213 Cache.get(
214 repositoryAccessCache,
215 JSON.stringify([
216 input.cwd,
217 input.repository,
218 input.host,
219 credential?.credentialFingerprint ?? null,
220 ]),
221 ),
222 ),
223 );
224
225 const fail = (operation: string) => (error: GitHubPullRequestCli.GitHubPullRequestCliError) =>192 const fail = (operation: string) => (error: GitHubPullRequestCli.GitHubPullRequestCliError) =>
226 new PullRequestProviderError({193 new PullRequestProviderError({
227 provider: "github",194 provider: "github",
...@@ -340,102 +307,80 @@ export const make = Effect.gen(function* () {...@@ -340,102 +307,80 @@ export const make = Effect.gen(function* () {
340 getChangeRequestStack: (input) =>307 getChangeRequestStack: (input) =>
341 cli.getPullRequestStack(input).pipe(Effect.mapError(fail("getChangeRequestStack"))),308 cli.getPullRequestStack(input).pipe(Effect.mapError(fail("getChangeRequestStack"))),
342309
310 getChangeRequestPreview: (input) =>
311 cli.getPullRequestPreview(input).pipe(Effect.mapError(fail("getChangeRequestPreview"))),
312
343 getChangeRequest: (input) =>313 getChangeRequest: (input) =>
344 Effect.all(314 cli.getPullRequestDetail(input).pipe(
345 [315 Effect.flatMap((pullRequest) => {
346 cli.getPullRequestDetail(input).pipe(316 // Fork workflows awaiting approval are absent from the normal check rollup.
347 Effect.flatMap((pullRequest) =>317 const approvals =
348 Effect.all({318 pullRequest.state !== "open" || pullRequest.isCrossRepository !== true
349 // Only an open pull request can be behind anything worth saying so about, and319 ? Effect.succeed({
350 // only one whose head repository is known can be compared at all.320 runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>,
351 comparison:321 unavailable: false,
352 pullRequest.state !== "open" || pullRequest.headRepositoryOwner === null322 })
353 ? Effect.succeed(null)323 : pullRequest.headSha == null || pullRequest.headRepositoryOwner == null
354 : cli324 ? Effect.succeed({
355 .getPullRequestBaseComparison({325 runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>,
356 ...input,326 unavailable: true,
357 headRef: `${pullRequest.headRepositoryOwner}:${pullRequest.headBranch}`,327 })
358 })328 : cli
359 .pipe(Effect.orElseSucceed(() => null)),329 .listWorkflowRunsRequiringApproval({
360 // GitHub omits a fork workflow that has not been approved from the normal check330 ...input,
361 // rollup. Read the action-required runs by head revision so "all passed" cannot331 headSha: pullRequest.headSha,
362 // be shown while a whole workflow is still waiting to start.332 headBranch: pullRequest.headBranch,
363 workflowApprovals:333 headRepositoryOwner: pullRequest.headRepositoryOwner,
364 pullRequest.state !== "open" || pullRequest.isCrossRepository !== true334 isCrossRepository: true,
365 ? Effect.succeed({335 })
366 runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>,336 .pipe(
367 unavailable: false,337 Effect.matchEffect({
368 })338 onFailure: (error) =>
369 : pullRequest.headSha == null || pullRequest.headRepositoryOwner == null339 error._tag === "GitHubCliRateLimitError" ||
370 ? Effect.succeed({340 error._tag === "SourceControlRateLimitPausedError"
371 runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>,341 ? Effect.fail(error)
372 unavailable: true,342 : Effect.succeed({
373 })343 runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>,
374 : cli344 unavailable: true,
375 .listWorkflowRunsRequiringApproval({345 }),
376 ...input,346 onSuccess: (runs) => Effect.succeed({ runs, unavailable: false }),
377 headSha: pullRequest.headSha,347 }),
378 headBranch: pullRequest.headBranch,348 );
379 headRepositoryOwner: pullRequest.headRepositoryOwner,349 return approvals.pipe(
380 isCrossRepository: true,350 Effect.map((workflowApprovals): ProviderChangeRequestDetail => ({
381 })351 ...pullRequest,
382 .pipe(352 author: withAvatar(pullRequest.author, new Map<string, string>(), input.host),
383 Effect.matchEffect({353 checks: withWorkflowApprovals(
384 onFailure: (error) =>354 pullRequest.checks,
385 error._tag === "GitHubCliRateLimitError" ||355 workflowApprovals.runs,
386 error._tag === "SourceControlRateLimitPausedError"356 workflowApprovals.unavailable,
387 ? Effect.fail(error)357 ),
388 : Effect.succeed({358 ...(workflowApprovals.unavailable
389 runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>,359 ? {}
390 unavailable: true,360 : { workflowApprovalsRequired: workflowApprovals.runs.length }),
391 }),361 reviewers: pullRequest.reviewRequestLogins.map((login) => ({
392 onSuccess: (runs) => Effect.succeed({ runs, unavailable: false }),362 login,
393 }),363 name: null,
394 ),364 avatarUrl: null,
395 }).pipe(Effect.map((extra) => ({ pullRequest, ...extra }))),365 })),
396 ),366 mergeCapabilities: pullRequest.viewerAccess.mergeCapabilities,
397 ),367 viewerPermissions: gitHubViewerPermissions({
398 getRepositoryAccess({368 ...pullRequest.viewerAccess,
399 cwd: input.cwd,369 canUpdateBranch: pullRequest.comparison?.viewerCanUpdate === true,
400 repository: input.repository,370 }),
401 host: input.host,371 baseComparison:
402 }),372 pullRequest.comparison === null || pullRequest.comparison.behindBy === null
403 // A small permissions query replaces the deeply paginated review-thread walk on the373 ? "unknown"
404 // core path. Writes ask again immediately before mutating, so this is presentation.374 : pullRequest.comparison.behindBy > 0
405 cli.getViewerAccess(input),375 ? "behind"
406 ],376 : "up-to-date",
407 { concurrency: 3 },377 ...(pullRequest.comparison?.behindBy == null
408 ).pipe(378 ? {}
379 : { behindBy: pullRequest.comparison.behindBy }),
380 })),
381 );
382 }),
409 Effect.mapError(fail("getChangeRequest")),383 Effect.mapError(fail("getChangeRequest")),
410 Effect.map(([detail, repository, viewerAccess]): ProviderChangeRequestDetail => ({
411 ...detail.pullRequest,
412 author: withAvatar(detail.pullRequest.author, new Map<string, string>(), input.host),
413 checks: withWorkflowApprovals(
414 detail.pullRequest.checks,
415 detail.workflowApprovals.runs,
416 detail.workflowApprovals.unavailable,
417 ),
418 ...(detail.workflowApprovals.unavailable
419 ? {}
420 : { workflowApprovalsRequired: detail.workflowApprovals.runs.length }),
421 reviewers: detail.pullRequest.reviewRequestLogins.map((login) => ({
422 login,
423 name: null,
424 avatarUrl: null,
425 })),
426 mergeCapabilities: repository.mergeCapabilities,
427 viewerPermissions: gitHubViewerPermissions({
428 ...viewerAccess,
429 canUpdateBranch: detail.comparison?.viewerCanUpdate === true,
430 }),
431 baseComparison:
432 detail.comparison === null || detail.comparison.behindBy === null
433 ? "unknown"
434 : detail.comparison.behindBy > 0
435 ? "behind"
436 : "up-to-date",
437 ...(detail.comparison?.behindBy == null ? {} : { behindBy: detail.comparison.behindBy }),
438 })),
439 ),384 ),
440385
441 getChangeRequestActivity: (input) =>386 getChangeRequestActivity: (input) =>
apps/server/src/pullRequest/PullRequestProvider.ts+9
...@@ -31,6 +31,7 @@ import type {...@@ -31,6 +31,7 @@ import type {
31 PullRequestReviewerKind,31 PullRequestReviewerKind,
32 PullRequestLabelCandidateList,32 PullRequestLabelCandidateList,
33 PullRequestState,33 PullRequestState,
34 PullRequestPreview,
34 PullRequestUpdateMethod,35 PullRequestUpdateMethod,
35 PullRequestViewerPermissions,36 PullRequestViewerPermissions,
36 SourceControlProviderKind,37 SourceControlProviderKind,
...@@ -400,6 +401,14 @@ export interface PullRequestProviderApi {...@@ -400,6 +401,14 @@ export interface PullRequestProviderApi {
400 input: ProviderRepositoryRef & { readonly number: number },401 input: ProviderRepositoryRef & { readonly number: number },
401 ) => Effect.Effect<ProviderChangeRequestDetail, PullRequestProviderError>;402 ) => Effect.Effect<ProviderChangeRequestDetail, PullRequestProviderError>;
402403
404 /** Hosts without a narrow read use their existing detail response for hover cards. */
405 readonly getChangeRequestPreview?: (
406 input: ProviderRepositoryRef & { readonly number: number },
407 ) => Effect.Effect<
408 Omit<PullRequestPreview, "projectId" | "repository">,
409 PullRequestProviderError
410 >;
411
403 /**412 /**
404 * The cheap live fields used by linked threads. Optional because a provider without a narrow413 * The cheap live fields used by linked threads. Optional because a provider without a narrow
405 * endpoint can fall back to its full detail read at the service boundary.414 * endpoint can fall back to its full detail read at the service boundary.
apps/server/src/pullRequest/PullRequestService.test.ts+218-1
...@@ -116,6 +116,215 @@ function hostedChangeRequest(body: string, additions = 1) {...@@ -116,6 +116,215 @@ function hostedChangeRequest(body: string, additions = 1) {
116 };116 };
117}117}
118118
119it.effect("caches narrow previews and invalidates them after refresh or mutation", () =>
120 Effect.gen(function* () {
121 let reads = 0;
122 const service = yield* makeService({
123 projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })],
124 providers: [
125 fakeProvider("github", {
126 getViewer: () => Effect.die("preview must not read the viewer"),
127 getChangeRequestPreview: () =>
128 Effect.sync(() => {
129 reads += 1;
130 return changeRequest(1, "2026-07-02T00:00:00Z");
131 }),
132 }),
133 ],
134 });
135 const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
136 const previews = yield* Effect.all([service.preview(ref), service.preview(ref)], {
137 concurrency: 2,
138 });
139 assert.deepStrictEqual(previews[0], {
140 ...ref,
141 title: "Change request 1",
142 url: "https://host/pull/1",
143 author: { login: "octocat", name: null, avatarUrl: null },
144 state: "open",
145 isDraft: false,
146 createdAt: "2026-07-01T00:00:00Z",
147 });
148 assert.strictEqual(reads, 1);
149 yield* service.invalidate({ reference: ref });
150 yield* service.preview(ref);
151 assert.strictEqual(reads, 2);
152 yield* service.runAction({ ...ref, action: "close" });
153 yield* service.preview(ref);
154 assert.strictEqual(reads, 3);
155 yield* service.refreshAfterTurn(ref.projectId);
156 yield* service.preview(ref);
157 assert.strictEqual(reads, 4);
158 const error = yield* Effect.flip(service.preview({ ...ref, repository: "another/repo" }));
159 assert.strictEqual(error._tag, "PullRequestOperationError");
160 assert.strictEqual(reads, 4);
161 }),
162);
163
164it.effect("keeps cached previews available and pauses uncached previews until quota resets", () =>
165 Effect.gen(function* () {
166 let reads = 0;
167 const retryAt = Date.parse("2099-08-13T14:00:00Z");
168 const service = yield* makeService({
169 projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })],
170 providers: [
171 fakeProvider("github", {
172 getChangeRequestPreview: ({ number }) =>
173 Effect.gen(function* () {
174 reads++;
175 if (reads === 2)
176 return yield* new PullRequestProviderError({
177 provider: "github",
178 operation: "getChangeRequestPreview",
179 reason: "rate-limited",
180 detail: "GitHub requests are paused until the rate limit resets.",
181 retryAt,
182 });
183 return changeRequest(number, "2026-07-02T00:00:00Z");
184 }),
185 }),
186 ],
187 });
188 const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
189 yield* service.preview(ref);
190 const limited = yield* Effect.flip(service.preview({ ...ref, number: 2 }));
191 assert.instanceOf(limited, PullRequestOperationError);
192 if (limited._tag !== "PullRequestOperationError") return;
193 assert.include(limited.detail, "paused");
194 assert.strictEqual((yield* service.preview(ref)).number, 1);
195 for (const number of [2, 3, 4]) {
196 const paused = yield* Effect.flip(service.preview({ ...ref, number }));
197 assert.instanceOf(paused, PullRequestOperationError);
198 if (paused._tag !== "PullRequestOperationError") return;
199 assert.include(paused.detail, "paused");
200 }
201 assert.strictEqual(reads, 2);
202 yield* TestClock.setTime(retryAt);
203 assert.strictEqual((yield* service.preview({ ...ref, number: 2 })).number, 2);
204 assert.strictEqual(reads, 3);
205 }),
206);
207
208it.effect("reuses only unexpired detail for previews", () =>
209 Effect.gen(function* () {
210 let previewReads = 0;
211 const service = yield* makeService({
212 projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })],
213 providers: [
214 fakeProvider("github", {
215 getChangeRequest: () => Effect.succeed(hostedChangeRequest("Description")),
216 getChangeRequestPreview: () =>
217 Effect.sync(() => {
218 previewReads++;
219 return {
220 ...changeRequest(1, "2026-07-02T00:00:00Z"),
221 title: "Updated title",
222 state: "closed" as const,
223 };
224 }),
225 }),
226 ],
227 });
228 const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
229 yield* service.detail(ref);
230 assert.strictEqual((yield* service.preview(ref)).title, "Change request 1");
231 assert.strictEqual(previewReads, 0);
232 yield* TestClock.adjust("16 seconds");
233 const preview = yield* service.preview(ref);
234 assert.strictEqual(preview.title, "Updated title");
235 assert.strictEqual(preview.state, "closed");
236 assert.strictEqual(previewReads, 1);
237 }),
238);
239
240it.effect("does not wait for an in-flight detail read to display a preview", () =>
241 Effect.gen(function* () {
242 const detailStarted = yield* Deferred.make<void>();
243 const releaseDetail = yield* Deferred.make<void>();
244 const service = yield* makeService({
245 projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })],
246 providers: [
247 fakeProvider("github", {
248 getChangeRequest: () =>
249 Deferred.succeed(detailStarted, undefined).pipe(
250 Effect.andThen(Deferred.await(releaseDetail)),
251 Effect.as(hostedChangeRequest("Description")),
252 ),
253 getChangeRequestPreview: () => Effect.succeed(changeRequest(1, "2026-07-02T00:00:00Z")),
254 }),
255 ],
256 });
257 const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
258 const detail = yield* Effect.forkChild(service.detail(ref));
259 yield* Deferred.await(detailStarted);
260 assert.strictEqual((yield* service.preview(ref)).title, "Change request 1");
261 yield* Deferred.succeed(releaseDetail, undefined);
262 yield* Fiber.join(detail);
263 }),
264);
265
266it.effect("keeps previews warm when another project finishes a turn", () =>
267 Effect.gen(function* () {
268 const reads: string[] = [];
269 const service = yield* makeService({
270 projects: [
271 project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" }),
272 project({ id: "p2", title: "docs", workspaceRoot: "/d", repository: "acme/docs" }),
273 ],
274 providers: [
275 fakeProvider("github", {
276 getChangeRequestPreview: (input) =>
277 Effect.sync(() => {
278 reads.push(input.repository);
279 return changeRequest(1, "2026-07-02T00:00:00Z");
280 }),
281 }),
282 ],
283 });
284 const web = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
285 const docs = { projectId: "p2" as ProjectId, repository: "acme/docs", number: 1 };
286 yield* service.preview(web);
287 yield* service.preview(docs);
288 yield* service.preview({ ...web, host: "github.com" });
289 assert.deepStrictEqual(reads, ["acme/web", "acme/docs"]);
290 yield* service.refreshAfterTurn(web.projectId);
291 yield* service.preview(web);
292 yield* service.preview(docs);
293 assert.deepStrictEqual(reads, ["acme/web", "acme/docs", "acme/web"]);
294 }),
295);
296
297it.effect("uses full detail for hosts without a narrow preview", () =>
298 Effect.gen(function* () {
299 let reads = 0;
300 const service = yield* makeService({
301 projects: [
302 project({
303 id: "p1",
304 title: "web",
305 workspaceRoot: "/w",
306 repository: "acme/web",
307 provider: "gitlab",
308 }),
309 ],
310 providers: [
311 fakeProvider("gitlab", {
312 getChangeRequest: () =>
313 Effect.sync(() => {
314 reads += 1;
315 return hostedChangeRequest("Description");
316 }),
317 }),
318 ],
319 });
320 const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
321 const preview = yield* service.preview(ref);
322 assert.strictEqual(preview.title, "Change request 1");
323 assert.strictEqual(reads, 1);
324 assert.ok(!("body" in preview));
325 }),
326);
327
119function unusable(provider: SourceControlProviderKind, reason: "missing-tool" | "unauthenticated") {328function unusable(provider: SourceControlProviderKind, reason: "missing-tool" | "unauthenticated") {
120 return new PullRequestProviderError({329 return new PullRequestProviderError({
121 provider,330 provider,
...@@ -1840,6 +2049,13 @@ it.effect("routes explicit Forgejo HTTP authorities through SSH checkouts after...@@ -1840,6 +2049,13 @@ it.effect("routes explicit Forgejo HTTP authorities through SSH checkouts after
1840 state: "open",2049 state: "open",
1841 });2050 });
1842 assert.strictEqual(listed.viewers["code.example:3000"], "bilal");2051 assert.strictEqual(listed.viewers["code.example:3000"], "bilal");
2052 const preview = yield* service.preview({
2053 projectId: "ssh" as ProjectId,
2054 host: "code.example:3000",
2055 repository: "team/repo",
2056 number: 42,
2057 });
2058 assert.strictEqual(preview.number, 42);
1843 const detail = yield* service.detail({2059 const detail = yield* service.detail({
1844 projectId: "ssh" as ProjectId,2060 projectId: "ssh" as ProjectId,
1845 host: "code.example:3000",2061 host: "code.example:3000",
...@@ -4071,7 +4287,7 @@ it.effect("keeps routed reads separate when the GitHub account changes", () =>...@@ -4071,7 +4287,7 @@ it.effect("keeps routed reads separate when the GitHub account changes", () =>
40714287
4072it.effect("isolates routed caches for two credentials belonging to the same account", () =>4288it.effect("isolates routed caches for two credentials belonging to the same account", () =>
4073 Effect.gen(function* () {4289 Effect.gen(function* () {
4074 for (const operation of ["summary", "detail", "diff", "filesViewed"] as const) {4290 for (const operation of ["summary", "detail", "diff", "preview", "filesViewed"] as const) {
4075 let credential = "broad";4291 let credential = "broad";
4076 let calls = 0;4292 let calls = 0;
4077 const read = () =>4293 const read = () =>
...@@ -4109,6 +4325,7 @@ it.effect("isolates routed caches for two credentials belonging to the same acco...@@ -4109,6 +4325,7 @@ it.effect("isolates routed caches for two credentials belonging to the same acco
4109 read().pipe(4325 read().pipe(
4110 Effect.as({ patch: "private patch", truncated: false, nextCursor: null }),4326 Effect.as({ patch: "private patch", truncated: false, nextCursor: null }),
4111 ),4327 ),
4328 getChangeRequestPreview: read,
4112 }),4329 }),
4113 ],4330 ],
4114 });4331 });
apps/server/src/pullRequest/PullRequestService.ts+61
...@@ -32,6 +32,7 @@ import {...@@ -32,6 +32,7 @@ import {
32 type PullRequestCommentInput,32 type PullRequestCommentInput,
33 type PullRequestCommentUpdateInput,33 type PullRequestCommentUpdateInput,
34 type PullRequestDetail,34 type PullRequestDetail,
35 type PullRequestPreview,
35 type PullRequestDiffFileContentsInput,36 type PullRequestDiffFileContentsInput,
36 type PullRequestDiffFileContentsResult,37 type PullRequestDiffFileContentsResult,
37 type PullRequestDiffStat,38 type PullRequestDiffStat,
...@@ -202,6 +203,9 @@ export class PullRequestService extends Context.Service<...@@ -202,6 +203,9 @@ export class PullRequestService extends Context.Service<
202 readonly subscribeRefreshes: Stream.Stream<number>;203 readonly subscribeRefreshes: Stream.Stream<number>;
203 readonly refreshAfterTurn: (projectId: ProjectId) => Effect.Effect<void>;204 readonly refreshAfterTurn: (projectId: ProjectId) => Effect.Effect<void>;
204 readonly detail: (input: PullRequestRef) => Effect.Effect<PullRequestDetail, PullRequestError>;205 readonly detail: (input: PullRequestRef) => Effect.Effect<PullRequestDetail, PullRequestError>;
206 readonly preview: (
207 input: PullRequestRef,
208 ) => Effect.Effect<PullRequestPreview, PullRequestError>;
205 readonly activity: (209 readonly activity: (
206 input: PullRequestRef,210 input: PullRequestRef,
207 ) => Effect.Effect<PullRequestActivity, PullRequestError>;211 ) => Effect.Effect<PullRequestActivity, PullRequestError>;
...@@ -546,6 +550,9 @@ function withRateLimitBackoff(...@@ -546,6 +550,9 @@ function withRateLimitBackoff(
546 listChangeRequestStats: wrap("listChangeRequestStats", api.listChangeRequestStats),550 listChangeRequestStats: wrap("listChangeRequestStats", api.listChangeRequestStats),
547 }),551 }),
548 getChangeRequest: wrap("getChangeRequest", api.getChangeRequest),552 getChangeRequest: wrap("getChangeRequest", api.getChangeRequest),
553 ...(api.getChangeRequestPreview === undefined
554 ? {}
555 : { getChangeRequestPreview: wrap("getChangeRequestPreview", api.getChangeRequestPreview) }),
549 ...(api.getChangeRequestSummary === undefined556 ...(api.getChangeRequestSummary === undefined
550 ? {}557 ? {}
551 : {558 : {
...@@ -1679,6 +1686,38 @@ export const make = Effect.gen(function* () {...@@ -1679,6 +1686,38 @@ export const make = Effect.gen(function* () {
1679 ),1686 ),
1680 );1687 );
16811688
1689 const previewFields = (value: PullRequestPreview): PullRequestPreview => ({
1690 projectId: value.projectId,
1691 repository: value.repository,
1692 number: value.number,
1693 title: value.title,
1694 url: value.url,
1695 author: value.author,
1696 state: value.state,
1697 isDraft: value.isDraft,
1698 createdAt: value.createdAt,
1699 });
1700 const previewUncached: PullRequestService["Service"]["preview"] = (input) =>
1701 requireProject(input).pipe(
1702 Effect.flatMap((project) =>
1703 (project.api.getChangeRequestPreview ?? project.api.getChangeRequest)({
1704 cwd: project.project.workspaceRoot,
1705 repository: project.repository,
1706 host: project.host,
1707 number: input.number,
1708 }).pipe(
1709 Effect.mapError(toPullRequestError("preview")),
1710 Effect.map((value) =>
1711 previewFields({
1712 ...value,
1713 projectId: project.project.id,
1714 repository: project.repository,
1715 }),
1716 ),
1717 ),
1718 ),
1719 );
1720
1682 const activityUncached: PullRequestService["Service"]["activity"] = (input) =>1721 const activityUncached: PullRequestService["Service"]["activity"] = (input) =>
1683 requireProject(input).pipe(1722 requireProject(input).pipe(
1684 Effect.flatMap((project) =>1723 Effect.flatMap((project) =>
...@@ -2885,6 +2924,27 @@ export const make = Effect.gen(function* () {...@@ -2885,6 +2924,27 @@ export const make = Effect.gen(function* () {
2885 timeToLive: (exit) => (Exit.isSuccess(exit) ? DETAIL_CACHE_TTL : Duration.zero),2924 timeToLive: (exit) => (Exit.isSuccess(exit) ? DETAIL_CACHE_TTL : Duration.zero),
2886 },2925 },
2887 );2926 );
2927
2928 const previewCache = yield* Cache.makeWith(
2929 (key: string) => {
2930 return previewUncached(refOfCacheKey(key));
2931 },
2932 {
2933 capacity: DETAIL_CACHE_CAPACITY,
2934 timeToLive: (exit) => (Exit.isSuccess(exit) ? DETAIL_CACHE_TTL : Duration.zero),
2935 },
2936 );
2937 const preview: PullRequestService["Service"]["preview"] = (input) => {
2938 const key = refCacheKey(input);
2939 return Cache.getSuccess(detailCache, key).pipe(
2940 Effect.flatMap(
2941 Option.match({
2942 onNone: () => Cache.get(previewCache, key),
2943 onSome: (detail) => Effect.succeed(previewFields(detail)),
2944 }),
2945 ),
2946 );
2947 };
2888 const activity: PullRequestService["Service"]["activity"] = (input) => {2948 const activity: PullRequestService["Service"]["activity"] = (input) => {
2889 const key = refCacheKey(input);2949 const key = refCacheKey(input);
2890 return Cache.get(activityCache, key);2950 return Cache.get(activityCache, key);
...@@ -3131,6 +3191,7 @@ export const make = Effect.gen(function* () {...@@ -3131,6 +3191,7 @@ export const make = Effect.gen(function* () {
3131 refreshAfterTurn,3191 refreshAfterTurn,
3132 detail: credentialCached(detail),3192 detail: credentialCached(detail),
3133 activity: credentialCached(activity),3193 activity: credentialCached(activity),
3194 preview: credentialCached(preview),
3134 threadComments,3195 threadComments,
3135 diff: credentialCached(diff),3196 diff: credentialCached(diff),
3136 diffFileContents,3197 diffFileContents,
apps/server/src/pullRequest/gitHubPullRequestJson.test.ts+43-15
...@@ -17,7 +17,6 @@ import {...@@ -17,7 +17,6 @@ import {
17 decodePullRequestSearchJson,17 decodePullRequestSearchJson,
18 decodePullRequestStacksJson,18 decodePullRequestStacksJson,
19 decodeLabelCandidatesJson,19 decodeLabelCandidatesJson,
20 decodeRepositoryAccessJson,
21 decodeReviewerCandidatesJson,20 decodeReviewerCandidatesJson,
22 decodeReviewThreadCommentsJson,21 decodeReviewThreadCommentsJson,
23 decodeReviewThreadsJson,22 decodeReviewThreadsJson,
...@@ -810,31 +809,38 @@ describe("reaction decoding", () => {...@@ -810,31 +809,38 @@ describe("reaction decoding", () => {
810describe("repository access decoding", () => {809describe("repository access decoding", () => {
811 const repositoryJson = (viewerPermission?: string | null) =>810 const repositoryJson = (viewerPermission?: string | null) =>
812 JSON.stringify({811 JSON.stringify({
813 mergeCommitAllowed: true,812 data: {
814 squashMergeAllowed: false,813 repository: {
815 rebaseMergeAllowed: true,814 pullRequest: null,
816 ...(viewerPermission === undefined ? {} : { viewerPermission }),815 mergeCommitAllowed: true,
816 squashMergeAllowed: false,
817 rebaseMergeAllowed: true,
818 ...(viewerPermission === undefined ? {} : { viewerPermission }),
819 },
820 },
817 });821 });
818822
819 it("reads the three settings gh reports", () => {823 it("reads merge settings with viewer permissions", () => {
820 expect(824 expect(
821 expectSuccess(decodeRepositoryAccessJson(repositoryJson("ADMIN"))).mergeCapabilities,825 expectSuccess(decodeViewerPermissionsJson(repositoryJson("ADMIN"))).mergeCapabilities,
822 ).toEqual({ merge: true, squash: false, rebase: true });826 ).toEqual({ merge: true, squash: false, rebase: true });
823 });827 });
824828
825 it("fails rather than defaulting open when a setting is missing", () => {829 it("fails rather than defaulting open when a setting is missing", () => {
826 const decoded = decodeRepositoryAccessJson(JSON.stringify({ mergeCommitAllowed: true }));830 const decoded = decodeViewerPermissionsJson(
831 JSON.stringify({ data: { repository: { pullRequest: null, mergeCommitAllowed: true } } }),
832 );
827 expect(Result.isSuccess(decoded)).toBe(false);833 expect(Result.isSuccess(decoded)).toBe(false);
828 });834 });
829835
830 it("counts the roles that can push as write, and the ones that cannot as read", () => {836 it("counts the roles that can push as write, and the ones that cannot as read", () => {
831 for (const permission of ["ADMIN", "MAINTAIN", "WRITE"]) {837 for (const permission of ["ADMIN", "MAINTAIN", "WRITE"]) {
832 expect(expectSuccess(decodeRepositoryAccessJson(repositoryJson(permission))).canWrite).toBe(838 expect(expectSuccess(decodeViewerPermissionsJson(repositoryJson(permission))).canWrite).toBe(
833 true,839 true,
834 );840 );
835 }841 }
836 for (const permission of ["TRIAGE", "READ", "NONE"]) {842 for (const permission of ["TRIAGE", "READ", "NONE"]) {
837 expect(expectSuccess(decodeRepositoryAccessJson(repositoryJson(permission))).canWrite).toBe(843 expect(expectSuccess(decodeViewerPermissionsJson(repositoryJson(permission))).canWrite).toBe(
838 false,844 false,
839 );845 );
840 }846 }
...@@ -843,14 +849,23 @@ describe("repository access decoding", () => {...@@ -843,14 +849,23 @@ describe("repository access decoding", () => {
843 it("withholds write where gh names no permission, which is not a standing it gave", () => {849 it("withholds write where gh names no permission, which is not a standing it gave", () => {
844 // The one place an unknown answer is not granted: a Merge button a reader cannot use wastes850 // The one place an unknown answer is not granted: a Merge button a reader cannot use wastes
845 // the press, where a missing one still leaves the pull request open on its host.851 // the press, where a missing one still leaves the pull request open on its host.
846 expect(expectSuccess(decodeRepositoryAccessJson(repositoryJson())).canWrite).toBe(false);852 expect(expectSuccess(decodeViewerPermissionsJson(repositoryJson())).canWrite).toBe(false);
847 expect(expectSuccess(decodeRepositoryAccessJson(repositoryJson(null))).canWrite).toBe(false);853 expect(expectSuccess(decodeViewerPermissionsJson(repositoryJson(null))).canWrite).toBe(false);
848 });854 });
849});855});
850856
851describe("viewer permission decoding", () => {857describe("viewer permission decoding", () => {
852 const viewerJson = (repository: Record<string, unknown>) =>858 const viewerJson = (repository: Record<string, unknown>) =>
853 JSON.stringify({ data: { repository } });859 JSON.stringify({
860 data: {
861 repository: {
862 mergeCommitAllowed: true,
863 squashMergeAllowed: false,
864 rebaseMergeAllowed: true,
865 ...repository,
866 },
867 },
868 });
854869
855 it("reads the repository's role and the pull request's own viewer fields together", () => {870 it("reads the repository's role and the pull request's own viewer fields together", () => {
856 expect(871 expect(
...@@ -862,7 +877,13 @@ describe("viewer permission decoding", () => {...@@ -862,7 +877,13 @@ describe("viewer permission decoding", () => {
862 }),877 }),
863 ),878 ),
864 ),879 ),
865 ).toEqual({ canWrite: false, canTriage: false, canUpdate: true, didAuthor: true });880 ).toEqual({
881 mergeCapabilities: { merge: true, squash: false, rebase: true },
882 canWrite: false,
883 canTriage: false,
884 canUpdate: true,
885 didAuthor: true,
886 });
866 });887 });
867888
868 it("says no to a passer-by on a repository they can only read", () => {889 it("says no to a passer-by on a repository they can only read", () => {
...@@ -875,7 +896,13 @@ describe("viewer permission decoding", () => {...@@ -875,7 +896,13 @@ describe("viewer permission decoding", () => {
875 }),896 }),
876 ),897 ),
877 ),898 ),
878 ).toEqual({ canWrite: false, canTriage: false, canUpdate: false, didAuthor: false });899 ).toEqual({
900 mergeCapabilities: { merge: true, squash: false, rebase: true },
901 canWrite: false,
902 canTriage: false,
903 canUpdate: false,
904 didAuthor: false,
905 });
879 });906 });
880907
881 it("reads silence as permission, but not as authorship", () => {908 it("reads silence as permission, but not as authorship", () => {
...@@ -883,6 +910,7 @@ describe("viewer permission decoding", () => {...@@ -883,6 +910,7 @@ describe("viewer permission decoding", () => {
883 // answer grants it and lets the host refuse; authorship is a fact about who wrote the change,910 // answer grants it and lets the host refuse; authorship is a fact about who wrote the change,
884 // and claiming it for someone who did not is how an author's own rules get handed out.911 // and claiming it for someone who did not is how an author's own rules get handed out.
885 expect(expectSuccess(decodeViewerPermissionsJson(viewerJson({ pullRequest: null })))).toEqual({912 expect(expectSuccess(decodeViewerPermissionsJson(viewerJson({ pullRequest: null })))).toEqual({
913 mergeCapabilities: { merge: true, squash: false, rebase: true },
886 canWrite: false,914 canWrite: false,
887 canTriage: false,915 canTriage: false,
888 canUpdate: true,916 canUpdate: true,
apps/server/src/pullRequest/gitHubPullRequestJson.ts+187-35
...@@ -5,6 +5,7 @@ import * as Schema from "effect/Schema";...@@ -5,6 +5,7 @@ import * as Schema from "effect/Schema";
5import type {5import type {
6 PullRequestStackMembership,6 PullRequestStackMembership,
7 PullRequestActor,7 PullRequestActor,
8 PullRequestPreview,
8 PullRequestCheck,9 PullRequestCheck,
9 PullRequestCheckStatus,10 PullRequestCheckStatus,
10 PullRequestChecksState,11 PullRequestChecksState,
...@@ -596,6 +597,62 @@ const RawRepositoryAccessSchema = Schema.Struct({...@@ -596,6 +597,62 @@ const RawRepositoryAccessSchema = Schema.Struct({
596 viewerPermission: Schema.optional(Schema.NullOr(Schema.String)),597 viewerPermission: Schema.optional(Schema.NullOr(Schema.String)),
597});598});
598599
600const RawCoreSchema = Schema.Struct({
601 data: Schema.Struct({
602 repository: Schema.Struct({
603 ...RawRepositoryAccessSchema.fields,
604 pullRequest: Schema.Struct({
605 ...RawDetailSchema.fields,
606 ...RawViewerFieldsSchema.fields,
607 viewerCanUpdateBranch: Schema.Boolean,
608 baseRef: Schema.NullOr(
609 Schema.Struct({
610 compare: Schema.NullOr(Schema.Struct({ behindBy: Schema.Int })),
611 }),
612 ),
613 reviewRequests: Schema.Struct({
614 nodes: Schema.Array(
615 Schema.Struct({ requestedReviewer: Schema.NullOr(RawReviewRequestSchema) }),
616 ),
617 }),
618 labels: Schema.Struct({ nodes: Schema.Array(RawLabelSchema) }),
619 commits: Schema.Struct({
620 nodes: Schema.Array(
621 Schema.Struct({
622 commit: Schema.Struct({
623 statusCheckRollup: Schema.NullOr(
624 Schema.Struct({
625 contexts: Schema.Struct({
626 nodes: Schema.Array(
627 Schema.Struct({
628 ...RawCheckSchema.fields,
629 checkSuite: Schema.optional(
630 Schema.NullOr(
631 Schema.Struct({
632 workflowRun: Schema.NullOr(
633 Schema.Struct({
634 workflow: Schema.NullOr(Schema.Struct({ name: Schema.String })),
635 }),
636 ),
637 }),
638 ),
639 ),
640 }),
641 ),
642 pageInfo: Schema.Struct({ hasNextPage: Schema.Boolean }),
643 }),
644 }),
645 ),
646 }),
647 }),
648 ),
649 }),
650 }),
651 }),
652 }),
653});
654const decodeCore = decodeJsonResult(RawCoreSchema);
655
599const RawPullRequestFileSchema = Schema.Struct({656const RawPullRequestFileSchema = Schema.Struct({
600 filename: Schema.String,657 filename: Schema.String,
601 status: Schema.optional(Schema.NullOr(Schema.String)),658 status: Schema.optional(Schema.NullOr(Schema.String)),
...@@ -644,6 +701,78 @@ export const PULL_REQUEST_LIST_JSON_FIELDS =...@@ -644,6 +701,78 @@ export const PULL_REQUEST_LIST_JSON_FIELDS =
644 "number,title,url,author,headRefName,baseRefName,state,isDraft,mergeable,reviewDecision,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,labels,statusCheckRollup";701 "number,title,url,author,headRefName,baseRefName,state,isDraft,mergeable,reviewDecision,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,labels,statusCheckRollup";
645702
646export const PULL_REQUEST_DETAIL_JSON_FIELDS = `${PULL_REQUEST_LIST_JSON_FIELDS},body,changedFiles,closedAt,isCrossRepository,headRepositoryOwner,headRefOid,autoMergeRequest`;703export const PULL_REQUEST_DETAIL_JSON_FIELDS = `${PULL_REQUEST_LIST_JSON_FIELDS},body,changedFiles,closedAt,isCrossRepository,headRepositoryOwner,headRefOid,autoMergeRequest`;
704
705/** Pull refs let the comparison share the detail read without first resolving a fork branch. */
706export const PULL_REQUEST_CORE_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $headRef: String!) {
707 repository(owner: $owner, name: $name) {
708 mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed viewerPermission
709 pullRequest(number: $number) {
710 number title url body state isDraft mergeable reviewDecision
711 additions deletions changedFiles createdAt updatedAt mergedAt closedAt
712 headRefName baseRefName headRefOid isCrossRepository
713 headRepositoryOwner { login }
714 author { login avatarUrl ... on User { id name } }
715 autoMergeRequest { mergeMethod }
716 viewerCanUpdate viewerDidAuthor viewerCanUpdateBranch
717 baseRef { compare(headRef: $headRef) { behindBy } }
718 reviewRequests(first: 100) {
719 nodes { requestedReviewer { ... on User { login name } ... on Bot { login } ... on Team { slug name } } }
720 }
721 labels(first: 100) { nodes { name color } }
722 commits(last: 1) {
723 nodes { commit { statusCheckRollup { contexts(first: 100) {
724 nodes {
725 __typename
726 ... on StatusContext { context state targetUrl createdAt description }
727 ... on CheckRun {
728 name status conclusion startedAt completedAt detailsUrl
729 checkSuite { workflowRun { workflow { name } } }
730 }
731 }
732 pageInfo { hasNextPage }
733 } } } }
734 }
735 }
736 }
737}`;
738
739export const PULL_REQUEST_PREVIEW_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) {
740 repository(owner: $owner, name: $name) {
741 pullRequest(number: $number) {
742 number title url state isDraft createdAt
743 author { login avatarUrl ... on User { name } }
744 }
745 }
746}`;
747
748const decodePullRequestPreview = decodeJsonResult(
749 Schema.Struct({
750 data: Schema.Struct({
751 repository: Schema.Struct({
752 pullRequest: Schema.Struct({
753 number: Schema.Int,
754 title: Schema.String,
755 url: Schema.String,
756 state: Schema.String,
757 isDraft: Schema.Boolean,
758 createdAt: Schema.String,
759 author: Schema.NullOr(RawActorSchema),
760 }),
761 }),
762 }),
763 }),
764);
765
766export function decodePullRequestPreviewJson(
767 raw: string,
768): Result.Result<Omit<PullRequestPreview, "projectId" | "repository">, DecodeFailure> {
769 return Result.map(decodePullRequestPreview(raw), ({ data }) => ({
770 ...data.repository.pullRequest,
771 author: toActor(data.repository.pullRequest.author),
772 state: toState(data.repository.pullRequest),
773 }));
774}
775
647export const PULL_REQUEST_ACTIVITY_JSON_FIELDS = "author,comments,reviews,commits";776export const PULL_REQUEST_ACTIVITY_JSON_FIELDS = "author,comments,reviews,commits";
648777
649/** GitHub's own ceiling on a connection page, which is what both thread reads ask for. */778/** GitHub's own ceiling on a connection page, which is what both thread reads ask for. */
...@@ -1034,14 +1163,6 @@ export function buildReviewSubmissionJson(input: {...@@ -1034,14 +1163,6 @@ export function buildReviewSubmissionJson(input: {
1034 });1163 });
1035}1164}
10361165
1037/**
1038 * `viewerPermission` rides along with the merge settings rather than being asked for on its own:
1039 * `gh repo view --json` serves both out of the same GraphQL repository object, so the viewer's
1040 * standing on the repository costs no request of its own.
1041 */
1042export const REPOSITORY_ACCESS_JSON_FIELDS =
1043 "mergeCommitAllowed,squashMergeAllowed,rebaseMergeAllowed,viewerPermission";
1044
1045export interface GitHubPullRequestListItem {1166export interface GitHubPullRequestListItem {
1046 readonly stack?: PullRequestStackMembership;1167 readonly stack?: PullRequestStackMembership;
1047 /** The author's node id, kept so a batch can resolve the avatar the listing does not carry. */1168 /** The author's node id, kept so a batch can resolve the avatar the listing does not carry. */
...@@ -1489,7 +1610,6 @@ const decodeWorkflowRunApprovals = decodeJsonResult(Schema.Array(RawWorkflowRunA...@@ -1489,7 +1610,6 @@ const decodeWorkflowRunApprovals = decodeJsonResult(Schema.Array(RawWorkflowRunA
1489const decodePullRequestHeads = decodeJsonResult(Schema.Array(RawPullRequestHeadSchema));1610const decodePullRequestHeads = decodeJsonResult(Schema.Array(RawPullRequestHeadSchema));
1490const decodeActivity = decodeJsonResult(RawActivitySchema);1611const decodeActivity = decodeJsonResult(RawActivitySchema);
1491const decodeFileEntry = Schema.decodeUnknownExit(RawPullRequestFileSchema);1612const decodeFileEntry = Schema.decodeUnknownExit(RawPullRequestFileSchema);
1492const decodeRepositoryAccess = decodeJsonResult(RawRepositoryAccessSchema);
1493const decodeReviewThreads = decodeJsonResult(RawReviewThreadsSchema);1613const decodeReviewThreads = decodeJsonResult(RawReviewThreadsSchema);
1494const decodeReviewThreadComments = decodeJsonResult(RawReviewThreadCommentsSchema);1614const decodeReviewThreadComments = decodeJsonResult(RawReviewThreadCommentsSchema);
14951615
...@@ -1689,6 +1809,54 @@ export function decodePullRequestStatsJson(...@@ -1689,6 +1809,54 @@ export function decodePullRequestStatsJson(
1689 return Result.succeed(stats);1809 return Result.succeed(stats);
1690}1810}
16911811
1812export interface GitHubPullRequestCore extends GitHubPullRequestDetail {
1813 readonly viewerAccess: GitHubViewerAccess & GitHubRepositoryAccess;
1814 readonly comparison: GitHubBaseComparison | null;
1815 readonly checksTruncated: boolean;
1816}
1817
1818export function decodePullRequestCoreJson(
1819 raw: string,
1820): Result.Result<GitHubPullRequestCore, DecodeFailure> {
1821 const decoded = decodeCore(raw);
1822 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
1823 const repository = decoded.success.data.repository;
1824 const pr = repository.pullRequest;
1825 const contexts = pr.commits.nodes[0]?.commit.statusCheckRollup?.contexts;
1826 return Result.succeed({
1827 ...toDetail({
1828 ...pr,
1829 reviewRequests: pr.reviewRequests.nodes.flatMap(({ requestedReviewer }) =>
1830 requestedReviewer === null ? [] : [requestedReviewer],
1831 ),
1832 labels: pr.labels.nodes,
1833 statusCheckRollup:
1834 contexts?.nodes.map((check) => ({
1835 ...check,
1836 workflowName: check.checkSuite?.workflowRun?.workflow?.name ?? null,
1837 })) ?? [],
1838 }),
1839 viewerAccess: {
1840 canWrite: toCanWrite(repository.viewerPermission),
1841 canTriage: toCanTriage(repository.viewerPermission),
1842 ...toPullRequestViewerFields(pr),
1843 mergeCapabilities: {
1844 merge: repository.mergeCommitAllowed,
1845 squash: repository.squashMergeAllowed,
1846 rebase: repository.rebaseMergeAllowed,
1847 },
1848 },
1849 comparison:
1850 pr.state !== "OPEN" || pr.baseRef?.compare == null
1851 ? null
1852 : {
1853 behindBy: pr.baseRef.compare.behindBy,
1854 viewerCanUpdate: pr.viewerCanUpdateBranch,
1855 },
1856 checksTruncated: contexts?.pageInfo.hasNextPage === true,
1857 });
1858}
1859
1692export function decodePullRequestDetailJson(1860export function decodePullRequestDetailJson(
1693 raw: string,1861 raw: string,
1694): Result.Result<GitHubPullRequestDetail, DecodeFailure> {1862): Result.Result<GitHubPullRequestDetail, DecodeFailure> {
...@@ -2052,7 +2220,7 @@ export function decodeReviewThreadCommentsJson(raw: string): Result.Result<...@@ -2052,7 +2220,7 @@ export function decodeReviewThreadCommentsJson(raw: string): Result.Result<
2052 });2220 });
2053}2221}
20542222
2055/** What one `gh repo view` answers: what the repository allows, and where the viewer stands. */2223/** Repository settings returned alongside the pull request viewer permissions. */
2056export interface GitHubRepositoryAccess {2224export interface GitHubRepositoryAccess {
2057 readonly mergeCapabilities: PullRequestMergeCapabilities;2225 readonly mergeCapabilities: PullRequestMergeCapabilities;
2058 readonly canWrite: boolean;2226 readonly canWrite: boolean;
...@@ -2084,22 +2252,6 @@ function toCanTriage(viewerPermission: string | null | undefined): boolean {...@@ -2084,22 +2252,6 @@ function toCanTriage(viewerPermission: string | null | undefined): boolean {
2084 return viewerPermission?.trim().toUpperCase() === "TRIAGE" || toCanWrite(viewerPermission);2252 return viewerPermission?.trim().toUpperCase() === "TRIAGE" || toCanWrite(viewerPermission);
2085}2253}
20862254
2087export function decodeRepositoryAccessJson(
2088 raw: string,
2089): Result.Result<GitHubRepositoryAccess, DecodeFailure> {
2090 const decoded = decodeRepositoryAccess(raw);
2091 return Result.isSuccess(decoded)
2092 ? Result.succeed({
2093 mergeCapabilities: {
2094 merge: decoded.success.mergeCommitAllowed,
2095 squash: decoded.success.squashMergeAllowed,
2096 rebase: decoded.success.rebaseMergeAllowed,
2097 },
2098 canWrite: toCanWrite(decoded.success.viewerPermission),
2099 })
2100 : Result.fail(decoded.failure);
2101}
2102
2103/**2255/**
2104 * Who a review may be asked of, and who it has already been asked of, in one read.2256 * Who a review may be asked of, and who it has already been asked of, in one read.
2105 *2257 *
...@@ -2428,15 +2580,10 @@ export interface GitHubViewerAccess {...@@ -2428,15 +2580,10 @@ export interface GitHubViewerAccess {
2428 readonly canUpdateBranch?: boolean;2580 readonly canUpdateBranch?: boolean;
2429}2581}
24302582
2431/**2583/** Core detail and write checks share one read of permissions and merge settings. */
2432 * The viewer's standing, asked on its own. Only the write path needs this: reading a pull request
2433 * already carries the same three fields on calls it was making anyway, and this exists so that a
2434 * merge or a close is decided by what GitHub says now rather than by what the page was told when
2435 * it loaded.
2436 */
2437export const VIEWER_PERMISSIONS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) {2584export const VIEWER_PERMISSIONS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) {
2438 repository(owner: $owner, name: $name) {2585 repository(owner: $owner, name: $name) {
2439 viewerPermission2586 mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed viewerPermission
2440 pullRequest(number: $number) { viewerCanUpdate viewerDidAuthor }2587 pullRequest(number: $number) { viewerCanUpdate viewerDidAuthor }
2441 }2588 }
2442}`;2589}`;
...@@ -2444,7 +2591,7 @@ export const VIEWER_PERMISSIONS_GRAPHQL_QUERY = `query($owner: String!, $name: S...@@ -2444,7 +2591,7 @@ export const VIEWER_PERMISSIONS_GRAPHQL_QUERY = `query($owner: String!, $name: S
2444const RawViewerPermissionsSchema = Schema.Struct({2591const RawViewerPermissionsSchema = Schema.Struct({
2445 data: Schema.Struct({2592 data: Schema.Struct({
2446 repository: Schema.Struct({2593 repository: Schema.Struct({
2447 viewerPermission: Schema.optional(Schema.NullOr(Schema.String)),2594 ...RawRepositoryAccessSchema.fields,
2448 /** Null for a number that names no pull request the viewer can see. */2595 /** Null for a number that names no pull request the viewer can see. */
2449 pullRequest: Schema.NullOr(RawViewerFieldsSchema),2596 pullRequest: Schema.NullOr(RawViewerFieldsSchema),
2450 }),2597 }),
...@@ -2455,13 +2602,18 @@ const decodeViewerPermissions = decodeJsonResult(RawViewerPermissionsSchema);...@@ -2455,13 +2602,18 @@ const decodeViewerPermissions = decodeJsonResult(RawViewerPermissionsSchema);
24552602
2456export function decodeViewerPermissionsJson(2603export function decodeViewerPermissionsJson(
2457 raw: string,2604 raw: string,
2458): Result.Result<GitHubViewerAccess, DecodeFailure> {2605): Result.Result<GitHubViewerAccess & GitHubRepositoryAccess, DecodeFailure> {
2459 const decoded = decodeViewerPermissions(raw);2606 const decoded = decodeViewerPermissions(raw);
2460 if (!Result.isSuccess(decoded)) {2607 if (!Result.isSuccess(decoded)) {
2461 return Result.fail(decoded.failure);2608 return Result.fail(decoded.failure);
2462 }2609 }
2463 const repository = decoded.success.data.repository;2610 const repository = decoded.success.data.repository;
2464 return Result.succeed({2611 return Result.succeed({
2612 mergeCapabilities: {
2613 merge: repository.mergeCommitAllowed,
2614 squash: repository.squashMergeAllowed,
2615 rebase: repository.rebaseMergeAllowed,
2616 },
2465 canWrite: toCanWrite(repository.viewerPermission),2617 canWrite: toCanWrite(repository.viewerPermission),
2466 canTriage: toCanTriage(repository.viewerPermission),2618 canTriage: toCanTriage(repository.viewerPermission),
2467 ...toPullRequestViewerFields(repository.pullRequest),2619 ...toPullRequestViewerFields(repository.pullRequest),
apps/server/src/ws.ts+6
...@@ -2729,6 +2729,12 @@ const makeWsRpcLayer = (...@@ -2729,6 +2729,12 @@ const makeWsRpcLayer = (
2729 "rpc.aggregate": "pull-requests",2729 "rpc.aggregate": "pull-requests",
2730 },2730 },
2731 ),2731 ),
2732 [WS_METHODS.pullRequestsPreview]: (input) =>
2733 observeRpcEffect(
2734 WS_METHODS.pullRequestsPreview,
2735 withPullRequestViewer(input, pullRequests.preview(input)),
2736 { "rpc.aggregate": "pull-requests" },
2737 ),
2732 [WS_METHODS.pullRequestsActivity]: (input) =>2738 [WS_METHODS.pullRequestsActivity]: (input) =>
2733 observeRpcEffect(2739 observeRpcEffect(
2734 WS_METHODS.pullRequestsActivity,2740 WS_METHODS.pullRequestsActivity,
apps/web/src/components/ChatMarkdown.tsx+1
...@@ -2905,6 +2905,7 @@ const CHAT_MARKDOWN_COMPONENTS = {...@@ -2905,6 +2905,7 @@ const CHAT_MARKDOWN_COMPONENTS = {
2905 environmentId,2905 environmentId,
2906 input: {2906 input: {
2907 projectId: pullRequestProject.id,2907 projectId: pullRequestProject.id,
2908 host: pullRequestCandidate.authority ?? pullRequestCandidate.host,
2908 repository:2909 repository:
2909 sourceControlRepositorySelector(pullRequestProject.repositoryIdentity) ??2910 sourceControlRepositorySelector(pullRequestProject.repositoryIdentity) ??
2910 pullRequestCandidate.repository,2911 pullRequestCandidate.repository,
apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx+32-31
...@@ -42,11 +42,10 @@ export function PullRequestLinkPreview({...@@ -42,11 +42,10 @@ export function PullRequestLinkPreview({
42 })42 })
43 : null,43 : null,
44 );44 );
45 const readDetail = useAtomQueryRunner(pullRequestEnvironment.detail, {45 const readPreview = useAtomQueryRunner(pullRequestEnvironment.preview, {
46 reportFailure: false,46 reportFailure: false,
47 reportDefect: false,47 reportDefect: false,
48 });48 });
49
50 const trigger = confirmBeforeOpen49 const trigger = confirmBeforeOpen
51 ? cloneElement(link, {50 ? cloneElement(link, {
52 onClick: (event) => {51 onClick: (event) => {
...@@ -56,7 +55,7 @@ export function PullRequestLinkPreview({...@@ -56,7 +55,7 @@ export function PullRequestLinkPreview({
56 if (resolvingClick) return;55 if (resolvingClick) return;
57 setOpen(false);56 setOpen(false);
58 setResolvingClick(true);57 setResolvingClick(true);
59 void readDetail({ environmentId: target.environmentId, input: target.input })58 void readPreview(target)
60 .then(async (result) => {59 .then(async (result) => {
61 if (isAtomCommandInterrupted(result)) return;60 if (isAtomCommandInterrupted(result)) return;
62 if (result._tag === "Success" && onOpenPullRequest(result.value.url)) return;61 if (result._tag === "Success" && onOpenPullRequest(result.value.url)) return;
...@@ -84,36 +83,38 @@ export function PullRequestLinkPreview({...@@ -84,36 +83,38 @@ export function PullRequestLinkPreview({
84 return (83 return (
85 <PreviewCard open={open} onOpenChange={setOpen}>84 <PreviewCard open={open} onOpenChange={setOpen}>
86 <PreviewCardTrigger render={trigger} delay={350} closeDelay={120} />85 <PreviewCardTrigger render={trigger} delay={350} closeDelay={120} />
87 <PreviewCardPopup align="center" className="w-80 max-w-[calc(100vw-2rem)] p-3">86 {detail !== null || detailQuery.error !== null ? (
88 {detail === null ? (87 <PreviewCardPopup align="center" className="w-80 max-w-[calc(100vw-2rem)] p-3">
89 <p className="text-xs leading-relaxed text-muted-foreground wrap-anywhere">88 {detail === null ? (
90 {detailQuery.isPending ? "Loading pull request details…" : originalUrl}89 <p className="text-xs leading-relaxed text-muted-foreground wrap-anywhere">
91 </p>90 {originalUrl}
92 ) : (
93 <div className="min-w-0">
94 <div className="flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground">
95 <span className="min-w-0 truncate">{detail.repository}</span>
96 <span className="shrink-0">#{detail.number}</span>
97 <span aria-hidden>·</span>
98 {state === null ? null : (
99 <span className="inline-flex shrink-0 items-center gap-1">
100 <state.Icon aria-hidden className={`size-3 ${state.toneClassName}`} />
101 {state.label}
102 </span>
103 )}
104 </div>
105 <p className="mt-1 text-sm font-medium leading-snug text-foreground text-pretty">
106 {detail.title}
107 </p>91 </p>
108 <div className="mt-2 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">92 ) : (
109 <PullRequestActorAvatar actor={detail.author} className="size-4" />93 <div className="min-w-0">
110 <span className="min-w-0 truncate">{authorLabel}</span>94 <div className="flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground">
111 <span aria-hidden>·</span>95 <span className="min-w-0 truncate">{detail.repository}</span>
112 <span className="shrink-0">opened {formatRelativeTimeLabel(detail.createdAt)}</span>96 <span className="shrink-0">#{detail.number}</span>
97 <span aria-hidden>·</span>
98 {state === null ? null : (
99 <span className="inline-flex shrink-0 items-center gap-1">
100 <state.Icon aria-hidden className={`size-3 ${state.toneClassName}`} />
101 {state.label}
102 </span>
103 )}
104 </div>
105 <p className="mt-1 text-sm font-medium leading-snug text-foreground text-pretty">
106 {detail.title}
107 </p>
108 <div className="mt-2 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
109 <PullRequestActorAvatar actor={detail.author} className="size-4" />
110 <span className="min-w-0 truncate">{authorLabel}</span>
111 <span aria-hidden>·</span>
112 <span className="shrink-0">opened {formatRelativeTimeLabel(detail.createdAt)}</span>
113 </div>
113 </div>114 </div>
114 </div>115 )}
115 )}116 </PreviewCardPopup>
116 </PreviewCardPopup>117 ) : null}
117 </PreviewCard>118 </PreviewCard>
118 );119 );
119}120}
knip.jsonc+1
...@@ -23,6 +23,7 @@...@@ -23,6 +23,7 @@
23 "src/claude-history-worker.ts!",23 "src/claude-history-worker.ts!",
24 "scripts/cli.ts",24 "scripts/cli.ts",
25 "scripts/evaluate-thread-titles.ts",25 "scripts/evaluate-thread-titles.ts",
26 "scripts/measure-pr-preview.ts",
26 "src/provider/testFixtures/*.mjs",27 "src/provider/testFixtures/*.mjs",
27 ],28 ],
28 // Keep the transitive Effect runtime pinned for standalone npm installs.29 // Keep the transitive Effect runtime pinned for standalone npm installs.
packages/client-runtime/src/state/pullRequestRouting.ts+1
...@@ -29,6 +29,7 @@ const reads = new Set<string>([...@@ -29,6 +29,7 @@ const reads = new Set<string>([
29 WS_METHODS.pullRequestsSummary,29 WS_METHODS.pullRequestsSummary,
30 WS_METHODS.pullRequestsStack,30 WS_METHODS.pullRequestsStack,
31 WS_METHODS.pullRequestsDetail,31 WS_METHODS.pullRequestsDetail,
32 WS_METHODS.pullRequestsPreview,
32 WS_METHODS.pullRequestsActivity,33 WS_METHODS.pullRequestsActivity,
33 WS_METHODS.pullRequestsThreadComments,34 WS_METHODS.pullRequestsThreadComments,
34 WS_METHODS.pullRequestsDiffFileContents,35 WS_METHODS.pullRequestsDiffFileContents,
packages/client-runtime/src/state/pullRequests.test.ts+78
...@@ -887,6 +887,84 @@ it.effect("keeps concurrent diff file reads on different hosts separate", () =>...@@ -887,6 +887,84 @@ it.effect("keeps concurrent diff file reads on different hosts separate", () =>
887 ),887 ),
888);888);
889889
890it.effect("keeps hover previews fresh after edits and turns", () =>
891 Effect.scoped(
892 Effect.gen(function* () {
893 const refreshEvents = yield* PubSub.unbounded<number>();
894 let title = "Original title";
895 let reads = 0;
896 const read = (input: unknown) =>
897 Effect.sync(() => {
898 expect(input).toEqual(reference);
899 reads++;
900 return { ...reference, title };
901 });
902 const reference = {
903 projectId: ProjectId.make("project-1"),
904 repository: "acme/web",
905 number: 1,
906 host: "github.example.com",
907 };
908 const client = {
909 [WS_METHODS.pullRequestsSubscribeRefreshes]: () => Stream.fromPubSub(refreshEvents),
910 [WS_METHODS.pullRequestsPreview]: read,
911 [WS_METHODS.pullRequestsDetail]: read,
912 [WS_METHODS.pullRequestsUpdate]: (input: { title: string }) =>
913 Effect.sync(() => {
914 title = input.title;
915 }),
916 [WS_METHODS.pullRequestsRunAction]: () =>
917 Effect.sync(() => {
918 title = "Closed";
919 }),
920 [WS_METHODS.pullRequestsInvalidate]: () => Effect.void,
921 } as unknown as WsRpcProtocolClient;
922 const { atoms, registry } = yield* makeTestRuntime(client);
923 const target = { environmentId: TARGET.environmentId, input: reference };
924 const preview = atoms.preview(target);
925 const unmount = registry.mount(preview);
926 yield* Effect.addFinalizer(() => Effect.sync(unmount));
927 expect(
928 (yield* AtomRegistry.getResult(registry, preview, { suspendOnWaiting: true })).title,
929 ).toBe(title);
930 yield* Effect.promise(() => executeAtomQuery(registry, preview));
931 expect(reads).toBe(1);
932 const edited = yield* Effect.promise(() =>
933 atoms.update.run(registry, { ...target, input: { ...reference, title: "Edited" } }),
934 );
935 expect(AsyncResult.isSuccess(edited)).toBe(true);
936 expect(
937 (yield* AtomRegistry.getResult(registry, preview, { suspendOnWaiting: true })).title,
938 ).toBe("Edited");
939 yield* Effect.promise(() =>
940 atoms.runAction.run(registry, { ...target, input: { ...reference, action: "close" } }),
941 );
942 expect(
943 (yield* AtomRegistry.getResult(registry, preview, { suspendOnWaiting: true })).title,
944 ).toBe("Closed");
945 title = "Refreshed";
946 yield* Effect.promise(() =>
947 atoms.invalidate.run(registry, {
948 environmentId: target.environmentId,
949 input: { reference },
950 }),
951 );
952 expect(
953 (yield* AtomRegistry.getResult(registry, preview, { suspendOnWaiting: true })).title,
954 ).toBe("Refreshed");
955 const refreshed = Latch.makeUnsafe();
956 const stop = registry.subscribe(preview, (result) => {
957 if (AsyncResult.isSuccess(result) && result.value.title === "After turn")
958 refreshed.openUnsafe();
959 });
960 yield* Effect.addFinalizer(() => Effect.sync(stop));
961 title = "After turn";
962 yield* PubSub.publish(refreshEvents, 1);
963 yield* refreshed.await;
964 }),
965 ),
966);
967
890it.effect("shares close, reopen, and merge with an untouched client's mounted PR readers", () =>968it.effect("shares close, reopen, and merge with an untouched client's mounted PR readers", () =>
891 Effect.scoped(969 Effect.scoped(
892 Effect.gen(function* () {970 Effect.gen(function* () {
packages/client-runtime/src/state/pullRequests.ts+18
...@@ -176,6 +176,15 @@ export function createPullRequestEnvironmentAtoms<R, E>(...@@ -176,6 +176,15 @@ export function createPullRequestEnvironmentAtoms<R, E>(
176 refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }),176 refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }),
177 }),177 }),
178 );178 );
179 const preview = writableQueryFamily(
180 createEnvironmentRpcQueryAtomFamily(runtime, {
181 label: "environment-data:pull-requests:preview",
182 tag: WS_METHODS.pullRequestsPreview,
183 execute: (input) => routedRequest(WS_METHODS.pullRequestsPreview, input),
184 staleTimeMs: 60_000,
185 refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }),
186 }),
187 );
179 const labelCandidates = writableQueryFamily(188 const labelCandidates = writableQueryFamily(
180 createEnvironmentRpcQueryAtomFamily(runtime, {189 createEnvironmentRpcQueryAtomFamily(runtime, {
181 label: "environment-data:pull-requests:label-candidates",190 label: "environment-data:pull-requests:label-candidates",
...@@ -221,6 +230,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(...@@ -221,6 +230,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(
221 refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }),230 refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }),
222 }),231 }),
223 detail,232 detail,
233 preview,
224 activity,234 activity,
225 threadComments: createEnvironmentRpcCommand(runtime, {235 threadComments: createEnvironmentRpcCommand(runtime, {
226 label: "environment-data:pull-requests:thread-comments",236 label: "environment-data:pull-requests:thread-comments",
...@@ -295,6 +305,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(...@@ -295,6 +305,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(
295 label: "environment-data:pull-requests:run-action",305 label: "environment-data:pull-requests:run-action",
296 tag: WS_METHODS.pullRequestsRunAction,306 tag: WS_METHODS.pullRequestsRunAction,
297 execute: (input) => routedRequest(WS_METHODS.pullRequestsRunAction, input),307 execute: (input) => routedRequest(WS_METHODS.pullRequestsRunAction, input),
308 onSuccess: (target, registry) => Effect.sync(() => registry.refresh(preview(target))),
298 scheduler: commandScheduler,309 scheduler: commandScheduler,
299 concurrency: serialPerEnvironment,310 concurrency: serialPerEnvironment,
300 }),311 }),
...@@ -302,6 +313,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(...@@ -302,6 +313,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(
302 label: "environment-data:pull-requests:update",313 label: "environment-data:pull-requests:update",
303 tag: WS_METHODS.pullRequestsUpdate,314 tag: WS_METHODS.pullRequestsUpdate,
304 execute: (input) => routedRequest(WS_METHODS.pullRequestsUpdate, input),315 execute: (input) => routedRequest(WS_METHODS.pullRequestsUpdate, input),
316 onSuccess: (target, registry) => Effect.sync(() => registry.refresh(preview(target))),
305 scheduler: commandScheduler,317 scheduler: commandScheduler,
306 concurrency: serialPerEnvironment,318 concurrency: serialPerEnvironment,
307 }),319 }),
...@@ -485,6 +497,12 @@ export function createPullRequestEnvironmentAtoms<R, E>(...@@ -485,6 +497,12 @@ export function createPullRequestEnvironmentAtoms<R, E>(
485 label: "environment-data:pull-requests:invalidate",497 label: "environment-data:pull-requests:invalidate",
486 tag: WS_METHODS.pullRequestsInvalidate,498 tag: WS_METHODS.pullRequestsInvalidate,
487 execute: (input) => routedRequest(WS_METHODS.pullRequestsInvalidate, input),499 execute: (input) => routedRequest(WS_METHODS.pullRequestsInvalidate, input),
500 onSuccess: ({ environmentId, input }, registry) =>
501 Effect.sync(() => {
502 if (input.reference !== undefined) {
503 registry.refresh(preview({ environmentId, input: input.reference }));
504 }
505 }),
488 scheduler: commandScheduler,506 scheduler: commandScheduler,
489 concurrency: serialPerEnvironment,507 concurrency: serialPerEnvironment,
490 }),508 }),
packages/contracts/src/pullRequest.ts+14
...@@ -703,6 +703,20 @@ export const PullRequestLinkedThreadsResult = Schema.Struct({...@@ -703,6 +703,20 @@ export const PullRequestLinkedThreadsResult = Schema.Struct({
703});703});
704export type PullRequestLinkedThreadsResult = typeof PullRequestLinkedThreadsResult.Type;704export type PullRequestLinkedThreadsResult = typeof PullRequestLinkedThreadsResult.Type;
705705
706/** The complete hover card, without checks, permissions, or branch comparisons. */
707export const PullRequestPreview = Schema.Struct({
708 projectId: ProjectId,
709 repository: TrimmedNonEmptyString,
710 number: PositiveInt,
711 title: TrimmedNonEmptyString,
712 url: TrimmedNonEmptyString,
713 author: Schema.NullOr(PullRequestActor),
714 state: PullRequestState,
715 isDraft: Schema.Boolean,
716 createdAt: IsoDateTime,
717});
718export type PullRequestPreview = typeof PullRequestPreview.Type;
719
706/**720/**
707 * The small live shape a linked thread needs. Keeping it separate from detail means a sidebar721 * The small live shape a linked thread needs. Keeping it separate from detail means a sidebar
708 * status check never loads permissions, repository settings, checks, or base comparison data.722 * status check never loads permissions, repository settings, checks, or base comparison data.
packages/contracts/src/rpc.ts+9
...@@ -111,6 +111,7 @@ import {...@@ -111,6 +111,7 @@ import {
111 PullRequestCommentInput,111 PullRequestCommentInput,
112 PullRequestCommentUpdateInput,112 PullRequestCommentUpdateInput,
113 PullRequestDetail,113 PullRequestDetail,
114 PullRequestPreview,
114 PullRequestDiffFileContentsInput,115 PullRequestDiffFileContentsInput,
115 PullRequestDiffFileContentsResult,116 PullRequestDiffFileContentsResult,
116 PullRequestFilesViewedResult,117 PullRequestFilesViewedResult,
...@@ -395,6 +396,7 @@ export const WS_METHODS = {...@@ -395,6 +396,7 @@ export const WS_METHODS = {
395 pullRequestsStack: "pullRequests.stack",396 pullRequestsStack: "pullRequests.stack",
396 pullRequestsLinkedThreads: "pullRequests.linkedThreads",397 pullRequestsLinkedThreads: "pullRequests.linkedThreads",
397 pullRequestsDetail: "pullRequests.detail",398 pullRequestsDetail: "pullRequests.detail",
399 pullRequestsPreview: "pullRequests.preview",
398 pullRequestsActivity: "pullRequests.activity",400 pullRequestsActivity: "pullRequests.activity",
399 pullRequestsThreadComments: "pullRequests.threadComments",401 pullRequestsThreadComments: "pullRequests.threadComments",
400 pullRequestsDiffFileContents: "pullRequests.diffFileContents",402 pullRequestsDiffFileContents: "pullRequests.diffFileContents",
...@@ -737,6 +739,12 @@ const WsPullRequestsDetailRpc = Rpc.make(WS_METHODS.pullRequestsDetail, {...@@ -737,6 +739,12 @@ const WsPullRequestsDetailRpc = Rpc.make(WS_METHODS.pullRequestsDetail, {
737 error: PullRequestRpcError,739 error: PullRequestRpcError,
738});740});
739741
742const WsPullRequestsPreviewRpc = Rpc.make(WS_METHODS.pullRequestsPreview, {
743 payload: PullRequestRef,
744 success: PullRequestPreview,
745 error: PullRequestRpcError,
746});
747
740const WsPullRequestsActivityRpc = Rpc.make(WS_METHODS.pullRequestsActivity, {748const WsPullRequestsActivityRpc = Rpc.make(WS_METHODS.pullRequestsActivity, {
741 payload: PullRequestRef,749 payload: PullRequestRef,
742 success: PullRequestActivity,750 success: PullRequestActivity,
...@@ -1418,6 +1426,7 @@ export const WsRpcGroup = RpcGroup.make(...@@ -1418,6 +1426,7 @@ export const WsRpcGroup = RpcGroup.make(
1418 WsPullRequestsStackRpc,1426 WsPullRequestsStackRpc,
1419 WsPullRequestsLinkedThreadsRpc,1427 WsPullRequestsLinkedThreadsRpc,
1420 WsPullRequestsDetailRpc,1428 WsPullRequestsDetailRpc,
1429 WsPullRequestsPreviewRpc,
1421 WsPullRequestsActivityRpc,1430 WsPullRequestsActivityRpc,
1422 WsPullRequestsThreadCommentsRpc,1431 WsPullRequestsThreadCommentsRpc,
1423 WsPullRequestsDiffFileContentsRpc,1432 WsPullRequestsDiffFileContentsRpc,