From 20fbd4b1c3982d9f670e27646ed635fb9ca7068c Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Sat, 5 Sep 2026 00:30:48 -0500 Subject: [PATCH] feat(pull-requests): serve cached dependency context --- apps/server/src/auth/RpcAuthorization.test.ts | 6 + apps/server/src/auth/RpcAuthorization.ts | 1 + .../pullRequest/PullRequestService.test.ts | 417 ++++++++++++++++++ .../src/pullRequest/PullRequestService.ts | 299 ++++++++++++- apps/server/src/ws.ts | 6 + .../client-runtime/src/state/pullRequests.ts | 7 + packages/contracts/src/rpc.ts | 12 + 7 files changed, 744 insertions(+), 4 deletions(-) diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 25971b0c0aec..ab61281f6749 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -54,6 +54,12 @@ describe("RPC authorization scopes", () => { ); }); + it("reads dependency context under the same scope as pull request detail", () => { + expect(requiredScopeForRpcMethod(WS_METHODS.pullRequestsDependencyContext)).toBe( + requiredScopeForRpcMethod(WS_METHODS.pullRequestsDetail), + ); + }); + it("rejects unknown RPC method names", () => { for (const method of ["server.notRegistered", "toString", "constructor"]) { expect(() => requiredScopeForRpcMethod(method)).toThrow( diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index de6661f45886..12c87048593d 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -68,6 +68,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.pullRequestsListStats]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsSummary]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsDependencyContext]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsDiffFileContents]: AuthOrchestrationReadScope, diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 0dd5a30fb396..56b16f103cfe 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -2761,6 +2761,23 @@ it.effect("explicit and turn invalidations make the next listing ask the host ag }), ); +it.effect("publishes the first reference invalidation to an existing subscriber", () => + Effect.gen(function* () { + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const service = yield* makeService({ projects: [], providers: [] }); + const firstRefresh = yield* Stream.runHead(service.subscribeRefreshes).pipe( + Effect.forkChild({ startImmediately: true }), + ); + + yield* service.invalidate({ reference }); + // Ensure a lagging publication also completes the subscriber instead of leaving the test + // waiting: the reference invalidation itself must still be the first observed revision. + yield* service.refreshAfterTurn; + + assert.strictEqual(Option.getOrThrow(yield* Fiber.join(firstRefresh)), 2); + }), +); + it.effect("a mutation makes the next listing ask the host again, with no client asking", () => Effect.gen(function* () { let hostCalls = 0; @@ -2788,6 +2805,406 @@ it.effect("a mutation makes the next listing ask the host again, with no client }), ); +it.effect("coalesces bounded dependency reads and invalidates them by repository", () => + Effect.gen(function* () { + let relationshipReads = 0; + let nativeReads = 0; + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 2 }; + const parent = { + ...changeRequest(1, "2026-07-02T00:00:00Z"), + headBranch: "migration", + headRepositoryNameWithOwner: "acme/web", + }; + const child = { + ...changeRequest(2, "2026-07-03T00:00:00Z"), + headBranch: "api", + headRepositoryNameWithOwner: "acme/web", + baseBranch: "migration", + }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + listChangeRequests: (input) => { + relationshipReads += 1; + assert.strictEqual(input.host, "github.com"); + assert.strictEqual(input.repository, "acme/web"); + assert.strictEqual(input.state, "open"); + assert.strictEqual(input.involvement, "all"); + assert.strictEqual(input.limit, 200); + assert.strictEqual(input.relationshipOnly, true); + return Effect.succeed({ items: [parent, child], truncated: false, continues: false }); + }, + getNativeDependencyMembership: () => { + nativeReads += 1; + return Effect.succeed({ status: "none" }); + }, + }), + ], + }); + + const [first, second] = yield* Effect.all( + [service.dependencyContext(reference), service.dependencyContext(reference)], + { concurrency: 2 }, + ); + assert.deepStrictEqual(first, second); + assert.deepStrictEqual(first.edges, [{ child: 2, parent: 1, certainty: "confirmed" }]); + assert.deepStrictEqual(first.native, { status: "none" }); + assert.strictEqual(relationshipReads, 1); + assert.strictEqual(nativeReads, 1); + + yield* service.dependencyContext({ ...reference, number: 1 }); + assert.strictEqual(relationshipReads, 1); + assert.strictEqual(nativeReads, 2); + + yield* service.invalidate({ reference }); + yield* service.dependencyContext(reference); + assert.strictEqual(relationshipReads, 2); + assert.strictEqual(nativeReads, 3); + + yield* service.update({ ...reference, title: "Renamed" }); + yield* service.dependencyContext(reference); + assert.strictEqual(relationshipReads, 3); + assert.strictEqual(nativeReads, 4); + assert.isAbove(Option.getOrThrow(yield* Stream.runHead(service.subscribeRefreshes)), 0); + }), +); + +it.effect("retries a failed relationship read immediately and caches the recovered context", () => + Effect.gen(function* () { + let relationshipReads = 0; + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 2 }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + listChangeRequests: () => { + relationshipReads += 1; + if (relationshipReads === 1) return Effect.fail(requestFailed); + return Effect.succeed({ + items: [ + { + ...changeRequest(1, "2026-07-02T00:00:00Z"), + headBranch: "migration", + headRepositoryNameWithOwner: "acme/web", + }, + { + ...changeRequest(2, "2026-07-03T00:00:00Z"), + headBranch: "api", + headRepositoryNameWithOwner: "acme/web", + baseBranch: "migration", + }, + ], + truncated: false, + continues: false, + }); + }, + }), + ], + }); + + assert.strictEqual((yield* service.dependencyContext(reference)).coverage, "unavailable"); + const recovered = yield* service.dependencyContext(reference); + assert.strictEqual(recovered.coverage, "complete"); + assert.deepStrictEqual(recovered.edges, [{ child: 2, parent: 1, certainty: "confirmed" }]); + assert.strictEqual(relationshipReads, 2); + assert.deepStrictEqual(yield* service.dependencyContext(reference), recovered); + yield* service.dependencyContext({ ...reference, number: 1 }); + assert.strictEqual(relationshipReads, 2); + }), +); + +it.effect("retries a failed focus summary and caches the recovered context", () => + Effect.gen(function* () { + let relationshipReads = 0; + let summaryReads = 0; + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 2 }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + listChangeRequests: () => { + relationshipReads += 1; + return Effect.succeed({ + items: [ + { + ...changeRequest(1, "2026-07-02T00:00:00Z"), + headBranch: "migration", + headRepositoryNameWithOwner: "acme/web", + }, + ], + truncated: false, + continues: false, + }); + }, + getChangeRequestSummary: () => { + summaryReads += 1; + return summaryReads === 1 + ? Effect.fail(requestFailed) + : Effect.succeed({ + ...changeRequest(2, "2026-07-03T00:00:00Z"), + headBranch: "api", + baseBranch: "migration", + }); + }, + }), + ], + }); + + const degraded = yield* service.dependencyContext(reference); + assert.isTrue(degraded.issues.some((issue) => issue.reason === "host-unavailable")); + const recovered = yield* service.dependencyContext(reference); + assert.isFalse(recovered.issues.some((issue) => issue.reason === "host-unavailable")); + assert.isTrue(recovered.nodes.some((node) => node.ref.number === 2)); + assert.strictEqual(relationshipReads, 1); + assert.strictEqual(summaryReads, 2); + assert.deepStrictEqual(yield* service.dependencyContext(reference), recovered); + assert.strictEqual(summaryReads, 2); + }), +); + +it.effect("retries unavailable native membership and caches the recovered context", () => + Effect.gen(function* () { + let relationshipReads = 0; + let nativeReads = 0; + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 2 }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + listChangeRequests: () => { + relationshipReads += 1; + return Effect.succeed({ + items: [ + { + ...changeRequest(1, "2026-07-02T00:00:00Z"), + headBranch: "migration", + headRepositoryNameWithOwner: "acme/web", + }, + { + ...changeRequest(2, "2026-07-03T00:00:00Z"), + headBranch: "api", + headRepositoryNameWithOwner: "acme/web", + baseBranch: "migration", + }, + ], + truncated: false, + continues: false, + }); + }, + getNativeDependencyMembership: () => { + nativeReads += 1; + return nativeReads === 1 + ? Effect.fail(requestFailed) + : Effect.succeed({ status: "none" }); + }, + }), + ], + }); + + const degraded = yield* service.dependencyContext(reference); + assert.deepStrictEqual(degraded.edges, [{ child: 2, parent: 1, certainty: "confirmed" }]); + assert.strictEqual(degraded.coverage, "complete"); + assert.deepStrictEqual(degraded.native, { status: "unavailable" }); + const recovered = yield* service.dependencyContext(reference); + assert.deepStrictEqual(recovered.native, { status: "none" }); + assert.strictEqual(relationshipReads, 1); + assert.strictEqual(nativeReads, 2); + assert.deepStrictEqual(yield* service.dependencyContext(reference), recovered); + assert.strictEqual(nativeReads, 2); + }), +); + +it.effect("treats omitted raw relationship rows as partial coverage", () => + Effect.gen(function* () { + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 2 }; + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "web", + workspaceRoot: "/a", + repository: "acme/web", + provider: "gitlab", + }), + ], + providers: [ + fakeProvider("gitlab", { + listChangeRequests: () => + Effect.succeed({ + items: [ + { + ...changeRequest(1, "2026-07-02T00:00:00Z"), + headBranch: "migration", + headRepositoryNameWithOwner: "acme/web", + }, + { + ...changeRequest(2, "2026-07-03T00:00:00Z"), + headBranch: "api", + headRepositoryNameWithOwner: "acme/web", + baseBranch: "migration", + }, + ], + truncated: false, + cursorAdvance: 3, + continues: false, + }), + }), + ], + }); + + const result = yield* service.dependencyContext(reference); + assert.strictEqual(result.coverage, "partial"); + assert.deepStrictEqual(result.edges, [{ child: 2, parent: 1, certainty: "candidate" }]); + assert.isTrue(result.issues.some((issue) => issue.reason === "budget")); + }), +); + +it.effect("treats duplicate provider rows as partial relationship coverage", () => + Effect.gen(function* () { + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 2 }; + const parent = { + ...changeRequest(1, "2026-07-02T00:00:00Z"), + headBranch: "migration", + headRepositoryNameWithOwner: "acme/web", + }; + const child = { + ...changeRequest(2, "2026-07-03T00:00:00Z"), + headBranch: "api", + headRepositoryNameWithOwner: "acme/web", + baseBranch: "migration", + }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + listChangeRequests: () => + Effect.succeed({ + items: [parent, child, parent], + truncated: false, + continues: false, + }), + }), + ], + }); + + const result = yield* service.dependencyContext(reference); + assert.strictEqual(result.coverage, "partial"); + assert.deepStrictEqual(result.edges, [{ child: 2, parent: 1, certainty: "candidate" }]); + assert.isTrue(result.issues.some((issue) => issue.reason === "budget")); + }), +); + +it.effect("keeps a merged focus visible without linking it to a reused live branch", () => + Effect.gen(function* () { + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 2 }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + listChangeRequests: () => + Effect.succeed({ + items: [ + { + ...changeRequest(1, "2026-07-04T00:00:00Z"), + headBranch: "reused-branch", + headRepositoryNameWithOwner: "acme/web", + }, + ], + truncated: false, + continues: false, + }), + getChangeRequestSummary: () => + Effect.succeed({ + ...changeRequest(2, "2026-07-03T00:00:00Z"), + state: "merged", + baseBranch: "reused-branch", + }), + }), + ], + }); + + const result = yield* service.dependencyContext(reference); + assert.deepStrictEqual( + result.nodes.map((node) => [node.ref.number, node.state]), + [[2, "merged"]], + ); + assert.deepStrictEqual(result.edges, []); + }), +); + +it.effect("appends ordered native members as nodes without fabricating branch edges", () => + Effect.gen(function* () { + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 2 }; + const focus = { + ...changeRequest(2, "2026-07-03T00:00:00Z"), + headBranch: "api", + headRepositoryNameWithOwner: "acme/web", + baseBranch: "migration", + }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + listChangeRequests: () => + Effect.succeed({ items: [focus], truncated: false, continues: false }), + getNativeDependencyMembership: () => + Effect.succeed({ + status: "present", + id: "STACK_1", + coverage: "complete", + members: [ + { + number: 1, + title: "Migration", + url: "https://github.com/acme/web/pull/1", + state: "merged", + isDraft: false, + headBranch: "migration", + headRepositoryNameWithOwner: "acme/web", + baseBranch: "main", + }, + focus, + ], + }), + }), + ], + }); + + const result = yield* service.dependencyContext(reference); + assert.deepStrictEqual(result.native, { + status: "present", + id: "STACK_1", + members: [1, 2], + coverage: "complete", + }); + assert.deepStrictEqual(result.nodes.map((node) => node.ref.number).sort(), [1, 2]); + assert.deepStrictEqual(result.edges, []); + }), +); + +it.effect("returns an unavailable context when the relationship listing fails", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + listChangeRequests: () => Effect.fail(requestFailed), + }), + ], + }); + + const result = yield* service.dependencyContext({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 2, + }); + assert.strictEqual(result.coverage, "unavailable"); + assert.deepStrictEqual(result.nodes, []); + assert.deepStrictEqual(result.issues, [{ reason: "host-unavailable" }]); + }), +); + it.effect("does not cache a failed listing", () => Effect.gen(function* () { let hostCalls = 0; diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index f6a0405ec9b2..445f267fc7a5 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -25,6 +25,7 @@ import { type PullRequestCommentInput, type PullRequestCommentUpdateInput, type PullRequestDetail, + type PullRequestDependencyContext, type PullRequestDiffFileContentsInput, type PullRequestDiffFileContentsResult, type PullRequestDiffStat, @@ -63,11 +64,17 @@ import * as SourceControlProviderRegistry from "../sourceControl/SourceControlPr import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; import { type ProviderChangeRequest, + type ProviderChangeRequestPage, type ProviderListCursor, + type ProviderNativeDependencyMembership, type PullRequestProviderApi, PullRequestProviderError, } from "./PullRequestProvider.ts"; import { PullRequestProviderRegistry } from "./PullRequestProviderRegistry.ts"; +import { + buildPullRequestDependencyContext, + type ProviderDependencyNode, +} from "./pullRequestDependencyTopology.ts"; export interface PullRequestMergeEvent extends PullRequestRef { readonly mergedAt: string; @@ -117,6 +124,7 @@ const DIFF_CACHE_TTL = Duration.seconds(60); const COMMIT_DIFF_CACHE_TTL = Duration.minutes(10); /** Sized like the client's own stale time; a row's counts move only when somebody pushes. */ const LIST_STATS_CACHE_TTL = Duration.seconds(60); +const DEPENDENCY_CACHE_TTL = Duration.seconds(30); /** A diff can stay interactive while its next cached value is fetched off the critical path. */ const DIFF_STALE_WINDOW = Duration.minutes(10); /** How long one host's signed-in login is believed without asking its CLI again. */ @@ -129,6 +137,9 @@ const LIST_STATS_CACHE_CAPACITY = 32; const DETAIL_CACHE_CAPACITY = 128; const DIFF_CACHE_CAPACITY = 128; const VIEWER_CACHE_CAPACITY = 32; +const DEPENDENCY_CACHE_CAPACITY = 64; +const DEPENDENCY_RELATIONSHIP_LIMIT = 200; +const NATIVE_DEPENDENCY_MEMBER_LIMIT = 100; export type PullRequestError = PullRequestUnavailableError | PullRequestOperationError; @@ -153,6 +164,9 @@ export class PullRequestService extends Context.Service< readonly subscribeRefreshes: Stream.Stream; readonly refreshAfterTurn: Effect.Effect; readonly detail: (input: PullRequestRef) => Effect.Effect; + readonly dependencyContext: ( + input: PullRequestRef, + ) => Effect.Effect; readonly activity: ( input: PullRequestRef, ) => Effect.Effect; @@ -473,6 +487,14 @@ function withRateLimitBackoff( listChangeRequestStats: wrap("listChangeRequestStats", api.listChangeRequestStats), }), getChangeRequest: wrap("getChangeRequest", api.getChangeRequest), + ...(api.getNativeDependencyMembership === undefined + ? {} + : { + getNativeDependencyMembership: wrap( + "getNativeDependencyMembership", + api.getNativeDependencyMembership, + ), + }), ...(api.getChangeRequestSummary === undefined ? {} : { @@ -1296,7 +1318,13 @@ export const make = Effect.gen(function* () { ).pipe( Effect.map(([changeRequest, viewer, capabilities]): PullRequestDetail => ({ provider: project.api.kind, - capabilities, + capabilities: { + ...capabilities, + dependencies: { + branchRelationships: true, + nativeMembership: project.api.getNativeDependencyMembership !== undefined, + }, + }, projectId: project.project.id, projectTitle: project.project.title, workspaceRoot: project.project.workspaceRoot, @@ -1345,6 +1373,195 @@ export const make = Effect.gen(function* () { ), ); + type DependencyRelationshipRead = + | { readonly _tag: "Success"; readonly page: ProviderChangeRequestPage } + | { readonly _tag: "Failure"; readonly error: PullRequestProviderError }; + const dependencyRelationshipsUncached = (project: SupportedProject) => + project.api + .listChangeRequests({ + cwd: project.project.workspaceRoot, + host: project.host, + repository: project.repository, + state: "open", + involvement: "all", + // The viewer is ignored for an all-involvement read. Avoid a separate account request in + // the dependency budget just to fill a field no adapter consults in this mode. + viewer: "", + limit: DEPENDENCY_RELATIONSHIP_LIMIT, + relationshipOnly: true, + }) + .pipe( + Effect.map((page): DependencyRelationshipRead => ({ _tag: "Success", page })), + Effect.catch((error) => + Effect.succeed({ _tag: "Failure", error }), + ), + ); + // Replaced with the repository-index cache below once its epoch machinery has been created. + let dependencyRelationships: ( + project: SupportedProject, + ) => Effect.Effect = + dependencyRelationshipsUncached; + + const dependencyContextUncached: PullRequestService["Service"]["dependencyContext"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project) => + Effect.gen(function* () { + const branchRead = yield* dependencyRelationships(project); + const nativeProviderRead = yield* project.api.getNativeDependencyMembership === undefined + ? Effect.succeed({ _tag: "Unsupported" as const }) + : project.api + .getNativeDependencyMembership({ + cwd: project.project.workspaceRoot, + host: project.host, + repository: project.repository, + number: input.number, + limit: NATIVE_DEPENDENCY_MEMBER_LIMIT, + }) + .pipe( + Effect.map((membership: ProviderNativeDependencyMembership) => ({ + _tag: "Success" as const, + membership, + })), + Effect.orElseSucceed(() => ({ _tag: "Failure" as const })), + ); + const nativeRead: PullRequestDependencyContext["native"] = + nativeProviderRead._tag === "Unsupported" + ? undefined + : nativeProviderRead._tag === "Failure" + ? { status: "unavailable" } + : nativeProviderRead.membership.status === "none" + ? { status: "none" } + : { + status: "present", + id: nativeProviderRead.membership.id, + members: nativeProviderRead.membership.members + .slice(0, NATIVE_DEPENDENCY_MEMBER_LIMIT) + .map((member) => member.number), + coverage: + nativeProviderRead.membership.coverage === "partial" || + nativeProviderRead.membership.members.length > + NATIVE_DEPENDENCY_MEMBER_LIMIT + ? "partial" + : "complete", + }; + + let rows: ProviderDependencyNode[] = []; + let branchComplete = false; + let branchUnavailable = false; + let budgetExhausted = false; + if (branchRead._tag === "Success") { + rows = branchRead.page.items.slice(0, DEPENDENCY_RELATIONSHIP_LIMIT); + budgetExhausted = + branchRead.page.truncated || + branchRead.page.items.length > DEPENDENCY_RELATIONSHIP_LIMIT || + new Set(branchRead.page.items.map((row) => row.number)).size !== + branchRead.page.items.length || + (branchRead.page.cursorAdvance !== undefined && + branchRead.page.cursorAdvance !== branchRead.page.items.length); + branchComplete = !budgetExhausted; + if (!rows.some((row) => row.number === input.number)) { + const nativeFocus = + nativeProviderRead._tag === "Success" && + nativeProviderRead.membership.status === "present" + ? (nativeProviderRead.membership.members.find( + (member) => member.number === input.number, + ) ?? null) + : null; + const summaryRead = project.api.getChangeRequestSummary; + const summary = + nativeFocus !== null || summaryRead === undefined + ? null + : yield* summaryRead({ + cwd: project.project.workspaceRoot, + host: project.host, + repository: project.repository, + number: input.number, + }).pipe(Effect.orElseSucceed(() => null)); + const focus: ProviderDependencyNode | null = + nativeFocus ?? + (summary === null + ? null + : { + number: summary.number, + title: summary.title, + url: summary.url, + state: summary.state, + isDraft: summary.isDraft === true, + headBranch: summary.headBranch, + headRepositoryNameWithOwner: null, + baseBranch: summary.baseBranch, + }); + if (focus === null) { + branchComplete = false; + branchUnavailable = true; + } else if (rows.length === DEPENDENCY_RELATIONSHIP_LIMIT) { + rows[rows.length - 1] = focus; + budgetExhausted = true; + branchComplete = false; + } else { + rows.push(focus); + } + } + } else { + branchUnavailable = true; + } + + const topology = buildPullRequestDependencyContext({ + projectId: project.project.id, + provider: project.api.kind, + host: project.host, + repository: project.repository, + focus: input.number, + rows, + complete: branchComplete, + }); + + const nativeMembership = + nativeProviderRead._tag === "Success" && + nativeProviderRead.membership.status === "present" + ? nativeProviderRead.membership.members.slice(0, NATIVE_DEPENDENCY_MEMBER_LIMIT) + : []; + const nodesByNumber = new Map(topology.nodes.map((node) => [node.ref.number, node])); + for (const member of nativeMembership) { + if (nodesByNumber.has(member.number)) continue; + nodesByNumber.set(member.number, { + ref: { + projectId: project.project.id, + repository: project.repository, + number: member.number, + }, + title: member.title, + url: member.url, + state: member.state, + isDraft: member.isDraft, + baseBranch: member.baseBranch, + head: + member.headRepositoryNameWithOwner == null + ? null + : { + repository: member.headRepositoryNameWithOwner, + branch: member.headBranch, + }, + }); + } + const issues = [...topology.issues]; + if (budgetExhausted && !issues.some((issue) => issue.reason === "budget")) { + issues.push({ reason: "budget" }); + } + if (branchUnavailable && !issues.some((issue) => issue.reason === "host-unavailable")) { + issues.push({ reason: "host-unavailable" }); + } + return { + ...topology, + nodes: [...nodesByNumber.values()], + coverage: branchRead._tag === "Failure" ? "unavailable" : topology.coverage, + issues, + ...(nativeRead === undefined ? {} : { native: nativeRead }), + }; + }), + ), + ); + const activityUncached: PullRequestService["Service"]["activity"] = (input) => requireProject(input).pipe( Effect.flatMap((project) => @@ -2162,7 +2379,9 @@ export const make = Effect.gen(function* () { let listingsEpoch = 0; let turnRefreshEpoch = 0; const refEpochs = new Map(); + const repositoryEpochs = new Map(); const REF_EPOCH_CAPACITY = 2_048; + const REPOSITORY_EPOCH_CAPACITY = 512; const refScope = (ref: PullRequestRef) => `${ref.projectId} ${ref.repository} ${ref.number}`; const refEpoch = (ref: PullRequestRef) => Math.max(turnRefreshEpoch, refEpochs.get(refScope(ref)) ?? 0); @@ -2176,6 +2395,18 @@ export const make = Effect.gen(function* () { } refEpochs.set(scope, ++epochCounter); }; + const repositoryScope = (ref: Pick) => + `${ref.projectId} ${ref.repository.trim().toLowerCase()}`; + const repositoryEpoch = (ref: Pick) => + Math.max(turnRefreshEpoch, listingsEpoch, repositoryEpochs.get(repositoryScope(ref)) ?? 0); + const bumpRepositoryEpoch = (ref: Pick) => { + const scope = repositoryScope(ref); + if (!repositoryEpochs.has(scope) && repositoryEpochs.size >= REPOSITORY_EPOCH_CAPACITY) { + const oldest = repositoryEpochs.keys().next().value; + if (oldest !== undefined) repositoryEpochs.delete(oldest); + } + repositoryEpochs.set(scope, ++epochCounter); + }; /** The positional filter slot of a cache key, back as the record `listUncached` takes. */ const filtersOfKey = ( @@ -2342,6 +2573,56 @@ export const make = Effect.gen(function* () { ); }; + const dependencyRelationshipCache = yield* Cache.makeWith( + (key: string) => { + const [, projectId, repository] = JSON.parse(key) as [number, string, string]; + return requireProject({ projectId, repository, number: 1 } as PullRequestRef).pipe( + Effect.flatMap(dependencyRelationshipsUncached), + ); + }, + { + capacity: DEPENDENCY_CACHE_CAPACITY, + timeToLive: (exit) => + Exit.isSuccess(exit) && exit.value._tag === "Success" + ? DEPENDENCY_CACHE_TTL + : Duration.zero, + }, + ); + dependencyRelationships = (project) => + Cache.get( + dependencyRelationshipCache, + JSON.stringify([ + repositoryEpoch({ + projectId: project.project.id, + repository: project.repository, + }), + project.project.id, + project.repository, + ]), + ); + + const dependencyCache = yield* Cache.makeWith( + (key: string) => { + const [, projectId, repository, number] = JSON.parse(key) as [number, string, string, number]; + return dependencyContextUncached({ projectId, repository, number } as PullRequestRef); + }, + { + capacity: DEPENDENCY_CACHE_CAPACITY, + timeToLive: (exit) => + Exit.isSuccess(exit) && + exit.value.coverage !== "unavailable" && + exit.value.native?.status !== "unavailable" && + !exit.value.issues.some((issue) => issue.reason === "host-unavailable") + ? DEPENDENCY_CACHE_TTL + : Duration.zero, + }, + ); + const dependencyContext: PullRequestService["Service"]["dependencyContext"] = (input) => + Cache.get( + dependencyCache, + JSON.stringify([repositoryEpoch(input), input.projectId, input.repository, input.number]), + ); + const activityCache = yield* Cache.makeWith( (key: string) => { const [, projectId, repository, number] = JSON.parse(key) as [number, string, string, number]; @@ -2428,12 +2709,18 @@ export const make = Effect.gen(function* () { const invalidate: PullRequestService["Service"]["invalidate"] = (input) => { const reference = input.reference; if (reference !== undefined) { - return Effect.sync(() => bumpRefEpoch(reference)); + return Effect.sync(() => { + bumpRefEpoch(reference); + bumpRepositoryEpoch(reference); + }).pipe(Effect.andThen(() => SubscriptionRef.set(pullRequestRefreshes, epochCounter))); } return Effect.sync(() => { listingsEpoch = ++epochCounter; viewersByHost.clear(); - }).pipe(Effect.andThen(Cache.invalidateAll(viewerFlights))); + }).pipe( + Effect.andThen(Cache.invalidateAll(viewerFlights)), + Effect.andThen(() => SubscriptionRef.set(pullRequestRefreshes, epochCounter)), + ); }; const refreshAfterTurn: PullRequestService["Service"]["refreshAfterTurn"] = Effect.suspend(() => { @@ -2453,8 +2740,9 @@ export const make = Effect.gen(function* () { Effect.tap(() => Effect.sync(() => { bumpRefEpoch(input); + bumpRepositoryEpoch(input); listingsEpoch = ++epochCounter; - }), + }).pipe(Effect.andThen(() => SubscriptionRef.set(pullRequestRefreshes, epochCounter))), ), ); const runActionAndInvalidate: PullRequestService["Service"]["runAction"] = Effect.fn( @@ -2462,7 +2750,9 @@ export const make = Effect.gen(function* () { )(function* (input) { const repository = yield* runAction(input); bumpRefEpoch({ ...input, repository }); + bumpRepositoryEpoch({ ...input, repository }); listingsEpoch = ++epochCounter; + yield* SubscriptionRef.set(pullRequestRefreshes, epochCounter); if (input.action === "merge") { // A successful merge action can merely enqueue the PR or enable auto-merge. const confirmed = yield* summaryUncached({ ...input, repository }).pipe( @@ -2494,6 +2784,7 @@ export const make = Effect.gen(function* () { ), refreshAfterTurn, detail, + dependencyContext, activity, threadComments, diff, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index a25065c452db..ce8e6c3f20df 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2092,6 +2092,12 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.pullRequestsDetail, pullRequests.detail(input), { "rpc.aggregate": "pull-requests", }), + [WS_METHODS.pullRequestsDependencyContext]: (input) => + observeRpcEffect( + WS_METHODS.pullRequestsDependencyContext, + pullRequests.dependencyContext(input), + { "rpc.aggregate": "pull-requests" }, + ), [WS_METHODS.pullRequestsActivity]: (input) => observeRpcEffect(WS_METHODS.pullRequestsActivity, pullRequests.activity(input), { "rpc.aggregate": "pull-requests", diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index 6144d8507a9e..2cadc196bda5 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -121,6 +121,13 @@ export function createPullRequestEnvironmentAtoms( staleTimeMs: 15_000, refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), }), + /** One bounded repository relationship read for the open PR panel, never for list rows. */ + dependencyContext: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:pull-requests:dependency-context", + tag: WS_METHODS.pullRequestsDependencyContext, + staleTimeMs: 30_000, + refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), + }), activity, threadComments: createEnvironmentRpcCommand(runtime, { label: "environment-data:pull-requests:thread-comments", diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index f7f2c2b6faa7..5aeb20c5d782 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -96,6 +96,7 @@ import { PullRequestCommentInput, PullRequestCommentUpdateInput, PullRequestDetail, + PullRequestDependencyContext, PullRequestDiffFileContentsInput, PullRequestDiffFileContentsResult, PullRequestInvalidateInput, @@ -331,6 +332,7 @@ export const WS_METHODS = { pullRequestsListStats: "pullRequests.listStats", pullRequestsSummary: "pullRequests.summary", pullRequestsDetail: "pullRequests.detail", + pullRequestsDependencyContext: "pullRequests.dependencyContext", pullRequestsActivity: "pullRequests.activity", pullRequestsThreadComments: "pullRequests.threadComments", pullRequestsDiffFileContents: "pullRequests.diffFileContents", @@ -640,6 +642,15 @@ export const WsPullRequestsDetailRpc = Rpc.make(WS_METHODS.pullRequestsDetail, { error: PullRequestRpcError, }); +export const WsPullRequestsDependencyContextRpc = Rpc.make( + WS_METHODS.pullRequestsDependencyContext, + { + payload: PullRequestRef, + success: PullRequestDependencyContext, + error: PullRequestRpcError, + }, +); + export const WsPullRequestsActivityRpc = Rpc.make(WS_METHODS.pullRequestsActivity, { payload: PullRequestRef, success: PullRequestActivity, @@ -1214,6 +1225,7 @@ export const WsRpcGroup = RpcGroup.make( WsPullRequestsListStatsRpc, WsPullRequestsSummaryRpc, WsPullRequestsDetailRpc, + WsPullRequestsDependencyContextRpc, WsPullRequestsActivityRpc, WsPullRequestsThreadCommentsRpc, WsPullRequestsDiffFileContentsRpc,