authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-02 03:56:32-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-02 03:56:32-04:00
logdd6879ffea24da9fc16cb62d7d6072d2c5b2bae3
tree1264368ef88f2065ad4dc42bca7703d4a8f8aacd
parent9e646ad84c9d5008187eabf479e85619df9de8ca
signature Signed by PGP key B5690EEEBB952194

fix(pull-requests): reuse github api reads (#9176)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

25 files changed, 868 insertions(+), 187 deletions(-)

apps/mobile/src/state/use-thread-pr.ts+2-2
......@@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react";
22import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
33import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
44import {
5 createLinkedPullRequestDetailAtomFamily,
5 createLinkedPullRequestSummaryAtomFamily,
66 pullRequestDetailToVcsStatus,
77} from "@t3tools/client-runtime/state/pull-requests";
88import { Atom } from "effect/unstable/reactivity";
......@@ -14,7 +14,7 @@ import { useEnvironmentQuery } from "./query";
1414import { presentThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation";
1515import { vcsEnvironment } from "./vcs";
1616
17const linkedPullRequestDetailAtom = createLinkedPullRequestDetailAtomFamily(connectionAtomRuntime);
17const linkedPullRequestDetailAtom = createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime);
1818const MAX_THREAD_PR_SNAPSHOTS = 500;
1919
2020interface ThreadPrSnapshot {
apps/server/src/auth/RpcAuthorization.ts+1
......@@ -55,6 +55,7 @@ export const RPC_REQUIRED_SCOPES = {
5555 [WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope,
5656 [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope,
5757 [WS_METHODS.pullRequestsListStats]: AuthOrchestrationReadScope,
58 [WS_METHODS.pullRequestsSummary]: AuthOrchestrationReadScope,
5859 [WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope,
5960 [WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope,
6061 [WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope,
apps/server/src/orchestration/ThreadSettlementReactor.test.ts+35-59
......@@ -8,7 +8,7 @@ import {
88 type OrchestrationProjectShell,
99 type OrchestrationShellSnapshot,
1010 type OrchestrationThreadShell,
11 type PullRequestDetail,
11 type PullRequestSummary,
1212 type ServerSettings,
1313 type ServerSettingsPatch,
1414} from "@t3tools/contracts";
......@@ -105,56 +105,24 @@ function makeSnapshot(
105105 };
106106}
107107
108function makePullRequestDetail(input: {
108function makePullRequestSummary(input: {
109109 readonly projectId: ProjectId;
110110 readonly repository: string;
111111 readonly number: number;
112112 readonly state: "open" | "closed" | "merged";
113113 readonly updatedAt?: string;
114}): PullRequestDetail {
114}): PullRequestSummary {
115115 return {
116116 provider: "github",
117 capabilities: {
118 diff: true,
119 comment: true,
120 actions: [],
121 mergeMethods: [],
122 search: true,
123 review: { inlineComment: true, reply: true, resolve: true, verdicts: [] },
124 reviewers: { request: true, listCandidates: true },
125 },
126 viewerPermissions: {
127 actions: [],
128 comment: true,
129 resolve: true,
130 verdicts: [],
131 requestReviewers: true,
132 },
133117 projectId: input.projectId,
134 projectTitle: "Linked project",
135 workspaceRoot: "/workspace/linked",
136118 repository: input.repository,
137119 number: input.number,
138120 title: "Pull request",
139 body: "",
140121 url: `https://example.test/${input.repository}/pull/${input.number}`,
141 author: null,
142122 state: input.state,
143 isDraft: false,
144 mergeability: "mergeable",
145 additions: 0,
146 deletions: 0,
147 changedFiles: 0,
148123 headBranch: "feature",
149124 baseBranch: "main",
150 createdAt: "2026-08-01T00:00:00.000Z",
151125 updatedAt: input.updatedAt ?? NOW,
152 mergedAt: input.state === "merged" ? (input.updatedAt ?? NOW) : null,
153 closedAt: input.state === "closed" ? (input.updatedAt ?? NOW) : null,
154 reviewers: [],
155 labels: [],
156 checks: [],
157 mergeCapabilities: { merge: true, squash: true, rebase: true },
158126 };
159127}
160128
......@@ -162,7 +130,7 @@ interface HarnessOptions {
162130 readonly snapshot: OrchestrationShellSnapshot;
163131 readonly settings?: ServerSettings;
164132 readonly branchPullRequest?: GitManager["Service"]["branchPullRequest"];
165 readonly pullRequestDetail?: PullRequestService["Service"]["detail"];
133 readonly pullRequestSummary?: PullRequestService["Service"]["summary"];
166134 readonly onDispatch?: (
167135 command: AutoSettleCommand,
168136 ) => Effect.Effect<void, OrchestrationCommandInvariantError>;
......@@ -179,13 +147,14 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
179147 const branchCalls = yield* Ref.make<
180148 ReadonlyArray<{ readonly cwd: string; readonly branch: string }>
181149 >([]);
182 const detailCalls = yield* Ref.make<
150 const summaryCalls = yield* Ref.make<
183151 ReadonlyArray<{
184152 readonly projectId: ProjectId;
185153 readonly repository: string;
186154 readonly number: number;
187155 }>
188156 >([]);
157 const summaryRecovery = yield* Ref.make<ReadonlyArray<boolean | undefined>>([]);
189158
190159 const updateSettings = (patch: ServerSettingsPatch) =>
191160 Effect.gen(function* () {
......@@ -200,18 +169,23 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
200169 Effect.andThen(options.branchPullRequest?.(input) ?? Effect.succeed(null)),
201170 );
202171
203 const pullRequestDetail: PullRequestService["Service"]["detail"] = (input) =>
204 Ref.update(detailCalls, (calls) => [...calls, input]).pipe(
205 Effect.andThen(
206 options.pullRequestDetail?.(input) ??
172 const pullRequestSummary: PullRequestService["Service"]["summary"] = (input, readOptions) =>
173 Effect.gen(function* () {
174 yield* Ref.update(summaryCalls, (calls) => [...calls, input]);
175 yield* Ref.update(summaryRecovery, (values) => [
176 ...values,
177 readOptions?.recoverTransientFailure,
178 ]);
179 return yield* (
180 options.pullRequestSummary?.(input, readOptions) ??
207181 Effect.succeed(
208 makePullRequestDetail({
182 makePullRequestSummary({
209183 ...input,
210184 state: "open",
211185 }),
212 ),
213 ),
214 );
186 )
187 );
188 });
215189
216190 const dispatch: OrchestrationEngineShape["dispatch"] = (command) => {
217191 if (command.type !== "thread.auto-settle") {
......@@ -243,7 +217,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
243217 ),
244218 }),
245219 Layer.mock(GitManager)({ branchPullRequest }),
246 Layer.mock(PullRequestService)({ detail: pullRequestDetail }),
220 Layer.mock(PullRequestService)({ summary: pullRequestSummary }),
247221 Layer.mock(OrchestrationEngineService)({
248222 readEvents: () => Stream.empty,
249223 dispatch,
......@@ -262,7 +236,8 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
262236 snapshotReads,
263237 commands,
264238 branchCalls,
265 detailCalls,
239 summaryCalls,
240 summaryRecovery,
266241 updateSettings,
267242 layer: ThreadSettlementReactor.layer.pipe(Layer.provide(dependencies)),
268243 };
......@@ -310,8 +285,8 @@ describe("ThreadSettlementReactor", () => {
310285 [makeProject(), makeProject(LINKED_PROJECT_ID, "/workspace/linked")],
311286 ),
312287 branchPullRequest: () => Effect.succeed(null),
313 pullRequestDetail: (input) =>
314 Effect.succeed(makePullRequestDetail({ ...input, state: "closed" })),
288 pullRequestSummary: (input) =>
289 Effect.succeed(makePullRequestSummary({ ...input, state: "closed" })),
315290 });
316291
317292 yield* Effect.gen(function* () {
......@@ -342,9 +317,10 @@ describe("ThreadSettlementReactor", () => {
342317 assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), [
343318 { cwd: "/workspace/project", branch: "inactive-feature" },
344319 ]);
345 assert.deepStrictEqual(yield* Ref.get(fixture.detailCalls), [
320 assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), [
346321 { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 42 },
347322 ]);
323 assert.deepStrictEqual(yield* Ref.get(fixture.summaryRecovery), [false]);
348324 }).pipe(Effect.provide(fixture.layer));
349325 }),
350326 ),
......@@ -480,10 +456,10 @@ describe("ThreadSettlementReactor", () => {
480456 ],
481457 [makeProject(), makeProject(LINKED_PROJECT_ID, "/workspace/linked")],
482458 ),
483 pullRequestDetail: () =>
459 pullRequestSummary: () =>
484460 Effect.fail(
485461 new PullRequestOperationError({
486 operation: "detail",
462 operation: "summary",
487463 detail: "host unavailable",
488464 }),
489465 ),
......@@ -497,7 +473,7 @@ describe("ThreadSettlementReactor", () => {
497473 (yield* Ref.get(fixture.commands)).map((command) => command.threadId),
498474 [ThreadId.make("inactive-without-pr")],
499475 );
500 assert.strictEqual((yield* Ref.get(fixture.detailCalls)).length, 1);
476 assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1);
501477 }).pipe(Effect.provide(fixture.layer));
502478 }),
503479 ),
......@@ -521,8 +497,8 @@ describe("ThreadSettlementReactor", () => {
521497 ],
522498 [makeProject(LINKED_PROJECT_ID, "/workspace/linked")],
523499 ),
524 pullRequestDetail: (input) =>
525 Effect.succeed(makePullRequestDetail({ ...input, state: "open" })),
500 pullRequestSummary: (input) =>
501 Effect.succeed(makePullRequestSummary({ ...input, state: "open" })),
526502 });
527503
528504 yield* Effect.gen(function* () {
......@@ -530,7 +506,7 @@ describe("ThreadSettlementReactor", () => {
530506 yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
531507
532508 assert.deepStrictEqual(yield* Ref.get(fixture.commands), []);
533 assert.deepStrictEqual(yield* Ref.get(fixture.detailCalls), [
509 assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), [
534510 { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 10 },
535511 ]);
536512 assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), []);
......@@ -569,8 +545,8 @@ describe("ThreadSettlementReactor", () => {
569545 ],
570546 ),
571547 branchPullRequest: () => Effect.succeed({ state: "closed", updatedAt: NOW }),
572 pullRequestDetail: (input) =>
573 Effect.succeed(makePullRequestDetail({ ...input, state: "merged" })),
548 pullRequestSummary: (input) =>
549 Effect.succeed(makePullRequestSummary({ ...input, state: "merged" })),
574550 });
575551
576552 yield* Effect.gen(function* () {
......@@ -580,7 +556,7 @@ describe("ThreadSettlementReactor", () => {
580556 assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), [
581557 { cwd: "/workspace/project-root", branch: "saved-feature" },
582558 ]);
583 assert.deepStrictEqual(yield* Ref.get(fixture.detailCalls), [
559 assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), [
584560 { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 77 },
585561 ]);
586562 assert.deepStrictEqual(
apps/server/src/orchestration/ThreadSettlementReactor.ts+12-6
......@@ -69,12 +69,18 @@ export const make = Effect.gen(function* () {
6969 if (!projects.has(thread.linkedPullRequest.projectId)) {
7070 return yield* Effect.die(new Error("linked pull request project not found"));
7171 }
72 const detail = yield* pullRequests.detail({
73 projectId: thread.linkedPullRequest.projectId,
74 repository: thread.linkedPullRequest.repository,
75 number: thread.linkedPullRequest.number,
76 });
77 return { state: detail.state, updatedAt: detail.updatedAt } satisfies SettlementPullRequest;
72 const summary = yield* pullRequests.summary(
73 {
74 projectId: thread.linkedPullRequest.projectId,
75 repository: thread.linkedPullRequest.repository,
76 number: thread.linkedPullRequest.number,
77 },
78 { recoverTransientFailure: false },
79 );
80 return {
81 state: summary.state,
82 updatedAt: summary.updatedAt,
83 } satisfies SettlementPullRequest;
7884 }
7985 if (thread.branch === null) return null;
8086 const project = projects.get(thread.projectId);
apps/server/src/pullRequest/GitHubPullRequestCli.test.ts+43
......@@ -10,12 +10,14 @@ import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";
1010import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts";
1111
1212const mockedExecute = vi.fn<GitHubCli.GitHubCli["Service"]["execute"]>();
13const mockedGetPullRequest = vi.fn<GitHubCli.GitHubCli["Service"]["getPullRequest"]>();
1314
1415const layer = it.layer(
1516 GitHubPullRequestCli.layer.pipe(
1617 Layer.provide(
1718 Layer.mock(GitHubCli.GitHubCli)({
1819 execute: mockedExecute,
20 getPullRequest: mockedGetPullRequest,
1921 }),
2022 ),
2123 Layer.provide(GitHubGraphQlBudget.layer),
......@@ -177,9 +179,50 @@ function searchQueryOfCall(index: number): string | undefined {
177179
178180afterEach(() => {
179181 mockedExecute.mockReset();
182 mockedGetPullRequest.mockReset();
180183});
181184
182185layer("GitHubPullRequestCli.layer", (it) => {
186 it.effect("reads linked pull request status through one narrow request", () =>
187 Effect.gen(function* () {
188 mockedGetPullRequest.mockReturnValueOnce(
189 Effect.succeed({
190 number: 7,
191 title: "Reuse the summary",
192 url: "https://github.com/acme/web/pull/7",
193 baseRefName: "main",
194 headRefName: "feat/summary",
195 state: "open",
196 updatedAt: "2026-08-24T12:34:56.000Z",
197 }),
198 );
199 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
200
201 const summary = yield* cli.getPullRequestSummary({
202 cwd: "/w",
203 repository: "acme/web",
204 host: "github.com",
205 number: 7,
206 });
207
208 assert.deepStrictEqual(summary, {
209 number: 7,
210 title: "Reuse the summary",
211 url: "https://github.com/acme/web/pull/7",
212 headBranch: "feat/summary",
213 baseBranch: "main",
214 state: "open",
215 updatedAt: "2026-08-24T12:34:56.000Z",
216 });
217 expect(mockedGetPullRequest).toHaveBeenCalledOnce();
218 expect(mockedGetPullRequest).toHaveBeenCalledWith({
219 cwd: "/w",
220 reference: "https://github.com/acme/web/pull/7",
221 });
222 expect(mockedExecute).not.toHaveBeenCalled();
223 }),
224 );
225
183226 it.effect("asks for one row more than the page, to probe for a next page", () =>
184227 Effect.gen(function* () {
185228 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1))));
apps/server/src/pullRequest/GitHubPullRequestCli.ts+68-1
......@@ -123,6 +123,25 @@ export class GitHubViewerLoginUnavailableError extends Schema.TaggedErrorClass<G
123123 }
124124}
125125
126/** Not a decode failure: gh answered, but the pull request carried no update time. */
127export class GitHubPullRequestUpdatedAtUnavailableError extends Schema.TaggedErrorClass<GitHubPullRequestUpdatedAtUnavailableError>()(
128 "GitHubPullRequestUpdatedAtUnavailableError",
129 {
130 command: Schema.Literal("gh"),
131 cwd: Schema.String,
132 repository: Schema.String,
133 number: Schema.Int,
134 },
135) {
136 get detail(): string {
137 return `Pull request ${this.repository}#${this.number} reported no update time.`;
138 }
139
140 override get message(): string {
141 return `GitHub CLI failed in getPullRequestSummary: ${this.detail}`;
142 }
143}
144
126145/** Not a decode failure: the reader asked to carry on from a cursor this walk never handed out. */
127146export class GitHubDiffCursorError extends Schema.TaggedErrorClass<GitHubDiffCursorError>()(
128147 "GitHubDiffCursorError",
......@@ -250,7 +269,8 @@ export type GitHubPullRequestCliError =
250269 | GitHubRepositorySelectorError
251270 | GitHubSubjectScopeError
252271 | SourceControlRateLimit.SourceControlRateLimitPausedError
253 | GitHubViewerLoginUnavailableError;
272 | GitHubViewerLoginUnavailableError
273 | GitHubPullRequestUpdatedAtUnavailableError;
254274
255275/** A large pull request can produce a multi-megabyte patch; past this it is truncated. */
256276const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
......@@ -360,6 +380,24 @@ export class GitHubPullRequestCli extends Context.Service<
360380 }>;
361381 }) => Effect.Effect<ReadonlyArray<GitHubPullRequestStat>, GitHubPullRequestCliError>;
362382
383 readonly getPullRequestSummary: (input: {
384 readonly cwd: string;
385 readonly repository: string;
386 readonly host: string;
387 readonly number: number;
388 }) => Effect.Effect<
389 {
390 readonly number: number;
391 readonly title: string;
392 readonly url: string;
393 readonly headBranch: string;
394 readonly baseBranch: string;
395 readonly state: "open" | "closed" | "merged";
396 readonly updatedAt: string;
397 },
398 GitHubPullRequestCliError
399 >;
400
363401 readonly getPullRequestDetail: (input: {
364402 readonly cwd: string;
365403 readonly repository: string;
......@@ -1326,6 +1364,35 @@ export const make = Effect.gen(function* () {
13261364 ).pipe(Effect.map((results) => results.flat()));
13271365 },
13281366
1367 getPullRequestSummary: (input) =>
1368 github
1369 .getPullRequest({
1370 cwd: input.cwd,
1371 reference: `https://${input.host}/${input.repository}/pull/${input.number}`,
1372 })
1373 .pipe(
1374 Effect.flatMap((summary) =>
1375 summary.updatedAt === undefined
1376 ? Effect.fail(
1377 new GitHubPullRequestUpdatedAtUnavailableError({
1378 command: "gh",
1379 cwd: input.cwd,
1380 repository: input.repository,
1381 number: input.number,
1382 }),
1383 )
1384 : Effect.succeed({
1385 number: summary.number,
1386 title: summary.title,
1387 url: summary.url,
1388 headBranch: summary.headRefName,
1389 baseBranch: summary.baseRefName,
1390 state: summary.state ?? "open",
1391 updatedAt: summary.updatedAt,
1392 }),
1393 ),
1394 ),
1395
13291396 getPullRequestDetail: (input) =>
13301397 github
13311398 .execute({
apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts+37
......@@ -7,6 +7,43 @@ import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";
77import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts";
88import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts";
99
10it.effect("uses one narrow read for a linked pull request summary", () =>
11 Effect.gen(function* () {
12 let summaryReads = 0;
13 const provider = yield* make.pipe(
14 Effect.provide(
15 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
16 getPullRequestSummary: () =>
17 Effect.sync(() => {
18 summaryReads += 1;
19 return {
20 number: 7,
21 title: "Summary",
22 url: "https://github.com/acme/web/pull/7",
23 headBranch: "feat/summary",
24 baseBranch: "main",
25 state: "open" as const,
26 updatedAt: "2026-08-24T12:34:56.000Z",
27 };
28 }),
29 }),
30 ),
31 );
32
33 const readSummary = provider.getChangeRequestSummary;
34 if (readSummary === undefined) return yield* Effect.die("summary read was not implemented");
35 const summary = yield* readSummary({
36 cwd: "/w",
37 repository: "acme/web",
38 host: "github.com",
39 number: 7,
40 });
41
42 expect(summary.state).toBe("open");
43 expect(summaryReads).toBe(1);
44 }),
45);
46
1047describe("gitHubViewerPermissions", () => {
1148 it("offers everything to a viewer who can write to the repository", () => {
1249 expect(gitHubViewerPermissions({ canWrite: true, canUpdate: true, didAuthor: false })).toEqual({
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+23-1
......@@ -1,4 +1,7 @@
1import * as Cache from "effect/Cache";
2import * as Duration from "effect/Duration";
13import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";
25import type {
36 PullRequestActor,
47 PullRequestCapabilities,
......@@ -129,6 +132,22 @@ const rendersEmpty = (body: string): boolean =>
129132export const make = Effect.gen(function* () {
130133 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
131134
135 const repositoryAccessCache = yield* Cache.makeWith(
136 (key: string) => {
137 const [cwd, repository, host] = JSON.parse(key) as [string, string, string];
138 return cli.getRepositoryAccess({ cwd, repository, host });
139 },
140 {
141 capacity: 128,
142 timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.minutes(10) : Duration.zero),
143 },
144 );
145 const getRepositoryAccess = (input: {
146 readonly cwd: string;
147 readonly repository: string;
148 readonly host: string;
149 }) => Cache.get(repositoryAccessCache, JSON.stringify([input.cwd, input.repository, input.host]));
150
132151 const fail = (operation: string) => (error: GitHubPullRequestCli.GitHubPullRequestCliError) =>
133152 new PullRequestProviderError({
134153 provider: "github",
......@@ -223,6 +242,9 @@ export const make = Effect.gen(function* () {
223242 })
224243 .pipe(Effect.mapError(fail("listChangeRequestStats"))),
225244
245 getChangeRequestSummary: (input) =>
246 cli.getPullRequestSummary(input).pipe(Effect.mapError(fail("getChangeRequestSummary"))),
247
226248 getChangeRequest: (input) =>
227249 Effect.all(
228250 [
......@@ -244,7 +266,7 @@ export const make = Effect.gen(function* () {
244266 ),
245267 ),
246268 ),
247 cli.getRepositoryAccess({
269 getRepositoryAccess({
248270 cwd: input.cwd,
249271 repository: input.repository,
250272 host: input.host,
apps/server/src/pullRequest/PullRequestProvider.ts+19
......@@ -87,6 +87,17 @@ export interface ProviderChangeRequest {
8787 readonly checksState?: PullRequestChecksState | null | undefined;
8888}
8989
90/** The fields needed to keep a linked thread's pull request status live. */
91export interface ProviderChangeRequestSummary {
92 readonly number: number;
93 readonly title: string;
94 readonly url: string;
95 readonly headBranch: string;
96 readonly baseBranch: string;
97 readonly state: PullRequestState;
98 readonly updatedAt: string;
99}
100
90101export interface ProviderChangeRequestPage {
91102 readonly items: ReadonlyArray<ProviderChangeRequest>;
92103 /** True when the host has more rows than the page size asked for. */
......@@ -301,6 +312,14 @@ export interface PullRequestProviderApi {
301312 input: ProviderRepositoryRef & { readonly number: number },
302313 ) => Effect.Effect<ProviderChangeRequestDetail, PullRequestProviderError>;
303314
315 /**
316 * The cheap live fields used by linked threads. Optional because a provider without a narrow
317 * endpoint can fall back to its full detail read at the service boundary.
318 */
319 readonly getChangeRequestSummary?: (
320 input: ProviderRepositoryRef & { readonly number: number },
321 ) => Effect.Effect<ProviderChangeRequestSummary, PullRequestProviderError>;
322
304323 /** Comments, line threads, and commits, kept off the critical path for the core detail. */
305324 readonly getChangeRequestActivity: (
306325 input: ProviderRepositoryRef & { readonly number: number },
apps/server/src/pullRequest/PullRequestService.test.ts+214
......@@ -2293,6 +2293,105 @@ it.effect("answers a repeated listing from cache, and concurrent readers share o
22932293 }),
22942294);
22952295
2296it.effect("shares one cold viewer lookup across distinct concurrent lists", () =>
2297 Effect.gen(function* () {
2298 let viewerCalls = 0;
2299 let listCalls = 0;
2300 const service = yield* makeService({
2301 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
2302 providers: [
2303 fakeProvider("github", {
2304 getViewer: () =>
2305 Effect.sync(() => {
2306 viewerCalls += 1;
2307 }).pipe(Effect.andThen(Effect.yieldNow), Effect.as("bilal")),
2308 listChangeRequests: () =>
2309 Effect.sync(() => {
2310 listCalls += 1;
2311 return { items: [], truncated: false, continues: true };
2312 }),
2313 }),
2314 ],
2315 });
2316
2317 yield* Effect.all(
2318 ["all", "authored", "reviewing"].map((involvement) =>
2319 service.list({
2320 state: "open",
2321 involvement: involvement as "all" | "authored" | "reviewing",
2322 }),
2323 ),
2324 { concurrency: "unbounded" },
2325 );
2326
2327 assert.strictEqual(viewerCalls, 1);
2328 assert.strictEqual(listCalls, 3);
2329 }),
2330);
2331
2332it.effect("uses five host reads for the normal indexed-repository page workflow", () =>
2333 Effect.gen(function* () {
2334 let viewerCalls = 0;
2335 let searchCalls = 0;
2336 let fallbackCalls = 0;
2337 let statsCalls = 0;
2338 const service = yield* makeService({
2339 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
2340 providers: [
2341 fakeProvider("github", {
2342 getViewer: () =>
2343 Effect.sync(() => {
2344 viewerCalls += 1;
2345 return "bilal";
2346 }),
2347 listChangeRequestsAcross: (input) =>
2348 Effect.sync(() => {
2349 searchCalls += 1;
2350 return {
2351 items:
2352 input.involvement === "all"
2353 ? [batchedChangeRequest(1, "acme/web", "2026-07-02T00:00:00Z")]
2354 : [],
2355 truncated: false,
2356 };
2357 }),
2358 listChangeRequests: () =>
2359 Effect.sync(() => {
2360 fallbackCalls += 1;
2361 return { items: [], truncated: false, continues: true };
2362 }),
2363 listChangeRequestStats: () =>
2364 Effect.sync(() => {
2365 statsCalls += 1;
2366 return [{ repository: "acme/web", number: 1, additions: 3, deletions: 1 }];
2367 }),
2368 }),
2369 ],
2370 });
2371
2372 const baseline = yield* service.list({ state: "open", involvement: "all" });
2373 yield* Effect.all(
2374 [
2375 service.list({ state: "open", involvement: "authored" }),
2376 service.list({ state: "open", involvement: "reviewing" }),
2377 ],
2378 { concurrency: "unbounded" },
2379 );
2380 yield* service.listStats({
2381 refs: baseline.entries.map(({ projectId, repository, number }) => ({
2382 projectId,
2383 repository,
2384 number,
2385 })),
2386 });
2387
2388 assert.deepStrictEqual(
2389 { viewerCalls, searchCalls, fallbackCalls, statsCalls },
2390 { viewerCalls: 1, searchCalls: 3, fallbackCalls: 0, statsCalls: 1 },
2391 );
2392 }),
2393);
2394
22962395it.effect("returns the refreshed listing on the first read after its cache expires", () =>
22972396 Effect.gen(function* () {
22982397 let hostCalls = 0;
......@@ -2371,10 +2470,15 @@ it.effect("a listing narrowed to some projects is its own cache entry", () =>
23712470it.effect("an explicit invalidation makes the next listing ask the host again", () =>
23722471 Effect.gen(function* () {
23732472 let hostCalls = 0;
2473 let viewerCalls = 0;
23742474 const service = yield* makeService({
23752475 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
23762476 providers: [
23772477 fakeProvider("github", {
2478 getViewer: () => {
2479 viewerCalls += 1;
2480 return Effect.succeed("bilal");
2481 },
23782482 listChangeRequests: () => {
23792483 hostCalls += 1;
23802484 return Effect.succeed({ items: [], truncated: false, continues: false });
......@@ -2387,6 +2491,7 @@ it.effect("an explicit invalidation makes the next listing ask the host again",
23872491 yield* service.invalidate({});
23882492 yield* service.list({ state: "open" });
23892493 assert.strictEqual(hostCalls, 2);
2494 assert.strictEqual(viewerCalls, 2);
23902495
23912496 // Forgetting one change request leaves the listings shared.
23922497 yield* service.invalidate({
......@@ -2829,6 +2934,115 @@ it.effect(
28292934 }),
28302935);
28312936
2937it.effect("shares linked summaries and only recovers transient failures for display reads", () =>
2938 Effect.gen(function* () {
2939 let calls = 0;
2940 let failing = false;
2941 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
2942 const service = yield* makeService({
2943 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
2944 providers: [
2945 fakeProvider("github", {
2946 getChangeRequestSummary: () =>
2947 Effect.sync(() => {
2948 calls += 1;
2949 return failing;
2950 }).pipe(
2951 Effect.tap(() => Effect.yieldNow),
2952 Effect.flatMap((shouldFail) =>
2953 shouldFail
2954 ? Effect.fail(
2955 new PullRequestProviderError({
2956 provider: "github",
2957 operation: "getChangeRequestSummary",
2958 reason: "failed",
2959 detail: "HTTP 504",
2960 }),
2961 )
2962 : Effect.succeed(changeRequest(1, "2026-07-02T00:00:00Z")),
2963 ),
2964 ),
2965 }),
2966 ],
2967 });
2968
2969 yield* Effect.all(
2970 [
2971 service.summary(reference, { recoverTransientFailure: false }),
2972 service.summary(reference, { recoverTransientFailure: false }),
2973 ],
2974 { concurrency: "unbounded" },
2975 );
2976 assert.strictEqual(calls, 1);
2977
2978 yield* TestClock.adjust("61 seconds");
2979 failing = true;
2980 const strict = yield* Effect.flip(
2981 service.summary(reference, { recoverTransientFailure: false }),
2982 );
2983 assert.strictEqual(strict._tag, "PullRequestOperationError");
2984
2985 const stale = yield* service.summary(reference);
2986 assert.strictEqual(stale.updatedAt, "2026-07-02T00:00:00Z");
2987 assert.strictEqual(calls, 3);
2988
2989 yield* service.invalidate({ reference });
2990 const invalidated = yield* Effect.flip(service.summary(reference));
2991 assert.strictEqual(invalidated._tag, "PullRequestOperationError");
2992 }),
2993);
2994
2995it.effect("keeps recent detail on a transient refresh failure but not after invalidation", () =>
2996 Effect.gen(function* () {
2997 let failing = false;
2998 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
2999 const service = yield* makeService({
3000 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
3001 providers: [
3002 fakeProvider("github", {
3003 getChangeRequest: () =>
3004 failing
3005 ? Effect.fail(
3006 new PullRequestProviderError({
3007 provider: "github",
3008 operation: "getChangeRequest",
3009 reason: "failed",
3010 detail: "spawn gh EAGAIN",
3011 }),
3012 )
3013 : Effect.succeed({
3014 ...changeRequest(1, "2026-07-02T00:00:00Z"),
3015 body: "last good body",
3016 changedFiles: 2,
3017 mergedAt: null,
3018 closedAt: null,
3019 reviewers: [],
3020 checks: [],
3021 mergeCapabilities: { merge: true, squash: true, rebase: true },
3022 viewerPermissions: {
3023 actions: ["merge"],
3024 comment: true,
3025 resolve: true,
3026 verdicts: ["comment", "approve", "request-changes"],
3027 requestReviewers: true,
3028 },
3029 }),
3030 }),
3031 ],
3032 });
3033
3034 yield* service.detail(reference);
3035 yield* TestClock.adjust("16 seconds");
3036 failing = true;
3037 const stale = yield* service.detail(reference);
3038 assert.strictEqual(stale.body, "last good body");
3039
3040 yield* service.invalidate({ reference });
3041 const invalidated = yield* Effect.flip(service.detail(reference));
3042 assert.strictEqual(invalidated._tag, "PullRequestOperationError");
3043 }),
3044);
3045
28323046it.effect("carries an armed auto-merge through to the detail, and silence as silence", () =>
28333047 Effect.gen(function* () {
28343048 const detailWith = (autoMergeEnabled: boolean | undefined) =>
apps/server/src/pullRequest/PullRequestService.ts+249-80
......@@ -5,6 +5,7 @@ import * as Duration from "effect/Duration";
55import * as Effect from "effect/Effect";
66import * as Exit from "effect/Exit";
77import * as Layer from "effect/Layer";
8import * as Schema from "effect/Schema";
89import {
910 PullRequestOperationError,
1011 PullRequestUnavailableError,
......@@ -38,6 +39,7 @@ import {
3839 type PullRequestReviewerCandidateList,
3940 type PullRequestReviewerRequestInput,
4041 type PullRequestSubmitReviewInput,
42 type PullRequestSummary,
4143 type PullRequestThreadReplyInput,
4244 type PullRequestThreadResolutionInput,
4345 type PullRequestThreadCommentsInput,
......@@ -96,6 +98,7 @@ const REPOSITORY_SEARCH_CHUNK = 100;
9698 * `invalidate` rather than a flag on the read, so an ordinary read can never opt out.
9799 */
98100const LIST_CACHE_TTL = Duration.seconds(30);
101const SUMMARY_CACHE_TTL = Duration.seconds(60);
99102const DETAIL_CACHE_TTL = Duration.seconds(15);
100103const DIFF_CACHE_TTL = Duration.seconds(60);
101104/** A commit is content-addressed, so its own diff cannot change under its key. */
......@@ -106,10 +109,14 @@ const LIST_STATS_CACHE_TTL = Duration.seconds(60);
106109const DIFF_STALE_WINDOW = Duration.minutes(10);
107110/** How long one host's signed-in login is believed without asking its CLI again. */
108111const VIEWER_CACHE_TTL = Duration.minutes(10);
112const SEARCH_VISIBILITY_TTL = Duration.minutes(10);
113const STALE_DETAIL_WINDOW = Duration.minutes(10);
114const isPullRequestProviderError = Schema.is(PullRequestProviderError);
109115const LIST_CACHE_CAPACITY = 64;
110116const LIST_STATS_CACHE_CAPACITY = 32;
111117const DETAIL_CACHE_CAPACITY = 128;
112118const DIFF_CACHE_CAPACITY = 128;
119const VIEWER_CACHE_CAPACITY = 32;
113120
114121export type PullRequestError = PullRequestUnavailableError | PullRequestOperationError;
115122
......@@ -122,6 +129,10 @@ export class PullRequestService extends Context.Service<
122129 readonly listStats: (
123130 input: PullRequestListStatsInput,
124131 ) => Effect.Effect<PullRequestListStatsResult, PullRequestError>;
132 readonly summary: (
133 input: PullRequestRef,
134 options?: { readonly recoverTransientFailure?: boolean },
135 ) => Effect.Effect<PullRequestSummary, PullRequestError>;
125136 readonly detail: (input: PullRequestRef) => Effect.Effect<PullRequestDetail, PullRequestError>;
126137 readonly activity: (
127138 input: PullRequestRef,
......@@ -430,6 +441,11 @@ function withRateLimitBackoff(
430441 listChangeRequestStats: wrap("listChangeRequestStats", api.listChangeRequestStats),
431442 }),
432443 getChangeRequest: wrap("getChangeRequest", api.getChangeRequest),
444 ...(api.getChangeRequestSummary === undefined
445 ? {}
446 : {
447 getChangeRequestSummary: wrap("getChangeRequestSummary", api.getChangeRequestSummary),
448 }),
433449 getChangeRequestActivity: wrap("getChangeRequestActivity", api.getChangeRequestActivity),
434450 ...(api.getReviewThreadComments === undefined
435451 ? {}
......@@ -699,6 +715,46 @@ export const make = Effect.gen(function* () {
699715 // "is this host set up" answer the provider switcher shows, and holding it would keep saying
700716 // signed-out after the reader has signed in.
701717 const viewersByHost = new Map<string, { readonly at: number; readonly result: ResolvedViewer }>();
718 const viewerFlights = yield* Cache.makeWith(
719 (key: string): Effect.Effect<ResolvedViewer> => {
720 const [host, kind, roots] = JSON.parse(key) as [
721 string,
722 SourceControlProviderKind,
723 ReadonlyArray<string>,
724 ];
725 const registered = registry.get(kind);
726 if (registered === null) {
727 return Effect.die(new Error(`Missing pull request provider: ${kind}`));
728 }
729 const api = withRateLimitBackoff(registered, host, rateLimits);
730 return Effect.firstSuccessOf(roots.map((cwd) => api.getViewer({ cwd }))).pipe(
731 Effect.map((viewer) => ({
732 host,
733 kind,
734 viewer: viewer as string | null,
735 error: null as PullRequestProviderError | null,
736 })),
737 Effect.tap((result) =>
738 Effect.map(Clock.currentTimeMillis, (at) => viewersByHost.set(host, { at, result })),
739 ),
740 Effect.catch((error) =>
741 Effect.succeed({
742 host,
743 kind,
744 viewer: null,
745 error,
746 }),
747 ),
748 );
749 },
750 {
751 capacity: VIEWER_CACHE_CAPACITY,
752 // The host-wide success map holds the real ten-minute answer. This short entry exists to
753 // keep simultaneous cold page reads on one in-flight lookup; failures remain retryable.
754 timeToLive: (exit) =>
755 Exit.isSuccess(exit) && exit.value.error === null ? Duration.seconds(1) : Duration.zero,
756 },
757 );
702758
703759 const resolveViewers = (
704760 projects: ReadonlyArray<SupportedProject>,
......@@ -718,18 +774,8 @@ export const make = Effect.gen(function* () {
718774 // unreadable worktree would otherwise report the whole host as signed out.
719775 const roots =
720776 viewerRoots.get(host) ?? forHost.map(({ project }) => project.workspaceRoot);
721 return Effect.firstSuccessOf(roots.map((cwd) => api.getViewer({ cwd }))).pipe(
722 Effect.map((viewer) => ({
723 host,
724 kind: api.kind,
725 viewer: viewer as string | null,
726 error: null as PullRequestProviderError | null,
727 })),
728 Effect.tap((result) =>
729 Effect.map(Clock.currentTimeMillis, (at) => viewersByHost.set(host, { at, result })),
730 ),
731 Effect.catch((error) => Effect.succeed({ host, kind: api.kind, viewer: null, error })),
732 );
777 const key = JSON.stringify([host, api.kind, [...new Set(roots)].sort()]);
778 return Cache.get(viewerFlights, key);
733779 }),
734780 { concurrency: REPOSITORY_CONCURRENCY },
735781 );
......@@ -809,6 +855,13 @@ export const make = Effect.gen(function* () {
809855 };
810856 };
811857
858 // A repository that has appeared in a host search is known to be indexed there. Empty
859 // authored/reviewing searches for that same repository are therefore real empty answers, not
860 // a reason to issue the two-command per-repository fallback again.
861 const searchVisibleAt = new Map<string, number>();
862 const searchVisibilityKey = (host: string, repository: string) =>
863 `${host}\n${repository.trim().toLowerCase()}`;
864
812865 const listUncached: PullRequestService["Service"]["list"] = (input) =>
813866 Effect.gen(function* () {
814867 const involvement = input.involvement ?? "all";
......@@ -1018,61 +1071,80 @@ export const make = Effect.gen(function* () {
10181071 ? {}
10191072 : { cursor: { updatedBefore: cursor.updatedBefore, delivered: cursor.delivered } }),
10201073 }).pipe(
1021 Effect.flatMap((page) => {
1022 const rows = new Map<string, Array<ProviderChangeRequest>>();
1023 for (const item of page.items) {
1024 const key = item.repository.trim().toLowerCase();
1025 const held = rows.get(key);
1026 if (held === undefined) rows.set(key, [item]);
1027 else held.push(item);
1028 }
1029 // The oldest row of the whole slice, which is how far every repository in it has now
1030 // been read — including the ones that contributed nothing to it.
1031 const boundary = page.items.reduce<string | null>(
1032 (oldest, item) =>
1033 oldest === null || item.updatedAt < oldest ? item.updatedAt : oldest,
1034 null,
1035 );
1036 return Effect.forEach(
1037 chunk,
1038 (project): Effect.Effect<RepositoryBatch> => {
1039 const fetched = rows.get(project.repository.trim().toLowerCase()) ?? [];
1040 // GitHub does not index every repository for search — a renamed one answers for
1041 // its old name with silence rather than with an error — so a repository the
1042 // search said nothing at all about is read on its own, once, before it is
1043 // believed. Only on its first slice: after that it has a boundary to carry on
1044 // from, and silence past one means the rows are older rather than absent. That
1045 // keeps a search-invisible repository from disappearing on a busy host, at the
1046 // price of one request per repository with nothing in the first slice — which
1047 // run together, and only there.
1048 if (fetched.length === 0 && cursorOf(project) === undefined) {
1049 return readRepository(project);
1074 Effect.flatMap((page) =>
1075 Effect.flatMap(Clock.currentTimeMillis, (now) => {
1076 const rows = new Map<string, Array<ProviderChangeRequest>>();
1077 for (const [key, visibleAt] of searchVisibleAt) {
1078 if (now - visibleAt > Duration.toMillis(SEARCH_VISIBILITY_TTL)) {
1079 searchVisibleAt.delete(key);
10501080 }
1051 const cursorHere = cursorOf(project);
1052 const items =
1053 cursorHere === undefined
1054 ? fetched
1055 : fetched.filter(
1056 (item) =>
1057 item.updatedAt !== cursorHere.updatedBefore ||
1058 !cursorHere.seenAt.includes(item.number),
1059 );
1060 return Effect.succeed({
1061 key: listCursorKey(project.host, project.repository),
1062 entries: items
1063 .filter((item) => matchesRowFilters(item, input.filters, viewer))
1064 .map((item) => toEntry({ project, item, viewer })),
1065 errors: [],
1066 truncated: page.truncated,
1067 nextCursor:
1068 page.truncated && boundary !== null
1069 ? listCursorAt(cursorHere, boundary, fetched, items.length)
1070 : null,
1071 });
1072 },
1073 { concurrency: REPOSITORY_CONCURRENCY },
1074 );
1075 }),
1081 }
1082 for (const item of page.items) {
1083 const key = item.repository.trim().toLowerCase();
1084 const held = rows.get(key);
1085 if (held === undefined) rows.set(key, [item]);
1086 else held.push(item);
1087 searchVisibleAt.set(searchVisibilityKey(first.host, item.repository), now);
1088 }
1089 // The oldest row of the whole slice, which is how far every repository in it has now
1090 // been read — including the ones that contributed nothing to it.
1091 const boundary = page.items.reduce<string | null>(
1092 (oldest, item) =>
1093 oldest === null || item.updatedAt < oldest ? item.updatedAt : oldest,
1094 null,
1095 );
1096 return Effect.forEach(
1097 chunk,
1098 (project): Effect.Effect<RepositoryBatch> => {
1099 const fetched = rows.get(project.repository.trim().toLowerCase()) ?? [];
1100 // GitHub does not index every repository for search — a renamed one answers for
1101 // its old name with silence rather than with an error — so a repository the
1102 // search said nothing at all about is read on its own, once, before it is
1103 // believed. Only on its first slice: after that it has a boundary to carry on
1104 // from, and silence past one means the rows are older rather than absent. That
1105 // keeps a search-invisible repository from disappearing on a busy host, at the
1106 // price of one request per repository with nothing in the first slice — which
1107 // run together, and only there.
1108 const lastVisible = searchVisibleAt.get(
1109 searchVisibilityKey(project.host, project.repository),
1110 );
1111 const searchIsKnownVisible =
1112 !page.truncated &&
1113 lastVisible !== undefined &&
1114 now - lastVisible <= Duration.toMillis(SEARCH_VISIBILITY_TTL);
1115 if (
1116 fetched.length === 0 &&
1117 cursorOf(project) === undefined &&
1118 !searchIsKnownVisible
1119 ) {
1120 return readRepository(project);
1121 }
1122 const cursorHere = cursorOf(project);
1123 const items =
1124 cursorHere === undefined
1125 ? fetched
1126 : fetched.filter(
1127 (item) =>
1128 item.updatedAt !== cursorHere.updatedBefore ||
1129 !cursorHere.seenAt.includes(item.number),
1130 );
1131 return Effect.succeed({
1132 key: listCursorKey(project.host, project.repository),
1133 entries: items
1134 .filter((item) => matchesRowFilters(item, input.filters, viewer))
1135 .map((item) => toEntry({ project, item, viewer })),
1136 errors: [],
1137 truncated: page.truncated,
1138 nextCursor:
1139 page.truncated && boundary !== null
1140 ? listCursorAt(cursorHere, boundary, fetched, items.length)
1141 : null,
1142 });
1143 },
1144 { concurrency: REPOSITORY_CONCURRENCY },
1145 );
1146 }),
1147 ),
10761148 Effect.catch(separately),
10771149 );
10781150 };
......@@ -1127,6 +1199,39 @@ export const make = Effect.gen(function* () {
11271199 const viewerOf = (project: SupportedProject): Effect.Effect<string | null> =>
11281200 resolveViewers([project], new Map()).pipe(Effect.map(([resolved]) => resolved?.viewer ?? null));
11291201
1202 const summaryUncached: PullRequestService["Service"]["summary"] = (input) =>
1203 requireProject(input).pipe(
1204 Effect.flatMap((project) => {
1205 const providerInput = {
1206 cwd: project.project.workspaceRoot,
1207 repository: project.repository,
1208 host: project.host,
1209 number: input.number,
1210 };
1211 const read =
1212 project.api.getChangeRequestSummary === undefined
1213 ? project.api.getChangeRequest(providerInput)
1214 : project.api.getChangeRequestSummary(providerInput);
1215 return read.pipe(
1216 Effect.mapError(toPullRequestError("summary")),
1217 Effect.map(
1218 (changeRequest): PullRequestSummary => ({
1219 provider: project.api.kind,
1220 projectId: project.project.id,
1221 repository: project.repository,
1222 number: changeRequest.number,
1223 title: changeRequest.title,
1224 url: changeRequest.url,
1225 state: changeRequest.state,
1226 headBranch: changeRequest.headBranch,
1227 baseBranch: changeRequest.baseBranch,
1228 updatedAt: changeRequest.updatedAt,
1229 }),
1230 ),
1231 );
1232 }),
1233 );
1234
11301235 const detailUncached: PullRequestService["Service"]["detail"] = (input) =>
11311236 requireProject(input).pipe(
11321237 Effect.flatMap((project) =>
......@@ -1848,6 +1953,50 @@ export const make = Effect.gen(function* () {
18481953 };
18491954 })();
18501955
1956 const makeLastGoodRead = <A>(capacity: number) => {
1957 const held = new Map<string, { readonly at: number; readonly value: A }>();
1958 const record = (key: string, value: A) =>
1959 Effect.map(Clock.currentTimeMillis, (at) => {
1960 held.delete(key);
1961 if (held.size >= capacity) {
1962 const oldest = held.keys().next().value;
1963 if (oldest !== undefined) held.delete(oldest);
1964 }
1965 held.set(key, { at, value });
1966 });
1967 const read = (key: string, effect: Effect.Effect<A, PullRequestError>) =>
1968 effect.pipe(
1969 Effect.tap((value) => record(key, value)),
1970 Effect.catchTags({
1971 PullRequestOperationError: (error) => {
1972 if (!isPullRequestProviderError(error.cause)) {
1973 return Effect.fail(error);
1974 }
1975 const provider = error.cause;
1976 if (provider.reason !== "failed" && provider.reason !== "rate-limited") {
1977 return Effect.fail(error);
1978 }
1979 return Effect.flatMap(Clock.currentTimeMillis, (now) => {
1980 const snapshot = held.get(key);
1981 if (
1982 snapshot === undefined ||
1983 now - snapshot.at > Duration.toMillis(STALE_DETAIL_WINDOW)
1984 ) {
1985 return Effect.fail(error);
1986 }
1987 return Effect.logWarning("using recent pull request data after a failed refresh", {
1988 operation: error.operation,
1989 reason: provider.reason,
1990 }).pipe(Effect.as(snapshot.value));
1991 });
1992 },
1993 }),
1994 );
1995 return { read, record };
1996 };
1997 const lastGoodSummary = makeLastGoodRead<PullRequestSummary>(DETAIL_CACHE_CAPACITY);
1998 const lastGoodDetail = makeLastGoodRead<PullRequestDetail>(DETAIL_CACHE_CAPACITY);
1999
18512000 // Epochs are the invalidation mechanism: a key carries its scope's epoch, so bumping the
18522001 // epoch strands every entry made under the old one — no enumerating a cache whose keys
18532002 // (cursors, commits) nothing holds a list of. The counter is shared and monotonic so a
......@@ -1858,6 +2007,8 @@ export const make = Effect.gen(function* () {
18582007 const REF_EPOCH_CAPACITY = 2_048;
18592008 const refScope = (ref: PullRequestRef) => `${ref.projectId} ${ref.repository} ${ref.number}`;
18602009 const refEpoch = (ref: PullRequestRef) => refEpochs.get(refScope(ref)) ?? 0;
2010 const refCacheKey = (ref: PullRequestRef) =>
2011 JSON.stringify([refEpoch(ref), ref.projectId, ref.repository, ref.number]);
18612012 const bumpRefEpoch = (ref: PullRequestRef) => {
18622013 const scope = refScope(ref);
18632014 if (!refEpochs.has(scope) && refEpochs.size >= REF_EPOCH_CAPACITY) {
......@@ -1884,6 +2035,24 @@ export const make = Effect.gen(function* () {
18842035 };
18852036 };
18862037
2038 const summaryCache = yield* Cache.makeWith(
2039 (key: string) => {
2040 const [, projectId, repository, number] = JSON.parse(key) as [number, string, string, number];
2041 return summaryUncached({ projectId, repository, number } as PullRequestRef);
2042 },
2043 {
2044 capacity: DETAIL_CACHE_CAPACITY,
2045 timeToLive: (exit) => (Exit.isSuccess(exit) ? SUMMARY_CACHE_TTL : Duration.zero),
2046 },
2047 );
2048 const summary: PullRequestService["Service"]["summary"] = (input, options) => {
2049 const key = refCacheKey(input);
2050 const cached = Cache.get(summaryCache, key);
2051 return options?.recoverTransientFailure === false
2052 ? cached.pipe(Effect.tap((value) => lastGoodSummary.record(key, value)))
2053 : lastGoodSummary.read(key, cached);
2054 };
2055
18872056 // Keys serialize positionally and parse back in the lookup, so the cache is the only holder
18882057 // of in-flight state: concurrent identical reads coalesce on the key into one host request.
18892058 // The continuation cursors are part of the key, entries sorted so one continuation is one
......@@ -1972,8 +2141,8 @@ export const make = Effect.gen(function* () {
19722141 },
19732142 );
19742143 const detail: PullRequestService["Service"]["detail"] = (input) => {
1975 const key = JSON.stringify([refEpoch(input), input.projectId, input.repository, input.number]);
1976 return Cache.get(detailCache, key);
2144 const key = refCacheKey(input);
2145 return lastGoodDetail.read(key, Cache.get(detailCache, key));
19772146 };
19782147
19792148 const activityCache = yield* Cache.makeWith(
......@@ -1987,7 +2156,7 @@ export const make = Effect.gen(function* () {
19872156 },
19882157 );
19892158 const activity: PullRequestService["Service"]["activity"] = (input) => {
1990 const key = JSON.stringify([refEpoch(input), input.projectId, input.repository, input.number]);
2159 const key = refCacheKey(input);
19912160 return Cache.get(activityCache, key);
19922161 };
19932162
......@@ -2059,17 +2228,16 @@ export const make = Effect.gen(function* () {
20592228 return Cache.get(listStatsCache, key);
20602229 };
20612230
2062 const invalidate: PullRequestService["Service"]["invalidate"] = (input) =>
2063 Effect.sync(() => {
2064 if (input.reference === undefined) {
2065 listingsEpoch = ++epochCounter;
2066 // A whole-workspace refresh is the reader asking to be re-answered from the hosts,
2067 // and that includes who the hosts say they are.
2068 viewersByHost.clear();
2069 return;
2070 }
2071 bumpRefEpoch(input.reference);
2072 });
2231 const invalidate: PullRequestService["Service"]["invalidate"] = (input) => {
2232 const reference = input.reference;
2233 if (reference !== undefined) {
2234 return Effect.sync(() => bumpRefEpoch(reference));
2235 }
2236 return Effect.sync(() => {
2237 listingsEpoch = ++epochCounter;
2238 viewersByHost.clear();
2239 }).pipe(Effect.andThen(Cache.invalidateAll(viewerFlights)));
2240 };
20732241
20742242 // A mutation's own client re-reads right after it, and every other client's next read must
20752243 // see the action too — so a write forgets the change request it touched and the listings its
......@@ -2091,6 +2259,7 @@ export const make = Effect.gen(function* () {
20912259 return PullRequestService.of({
20922260 list,
20932261 listStats,
2262 summary,
20942263 detail,
20952264 activity,
20962265 threadComments,
apps/server/src/server.test.ts+2
......@@ -136,6 +136,7 @@ import * as VcsDriver from "./vcs/VcsDriver.ts";
136136import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts";
137137import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts";
138138import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts";
139import * as VcsProcess from "./vcs/VcsProcess.ts";
139140import * as GitWorkflowService from "./git/GitWorkflowService.ts";
140141import * as ReviewService from "./review/ReviewService.ts";
141142import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
......@@ -1041,6 +1042,7 @@ const buildAppUnderTest = (options?: {
10411042 Layer.provideMerge(ServerSecretStore.layer),
10421043 Layer.provide(workspaceAndProjectServicesLayer),
10431044 Layer.provideMerge(FetchHttpClient.layer),
1045 Layer.provide(VcsProcess.layer),
10441046 Layer.provide(layerConfig),
10451047 );
10461048
apps/server/src/server.ts+2-2
......@@ -303,7 +303,6 @@ const PullRequestServiceLive = PullRequestService.layer.pipe(
303303 Layer.provide(PullRequestProviderRegistry.layer),
304304 Layer.provide(SourceControlProviderRegistryLayerLive),
305305 Layer.provide(SourceControlRateLimit.layer),
306 Layer.provide(VcsProcess.layer),
307306);
308307
309308const GitManagerLayerLive = GitManager.layer.pipe(
......@@ -713,7 +712,8 @@ export const makeServerLayer = Layer.unwrap(
713712 Layer.provideMerge(HttpServerLive),
714713 Layer.provide(ApplicationObservabilityLive),
715714 Layer.provideMerge(FetchHttpClient.layer),
716 Layer.provideMerge(VcsProcess.layer),
715 // PR reads, Git operations, and WebSocket discovery share one process limiter.
716 Layer.provide(VcsProcess.layer),
717717 Layer.provideMerge(PlatformServicesLive),
718718 );
719719 }),
apps/server/src/sourceControl/GitHubCli.test.ts+3-1
......@@ -66,6 +66,7 @@ describe("GitHubCli.layer", () => {
6666 headRefName: "feature/pr-threads",
6767 state: "OPEN",
6868 mergedAt: null,
69 updatedAt: "2026-08-24T12:34:56Z",
6970 isCrossRepository: true,
7071 headRepository: {
7172 nameWithOwner: "octocat/codething-mvp",
......@@ -91,6 +92,7 @@ describe("GitHubCli.layer", () => {
9192 baseRefName: "main",
9293 headRefName: "feature/pr-threads",
9394 state: "open",
95 updatedAt: "2026-08-24T12:34:56.000Z",
9496 isCrossRepository: true,
9597 headRepositoryNameWithOwner: "octocat/codething-mvp",
9698 headRepositoryOwnerLogin: "octocat",
......@@ -103,7 +105,7 @@ describe("GitHubCli.layer", () => {
103105 "view",
104106 "#42",
105107 "--json",
106 "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
108 "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
107109 ],
108110 cwd: "/repo",
109111 timeoutMs: 30_000,
apps/server/src/sourceControl/GitHubCli.ts+15-7
......@@ -1,6 +1,8 @@
11import * as Context from "effect/Context";
2import * as DateTime from "effect/DateTime";
23import * as Effect from "effect/Effect";
34import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
46import * as PlatformError from "effect/PlatformError";
57import * as Result from "effect/Result";
68import * as Schema from "effect/Schema";
......@@ -15,6 +17,7 @@ import * as VcsProcess from "../vcs/VcsProcess.ts";
1517import {
1618 decodeGitHubPullRequestJson,
1719 decodeGitHubPullRequestListJson,
20 type NormalizedGitHubPullRequestRecord,
1821} from "./gitHubPullRequests.ts";
1922
2023const DEFAULT_TIMEOUT_MS = 30_000;
......@@ -202,11 +205,20 @@ export interface GitHubPullRequestSummary {
202205 readonly baseRefName: string;
203206 readonly headRefName: string;
204207 readonly state?: "open" | "closed" | "merged";
208 readonly updatedAt?: string;
205209 readonly isCrossRepository?: boolean;
206210 readonly headRepositoryNameWithOwner?: string | null;
207211 readonly headRepositoryOwnerLogin?: string | null;
208212}
209213
214function pullRequestSummary(input: NormalizedGitHubPullRequestRecord): GitHubPullRequestSummary {
215 const { updatedAt, ...summary } = input;
216 return {
217 ...summary,
218 ...(Option.isSome(updatedAt) ? { updatedAt: DateTime.formatIso(updatedAt.value) } : {}),
219 };
220}
221
210222export interface GitHubRepositoryCloneUrls {
211223 readonly nameWithOwner: string;
212224 readonly url: string;
......@@ -373,9 +385,7 @@ export const make = Effect.gen(function* () {
373385 );
374386 }
375387
376 return Effect.succeed(
377 decoded.success.map(({ updatedAt: _updatedAt, ...summary }) => summary),
378 );
388 return Effect.succeed(decoded.success.map(pullRequestSummary));
379389 }),
380390 ),
381391 ),
......@@ -388,7 +398,7 @@ export const make = Effect.gen(function* () {
388398 "view",
389399 input.reference,
390400 "--json",
391 "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
401 "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
392402 ],
393403 }).pipe(
394404 Effect.map((result) => result.stdout.trim()),
......@@ -405,9 +415,7 @@ export const make = Effect.gen(function* () {
405415 );
406416 }
407417
408 return Effect.succeed(
409 (({ updatedAt: _updatedAt, ...summary }) => summary)(decoded.success),
410 );
418 return Effect.succeed(pullRequestSummary(decoded.success));
411419 }),
412420 ),
413421 ),
apps/server/src/sourceControl/GitHubSourceControlProvider.ts+17-5
......@@ -1,3 +1,4 @@
1import * as DateTime from "effect/DateTime";
12import * as Effect from "effect/Effect";
23import * as Layer from "effect/Layer";
34import * as Option from "effect/Option";
......@@ -29,7 +30,10 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq
2930 baseRefName: summary.baseRefName,
3031 headRefName: summary.headRefName,
3132 state: summary.state ?? "open",
32 updatedAt: Option.none(),
33 updatedAt:
34 summary.updatedAt === undefined
35 ? Option.none()
36 : Option.some(DateTime.makeUnsafe(summary.updatedAt)),
3337 ...(summary.isCrossRepository !== undefined
3438 ? { isCrossRepository: summary.isCrossRepository }
3539 : {}),
......@@ -162,10 +166,18 @@ export const make = Effect.gen(function* () {
162166 Effect.flatMap((decoded) =>
163167 Result.isSuccess(decoded)
164168 ? Effect.succeed(
165 decoded.success.map((item) => ({
166 ...toChangeRequest(item),
167 updatedAt: item.updatedAt,
168 })),
169 decoded.success.map((item) => {
170 const { updatedAt, ...summary } = item;
171 return {
172 ...toChangeRequest({
173 ...summary,
174 ...(Option.isSome(updatedAt)
175 ? { updatedAt: DateTime.formatIso(updatedAt.value) }
176 : {}),
177 }),
178 updatedAt,
179 };
180 }),
169181 )
170182 : Effect.fail(
171183 new GitHubCli.GitHubChangeRequestListDecodeError({
apps/server/src/vcs/VcsProcess.test.ts+61
......@@ -1,10 +1,14 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
22import { describe, expect, it } from "@effect/vitest";
33import * as Duration from "effect/Duration";
4import * as Deferred from "effect/Deferred";
45import * as Effect from "effect/Effect";
56import * as Fiber from "effect/Fiber";
67import * as Layer from "effect/Layer";
8import * as Queue from "effect/Queue";
9import * as Ref from "effect/Ref";
710import { TestClock } from "effect/testing";
11import { ChildProcessSpawner } from "effect/unstable/process";
812
913import {
1014 VcsProcessExitError,
......@@ -45,6 +49,63 @@ const captureProcessResult = (
4549 );
4650
4751describe("VcsProcess.run", () => {
52 it.effect("bounds a synthetic burst of GitHub API processes", () =>
53 Effect.gen(function* () {
54 const gate = yield* Deferred.make<void>();
55 const starts = yield* Queue.unbounded<number>();
56 const active = yield* Ref.make(0);
57 const peak = yield* Ref.make(0);
58 const total = yield* Ref.make(0);
59 const service = yield* VcsProcess.make.pipe(
60 Effect.provideService(
61 ProcessRunner.ProcessRunner,
62 ProcessRunner.ProcessRunner.of({
63 run: () =>
64 Effect.gen(function* () {
65 const count = yield* Ref.updateAndGet(active, (held) => held + 1);
66 yield* Ref.update(peak, (held) => Math.max(held, count));
67 yield* Ref.update(total, (held) => held + 1);
68 yield* Queue.offer(starts, count);
69 yield* Deferred.await(gate);
70 return {
71 stdout: "",
72 stderr: "",
73 code: ChildProcessSpawner.ExitCode(0),
74 timedOut: false,
75 stdoutTruncated: false,
76 stderrTruncated: false,
77 stdoutInvalidUtf8: false,
78 stderrInvalidUtf8: false,
79 };
80 }).pipe(Effect.ensuring(Ref.update(active, (count) => count - 1))),
81 }),
82 ),
83 );
84
85 const burst = yield* Effect.all(
86 Array.from({ length: 32 }, (_, index) =>
87 service.run({
88 operation: `synthetic.github.${index}`,
89 command: "gh",
90 args: ["api", "user"],
91 cwd: "/workspace",
92 }),
93 ),
94 { concurrency: "unbounded" },
95 ).pipe(Effect.forkChild);
96
97 yield* Effect.all(Array.from({ length: 4 }, () => Queue.take(starts)));
98 yield* Effect.yieldNow;
99 expect(yield* Queue.size(starts)).toBe(0);
100 expect(yield* Ref.get(peak)).toBe(4);
101
102 yield* Deferred.succeed(gate, undefined);
103 yield* Fiber.join(burst);
104 expect(yield* Ref.get(total)).toBe(32);
105 expect(yield* Ref.get(peak)).toBe(4);
106 }),
107 );
108
48109 it.effect("collects stdout", () =>
49110 Effect.gen(function* () {
50111 const result = yield* run({
apps/server/src/vcs/VcsProcess.ts+11-1
......@@ -2,6 +2,7 @@ import * as Context from "effect/Context";
22import * as Effect from "effect/Effect";
33import * as Layer from "effect/Layer";
44import * as Match from "effect/Match";
5import * as Semaphore from "effect/Semaphore";
56import { ChildProcessSpawner } from "effect/unstable/process";
67
78import {
......@@ -52,6 +53,8 @@ export class VcsProcess extends Context.Service<
5253const DEFAULT_TIMEOUT_MS = 30_000;
5354const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
5455const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]";
56const VCS_PROCESS_CONCURRENCY = 8;
57const GITHUB_PROCESS_CONCURRENCY = 4;
5558
5659const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFailureKind => {
5760 const normalized = stderr.toLowerCase();
......@@ -101,8 +104,10 @@ const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFai
101104
102105export const make = Effect.gen(function* () {
103106 const processRunner = yield* ProcessRunner.ProcessRunner;
107 const vcsProcesses = yield* Semaphore.make(VCS_PROCESS_CONCURRENCY);
108 const githubProcesses = yield* Semaphore.make(GITHUB_PROCESS_CONCURRENCY);
104109
105 const run = Effect.fn("VcsProcess.run")(function* (input: VcsProcessInput) {
110 const runUnbounded = Effect.fn("VcsProcess.runUnbounded")(function* (input: VcsProcessInput) {
106111 const baseError = {
107112 operation: input.operation,
108113 command: input.command,
......@@ -181,6 +186,11 @@ export const make = Effect.gen(function* () {
181186 } satisfies VcsProcessOutput;
182187 });
183188
189 const run = Effect.fn("VcsProcess.run")(function* (input: VcsProcessInput) {
190 const bounded = vcsProcesses.withPermits(1)(runUnbounded(input));
191 return yield* input.command === "gh" ? githubProcesses.withPermits(1)(bounded) : bounded;
192 });
193
184194 return VcsProcess.of({ run });
185195});
186196
apps/server/src/ws.ts+4-3
......@@ -33,7 +33,6 @@ import {
3333 type OrchestrationEvent,
3434 type OrchestrationShellStreamEvent,
3535 type OrchestrationShellStreamItem,
36 type OrchestrationThreadStreamItem,
3736 OrchestrationGetFullThreadDiffError,
3837 OrchestrationGetSnapshotError,
3938 OrchestrationSearchThreadsError,
......@@ -137,7 +136,6 @@ import * as SourceControlProviderRegistry from "./sourceControl/SourceControlPro
137136import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
138137import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts";
139138import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts";
140import * as VcsProcess from "./vcs/VcsProcess.ts";
141139import * as PairingGrantStore from "./auth/PairingGrantStore.ts";
142140import * as SessionStore from "./auth/SessionStore.ts";
143141import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts";
......@@ -1896,6 +1894,10 @@ const makeWsRpcLayer = (
18961894 observeRpcEffect(WS_METHODS.pullRequestsListStats, pullRequests.listStats(input), {
18971895 "rpc.aggregate": "pull-requests",
18981896 }),
1897 [WS_METHODS.pullRequestsSummary]: (input) =>
1898 observeRpcEffect(WS_METHODS.pullRequestsSummary, pullRequests.summary(input), {
1899 "rpc.aggregate": "pull-requests",
1900 }),
18991901 [WS_METHODS.pullRequestsDetail]: (input) =>
19001902 observeRpcEffect(WS_METHODS.pullRequestsDetail, pullRequests.detail(input), {
19011903 "rpc.aggregate": "pull-requests",
......@@ -2635,7 +2637,6 @@ export const websocketRpcRouteLayer = Layer.unwrap(
26352637 ),
26362638 ),
26372639 ),
2638 Layer.provide(VcsProcess.layer),
26392640 ),
26402641 ),
26412642 ),
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx-7
......@@ -492,13 +492,6 @@ export function PullRequestDetailPanel({
492492 const activityQuery = useEnvironmentQuery(
493493 pullRequestEnvironment.activity({ environmentId, input: reference }),
494494 );
495 // Detail and diff are independent server reads, so the diff for the default view (no commit,
496 // no cursor) is started here too rather than waiting for the Code tab to mount. This is one
497 // extra cached read per opened pull request even for readers who never open the tab, but it
498 // turns the tab's first paint from a cold request into a cache hit.
499 const _diffWarmUpQuery = useEnvironmentQuery(
500 pullRequestEnvironment.diff({ environmentId, input: { ...reference } }),
501 );
502495 const coreDetail = detailQuery.data;
503496 const activity = activityQuery.data;
504497 const detail = useMemo(
apps/web/src/routes/_chat.pull-requests.tsx+11-1
......@@ -760,7 +760,16 @@ function PullRequestsRouteView() {
760760 // Built together so the two reads share one memo, and in the same field order the feed's own
761761 // input uses: the atoms are keyed by their input, so the Authored tab then reads this answer.
762762 const partitionTargets = useMemo(() => {
763 if (!partitionsWanted) return { authored: NO_LIST_TARGETS, reviewing: NO_LIST_TARGETS };
763 // The main list goes first. Besides putting the visible rows on screen sooner, it proves
764 // which repositories the host search indexes, so an empty partition does not trigger the
765 // expensive per-repository fallback. With no rows at all both partitions are already empty.
766 if (
767 !partitionsWanted ||
768 baselineQuery.data === null ||
769 baselineQuery.data.entries.length === 0
770 ) {
771 return { authored: NO_LIST_TARGETS, reviewing: NO_LIST_TARGETS };
772 }
764773 const targetsFor = (involvement: PullRequestInvolvement) =>
765774 environmentQueries.map(({ environmentId, projectIds }) => ({
766775 environmentId,
......@@ -779,6 +788,7 @@ function PullRequestsRouteView() {
779788 menuFiltered,
780789 menuFilters,
781790 partitionsWanted,
791 baselineQuery.data,
782792 environmentQueries,
783793 scopedProjectId,
784794 search.host,
apps/web/src/state/pullRequests.ts+2-2
......@@ -1,6 +1,6 @@
11import { useAtomValue } from "@effect/atom-react";
22import {
3 createLinkedPullRequestDetailAtomFamily,
3 createLinkedPullRequestSummaryAtomFamily,
44 createPullRequestEnvironmentAtoms,
55} from "@t3tools/client-runtime/state/pull-requests";
66import type {
......@@ -23,7 +23,7 @@ import { formatEnvironmentQueryError } from "./query";
2323
2424export const pullRequestEnvironment = createPullRequestEnvironmentAtoms(connectionAtomRuntime);
2525export const linkedPullRequestDetailAtom =
26 createLinkedPullRequestDetailAtomFamily(connectionAtomRuntime);
26 createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime);
2727
2828export interface EnvironmentQueryTarget<Input> {
2929 readonly environmentId: EnvironmentId;
packages/client-runtime/src/state/pullRequests.ts+10-9
......@@ -2,6 +2,7 @@ import {
22 WS_METHODS,
33 type PullRequestDetail,
44 type PullRequestDiffInput,
5 type PullRequestSummary,
56 type VcsStatusResult,
67} from "@t3tools/contracts";
78import * as Data from "effect/Data";
......@@ -31,23 +32,23 @@ export class EnvironmentHttpConnectionNotReadyError extends Data.TaggedError(
3132 "EnvironmentHttpConnectionNotReadyError",
3233)<{ readonly message: string }> {}
3334
34export const LINKED_PULL_REQUEST_DETAIL_IDLE_TTL_MS = 5_000;
35export const LINKED_PULL_REQUEST_IDLE_TTL_MS = 5_000;
3536
36/** Refresh a linked PR while its thread is visible so merges update the sidebar. */
37export function createLinkedPullRequestDetailAtomFamily<R, E>(
37/** Refresh only the live fields a linked thread renders. */
38export function createLinkedPullRequestSummaryAtomFamily<R, E>(
3839 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
3940) {
4041 return createEnvironmentRpcQueryAtomFamily(runtime, {
41 label: "environment-data:pull-requests:linked-detail",
42 tag: WS_METHODS.pullRequestsDetail,
43 staleTimeMs: 15_000,
44 refreshIntervalMs: 30_000,
45 idleTtlMs: LINKED_PULL_REQUEST_DETAIL_IDLE_TTL_MS,
42 label: "environment-data:pull-requests:linked-summary",
43 tag: WS_METHODS.pullRequestsSummary,
44 staleTimeMs: 60_000,
45 refreshIntervalMs: 60_000,
46 idleTtlMs: LINKED_PULL_REQUEST_IDLE_TTL_MS,
4647 });
4748}
4849
4950export function pullRequestDetailToVcsStatus(
50 detail: PullRequestDetail,
51 detail: PullRequestDetail | PullRequestSummary,
5152): NonNullable<VcsStatusResult["pr"]> {
5253 return {
5354 number: detail.number,
packages/contracts/src/pullRequest.ts+18
......@@ -590,6 +590,24 @@ export const PullRequestRef = Schema.Struct({
590590});
591591export type PullRequestRef = typeof PullRequestRef.Type;
592592
593/**
594 * The small live shape a linked thread needs. Keeping it separate from detail means a sidebar
595 * status check never loads permissions, repository settings, checks, or base comparison data.
596 */
597export const PullRequestSummary = Schema.Struct({
598 provider: SourceControlProviderKind,
599 projectId: ProjectId,
600 repository: TrimmedNonEmptyString,
601 number: PositiveInt,
602 title: TrimmedNonEmptyString,
603 url: TrimmedNonEmptyString,
604 state: PullRequestState,
605 headBranch: TrimmedNonEmptyString,
606 baseBranch: TrimmedNonEmptyString,
607 updatedAt: IsoDateTime,
608});
609export type PullRequestSummary = typeof PullRequestSummary.Type;
610
593611/**
594612 * One row's line counts, read after the listing rather than inside it. On GitHub the pair is
595613 * 40-60% of the wall clock of the search that answers the whole page — measured over twelve
packages/contracts/src/rpc.ts+9
......@@ -97,6 +97,7 @@ import {
9797 PullRequestOperationError,
9898 PullRequestReactionInput,
9999 PullRequestRef,
100 PullRequestSummary,
100101 PullRequestReviewerCandidateList,
101102 PullRequestReviewerRequestInput,
102103 PullRequestSubmitReviewInput,
......@@ -302,6 +303,7 @@ export const WS_METHODS = {
302303 // Pull request methods
303304 pullRequestsList: "pullRequests.list",
304305 pullRequestsListStats: "pullRequests.listStats",
306 pullRequestsSummary: "pullRequests.summary",
305307 pullRequestsDetail: "pullRequests.detail",
306308 pullRequestsActivity: "pullRequests.activity",
307309 pullRequestsThreadComments: "pullRequests.threadComments",
......@@ -521,6 +523,12 @@ export const WsPullRequestsListStatsRpc = Rpc.make(WS_METHODS.pullRequestsListSt
521523 error: PullRequestRpcError,
522524});
523525
526export const WsPullRequestsSummaryRpc = Rpc.make(WS_METHODS.pullRequestsSummary, {
527 payload: PullRequestRef,
528 success: PullRequestSummary,
529 error: PullRequestRpcError,
530});
531
524532export const WsPullRequestsDetailRpc = Rpc.make(WS_METHODS.pullRequestsDetail, {
525533 payload: PullRequestRef,
526534 success: PullRequestDetail,
......@@ -1063,6 +1071,7 @@ export const WsRpcGroup = RpcGroup.make(
10631071 WsCloudInstallRelayClientRpc,
10641072 WsPullRequestsListRpc,
10651073 WsPullRequestsListStatsRpc,
1074 WsPullRequestsSummaryRpc,
10661075 WsPullRequestsDetailRpc,
10671076 WsPullRequestsActivityRpc,
10681077 WsPullRequestsThreadCommentsRpc,