From dd6879ffea24da9fc16cb62d7d6072d2c5b2bae3 Mon Sep 17 00:00:00 2001 From: maria Date: Wed, 2 Sep 2026 03:56:32 -0400 Subject: [PATCH] fix(pull-requests): reuse github api reads (#9176) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/mobile/src/state/use-thread-pr.ts | 4 +- apps/server/src/auth/RpcAuthorization.ts | 1 + .../ThreadSettlementReactor.test.ts | 94 ++--- .../orchestration/ThreadSettlementReactor.ts | 18 +- .../pullRequest/GitHubPullRequestCli.test.ts | 43 +++ .../src/pullRequest/GitHubPullRequestCli.ts | 69 +++- .../GitHubPullRequestProvider.test.ts | 37 ++ .../pullRequest/GitHubPullRequestProvider.ts | 24 +- .../src/pullRequest/PullRequestProvider.ts | 19 + .../pullRequest/PullRequestService.test.ts | 214 ++++++++++++ .../src/pullRequest/PullRequestService.ts | 329 +++++++++++++----- apps/server/src/server.test.ts | 2 + apps/server/src/server.ts | 4 +- .../src/sourceControl/GitHubCli.test.ts | 4 +- apps/server/src/sourceControl/GitHubCli.ts | 22 +- .../GitHubSourceControlProvider.ts | 22 +- apps/server/src/vcs/VcsProcess.test.ts | 61 ++++ apps/server/src/vcs/VcsProcess.ts | 12 +- apps/server/src/ws.ts | 7 +- .../pullRequest/PullRequestDetailPanel.tsx | 7 - apps/web/src/routes/_chat.pull-requests.tsx | 12 +- apps/web/src/state/pullRequests.ts | 4 +- .../client-runtime/src/state/pullRequests.ts | 19 +- packages/contracts/src/pullRequest.ts | 18 + packages/contracts/src/rpc.ts | 9 + 25 files changed, 868 insertions(+), 187 deletions(-) diff --git a/apps/mobile/src/state/use-thread-pr.ts b/apps/mobile/src/state/use-thread-pr.ts index 869d37e587cf585c22af5343ce8f37fa50ada9ff..8e4eb27963ace84bd2740baab894fff140d7115b 100644 --- a/apps/mobile/src/state/use-thread-pr.ts +++ b/apps/mobile/src/state/use-thread-pr.ts @@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { - createLinkedPullRequestDetailAtomFamily, + createLinkedPullRequestSummaryAtomFamily, pullRequestDetailToVcsStatus, } from "@t3tools/client-runtime/state/pull-requests"; import { Atom } from "effect/unstable/reactivity"; @@ -14,7 +14,7 @@ import { useEnvironmentQuery } from "./query"; import { presentThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation"; import { vcsEnvironment } from "./vcs"; -const linkedPullRequestDetailAtom = createLinkedPullRequestDetailAtomFamily(connectionAtomRuntime); +const linkedPullRequestDetailAtom = createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime); const MAX_THREAD_PR_SNAPSHOTS = 500; interface ThreadPrSnapshot { diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index f9d5a302ee9e3ea194abb1da259e696c41e0d194..c1eb8b157646d7a89186417aecf0cae8b5a17eea 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -55,6 +55,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope, [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsListStats]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsSummary]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope, diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index 5d8d47109faa391fe14c3ce804ee83990a102584..37ea643328c10c0a8f988fb60eeb3fd1553aaaaa 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -8,7 +8,7 @@ import { type OrchestrationProjectShell, type OrchestrationShellSnapshot, type OrchestrationThreadShell, - type PullRequestDetail, + type PullRequestSummary, type ServerSettings, type ServerSettingsPatch, } from "@t3tools/contracts"; @@ -105,56 +105,24 @@ function makeSnapshot( }; } -function makePullRequestDetail(input: { +function makePullRequestSummary(input: { readonly projectId: ProjectId; readonly repository: string; readonly number: number; readonly state: "open" | "closed" | "merged"; readonly updatedAt?: string; -}): PullRequestDetail { +}): PullRequestSummary { return { provider: "github", - capabilities: { - diff: true, - comment: true, - actions: [], - mergeMethods: [], - search: true, - review: { inlineComment: true, reply: true, resolve: true, verdicts: [] }, - reviewers: { request: true, listCandidates: true }, - }, - viewerPermissions: { - actions: [], - comment: true, - resolve: true, - verdicts: [], - requestReviewers: true, - }, projectId: input.projectId, - projectTitle: "Linked project", - workspaceRoot: "/workspace/linked", repository: input.repository, number: input.number, title: "Pull request", - body: "", url: `https://example.test/${input.repository}/pull/${input.number}`, - author: null, state: input.state, - isDraft: false, - mergeability: "mergeable", - additions: 0, - deletions: 0, - changedFiles: 0, headBranch: "feature", baseBranch: "main", - createdAt: "2026-08-01T00:00:00.000Z", updatedAt: input.updatedAt ?? NOW, - mergedAt: input.state === "merged" ? (input.updatedAt ?? NOW) : null, - closedAt: input.state === "closed" ? (input.updatedAt ?? NOW) : null, - reviewers: [], - labels: [], - checks: [], - mergeCapabilities: { merge: true, squash: true, rebase: true }, }; } @@ -162,7 +130,7 @@ interface HarnessOptions { readonly snapshot: OrchestrationShellSnapshot; readonly settings?: ServerSettings; readonly branchPullRequest?: GitManager["Service"]["branchPullRequest"]; - readonly pullRequestDetail?: PullRequestService["Service"]["detail"]; + readonly pullRequestSummary?: PullRequestService["Service"]["summary"]; readonly onDispatch?: ( command: AutoSettleCommand, ) => Effect.Effect; @@ -179,13 +147,14 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: const branchCalls = yield* Ref.make< ReadonlyArray<{ readonly cwd: string; readonly branch: string }> >([]); - const detailCalls = yield* Ref.make< + const summaryCalls = yield* Ref.make< ReadonlyArray<{ readonly projectId: ProjectId; readonly repository: string; readonly number: number; }> >([]); + const summaryRecovery = yield* Ref.make>([]); const updateSettings = (patch: ServerSettingsPatch) => Effect.gen(function* () { @@ -200,18 +169,23 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: Effect.andThen(options.branchPullRequest?.(input) ?? Effect.succeed(null)), ); - const pullRequestDetail: PullRequestService["Service"]["detail"] = (input) => - Ref.update(detailCalls, (calls) => [...calls, input]).pipe( - Effect.andThen( - options.pullRequestDetail?.(input) ?? + const pullRequestSummary: PullRequestService["Service"]["summary"] = (input, readOptions) => + Effect.gen(function* () { + yield* Ref.update(summaryCalls, (calls) => [...calls, input]); + yield* Ref.update(summaryRecovery, (values) => [ + ...values, + readOptions?.recoverTransientFailure, + ]); + return yield* ( + options.pullRequestSummary?.(input, readOptions) ?? Effect.succeed( - makePullRequestDetail({ + makePullRequestSummary({ ...input, state: "open", }), - ), - ), - ); + ) + ); + }); const dispatch: OrchestrationEngineShape["dispatch"] = (command) => { if (command.type !== "thread.auto-settle") { @@ -243,7 +217,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: ), }), Layer.mock(GitManager)({ branchPullRequest }), - Layer.mock(PullRequestService)({ detail: pullRequestDetail }), + Layer.mock(PullRequestService)({ summary: pullRequestSummary }), Layer.mock(OrchestrationEngineService)({ readEvents: () => Stream.empty, dispatch, @@ -262,7 +236,8 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: snapshotReads, commands, branchCalls, - detailCalls, + summaryCalls, + summaryRecovery, updateSettings, layer: ThreadSettlementReactor.layer.pipe(Layer.provide(dependencies)), }; @@ -310,8 +285,8 @@ describe("ThreadSettlementReactor", () => { [makeProject(), makeProject(LINKED_PROJECT_ID, "/workspace/linked")], ), branchPullRequest: () => Effect.succeed(null), - pullRequestDetail: (input) => - Effect.succeed(makePullRequestDetail({ ...input, state: "closed" })), + pullRequestSummary: (input) => + Effect.succeed(makePullRequestSummary({ ...input, state: "closed" })), }); yield* Effect.gen(function* () { @@ -342,9 +317,10 @@ describe("ThreadSettlementReactor", () => { assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), [ { cwd: "/workspace/project", branch: "inactive-feature" }, ]); - assert.deepStrictEqual(yield* Ref.get(fixture.detailCalls), [ + assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), [ { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 42 }, ]); + assert.deepStrictEqual(yield* Ref.get(fixture.summaryRecovery), [false]); }).pipe(Effect.provide(fixture.layer)); }), ), @@ -480,10 +456,10 @@ describe("ThreadSettlementReactor", () => { ], [makeProject(), makeProject(LINKED_PROJECT_ID, "/workspace/linked")], ), - pullRequestDetail: () => + pullRequestSummary: () => Effect.fail( new PullRequestOperationError({ - operation: "detail", + operation: "summary", detail: "host unavailable", }), ), @@ -497,7 +473,7 @@ describe("ThreadSettlementReactor", () => { (yield* Ref.get(fixture.commands)).map((command) => command.threadId), [ThreadId.make("inactive-without-pr")], ); - assert.strictEqual((yield* Ref.get(fixture.detailCalls)).length, 1); + assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1); }).pipe(Effect.provide(fixture.layer)); }), ), @@ -521,8 +497,8 @@ describe("ThreadSettlementReactor", () => { ], [makeProject(LINKED_PROJECT_ID, "/workspace/linked")], ), - pullRequestDetail: (input) => - Effect.succeed(makePullRequestDetail({ ...input, state: "open" })), + pullRequestSummary: (input) => + Effect.succeed(makePullRequestSummary({ ...input, state: "open" })), }); yield* Effect.gen(function* () { @@ -530,7 +506,7 @@ describe("ThreadSettlementReactor", () => { yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); assert.deepStrictEqual(yield* Ref.get(fixture.commands), []); - assert.deepStrictEqual(yield* Ref.get(fixture.detailCalls), [ + assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), [ { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 10 }, ]); assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), []); @@ -569,8 +545,8 @@ describe("ThreadSettlementReactor", () => { ], ), branchPullRequest: () => Effect.succeed({ state: "closed", updatedAt: NOW }), - pullRequestDetail: (input) => - Effect.succeed(makePullRequestDetail({ ...input, state: "merged" })), + pullRequestSummary: (input) => + Effect.succeed(makePullRequestSummary({ ...input, state: "merged" })), }); yield* Effect.gen(function* () { @@ -580,7 +556,7 @@ describe("ThreadSettlementReactor", () => { assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), [ { cwd: "/workspace/project-root", branch: "saved-feature" }, ]); - assert.deepStrictEqual(yield* Ref.get(fixture.detailCalls), [ + assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), [ { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 77 }, ]); assert.deepStrictEqual( diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index fd4486a9c406a2f36a233936fad278231c7dc695..edd52a8abd0ab5091372c9809e6ea2844de0737e 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -69,12 +69,18 @@ export const make = Effect.gen(function* () { if (!projects.has(thread.linkedPullRequest.projectId)) { return yield* Effect.die(new Error("linked pull request project not found")); } - const detail = yield* pullRequests.detail({ - projectId: thread.linkedPullRequest.projectId, - repository: thread.linkedPullRequest.repository, - number: thread.linkedPullRequest.number, - }); - return { state: detail.state, updatedAt: detail.updatedAt } satisfies SettlementPullRequest; + const summary = yield* pullRequests.summary( + { + projectId: thread.linkedPullRequest.projectId, + repository: thread.linkedPullRequest.repository, + number: thread.linkedPullRequest.number, + }, + { recoverTransientFailure: false }, + ); + return { + state: summary.state, + updatedAt: summary.updatedAt, + } satisfies SettlementPullRequest; } if (thread.branch === null) return null; const project = projects.get(thread.projectId); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index 33d0d120ccced2bff624fa738bb7613962bb845c..37f38deb5ce9a504c9e9dc99726ec7272ac04c7d 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -10,12 +10,14 @@ import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts"; const mockedExecute = vi.fn(); +const mockedGetPullRequest = vi.fn(); const layer = it.layer( GitHubPullRequestCli.layer.pipe( Layer.provide( Layer.mock(GitHubCli.GitHubCli)({ execute: mockedExecute, + getPullRequest: mockedGetPullRequest, }), ), Layer.provide(GitHubGraphQlBudget.layer), @@ -177,9 +179,50 @@ function searchQueryOfCall(index: number): string | undefined { afterEach(() => { mockedExecute.mockReset(); + mockedGetPullRequest.mockReset(); }); layer("GitHubPullRequestCli.layer", (it) => { + it.effect("reads linked pull request status through one narrow request", () => + Effect.gen(function* () { + mockedGetPullRequest.mockReturnValueOnce( + Effect.succeed({ + number: 7, + title: "Reuse the summary", + url: "https://github.com/acme/web/pull/7", + baseRefName: "main", + headRefName: "feat/summary", + state: "open", + updatedAt: "2026-08-24T12:34:56.000Z", + }), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const summary = yield* cli.getPullRequestSummary({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + assert.deepStrictEqual(summary, { + number: 7, + title: "Reuse the summary", + url: "https://github.com/acme/web/pull/7", + headBranch: "feat/summary", + baseBranch: "main", + state: "open", + updatedAt: "2026-08-24T12:34:56.000Z", + }); + expect(mockedGetPullRequest).toHaveBeenCalledOnce(); + expect(mockedGetPullRequest).toHaveBeenCalledWith({ + cwd: "/w", + reference: "https://github.com/acme/web/pull/7", + }); + expect(mockedExecute).not.toHaveBeenCalled(); + }), + ); + it.effect("asks for one row more than the page, to probe for a next page", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1)))); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index 2084a50d02061e38d4d5712223b160405e22c496..f11bd264c4b5ceab26b20c693225327049bb8bbe 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -123,6 +123,25 @@ export class GitHubViewerLoginUnavailableError extends Schema.TaggedErrorClass()( + "GitHubPullRequestUpdatedAtUnavailableError", + { + command: Schema.Literal("gh"), + cwd: Schema.String, + repository: Schema.String, + number: Schema.Int, + }, +) { + get detail(): string { + return `Pull request ${this.repository}#${this.number} reported no update time.`; + } + + override get message(): string { + return `GitHub CLI failed in getPullRequestSummary: ${this.detail}`; + } +} + /** Not a decode failure: the reader asked to carry on from a cursor this walk never handed out. */ export class GitHubDiffCursorError extends Schema.TaggedErrorClass()( "GitHubDiffCursorError", @@ -250,7 +269,8 @@ export type GitHubPullRequestCliError = | GitHubRepositorySelectorError | GitHubSubjectScopeError | SourceControlRateLimit.SourceControlRateLimitPausedError - | GitHubViewerLoginUnavailableError; + | GitHubViewerLoginUnavailableError + | GitHubPullRequestUpdatedAtUnavailableError; /** A large pull request can produce a multi-megabyte patch; past this it is truncated. */ const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; @@ -360,6 +380,24 @@ export class GitHubPullRequestCli extends Context.Service< }>; }) => Effect.Effect, GitHubPullRequestCliError>; + readonly getPullRequestSummary: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + }) => Effect.Effect< + { + readonly number: number; + readonly title: string; + readonly url: string; + readonly headBranch: string; + readonly baseBranch: string; + readonly state: "open" | "closed" | "merged"; + readonly updatedAt: string; + }, + GitHubPullRequestCliError + >; + readonly getPullRequestDetail: (input: { readonly cwd: string; readonly repository: string; @@ -1326,6 +1364,35 @@ export const make = Effect.gen(function* () { ).pipe(Effect.map((results) => results.flat())); }, + getPullRequestSummary: (input) => + github + .getPullRequest({ + cwd: input.cwd, + reference: `https://${input.host}/${input.repository}/pull/${input.number}`, + }) + .pipe( + Effect.flatMap((summary) => + summary.updatedAt === undefined + ? Effect.fail( + new GitHubPullRequestUpdatedAtUnavailableError({ + command: "gh", + cwd: input.cwd, + repository: input.repository, + number: input.number, + }), + ) + : Effect.succeed({ + number: summary.number, + title: summary.title, + url: summary.url, + headBranch: summary.headRefName, + baseBranch: summary.baseRefName, + state: summary.state ?? "open", + updatedAt: summary.updatedAt, + }), + ), + ), + getPullRequestDetail: (input) => github .execute({ diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts index 2555c05dc8fcd993b523c03a5d263882883bd654..afbeee638230c51cf5f4958c72fca94ec46b9894 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts @@ -7,6 +7,43 @@ import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts"; import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts"; +it.effect("uses one narrow read for a linked pull request summary", () => + Effect.gen(function* () { + let summaryReads = 0; + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + getPullRequestSummary: () => + Effect.sync(() => { + summaryReads += 1; + return { + number: 7, + title: "Summary", + url: "https://github.com/acme/web/pull/7", + headBranch: "feat/summary", + baseBranch: "main", + state: "open" as const, + updatedAt: "2026-08-24T12:34:56.000Z", + }; + }), + }), + ), + ); + + const readSummary = provider.getChangeRequestSummary; + if (readSummary === undefined) return yield* Effect.die("summary read was not implemented"); + const summary = yield* readSummary({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + expect(summary.state).toBe("open"); + expect(summaryReads).toBe(1); + }), +); + describe("gitHubViewerPermissions", () => { it("offers everything to a viewer who can write to the repository", () => { expect(gitHubViewerPermissions({ canWrite: true, canUpdate: true, didAuthor: false })).toEqual({ diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index cc097c30c2ed1117d272db3b40ef509648ef581b..ff8f31c818ddb63770e70d2b0037fd8f96b617d8 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -1,4 +1,7 @@ +import * as Cache from "effect/Cache"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import type { PullRequestActor, PullRequestCapabilities, @@ -129,6 +132,22 @@ const rendersEmpty = (body: string): boolean => export const make = Effect.gen(function* () { const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const repositoryAccessCache = yield* Cache.makeWith( + (key: string) => { + const [cwd, repository, host] = JSON.parse(key) as [string, string, string]; + return cli.getRepositoryAccess({ cwd, repository, host }); + }, + { + capacity: 128, + timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.minutes(10) : Duration.zero), + }, + ); + const getRepositoryAccess = (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + }) => Cache.get(repositoryAccessCache, JSON.stringify([input.cwd, input.repository, input.host])); + const fail = (operation: string) => (error: GitHubPullRequestCli.GitHubPullRequestCliError) => new PullRequestProviderError({ provider: "github", @@ -223,6 +242,9 @@ export const make = Effect.gen(function* () { }) .pipe(Effect.mapError(fail("listChangeRequestStats"))), + getChangeRequestSummary: (input) => + cli.getPullRequestSummary(input).pipe(Effect.mapError(fail("getChangeRequestSummary"))), + getChangeRequest: (input) => Effect.all( [ @@ -244,7 +266,7 @@ export const make = Effect.gen(function* () { ), ), ), - cli.getRepositoryAccess({ + getRepositoryAccess({ cwd: input.cwd, repository: input.repository, host: input.host, diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index eadc5e931328517bb9e48e86973d94ef4d2328b8..3235538287b161ddc9d2c0986adb0ac4e8b90614 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -87,6 +87,17 @@ export interface ProviderChangeRequest { readonly checksState?: PullRequestChecksState | null | undefined; } +/** The fields needed to keep a linked thread's pull request status live. */ +export interface ProviderChangeRequestSummary { + readonly number: number; + readonly title: string; + readonly url: string; + readonly headBranch: string; + readonly baseBranch: string; + readonly state: PullRequestState; + readonly updatedAt: string; +} + export interface ProviderChangeRequestPage { readonly items: ReadonlyArray; /** True when the host has more rows than the page size asked for. */ @@ -301,6 +312,14 @@ export interface PullRequestProviderApi { input: ProviderRepositoryRef & { readonly number: number }, ) => Effect.Effect; + /** + * The cheap live fields used by linked threads. Optional because a provider without a narrow + * endpoint can fall back to its full detail read at the service boundary. + */ + readonly getChangeRequestSummary?: ( + input: ProviderRepositoryRef & { readonly number: number }, + ) => Effect.Effect; + /** Comments, line threads, and commits, kept off the critical path for the core detail. */ readonly getChangeRequestActivity: ( input: ProviderRepositoryRef & { readonly number: number }, diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index d142e7368f6d454471fb710eb6f0ec6e95f5da43..d688430bdf5ebea088d4a5162578bf9c55668bde 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -2293,6 +2293,105 @@ it.effect("answers a repeated listing from cache, and concurrent readers share o }), ); +it.effect("shares one cold viewer lookup across distinct concurrent lists", () => + Effect.gen(function* () { + let viewerCalls = 0; + let listCalls = 0; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getViewer: () => + Effect.sync(() => { + viewerCalls += 1; + }).pipe(Effect.andThen(Effect.yieldNow), Effect.as("bilal")), + listChangeRequests: () => + Effect.sync(() => { + listCalls += 1; + return { items: [], truncated: false, continues: true }; + }), + }), + ], + }); + + yield* Effect.all( + ["all", "authored", "reviewing"].map((involvement) => + service.list({ + state: "open", + involvement: involvement as "all" | "authored" | "reviewing", + }), + ), + { concurrency: "unbounded" }, + ); + + assert.strictEqual(viewerCalls, 1); + assert.strictEqual(listCalls, 3); + }), +); + +it.effect("uses five host reads for the normal indexed-repository page workflow", () => + Effect.gen(function* () { + let viewerCalls = 0; + let searchCalls = 0; + let fallbackCalls = 0; + let statsCalls = 0; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getViewer: () => + Effect.sync(() => { + viewerCalls += 1; + return "bilal"; + }), + listChangeRequestsAcross: (input) => + Effect.sync(() => { + searchCalls += 1; + return { + items: + input.involvement === "all" + ? [batchedChangeRequest(1, "acme/web", "2026-07-02T00:00:00Z")] + : [], + truncated: false, + }; + }), + listChangeRequests: () => + Effect.sync(() => { + fallbackCalls += 1; + return { items: [], truncated: false, continues: true }; + }), + listChangeRequestStats: () => + Effect.sync(() => { + statsCalls += 1; + return [{ repository: "acme/web", number: 1, additions: 3, deletions: 1 }]; + }), + }), + ], + }); + + const baseline = yield* service.list({ state: "open", involvement: "all" }); + yield* Effect.all( + [ + service.list({ state: "open", involvement: "authored" }), + service.list({ state: "open", involvement: "reviewing" }), + ], + { concurrency: "unbounded" }, + ); + yield* service.listStats({ + refs: baseline.entries.map(({ projectId, repository, number }) => ({ + projectId, + repository, + number, + })), + }); + + assert.deepStrictEqual( + { viewerCalls, searchCalls, fallbackCalls, statsCalls }, + { viewerCalls: 1, searchCalls: 3, fallbackCalls: 0, statsCalls: 1 }, + ); + }), +); + it.effect("returns the refreshed listing on the first read after its cache expires", () => Effect.gen(function* () { let hostCalls = 0; @@ -2371,10 +2470,15 @@ it.effect("a listing narrowed to some projects is its own cache entry", () => it.effect("an explicit invalidation makes the next listing ask the host again", () => Effect.gen(function* () { let hostCalls = 0; + let viewerCalls = 0; const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ fakeProvider("github", { + getViewer: () => { + viewerCalls += 1; + return Effect.succeed("bilal"); + }, listChangeRequests: () => { hostCalls += 1; 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", yield* service.invalidate({}); yield* service.list({ state: "open" }); assert.strictEqual(hostCalls, 2); + assert.strictEqual(viewerCalls, 2); // Forgetting one change request leaves the listings shared. yield* service.invalidate({ @@ -2829,6 +2934,115 @@ it.effect( }), ); +it.effect("shares linked summaries and only recovers transient failures for display reads", () => + Effect.gen(function* () { + let calls = 0; + let failing = false; + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequestSummary: () => + Effect.sync(() => { + calls += 1; + return failing; + }).pipe( + Effect.tap(() => Effect.yieldNow), + Effect.flatMap((shouldFail) => + shouldFail + ? Effect.fail( + new PullRequestProviderError({ + provider: "github", + operation: "getChangeRequestSummary", + reason: "failed", + detail: "HTTP 504", + }), + ) + : Effect.succeed(changeRequest(1, "2026-07-02T00:00:00Z")), + ), + ), + }), + ], + }); + + yield* Effect.all( + [ + service.summary(reference, { recoverTransientFailure: false }), + service.summary(reference, { recoverTransientFailure: false }), + ], + { concurrency: "unbounded" }, + ); + assert.strictEqual(calls, 1); + + yield* TestClock.adjust("61 seconds"); + failing = true; + const strict = yield* Effect.flip( + service.summary(reference, { recoverTransientFailure: false }), + ); + assert.strictEqual(strict._tag, "PullRequestOperationError"); + + const stale = yield* service.summary(reference); + assert.strictEqual(stale.updatedAt, "2026-07-02T00:00:00Z"); + assert.strictEqual(calls, 3); + + yield* service.invalidate({ reference }); + const invalidated = yield* Effect.flip(service.summary(reference)); + assert.strictEqual(invalidated._tag, "PullRequestOperationError"); + }), +); + +it.effect("keeps recent detail on a transient refresh failure but not after invalidation", () => + Effect.gen(function* () { + let failing = false; + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequest: () => + failing + ? Effect.fail( + new PullRequestProviderError({ + provider: "github", + operation: "getChangeRequest", + reason: "failed", + detail: "spawn gh EAGAIN", + }), + ) + : Effect.succeed({ + ...changeRequest(1, "2026-07-02T00:00:00Z"), + body: "last good body", + changedFiles: 2, + mergedAt: null, + closedAt: null, + reviewers: [], + checks: [], + mergeCapabilities: { merge: true, squash: true, rebase: true }, + viewerPermissions: { + actions: ["merge"], + comment: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + requestReviewers: true, + }, + }), + }), + ], + }); + + yield* service.detail(reference); + yield* TestClock.adjust("16 seconds"); + failing = true; + const stale = yield* service.detail(reference); + assert.strictEqual(stale.body, "last good body"); + + yield* service.invalidate({ reference }); + const invalidated = yield* Effect.flip(service.detail(reference)); + assert.strictEqual(invalidated._tag, "PullRequestOperationError"); + }), +); + it.effect("carries an armed auto-merge through to the detail, and silence as silence", () => Effect.gen(function* () { const detailWith = (autoMergeEnabled: boolean | undefined) => diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 1ce5805839c19f720bd73bcb2fde1063c4344e3e..29496bff64d174f87cc1415056224568a69a4bcf 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -5,6 +5,7 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import { PullRequestOperationError, PullRequestUnavailableError, @@ -38,6 +39,7 @@ import { type PullRequestReviewerCandidateList, type PullRequestReviewerRequestInput, type PullRequestSubmitReviewInput, + type PullRequestSummary, type PullRequestThreadReplyInput, type PullRequestThreadResolutionInput, type PullRequestThreadCommentsInput, @@ -96,6 +98,7 @@ const REPOSITORY_SEARCH_CHUNK = 100; * `invalidate` rather than a flag on the read, so an ordinary read can never opt out. */ const LIST_CACHE_TTL = Duration.seconds(30); +const SUMMARY_CACHE_TTL = Duration.seconds(60); const DETAIL_CACHE_TTL = Duration.seconds(15); const DIFF_CACHE_TTL = Duration.seconds(60); /** 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); const DIFF_STALE_WINDOW = Duration.minutes(10); /** How long one host's signed-in login is believed without asking its CLI again. */ const VIEWER_CACHE_TTL = Duration.minutes(10); +const SEARCH_VISIBILITY_TTL = Duration.minutes(10); +const STALE_DETAIL_WINDOW = Duration.minutes(10); +const isPullRequestProviderError = Schema.is(PullRequestProviderError); const LIST_CACHE_CAPACITY = 64; const LIST_STATS_CACHE_CAPACITY = 32; const DETAIL_CACHE_CAPACITY = 128; const DIFF_CACHE_CAPACITY = 128; +const VIEWER_CACHE_CAPACITY = 32; export type PullRequestError = PullRequestUnavailableError | PullRequestOperationError; @@ -122,6 +129,10 @@ export class PullRequestService extends Context.Service< readonly listStats: ( input: PullRequestListStatsInput, ) => Effect.Effect; + readonly summary: ( + input: PullRequestRef, + options?: { readonly recoverTransientFailure?: boolean }, + ) => Effect.Effect; readonly detail: (input: PullRequestRef) => Effect.Effect; readonly activity: ( input: PullRequestRef, @@ -430,6 +441,11 @@ function withRateLimitBackoff( listChangeRequestStats: wrap("listChangeRequestStats", api.listChangeRequestStats), }), getChangeRequest: wrap("getChangeRequest", api.getChangeRequest), + ...(api.getChangeRequestSummary === undefined + ? {} + : { + getChangeRequestSummary: wrap("getChangeRequestSummary", api.getChangeRequestSummary), + }), getChangeRequestActivity: wrap("getChangeRequestActivity", api.getChangeRequestActivity), ...(api.getReviewThreadComments === undefined ? {} @@ -699,6 +715,46 @@ export const make = Effect.gen(function* () { // "is this host set up" answer the provider switcher shows, and holding it would keep saying // signed-out after the reader has signed in. const viewersByHost = new Map(); + const viewerFlights = yield* Cache.makeWith( + (key: string): Effect.Effect => { + const [host, kind, roots] = JSON.parse(key) as [ + string, + SourceControlProviderKind, + ReadonlyArray, + ]; + const registered = registry.get(kind); + if (registered === null) { + return Effect.die(new Error(`Missing pull request provider: ${kind}`)); + } + const api = withRateLimitBackoff(registered, host, rateLimits); + return Effect.firstSuccessOf(roots.map((cwd) => api.getViewer({ cwd }))).pipe( + Effect.map((viewer) => ({ + host, + kind, + viewer: viewer as string | null, + error: null as PullRequestProviderError | null, + })), + Effect.tap((result) => + Effect.map(Clock.currentTimeMillis, (at) => viewersByHost.set(host, { at, result })), + ), + Effect.catch((error) => + Effect.succeed({ + host, + kind, + viewer: null, + error, + }), + ), + ); + }, + { + capacity: VIEWER_CACHE_CAPACITY, + // The host-wide success map holds the real ten-minute answer. This short entry exists to + // keep simultaneous cold page reads on one in-flight lookup; failures remain retryable. + timeToLive: (exit) => + Exit.isSuccess(exit) && exit.value.error === null ? Duration.seconds(1) : Duration.zero, + }, + ); const resolveViewers = ( projects: ReadonlyArray, @@ -718,18 +774,8 @@ export const make = Effect.gen(function* () { // unreadable worktree would otherwise report the whole host as signed out. const roots = viewerRoots.get(host) ?? forHost.map(({ project }) => project.workspaceRoot); - return Effect.firstSuccessOf(roots.map((cwd) => api.getViewer({ cwd }))).pipe( - Effect.map((viewer) => ({ - host, - kind: api.kind, - viewer: viewer as string | null, - error: null as PullRequestProviderError | null, - })), - Effect.tap((result) => - Effect.map(Clock.currentTimeMillis, (at) => viewersByHost.set(host, { at, result })), - ), - Effect.catch((error) => Effect.succeed({ host, kind: api.kind, viewer: null, error })), - ); + const key = JSON.stringify([host, api.kind, [...new Set(roots)].sort()]); + return Cache.get(viewerFlights, key); }), { concurrency: REPOSITORY_CONCURRENCY }, ); @@ -809,6 +855,13 @@ export const make = Effect.gen(function* () { }; }; + // A repository that has appeared in a host search is known to be indexed there. Empty + // authored/reviewing searches for that same repository are therefore real empty answers, not + // a reason to issue the two-command per-repository fallback again. + const searchVisibleAt = new Map(); + const searchVisibilityKey = (host: string, repository: string) => + `${host}\n${repository.trim().toLowerCase()}`; + const listUncached: PullRequestService["Service"]["list"] = (input) => Effect.gen(function* () { const involvement = input.involvement ?? "all"; @@ -1018,61 +1071,80 @@ export const make = Effect.gen(function* () { ? {} : { cursor: { updatedBefore: cursor.updatedBefore, delivered: cursor.delivered } }), }).pipe( - Effect.flatMap((page) => { - const rows = new Map>(); - for (const item of page.items) { - const key = item.repository.trim().toLowerCase(); - const held = rows.get(key); - if (held === undefined) rows.set(key, [item]); - else held.push(item); - } - // The oldest row of the whole slice, which is how far every repository in it has now - // been read — including the ones that contributed nothing to it. - const boundary = page.items.reduce( - (oldest, item) => - oldest === null || item.updatedAt < oldest ? item.updatedAt : oldest, - null, - ); - return Effect.forEach( - chunk, - (project): Effect.Effect => { - const fetched = rows.get(project.repository.trim().toLowerCase()) ?? []; - // GitHub does not index every repository for search — a renamed one answers for - // its old name with silence rather than with an error — so a repository the - // search said nothing at all about is read on its own, once, before it is - // believed. Only on its first slice: after that it has a boundary to carry on - // from, and silence past one means the rows are older rather than absent. That - // keeps a search-invisible repository from disappearing on a busy host, at the - // price of one request per repository with nothing in the first slice — which - // run together, and only there. - if (fetched.length === 0 && cursorOf(project) === undefined) { - return readRepository(project); + Effect.flatMap((page) => + Effect.flatMap(Clock.currentTimeMillis, (now) => { + const rows = new Map>(); + for (const [key, visibleAt] of searchVisibleAt) { + if (now - visibleAt > Duration.toMillis(SEARCH_VISIBILITY_TTL)) { + searchVisibleAt.delete(key); } - const cursorHere = cursorOf(project); - const items = - cursorHere === undefined - ? fetched - : fetched.filter( - (item) => - item.updatedAt !== cursorHere.updatedBefore || - !cursorHere.seenAt.includes(item.number), - ); - return Effect.succeed({ - key: listCursorKey(project.host, project.repository), - entries: items - .filter((item) => matchesRowFilters(item, input.filters, viewer)) - .map((item) => toEntry({ project, item, viewer })), - errors: [], - truncated: page.truncated, - nextCursor: - page.truncated && boundary !== null - ? listCursorAt(cursorHere, boundary, fetched, items.length) - : null, - }); - }, - { concurrency: REPOSITORY_CONCURRENCY }, - ); - }), + } + for (const item of page.items) { + const key = item.repository.trim().toLowerCase(); + const held = rows.get(key); + if (held === undefined) rows.set(key, [item]); + else held.push(item); + searchVisibleAt.set(searchVisibilityKey(first.host, item.repository), now); + } + // The oldest row of the whole slice, which is how far every repository in it has now + // been read — including the ones that contributed nothing to it. + const boundary = page.items.reduce( + (oldest, item) => + oldest === null || item.updatedAt < oldest ? item.updatedAt : oldest, + null, + ); + return Effect.forEach( + chunk, + (project): Effect.Effect => { + const fetched = rows.get(project.repository.trim().toLowerCase()) ?? []; + // GitHub does not index every repository for search — a renamed one answers for + // its old name with silence rather than with an error — so a repository the + // search said nothing at all about is read on its own, once, before it is + // believed. Only on its first slice: after that it has a boundary to carry on + // from, and silence past one means the rows are older rather than absent. That + // keeps a search-invisible repository from disappearing on a busy host, at the + // price of one request per repository with nothing in the first slice — which + // run together, and only there. + const lastVisible = searchVisibleAt.get( + searchVisibilityKey(project.host, project.repository), + ); + const searchIsKnownVisible = + !page.truncated && + lastVisible !== undefined && + now - lastVisible <= Duration.toMillis(SEARCH_VISIBILITY_TTL); + if ( + fetched.length === 0 && + cursorOf(project) === undefined && + !searchIsKnownVisible + ) { + return readRepository(project); + } + const cursorHere = cursorOf(project); + const items = + cursorHere === undefined + ? fetched + : fetched.filter( + (item) => + item.updatedAt !== cursorHere.updatedBefore || + !cursorHere.seenAt.includes(item.number), + ); + return Effect.succeed({ + key: listCursorKey(project.host, project.repository), + entries: items + .filter((item) => matchesRowFilters(item, input.filters, viewer)) + .map((item) => toEntry({ project, item, viewer })), + errors: [], + truncated: page.truncated, + nextCursor: + page.truncated && boundary !== null + ? listCursorAt(cursorHere, boundary, fetched, items.length) + : null, + }); + }, + { concurrency: REPOSITORY_CONCURRENCY }, + ); + }), + ), Effect.catch(separately), ); }; @@ -1127,6 +1199,39 @@ export const make = Effect.gen(function* () { const viewerOf = (project: SupportedProject): Effect.Effect => resolveViewers([project], new Map()).pipe(Effect.map(([resolved]) => resolved?.viewer ?? null)); + const summaryUncached: PullRequestService["Service"]["summary"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project) => { + const providerInput = { + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + }; + const read = + project.api.getChangeRequestSummary === undefined + ? project.api.getChangeRequest(providerInput) + : project.api.getChangeRequestSummary(providerInput); + return read.pipe( + Effect.mapError(toPullRequestError("summary")), + Effect.map( + (changeRequest): PullRequestSummary => ({ + provider: project.api.kind, + projectId: project.project.id, + repository: project.repository, + number: changeRequest.number, + title: changeRequest.title, + url: changeRequest.url, + state: changeRequest.state, + headBranch: changeRequest.headBranch, + baseBranch: changeRequest.baseBranch, + updatedAt: changeRequest.updatedAt, + }), + ), + ); + }), + ); + const detailUncached: PullRequestService["Service"]["detail"] = (input) => requireProject(input).pipe( Effect.flatMap((project) => @@ -1848,6 +1953,50 @@ export const make = Effect.gen(function* () { }; })(); + const makeLastGoodRead = (capacity: number) => { + const held = new Map(); + const record = (key: string, value: A) => + Effect.map(Clock.currentTimeMillis, (at) => { + held.delete(key); + if (held.size >= capacity) { + const oldest = held.keys().next().value; + if (oldest !== undefined) held.delete(oldest); + } + held.set(key, { at, value }); + }); + const read = (key: string, effect: Effect.Effect) => + effect.pipe( + Effect.tap((value) => record(key, value)), + Effect.catchTags({ + PullRequestOperationError: (error) => { + if (!isPullRequestProviderError(error.cause)) { + return Effect.fail(error); + } + const provider = error.cause; + if (provider.reason !== "failed" && provider.reason !== "rate-limited") { + return Effect.fail(error); + } + return Effect.flatMap(Clock.currentTimeMillis, (now) => { + const snapshot = held.get(key); + if ( + snapshot === undefined || + now - snapshot.at > Duration.toMillis(STALE_DETAIL_WINDOW) + ) { + return Effect.fail(error); + } + return Effect.logWarning("using recent pull request data after a failed refresh", { + operation: error.operation, + reason: provider.reason, + }).pipe(Effect.as(snapshot.value)); + }); + }, + }), + ); + return { read, record }; + }; + const lastGoodSummary = makeLastGoodRead(DETAIL_CACHE_CAPACITY); + const lastGoodDetail = makeLastGoodRead(DETAIL_CACHE_CAPACITY); + // Epochs are the invalidation mechanism: a key carries its scope's epoch, so bumping the // epoch strands every entry made under the old one — no enumerating a cache whose keys // (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* () { const REF_EPOCH_CAPACITY = 2_048; const refScope = (ref: PullRequestRef) => `${ref.projectId} ${ref.repository} ${ref.number}`; const refEpoch = (ref: PullRequestRef) => refEpochs.get(refScope(ref)) ?? 0; + const refCacheKey = (ref: PullRequestRef) => + JSON.stringify([refEpoch(ref), ref.projectId, ref.repository, ref.number]); const bumpRefEpoch = (ref: PullRequestRef) => { const scope = refScope(ref); if (!refEpochs.has(scope) && refEpochs.size >= REF_EPOCH_CAPACITY) { @@ -1884,6 +2035,24 @@ export const make = Effect.gen(function* () { }; }; + const summaryCache = yield* Cache.makeWith( + (key: string) => { + const [, projectId, repository, number] = JSON.parse(key) as [number, string, string, number]; + return summaryUncached({ projectId, repository, number } as PullRequestRef); + }, + { + capacity: DETAIL_CACHE_CAPACITY, + timeToLive: (exit) => (Exit.isSuccess(exit) ? SUMMARY_CACHE_TTL : Duration.zero), + }, + ); + const summary: PullRequestService["Service"]["summary"] = (input, options) => { + const key = refCacheKey(input); + const cached = Cache.get(summaryCache, key); + return options?.recoverTransientFailure === false + ? cached.pipe(Effect.tap((value) => lastGoodSummary.record(key, value))) + : lastGoodSummary.read(key, cached); + }; + // Keys serialize positionally and parse back in the lookup, so the cache is the only holder // of in-flight state: concurrent identical reads coalesce on the key into one host request. // 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* () { }, ); const detail: PullRequestService["Service"]["detail"] = (input) => { - const key = JSON.stringify([refEpoch(input), input.projectId, input.repository, input.number]); - return Cache.get(detailCache, key); + const key = refCacheKey(input); + return lastGoodDetail.read(key, Cache.get(detailCache, key)); }; const activityCache = yield* Cache.makeWith( @@ -1987,7 +2156,7 @@ export const make = Effect.gen(function* () { }, ); const activity: PullRequestService["Service"]["activity"] = (input) => { - const key = JSON.stringify([refEpoch(input), input.projectId, input.repository, input.number]); + const key = refCacheKey(input); return Cache.get(activityCache, key); }; @@ -2059,17 +2228,16 @@ export const make = Effect.gen(function* () { return Cache.get(listStatsCache, key); }; - const invalidate: PullRequestService["Service"]["invalidate"] = (input) => - Effect.sync(() => { - if (input.reference === undefined) { - listingsEpoch = ++epochCounter; - // A whole-workspace refresh is the reader asking to be re-answered from the hosts, - // and that includes who the hosts say they are. - viewersByHost.clear(); - return; - } - bumpRefEpoch(input.reference); - }); + const invalidate: PullRequestService["Service"]["invalidate"] = (input) => { + const reference = input.reference; + if (reference !== undefined) { + return Effect.sync(() => bumpRefEpoch(reference)); + } + return Effect.sync(() => { + listingsEpoch = ++epochCounter; + viewersByHost.clear(); + }).pipe(Effect.andThen(Cache.invalidateAll(viewerFlights))); + }; // A mutation's own client re-reads right after it, and every other client's next read must // 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* () { return PullRequestService.of({ list, listStats, + summary, detail, activity, threadComments, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 71519da422c9c52f930f6b6b8702ba6b3311a5c8..37370b16a1334c42a0abab87b9b4897816109482 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -136,6 +136,7 @@ import * as VcsDriver from "./vcs/VcsDriver.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; +import * as VcsProcess from "./vcs/VcsProcess.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; @@ -1041,6 +1042,7 @@ const buildAppUnderTest = (options?: { Layer.provideMerge(ServerSecretStore.layer), Layer.provide(workspaceAndProjectServicesLayer), Layer.provideMerge(FetchHttpClient.layer), + Layer.provide(VcsProcess.layer), Layer.provide(layerConfig), ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 9ae28080b048e84b79d4652a987cf9b73aaab6ba..6a06f5e6eda4f7d470004ac3d9e3aeb5d44b4857 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -303,7 +303,6 @@ const PullRequestServiceLive = PullRequestService.layer.pipe( Layer.provide(PullRequestProviderRegistry.layer), Layer.provide(SourceControlProviderRegistryLayerLive), Layer.provide(SourceControlRateLimit.layer), - Layer.provide(VcsProcess.layer), ); const GitManagerLayerLive = GitManager.layer.pipe( @@ -713,7 +712,8 @@ export const makeServerLayer = Layer.unwrap( Layer.provideMerge(HttpServerLive), Layer.provide(ApplicationObservabilityLive), Layer.provideMerge(FetchHttpClient.layer), - Layer.provideMerge(VcsProcess.layer), + // PR reads, Git operations, and WebSocket discovery share one process limiter. + Layer.provide(VcsProcess.layer), Layer.provideMerge(PlatformServicesLive), ); }), diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 964ed3d021c101bb5d29c59a907292e437af82dd..ea79dc87e7fc88a6dfa8b2a4fe25544e848845a3 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -66,6 +66,7 @@ describe("GitHubCli.layer", () => { headRefName: "feature/pr-threads", state: "OPEN", mergedAt: null, + updatedAt: "2026-08-24T12:34:56Z", isCrossRepository: true, headRepository: { nameWithOwner: "octocat/codething-mvp", @@ -91,6 +92,7 @@ describe("GitHubCli.layer", () => { baseRefName: "main", headRefName: "feature/pr-threads", state: "open", + updatedAt: "2026-08-24T12:34:56.000Z", isCrossRepository: true, headRepositoryNameWithOwner: "octocat/codething-mvp", headRepositoryOwnerLogin: "octocat", @@ -103,7 +105,7 @@ describe("GitHubCli.layer", () => { "view", "#42", "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ], cwd: "/repo", timeoutMs: 30_000, diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 974574cbd20ebdff0776570f0d3b484563ddb793..a9b65b30e7061ba2ac0625cb88189b7ec736280b 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -1,6 +1,8 @@ import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; @@ -15,6 +17,7 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; import { decodeGitHubPullRequestJson, decodeGitHubPullRequestListJson, + type NormalizedGitHubPullRequestRecord, } from "./gitHubPullRequests.ts"; const DEFAULT_TIMEOUT_MS = 30_000; @@ -202,11 +205,20 @@ export interface GitHubPullRequestSummary { readonly baseRefName: string; readonly headRefName: string; readonly state?: "open" | "closed" | "merged"; + readonly updatedAt?: string; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; readonly headRepositoryOwnerLogin?: string | null; } +function pullRequestSummary(input: NormalizedGitHubPullRequestRecord): GitHubPullRequestSummary { + const { updatedAt, ...summary } = input; + return { + ...summary, + ...(Option.isSome(updatedAt) ? { updatedAt: DateTime.formatIso(updatedAt.value) } : {}), + }; +} + export interface GitHubRepositoryCloneUrls { readonly nameWithOwner: string; readonly url: string; @@ -373,9 +385,7 @@ export const make = Effect.gen(function* () { ); } - return Effect.succeed( - decoded.success.map(({ updatedAt: _updatedAt, ...summary }) => summary), - ); + return Effect.succeed(decoded.success.map(pullRequestSummary)); }), ), ), @@ -388,7 +398,7 @@ export const make = Effect.gen(function* () { "view", input.reference, "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => result.stdout.trim()), @@ -405,9 +415,7 @@ export const make = Effect.gen(function* () { ); } - return Effect.succeed( - (({ updatedAt: _updatedAt, ...summary }) => summary)(decoded.success), - ); + return Effect.succeed(pullRequestSummary(decoded.success)); }), ), ), diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 3dcc8ab826a6e4ec7d838e299020dc23a9405a95..738b10498d56f8469d6f4af7e6b9cb4ecf0761d2 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -1,3 +1,4 @@ +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -29,7 +30,10 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq baseRefName: summary.baseRefName, headRefName: summary.headRefName, state: summary.state ?? "open", - updatedAt: Option.none(), + updatedAt: + summary.updatedAt === undefined + ? Option.none() + : Option.some(DateTime.makeUnsafe(summary.updatedAt)), ...(summary.isCrossRepository !== undefined ? { isCrossRepository: summary.isCrossRepository } : {}), @@ -162,10 +166,18 @@ export const make = Effect.gen(function* () { Effect.flatMap((decoded) => Result.isSuccess(decoded) ? Effect.succeed( - decoded.success.map((item) => ({ - ...toChangeRequest(item), - updatedAt: item.updatedAt, - })), + decoded.success.map((item) => { + const { updatedAt, ...summary } = item; + return { + ...toChangeRequest({ + ...summary, + ...(Option.isSome(updatedAt) + ? { updatedAt: DateTime.formatIso(updatedAt.value) } + : {}), + }), + updatedAt, + }; + }), ) : Effect.fail( new GitHubCli.GitHubChangeRequestListDecodeError({ diff --git a/apps/server/src/vcs/VcsProcess.test.ts b/apps/server/src/vcs/VcsProcess.test.ts index bd3e5b4cdce201552a536354c0495e63f6106581..91191178c330f8a4a26177a34fc590a5c6df4a41 100644 --- a/apps/server/src/vcs/VcsProcess.test.ts +++ b/apps/server/src/vcs/VcsProcess.test.ts @@ -1,10 +1,14 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; import { TestClock } from "effect/testing"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { VcsProcessExitError, @@ -45,6 +49,63 @@ const captureProcessResult = ( ); describe("VcsProcess.run", () => { + it.effect("bounds a synthetic burst of GitHub API processes", () => + Effect.gen(function* () { + const gate = yield* Deferred.make(); + const starts = yield* Queue.unbounded(); + const active = yield* Ref.make(0); + const peak = yield* Ref.make(0); + const total = yield* Ref.make(0); + const service = yield* VcsProcess.make.pipe( + Effect.provideService( + ProcessRunner.ProcessRunner, + ProcessRunner.ProcessRunner.of({ + run: () => + Effect.gen(function* () { + const count = yield* Ref.updateAndGet(active, (held) => held + 1); + yield* Ref.update(peak, (held) => Math.max(held, count)); + yield* Ref.update(total, (held) => held + 1); + yield* Queue.offer(starts, count); + yield* Deferred.await(gate); + return { + stdout: "", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }; + }).pipe(Effect.ensuring(Ref.update(active, (count) => count - 1))), + }), + ), + ); + + const burst = yield* Effect.all( + Array.from({ length: 32 }, (_, index) => + service.run({ + operation: `synthetic.github.${index}`, + command: "gh", + args: ["api", "user"], + cwd: "/workspace", + }), + ), + { concurrency: "unbounded" }, + ).pipe(Effect.forkChild); + + yield* Effect.all(Array.from({ length: 4 }, () => Queue.take(starts))); + yield* Effect.yieldNow; + expect(yield* Queue.size(starts)).toBe(0); + expect(yield* Ref.get(peak)).toBe(4); + + yield* Deferred.succeed(gate, undefined); + yield* Fiber.join(burst); + expect(yield* Ref.get(total)).toBe(32); + expect(yield* Ref.get(peak)).toBe(4); + }), + ); + it.effect("collects stdout", () => Effect.gen(function* () { const result = yield* run({ diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts index ec245fa1360447bb6a1629e95d0fa9db920f5c56..69df83c420253c72dfd987307febf281b3a11f8c 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -2,6 +2,7 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Match from "effect/Match"; +import * as Semaphore from "effect/Semaphore"; import { ChildProcessSpawner } from "effect/unstable/process"; import { @@ -52,6 +53,8 @@ export class VcsProcess extends Context.Service< const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; +const VCS_PROCESS_CONCURRENCY = 8; +const GITHUB_PROCESS_CONCURRENCY = 4; const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFailureKind => { const normalized = stderr.toLowerCase(); @@ -101,8 +104,10 @@ const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFai export const make = Effect.gen(function* () { const processRunner = yield* ProcessRunner.ProcessRunner; + const vcsProcesses = yield* Semaphore.make(VCS_PROCESS_CONCURRENCY); + const githubProcesses = yield* Semaphore.make(GITHUB_PROCESS_CONCURRENCY); - const run = Effect.fn("VcsProcess.run")(function* (input: VcsProcessInput) { + const runUnbounded = Effect.fn("VcsProcess.runUnbounded")(function* (input: VcsProcessInput) { const baseError = { operation: input.operation, command: input.command, @@ -181,6 +186,11 @@ export const make = Effect.gen(function* () { } satisfies VcsProcessOutput; }); + const run = Effect.fn("VcsProcess.run")(function* (input: VcsProcessInput) { + const bounded = vcsProcesses.withPermits(1)(runUnbounded(input)); + return yield* input.command === "gh" ? githubProcesses.withPermits(1)(bounded) : bounded; + }); + return VcsProcess.of({ run }); }); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index aafeb032234c18614d1f8425c11c8f8233b00245..0c4d2c5c89d801cb9f62719f901bba6577780c4f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -33,7 +33,6 @@ import { type OrchestrationEvent, type OrchestrationShellStreamEvent, type OrchestrationShellStreamItem, - type OrchestrationThreadStreamItem, OrchestrationGetFullThreadDiffError, OrchestrationGetSnapshotError, OrchestrationSearchThreadsError, @@ -137,7 +136,6 @@ import * as SourceControlProviderRegistry from "./sourceControl/SourceControlPro import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; -import * as VcsProcess from "./vcs/VcsProcess.ts"; import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as SessionStore from "./auth/SessionStore.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; @@ -1896,6 +1894,10 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.pullRequestsListStats, pullRequests.listStats(input), { "rpc.aggregate": "pull-requests", }), + [WS_METHODS.pullRequestsSummary]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsSummary, pullRequests.summary(input), { + "rpc.aggregate": "pull-requests", + }), [WS_METHODS.pullRequestsDetail]: (input) => observeRpcEffect(WS_METHODS.pullRequestsDetail, pullRequests.detail(input), { "rpc.aggregate": "pull-requests", @@ -2635,7 +2637,6 @@ export const websocketRpcRouteLayer = Layer.unwrap( ), ), ), - Layer.provide(VcsProcess.layer), ), ), ), diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 97cd26fd4aad9fd20ebab696df50b279fe32dfd1..53011714d1a16092ade7aceda6225ddf39998796 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -492,13 +492,6 @@ export function PullRequestDetailPanel({ const activityQuery = useEnvironmentQuery( pullRequestEnvironment.activity({ environmentId, input: reference }), ); - // Detail and diff are independent server reads, so the diff for the default view (no commit, - // no cursor) is started here too rather than waiting for the Code tab to mount. This is one - // extra cached read per opened pull request even for readers who never open the tab, but it - // turns the tab's first paint from a cold request into a cache hit. - const _diffWarmUpQuery = useEnvironmentQuery( - pullRequestEnvironment.diff({ environmentId, input: { ...reference } }), - ); const coreDetail = detailQuery.data; const activity = activityQuery.data; const detail = useMemo( diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index abe76a6539435a8a2fba55bebf018e9f77ce0958..4c0bf1ad310e2240eb146b356b63f695034b69b4 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -760,7 +760,16 @@ function PullRequestsRouteView() { // Built together so the two reads share one memo, and in the same field order the feed's own // input uses: the atoms are keyed by their input, so the Authored tab then reads this answer. const partitionTargets = useMemo(() => { - if (!partitionsWanted) return { authored: NO_LIST_TARGETS, reviewing: NO_LIST_TARGETS }; + // The main list goes first. Besides putting the visible rows on screen sooner, it proves + // which repositories the host search indexes, so an empty partition does not trigger the + // expensive per-repository fallback. With no rows at all both partitions are already empty. + if ( + !partitionsWanted || + baselineQuery.data === null || + baselineQuery.data.entries.length === 0 + ) { + return { authored: NO_LIST_TARGETS, reviewing: NO_LIST_TARGETS }; + } const targetsFor = (involvement: PullRequestInvolvement) => environmentQueries.map(({ environmentId, projectIds }) => ({ environmentId, @@ -779,6 +788,7 @@ function PullRequestsRouteView() { menuFiltered, menuFilters, partitionsWanted, + baselineQuery.data, environmentQueries, scopedProjectId, search.host, diff --git a/apps/web/src/state/pullRequests.ts b/apps/web/src/state/pullRequests.ts index 939c4f3aea2d78b6892e3bb5722faad888e8bb2d..601b6efa3dccbe4f3470ab14fd0da2e9fc033dc7 100644 --- a/apps/web/src/state/pullRequests.ts +++ b/apps/web/src/state/pullRequests.ts @@ -1,6 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; import { - createLinkedPullRequestDetailAtomFamily, + createLinkedPullRequestSummaryAtomFamily, createPullRequestEnvironmentAtoms, } from "@t3tools/client-runtime/state/pull-requests"; import type { @@ -23,7 +23,7 @@ import { formatEnvironmentQueryError } from "./query"; export const pullRequestEnvironment = createPullRequestEnvironmentAtoms(connectionAtomRuntime); export const linkedPullRequestDetailAtom = - createLinkedPullRequestDetailAtomFamily(connectionAtomRuntime); + createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime); export interface EnvironmentQueryTarget { readonly environmentId: EnvironmentId; diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index 44f138abc4bf2c8415b93068ba4f907b9d231c25..b98524e048242de6a58ef7869edb802773c4b01d 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -2,6 +2,7 @@ import { WS_METHODS, type PullRequestDetail, type PullRequestDiffInput, + type PullRequestSummary, type VcsStatusResult, } from "@t3tools/contracts"; import * as Data from "effect/Data"; @@ -31,23 +32,23 @@ export class EnvironmentHttpConnectionNotReadyError extends Data.TaggedError( "EnvironmentHttpConnectionNotReadyError", )<{ readonly message: string }> {} -export const LINKED_PULL_REQUEST_DETAIL_IDLE_TTL_MS = 5_000; +export const LINKED_PULL_REQUEST_IDLE_TTL_MS = 5_000; -/** Refresh a linked PR while its thread is visible so merges update the sidebar. */ -export function createLinkedPullRequestDetailAtomFamily( +/** Refresh only the live fields a linked thread renders. */ +export function createLinkedPullRequestSummaryAtomFamily( runtime: Atom.AtomRuntime, ) { return createEnvironmentRpcQueryAtomFamily(runtime, { - label: "environment-data:pull-requests:linked-detail", - tag: WS_METHODS.pullRequestsDetail, - staleTimeMs: 15_000, - refreshIntervalMs: 30_000, - idleTtlMs: LINKED_PULL_REQUEST_DETAIL_IDLE_TTL_MS, + label: "environment-data:pull-requests:linked-summary", + tag: WS_METHODS.pullRequestsSummary, + staleTimeMs: 60_000, + refreshIntervalMs: 60_000, + idleTtlMs: LINKED_PULL_REQUEST_IDLE_TTL_MS, }); } export function pullRequestDetailToVcsStatus( - detail: PullRequestDetail, + detail: PullRequestDetail | PullRequestSummary, ): NonNullable { return { number: detail.number, diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 1f30803aa1d662e4ac73f9766b4756361076807d..ce7fd0973ccb5337c5862e0dec696debfb662031 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -590,6 +590,24 @@ export const PullRequestRef = Schema.Struct({ }); export type PullRequestRef = typeof PullRequestRef.Type; +/** + * The small live shape a linked thread needs. Keeping it separate from detail means a sidebar + * status check never loads permissions, repository settings, checks, or base comparison data. + */ +export const PullRequestSummary = Schema.Struct({ + provider: SourceControlProviderKind, + projectId: ProjectId, + repository: TrimmedNonEmptyString, + number: PositiveInt, + title: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, + state: PullRequestState, + headBranch: TrimmedNonEmptyString, + baseBranch: TrimmedNonEmptyString, + updatedAt: IsoDateTime, +}); +export type PullRequestSummary = typeof PullRequestSummary.Type; + /** * One row's line counts, read after the listing rather than inside it. On GitHub the pair is * 40-60% of the wall clock of the search that answers the whole page — measured over twelve diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 12f6f9893df9dd7bc2e7b3424cf9bb41026f0c4d..9c009baabcc37f7dd50c738f687c0b508130631a 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -97,6 +97,7 @@ import { PullRequestOperationError, PullRequestReactionInput, PullRequestRef, + PullRequestSummary, PullRequestReviewerCandidateList, PullRequestReviewerRequestInput, PullRequestSubmitReviewInput, @@ -302,6 +303,7 @@ export const WS_METHODS = { // Pull request methods pullRequestsList: "pullRequests.list", pullRequestsListStats: "pullRequests.listStats", + pullRequestsSummary: "pullRequests.summary", pullRequestsDetail: "pullRequests.detail", pullRequestsActivity: "pullRequests.activity", pullRequestsThreadComments: "pullRequests.threadComments", @@ -521,6 +523,12 @@ export const WsPullRequestsListStatsRpc = Rpc.make(WS_METHODS.pullRequestsListSt error: PullRequestRpcError, }); +export const WsPullRequestsSummaryRpc = Rpc.make(WS_METHODS.pullRequestsSummary, { + payload: PullRequestRef, + success: PullRequestSummary, + error: PullRequestRpcError, +}); + export const WsPullRequestsDetailRpc = Rpc.make(WS_METHODS.pullRequestsDetail, { payload: PullRequestRef, success: PullRequestDetail, @@ -1063,6 +1071,7 @@ export const WsRpcGroup = RpcGroup.make( WsCloudInstallRelayClientRpc, WsPullRequestsListRpc, WsPullRequestsListStatsRpc, + WsPullRequestsSummaryRpc, WsPullRequestsDetailRpc, WsPullRequestsActivityRpc, WsPullRequestsThreadCommentsRpc, -- 2.54.0