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";...@@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react";
2import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";2import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
3import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";3import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
4import {4import {
5 createLinkedPullRequestDetailAtomFamily,5 createLinkedPullRequestSummaryAtomFamily,
6 pullRequestDetailToVcsStatus,6 pullRequestDetailToVcsStatus,
7} from "@t3tools/client-runtime/state/pull-requests";7} from "@t3tools/client-runtime/state/pull-requests";
8import { Atom } from "effect/unstable/reactivity";8import { Atom } from "effect/unstable/reactivity";
...@@ -14,7 +14,7 @@ import { useEnvironmentQuery } from "./query";...@@ -14,7 +14,7 @@ import { useEnvironmentQuery } from "./query";
14import { presentThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation";14import { presentThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation";
15import { vcsEnvironment } from "./vcs";15import { vcsEnvironment } from "./vcs";
1616
17const linkedPullRequestDetailAtom = createLinkedPullRequestDetailAtomFamily(connectionAtomRuntime);17const linkedPullRequestDetailAtom = createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime);
18const MAX_THREAD_PR_SNAPSHOTS = 500;18const MAX_THREAD_PR_SNAPSHOTS = 500;
1919
20interface ThreadPrSnapshot {20interface ThreadPrSnapshot {
apps/server/src/auth/RpcAuthorization.ts+1
...@@ -55,6 +55,7 @@ export const RPC_REQUIRED_SCOPES = {...@@ -55,6 +55,7 @@ export const RPC_REQUIRED_SCOPES = {
55 [WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope,55 [WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope,
56 [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope,56 [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope,
57 [WS_METHODS.pullRequestsListStats]: AuthOrchestrationReadScope,57 [WS_METHODS.pullRequestsListStats]: AuthOrchestrationReadScope,
58 [WS_METHODS.pullRequestsSummary]: AuthOrchestrationReadScope,
58 [WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope,59 [WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope,
59 [WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope,60 [WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope,
60 [WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope,61 [WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope,
apps/server/src/orchestration/ThreadSettlementReactor.test.ts+35-59
...@@ -8,7 +8,7 @@ import {...@@ -8,7 +8,7 @@ import {
8 type OrchestrationProjectShell,8 type OrchestrationProjectShell,
9 type OrchestrationShellSnapshot,9 type OrchestrationShellSnapshot,
10 type OrchestrationThreadShell,10 type OrchestrationThreadShell,
11 type PullRequestDetail,11 type PullRequestSummary,
12 type ServerSettings,12 type ServerSettings,
13 type ServerSettingsPatch,13 type ServerSettingsPatch,
14} from "@t3tools/contracts";14} from "@t3tools/contracts";
...@@ -105,56 +105,24 @@ function makeSnapshot(...@@ -105,56 +105,24 @@ function makeSnapshot(
105 };105 };
106}106}
107107
108function makePullRequestDetail(input: {108function makePullRequestSummary(input: {
109 readonly projectId: ProjectId;109 readonly projectId: ProjectId;
110 readonly repository: string;110 readonly repository: string;
111 readonly number: number;111 readonly number: number;
112 readonly state: "open" | "closed" | "merged";112 readonly state: "open" | "closed" | "merged";
113 readonly updatedAt?: string;113 readonly updatedAt?: string;
114}): PullRequestDetail {114}): PullRequestSummary {
115 return {115 return {
116 provider: "github",116 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 },
133 projectId: input.projectId,117 projectId: input.projectId,
134 projectTitle: "Linked project",
135 workspaceRoot: "/workspace/linked",
136 repository: input.repository,118 repository: input.repository,
137 number: input.number,119 number: input.number,
138 title: "Pull request",120 title: "Pull request",
139 body: "",
140 url: `https://example.test/${input.repository}/pull/${input.number}`,121 url: `https://example.test/${input.repository}/pull/${input.number}`,
141 author: null,
142 state: input.state,122 state: input.state,
143 isDraft: false,
144 mergeability: "mergeable",
145 additions: 0,
146 deletions: 0,
147 changedFiles: 0,
148 headBranch: "feature",123 headBranch: "feature",
149 baseBranch: "main",124 baseBranch: "main",
150 createdAt: "2026-08-01T00:00:00.000Z",
151 updatedAt: input.updatedAt ?? NOW,125 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 },
158 };126 };
159}127}
160128
...@@ -162,7 +130,7 @@ interface HarnessOptions {...@@ -162,7 +130,7 @@ interface HarnessOptions {
162 readonly snapshot: OrchestrationShellSnapshot;130 readonly snapshot: OrchestrationShellSnapshot;
163 readonly settings?: ServerSettings;131 readonly settings?: ServerSettings;
164 readonly branchPullRequest?: GitManager["Service"]["branchPullRequest"];132 readonly branchPullRequest?: GitManager["Service"]["branchPullRequest"];
165 readonly pullRequestDetail?: PullRequestService["Service"]["detail"];133 readonly pullRequestSummary?: PullRequestService["Service"]["summary"];
166 readonly onDispatch?: (134 readonly onDispatch?: (
167 command: AutoSettleCommand,135 command: AutoSettleCommand,
168 ) => Effect.Effect<void, OrchestrationCommandInvariantError>;136 ) => Effect.Effect<void, OrchestrationCommandInvariantError>;
...@@ -179,13 +147,14 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:...@@ -179,13 +147,14 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
179 const branchCalls = yield* Ref.make<147 const branchCalls = yield* Ref.make<
180 ReadonlyArray<{ readonly cwd: string; readonly branch: string }>148 ReadonlyArray<{ readonly cwd: string; readonly branch: string }>
181 >([]);149 >([]);
182 const detailCalls = yield* Ref.make<150 const summaryCalls = yield* Ref.make<
183 ReadonlyArray<{151 ReadonlyArray<{
184 readonly projectId: ProjectId;152 readonly projectId: ProjectId;
185 readonly repository: string;153 readonly repository: string;
186 readonly number: number;154 readonly number: number;
187 }>155 }>
188 >([]);156 >([]);
157 const summaryRecovery = yield* Ref.make<ReadonlyArray<boolean | undefined>>([]);
189158
190 const updateSettings = (patch: ServerSettingsPatch) =>159 const updateSettings = (patch: ServerSettingsPatch) =>
191 Effect.gen(function* () {160 Effect.gen(function* () {
...@@ -200,18 +169,23 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:...@@ -200,18 +169,23 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
200 Effect.andThen(options.branchPullRequest?.(input) ?? Effect.succeed(null)),169 Effect.andThen(options.branchPullRequest?.(input) ?? Effect.succeed(null)),
201 );170 );
202171
203 const pullRequestDetail: PullRequestService["Service"]["detail"] = (input) =>172 const pullRequestSummary: PullRequestService["Service"]["summary"] = (input, readOptions) =>
204 Ref.update(detailCalls, (calls) => [...calls, input]).pipe(173 Effect.gen(function* () {
205 Effect.andThen(174 yield* Ref.update(summaryCalls, (calls) => [...calls, input]);
206 options.pullRequestDetail?.(input) ??175 yield* Ref.update(summaryRecovery, (values) => [
176 ...values,
177 readOptions?.recoverTransientFailure,
178 ]);
179 return yield* (
180 options.pullRequestSummary?.(input, readOptions) ??
207 Effect.succeed(181 Effect.succeed(
208 makePullRequestDetail({182 makePullRequestSummary({
209 ...input,183 ...input,
210 state: "open",184 state: "open",
211 }),185 }),
212 ),186 )
213 ),187 );
214 );188 });
215189
216 const dispatch: OrchestrationEngineShape["dispatch"] = (command) => {190 const dispatch: OrchestrationEngineShape["dispatch"] = (command) => {
217 if (command.type !== "thread.auto-settle") {191 if (command.type !== "thread.auto-settle") {
...@@ -243,7 +217,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:...@@ -243,7 +217,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
243 ),217 ),
244 }),218 }),
245 Layer.mock(GitManager)({ branchPullRequest }),219 Layer.mock(GitManager)({ branchPullRequest }),
246 Layer.mock(PullRequestService)({ detail: pullRequestDetail }),220 Layer.mock(PullRequestService)({ summary: pullRequestSummary }),
247 Layer.mock(OrchestrationEngineService)({221 Layer.mock(OrchestrationEngineService)({
248 readEvents: () => Stream.empty,222 readEvents: () => Stream.empty,
249 dispatch,223 dispatch,
...@@ -262,7 +236,8 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:...@@ -262,7 +236,8 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
262 snapshotReads,236 snapshotReads,
263 commands,237 commands,
264 branchCalls,238 branchCalls,
265 detailCalls,239 summaryCalls,
240 summaryRecovery,
266 updateSettings,241 updateSettings,
267 layer: ThreadSettlementReactor.layer.pipe(Layer.provide(dependencies)),242 layer: ThreadSettlementReactor.layer.pipe(Layer.provide(dependencies)),
268 };243 };
...@@ -310,8 +285,8 @@ describe("ThreadSettlementReactor", () => {...@@ -310,8 +285,8 @@ describe("ThreadSettlementReactor", () => {
310 [makeProject(), makeProject(LINKED_PROJECT_ID, "/workspace/linked")],285 [makeProject(), makeProject(LINKED_PROJECT_ID, "/workspace/linked")],
311 ),286 ),
312 branchPullRequest: () => Effect.succeed(null),287 branchPullRequest: () => Effect.succeed(null),
313 pullRequestDetail: (input) =>288 pullRequestSummary: (input) =>
314 Effect.succeed(makePullRequestDetail({ ...input, state: "closed" })),289 Effect.succeed(makePullRequestSummary({ ...input, state: "closed" })),
315 });290 });
316291
317 yield* Effect.gen(function* () {292 yield* Effect.gen(function* () {
...@@ -342,9 +317,10 @@ describe("ThreadSettlementReactor", () => {...@@ -342,9 +317,10 @@ describe("ThreadSettlementReactor", () => {
342 assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), [317 assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), [
343 { cwd: "/workspace/project", branch: "inactive-feature" },318 { cwd: "/workspace/project", branch: "inactive-feature" },
344 ]);319 ]);
345 assert.deepStrictEqual(yield* Ref.get(fixture.detailCalls), [320 assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), [
346 { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 42 },321 { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 42 },
347 ]);322 ]);
323 assert.deepStrictEqual(yield* Ref.get(fixture.summaryRecovery), [false]);
348 }).pipe(Effect.provide(fixture.layer));324 }).pipe(Effect.provide(fixture.layer));
349 }),325 }),
350 ),326 ),
...@@ -480,10 +456,10 @@ describe("ThreadSettlementReactor", () => {...@@ -480,10 +456,10 @@ describe("ThreadSettlementReactor", () => {
480 ],456 ],
481 [makeProject(), makeProject(LINKED_PROJECT_ID, "/workspace/linked")],457 [makeProject(), makeProject(LINKED_PROJECT_ID, "/workspace/linked")],
482 ),458 ),
483 pullRequestDetail: () =>459 pullRequestSummary: () =>
484 Effect.fail(460 Effect.fail(
485 new PullRequestOperationError({461 new PullRequestOperationError({
486 operation: "detail",462 operation: "summary",
487 detail: "host unavailable",463 detail: "host unavailable",
488 }),464 }),
489 ),465 ),
...@@ -497,7 +473,7 @@ describe("ThreadSettlementReactor", () => {...@@ -497,7 +473,7 @@ describe("ThreadSettlementReactor", () => {
497 (yield* Ref.get(fixture.commands)).map((command) => command.threadId),473 (yield* Ref.get(fixture.commands)).map((command) => command.threadId),
498 [ThreadId.make("inactive-without-pr")],474 [ThreadId.make("inactive-without-pr")],
499 );475 );
500 assert.strictEqual((yield* Ref.get(fixture.detailCalls)).length, 1);476 assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1);
501 }).pipe(Effect.provide(fixture.layer));477 }).pipe(Effect.provide(fixture.layer));
502 }),478 }),
503 ),479 ),
...@@ -521,8 +497,8 @@ describe("ThreadSettlementReactor", () => {...@@ -521,8 +497,8 @@ describe("ThreadSettlementReactor", () => {
521 ],497 ],
522 [makeProject(LINKED_PROJECT_ID, "/workspace/linked")],498 [makeProject(LINKED_PROJECT_ID, "/workspace/linked")],
523 ),499 ),
524 pullRequestDetail: (input) =>500 pullRequestSummary: (input) =>
525 Effect.succeed(makePullRequestDetail({ ...input, state: "open" })),501 Effect.succeed(makePullRequestSummary({ ...input, state: "open" })),
526 });502 });
527503
528 yield* Effect.gen(function* () {504 yield* Effect.gen(function* () {
...@@ -530,7 +506,7 @@ describe("ThreadSettlementReactor", () => {...@@ -530,7 +506,7 @@ describe("ThreadSettlementReactor", () => {
530 yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);506 yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
531507
532 assert.deepStrictEqual(yield* Ref.get(fixture.commands), []);508 assert.deepStrictEqual(yield* Ref.get(fixture.commands), []);
533 assert.deepStrictEqual(yield* Ref.get(fixture.detailCalls), [509 assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), [
534 { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 10 },510 { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 10 },
535 ]);511 ]);
536 assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), []);512 assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), []);
...@@ -569,8 +545,8 @@ describe("ThreadSettlementReactor", () => {...@@ -569,8 +545,8 @@ describe("ThreadSettlementReactor", () => {
569 ],545 ],
570 ),546 ),
571 branchPullRequest: () => Effect.succeed({ state: "closed", updatedAt: NOW }),547 branchPullRequest: () => Effect.succeed({ state: "closed", updatedAt: NOW }),
572 pullRequestDetail: (input) =>548 pullRequestSummary: (input) =>
573 Effect.succeed(makePullRequestDetail({ ...input, state: "merged" })),549 Effect.succeed(makePullRequestSummary({ ...input, state: "merged" })),
574 });550 });
575551
576 yield* Effect.gen(function* () {552 yield* Effect.gen(function* () {
...@@ -580,7 +556,7 @@ describe("ThreadSettlementReactor", () => {...@@ -580,7 +556,7 @@ describe("ThreadSettlementReactor", () => {
580 assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), [556 assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), [
581 { cwd: "/workspace/project-root", branch: "saved-feature" },557 { cwd: "/workspace/project-root", branch: "saved-feature" },
582 ]);558 ]);
583 assert.deepStrictEqual(yield* Ref.get(fixture.detailCalls), [559 assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), [
584 { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 77 },560 { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 77 },
585 ]);561 ]);
586 assert.deepStrictEqual(562 assert.deepStrictEqual(
apps/server/src/orchestration/ThreadSettlementReactor.ts+12-6
...@@ -69,12 +69,18 @@ export const make = Effect.gen(function* () {...@@ -69,12 +69,18 @@ export const make = Effect.gen(function* () {
69 if (!projects.has(thread.linkedPullRequest.projectId)) {69 if (!projects.has(thread.linkedPullRequest.projectId)) {
70 return yield* Effect.die(new Error("linked pull request project not found"));70 return yield* Effect.die(new Error("linked pull request project not found"));
71 }71 }
72 const detail = yield* pullRequests.detail({72 const summary = yield* pullRequests.summary(
73 projectId: thread.linkedPullRequest.projectId,73 {
74 repository: thread.linkedPullRequest.repository,74 projectId: thread.linkedPullRequest.projectId,
75 number: thread.linkedPullRequest.number,75 repository: thread.linkedPullRequest.repository,
76 });76 number: thread.linkedPullRequest.number,
77 return { state: detail.state, updatedAt: detail.updatedAt } satisfies SettlementPullRequest;77 },
78 { recoverTransientFailure: false },
79 );
80 return {
81 state: summary.state,
82 updatedAt: summary.updatedAt,
83 } satisfies SettlementPullRequest;
78 }84 }
79 if (thread.branch === null) return null;85 if (thread.branch === null) return null;
80 const project = projects.get(thread.projectId);86 const project = projects.get(thread.projectId);
apps/server/src/pullRequest/GitHubPullRequestCli.test.ts+43
...@@ -10,12 +10,14 @@ import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";...@@ -10,12 +10,14 @@ import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";
10import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts";10import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts";
1111
12const mockedExecute = vi.fn<GitHubCli.GitHubCli["Service"]["execute"]>();12const mockedExecute = vi.fn<GitHubCli.GitHubCli["Service"]["execute"]>();
13const mockedGetPullRequest = vi.fn<GitHubCli.GitHubCli["Service"]["getPullRequest"]>();
1314
14const layer = it.layer(15const layer = it.layer(
15 GitHubPullRequestCli.layer.pipe(16 GitHubPullRequestCli.layer.pipe(
16 Layer.provide(17 Layer.provide(
17 Layer.mock(GitHubCli.GitHubCli)({18 Layer.mock(GitHubCli.GitHubCli)({
18 execute: mockedExecute,19 execute: mockedExecute,
20 getPullRequest: mockedGetPullRequest,
19 }),21 }),
20 ),22 ),
21 Layer.provide(GitHubGraphQlBudget.layer),23 Layer.provide(GitHubGraphQlBudget.layer),
...@@ -177,9 +179,50 @@ function searchQueryOfCall(index: number): string | undefined {...@@ -177,9 +179,50 @@ function searchQueryOfCall(index: number): string | undefined {
177179
178afterEach(() => {180afterEach(() => {
179 mockedExecute.mockReset();181 mockedExecute.mockReset();
182 mockedGetPullRequest.mockReset();
180});183});
181184
182layer("GitHubPullRequestCli.layer", (it) => {185layer("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
183 it.effect("asks for one row more than the page, to probe for a next page", () =>226 it.effect("asks for one row more than the page, to probe for a next page", () =>
184 Effect.gen(function* () {227 Effect.gen(function* () {
185 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1))));228 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...@@ -123,6 +123,25 @@ export class GitHubViewerLoginUnavailableError extends Schema.TaggedErrorClass<G
123 }123 }
124}124}
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
126/** Not a decode failure: the reader asked to carry on from a cursor this walk never handed out. */145/** Not a decode failure: the reader asked to carry on from a cursor this walk never handed out. */
127export class GitHubDiffCursorError extends Schema.TaggedErrorClass<GitHubDiffCursorError>()(146export class GitHubDiffCursorError extends Schema.TaggedErrorClass<GitHubDiffCursorError>()(
128 "GitHubDiffCursorError",147 "GitHubDiffCursorError",
...@@ -250,7 +269,8 @@ export type GitHubPullRequestCliError =...@@ -250,7 +269,8 @@ export type GitHubPullRequestCliError =
250 | GitHubRepositorySelectorError269 | GitHubRepositorySelectorError
251 | GitHubSubjectScopeError270 | GitHubSubjectScopeError
252 | SourceControlRateLimit.SourceControlRateLimitPausedError271 | SourceControlRateLimit.SourceControlRateLimitPausedError
253 | GitHubViewerLoginUnavailableError;272 | GitHubViewerLoginUnavailableError
273 | GitHubPullRequestUpdatedAtUnavailableError;
254274
255/** A large pull request can produce a multi-megabyte patch; past this it is truncated. */275/** A large pull request can produce a multi-megabyte patch; past this it is truncated. */
256const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;276const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
...@@ -360,6 +380,24 @@ export class GitHubPullRequestCli extends Context.Service<...@@ -360,6 +380,24 @@ export class GitHubPullRequestCli extends Context.Service<
360 }>;380 }>;
361 }) => Effect.Effect<ReadonlyArray<GitHubPullRequestStat>, GitHubPullRequestCliError>;381 }) => 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
363 readonly getPullRequestDetail: (input: {401 readonly getPullRequestDetail: (input: {
364 readonly cwd: string;402 readonly cwd: string;
365 readonly repository: string;403 readonly repository: string;
...@@ -1326,6 +1364,35 @@ export const make = Effect.gen(function* () {...@@ -1326,6 +1364,35 @@ export const make = Effect.gen(function* () {
1326 ).pipe(Effect.map((results) => results.flat()));1364 ).pipe(Effect.map((results) => results.flat()));
1327 },1365 },
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
1329 getPullRequestDetail: (input) =>1396 getPullRequestDetail: (input) =>
1330 github1397 github
1331 .execute({1398 .execute({
apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts+37
...@@ -7,6 +7,43 @@ import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";...@@ -7,6 +7,43 @@ import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";
7import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts";7import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts";
8import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts";8import 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
10describe("gitHubViewerPermissions", () => {47describe("gitHubViewerPermissions", () => {
11 it("offers everything to a viewer who can write to the repository", () => {48 it("offers everything to a viewer who can write to the repository", () => {
12 expect(gitHubViewerPermissions({ canWrite: true, canUpdate: true, didAuthor: false })).toEqual({49 expect(gitHubViewerPermissions({ canWrite: true, canUpdate: true, didAuthor: false })).toEqual({
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+23-1
...@@ -1,4 +1,7 @@...@@ -1,4 +1,7 @@
1import * as Cache from "effect/Cache";
2import * as Duration from "effect/Duration";
1import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";
2import type {5import type {
3 PullRequestActor,6 PullRequestActor,
4 PullRequestCapabilities,7 PullRequestCapabilities,
...@@ -129,6 +132,22 @@ const rendersEmpty = (body: string): boolean =>...@@ -129,6 +132,22 @@ const rendersEmpty = (body: string): boolean =>
129export const make = Effect.gen(function* () {132export const make = Effect.gen(function* () {
130 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;133 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
132 const fail = (operation: string) => (error: GitHubPullRequestCli.GitHubPullRequestCliError) =>151 const fail = (operation: string) => (error: GitHubPullRequestCli.GitHubPullRequestCliError) =>
133 new PullRequestProviderError({152 new PullRequestProviderError({
134 provider: "github",153 provider: "github",
...@@ -223,6 +242,9 @@ export const make = Effect.gen(function* () {...@@ -223,6 +242,9 @@ export const make = Effect.gen(function* () {
223 })242 })
224 .pipe(Effect.mapError(fail("listChangeRequestStats"))),243 .pipe(Effect.mapError(fail("listChangeRequestStats"))),
225244
245 getChangeRequestSummary: (input) =>
246 cli.getPullRequestSummary(input).pipe(Effect.mapError(fail("getChangeRequestSummary"))),
247
226 getChangeRequest: (input) =>248 getChangeRequest: (input) =>
227 Effect.all(249 Effect.all(
228 [250 [
...@@ -244,7 +266,7 @@ export const make = Effect.gen(function* () {...@@ -244,7 +266,7 @@ export const make = Effect.gen(function* () {
244 ),266 ),
245 ),267 ),
246 ),268 ),
247 cli.getRepositoryAccess({269 getRepositoryAccess({
248 cwd: input.cwd,270 cwd: input.cwd,
249 repository: input.repository,271 repository: input.repository,
250 host: input.host,272 host: input.host,
apps/server/src/pullRequest/PullRequestProvider.ts+19
...@@ -87,6 +87,17 @@ export interface ProviderChangeRequest {...@@ -87,6 +87,17 @@ export interface ProviderChangeRequest {
87 readonly checksState?: PullRequestChecksState | null | undefined;87 readonly checksState?: PullRequestChecksState | null | undefined;
88}88}
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
90export interface ProviderChangeRequestPage {101export interface ProviderChangeRequestPage {
91 readonly items: ReadonlyArray<ProviderChangeRequest>;102 readonly items: ReadonlyArray<ProviderChangeRequest>;
92 /** True when the host has more rows than the page size asked for. */103 /** True when the host has more rows than the page size asked for. */
...@@ -301,6 +312,14 @@ export interface PullRequestProviderApi {...@@ -301,6 +312,14 @@ export interface PullRequestProviderApi {
301 input: ProviderRepositoryRef & { readonly number: number },312 input: ProviderRepositoryRef & { readonly number: number },
302 ) => Effect.Effect<ProviderChangeRequestDetail, PullRequestProviderError>;313 ) => 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
304 /** Comments, line threads, and commits, kept off the critical path for the core detail. */323 /** Comments, line threads, and commits, kept off the critical path for the core detail. */
305 readonly getChangeRequestActivity: (324 readonly getChangeRequestActivity: (
306 input: ProviderRepositoryRef & { readonly number: number },325 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...@@ -2293,6 +2293,105 @@ it.effect("answers a repeated listing from cache, and concurrent readers share o
2293 }),2293 }),
2294);2294);
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
2296it.effect("returns the refreshed listing on the first read after its cache expires", () =>2395it.effect("returns the refreshed listing on the first read after its cache expires", () =>
2297 Effect.gen(function* () {2396 Effect.gen(function* () {
2298 let hostCalls = 0;2397 let hostCalls = 0;
...@@ -2371,10 +2470,15 @@ it.effect("a listing narrowed to some projects is its own cache entry", () =>...@@ -2371,10 +2470,15 @@ it.effect("a listing narrowed to some projects is its own cache entry", () =>
2371it.effect("an explicit invalidation makes the next listing ask the host again", () =>2470it.effect("an explicit invalidation makes the next listing ask the host again", () =>
2372 Effect.gen(function* () {2471 Effect.gen(function* () {
2373 let hostCalls = 0;2472 let hostCalls = 0;
2473 let viewerCalls = 0;
2374 const service = yield* makeService({2474 const service = yield* makeService({
2375 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],2475 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
2376 providers: [2476 providers: [
2377 fakeProvider("github", {2477 fakeProvider("github", {
2478 getViewer: () => {
2479 viewerCalls += 1;
2480 return Effect.succeed("bilal");
2481 },
2378 listChangeRequests: () => {2482 listChangeRequests: () => {
2379 hostCalls += 1;2483 hostCalls += 1;
2380 return Effect.succeed({ items: [], truncated: false, continues: false });2484 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",...@@ -2387,6 +2491,7 @@ it.effect("an explicit invalidation makes the next listing ask the host again",
2387 yield* service.invalidate({});2491 yield* service.invalidate({});
2388 yield* service.list({ state: "open" });2492 yield* service.list({ state: "open" });
2389 assert.strictEqual(hostCalls, 2);2493 assert.strictEqual(hostCalls, 2);
2494 assert.strictEqual(viewerCalls, 2);
23902495
2391 // Forgetting one change request leaves the listings shared.2496 // Forgetting one change request leaves the listings shared.
2392 yield* service.invalidate({2497 yield* service.invalidate({
...@@ -2829,6 +2934,115 @@ it.effect(...@@ -2829,6 +2934,115 @@ it.effect(
2829 }),2934 }),
2830);2935);
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
2832it.effect("carries an armed auto-merge through to the detail, and silence as silence", () =>3046it.effect("carries an armed auto-merge through to the detail, and silence as silence", () =>
2833 Effect.gen(function* () {3047 Effect.gen(function* () {
2834 const detailWith = (autoMergeEnabled: boolean | undefined) =>3048 const detailWith = (autoMergeEnabled: boolean | undefined) =>
apps/server/src/pullRequest/PullRequestService.ts+249-80
...@@ -5,6 +5,7 @@ import * as Duration from "effect/Duration";...@@ -5,6 +5,7 @@ import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
6import * as Exit from "effect/Exit";6import * as Exit from "effect/Exit";
7import * as Layer from "effect/Layer";7import * as Layer from "effect/Layer";
8import * as Schema from "effect/Schema";
8import {9import {
9 PullRequestOperationError,10 PullRequestOperationError,
10 PullRequestUnavailableError,11 PullRequestUnavailableError,
...@@ -38,6 +39,7 @@ import {...@@ -38,6 +39,7 @@ import {
38 type PullRequestReviewerCandidateList,39 type PullRequestReviewerCandidateList,
39 type PullRequestReviewerRequestInput,40 type PullRequestReviewerRequestInput,
40 type PullRequestSubmitReviewInput,41 type PullRequestSubmitReviewInput,
42 type PullRequestSummary,
41 type PullRequestThreadReplyInput,43 type PullRequestThreadReplyInput,
42 type PullRequestThreadResolutionInput,44 type PullRequestThreadResolutionInput,
43 type PullRequestThreadCommentsInput,45 type PullRequestThreadCommentsInput,
...@@ -96,6 +98,7 @@ const REPOSITORY_SEARCH_CHUNK = 100;...@@ -96,6 +98,7 @@ const REPOSITORY_SEARCH_CHUNK = 100;
96 * `invalidate` rather than a flag on the read, so an ordinary read can never opt out.98 * `invalidate` rather than a flag on the read, so an ordinary read can never opt out.
97 */99 */
98const LIST_CACHE_TTL = Duration.seconds(30);100const LIST_CACHE_TTL = Duration.seconds(30);
101const SUMMARY_CACHE_TTL = Duration.seconds(60);
99const DETAIL_CACHE_TTL = Duration.seconds(15);102const DETAIL_CACHE_TTL = Duration.seconds(15);
100const DIFF_CACHE_TTL = Duration.seconds(60);103const DIFF_CACHE_TTL = Duration.seconds(60);
101/** A commit is content-addressed, so its own diff cannot change under its key. */104/** 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);...@@ -106,10 +109,14 @@ const LIST_STATS_CACHE_TTL = Duration.seconds(60);
106const DIFF_STALE_WINDOW = Duration.minutes(10);109const DIFF_STALE_WINDOW = Duration.minutes(10);
107/** How long one host's signed-in login is believed without asking its CLI again. */110/** How long one host's signed-in login is believed without asking its CLI again. */
108const VIEWER_CACHE_TTL = Duration.minutes(10);111const 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);
109const LIST_CACHE_CAPACITY = 64;115const LIST_CACHE_CAPACITY = 64;
110const LIST_STATS_CACHE_CAPACITY = 32;116const LIST_STATS_CACHE_CAPACITY = 32;
111const DETAIL_CACHE_CAPACITY = 128;117const DETAIL_CACHE_CAPACITY = 128;
112const DIFF_CACHE_CAPACITY = 128;118const DIFF_CACHE_CAPACITY = 128;
119const VIEWER_CACHE_CAPACITY = 32;
113120
114export type PullRequestError = PullRequestUnavailableError | PullRequestOperationError;121export type PullRequestError = PullRequestUnavailableError | PullRequestOperationError;
115122
...@@ -122,6 +129,10 @@ export class PullRequestService extends Context.Service<...@@ -122,6 +129,10 @@ export class PullRequestService extends Context.Service<
122 readonly listStats: (129 readonly listStats: (
123 input: PullRequestListStatsInput,130 input: PullRequestListStatsInput,
124 ) => Effect.Effect<PullRequestListStatsResult, PullRequestError>;131 ) => Effect.Effect<PullRequestListStatsResult, PullRequestError>;
132 readonly summary: (
133 input: PullRequestRef,
134 options?: { readonly recoverTransientFailure?: boolean },
135 ) => Effect.Effect<PullRequestSummary, PullRequestError>;
125 readonly detail: (input: PullRequestRef) => Effect.Effect<PullRequestDetail, PullRequestError>;136 readonly detail: (input: PullRequestRef) => Effect.Effect<PullRequestDetail, PullRequestError>;
126 readonly activity: (137 readonly activity: (
127 input: PullRequestRef,138 input: PullRequestRef,
...@@ -430,6 +441,11 @@ function withRateLimitBackoff(...@@ -430,6 +441,11 @@ function withRateLimitBackoff(
430 listChangeRequestStats: wrap("listChangeRequestStats", api.listChangeRequestStats),441 listChangeRequestStats: wrap("listChangeRequestStats", api.listChangeRequestStats),
431 }),442 }),
432 getChangeRequest: wrap("getChangeRequest", api.getChangeRequest),443 getChangeRequest: wrap("getChangeRequest", api.getChangeRequest),
444 ...(api.getChangeRequestSummary === undefined
445 ? {}
446 : {
447 getChangeRequestSummary: wrap("getChangeRequestSummary", api.getChangeRequestSummary),
448 }),
433 getChangeRequestActivity: wrap("getChangeRequestActivity", api.getChangeRequestActivity),449 getChangeRequestActivity: wrap("getChangeRequestActivity", api.getChangeRequestActivity),
434 ...(api.getReviewThreadComments === undefined450 ...(api.getReviewThreadComments === undefined
435 ? {}451 ? {}
...@@ -699,6 +715,46 @@ export const make = Effect.gen(function* () {...@@ -699,6 +715,46 @@ export const make = Effect.gen(function* () {
699 // "is this host set up" answer the provider switcher shows, and holding it would keep saying715 // "is this host set up" answer the provider switcher shows, and holding it would keep saying
700 // signed-out after the reader has signed in.716 // signed-out after the reader has signed in.
701 const viewersByHost = new Map<string, { readonly at: number; readonly result: ResolvedViewer }>();717 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
703 const resolveViewers = (759 const resolveViewers = (
704 projects: ReadonlyArray<SupportedProject>,760 projects: ReadonlyArray<SupportedProject>,
...@@ -718,18 +774,8 @@ export const make = Effect.gen(function* () {...@@ -718,18 +774,8 @@ export const make = Effect.gen(function* () {
718 // unreadable worktree would otherwise report the whole host as signed out.774 // unreadable worktree would otherwise report the whole host as signed out.
719 const roots =775 const roots =
720 viewerRoots.get(host) ?? forHost.map(({ project }) => project.workspaceRoot);776 viewerRoots.get(host) ?? forHost.map(({ project }) => project.workspaceRoot);
721 return Effect.firstSuccessOf(roots.map((cwd) => api.getViewer({ cwd }))).pipe(777 const key = JSON.stringify([host, api.kind, [...new Set(roots)].sort()]);
722 Effect.map((viewer) => ({778 return Cache.get(viewerFlights, key);
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 );
733 }),779 }),
734 { concurrency: REPOSITORY_CONCURRENCY },780 { concurrency: REPOSITORY_CONCURRENCY },
735 );781 );
...@@ -809,6 +855,13 @@ export const make = Effect.gen(function* () {...@@ -809,6 +855,13 @@ export const make = Effect.gen(function* () {
809 };855 };
810 };856 };
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
812 const listUncached: PullRequestService["Service"]["list"] = (input) =>865 const listUncached: PullRequestService["Service"]["list"] = (input) =>
813 Effect.gen(function* () {866 Effect.gen(function* () {
814 const involvement = input.involvement ?? "all";867 const involvement = input.involvement ?? "all";
...@@ -1018,61 +1071,80 @@ export const make = Effect.gen(function* () {...@@ -1018,61 +1071,80 @@ export const make = Effect.gen(function* () {
1018 ? {}1071 ? {}
1019 : { cursor: { updatedBefore: cursor.updatedBefore, delivered: cursor.delivered } }),1072 : { cursor: { updatedBefore: cursor.updatedBefore, delivered: cursor.delivered } }),
1020 }).pipe(1073 }).pipe(
1021 Effect.flatMap((page) => {1074 Effect.flatMap((page) =>
1022 const rows = new Map<string, Array<ProviderChangeRequest>>();1075 Effect.flatMap(Clock.currentTimeMillis, (now) => {
1023 for (const item of page.items) {1076 const rows = new Map<string, Array<ProviderChangeRequest>>();
1024 const key = item.repository.trim().toLowerCase();1077 for (const [key, visibleAt] of searchVisibleAt) {
1025 const held = rows.get(key);1078 if (now - visibleAt > Duration.toMillis(SEARCH_VISIBILITY_TTL)) {
1026 if (held === undefined) rows.set(key, [item]);1079 searchVisibleAt.delete(key);
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);
1050 }1080 }
1051 const cursorHere = cursorOf(project);1081 }
1052 const items =1082 for (const item of page.items) {
1053 cursorHere === undefined1083 const key = item.repository.trim().toLowerCase();
1054 ? fetched1084 const held = rows.get(key);
1055 : fetched.filter(1085 if (held === undefined) rows.set(key, [item]);
1056 (item) =>1086 else held.push(item);
1057 item.updatedAt !== cursorHere.updatedBefore ||1087 searchVisibleAt.set(searchVisibilityKey(first.host, item.repository), now);
1058 !cursorHere.seenAt.includes(item.number),1088 }
1059 );1089 // The oldest row of the whole slice, which is how far every repository in it has now
1060 return Effect.succeed({1090 // been read — including the ones that contributed nothing to it.
1061 key: listCursorKey(project.host, project.repository),1091 const boundary = page.items.reduce<string | null>(
1062 entries: items1092 (oldest, item) =>
1063 .filter((item) => matchesRowFilters(item, input.filters, viewer))1093 oldest === null || item.updatedAt < oldest ? item.updatedAt : oldest,
1064 .map((item) => toEntry({ project, item, viewer })),1094 null,
1065 errors: [],1095 );
1066 truncated: page.truncated,1096 return Effect.forEach(
1067 nextCursor:1097 chunk,
1068 page.truncated && boundary !== null1098 (project): Effect.Effect<RepositoryBatch> => {
1069 ? listCursorAt(cursorHere, boundary, fetched, items.length)1099 const fetched = rows.get(project.repository.trim().toLowerCase()) ?? [];
1070 : null,1100 // GitHub does not index every repository for search — a renamed one answers for
1071 });1101 // its old name with silence rather than with an error — so a repository the
1072 },1102 // search said nothing at all about is read on its own, once, before it is
1073 { concurrency: REPOSITORY_CONCURRENCY },1103 // believed. Only on its first slice: after that it has a boundary to carry on
1074 );1104 // from, and silence past one means the rows are older rather than absent. That
1075 }),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 ),
1076 Effect.catch(separately),1148 Effect.catch(separately),
1077 );1149 );
1078 };1150 };
...@@ -1127,6 +1199,39 @@ export const make = Effect.gen(function* () {...@@ -1127,6 +1199,39 @@ export const make = Effect.gen(function* () {
1127 const viewerOf = (project: SupportedProject): Effect.Effect<string | null> =>1199 const viewerOf = (project: SupportedProject): Effect.Effect<string | null> =>
1128 resolveViewers([project], new Map()).pipe(Effect.map(([resolved]) => resolved?.viewer ?? null));1200 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
1130 const detailUncached: PullRequestService["Service"]["detail"] = (input) =>1235 const detailUncached: PullRequestService["Service"]["detail"] = (input) =>
1131 requireProject(input).pipe(1236 requireProject(input).pipe(
1132 Effect.flatMap((project) =>1237 Effect.flatMap((project) =>
...@@ -1848,6 +1953,50 @@ export const make = Effect.gen(function* () {...@@ -1848,6 +1953,50 @@ export const make = Effect.gen(function* () {
1848 };1953 };
1849 })();1954 })();
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
1851 // Epochs are the invalidation mechanism: a key carries its scope's epoch, so bumping the2000 // Epochs are the invalidation mechanism: a key carries its scope's epoch, so bumping the
1852 // epoch strands every entry made under the old one — no enumerating a cache whose keys2001 // epoch strands every entry made under the old one — no enumerating a cache whose keys
1853 // (cursors, commits) nothing holds a list of. The counter is shared and monotonic so a2002 // (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* () {...@@ -1858,6 +2007,8 @@ export const make = Effect.gen(function* () {
1858 const REF_EPOCH_CAPACITY = 2_048;2007 const REF_EPOCH_CAPACITY = 2_048;
1859 const refScope = (ref: PullRequestRef) => `${ref.projectId} ${ref.repository} ${ref.number}`;2008 const refScope = (ref: PullRequestRef) => `${ref.projectId} ${ref.repository} ${ref.number}`;
1860 const refEpoch = (ref: PullRequestRef) => refEpochs.get(refScope(ref)) ?? 0;2009 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]);
1861 const bumpRefEpoch = (ref: PullRequestRef) => {2012 const bumpRefEpoch = (ref: PullRequestRef) => {
1862 const scope = refScope(ref);2013 const scope = refScope(ref);
1863 if (!refEpochs.has(scope) && refEpochs.size >= REF_EPOCH_CAPACITY) {2014 if (!refEpochs.has(scope) && refEpochs.size >= REF_EPOCH_CAPACITY) {
...@@ -1884,6 +2035,24 @@ export const make = Effect.gen(function* () {...@@ -1884,6 +2035,24 @@ export const make = Effect.gen(function* () {
1884 };2035 };
1885 };2036 };
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
1887 // Keys serialize positionally and parse back in the lookup, so the cache is the only holder2056 // Keys serialize positionally and parse back in the lookup, so the cache is the only holder
1888 // of in-flight state: concurrent identical reads coalesce on the key into one host request.2057 // of in-flight state: concurrent identical reads coalesce on the key into one host request.
1889 // The continuation cursors are part of the key, entries sorted so one continuation is one2058 // 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* () {...@@ -1972,8 +2141,8 @@ export const make = Effect.gen(function* () {
1972 },2141 },
1973 );2142 );
1974 const detail: PullRequestService["Service"]["detail"] = (input) => {2143 const detail: PullRequestService["Service"]["detail"] = (input) => {
1975 const key = JSON.stringify([refEpoch(input), input.projectId, input.repository, input.number]);2144 const key = refCacheKey(input);
1976 return Cache.get(detailCache, key);2145 return lastGoodDetail.read(key, Cache.get(detailCache, key));
1977 };2146 };
19782147
1979 const activityCache = yield* Cache.makeWith(2148 const activityCache = yield* Cache.makeWith(
...@@ -1987,7 +2156,7 @@ export const make = Effect.gen(function* () {...@@ -1987,7 +2156,7 @@ export const make = Effect.gen(function* () {
1987 },2156 },
1988 );2157 );
1989 const activity: PullRequestService["Service"]["activity"] = (input) => {2158 const activity: PullRequestService["Service"]["activity"] = (input) => {
1990 const key = JSON.stringify([refEpoch(input), input.projectId, input.repository, input.number]);2159 const key = refCacheKey(input);
1991 return Cache.get(activityCache, key);2160 return Cache.get(activityCache, key);
1992 };2161 };
19932162
...@@ -2059,17 +2228,16 @@ export const make = Effect.gen(function* () {...@@ -2059,17 +2228,16 @@ export const make = Effect.gen(function* () {
2059 return Cache.get(listStatsCache, key);2228 return Cache.get(listStatsCache, key);
2060 };2229 };
20612230
2062 const invalidate: PullRequestService["Service"]["invalidate"] = (input) =>2231 const invalidate: PullRequestService["Service"]["invalidate"] = (input) => {
2063 Effect.sync(() => {2232 const reference = input.reference;
2064 if (input.reference === undefined) {2233 if (reference !== undefined) {
2065 listingsEpoch = ++epochCounter;2234 return Effect.sync(() => bumpRefEpoch(reference));
2066 // A whole-workspace refresh is the reader asking to be re-answered from the hosts,2235 }
2067 // and that includes who the hosts say they are.2236 return Effect.sync(() => {
2068 viewersByHost.clear();2237 listingsEpoch = ++epochCounter;
2069 return;2238 viewersByHost.clear();
2070 }2239 }).pipe(Effect.andThen(Cache.invalidateAll(viewerFlights)));
2071 bumpRefEpoch(input.reference);2240 };
2072 });
20732241
2074 // A mutation's own client re-reads right after it, and every other client's next read must2242 // A mutation's own client re-reads right after it, and every other client's next read must
2075 // see the action too — so a write forgets the change request it touched and the listings its2243 // 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* () {...@@ -2091,6 +2259,7 @@ export const make = Effect.gen(function* () {
2091 return PullRequestService.of({2259 return PullRequestService.of({
2092 list,2260 list,
2093 listStats,2261 listStats,
2262 summary,
2094 detail,2263 detail,
2095 activity,2264 activity,
2096 threadComments,2265 threadComments,
apps/server/src/server.test.ts+2
...@@ -136,6 +136,7 @@ import * as VcsDriver from "./vcs/VcsDriver.ts";...@@ -136,6 +136,7 @@ import * as VcsDriver from "./vcs/VcsDriver.ts";
136import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts";136import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts";
137import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts";137import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts";
138import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts";138import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts";
139import * as VcsProcess from "./vcs/VcsProcess.ts";
139import * as GitWorkflowService from "./git/GitWorkflowService.ts";140import * as GitWorkflowService from "./git/GitWorkflowService.ts";
140import * as ReviewService from "./review/ReviewService.ts";141import * as ReviewService from "./review/ReviewService.ts";
141import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";142import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
...@@ -1041,6 +1042,7 @@ const buildAppUnderTest = (options?: {...@@ -1041,6 +1042,7 @@ const buildAppUnderTest = (options?: {
1041 Layer.provideMerge(ServerSecretStore.layer),1042 Layer.provideMerge(ServerSecretStore.layer),
1042 Layer.provide(workspaceAndProjectServicesLayer),1043 Layer.provide(workspaceAndProjectServicesLayer),
1043 Layer.provideMerge(FetchHttpClient.layer),1044 Layer.provideMerge(FetchHttpClient.layer),
1045 Layer.provide(VcsProcess.layer),
1044 Layer.provide(layerConfig),1046 Layer.provide(layerConfig),
1045 );1047 );
10461048
apps/server/src/server.ts+2-2
...@@ -303,7 +303,6 @@ const PullRequestServiceLive = PullRequestService.layer.pipe(...@@ -303,7 +303,6 @@ const PullRequestServiceLive = PullRequestService.layer.pipe(
303 Layer.provide(PullRequestProviderRegistry.layer),303 Layer.provide(PullRequestProviderRegistry.layer),
304 Layer.provide(SourceControlProviderRegistryLayerLive),304 Layer.provide(SourceControlProviderRegistryLayerLive),
305 Layer.provide(SourceControlRateLimit.layer),305 Layer.provide(SourceControlRateLimit.layer),
306 Layer.provide(VcsProcess.layer),
307);306);
308307
309const GitManagerLayerLive = GitManager.layer.pipe(308const GitManagerLayerLive = GitManager.layer.pipe(
...@@ -713,7 +712,8 @@ export const makeServerLayer = Layer.unwrap(...@@ -713,7 +712,8 @@ export const makeServerLayer = Layer.unwrap(
713 Layer.provideMerge(HttpServerLive),712 Layer.provideMerge(HttpServerLive),
714 Layer.provide(ApplicationObservabilityLive),713 Layer.provide(ApplicationObservabilityLive),
715 Layer.provideMerge(FetchHttpClient.layer),714 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),
717 Layer.provideMerge(PlatformServicesLive),717 Layer.provideMerge(PlatformServicesLive),
718 );718 );
719 }),719 }),
apps/server/src/sourceControl/GitHubCli.test.ts+3-1
...@@ -66,6 +66,7 @@ describe("GitHubCli.layer", () => {...@@ -66,6 +66,7 @@ describe("GitHubCli.layer", () => {
66 headRefName: "feature/pr-threads",66 headRefName: "feature/pr-threads",
67 state: "OPEN",67 state: "OPEN",
68 mergedAt: null,68 mergedAt: null,
69 updatedAt: "2026-08-24T12:34:56Z",
69 isCrossRepository: true,70 isCrossRepository: true,
70 headRepository: {71 headRepository: {
71 nameWithOwner: "octocat/codething-mvp",72 nameWithOwner: "octocat/codething-mvp",
...@@ -91,6 +92,7 @@ describe("GitHubCli.layer", () => {...@@ -91,6 +92,7 @@ describe("GitHubCli.layer", () => {
91 baseRefName: "main",92 baseRefName: "main",
92 headRefName: "feature/pr-threads",93 headRefName: "feature/pr-threads",
93 state: "open",94 state: "open",
95 updatedAt: "2026-08-24T12:34:56.000Z",
94 isCrossRepository: true,96 isCrossRepository: true,
95 headRepositoryNameWithOwner: "octocat/codething-mvp",97 headRepositoryNameWithOwner: "octocat/codething-mvp",
96 headRepositoryOwnerLogin: "octocat",98 headRepositoryOwnerLogin: "octocat",
...@@ -103,7 +105,7 @@ describe("GitHubCli.layer", () => {...@@ -103,7 +105,7 @@ describe("GitHubCli.layer", () => {
103 "view",105 "view",
104 "#42",106 "#42",
105 "--json",107 "--json",
106 "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",108 "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
107 ],109 ],
108 cwd: "/repo",110 cwd: "/repo",
109 timeoutMs: 30_000,111 timeoutMs: 30_000,
apps/server/src/sourceControl/GitHubCli.ts+15-7
...@@ -1,6 +1,8 @@...@@ -1,6 +1,8 @@
1import * as Context from "effect/Context";1import * as Context from "effect/Context";
2import * as DateTime from "effect/DateTime";
2import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
4import * as PlatformError from "effect/PlatformError";6import * as PlatformError from "effect/PlatformError";
5import * as Result from "effect/Result";7import * as Result from "effect/Result";
6import * as Schema from "effect/Schema";8import * as Schema from "effect/Schema";
...@@ -15,6 +17,7 @@ import * as VcsProcess from "../vcs/VcsProcess.ts";...@@ -15,6 +17,7 @@ import * as VcsProcess from "../vcs/VcsProcess.ts";
15import {17import {
16 decodeGitHubPullRequestJson,18 decodeGitHubPullRequestJson,
17 decodeGitHubPullRequestListJson,19 decodeGitHubPullRequestListJson,
20 type NormalizedGitHubPullRequestRecord,
18} from "./gitHubPullRequests.ts";21} from "./gitHubPullRequests.ts";
1922
20const DEFAULT_TIMEOUT_MS = 30_000;23const DEFAULT_TIMEOUT_MS = 30_000;
...@@ -202,11 +205,20 @@ export interface GitHubPullRequestSummary {...@@ -202,11 +205,20 @@ export interface GitHubPullRequestSummary {
202 readonly baseRefName: string;205 readonly baseRefName: string;
203 readonly headRefName: string;206 readonly headRefName: string;
204 readonly state?: "open" | "closed" | "merged";207 readonly state?: "open" | "closed" | "merged";
208 readonly updatedAt?: string;
205 readonly isCrossRepository?: boolean;209 readonly isCrossRepository?: boolean;
206 readonly headRepositoryNameWithOwner?: string | null;210 readonly headRepositoryNameWithOwner?: string | null;
207 readonly headRepositoryOwnerLogin?: string | null;211 readonly headRepositoryOwnerLogin?: string | null;
208}212}
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
210export interface GitHubRepositoryCloneUrls {222export interface GitHubRepositoryCloneUrls {
211 readonly nameWithOwner: string;223 readonly nameWithOwner: string;
212 readonly url: string;224 readonly url: string;
...@@ -373,9 +385,7 @@ export const make = Effect.gen(function* () {...@@ -373,9 +385,7 @@ export const make = Effect.gen(function* () {
373 );385 );
374 }386 }
375387
376 return Effect.succeed(388 return Effect.succeed(decoded.success.map(pullRequestSummary));
377 decoded.success.map(({ updatedAt: _updatedAt, ...summary }) => summary),
378 );
379 }),389 }),
380 ),390 ),
381 ),391 ),
...@@ -388,7 +398,7 @@ export const make = Effect.gen(function* () {...@@ -388,7 +398,7 @@ export const make = Effect.gen(function* () {
388 "view",398 "view",
389 input.reference,399 input.reference,
390 "--json",400 "--json",
391 "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",401 "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
392 ],402 ],
393 }).pipe(403 }).pipe(
394 Effect.map((result) => result.stdout.trim()),404 Effect.map((result) => result.stdout.trim()),
...@@ -405,9 +415,7 @@ export const make = Effect.gen(function* () {...@@ -405,9 +415,7 @@ export const make = Effect.gen(function* () {
405 );415 );
406 }416 }
407417
408 return Effect.succeed(418 return Effect.succeed(pullRequestSummary(decoded.success));
409 (({ updatedAt: _updatedAt, ...summary }) => summary)(decoded.success),
410 );
411 }),419 }),
412 ),420 ),
413 ),421 ),
apps/server/src/sourceControl/GitHubSourceControlProvider.ts+17-5
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as DateTime from "effect/DateTime";
1import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
2import * as Layer from "effect/Layer";3import * as Layer from "effect/Layer";
3import * as Option from "effect/Option";4import * as Option from "effect/Option";
...@@ -29,7 +30,10 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq...@@ -29,7 +30,10 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq
29 baseRefName: summary.baseRefName,30 baseRefName: summary.baseRefName,
30 headRefName: summary.headRefName,31 headRefName: summary.headRefName,
31 state: summary.state ?? "open",32 state: summary.state ?? "open",
32 updatedAt: Option.none(),33 updatedAt:
34 summary.updatedAt === undefined
35 ? Option.none()
36 : Option.some(DateTime.makeUnsafe(summary.updatedAt)),
33 ...(summary.isCrossRepository !== undefined37 ...(summary.isCrossRepository !== undefined
34 ? { isCrossRepository: summary.isCrossRepository }38 ? { isCrossRepository: summary.isCrossRepository }
35 : {}),39 : {}),
...@@ -162,10 +166,18 @@ export const make = Effect.gen(function* () {...@@ -162,10 +166,18 @@ export const make = Effect.gen(function* () {
162 Effect.flatMap((decoded) =>166 Effect.flatMap((decoded) =>
163 Result.isSuccess(decoded)167 Result.isSuccess(decoded)
164 ? Effect.succeed(168 ? Effect.succeed(
165 decoded.success.map((item) => ({169 decoded.success.map((item) => {
166 ...toChangeRequest(item),170 const { updatedAt, ...summary } = item;
167 updatedAt: item.updatedAt,171 return {
168 })),172 ...toChangeRequest({
173 ...summary,
174 ...(Option.isSome(updatedAt)
175 ? { updatedAt: DateTime.formatIso(updatedAt.value) }
176 : {}),
177 }),
178 updatedAt,
179 };
180 }),
169 )181 )
170 : Effect.fail(182 : Effect.fail(
171 new GitHubCli.GitHubChangeRequestListDecodeError({183 new GitHubCli.GitHubChangeRequestListDecodeError({
apps/server/src/vcs/VcsProcess.test.ts+61
...@@ -1,10 +1,14 @@...@@ -1,10 +1,14 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { describe, expect, it } from "@effect/vitest";2import { describe, expect, it } from "@effect/vitest";
3import * as Duration from "effect/Duration";3import * as Duration from "effect/Duration";
4import * as Deferred from "effect/Deferred";
4import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";6import * as Fiber from "effect/Fiber";
6import * as Layer from "effect/Layer";7import * as Layer from "effect/Layer";
8import * as Queue from "effect/Queue";
9import * as Ref from "effect/Ref";
7import { TestClock } from "effect/testing";10import { TestClock } from "effect/testing";
11import { ChildProcessSpawner } from "effect/unstable/process";
812
9import {13import {
10 VcsProcessExitError,14 VcsProcessExitError,
...@@ -45,6 +49,63 @@ const captureProcessResult = (...@@ -45,6 +49,63 @@ const captureProcessResult = (
45 );49 );
4650
47describe("VcsProcess.run", () => {51describe("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
48 it.effect("collects stdout", () =>109 it.effect("collects stdout", () =>
49 Effect.gen(function* () {110 Effect.gen(function* () {
50 const result = yield* run({111 const result = yield* run({
apps/server/src/vcs/VcsProcess.ts+11-1
...@@ -2,6 +2,7 @@ import * as Context from "effect/Context";...@@ -2,6 +2,7 @@ import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";3import * as Layer from "effect/Layer";
4import * as Match from "effect/Match";4import * as Match from "effect/Match";
5import * as Semaphore from "effect/Semaphore";
5import { ChildProcessSpawner } from "effect/unstable/process";6import { ChildProcessSpawner } from "effect/unstable/process";
67
7import {8import {
...@@ -52,6 +53,8 @@ export class VcsProcess extends Context.Service<...@@ -52,6 +53,8 @@ export class VcsProcess extends Context.Service<
52const DEFAULT_TIMEOUT_MS = 30_000;53const DEFAULT_TIMEOUT_MS = 30_000;
53const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;54const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
54const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]";55const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]";
56const VCS_PROCESS_CONCURRENCY = 8;
57const GITHUB_PROCESS_CONCURRENCY = 4;
5558
56const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFailureKind => {59const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFailureKind => {
57 const normalized = stderr.toLowerCase();60 const normalized = stderr.toLowerCase();
...@@ -101,8 +104,10 @@ const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFai...@@ -101,8 +104,10 @@ const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFai
101104
102export const make = Effect.gen(function* () {105export const make = Effect.gen(function* () {
103 const processRunner = yield* ProcessRunner.ProcessRunner;106 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) {
106 const baseError = {111 const baseError = {
107 operation: input.operation,112 operation: input.operation,
108 command: input.command,113 command: input.command,
...@@ -181,6 +186,11 @@ export const make = Effect.gen(function* () {...@@ -181,6 +186,11 @@ export const make = Effect.gen(function* () {
181 } satisfies VcsProcessOutput;186 } satisfies VcsProcessOutput;
182 });187 });
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
184 return VcsProcess.of({ run });194 return VcsProcess.of({ run });
185});195});
186196
apps/server/src/ws.ts+4-3
...@@ -33,7 +33,6 @@ import {...@@ -33,7 +33,6 @@ import {
33 type OrchestrationEvent,33 type OrchestrationEvent,
34 type OrchestrationShellStreamEvent,34 type OrchestrationShellStreamEvent,
35 type OrchestrationShellStreamItem,35 type OrchestrationShellStreamItem,
36 type OrchestrationThreadStreamItem,
37 OrchestrationGetFullThreadDiffError,36 OrchestrationGetFullThreadDiffError,
38 OrchestrationGetSnapshotError,37 OrchestrationGetSnapshotError,
39 OrchestrationSearchThreadsError,38 OrchestrationSearchThreadsError,
...@@ -137,7 +136,6 @@ import * as SourceControlProviderRegistry from "./sourceControl/SourceControlPro...@@ -137,7 +136,6 @@ import * as SourceControlProviderRegistry from "./sourceControl/SourceControlPro
137import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";136import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
138import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts";137import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts";
139import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts";138import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts";
140import * as VcsProcess from "./vcs/VcsProcess.ts";
141import * as PairingGrantStore from "./auth/PairingGrantStore.ts";139import * as PairingGrantStore from "./auth/PairingGrantStore.ts";
142import * as SessionStore from "./auth/SessionStore.ts";140import * as SessionStore from "./auth/SessionStore.ts";
143import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts";141import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts";
...@@ -1896,6 +1894,10 @@ const makeWsRpcLayer = (...@@ -1896,6 +1894,10 @@ const makeWsRpcLayer = (
1896 observeRpcEffect(WS_METHODS.pullRequestsListStats, pullRequests.listStats(input), {1894 observeRpcEffect(WS_METHODS.pullRequestsListStats, pullRequests.listStats(input), {
1897 "rpc.aggregate": "pull-requests",1895 "rpc.aggregate": "pull-requests",
1898 }),1896 }),
1897 [WS_METHODS.pullRequestsSummary]: (input) =>
1898 observeRpcEffect(WS_METHODS.pullRequestsSummary, pullRequests.summary(input), {
1899 "rpc.aggregate": "pull-requests",
1900 }),
1899 [WS_METHODS.pullRequestsDetail]: (input) =>1901 [WS_METHODS.pullRequestsDetail]: (input) =>
1900 observeRpcEffect(WS_METHODS.pullRequestsDetail, pullRequests.detail(input), {1902 observeRpcEffect(WS_METHODS.pullRequestsDetail, pullRequests.detail(input), {
1901 "rpc.aggregate": "pull-requests",1903 "rpc.aggregate": "pull-requests",
...@@ -2635,7 +2637,6 @@ export const websocketRpcRouteLayer = Layer.unwrap(...@@ -2635,7 +2637,6 @@ export const websocketRpcRouteLayer = Layer.unwrap(
2635 ),2637 ),
2636 ),2638 ),
2637 ),2639 ),
2638 Layer.provide(VcsProcess.layer),
2639 ),2640 ),
2640 ),2641 ),
2641 ),2642 ),
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx-7
...@@ -492,13 +492,6 @@ export function PullRequestDetailPanel({...@@ -492,13 +492,6 @@ export function PullRequestDetailPanel({
492 const activityQuery = useEnvironmentQuery(492 const activityQuery = useEnvironmentQuery(
493 pullRequestEnvironment.activity({ environmentId, input: reference }),493 pullRequestEnvironment.activity({ environmentId, input: reference }),
494 );494 );
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 );
502 const coreDetail = detailQuery.data;495 const coreDetail = detailQuery.data;
503 const activity = activityQuery.data;496 const activity = activityQuery.data;
504 const detail = useMemo(497 const detail = useMemo(
apps/web/src/routes/_chat.pull-requests.tsx+11-1
...@@ -760,7 +760,16 @@ function PullRequestsRouteView() {...@@ -760,7 +760,16 @@ function PullRequestsRouteView() {
760 // Built together so the two reads share one memo, and in the same field order the feed's own760 // Built together so the two reads share one memo, and in the same field order the feed's own
761 // input uses: the atoms are keyed by their input, so the Authored tab then reads this answer.761 // input uses: the atoms are keyed by their input, so the Authored tab then reads this answer.
762 const partitionTargets = useMemo(() => {762 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 }
764 const targetsFor = (involvement: PullRequestInvolvement) =>773 const targetsFor = (involvement: PullRequestInvolvement) =>
765 environmentQueries.map(({ environmentId, projectIds }) => ({774 environmentQueries.map(({ environmentId, projectIds }) => ({
766 environmentId,775 environmentId,
...@@ -779,6 +788,7 @@ function PullRequestsRouteView() {...@@ -779,6 +788,7 @@ function PullRequestsRouteView() {
779 menuFiltered,788 menuFiltered,
780 menuFilters,789 menuFilters,
781 partitionsWanted,790 partitionsWanted,
791 baselineQuery.data,
782 environmentQueries,792 environmentQueries,
783 scopedProjectId,793 scopedProjectId,
784 search.host,794 search.host,
apps/web/src/state/pullRequests.ts+2-2
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import {2import {
3 createLinkedPullRequestDetailAtomFamily,3 createLinkedPullRequestSummaryAtomFamily,
4 createPullRequestEnvironmentAtoms,4 createPullRequestEnvironmentAtoms,
5} from "@t3tools/client-runtime/state/pull-requests";5} from "@t3tools/client-runtime/state/pull-requests";
6import type {6import type {
...@@ -23,7 +23,7 @@ import { formatEnvironmentQueryError } from "./query";...@@ -23,7 +23,7 @@ import { formatEnvironmentQueryError } from "./query";
2323
24export const pullRequestEnvironment = createPullRequestEnvironmentAtoms(connectionAtomRuntime);24export const pullRequestEnvironment = createPullRequestEnvironmentAtoms(connectionAtomRuntime);
25export const linkedPullRequestDetailAtom =25export const linkedPullRequestDetailAtom =
26 createLinkedPullRequestDetailAtomFamily(connectionAtomRuntime);26 createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime);
2727
28export interface EnvironmentQueryTarget<Input> {28export interface EnvironmentQueryTarget<Input> {
29 readonly environmentId: EnvironmentId;29 readonly environmentId: EnvironmentId;
packages/client-runtime/src/state/pullRequests.ts+10-9
...@@ -2,6 +2,7 @@ import {...@@ -2,6 +2,7 @@ import {
2 WS_METHODS,2 WS_METHODS,
3 type PullRequestDetail,3 type PullRequestDetail,
4 type PullRequestDiffInput,4 type PullRequestDiffInput,
5 type PullRequestSummary,
5 type VcsStatusResult,6 type VcsStatusResult,
6} from "@t3tools/contracts";7} from "@t3tools/contracts";
7import * as Data from "effect/Data";8import * as Data from "effect/Data";
...@@ -31,23 +32,23 @@ export class EnvironmentHttpConnectionNotReadyError extends Data.TaggedError(...@@ -31,23 +32,23 @@ export class EnvironmentHttpConnectionNotReadyError extends Data.TaggedError(
31 "EnvironmentHttpConnectionNotReadyError",32 "EnvironmentHttpConnectionNotReadyError",
32)<{ readonly message: string }> {}33)<{ 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. */37/** Refresh only the live fields a linked thread renders. */
37export function createLinkedPullRequestDetailAtomFamily<R, E>(38export function createLinkedPullRequestSummaryAtomFamily<R, E>(
38 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,39 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
39) {40) {
40 return createEnvironmentRpcQueryAtomFamily(runtime, {41 return createEnvironmentRpcQueryAtomFamily(runtime, {
41 label: "environment-data:pull-requests:linked-detail",42 label: "environment-data:pull-requests:linked-summary",
42 tag: WS_METHODS.pullRequestsDetail,43 tag: WS_METHODS.pullRequestsSummary,
43 staleTimeMs: 15_000,44 staleTimeMs: 60_000,
44 refreshIntervalMs: 30_000,45 refreshIntervalMs: 60_000,
45 idleTtlMs: LINKED_PULL_REQUEST_DETAIL_IDLE_TTL_MS,46 idleTtlMs: LINKED_PULL_REQUEST_IDLE_TTL_MS,
46 });47 });
47}48}
4849
49export function pullRequestDetailToVcsStatus(50export function pullRequestDetailToVcsStatus(
50 detail: PullRequestDetail,51 detail: PullRequestDetail | PullRequestSummary,
51): NonNullable<VcsStatusResult["pr"]> {52): NonNullable<VcsStatusResult["pr"]> {
52 return {53 return {
53 number: detail.number,54 number: detail.number,
packages/contracts/src/pullRequest.ts+18
...@@ -590,6 +590,24 @@ export const PullRequestRef = Schema.Struct({...@@ -590,6 +590,24 @@ export const PullRequestRef = Schema.Struct({
590});590});
591export type PullRequestRef = typeof PullRequestRef.Type;591export 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
593/**611/**
594 * One row's line counts, read after the listing rather than inside it. On GitHub the pair is612 * One row's line counts, read after the listing rather than inside it. On GitHub the pair is
595 * 40-60% of the wall clock of the search that answers the whole page — measured over twelve613 * 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 {...@@ -97,6 +97,7 @@ import {
97 PullRequestOperationError,97 PullRequestOperationError,
98 PullRequestReactionInput,98 PullRequestReactionInput,
99 PullRequestRef,99 PullRequestRef,
100 PullRequestSummary,
100 PullRequestReviewerCandidateList,101 PullRequestReviewerCandidateList,
101 PullRequestReviewerRequestInput,102 PullRequestReviewerRequestInput,
102 PullRequestSubmitReviewInput,103 PullRequestSubmitReviewInput,
...@@ -302,6 +303,7 @@ export const WS_METHODS = {...@@ -302,6 +303,7 @@ export const WS_METHODS = {
302 // Pull request methods303 // Pull request methods
303 pullRequestsList: "pullRequests.list",304 pullRequestsList: "pullRequests.list",
304 pullRequestsListStats: "pullRequests.listStats",305 pullRequestsListStats: "pullRequests.listStats",
306 pullRequestsSummary: "pullRequests.summary",
305 pullRequestsDetail: "pullRequests.detail",307 pullRequestsDetail: "pullRequests.detail",
306 pullRequestsActivity: "pullRequests.activity",308 pullRequestsActivity: "pullRequests.activity",
307 pullRequestsThreadComments: "pullRequests.threadComments",309 pullRequestsThreadComments: "pullRequests.threadComments",
...@@ -521,6 +523,12 @@ export const WsPullRequestsListStatsRpc = Rpc.make(WS_METHODS.pullRequestsListSt...@@ -521,6 +523,12 @@ export const WsPullRequestsListStatsRpc = Rpc.make(WS_METHODS.pullRequestsListSt
521 error: PullRequestRpcError,523 error: PullRequestRpcError,
522});524});
523525
526export const WsPullRequestsSummaryRpc = Rpc.make(WS_METHODS.pullRequestsSummary, {
527 payload: PullRequestRef,
528 success: PullRequestSummary,
529 error: PullRequestRpcError,
530});
531
524export const WsPullRequestsDetailRpc = Rpc.make(WS_METHODS.pullRequestsDetail, {532export const WsPullRequestsDetailRpc = Rpc.make(WS_METHODS.pullRequestsDetail, {
525 payload: PullRequestRef,533 payload: PullRequestRef,
526 success: PullRequestDetail,534 success: PullRequestDetail,
...@@ -1063,6 +1071,7 @@ export const WsRpcGroup = RpcGroup.make(...@@ -1063,6 +1071,7 @@ export const WsRpcGroup = RpcGroup.make(
1063 WsCloudInstallRelayClientRpc,1071 WsCloudInstallRelayClientRpc,
1064 WsPullRequestsListRpc,1072 WsPullRequestsListRpc,
1065 WsPullRequestsListStatsRpc,1073 WsPullRequestsListStatsRpc,
1074 WsPullRequestsSummaryRpc,
1066 WsPullRequestsDetailRpc,1075 WsPullRequestsDetailRpc,
1067 WsPullRequestsActivityRpc,1076 WsPullRequestsActivityRpc,
1068 WsPullRequestsThreadCommentsRpc,1077 WsPullRequestsThreadCommentsRpc,