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 = {
7373 [WS_METHODS.pullRequestsStack]: AuthOrchestrationReadScope,
7474 [WS_METHODS.pullRequestsLinkedThreads]: AuthOrchestrationReadScope,
7575 [WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope,
76 [WS_METHODS.pullRequestsPreview]: AuthOrchestrationReadScope,
7677 [WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope,
7778 [WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope,
7879 [WS_METHODS.pullRequestsDiffFileContents]: AuthOrchestrationReadScope,
apps/server/src/pullRequest/GitHubPullRequestCli.test.ts+459-91
......@@ -1,5 +1,6 @@
11import { afterEach, assert, expect, it, vi } from "@effect/vitest";
22import * as Effect from "effect/Effect";
3import * as Clock from "effect/Clock";
34import * as Deferred from "effect/Deferred";
45import * as Fiber from "effect/Fiber";
56import * as Layer from "effect/Layer";
......@@ -16,6 +17,36 @@ import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts";
1617
1718const 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
1950const mockedExecute = vi.fn<GitHubCli.GitHubCli["Service"]["execute"]>();
2051const mockedStackMemberships = vi.fn<GitHubCli.GitHubCli["Service"]["execute"]>(() =>
2152 Effect.succeed(output('{"data":{}}')),
......@@ -33,7 +64,7 @@ const layer = it.layer(
3364 getPullRequest: mockedGetPullRequest,
3465 }),
3566 ),
36 Layer.provide(GitHubGraphQlBudget.layer),
67 Layer.provideMerge(GitHubGraphQlBudget.layer),
3768 ),
3869);
3970
......@@ -255,6 +286,118 @@ it.effect(
255286);
256287
257288layer("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
258401 it.effect("coalesces concurrent identity verification for the same host and credential", () =>
259402 Effect.gen(function* () {
260403 mockedExecute.mockImplementation((input) =>
......@@ -1741,19 +1884,20 @@ layer("GitHubPullRequestCli.layer", (it) => {
17411884 mockedExecute.mockReturnValueOnce(
17421885 Effect.succeed(
17431886 output(
1744 // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response.
1745 JSON.stringify({
1746 number: 7,
1747 title: "Pull request 7",
1748 url: "https://github.com/acme/web/pull/7",
1749 headRefName: "feat/page",
1750 headRefOid: "abc123",
1751 isCrossRepository: false,
1752 headRepositoryOwner: { login: "acme" },
1753 baseRefName: "main",
1754 createdAt: "2026-07-01T00:00:00Z",
1755 updatedAt: "2026-07-02T00:00:00Z",
1756 }),
1887 encodeJson(
1888 coreResponse({
1889 number: 7,
1890 title: "Pull request 7",
1891 url: "https://github.com/acme/web/pull/7",
1892 headRefName: "feat/page",
1893 headRefOid: "abc123",
1894 isCrossRepository: false,
1895 headRepositoryOwner: { login: "acme" },
1896 baseRefName: "main",
1897 createdAt: "2026-07-01T00:00:00Z",
1898 updatedAt: "2026-07-02T00:00:00Z",
1899 }),
1900 ),
17571901 ),
17581902 ),
17591903 );
......@@ -1774,19 +1918,20 @@ layer("GitHubPullRequestCli.layer", (it) => {
17741918 it.effect("finds and approves every workflow waiting on a maintainer", () =>
17751919 Effect.gen(function* () {
17761920 const detail = output(
1777 // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response.
1778 JSON.stringify({
1779 number: 7,
1780 title: "Pull request 7",
1781 url: "https://github.com/acme/web/pull/7",
1782 headRefName: "feat/page",
1783 headRefOid: "abc123",
1784 isCrossRepository: true,
1785 headRepositoryOwner: { login: "octocat" },
1786 baseRefName: "main",
1787 createdAt: "2026-07-01T00:00:00Z",
1788 updatedAt: "2026-07-02T00:00:00Z",
1789 }),
1921 encodeJson(
1922 coreResponse({
1923 number: 7,
1924 title: "Pull request 7",
1925 url: "https://github.com/acme/web/pull/7",
1926 headRefName: "feat/page",
1927 headRefOid: "abc123",
1928 isCrossRepository: true,
1929 headRepositoryOwner: { login: "octocat" },
1930 baseRefName: "main",
1931 createdAt: "2026-07-01T00:00:00Z",
1932 updatedAt: "2026-07-02T00:00:00Z",
1933 }),
1934 ),
17901935 );
17911936 const heads = output(
17921937 // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response.
......@@ -1902,7 +2047,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
19022047 updatedAt: "2026-07-02T00:00:00Z",
19032048 };
19042049 for (const value of [
1905 detail,
2050 coreResponse(detail),
19062051 [
19072052 {
19082053 number: 7,
......@@ -1912,7 +2057,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
19122057 },
19132058 ],
19142059 [{ databaseId: 10, workflowName: "build", url: "https://example.com/10" }],
1915 { ...detail, headRefOid: "def456" },
2060 coreResponse({ ...detail, headRefOid: "def456" }),
19162061 ]) {
19172062 mockedExecute.mockReturnValueOnce(
19182063 Effect.succeed(
......@@ -1943,8 +2088,47 @@ layer("GitHubPullRequestCli.layer", (it) => {
19432088 }),
19442089 );
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
19462129 it.effect("refuses workflow approval when one head belongs to several pull requests", () =>
19472130 Effect.gen(function* () {
2131 mockedExecute.mockReturnValue(Effect.succeed(output("[]")));
19482132 mockedExecute.mockReturnValueOnce(
19492133 Effect.succeed(
19502134 output(
......@@ -1983,7 +2167,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
19832167 limit: 1_000,
19842168 });
19852169 expect(error.detail).toContain("instead of uniquely matching #7");
1986 expect(mockedExecute).toHaveBeenCalledTimes(1);
2170 expect(mockedExecute).toHaveBeenCalledTimes(2);
19872171 }),
19882172 );
19892173
......@@ -1992,19 +2176,20 @@ layer("GitHubPullRequestCli.layer", (it) => {
19922176 mockedExecute.mockReturnValueOnce(
19932177 Effect.succeed(
19942178 output(
1995 // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response.
1996 JSON.stringify({
1997 number: 7,
1998 title: "Pull request 7",
1999 url: "https://github.com/acme/web/pull/7",
2000 headRefName: "feat/page",
2001 headRefOid: "abc123",
2002 isCrossRepository: true,
2003 headRepositoryOwner: null,
2004 baseRefName: "main",
2005 createdAt: "2026-07-01T00:00:00Z",
2006 updatedAt: "2026-07-02T00:00:00Z",
2007 }),
2179 encodeJson(
2180 coreResponse({
2181 number: 7,
2182 title: "Pull request 7",
2183 url: "https://github.com/acme/web/pull/7",
2184 headRefName: "feat/page",
2185 headRefOid: "abc123",
2186 isCrossRepository: true,
2187 headRepositoryOwner: null,
2188 baseRefName: "main",
2189 createdAt: "2026-07-01T00:00:00Z",
2190 updatedAt: "2026-07-02T00:00:00Z",
2191 }),
2192 ),
20082193 ),
20092194 ),
20102195 );
......@@ -3041,19 +3226,20 @@ layer("GitHubPullRequestCli.layer", (it) => {
30413226 mockedExecute.mockReturnValueOnce(
30423227 Effect.succeed(
30433228 output(
3044 // @effect-diagnostics-next-line preferSchemaOverJson:off
3045 JSON.stringify({
3046 number: 7,
3047 title: "Progressive detail",
3048 url: "https://github.com/acme/web/pull/7",
3049 author: { login: "octocat" },
3050 headRefName: "feature",
3051 baseRefName: "main",
3052 createdAt: "2026-07-01T00:00:00Z",
3053 updatedAt: "2026-07-02T00:00:00Z",
3054 body: "Core body",
3055 changedFiles: 2,
3056 }),
3229 encodeJson(
3230 coreResponse({
3231 number: 7,
3232 title: "Progressive detail",
3233 url: "https://github.com/acme/web/pull/7",
3234 author: { login: "octocat" },
3235 headRefName: "feature",
3236 baseRefName: "main",
3237 createdAt: "2026-07-01T00:00:00Z",
3238 updatedAt: "2026-07-02T00:00:00Z",
3239 body: "Core body",
3240 changedFiles: 2,
3241 }),
3242 ),
30573243 ),
30583244 ),
30593245 );
......@@ -3083,13 +3269,216 @@ layer("GitHubPullRequestCli.layer", (it) => {
30833269
30843270 expect(detail.body).toBe("Core body");
30853271 expect(activity.author?.login).toBe("octocat");
3086 expect(callAt(0).args.at(-1)).toBe(
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",
3088 );
3272 expect(callAt(0).args).toContain("headRef=refs/pull/7/head");
3273 expect(callAt(0).args.at(-1)).toContain("viewerCanUpdateBranch");
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 });
30893280 expect(callAt(1).args.at(-1)).toBe("author,comments,reviews,commits");
30903281 }),
30913282 );
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
30933482 it.effect("fails a files page too large to read rather than calling the diff whole", () =>
30943483 Effect.gen(function* () {
30953484 mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused));
......@@ -3271,6 +3660,9 @@ layer("GitHubPullRequestCli.layer", (it) => {
32713660 JSON.stringify({
32723661 data: {
32733662 repository: {
3663 mergeCommitAllowed: true,
3664 squashMergeAllowed: false,
3665 rebaseMergeAllowed: true,
32743666 viewerPermission: "READ",
32753667 pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true },
32763668 },
......@@ -3291,7 +3683,11 @@ layer("GitHubPullRequestCli.layer", (it) => {
32913683 // One request, because both answers hang off the same repository object.
32923684 assert.strictEqual(mockedExecute.mock.calls.length, 1);
32933685 expect(callAt(0).args).toContain("number=7");
3686 expect(callAt(0).args.at(-1)).toContain(
3687 "mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed",
3688 );
32943689 expect(access).toEqual({
3690 mergeCapabilities: { merge: true, squash: false, rebase: true },
32953691 canWrite: false,
32963692 canTriage: false,
32973693 canUpdate: true,
......@@ -3433,6 +3829,9 @@ layer("GitHubPullRequestCli.layer", (it) => {
34333829 JSON.stringify({
34343830 data: {
34353831 repository: {
3832 mergeCommitAllowed: true,
3833 squashMergeAllowed: false,
3834 rebaseMergeAllowed: true,
34363835 viewerPermission: "READ",
34373836 pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true },
34383837 },
......@@ -3460,6 +3859,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
34603859
34613860 assert.strictEqual(mockedExecute.mock.calls.length, 2);
34623861 expect(access).toEqual({
3862 mergeCapabilities: { merge: true, squash: false, rebase: true },
34633863 canWrite: false,
34643864 canTriage: false,
34653865 canUpdate: true,
......@@ -3469,38 +3869,6 @@ layer("GitHubPullRequestCli.layer", (it) => {
34693869 }),
34703870 );
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
35043872 it.effect("asks GitHub to review, naming the collection a request is added to", () =>
35053873 Effect.gen(function* () {
35063874 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
apps/server/src/pullRequest/GitHubPullRequestCli.ts+182-134
......@@ -30,6 +30,7 @@ import {
3030 type PullRequestLabelCandidateList,
3131 type PullRequestThreadCommentsResult,
3232 type PullRequestUpdateMethod,
33 type PullRequestPreview,
3334} from "@t3tools/contracts";
3435
3536import * as GitHubCli from "../sourceControl/GitHubCli.ts";
......@@ -44,6 +45,11 @@ import {
4445 decodeActorAvatarsJson,
4546 decodePullRequestActivityJson,
4647 decodePullRequestDetailJson,
48 decodePullRequestCoreJson,
49 PULL_REQUEST_CORE_GRAPHQL_QUERY,
50 type GitHubPullRequestCore,
51 decodePullRequestPreviewJson,
52 PULL_REQUEST_PREVIEW_GRAPHQL_QUERY,
4753 decodePullRequestFilesJson,
4854 decodePullRequestFilesViewedJson,
4955 decodePullRequestHeadsJson,
......@@ -53,7 +59,6 @@ import {
5359 decodePullRequestStacksJson,
5460 decodePullRequestStatsJson,
5561 decodeReactionSubjectScopeJson,
56 decodeRepositoryAccessJson,
5762 decodeReviewerCandidatesJson,
5863 decodeLabelCandidatesJson,
5964 buildLabelRequestJson,
......@@ -78,7 +83,6 @@ import {
7883 REMOVE_REACTION_GRAPHQL_MUTATION,
7984 REVERT_PULL_REQUEST_GRAPHQL_MUTATION,
8085 gitHubReactionContent,
81 REPOSITORY_ACCESS_JSON_FIELDS,
8286 RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION,
8387 REVIEWER_CANDIDATES_GRAPHQL_QUERY,
8488 REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY,
......@@ -94,7 +98,6 @@ import {
9498 decodeViewerPermissionsJson,
9599 decodeWorkflowRunApprovalsJson,
96100 type GitHubBaseComparison,
97 type GitHubPullRequestDetail,
98101 type GitHubPullRequestActivity,
99102 type GitHubPullRequestHead,
100103 type GitHubPullRequestListItem,
......@@ -519,7 +522,17 @@ export class GitHubPullRequestCli extends Context.Service<
519522 readonly repository: string;
520523 readonly host: string;
521524 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
524537 readonly listWorkflowRunsRequiringApproval: (input: {
525538 readonly cwd: string;
......@@ -639,13 +652,6 @@ export class GitHubPullRequestCli extends Context.Service<
639652 readonly cursor: string;
640653 }) => 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
649655 /** The viewer's standing on its own, for deciding a write without reading the whole detail. */
650656 readonly getViewerAccess: (input: {
651657 readonly cwd: string;
......@@ -654,7 +660,7 @@ export class GitHubPullRequestCli extends Context.Service<
654660 readonly number: number;
655661 /** Manual action checks may use the quota held back from automatic reads. */
656662 readonly allowReserve?: boolean | undefined;
657 }) => Effect.Effect<GitHubViewerAccess, GitHubPullRequestCliError>;
663 }) => Effect.Effect<GitHubViewerAccess & GitHubRepositoryAccess, GitHubPullRequestCliError>;
658664
659665 /** Who this pull request may be sent to, and who it has already been sent to. */
660666 readonly listReviewerCandidates: (input: {
......@@ -1514,7 +1520,9 @@ export const make = Effect.gen(function* () {
15141520 return { oldContents, newContents };
15151521 });
15161522
1517 const getPullRequestDetail: GitHubPullRequestCli["Service"]["getPullRequestDetail"] = (input) =>
1523 const readLegacyDetail = (
1524 input: Parameters<GitHubPullRequestCli["Service"]["getPullRequestDetail"]>[0],
1525 ) =>
15181526 github
15191527 .execute({
15201528 cwd: input.cwd,
......@@ -1543,6 +1551,53 @@ export const make = Effect.gen(function* () {
15431551 }),
15441552 );
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
15461601 const workflowApprovalLimit = 1_000;
15471602 const workflowApprovalProbeLimit = String(workflowApprovalLimit + 1);
15481603 const workflowApprovalReadError = (cwd: string, cause: unknown) =>
......@@ -1554,71 +1609,74 @@ export const make = Effect.gen(function* () {
15541609 });
15551610 const listWorkflowRunsRequiringApproval: GitHubPullRequestCli["Service"]["listWorkflowRunsRequiringApproval"] =
15561611 (input) =>
1557 github
1558 .execute({
1559 cwd: input.cwd,
1560 args: [
1561 "pr",
1562 "list",
1563 ...repositoryArgs(input),
1564 "--state",
1565 "open",
1566 "--head",
1567 input.headBranch,
1568 "--limit",
1569 workflowApprovalProbeLimit,
1570 "--json",
1571 "number,headRefOid,isCrossRepository,headRepositoryOwner",
1572 ],
1573 })
1574 .pipe(
1575 Effect.flatMap(
1576 (
1577 result,
1578 ): Effect.Effect<
1579 GitHubPullRequestHead,
1580 GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError
1581 > => {
1582 const decoded = decodePullRequestHeadsJson(result.stdout.trim());
1583 if (!Result.isSuccess(decoded)) {
1584 return Effect.fail(workflowApprovalReadError(input.cwd, decoded.failure));
1585 }
1586 const exactHeads = decoded.success.filter(
1587 (pullRequest) =>
1588 pullRequest.headSha === input.headSha &&
1589 pullRequest.isCrossRepository === true &&
1590 pullRequest.headRepositoryOwner?.toLowerCase() ===
1591 input.headRepositoryOwner.toLowerCase(),
1592 );
1593 if (decoded.success.length > workflowApprovalLimit) {
1594 return Effect.fail(
1595 new GitHubWorkflowApprovalRefusedError({
1596 command: "gh",
1597 cwd: input.cwd,
1598 number: input.number,
1599 reason: "head-list-truncated",
1600 observedCount: decoded.success.length,
1601 limit: workflowApprovalLimit,
1602 }),
1603 );
1604 }
1605 if (exactHeads.length !== 1 || exactHeads[0]?.number !== input.number) {
1606 return Effect.fail(
1607 new GitHubWorkflowApprovalRefusedError({
1608 command: "gh",
1609 cwd: input.cwd,
1610 number: input.number,
1611 reason: "head-not-unique",
1612 observedCount: exactHeads.length,
1613 limit: workflowApprovalLimit,
1614 }),
1615 );
1616 }
1617 return Effect.succeed(exactHeads[0]);
1618 },
1619 ),
1620 Effect.flatMap(() =>
1621 github.execute({
1612 Effect.all(
1613 [
1614 github
1615 .execute({
1616 cwd: input.cwd,
1617 args: [
1618 "pr",
1619 "list",
1620 ...repositoryArgs(input),
1621 "--state",
1622 "open",
1623 "--head",
1624 input.headBranch,
1625 "--limit",
1626 workflowApprovalProbeLimit,
1627 "--json",
1628 "number,headRefOid,isCrossRepository,headRepositoryOwner",
1629 ],
1630 })
1631 .pipe(
1632 Effect.flatMap(
1633 (
1634 result,
1635 ): Effect.Effect<
1636 GitHubPullRequestHead,
1637 GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError
1638 > => {
1639 const decoded = decodePullRequestHeadsJson(result.stdout.trim());
1640 if (!Result.isSuccess(decoded)) {
1641 return Effect.fail(workflowApprovalReadError(input.cwd, decoded.failure));
1642 }
1643 const exactHeads = decoded.success.filter(
1644 (pullRequest) =>
1645 pullRequest.headSha === input.headSha &&
1646 pullRequest.isCrossRepository === true &&
1647 pullRequest.headRepositoryOwner?.toLowerCase() ===
1648 input.headRepositoryOwner.toLowerCase(),
1649 );
1650 if (decoded.success.length > workflowApprovalLimit) {
1651 return Effect.fail(
1652 new GitHubWorkflowApprovalRefusedError({
1653 command: "gh",
1654 cwd: input.cwd,
1655 number: input.number,
1656 reason: "head-list-truncated",
1657 observedCount: decoded.success.length,
1658 limit: workflowApprovalLimit,
1659 }),
1660 );
1661 }
1662 if (exactHeads.length !== 1 || exactHeads[0]?.number !== input.number) {
1663 return Effect.fail(
1664 new GitHubWorkflowApprovalRefusedError({
1665 command: "gh",
1666 cwd: input.cwd,
1667 number: input.number,
1668 reason: "head-not-unique",
1669 observedCount: exactHeads.length,
1670 limit: workflowApprovalLimit,
1671 }),
1672 );
1673 }
1674 return Effect.succeed(exactHeads[0]);
1675 },
1676 ),
1677 ),
1678 github
1679 .execute({
16221680 cwd: input.cwd,
16231681 args: [
16241682 "run",
......@@ -1637,34 +1695,37 @@ export const make = Effect.gen(function* () {
16371695 "--json",
16381696 "databaseId,workflowName,url",
16391697 ],
1640 }),
1641 ),
1642 Effect.flatMap(
1643 (
1644 result,
1645 ): Effect.Effect<
1646 ReadonlyArray<GitHubWorkflowRunApproval>,
1647 GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError
1648 > => {
1649 const decoded = decodeWorkflowRunApprovalsJson(result.stdout.trim());
1650 if (!Result.isSuccess(decoded)) {
1651 return Effect.fail(workflowApprovalReadError(input.cwd, decoded.failure));
1652 }
1653 return decoded.success.length > workflowApprovalLimit
1654 ? Effect.fail(
1655 new GitHubWorkflowApprovalRefusedError({
1656 command: "gh",
1657 cwd: input.cwd,
1658 number: input.number,
1659 reason: "run-list-truncated",
1660 observedCount: decoded.success.length,
1661 limit: workflowApprovalLimit,
1662 }),
1663 )
1664 : Effect.succeed(decoded.success);
1665 },
1666 ),
1667 );
1698 })
1699 .pipe(
1700 Effect.flatMap(
1701 (
1702 result,
1703 ): Effect.Effect<
1704 ReadonlyArray<GitHubWorkflowRunApproval>,
1705 GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError
1706 > => {
1707 const decoded = decodeWorkflowRunApprovalsJson(result.stdout.trim());
1708 if (!Result.isSuccess(decoded)) {
1709 return Effect.fail(workflowApprovalReadError(input.cwd, decoded.failure));
1710 }
1711 return decoded.success.length > workflowApprovalLimit
1712 ? Effect.fail(
1713 new GitHubWorkflowApprovalRefusedError({
1714 command: "gh",
1715 cwd: input.cwd,
1716 number: input.number,
1717 reason: "run-list-truncated",
1718 observedCount: decoded.success.length,
1719 limit: workflowApprovalLimit,
1720 }),
1721 )
1722 : Effect.succeed(decoded.success);
1723 },
1724 ),
1725 ),
1726 ],
1727 { concurrency: 2 },
1728 ).pipe(Effect.map(([, runs]) => runs));
16681729
16691730 return GitHubPullRequestCli.of({
16701731 withVerifiedCredential,
......@@ -1924,6 +1985,21 @@ export const make = Effect.gen(function* () {
19241985 ),
19251986
19261987 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 },
19272003 listWorkflowRunsRequiringApproval,
19282004
19292005 getPullRequestStack: (input) => {
......@@ -2246,34 +2322,6 @@ export const make = Effect.gen(function* () {
22462322 });
22472323 },
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
22772325 getViewerAccess: (input) => {
22782326 const { owner, name } = parseRepositorySelector(input.repository);
22792327 return graphqlRead({
apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts+112-37
......@@ -9,6 +9,18 @@ import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";
99import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts";
1010import 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
1224it.effect("maps credential verification failures without relabeling operation failures", () =>
1325 Effect.gen(function* () {
1426 let verificationFails = true;
......@@ -229,7 +241,7 @@ describe("gitHubViewerPermissions", () => {
229241 });
230242 });
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", () =>
233245 Effect.gen(function* () {
234246 const provider = yield* make;
235247 const detail = yield* provider.getChangeRequest({
......@@ -276,6 +288,9 @@ describe("gitHubViewerPermissions", () => {
276288 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
277289 getPullRequestDetail: () =>
278290 Effect.succeed({
291 ...coreFields,
292 comparison: null,
293 viewerAccess: { ...coreFields.viewerAccess, canWrite: false, canTriage: false },
279294 authorId: null,
280295 number: 7,
281296 title: "Pull request 7",
......@@ -285,9 +300,9 @@ describe("gitHubViewerPermissions", () => {
285300 headRepositoryOwner: null,
286301 headBranch: "feat/page",
287302 baseBranch: "main",
288 state: "open",
303 state: "open" as const,
289304 isDraft: false,
290 mergeability: "mergeable",
305 mergeability: "mergeable" as const,
291306 reviewDecision: null,
292307 additions: 1,
293308 deletions: 1,
......@@ -304,25 +319,22 @@ describe("gitHubViewerPermissions", () => {
304319 checks: [],
305320 comments: [],
306321 commits: [],
307 }),
308 getRepositoryAccess: () =>
309 GitHubCli.PinnedGitHubCredential.pipe(
310 Effect.map((credential) => ({
311 canWrite: false,
312 mergeCapabilities: {
313 merge: true,
314 squash: credential?.credentialFingerprint !== "restricted",
315 rebase: true,
316 },
317 })),
322 }).pipe(
323 Effect.flatMap((detail) =>
324 GitHubCli.PinnedGitHubCredential.pipe(
325 Effect.map((credential) => ({
326 ...detail,
327 viewerAccess: {
328 ...detail.viewerAccess,
329 mergeCapabilities: {
330 ...detail.viewerAccess.mergeCapabilities,
331 squash: credential?.credentialFingerprint !== "restricted",
332 },
333 },
334 })),
335 ),
336 ),
318337 ),
319 getViewerAccess: () =>
320 Effect.succeed({
321 canWrite: false,
322 canTriage: false,
323 canUpdate: true,
324 didAuthor: false,
325 }),
326338 }),
327339 ),
328340 ),
......@@ -364,6 +376,7 @@ describe("gitHubViewerPermissions", () => {
364376 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
365377 getPullRequestDetail: () =>
366378 Effect.succeed({
379 ...coreFields,
367380 authorId: null,
368381 number: 7,
369382 title: "Pull request 7",
......@@ -412,13 +425,14 @@ describe("gitHubViewerPermissions", () => {
412425 ]),
413426 getPullRequestBaseComparison: () =>
414427 Effect.succeed({ behindBy: 0, viewerCanUpdate: true }),
415 getRepositoryAccess: () =>
428 getViewerAccess: () =>
416429 Effect.succeed({
417430 canWrite: true,
431 canTriage: true,
432 canUpdate: true,
433 didAuthor: false,
418434 mergeCapabilities: { merge: true, squash: true, rebase: true },
419435 }),
420 getViewerAccess: () =>
421 Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }),
422436 }),
423437 ),
424438 ),
......@@ -426,6 +440,7 @@ describe("gitHubViewerPermissions", () => {
426440});
427441
428442const openDetail = {
443 ...coreFields,
429444 authorId: null,
430445 number: 7,
431446 title: "Pull request 7",
......@@ -457,6 +472,39 @@ const openDetail = {
457472 commits: [],
458473};
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
460508it.effect("does not classify same-repository gates as fork workflow approvals", () =>
461509 Effect.gen(function* () {
462510 const provider = yield* make;
......@@ -476,13 +524,14 @@ it.effect("does not classify same-repository gates as fork workflow approvals",
476524 getPullRequestBaseComparison: () => Effect.succeed({ behindBy: 0, viewerCanUpdate: true }),
477525 listWorkflowRunsRequiringApproval: () =>
478526 Effect.die("same-repository pull requests must not probe fork workflow approvals"),
479 getRepositoryAccess: () =>
527 getViewerAccess: () =>
480528 Effect.succeed({
481529 canWrite: true,
530 canTriage: true,
531 canUpdate: true,
532 didAuthor: false,
482533 mergeCapabilities: { merge: true, squash: true, rebase: true },
483534 }),
484 getViewerAccess: () =>
485 Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }),
486535 }),
487536 ),
488537 ),
......@@ -523,13 +572,14 @@ it.effect("keeps an unsafe workflow approval scope visible as unknown", () =>
523572 limit: 1_000,
524573 }),
525574 ),
526 getRepositoryAccess: () =>
575 getViewerAccess: () =>
527576 Effect.succeed({
528577 canWrite: true,
578 canTriage: true,
579 canUpdate: true,
580 didAuthor: false,
529581 mergeCapabilities: { merge: true, squash: true, rebase: true },
530582 }),
531 getViewerAccess: () =>
532 Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }),
533583 }),
534584 ),
535585 ),
......@@ -562,13 +612,14 @@ it.effect("propagates workflow discovery rate limits", () =>
562612 cause: new Error("rate limited"),
563613 }),
564614 ),
565 getRepositoryAccess: () =>
615 getViewerAccess: () =>
566616 Effect.succeed({
567617 canWrite: true,
618 canTriage: true,
619 canUpdate: true,
620 didAuthor: false,
568621 mergeCapabilities: { merge: true, squash: true, rebase: true },
569622 }),
570 getViewerAccess: () =>
571 Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }),
572623 }),
573624 ),
574625 ),
......@@ -598,7 +649,13 @@ describe("getViewerPermissions", () => {
598649 getViewerAccess: () =>
599650 Effect.sync(() => {
600651 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 };
602659 }),
603660 }),
604661 ),
......@@ -615,7 +672,13 @@ describe("getViewerPermissions", () => {
615672 getPullRequestDetail: () => Effect.succeed(openDetail),
616673 getPullRequestBaseComparison: () => comparison,
617674 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 }),
619682 });
620683
621684 it.effect("offers update-branch when the comparison grants it", () =>
......@@ -661,7 +724,13 @@ describe("getViewerPermissions", () => {
661724 getViewerAccess: (input) =>
662725 Effect.sync(() => {
663726 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 };
665734 }),
666735 }),
667736 ),
......@@ -696,7 +765,13 @@ describe("getViewerPermissions", () => {
696765 }),
697766 ),
698767 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 }),
700775 }),
701776 ),
702777 ),
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+72-127
......@@ -1,7 +1,4 @@
1import * as Cache from "effect/Cache";
2import * as Duration from "effect/Duration";
31import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";
52import type {
63 PullRequestActor,
74 PullRequestCapabilities,
......@@ -11,7 +8,6 @@ import type {
118} from "@t3tools/contracts";
129
1310import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";
14import { PinnedGitHubCredential } from "../sourceControl/GitHubCli.ts";
1511import {
1612 PullRequestProviderError,
1713 type PullRequestProviderFailure,
......@@ -193,35 +189,6 @@ const rendersEmpty = (body: string): boolean =>
193189export const make = Effect.gen(function* () {
194190 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
225192 const fail = (operation: string) => (error: GitHubPullRequestCli.GitHubPullRequestCliError) =>
226193 new PullRequestProviderError({
227194 provider: "github",
......@@ -340,102 +307,80 @@ export const make = Effect.gen(function* () {
340307 getChangeRequestStack: (input) =>
341308 cli.getPullRequestStack(input).pipe(Effect.mapError(fail("getChangeRequestStack"))),
342309
310 getChangeRequestPreview: (input) =>
311 cli.getPullRequestPreview(input).pipe(Effect.mapError(fail("getChangeRequestPreview"))),
312
343313 getChangeRequest: (input) =>
344 Effect.all(
345 [
346 cli.getPullRequestDetail(input).pipe(
347 Effect.flatMap((pullRequest) =>
348 Effect.all({
349 // Only an open pull request can be behind anything worth saying so about, and
350 // only one whose head repository is known can be compared at all.
351 comparison:
352 pullRequest.state !== "open" || pullRequest.headRepositoryOwner === null
353 ? Effect.succeed(null)
354 : cli
355 .getPullRequestBaseComparison({
356 ...input,
357 headRef: `${pullRequest.headRepositoryOwner}:${pullRequest.headBranch}`,
358 })
359 .pipe(Effect.orElseSucceed(() => null)),
360 // GitHub omits a fork workflow that has not been approved from the normal check
361 // rollup. Read the action-required runs by head revision so "all passed" cannot
362 // be shown while a whole workflow is still waiting to start.
363 workflowApprovals:
364 pullRequest.state !== "open" || pullRequest.isCrossRepository !== true
365 ? Effect.succeed({
366 runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>,
367 unavailable: false,
368 })
369 : pullRequest.headSha == null || pullRequest.headRepositoryOwner == null
370 ? Effect.succeed({
371 runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>,
372 unavailable: true,
373 })
374 : cli
375 .listWorkflowRunsRequiringApproval({
376 ...input,
377 headSha: pullRequest.headSha,
378 headBranch: pullRequest.headBranch,
379 headRepositoryOwner: pullRequest.headRepositoryOwner,
380 isCrossRepository: true,
381 })
382 .pipe(
383 Effect.matchEffect({
384 onFailure: (error) =>
385 error._tag === "GitHubCliRateLimitError" ||
386 error._tag === "SourceControlRateLimitPausedError"
387 ? Effect.fail(error)
388 : Effect.succeed({
389 runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>,
390 unavailable: true,
391 }),
392 onSuccess: (runs) => Effect.succeed({ runs, unavailable: false }),
393 }),
394 ),
395 }).pipe(Effect.map((extra) => ({ pullRequest, ...extra }))),
396 ),
397 ),
398 getRepositoryAccess({
399 cwd: input.cwd,
400 repository: input.repository,
401 host: input.host,
402 }),
403 // A small permissions query replaces the deeply paginated review-thread walk on the
404 // core path. Writes ask again immediately before mutating, so this is presentation.
405 cli.getViewerAccess(input),
406 ],
407 { concurrency: 3 },
408 ).pipe(
314 cli.getPullRequestDetail(input).pipe(
315 Effect.flatMap((pullRequest) => {
316 // Fork workflows awaiting approval are absent from the normal check rollup.
317 const approvals =
318 pullRequest.state !== "open" || pullRequest.isCrossRepository !== true
319 ? Effect.succeed({
320 runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>,
321 unavailable: false,
322 })
323 : pullRequest.headSha == null || pullRequest.headRepositoryOwner == null
324 ? Effect.succeed({
325 runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>,
326 unavailable: true,
327 })
328 : cli
329 .listWorkflowRunsRequiringApproval({
330 ...input,
331 headSha: pullRequest.headSha,
332 headBranch: pullRequest.headBranch,
333 headRepositoryOwner: pullRequest.headRepositoryOwner,
334 isCrossRepository: true,
335 })
336 .pipe(
337 Effect.matchEffect({
338 onFailure: (error) =>
339 error._tag === "GitHubCliRateLimitError" ||
340 error._tag === "SourceControlRateLimitPausedError"
341 ? Effect.fail(error)
342 : Effect.succeed({
343 runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>,
344 unavailable: true,
345 }),
346 onSuccess: (runs) => Effect.succeed({ runs, unavailable: false }),
347 }),
348 );
349 return approvals.pipe(
350 Effect.map((workflowApprovals): ProviderChangeRequestDetail => ({
351 ...pullRequest,
352 author: withAvatar(pullRequest.author, new Map<string, string>(), input.host),
353 checks: withWorkflowApprovals(
354 pullRequest.checks,
355 workflowApprovals.runs,
356 workflowApprovals.unavailable,
357 ),
358 ...(workflowApprovals.unavailable
359 ? {}
360 : { workflowApprovalsRequired: workflowApprovals.runs.length }),
361 reviewers: pullRequest.reviewRequestLogins.map((login) => ({
362 login,
363 name: null,
364 avatarUrl: null,
365 })),
366 mergeCapabilities: pullRequest.viewerAccess.mergeCapabilities,
367 viewerPermissions: gitHubViewerPermissions({
368 ...pullRequest.viewerAccess,
369 canUpdateBranch: pullRequest.comparison?.viewerCanUpdate === true,
370 }),
371 baseComparison:
372 pullRequest.comparison === null || pullRequest.comparison.behindBy === null
373 ? "unknown"
374 : pullRequest.comparison.behindBy > 0
375 ? "behind"
376 : "up-to-date",
377 ...(pullRequest.comparison?.behindBy == null
378 ? {}
379 : { behindBy: pullRequest.comparison.behindBy }),
380 })),
381 );
382 }),
409383 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 })),
439384 ),
440385
441386 getChangeRequestActivity: (input) =>
apps/server/src/pullRequest/PullRequestProvider.ts+9
......@@ -31,6 +31,7 @@ import type {
3131 PullRequestReviewerKind,
3232 PullRequestLabelCandidateList,
3333 PullRequestState,
34 PullRequestPreview,
3435 PullRequestUpdateMethod,
3536 PullRequestViewerPermissions,
3637 SourceControlProviderKind,
......@@ -400,6 +401,14 @@ export interface PullRequestProviderApi {
400401 input: ProviderRepositoryRef & { readonly number: number },
401402 ) => 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
403412 /**
404413 * The cheap live fields used by linked threads. Optional because a provider without a narrow
405414 * 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) {
116116 };
117117}
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
119328function unusable(provider: SourceControlProviderKind, reason: "missing-tool" | "unauthenticated") {
120329 return new PullRequestProviderError({
121330 provider,
......@@ -1840,6 +2049,13 @@ it.effect("routes explicit Forgejo HTTP authorities through SSH checkouts after
18402049 state: "open",
18412050 });
18422051 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);
18432059 const detail = yield* service.detail({
18442060 projectId: "ssh" as ProjectId,
18452061 host: "code.example:3000",
......@@ -4071,7 +4287,7 @@ it.effect("keeps routed reads separate when the GitHub account changes", () =>
40714287
40724288it.effect("isolates routed caches for two credentials belonging to the same account", () =>
40734289 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) {
40754291 let credential = "broad";
40764292 let calls = 0;
40774293 const read = () =>
......@@ -4109,6 +4325,7 @@ it.effect("isolates routed caches for two credentials belonging to the same acco
41094325 read().pipe(
41104326 Effect.as({ patch: "private patch", truncated: false, nextCursor: null }),
41114327 ),
4328 getChangeRequestPreview: read,
41124329 }),
41134330 ],
41144331 });
apps/server/src/pullRequest/PullRequestService.ts+61
......@@ -32,6 +32,7 @@ import {
3232 type PullRequestCommentInput,
3333 type PullRequestCommentUpdateInput,
3434 type PullRequestDetail,
35 type PullRequestPreview,
3536 type PullRequestDiffFileContentsInput,
3637 type PullRequestDiffFileContentsResult,
3738 type PullRequestDiffStat,
......@@ -202,6 +203,9 @@ export class PullRequestService extends Context.Service<
202203 readonly subscribeRefreshes: Stream.Stream<number>;
203204 readonly refreshAfterTurn: (projectId: ProjectId) => Effect.Effect<void>;
204205 readonly detail: (input: PullRequestRef) => Effect.Effect<PullRequestDetail, PullRequestError>;
206 readonly preview: (
207 input: PullRequestRef,
208 ) => Effect.Effect<PullRequestPreview, PullRequestError>;
205209 readonly activity: (
206210 input: PullRequestRef,
207211 ) => Effect.Effect<PullRequestActivity, PullRequestError>;
......@@ -546,6 +550,9 @@ function withRateLimitBackoff(
546550 listChangeRequestStats: wrap("listChangeRequestStats", api.listChangeRequestStats),
547551 }),
548552 getChangeRequest: wrap("getChangeRequest", api.getChangeRequest),
553 ...(api.getChangeRequestPreview === undefined
554 ? {}
555 : { getChangeRequestPreview: wrap("getChangeRequestPreview", api.getChangeRequestPreview) }),
549556 ...(api.getChangeRequestSummary === undefined
550557 ? {}
551558 : {
......@@ -1679,6 +1686,38 @@ export const make = Effect.gen(function* () {
16791686 ),
16801687 );
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
16821721 const activityUncached: PullRequestService["Service"]["activity"] = (input) =>
16831722 requireProject(input).pipe(
16841723 Effect.flatMap((project) =>
......@@ -2885,6 +2924,27 @@ export const make = Effect.gen(function* () {
28852924 timeToLive: (exit) => (Exit.isSuccess(exit) ? DETAIL_CACHE_TTL : Duration.zero),
28862925 },
28872926 );
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 };
28882948 const activity: PullRequestService["Service"]["activity"] = (input) => {
28892949 const key = refCacheKey(input);
28902950 return Cache.get(activityCache, key);
......@@ -3131,6 +3191,7 @@ export const make = Effect.gen(function* () {
31313191 refreshAfterTurn,
31323192 detail: credentialCached(detail),
31333193 activity: credentialCached(activity),
3194 preview: credentialCached(preview),
31343195 threadComments,
31353196 diff: credentialCached(diff),
31363197 diffFileContents,
apps/server/src/pullRequest/gitHubPullRequestJson.test.ts+43-15
......@@ -17,7 +17,6 @@ import {
1717 decodePullRequestSearchJson,
1818 decodePullRequestStacksJson,
1919 decodeLabelCandidatesJson,
20 decodeRepositoryAccessJson,
2120 decodeReviewerCandidatesJson,
2221 decodeReviewThreadCommentsJson,
2322 decodeReviewThreadsJson,
......@@ -810,31 +809,38 @@ describe("reaction decoding", () => {
810809describe("repository access decoding", () => {
811810 const repositoryJson = (viewerPermission?: string | null) =>
812811 JSON.stringify({
813 mergeCommitAllowed: true,
814 squashMergeAllowed: false,
815 rebaseMergeAllowed: true,
816 ...(viewerPermission === undefined ? {} : { viewerPermission }),
812 data: {
813 repository: {
814 pullRequest: null,
815 mergeCommitAllowed: true,
816 squashMergeAllowed: false,
817 rebaseMergeAllowed: true,
818 ...(viewerPermission === undefined ? {} : { viewerPermission }),
819 },
820 },
817821 });
818822
819 it("reads the three settings gh reports", () => {
823 it("reads merge settings with viewer permissions", () => {
820824 expect(
821 expectSuccess(decodeRepositoryAccessJson(repositoryJson("ADMIN"))).mergeCapabilities,
825 expectSuccess(decodeViewerPermissionsJson(repositoryJson("ADMIN"))).mergeCapabilities,
822826 ).toEqual({ merge: true, squash: false, rebase: true });
823827 });
824828
825829 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 );
827833 expect(Result.isSuccess(decoded)).toBe(false);
828834 });
829835
830836 it("counts the roles that can push as write, and the ones that cannot as read", () => {
831837 for (const permission of ["ADMIN", "MAINTAIN", "WRITE"]) {
832 expect(expectSuccess(decodeRepositoryAccessJson(repositoryJson(permission))).canWrite).toBe(
838 expect(expectSuccess(decodeViewerPermissionsJson(repositoryJson(permission))).canWrite).toBe(
833839 true,
834840 );
835841 }
836842 for (const permission of ["TRIAGE", "READ", "NONE"]) {
837 expect(expectSuccess(decodeRepositoryAccessJson(repositoryJson(permission))).canWrite).toBe(
843 expect(expectSuccess(decodeViewerPermissionsJson(repositoryJson(permission))).canWrite).toBe(
838844 false,
839845 );
840846 }
......@@ -843,14 +849,23 @@ describe("repository access decoding", () => {
843849 it("withholds write where gh names no permission, which is not a standing it gave", () => {
844850 // The one place an unknown answer is not granted: a Merge button a reader cannot use wastes
845851 // the press, where a missing one still leaves the pull request open on its host.
846 expect(expectSuccess(decodeRepositoryAccessJson(repositoryJson())).canWrite).toBe(false);
847 expect(expectSuccess(decodeRepositoryAccessJson(repositoryJson(null))).canWrite).toBe(false);
852 expect(expectSuccess(decodeViewerPermissionsJson(repositoryJson())).canWrite).toBe(false);
853 expect(expectSuccess(decodeViewerPermissionsJson(repositoryJson(null))).canWrite).toBe(false);
848854 });
849855});
850856
851857describe("viewer permission decoding", () => {
852858 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
855870 it("reads the repository's role and the pull request's own viewer fields together", () => {
856871 expect(
......@@ -862,7 +877,13 @@ describe("viewer permission decoding", () => {
862877 }),
863878 ),
864879 ),
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 });
866887 });
867888
868889 it("says no to a passer-by on a repository they can only read", () => {
......@@ -875,7 +896,13 @@ describe("viewer permission decoding", () => {
875896 }),
876897 ),
877898 ),
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 });
879906 });
880907
881908 it("reads silence as permission, but not as authorship", () => {
......@@ -883,6 +910,7 @@ describe("viewer permission decoding", () => {
883910 // answer grants it and lets the host refuse; authorship is a fact about who wrote the change,
884911 // and claiming it for someone who did not is how an author's own rules get handed out.
885912 expect(expectSuccess(decodeViewerPermissionsJson(viewerJson({ pullRequest: null })))).toEqual({
913 mergeCapabilities: { merge: true, squash: false, rebase: true },
886914 canWrite: false,
887915 canTriage: false,
888916 canUpdate: true,
apps/server/src/pullRequest/gitHubPullRequestJson.ts+187-35
......@@ -5,6 +5,7 @@ import * as Schema from "effect/Schema";
55import type {
66 PullRequestStackMembership,
77 PullRequestActor,
8 PullRequestPreview,
89 PullRequestCheck,
910 PullRequestCheckStatus,
1011 PullRequestChecksState,
......@@ -596,6 +597,62 @@ const RawRepositoryAccessSchema = Schema.Struct({
596597 viewerPermission: Schema.optional(Schema.NullOr(Schema.String)),
597598});
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
599656const RawPullRequestFileSchema = Schema.Struct({
600657 filename: Schema.String,
601658 status: Schema.optional(Schema.NullOr(Schema.String)),
......@@ -644,6 +701,78 @@ export const PULL_REQUEST_LIST_JSON_FIELDS =
644701 "number,title,url,author,headRefName,baseRefName,state,isDraft,mergeable,reviewDecision,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,labels,statusCheckRollup";
645702
646703export 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
647776export const PULL_REQUEST_ACTIVITY_JSON_FIELDS = "author,comments,reviews,commits";
648777
649778/** GitHub's own ceiling on a connection page, which is what both thread reads ask for. */
......@@ -1034,14 +1163,6 @@ export function buildReviewSubmissionJson(input: {
10341163 });
10351164}
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
10451166export interface GitHubPullRequestListItem {
10461167 readonly stack?: PullRequestStackMembership;
10471168 /** 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
14891610const decodePullRequestHeads = decodeJsonResult(Schema.Array(RawPullRequestHeadSchema));
14901611const decodeActivity = decodeJsonResult(RawActivitySchema);
14911612const decodeFileEntry = Schema.decodeUnknownExit(RawPullRequestFileSchema);
1492const decodeRepositoryAccess = decodeJsonResult(RawRepositoryAccessSchema);
14931613const decodeReviewThreads = decodeJsonResult(RawReviewThreadsSchema);
14941614const decodeReviewThreadComments = decodeJsonResult(RawReviewThreadCommentsSchema);
14951615
......@@ -1689,6 +1809,54 @@ export function decodePullRequestStatsJson(
16891809 return Result.succeed(stats);
16901810}
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
16921860export function decodePullRequestDetailJson(
16931861 raw: string,
16941862): Result.Result<GitHubPullRequestDetail, DecodeFailure> {
......@@ -2052,7 +2220,7 @@ export function decodeReviewThreadCommentsJson(raw: string): Result.Result<
20522220 });
20532221}
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. */
20562224export interface GitHubRepositoryAccess {
20572225 readonly mergeCapabilities: PullRequestMergeCapabilities;
20582226 readonly canWrite: boolean;
......@@ -2084,22 +2252,6 @@ function toCanTriage(viewerPermission: string | null | undefined): boolean {
20842252 return viewerPermission?.trim().toUpperCase() === "TRIAGE" || toCanWrite(viewerPermission);
20852253}
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
21032255/**
21042256 * Who a review may be asked of, and who it has already been asked of, in one read.
21052257 *
......@@ -2428,15 +2580,10 @@ export interface GitHubViewerAccess {
24282580 readonly canUpdateBranch?: boolean;
24292581}
24302582
2431/**
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 */
2583/** Core detail and write checks share one read of permissions and merge settings. */
24372584export const VIEWER_PERMISSIONS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) {
24382585 repository(owner: $owner, name: $name) {
2439 viewerPermission
2586 mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed viewerPermission
24402587 pullRequest(number: $number) { viewerCanUpdate viewerDidAuthor }
24412588 }
24422589}`;
......@@ -2444,7 +2591,7 @@ export const VIEWER_PERMISSIONS_GRAPHQL_QUERY = `query($owner: String!, $name: S
24442591const RawViewerPermissionsSchema = Schema.Struct({
24452592 data: Schema.Struct({
24462593 repository: Schema.Struct({
2447 viewerPermission: Schema.optional(Schema.NullOr(Schema.String)),
2594 ...RawRepositoryAccessSchema.fields,
24482595 /** Null for a number that names no pull request the viewer can see. */
24492596 pullRequest: Schema.NullOr(RawViewerFieldsSchema),
24502597 }),
......@@ -2455,13 +2602,18 @@ const decodeViewerPermissions = decodeJsonResult(RawViewerPermissionsSchema);
24552602
24562603export function decodeViewerPermissionsJson(
24572604 raw: string,
2458): Result.Result<GitHubViewerAccess, DecodeFailure> {
2605): Result.Result<GitHubViewerAccess & GitHubRepositoryAccess, DecodeFailure> {
24592606 const decoded = decodeViewerPermissions(raw);
24602607 if (!Result.isSuccess(decoded)) {
24612608 return Result.fail(decoded.failure);
24622609 }
24632610 const repository = decoded.success.data.repository;
24642611 return Result.succeed({
2612 mergeCapabilities: {
2613 merge: repository.mergeCommitAllowed,
2614 squash: repository.squashMergeAllowed,
2615 rebase: repository.rebaseMergeAllowed,
2616 },
24652617 canWrite: toCanWrite(repository.viewerPermission),
24662618 canTriage: toCanTriage(repository.viewerPermission),
24672619 ...toPullRequestViewerFields(repository.pullRequest),
apps/server/src/ws.ts+6
......@@ -2729,6 +2729,12 @@ const makeWsRpcLayer = (
27292729 "rpc.aggregate": "pull-requests",
27302730 },
27312731 ),
2732 [WS_METHODS.pullRequestsPreview]: (input) =>
2733 observeRpcEffect(
2734 WS_METHODS.pullRequestsPreview,
2735 withPullRequestViewer(input, pullRequests.preview(input)),
2736 { "rpc.aggregate": "pull-requests" },
2737 ),
27322738 [WS_METHODS.pullRequestsActivity]: (input) =>
27332739 observeRpcEffect(
27342740 WS_METHODS.pullRequestsActivity,
apps/web/src/components/ChatMarkdown.tsx+1
......@@ -2905,6 +2905,7 @@ const CHAT_MARKDOWN_COMPONENTS = {
29052905 environmentId,
29062906 input: {
29072907 projectId: pullRequestProject.id,
2908 host: pullRequestCandidate.authority ?? pullRequestCandidate.host,
29082909 repository:
29092910 sourceControlRepositorySelector(pullRequestProject.repositoryIdentity) ??
29102911 pullRequestCandidate.repository,
apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx+32-31
......@@ -42,11 +42,10 @@ export function PullRequestLinkPreview({
4242 })
4343 : null,
4444 );
45 const readDetail = useAtomQueryRunner(pullRequestEnvironment.detail, {
45 const readPreview = useAtomQueryRunner(pullRequestEnvironment.preview, {
4646 reportFailure: false,
4747 reportDefect: false,
4848 });
49
5049 const trigger = confirmBeforeOpen
5150 ? cloneElement(link, {
5251 onClick: (event) => {
......@@ -56,7 +55,7 @@ export function PullRequestLinkPreview({
5655 if (resolvingClick) return;
5756 setOpen(false);
5857 setResolvingClick(true);
59 void readDetail({ environmentId: target.environmentId, input: target.input })
58 void readPreview(target)
6059 .then(async (result) => {
6160 if (isAtomCommandInterrupted(result)) return;
6261 if (result._tag === "Success" && onOpenPullRequest(result.value.url)) return;
......@@ -84,36 +83,38 @@ export function PullRequestLinkPreview({
8483 return (
8584 <PreviewCard open={open} onOpenChange={setOpen}>
8685 <PreviewCardTrigger render={trigger} delay={350} closeDelay={120} />
87 <PreviewCardPopup align="center" className="w-80 max-w-[calc(100vw-2rem)] p-3">
88 {detail === null ? (
89 <p className="text-xs leading-relaxed text-muted-foreground wrap-anywhere">
90 {detailQuery.isPending ? "Loading pull request details…" : originalUrl}
91 </p>
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}
86 {detail !== null || detailQuery.error !== null ? (
87 <PreviewCardPopup align="center" className="w-80 max-w-[calc(100vw-2rem)] p-3">
88 {detail === null ? (
89 <p className="text-xs leading-relaxed text-muted-foreground wrap-anywhere">
90 {originalUrl}
10791 </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>
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>
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>
113114 </div>
114 </div>
115 )}
116 </PreviewCardPopup>
115 )}
116 </PreviewCardPopup>
117 ) : null}
117118 </PreviewCard>
118119 );
119120}
knip.jsonc+1
......@@ -23,6 +23,7 @@
2323 "src/claude-history-worker.ts!",
2424 "scripts/cli.ts",
2525 "scripts/evaluate-thread-titles.ts",
26 "scripts/measure-pr-preview.ts",
2627 "src/provider/testFixtures/*.mjs",
2728 ],
2829 // 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>([
2929 WS_METHODS.pullRequestsSummary,
3030 WS_METHODS.pullRequestsStack,
3131 WS_METHODS.pullRequestsDetail,
32 WS_METHODS.pullRequestsPreview,
3233 WS_METHODS.pullRequestsActivity,
3334 WS_METHODS.pullRequestsThreadComments,
3435 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", () =>
887887 ),
888888);
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
890968it.effect("shares close, reopen, and merge with an untouched client's mounted PR readers", () =>
891969 Effect.scoped(
892970 Effect.gen(function* () {
packages/client-runtime/src/state/pullRequests.ts+18
......@@ -176,6 +176,15 @@ export function createPullRequestEnvironmentAtoms<R, E>(
176176 refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }),
177177 }),
178178 );
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 );
179188 const labelCandidates = writableQueryFamily(
180189 createEnvironmentRpcQueryAtomFamily(runtime, {
181190 label: "environment-data:pull-requests:label-candidates",
......@@ -221,6 +230,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(
221230 refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }),
222231 }),
223232 detail,
233 preview,
224234 activity,
225235 threadComments: createEnvironmentRpcCommand(runtime, {
226236 label: "environment-data:pull-requests:thread-comments",
......@@ -295,6 +305,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(
295305 label: "environment-data:pull-requests:run-action",
296306 tag: WS_METHODS.pullRequestsRunAction,
297307 execute: (input) => routedRequest(WS_METHODS.pullRequestsRunAction, input),
308 onSuccess: (target, registry) => Effect.sync(() => registry.refresh(preview(target))),
298309 scheduler: commandScheduler,
299310 concurrency: serialPerEnvironment,
300311 }),
......@@ -302,6 +313,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(
302313 label: "environment-data:pull-requests:update",
303314 tag: WS_METHODS.pullRequestsUpdate,
304315 execute: (input) => routedRequest(WS_METHODS.pullRequestsUpdate, input),
316 onSuccess: (target, registry) => Effect.sync(() => registry.refresh(preview(target))),
305317 scheduler: commandScheduler,
306318 concurrency: serialPerEnvironment,
307319 }),
......@@ -485,6 +497,12 @@ export function createPullRequestEnvironmentAtoms<R, E>(
485497 label: "environment-data:pull-requests:invalidate",
486498 tag: WS_METHODS.pullRequestsInvalidate,
487499 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 }),
488506 scheduler: commandScheduler,
489507 concurrency: serialPerEnvironment,
490508 }),
packages/contracts/src/pullRequest.ts+14
......@@ -703,6 +703,20 @@ export const PullRequestLinkedThreadsResult = Schema.Struct({
703703});
704704export 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
706720/**
707721 * The small live shape a linked thread needs. Keeping it separate from detail means a sidebar
708722 * status check never loads permissions, repository settings, checks, or base comparison data.
packages/contracts/src/rpc.ts+9
......@@ -111,6 +111,7 @@ import {
111111 PullRequestCommentInput,
112112 PullRequestCommentUpdateInput,
113113 PullRequestDetail,
114 PullRequestPreview,
114115 PullRequestDiffFileContentsInput,
115116 PullRequestDiffFileContentsResult,
116117 PullRequestFilesViewedResult,
......@@ -395,6 +396,7 @@ export const WS_METHODS = {
395396 pullRequestsStack: "pullRequests.stack",
396397 pullRequestsLinkedThreads: "pullRequests.linkedThreads",
397398 pullRequestsDetail: "pullRequests.detail",
399 pullRequestsPreview: "pullRequests.preview",
398400 pullRequestsActivity: "pullRequests.activity",
399401 pullRequestsThreadComments: "pullRequests.threadComments",
400402 pullRequestsDiffFileContents: "pullRequests.diffFileContents",
......@@ -737,6 +739,12 @@ const WsPullRequestsDetailRpc = Rpc.make(WS_METHODS.pullRequestsDetail, {
737739 error: PullRequestRpcError,
738740});
739741
742const WsPullRequestsPreviewRpc = Rpc.make(WS_METHODS.pullRequestsPreview, {
743 payload: PullRequestRef,
744 success: PullRequestPreview,
745 error: PullRequestRpcError,
746});
747
740748const WsPullRequestsActivityRpc = Rpc.make(WS_METHODS.pullRequestsActivity, {
741749 payload: PullRequestRef,
742750 success: PullRequestActivity,
......@@ -1418,6 +1426,7 @@ export const WsRpcGroup = RpcGroup.make(
14181426 WsPullRequestsStackRpc,
14191427 WsPullRequestsLinkedThreadsRpc,
14201428 WsPullRequestsDetailRpc,
1429 WsPullRequestsPreviewRpc,
14211430 WsPullRequestsActivityRpc,
14221431 WsPullRequestsThreadCommentsRpc,
14231432 WsPullRequestsDiffFileContentsRpc,