| author | |
| committer | |
| log | dd6879ffea24da9fc16cb62d7d6072d2c5b2bae3 |
| tree | 1264368ef88f2065ad4dc42bca7703d4a8f8aacd |
| parent | 9e646ad84c9d5008187eabf479e85619df9de8ca |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>25 files changed, 868 insertions(+), 187 deletions(-)
apps/mobile/src/state/use-thread-pr.ts+2-2| ... | ... | @@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; |
| 2 | 2 | import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 3 | 3 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 4 | 4 | import { |
| 5 | createLinkedPullRequestDetailAtomFamily, | |
| 5 | createLinkedPullRequestSummaryAtomFamily, | |
| 6 | 6 | pullRequestDetailToVcsStatus, |
| 7 | 7 | } from "@t3tools/client-runtime/state/pull-requests"; |
| 8 | 8 | import { Atom } from "effect/unstable/reactivity"; |
| ... | ... | @@ -14,7 +14,7 @@ import { useEnvironmentQuery } from "./query"; |
| 14 | 14 | import { presentThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation"; |
| 15 | 15 | import { vcsEnvironment } from "./vcs"; |
| 16 | 16 | |
| 17 | const linkedPullRequestDetailAtom = createLinkedPullRequestDetailAtomFamily(connectionAtomRuntime); | |
| 17 | const linkedPullRequestDetailAtom = createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime); | |
| 18 | 18 | const MAX_THREAD_PR_SNAPSHOTS = 500; |
| 19 | 19 | |
| 20 | 20 | interface ThreadPrSnapshot { |
apps/server/src/auth/RpcAuthorization.ts+1| ... | ... | @@ -55,6 +55,7 @@ export const RPC_REQUIRED_SCOPES = { |
| 55 | 55 | [WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope, |
| 56 | 56 | [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope, |
| 57 | 57 | [WS_METHODS.pullRequestsListStats]: AuthOrchestrationReadScope, |
| 58 | [WS_METHODS.pullRequestsSummary]: AuthOrchestrationReadScope, | |
| 58 | 59 | [WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope, |
| 59 | 60 | [WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope, |
| 60 | 61 | [WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope, |
apps/server/src/orchestration/ThreadSettlementReactor.test.ts+35-59| ... | ... | @@ -8,7 +8,7 @@ import { |
| 8 | 8 | type OrchestrationProjectShell, |
| 9 | 9 | type OrchestrationShellSnapshot, |
| 10 | 10 | type OrchestrationThreadShell, |
| 11 | type PullRequestDetail, | |
| 11 | type PullRequestSummary, | |
| 12 | 12 | type ServerSettings, |
| 13 | 13 | type ServerSettingsPatch, |
| 14 | 14 | } from "@t3tools/contracts"; |
| ... | ... | @@ -105,56 +105,24 @@ function makeSnapshot( |
| 105 | 105 | }; |
| 106 | 106 | } |
| 107 | 107 | |
| 108 | function makePullRequestDetail(input: { | |
| 108 | function makePullRequestSummary(input: { | |
| 109 | 109 | readonly projectId: ProjectId; |
| 110 | 110 | readonly repository: string; |
| 111 | 111 | readonly number: number; |
| 112 | 112 | readonly state: "open" | "closed" | "merged"; |
| 113 | 113 | readonly updatedAt?: string; |
| 114 | }): PullRequestDetail { | |
| 114 | }): PullRequestSummary { | |
| 115 | 115 | return { |
| 116 | 116 | provider: "github", |
| 117 | capabilities: { | |
| 118 | diff: true, | |
| 119 | comment: true, | |
| 120 | actions: [], | |
| 121 | mergeMethods: [], | |
| 122 | search: true, | |
| 123 | review: { inlineComment: true, reply: true, resolve: true, verdicts: [] }, | |
| 124 | reviewers: { request: true, listCandidates: true }, | |
| 125 | }, | |
| 126 | viewerPermissions: { | |
| 127 | actions: [], | |
| 128 | comment: true, | |
| 129 | resolve: true, | |
| 130 | verdicts: [], | |
| 131 | requestReviewers: true, | |
| 132 | }, | |
| 133 | 117 | projectId: input.projectId, |
| 134 | projectTitle: "Linked project", | |
| 135 | workspaceRoot: "/workspace/linked", | |
| 136 | 118 | repository: input.repository, |
| 137 | 119 | number: input.number, |
| 138 | 120 | title: "Pull request", |
| 139 | body: "", | |
| 140 | 121 | url: `https://example.test/${input.repository}/pull/${input.number}`, |
| 141 | author: null, | |
| 142 | 122 | state: input.state, |
| 143 | isDraft: false, | |
| 144 | mergeability: "mergeable", | |
| 145 | additions: 0, | |
| 146 | deletions: 0, | |
| 147 | changedFiles: 0, | |
| 148 | 123 | headBranch: "feature", |
| 149 | 124 | baseBranch: "main", |
| 150 | createdAt: "2026-08-01T00:00:00.000Z", | |
| 151 | 125 | updatedAt: input.updatedAt ?? NOW, |
| 152 | mergedAt: input.state === "merged" ? (input.updatedAt ?? NOW) : null, | |
| 153 | closedAt: input.state === "closed" ? (input.updatedAt ?? NOW) : null, | |
| 154 | reviewers: [], | |
| 155 | labels: [], | |
| 156 | checks: [], | |
| 157 | mergeCapabilities: { merge: true, squash: true, rebase: true }, | |
| 158 | 126 | }; |
| 159 | 127 | } |
| 160 | 128 | |
| ... | ... | @@ -162,7 +130,7 @@ interface HarnessOptions { |
| 162 | 130 | readonly snapshot: OrchestrationShellSnapshot; |
| 163 | 131 | readonly settings?: ServerSettings; |
| 164 | 132 | readonly branchPullRequest?: GitManager["Service"]["branchPullRequest"]; |
| 165 | readonly pullRequestDetail?: PullRequestService["Service"]["detail"]; | |
| 133 | readonly pullRequestSummary?: PullRequestService["Service"]["summary"]; | |
| 166 | 134 | readonly onDispatch?: ( |
| 167 | 135 | command: AutoSettleCommand, |
| 168 | 136 | ) => Effect.Effect<void, OrchestrationCommandInvariantError>; |
| ... | ... | @@ -179,13 +147,14 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: |
| 179 | 147 | const branchCalls = yield* Ref.make< |
| 180 | 148 | ReadonlyArray<{ readonly cwd: string; readonly branch: string }> |
| 181 | 149 | >([]); |
| 182 | const detailCalls = yield* Ref.make< | |
| 150 | const summaryCalls = yield* Ref.make< | |
| 183 | 151 | ReadonlyArray<{ |
| 184 | 152 | readonly projectId: ProjectId; |
| 185 | 153 | readonly repository: string; |
| 186 | 154 | readonly number: number; |
| 187 | 155 | }> |
| 188 | 156 | >([]); |
| 157 | const summaryRecovery = yield* Ref.make<ReadonlyArray<boolean | undefined>>([]); | |
| 189 | 158 | |
| 190 | 159 | const updateSettings = (patch: ServerSettingsPatch) => |
| 191 | 160 | Effect.gen(function* () { |
| ... | ... | @@ -200,18 +169,23 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: |
| 200 | 169 | Effect.andThen(options.branchPullRequest?.(input) ?? Effect.succeed(null)), |
| 201 | 170 | ); |
| 202 | 171 | |
| 203 | const pullRequestDetail: PullRequestService["Service"]["detail"] = (input) => | |
| 204 | Ref.update(detailCalls, (calls) => [...calls, input]).pipe( | |
| 205 | Effect.andThen( | |
| 206 | options.pullRequestDetail?.(input) ?? | |
| 172 | const pullRequestSummary: PullRequestService["Service"]["summary"] = (input, readOptions) => | |
| 173 | Effect.gen(function* () { | |
| 174 | yield* Ref.update(summaryCalls, (calls) => [...calls, input]); | |
| 175 | yield* Ref.update(summaryRecovery, (values) => [ | |
| 176 | ...values, | |
| 177 | readOptions?.recoverTransientFailure, | |
| 178 | ]); | |
| 179 | return yield* ( | |
| 180 | options.pullRequestSummary?.(input, readOptions) ?? | |
| 207 | 181 | Effect.succeed( |
| 208 | makePullRequestDetail({ | |
| 182 | makePullRequestSummary({ | |
| 209 | 183 | ...input, |
| 210 | 184 | state: "open", |
| 211 | 185 | }), |
| 212 | ), | |
| 213 | ), | |
| 214 | ); | |
| 186 | ) | |
| 187 | ); | |
| 188 | }); | |
| 215 | 189 | |
| 216 | 190 | const dispatch: OrchestrationEngineShape["dispatch"] = (command) => { |
| 217 | 191 | if (command.type !== "thread.auto-settle") { |
| ... | ... | @@ -243,7 +217,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: |
| 243 | 217 | ), |
| 244 | 218 | }), |
| 245 | 219 | Layer.mock(GitManager)({ branchPullRequest }), |
| 246 | Layer.mock(PullRequestService)({ detail: pullRequestDetail }), | |
| 220 | Layer.mock(PullRequestService)({ summary: pullRequestSummary }), | |
| 247 | 221 | Layer.mock(OrchestrationEngineService)({ |
| 248 | 222 | readEvents: () => Stream.empty, |
| 249 | 223 | dispatch, |
| ... | ... | @@ -262,7 +236,8 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: |
| 262 | 236 | snapshotReads, |
| 263 | 237 | commands, |
| 264 | 238 | branchCalls, |
| 265 | detailCalls, | |
| 239 | summaryCalls, | |
| 240 | summaryRecovery, | |
| 266 | 241 | updateSettings, |
| 267 | 242 | layer: ThreadSettlementReactor.layer.pipe(Layer.provide(dependencies)), |
| 268 | 243 | }; |
| ... | ... | @@ -310,8 +285,8 @@ describe("ThreadSettlementReactor", () => { |
| 310 | 285 | [makeProject(), makeProject(LINKED_PROJECT_ID, "/workspace/linked")], |
| 311 | 286 | ), |
| 312 | 287 | branchPullRequest: () => Effect.succeed(null), |
| 313 | pullRequestDetail: (input) => | |
| 314 | Effect.succeed(makePullRequestDetail({ ...input, state: "closed" })), | |
| 288 | pullRequestSummary: (input) => | |
| 289 | Effect.succeed(makePullRequestSummary({ ...input, state: "closed" })), | |
| 315 | 290 | }); |
| 316 | 291 | |
| 317 | 292 | yield* Effect.gen(function* () { |
| ... | ... | @@ -342,9 +317,10 @@ describe("ThreadSettlementReactor", () => { |
| 342 | 317 | assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), [ |
| 343 | 318 | { cwd: "/workspace/project", branch: "inactive-feature" }, |
| 344 | 319 | ]); |
| 345 | assert.deepStrictEqual(yield* Ref.get(fixture.detailCalls), [ | |
| 320 | assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), [ | |
| 346 | 321 | { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 42 }, |
| 347 | 322 | ]); |
| 323 | assert.deepStrictEqual(yield* Ref.get(fixture.summaryRecovery), [false]); | |
| 348 | 324 | }).pipe(Effect.provide(fixture.layer)); |
| 349 | 325 | }), |
| 350 | 326 | ), |
| ... | ... | @@ -480,10 +456,10 @@ describe("ThreadSettlementReactor", () => { |
| 480 | 456 | ], |
| 481 | 457 | [makeProject(), makeProject(LINKED_PROJECT_ID, "/workspace/linked")], |
| 482 | 458 | ), |
| 483 | pullRequestDetail: () => | |
| 459 | pullRequestSummary: () => | |
| 484 | 460 | Effect.fail( |
| 485 | 461 | new PullRequestOperationError({ |
| 486 | operation: "detail", | |
| 462 | operation: "summary", | |
| 487 | 463 | detail: "host unavailable", |
| 488 | 464 | }), |
| 489 | 465 | ), |
| ... | ... | @@ -497,7 +473,7 @@ describe("ThreadSettlementReactor", () => { |
| 497 | 473 | (yield* Ref.get(fixture.commands)).map((command) => command.threadId), |
| 498 | 474 | [ThreadId.make("inactive-without-pr")], |
| 499 | 475 | ); |
| 500 | assert.strictEqual((yield* Ref.get(fixture.detailCalls)).length, 1); | |
| 476 | assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1); | |
| 501 | 477 | }).pipe(Effect.provide(fixture.layer)); |
| 502 | 478 | }), |
| 503 | 479 | ), |
| ... | ... | @@ -521,8 +497,8 @@ describe("ThreadSettlementReactor", () => { |
| 521 | 497 | ], |
| 522 | 498 | [makeProject(LINKED_PROJECT_ID, "/workspace/linked")], |
| 523 | 499 | ), |
| 524 | pullRequestDetail: (input) => | |
| 525 | Effect.succeed(makePullRequestDetail({ ...input, state: "open" })), | |
| 500 | pullRequestSummary: (input) => | |
| 501 | Effect.succeed(makePullRequestSummary({ ...input, state: "open" })), | |
| 526 | 502 | }); |
| 527 | 503 | |
| 528 | 504 | yield* Effect.gen(function* () { |
| ... | ... | @@ -530,7 +506,7 @@ describe("ThreadSettlementReactor", () => { |
| 530 | 506 | yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); |
| 531 | 507 | |
| 532 | 508 | assert.deepStrictEqual(yield* Ref.get(fixture.commands), []); |
| 533 | assert.deepStrictEqual(yield* Ref.get(fixture.detailCalls), [ | |
| 509 | assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), [ | |
| 534 | 510 | { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 10 }, |
| 535 | 511 | ]); |
| 536 | 512 | assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), []); |
| ... | ... | @@ -569,8 +545,8 @@ describe("ThreadSettlementReactor", () => { |
| 569 | 545 | ], |
| 570 | 546 | ), |
| 571 | 547 | branchPullRequest: () => Effect.succeed({ state: "closed", updatedAt: NOW }), |
| 572 | pullRequestDetail: (input) => | |
| 573 | Effect.succeed(makePullRequestDetail({ ...input, state: "merged" })), | |
| 548 | pullRequestSummary: (input) => | |
| 549 | Effect.succeed(makePullRequestSummary({ ...input, state: "merged" })), | |
| 574 | 550 | }); |
| 575 | 551 | |
| 576 | 552 | yield* Effect.gen(function* () { |
| ... | ... | @@ -580,7 +556,7 @@ describe("ThreadSettlementReactor", () => { |
| 580 | 556 | assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), [ |
| 581 | 557 | { cwd: "/workspace/project-root", branch: "saved-feature" }, |
| 582 | 558 | ]); |
| 583 | assert.deepStrictEqual(yield* Ref.get(fixture.detailCalls), [ | |
| 559 | assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), [ | |
| 584 | 560 | { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 77 }, |
| 585 | 561 | ]); |
| 586 | 562 | assert.deepStrictEqual( |
apps/server/src/orchestration/ThreadSettlementReactor.ts+12-6| ... | ... | @@ -69,12 +69,18 @@ export const make = Effect.gen(function* () { |
| 69 | 69 | if (!projects.has(thread.linkedPullRequest.projectId)) { |
| 70 | 70 | return yield* Effect.die(new Error("linked pull request project not found")); |
| 71 | 71 | } |
| 72 | const detail = yield* pullRequests.detail({ | |
| 73 | projectId: thread.linkedPullRequest.projectId, | |
| 74 | repository: thread.linkedPullRequest.repository, | |
| 75 | number: thread.linkedPullRequest.number, | |
| 76 | }); | |
| 77 | return { state: detail.state, updatedAt: detail.updatedAt } satisfies SettlementPullRequest; | |
| 72 | const summary = yield* pullRequests.summary( | |
| 73 | { | |
| 74 | projectId: thread.linkedPullRequest.projectId, | |
| 75 | repository: thread.linkedPullRequest.repository, | |
| 76 | number: thread.linkedPullRequest.number, | |
| 77 | }, | |
| 78 | { recoverTransientFailure: false }, | |
| 79 | ); | |
| 80 | return { | |
| 81 | state: summary.state, | |
| 82 | updatedAt: summary.updatedAt, | |
| 83 | } satisfies SettlementPullRequest; | |
| 78 | 84 | } |
| 79 | 85 | if (thread.branch === null) return null; |
| 80 | 86 | const project = projects.get(thread.projectId); |
apps/server/src/pullRequest/GitHubPullRequestCli.test.ts+43| ... | ... | @@ -10,12 +10,14 @@ import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; |
| 10 | 10 | import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts"; |
| 11 | 11 | |
| 12 | 12 | const mockedExecute = vi.fn<GitHubCli.GitHubCli["Service"]["execute"]>(); |
| 13 | const mockedGetPullRequest = vi.fn<GitHubCli.GitHubCli["Service"]["getPullRequest"]>(); | |
| 13 | 14 | |
| 14 | 15 | const layer = it.layer( |
| 15 | 16 | GitHubPullRequestCli.layer.pipe( |
| 16 | 17 | Layer.provide( |
| 17 | 18 | Layer.mock(GitHubCli.GitHubCli)({ |
| 18 | 19 | execute: mockedExecute, |
| 20 | getPullRequest: mockedGetPullRequest, | |
| 19 | 21 | }), |
| 20 | 22 | ), |
| 21 | 23 | Layer.provide(GitHubGraphQlBudget.layer), |
| ... | ... | @@ -177,9 +179,50 @@ function searchQueryOfCall(index: number): string | undefined { |
| 177 | 179 | |
| 178 | 180 | afterEach(() => { |
| 179 | 181 | mockedExecute.mockReset(); |
| 182 | mockedGetPullRequest.mockReset(); | |
| 180 | 183 | }); |
| 181 | 184 | |
| 182 | 185 | layer("GitHubPullRequestCli.layer", (it) => { |
| 186 | it.effect("reads linked pull request status through one narrow request", () => | |
| 187 | Effect.gen(function* () { | |
| 188 | mockedGetPullRequest.mockReturnValueOnce( | |
| 189 | Effect.succeed({ | |
| 190 | number: 7, | |
| 191 | title: "Reuse the summary", | |
| 192 | url: "https://github.com/acme/web/pull/7", | |
| 193 | baseRefName: "main", | |
| 194 | headRefName: "feat/summary", | |
| 195 | state: "open", | |
| 196 | updatedAt: "2026-08-24T12:34:56.000Z", | |
| 197 | }), | |
| 198 | ); | |
| 199 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 200 | ||
| 201 | const summary = yield* cli.getPullRequestSummary({ | |
| 202 | cwd: "/w", | |
| 203 | repository: "acme/web", | |
| 204 | host: "github.com", | |
| 205 | number: 7, | |
| 206 | }); | |
| 207 | ||
| 208 | assert.deepStrictEqual(summary, { | |
| 209 | number: 7, | |
| 210 | title: "Reuse the summary", | |
| 211 | url: "https://github.com/acme/web/pull/7", | |
| 212 | headBranch: "feat/summary", | |
| 213 | baseBranch: "main", | |
| 214 | state: "open", | |
| 215 | updatedAt: "2026-08-24T12:34:56.000Z", | |
| 216 | }); | |
| 217 | expect(mockedGetPullRequest).toHaveBeenCalledOnce(); | |
| 218 | expect(mockedGetPullRequest).toHaveBeenCalledWith({ | |
| 219 | cwd: "/w", | |
| 220 | reference: "https://github.com/acme/web/pull/7", | |
| 221 | }); | |
| 222 | expect(mockedExecute).not.toHaveBeenCalled(); | |
| 223 | }), | |
| 224 | ); | |
| 225 | ||
| 183 | 226 | it.effect("asks for one row more than the page, to probe for a next page", () => |
| 184 | 227 | Effect.gen(function* () { |
| 185 | 228 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1)))); |
apps/server/src/pullRequest/GitHubPullRequestCli.ts+68-1| ... | ... | @@ -123,6 +123,25 @@ export class GitHubViewerLoginUnavailableError extends Schema.TaggedErrorClass<G |
| 123 | 123 | } |
| 124 | 124 | } |
| 125 | 125 | |
| 126 | /** Not a decode failure: gh answered, but the pull request carried no update time. */ | |
| 127 | export class GitHubPullRequestUpdatedAtUnavailableError extends Schema.TaggedErrorClass<GitHubPullRequestUpdatedAtUnavailableError>()( | |
| 128 | "GitHubPullRequestUpdatedAtUnavailableError", | |
| 129 | { | |
| 130 | command: Schema.Literal("gh"), | |
| 131 | cwd: Schema.String, | |
| 132 | repository: Schema.String, | |
| 133 | number: Schema.Int, | |
| 134 | }, | |
| 135 | ) { | |
| 136 | get detail(): string { | |
| 137 | return `Pull request ${this.repository}#${this.number} reported no update time.`; | |
| 138 | } | |
| 139 | ||
| 140 | override get message(): string { | |
| 141 | return `GitHub CLI failed in getPullRequestSummary: ${this.detail}`; | |
| 142 | } | |
| 143 | } | |
| 144 | ||
| 126 | 145 | /** Not a decode failure: the reader asked to carry on from a cursor this walk never handed out. */ |
| 127 | 146 | export class GitHubDiffCursorError extends Schema.TaggedErrorClass<GitHubDiffCursorError>()( |
| 128 | 147 | "GitHubDiffCursorError", |
| ... | ... | @@ -250,7 +269,8 @@ export type GitHubPullRequestCliError = |
| 250 | 269 | | GitHubRepositorySelectorError |
| 251 | 270 | | GitHubSubjectScopeError |
| 252 | 271 | | SourceControlRateLimit.SourceControlRateLimitPausedError |
| 253 | | GitHubViewerLoginUnavailableError; | |
| 272 | | GitHubViewerLoginUnavailableError | |
| 273 | | GitHubPullRequestUpdatedAtUnavailableError; | |
| 254 | 274 | |
| 255 | 275 | /** A large pull request can produce a multi-megabyte patch; past this it is truncated. */ |
| 256 | 276 | const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; |
| ... | ... | @@ -360,6 +380,24 @@ export class GitHubPullRequestCli extends Context.Service< |
| 360 | 380 | }>; |
| 361 | 381 | }) => Effect.Effect<ReadonlyArray<GitHubPullRequestStat>, GitHubPullRequestCliError>; |
| 362 | 382 | |
| 383 | readonly getPullRequestSummary: (input: { | |
| 384 | readonly cwd: string; | |
| 385 | readonly repository: string; | |
| 386 | readonly host: string; | |
| 387 | readonly number: number; | |
| 388 | }) => Effect.Effect< | |
| 389 | { | |
| 390 | readonly number: number; | |
| 391 | readonly title: string; | |
| 392 | readonly url: string; | |
| 393 | readonly headBranch: string; | |
| 394 | readonly baseBranch: string; | |
| 395 | readonly state: "open" | "closed" | "merged"; | |
| 396 | readonly updatedAt: string; | |
| 397 | }, | |
| 398 | GitHubPullRequestCliError | |
| 399 | >; | |
| 400 | ||
| 363 | 401 | readonly getPullRequestDetail: (input: { |
| 364 | 402 | readonly cwd: string; |
| 365 | 403 | readonly repository: string; |
| ... | ... | @@ -1326,6 +1364,35 @@ export const make = Effect.gen(function* () { |
| 1326 | 1364 | ).pipe(Effect.map((results) => results.flat())); |
| 1327 | 1365 | }, |
| 1328 | 1366 | |
| 1367 | getPullRequestSummary: (input) => | |
| 1368 | github | |
| 1369 | .getPullRequest({ | |
| 1370 | cwd: input.cwd, | |
| 1371 | reference: `https://${input.host}/${input.repository}/pull/${input.number}`, | |
| 1372 | }) | |
| 1373 | .pipe( | |
| 1374 | Effect.flatMap((summary) => | |
| 1375 | summary.updatedAt === undefined | |
| 1376 | ? Effect.fail( | |
| 1377 | new GitHubPullRequestUpdatedAtUnavailableError({ | |
| 1378 | command: "gh", | |
| 1379 | cwd: input.cwd, | |
| 1380 | repository: input.repository, | |
| 1381 | number: input.number, | |
| 1382 | }), | |
| 1383 | ) | |
| 1384 | : Effect.succeed({ | |
| 1385 | number: summary.number, | |
| 1386 | title: summary.title, | |
| 1387 | url: summary.url, | |
| 1388 | headBranch: summary.headRefName, | |
| 1389 | baseBranch: summary.baseRefName, | |
| 1390 | state: summary.state ?? "open", | |
| 1391 | updatedAt: summary.updatedAt, | |
| 1392 | }), | |
| 1393 | ), | |
| 1394 | ), | |
| 1395 | ||
| 1329 | 1396 | getPullRequestDetail: (input) => |
| 1330 | 1397 | github |
| 1331 | 1398 | .execute({ |
apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts+37| ... | ... | @@ -7,6 +7,43 @@ import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; |
| 7 | 7 | import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts"; |
| 8 | 8 | import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts"; |
| 9 | 9 | |
| 10 | it.effect("uses one narrow read for a linked pull request summary", () => | |
| 11 | Effect.gen(function* () { | |
| 12 | let summaryReads = 0; | |
| 13 | const provider = yield* make.pipe( | |
| 14 | Effect.provide( | |
| 15 | Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ | |
| 16 | getPullRequestSummary: () => | |
| 17 | Effect.sync(() => { | |
| 18 | summaryReads += 1; | |
| 19 | return { | |
| 20 | number: 7, | |
| 21 | title: "Summary", | |
| 22 | url: "https://github.com/acme/web/pull/7", | |
| 23 | headBranch: "feat/summary", | |
| 24 | baseBranch: "main", | |
| 25 | state: "open" as const, | |
| 26 | updatedAt: "2026-08-24T12:34:56.000Z", | |
| 27 | }; | |
| 28 | }), | |
| 29 | }), | |
| 30 | ), | |
| 31 | ); | |
| 32 | ||
| 33 | const readSummary = provider.getChangeRequestSummary; | |
| 34 | if (readSummary === undefined) return yield* Effect.die("summary read was not implemented"); | |
| 35 | const summary = yield* readSummary({ | |
| 36 | cwd: "/w", | |
| 37 | repository: "acme/web", | |
| 38 | host: "github.com", | |
| 39 | number: 7, | |
| 40 | }); | |
| 41 | ||
| 42 | expect(summary.state).toBe("open"); | |
| 43 | expect(summaryReads).toBe(1); | |
| 44 | }), | |
| 45 | ); | |
| 46 | ||
| 10 | 47 | describe("gitHubViewerPermissions", () => { |
| 11 | 48 | it("offers everything to a viewer who can write to the repository", () => { |
| 12 | 49 | expect(gitHubViewerPermissions({ canWrite: true, canUpdate: true, didAuthor: false })).toEqual({ |
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+23-1| ... | ... | @@ -1,4 +1,7 @@ |
| 1 | import * as Cache from "effect/Cache"; | |
| 2 | import * as Duration from "effect/Duration"; | |
| 1 | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Exit from "effect/Exit"; | |
| 2 | 5 | import type { |
| 3 | 6 | PullRequestActor, |
| 4 | 7 | PullRequestCapabilities, |
| ... | ... | @@ -129,6 +132,22 @@ const rendersEmpty = (body: string): boolean => |
| 129 | 132 | export const make = Effect.gen(function* () { |
| 130 | 133 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 131 | 134 | |
| 135 | const repositoryAccessCache = yield* Cache.makeWith( | |
| 136 | (key: string) => { | |
| 137 | const [cwd, repository, host] = JSON.parse(key) as [string, string, string]; | |
| 138 | return cli.getRepositoryAccess({ cwd, repository, host }); | |
| 139 | }, | |
| 140 | { | |
| 141 | capacity: 128, | |
| 142 | timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.minutes(10) : Duration.zero), | |
| 143 | }, | |
| 144 | ); | |
| 145 | const getRepositoryAccess = (input: { | |
| 146 | readonly cwd: string; | |
| 147 | readonly repository: string; | |
| 148 | readonly host: string; | |
| 149 | }) => Cache.get(repositoryAccessCache, JSON.stringify([input.cwd, input.repository, input.host])); | |
| 150 | ||
| 132 | 151 | const fail = (operation: string) => (error: GitHubPullRequestCli.GitHubPullRequestCliError) => |
| 133 | 152 | new PullRequestProviderError({ |
| 134 | 153 | provider: "github", |
| ... | ... | @@ -223,6 +242,9 @@ export const make = Effect.gen(function* () { |
| 223 | 242 | }) |
| 224 | 243 | .pipe(Effect.mapError(fail("listChangeRequestStats"))), |
| 225 | 244 | |
| 245 | getChangeRequestSummary: (input) => | |
| 246 | cli.getPullRequestSummary(input).pipe(Effect.mapError(fail("getChangeRequestSummary"))), | |
| 247 | ||
| 226 | 248 | getChangeRequest: (input) => |
| 227 | 249 | Effect.all( |
| 228 | 250 | [ |
| ... | ... | @@ -244,7 +266,7 @@ export const make = Effect.gen(function* () { |
| 244 | 266 | ), |
| 245 | 267 | ), |
| 246 | 268 | ), |
| 247 | cli.getRepositoryAccess({ | |
| 269 | getRepositoryAccess({ | |
| 248 | 270 | cwd: input.cwd, |
| 249 | 271 | repository: input.repository, |
| 250 | 272 | host: input.host, |
apps/server/src/pullRequest/PullRequestProvider.ts+19| ... | ... | @@ -87,6 +87,17 @@ export interface ProviderChangeRequest { |
| 87 | 87 | readonly checksState?: PullRequestChecksState | null | undefined; |
| 88 | 88 | } |
| 89 | 89 | |
| 90 | /** The fields needed to keep a linked thread's pull request status live. */ | |
| 91 | export interface ProviderChangeRequestSummary { | |
| 92 | readonly number: number; | |
| 93 | readonly title: string; | |
| 94 | readonly url: string; | |
| 95 | readonly headBranch: string; | |
| 96 | readonly baseBranch: string; | |
| 97 | readonly state: PullRequestState; | |
| 98 | readonly updatedAt: string; | |
| 99 | } | |
| 100 | ||
| 90 | 101 | export interface ProviderChangeRequestPage { |
| 91 | 102 | readonly items: ReadonlyArray<ProviderChangeRequest>; |
| 92 | 103 | /** True when the host has more rows than the page size asked for. */ |
| ... | ... | @@ -301,6 +312,14 @@ export interface PullRequestProviderApi { |
| 301 | 312 | input: ProviderRepositoryRef & { readonly number: number }, |
| 302 | 313 | ) => Effect.Effect<ProviderChangeRequestDetail, PullRequestProviderError>; |
| 303 | 314 | |
| 315 | /** | |
| 316 | * The cheap live fields used by linked threads. Optional because a provider without a narrow | |
| 317 | * endpoint can fall back to its full detail read at the service boundary. | |
| 318 | */ | |
| 319 | readonly getChangeRequestSummary?: ( | |
| 320 | input: ProviderRepositoryRef & { readonly number: number }, | |
| 321 | ) => Effect.Effect<ProviderChangeRequestSummary, PullRequestProviderError>; | |
| 322 | ||
| 304 | 323 | /** Comments, line threads, and commits, kept off the critical path for the core detail. */ |
| 305 | 324 | readonly getChangeRequestActivity: ( |
| 306 | 325 | input: ProviderRepositoryRef & { readonly number: number }, |
apps/server/src/pullRequest/PullRequestService.test.ts+214| ... | ... | @@ -2293,6 +2293,105 @@ it.effect("answers a repeated listing from cache, and concurrent readers share o |
| 2293 | 2293 | }), |
| 2294 | 2294 | ); |
| 2295 | 2295 | |
| 2296 | it.effect("shares one cold viewer lookup across distinct concurrent lists", () => | |
| 2297 | Effect.gen(function* () { | |
| 2298 | let viewerCalls = 0; | |
| 2299 | let listCalls = 0; | |
| 2300 | const service = yield* makeService({ | |
| 2301 | projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], | |
| 2302 | providers: [ | |
| 2303 | fakeProvider("github", { | |
| 2304 | getViewer: () => | |
| 2305 | Effect.sync(() => { | |
| 2306 | viewerCalls += 1; | |
| 2307 | }).pipe(Effect.andThen(Effect.yieldNow), Effect.as("bilal")), | |
| 2308 | listChangeRequests: () => | |
| 2309 | Effect.sync(() => { | |
| 2310 | listCalls += 1; | |
| 2311 | return { items: [], truncated: false, continues: true }; | |
| 2312 | }), | |
| 2313 | }), | |
| 2314 | ], | |
| 2315 | }); | |
| 2316 | ||
| 2317 | yield* Effect.all( | |
| 2318 | ["all", "authored", "reviewing"].map((involvement) => | |
| 2319 | service.list({ | |
| 2320 | state: "open", | |
| 2321 | involvement: involvement as "all" | "authored" | "reviewing", | |
| 2322 | }), | |
| 2323 | ), | |
| 2324 | { concurrency: "unbounded" }, | |
| 2325 | ); | |
| 2326 | ||
| 2327 | assert.strictEqual(viewerCalls, 1); | |
| 2328 | assert.strictEqual(listCalls, 3); | |
| 2329 | }), | |
| 2330 | ); | |
| 2331 | ||
| 2332 | it.effect("uses five host reads for the normal indexed-repository page workflow", () => | |
| 2333 | Effect.gen(function* () { | |
| 2334 | let viewerCalls = 0; | |
| 2335 | let searchCalls = 0; | |
| 2336 | let fallbackCalls = 0; | |
| 2337 | let statsCalls = 0; | |
| 2338 | const service = yield* makeService({ | |
| 2339 | projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], | |
| 2340 | providers: [ | |
| 2341 | fakeProvider("github", { | |
| 2342 | getViewer: () => | |
| 2343 | Effect.sync(() => { | |
| 2344 | viewerCalls += 1; | |
| 2345 | return "bilal"; | |
| 2346 | }), | |
| 2347 | listChangeRequestsAcross: (input) => | |
| 2348 | Effect.sync(() => { | |
| 2349 | searchCalls += 1; | |
| 2350 | return { | |
| 2351 | items: | |
| 2352 | input.involvement === "all" | |
| 2353 | ? [batchedChangeRequest(1, "acme/web", "2026-07-02T00:00:00Z")] | |
| 2354 | : [], | |
| 2355 | truncated: false, | |
| 2356 | }; | |
| 2357 | }), | |
| 2358 | listChangeRequests: () => | |
| 2359 | Effect.sync(() => { | |
| 2360 | fallbackCalls += 1; | |
| 2361 | return { items: [], truncated: false, continues: true }; | |
| 2362 | }), | |
| 2363 | listChangeRequestStats: () => | |
| 2364 | Effect.sync(() => { | |
| 2365 | statsCalls += 1; | |
| 2366 | return [{ repository: "acme/web", number: 1, additions: 3, deletions: 1 }]; | |
| 2367 | }), | |
| 2368 | }), | |
| 2369 | ], | |
| 2370 | }); | |
| 2371 | ||
| 2372 | const baseline = yield* service.list({ state: "open", involvement: "all" }); | |
| 2373 | yield* Effect.all( | |
| 2374 | [ | |
| 2375 | service.list({ state: "open", involvement: "authored" }), | |
| 2376 | service.list({ state: "open", involvement: "reviewing" }), | |
| 2377 | ], | |
| 2378 | { concurrency: "unbounded" }, | |
| 2379 | ); | |
| 2380 | yield* service.listStats({ | |
| 2381 | refs: baseline.entries.map(({ projectId, repository, number }) => ({ | |
| 2382 | projectId, | |
| 2383 | repository, | |
| 2384 | number, | |
| 2385 | })), | |
| 2386 | }); | |
| 2387 | ||
| 2388 | assert.deepStrictEqual( | |
| 2389 | { viewerCalls, searchCalls, fallbackCalls, statsCalls }, | |
| 2390 | { viewerCalls: 1, searchCalls: 3, fallbackCalls: 0, statsCalls: 1 }, | |
| 2391 | ); | |
| 2392 | }), | |
| 2393 | ); | |
| 2394 | ||
| 2296 | 2395 | it.effect("returns the refreshed listing on the first read after its cache expires", () => |
| 2297 | 2396 | Effect.gen(function* () { |
| 2298 | 2397 | let hostCalls = 0; |
| ... | ... | @@ -2371,10 +2470,15 @@ it.effect("a listing narrowed to some projects is its own cache entry", () => |
| 2371 | 2470 | it.effect("an explicit invalidation makes the next listing ask the host again", () => |
| 2372 | 2471 | Effect.gen(function* () { |
| 2373 | 2472 | let hostCalls = 0; |
| 2473 | let viewerCalls = 0; | |
| 2374 | 2474 | const service = yield* makeService({ |
| 2375 | 2475 | projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], |
| 2376 | 2476 | providers: [ |
| 2377 | 2477 | fakeProvider("github", { |
| 2478 | getViewer: () => { | |
| 2479 | viewerCalls += 1; | |
| 2480 | return Effect.succeed("bilal"); | |
| 2481 | }, | |
| 2378 | 2482 | listChangeRequests: () => { |
| 2379 | 2483 | hostCalls += 1; |
| 2380 | 2484 | return Effect.succeed({ items: [], truncated: false, continues: false }); |
| ... | ... | @@ -2387,6 +2491,7 @@ it.effect("an explicit invalidation makes the next listing ask the host again", |
| 2387 | 2491 | yield* service.invalidate({}); |
| 2388 | 2492 | yield* service.list({ state: "open" }); |
| 2389 | 2493 | assert.strictEqual(hostCalls, 2); |
| 2494 | assert.strictEqual(viewerCalls, 2); | |
| 2390 | 2495 | |
| 2391 | 2496 | // Forgetting one change request leaves the listings shared. |
| 2392 | 2497 | yield* service.invalidate({ |
| ... | ... | @@ -2829,6 +2934,115 @@ it.effect( |
| 2829 | 2934 | }), |
| 2830 | 2935 | ); |
| 2831 | 2936 | |
| 2937 | it.effect("shares linked summaries and only recovers transient failures for display reads", () => | |
| 2938 | Effect.gen(function* () { | |
| 2939 | let calls = 0; | |
| 2940 | let failing = false; | |
| 2941 | const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; | |
| 2942 | const service = yield* makeService({ | |
| 2943 | projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], | |
| 2944 | providers: [ | |
| 2945 | fakeProvider("github", { | |
| 2946 | getChangeRequestSummary: () => | |
| 2947 | Effect.sync(() => { | |
| 2948 | calls += 1; | |
| 2949 | return failing; | |
| 2950 | }).pipe( | |
| 2951 | Effect.tap(() => Effect.yieldNow), | |
| 2952 | Effect.flatMap((shouldFail) => | |
| 2953 | shouldFail | |
| 2954 | ? Effect.fail( | |
| 2955 | new PullRequestProviderError({ | |
| 2956 | provider: "github", | |
| 2957 | operation: "getChangeRequestSummary", | |
| 2958 | reason: "failed", | |
| 2959 | detail: "HTTP 504", | |
| 2960 | }), | |
| 2961 | ) | |
| 2962 | : Effect.succeed(changeRequest(1, "2026-07-02T00:00:00Z")), | |
| 2963 | ), | |
| 2964 | ), | |
| 2965 | }), | |
| 2966 | ], | |
| 2967 | }); | |
| 2968 | ||
| 2969 | yield* Effect.all( | |
| 2970 | [ | |
| 2971 | service.summary(reference, { recoverTransientFailure: false }), | |
| 2972 | service.summary(reference, { recoverTransientFailure: false }), | |
| 2973 | ], | |
| 2974 | { concurrency: "unbounded" }, | |
| 2975 | ); | |
| 2976 | assert.strictEqual(calls, 1); | |
| 2977 | ||
| 2978 | yield* TestClock.adjust("61 seconds"); | |
| 2979 | failing = true; | |
| 2980 | const strict = yield* Effect.flip( | |
| 2981 | service.summary(reference, { recoverTransientFailure: false }), | |
| 2982 | ); | |
| 2983 | assert.strictEqual(strict._tag, "PullRequestOperationError"); | |
| 2984 | ||
| 2985 | const stale = yield* service.summary(reference); | |
| 2986 | assert.strictEqual(stale.updatedAt, "2026-07-02T00:00:00Z"); | |
| 2987 | assert.strictEqual(calls, 3); | |
| 2988 | ||
| 2989 | yield* service.invalidate({ reference }); | |
| 2990 | const invalidated = yield* Effect.flip(service.summary(reference)); | |
| 2991 | assert.strictEqual(invalidated._tag, "PullRequestOperationError"); | |
| 2992 | }), | |
| 2993 | ); | |
| 2994 | ||
| 2995 | it.effect("keeps recent detail on a transient refresh failure but not after invalidation", () => | |
| 2996 | Effect.gen(function* () { | |
| 2997 | let failing = false; | |
| 2998 | const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; | |
| 2999 | const service = yield* makeService({ | |
| 3000 | projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], | |
| 3001 | providers: [ | |
| 3002 | fakeProvider("github", { | |
| 3003 | getChangeRequest: () => | |
| 3004 | failing | |
| 3005 | ? Effect.fail( | |
| 3006 | new PullRequestProviderError({ | |
| 3007 | provider: "github", | |
| 3008 | operation: "getChangeRequest", | |
| 3009 | reason: "failed", | |
| 3010 | detail: "spawn gh EAGAIN", | |
| 3011 | }), | |
| 3012 | ) | |
| 3013 | : Effect.succeed({ | |
| 3014 | ...changeRequest(1, "2026-07-02T00:00:00Z"), | |
| 3015 | body: "last good body", | |
| 3016 | changedFiles: 2, | |
| 3017 | mergedAt: null, | |
| 3018 | closedAt: null, | |
| 3019 | reviewers: [], | |
| 3020 | checks: [], | |
| 3021 | mergeCapabilities: { merge: true, squash: true, rebase: true }, | |
| 3022 | viewerPermissions: { | |
| 3023 | actions: ["merge"], | |
| 3024 | comment: true, | |
| 3025 | resolve: true, | |
| 3026 | verdicts: ["comment", "approve", "request-changes"], | |
| 3027 | requestReviewers: true, | |
| 3028 | }, | |
| 3029 | }), | |
| 3030 | }), | |
| 3031 | ], | |
| 3032 | }); | |
| 3033 | ||
| 3034 | yield* service.detail(reference); | |
| 3035 | yield* TestClock.adjust("16 seconds"); | |
| 3036 | failing = true; | |
| 3037 | const stale = yield* service.detail(reference); | |
| 3038 | assert.strictEqual(stale.body, "last good body"); | |
| 3039 | ||
| 3040 | yield* service.invalidate({ reference }); | |
| 3041 | const invalidated = yield* Effect.flip(service.detail(reference)); | |
| 3042 | assert.strictEqual(invalidated._tag, "PullRequestOperationError"); | |
| 3043 | }), | |
| 3044 | ); | |
| 3045 | ||
| 2832 | 3046 | it.effect("carries an armed auto-merge through to the detail, and silence as silence", () => |
| 2833 | 3047 | Effect.gen(function* () { |
| 2834 | 3048 | const detailWith = (autoMergeEnabled: boolean | undefined) => |
apps/server/src/pullRequest/PullRequestService.ts+249-80| ... | ... | @@ -5,6 +5,7 @@ import * as Duration from "effect/Duration"; |
| 5 | 5 | import * as Effect from "effect/Effect"; |
| 6 | 6 | import * as Exit from "effect/Exit"; |
| 7 | 7 | import * as Layer from "effect/Layer"; |
| 8 | import * as Schema from "effect/Schema"; | |
| 8 | 9 | import { |
| 9 | 10 | PullRequestOperationError, |
| 10 | 11 | PullRequestUnavailableError, |
| ... | ... | @@ -38,6 +39,7 @@ import { |
| 38 | 39 | type PullRequestReviewerCandidateList, |
| 39 | 40 | type PullRequestReviewerRequestInput, |
| 40 | 41 | type PullRequestSubmitReviewInput, |
| 42 | type PullRequestSummary, | |
| 41 | 43 | type PullRequestThreadReplyInput, |
| 42 | 44 | type PullRequestThreadResolutionInput, |
| 43 | 45 | type PullRequestThreadCommentsInput, |
| ... | ... | @@ -96,6 +98,7 @@ const REPOSITORY_SEARCH_CHUNK = 100; |
| 96 | 98 | * `invalidate` rather than a flag on the read, so an ordinary read can never opt out. |
| 97 | 99 | */ |
| 98 | 100 | const LIST_CACHE_TTL = Duration.seconds(30); |
| 101 | const SUMMARY_CACHE_TTL = Duration.seconds(60); | |
| 99 | 102 | const DETAIL_CACHE_TTL = Duration.seconds(15); |
| 100 | 103 | const DIFF_CACHE_TTL = Duration.seconds(60); |
| 101 | 104 | /** A commit is content-addressed, so its own diff cannot change under its key. */ |
| ... | ... | @@ -106,10 +109,14 @@ const LIST_STATS_CACHE_TTL = Duration.seconds(60); |
| 106 | 109 | const DIFF_STALE_WINDOW = Duration.minutes(10); |
| 107 | 110 | /** How long one host's signed-in login is believed without asking its CLI again. */ |
| 108 | 111 | const VIEWER_CACHE_TTL = Duration.minutes(10); |
| 112 | const SEARCH_VISIBILITY_TTL = Duration.minutes(10); | |
| 113 | const STALE_DETAIL_WINDOW = Duration.minutes(10); | |
| 114 | const isPullRequestProviderError = Schema.is(PullRequestProviderError); | |
| 109 | 115 | const LIST_CACHE_CAPACITY = 64; |
| 110 | 116 | const LIST_STATS_CACHE_CAPACITY = 32; |
| 111 | 117 | const DETAIL_CACHE_CAPACITY = 128; |
| 112 | 118 | const DIFF_CACHE_CAPACITY = 128; |
| 119 | const VIEWER_CACHE_CAPACITY = 32; | |
| 113 | 120 | |
| 114 | 121 | export type PullRequestError = PullRequestUnavailableError | PullRequestOperationError; |
| 115 | 122 | |
| ... | ... | @@ -122,6 +129,10 @@ export class PullRequestService extends Context.Service< |
| 122 | 129 | readonly listStats: ( |
| 123 | 130 | input: PullRequestListStatsInput, |
| 124 | 131 | ) => Effect.Effect<PullRequestListStatsResult, PullRequestError>; |
| 132 | readonly summary: ( | |
| 133 | input: PullRequestRef, | |
| 134 | options?: { readonly recoverTransientFailure?: boolean }, | |
| 135 | ) => Effect.Effect<PullRequestSummary, PullRequestError>; | |
| 125 | 136 | readonly detail: (input: PullRequestRef) => Effect.Effect<PullRequestDetail, PullRequestError>; |
| 126 | 137 | readonly activity: ( |
| 127 | 138 | input: PullRequestRef, |
| ... | ... | @@ -430,6 +441,11 @@ function withRateLimitBackoff( |
| 430 | 441 | listChangeRequestStats: wrap("listChangeRequestStats", api.listChangeRequestStats), |
| 431 | 442 | }), |
| 432 | 443 | getChangeRequest: wrap("getChangeRequest", api.getChangeRequest), |
| 444 | ...(api.getChangeRequestSummary === undefined | |
| 445 | ? {} | |
| 446 | : { | |
| 447 | getChangeRequestSummary: wrap("getChangeRequestSummary", api.getChangeRequestSummary), | |
| 448 | }), | |
| 433 | 449 | getChangeRequestActivity: wrap("getChangeRequestActivity", api.getChangeRequestActivity), |
| 434 | 450 | ...(api.getReviewThreadComments === undefined |
| 435 | 451 | ? {} |
| ... | ... | @@ -699,6 +715,46 @@ export const make = Effect.gen(function* () { |
| 699 | 715 | // "is this host set up" answer the provider switcher shows, and holding it would keep saying |
| 700 | 716 | // signed-out after the reader has signed in. |
| 701 | 717 | const viewersByHost = new Map<string, { readonly at: number; readonly result: ResolvedViewer }>(); |
| 718 | const viewerFlights = yield* Cache.makeWith( | |
| 719 | (key: string): Effect.Effect<ResolvedViewer> => { | |
| 720 | const [host, kind, roots] = JSON.parse(key) as [ | |
| 721 | string, | |
| 722 | SourceControlProviderKind, | |
| 723 | ReadonlyArray<string>, | |
| 724 | ]; | |
| 725 | const registered = registry.get(kind); | |
| 726 | if (registered === null) { | |
| 727 | return Effect.die(new Error(`Missing pull request provider: ${kind}`)); | |
| 728 | } | |
| 729 | const api = withRateLimitBackoff(registered, host, rateLimits); | |
| 730 | return Effect.firstSuccessOf(roots.map((cwd) => api.getViewer({ cwd }))).pipe( | |
| 731 | Effect.map((viewer) => ({ | |
| 732 | host, | |
| 733 | kind, | |
| 734 | viewer: viewer as string | null, | |
| 735 | error: null as PullRequestProviderError | null, | |
| 736 | })), | |
| 737 | Effect.tap((result) => | |
| 738 | Effect.map(Clock.currentTimeMillis, (at) => viewersByHost.set(host, { at, result })), | |
| 739 | ), | |
| 740 | Effect.catch((error) => | |
| 741 | Effect.succeed({ | |
| 742 | host, | |
| 743 | kind, | |
| 744 | viewer: null, | |
| 745 | error, | |
| 746 | }), | |
| 747 | ), | |
| 748 | ); | |
| 749 | }, | |
| 750 | { | |
| 751 | capacity: VIEWER_CACHE_CAPACITY, | |
| 752 | // The host-wide success map holds the real ten-minute answer. This short entry exists to | |
| 753 | // keep simultaneous cold page reads on one in-flight lookup; failures remain retryable. | |
| 754 | timeToLive: (exit) => | |
| 755 | Exit.isSuccess(exit) && exit.value.error === null ? Duration.seconds(1) : Duration.zero, | |
| 756 | }, | |
| 757 | ); | |
| 702 | 758 | |
| 703 | 759 | const resolveViewers = ( |
| 704 | 760 | projects: ReadonlyArray<SupportedProject>, |
| ... | ... | @@ -718,18 +774,8 @@ export const make = Effect.gen(function* () { |
| 718 | 774 | // unreadable worktree would otherwise report the whole host as signed out. |
| 719 | 775 | const roots = |
| 720 | 776 | viewerRoots.get(host) ?? forHost.map(({ project }) => project.workspaceRoot); |
| 721 | return Effect.firstSuccessOf(roots.map((cwd) => api.getViewer({ cwd }))).pipe( | |
| 722 | Effect.map((viewer) => ({ | |
| 723 | host, | |
| 724 | kind: api.kind, | |
| 725 | viewer: viewer as string | null, | |
| 726 | error: null as PullRequestProviderError | null, | |
| 727 | })), | |
| 728 | Effect.tap((result) => | |
| 729 | Effect.map(Clock.currentTimeMillis, (at) => viewersByHost.set(host, { at, result })), | |
| 730 | ), | |
| 731 | Effect.catch((error) => Effect.succeed({ host, kind: api.kind, viewer: null, error })), | |
| 732 | ); | |
| 777 | const key = JSON.stringify([host, api.kind, [...new Set(roots)].sort()]); | |
| 778 | return Cache.get(viewerFlights, key); | |
| 733 | 779 | }), |
| 734 | 780 | { concurrency: REPOSITORY_CONCURRENCY }, |
| 735 | 781 | ); |
| ... | ... | @@ -809,6 +855,13 @@ export const make = Effect.gen(function* () { |
| 809 | 855 | }; |
| 810 | 856 | }; |
| 811 | 857 | |
| 858 | // A repository that has appeared in a host search is known to be indexed there. Empty | |
| 859 | // authored/reviewing searches for that same repository are therefore real empty answers, not | |
| 860 | // a reason to issue the two-command per-repository fallback again. | |
| 861 | const searchVisibleAt = new Map<string, number>(); | |
| 862 | const searchVisibilityKey = (host: string, repository: string) => | |
| 863 | `${host}\n${repository.trim().toLowerCase()}`; | |
| 864 | ||
| 812 | 865 | const listUncached: PullRequestService["Service"]["list"] = (input) => |
| 813 | 866 | Effect.gen(function* () { |
| 814 | 867 | const involvement = input.involvement ?? "all"; |
| ... | ... | @@ -1018,61 +1071,80 @@ export const make = Effect.gen(function* () { |
| 1018 | 1071 | ? {} |
| 1019 | 1072 | : { cursor: { updatedBefore: cursor.updatedBefore, delivered: cursor.delivered } }), |
| 1020 | 1073 | }).pipe( |
| 1021 | Effect.flatMap((page) => { | |
| 1022 | const rows = new Map<string, Array<ProviderChangeRequest>>(); | |
| 1023 | for (const item of page.items) { | |
| 1024 | const key = item.repository.trim().toLowerCase(); | |
| 1025 | const held = rows.get(key); | |
| 1026 | if (held === undefined) rows.set(key, [item]); | |
| 1027 | else held.push(item); | |
| 1028 | } | |
| 1029 | // The oldest row of the whole slice, which is how far every repository in it has now | |
| 1030 | // been read — including the ones that contributed nothing to it. | |
| 1031 | const boundary = page.items.reduce<string | null>( | |
| 1032 | (oldest, item) => | |
| 1033 | oldest === null || item.updatedAt < oldest ? item.updatedAt : oldest, | |
| 1034 | null, | |
| 1035 | ); | |
| 1036 | return Effect.forEach( | |
| 1037 | chunk, | |
| 1038 | (project): Effect.Effect<RepositoryBatch> => { | |
| 1039 | const fetched = rows.get(project.repository.trim().toLowerCase()) ?? []; | |
| 1040 | // GitHub does not index every repository for search — a renamed one answers for | |
| 1041 | // its old name with silence rather than with an error — so a repository the | |
| 1042 | // search said nothing at all about is read on its own, once, before it is | |
| 1043 | // believed. Only on its first slice: after that it has a boundary to carry on | |
| 1044 | // from, and silence past one means the rows are older rather than absent. That | |
| 1045 | // keeps a search-invisible repository from disappearing on a busy host, at the | |
| 1046 | // price of one request per repository with nothing in the first slice — which | |
| 1047 | // run together, and only there. | |
| 1048 | if (fetched.length === 0 && cursorOf(project) === undefined) { | |
| 1049 | return readRepository(project); | |
| 1074 | Effect.flatMap((page) => | |
| 1075 | Effect.flatMap(Clock.currentTimeMillis, (now) => { | |
| 1076 | const rows = new Map<string, Array<ProviderChangeRequest>>(); | |
| 1077 | for (const [key, visibleAt] of searchVisibleAt) { | |
| 1078 | if (now - visibleAt > Duration.toMillis(SEARCH_VISIBILITY_TTL)) { | |
| 1079 | searchVisibleAt.delete(key); | |
| 1050 | 1080 | } |
| 1051 | const cursorHere = cursorOf(project); | |
| 1052 | const items = | |
| 1053 | cursorHere === undefined | |
| 1054 | ? fetched | |
| 1055 | : fetched.filter( | |
| 1056 | (item) => | |
| 1057 | item.updatedAt !== cursorHere.updatedBefore || | |
| 1058 | !cursorHere.seenAt.includes(item.number), | |
| 1059 | ); | |
| 1060 | return Effect.succeed({ | |
| 1061 | key: listCursorKey(project.host, project.repository), | |
| 1062 | entries: items | |
| 1063 | .filter((item) => matchesRowFilters(item, input.filters, viewer)) | |
| 1064 | .map((item) => toEntry({ project, item, viewer })), | |
| 1065 | errors: [], | |
| 1066 | truncated: page.truncated, | |
| 1067 | nextCursor: | |
| 1068 | page.truncated && boundary !== null | |
| 1069 | ? listCursorAt(cursorHere, boundary, fetched, items.length) | |
| 1070 | : null, | |
| 1071 | }); | |
| 1072 | }, | |
| 1073 | { concurrency: REPOSITORY_CONCURRENCY }, | |
| 1074 | ); | |
| 1075 | }), | |
| 1081 | } | |
| 1082 | for (const item of page.items) { | |
| 1083 | const key = item.repository.trim().toLowerCase(); | |
| 1084 | const held = rows.get(key); | |
| 1085 | if (held === undefined) rows.set(key, [item]); | |
| 1086 | else held.push(item); | |
| 1087 | searchVisibleAt.set(searchVisibilityKey(first.host, item.repository), now); | |
| 1088 | } | |
| 1089 | // The oldest row of the whole slice, which is how far every repository in it has now | |
| 1090 | // been read — including the ones that contributed nothing to it. | |
| 1091 | const boundary = page.items.reduce<string | null>( | |
| 1092 | (oldest, item) => | |
| 1093 | oldest === null || item.updatedAt < oldest ? item.updatedAt : oldest, | |
| 1094 | null, | |
| 1095 | ); | |
| 1096 | return Effect.forEach( | |
| 1097 | chunk, | |
| 1098 | (project): Effect.Effect<RepositoryBatch> => { | |
| 1099 | const fetched = rows.get(project.repository.trim().toLowerCase()) ?? []; | |
| 1100 | // GitHub does not index every repository for search — a renamed one answers for | |
| 1101 | // its old name with silence rather than with an error — so a repository the | |
| 1102 | // search said nothing at all about is read on its own, once, before it is | |
| 1103 | // believed. Only on its first slice: after that it has a boundary to carry on | |
| 1104 | // from, and silence past one means the rows are older rather than absent. That | |
| 1105 | // keeps a search-invisible repository from disappearing on a busy host, at the | |
| 1106 | // price of one request per repository with nothing in the first slice — which | |
| 1107 | // run together, and only there. | |
| 1108 | const lastVisible = searchVisibleAt.get( | |
| 1109 | searchVisibilityKey(project.host, project.repository), | |
| 1110 | ); | |
| 1111 | const searchIsKnownVisible = | |
| 1112 | !page.truncated && | |
| 1113 | lastVisible !== undefined && | |
| 1114 | now - lastVisible <= Duration.toMillis(SEARCH_VISIBILITY_TTL); | |
| 1115 | if ( | |
| 1116 | fetched.length === 0 && | |
| 1117 | cursorOf(project) === undefined && | |
| 1118 | !searchIsKnownVisible | |
| 1119 | ) { | |
| 1120 | return readRepository(project); | |
| 1121 | } | |
| 1122 | const cursorHere = cursorOf(project); | |
| 1123 | const items = | |
| 1124 | cursorHere === undefined | |
| 1125 | ? fetched | |
| 1126 | : fetched.filter( | |
| 1127 | (item) => | |
| 1128 | item.updatedAt !== cursorHere.updatedBefore || | |
| 1129 | !cursorHere.seenAt.includes(item.number), | |
| 1130 | ); | |
| 1131 | return Effect.succeed({ | |
| 1132 | key: listCursorKey(project.host, project.repository), | |
| 1133 | entries: items | |
| 1134 | .filter((item) => matchesRowFilters(item, input.filters, viewer)) | |
| 1135 | .map((item) => toEntry({ project, item, viewer })), | |
| 1136 | errors: [], | |
| 1137 | truncated: page.truncated, | |
| 1138 | nextCursor: | |
| 1139 | page.truncated && boundary !== null | |
| 1140 | ? listCursorAt(cursorHere, boundary, fetched, items.length) | |
| 1141 | : null, | |
| 1142 | }); | |
| 1143 | }, | |
| 1144 | { concurrency: REPOSITORY_CONCURRENCY }, | |
| 1145 | ); | |
| 1146 | }), | |
| 1147 | ), | |
| 1076 | 1148 | Effect.catch(separately), |
| 1077 | 1149 | ); |
| 1078 | 1150 | }; |
| ... | ... | @@ -1127,6 +1199,39 @@ export const make = Effect.gen(function* () { |
| 1127 | 1199 | const viewerOf = (project: SupportedProject): Effect.Effect<string | null> => |
| 1128 | 1200 | resolveViewers([project], new Map()).pipe(Effect.map(([resolved]) => resolved?.viewer ?? null)); |
| 1129 | 1201 | |
| 1202 | const summaryUncached: PullRequestService["Service"]["summary"] = (input) => | |
| 1203 | requireProject(input).pipe( | |
| 1204 | Effect.flatMap((project) => { | |
| 1205 | const providerInput = { | |
| 1206 | cwd: project.project.workspaceRoot, | |
| 1207 | repository: project.repository, | |
| 1208 | host: project.host, | |
| 1209 | number: input.number, | |
| 1210 | }; | |
| 1211 | const read = | |
| 1212 | project.api.getChangeRequestSummary === undefined | |
| 1213 | ? project.api.getChangeRequest(providerInput) | |
| 1214 | : project.api.getChangeRequestSummary(providerInput); | |
| 1215 | return read.pipe( | |
| 1216 | Effect.mapError(toPullRequestError("summary")), | |
| 1217 | Effect.map( | |
| 1218 | (changeRequest): PullRequestSummary => ({ | |
| 1219 | provider: project.api.kind, | |
| 1220 | projectId: project.project.id, | |
| 1221 | repository: project.repository, | |
| 1222 | number: changeRequest.number, | |
| 1223 | title: changeRequest.title, | |
| 1224 | url: changeRequest.url, | |
| 1225 | state: changeRequest.state, | |
| 1226 | headBranch: changeRequest.headBranch, | |
| 1227 | baseBranch: changeRequest.baseBranch, | |
| 1228 | updatedAt: changeRequest.updatedAt, | |
| 1229 | }), | |
| 1230 | ), | |
| 1231 | ); | |
| 1232 | }), | |
| 1233 | ); | |
| 1234 | ||
| 1130 | 1235 | const detailUncached: PullRequestService["Service"]["detail"] = (input) => |
| 1131 | 1236 | requireProject(input).pipe( |
| 1132 | 1237 | Effect.flatMap((project) => |
| ... | ... | @@ -1848,6 +1953,50 @@ export const make = Effect.gen(function* () { |
| 1848 | 1953 | }; |
| 1849 | 1954 | })(); |
| 1850 | 1955 | |
| 1956 | const makeLastGoodRead = <A>(capacity: number) => { | |
| 1957 | const held = new Map<string, { readonly at: number; readonly value: A }>(); | |
| 1958 | const record = (key: string, value: A) => | |
| 1959 | Effect.map(Clock.currentTimeMillis, (at) => { | |
| 1960 | held.delete(key); | |
| 1961 | if (held.size >= capacity) { | |
| 1962 | const oldest = held.keys().next().value; | |
| 1963 | if (oldest !== undefined) held.delete(oldest); | |
| 1964 | } | |
| 1965 | held.set(key, { at, value }); | |
| 1966 | }); | |
| 1967 | const read = (key: string, effect: Effect.Effect<A, PullRequestError>) => | |
| 1968 | effect.pipe( | |
| 1969 | Effect.tap((value) => record(key, value)), | |
| 1970 | Effect.catchTags({ | |
| 1971 | PullRequestOperationError: (error) => { | |
| 1972 | if (!isPullRequestProviderError(error.cause)) { | |
| 1973 | return Effect.fail(error); | |
| 1974 | } | |
| 1975 | const provider = error.cause; | |
| 1976 | if (provider.reason !== "failed" && provider.reason !== "rate-limited") { | |
| 1977 | return Effect.fail(error); | |
| 1978 | } | |
| 1979 | return Effect.flatMap(Clock.currentTimeMillis, (now) => { | |
| 1980 | const snapshot = held.get(key); | |
| 1981 | if ( | |
| 1982 | snapshot === undefined || | |
| 1983 | now - snapshot.at > Duration.toMillis(STALE_DETAIL_WINDOW) | |
| 1984 | ) { | |
| 1985 | return Effect.fail(error); | |
| 1986 | } | |
| 1987 | return Effect.logWarning("using recent pull request data after a failed refresh", { | |
| 1988 | operation: error.operation, | |
| 1989 | reason: provider.reason, | |
| 1990 | }).pipe(Effect.as(snapshot.value)); | |
| 1991 | }); | |
| 1992 | }, | |
| 1993 | }), | |
| 1994 | ); | |
| 1995 | return { read, record }; | |
| 1996 | }; | |
| 1997 | const lastGoodSummary = makeLastGoodRead<PullRequestSummary>(DETAIL_CACHE_CAPACITY); | |
| 1998 | const lastGoodDetail = makeLastGoodRead<PullRequestDetail>(DETAIL_CACHE_CAPACITY); | |
| 1999 | ||
| 1851 | 2000 | // Epochs are the invalidation mechanism: a key carries its scope's epoch, so bumping the |
| 1852 | 2001 | // epoch strands every entry made under the old one — no enumerating a cache whose keys |
| 1853 | 2002 | // (cursors, commits) nothing holds a list of. The counter is shared and monotonic so a |
| ... | ... | @@ -1858,6 +2007,8 @@ export const make = Effect.gen(function* () { |
| 1858 | 2007 | const REF_EPOCH_CAPACITY = 2_048; |
| 1859 | 2008 | const refScope = (ref: PullRequestRef) => `${ref.projectId} ${ref.repository} ${ref.number}`; |
| 1860 | 2009 | const refEpoch = (ref: PullRequestRef) => refEpochs.get(refScope(ref)) ?? 0; |
| 2010 | const refCacheKey = (ref: PullRequestRef) => | |
| 2011 | JSON.stringify([refEpoch(ref), ref.projectId, ref.repository, ref.number]); | |
| 1861 | 2012 | const bumpRefEpoch = (ref: PullRequestRef) => { |
| 1862 | 2013 | const scope = refScope(ref); |
| 1863 | 2014 | if (!refEpochs.has(scope) && refEpochs.size >= REF_EPOCH_CAPACITY) { |
| ... | ... | @@ -1884,6 +2035,24 @@ export const make = Effect.gen(function* () { |
| 1884 | 2035 | }; |
| 1885 | 2036 | }; |
| 1886 | 2037 | |
| 2038 | const summaryCache = yield* Cache.makeWith( | |
| 2039 | (key: string) => { | |
| 2040 | const [, projectId, repository, number] = JSON.parse(key) as [number, string, string, number]; | |
| 2041 | return summaryUncached({ projectId, repository, number } as PullRequestRef); | |
| 2042 | }, | |
| 2043 | { | |
| 2044 | capacity: DETAIL_CACHE_CAPACITY, | |
| 2045 | timeToLive: (exit) => (Exit.isSuccess(exit) ? SUMMARY_CACHE_TTL : Duration.zero), | |
| 2046 | }, | |
| 2047 | ); | |
| 2048 | const summary: PullRequestService["Service"]["summary"] = (input, options) => { | |
| 2049 | const key = refCacheKey(input); | |
| 2050 | const cached = Cache.get(summaryCache, key); | |
| 2051 | return options?.recoverTransientFailure === false | |
| 2052 | ? cached.pipe(Effect.tap((value) => lastGoodSummary.record(key, value))) | |
| 2053 | : lastGoodSummary.read(key, cached); | |
| 2054 | }; | |
| 2055 | ||
| 1887 | 2056 | // Keys serialize positionally and parse back in the lookup, so the cache is the only holder |
| 1888 | 2057 | // of in-flight state: concurrent identical reads coalesce on the key into one host request. |
| 1889 | 2058 | // The continuation cursors are part of the key, entries sorted so one continuation is one |
| ... | ... | @@ -1972,8 +2141,8 @@ export const make = Effect.gen(function* () { |
| 1972 | 2141 | }, |
| 1973 | 2142 | ); |
| 1974 | 2143 | const detail: PullRequestService["Service"]["detail"] = (input) => { |
| 1975 | const key = JSON.stringify([refEpoch(input), input.projectId, input.repository, input.number]); | |
| 1976 | return Cache.get(detailCache, key); | |
| 2144 | const key = refCacheKey(input); | |
| 2145 | return lastGoodDetail.read(key, Cache.get(detailCache, key)); | |
| 1977 | 2146 | }; |
| 1978 | 2147 | |
| 1979 | 2148 | const activityCache = yield* Cache.makeWith( |
| ... | ... | @@ -1987,7 +2156,7 @@ export const make = Effect.gen(function* () { |
| 1987 | 2156 | }, |
| 1988 | 2157 | ); |
| 1989 | 2158 | const activity: PullRequestService["Service"]["activity"] = (input) => { |
| 1990 | const key = JSON.stringify([refEpoch(input), input.projectId, input.repository, input.number]); | |
| 2159 | const key = refCacheKey(input); | |
| 1991 | 2160 | return Cache.get(activityCache, key); |
| 1992 | 2161 | }; |
| 1993 | 2162 | |
| ... | ... | @@ -2059,17 +2228,16 @@ export const make = Effect.gen(function* () { |
| 2059 | 2228 | return Cache.get(listStatsCache, key); |
| 2060 | 2229 | }; |
| 2061 | 2230 | |
| 2062 | const invalidate: PullRequestService["Service"]["invalidate"] = (input) => | |
| 2063 | Effect.sync(() => { | |
| 2064 | if (input.reference === undefined) { | |
| 2065 | listingsEpoch = ++epochCounter; | |
| 2066 | // A whole-workspace refresh is the reader asking to be re-answered from the hosts, | |
| 2067 | // and that includes who the hosts say they are. | |
| 2068 | viewersByHost.clear(); | |
| 2069 | return; | |
| 2070 | } | |
| 2071 | bumpRefEpoch(input.reference); | |
| 2072 | }); | |
| 2231 | const invalidate: PullRequestService["Service"]["invalidate"] = (input) => { | |
| 2232 | const reference = input.reference; | |
| 2233 | if (reference !== undefined) { | |
| 2234 | return Effect.sync(() => bumpRefEpoch(reference)); | |
| 2235 | } | |
| 2236 | return Effect.sync(() => { | |
| 2237 | listingsEpoch = ++epochCounter; | |
| 2238 | viewersByHost.clear(); | |
| 2239 | }).pipe(Effect.andThen(Cache.invalidateAll(viewerFlights))); | |
| 2240 | }; | |
| 2073 | 2241 | |
| 2074 | 2242 | // A mutation's own client re-reads right after it, and every other client's next read must |
| 2075 | 2243 | // see the action too — so a write forgets the change request it touched and the listings its |
| ... | ... | @@ -2091,6 +2259,7 @@ export const make = Effect.gen(function* () { |
| 2091 | 2259 | return PullRequestService.of({ |
| 2092 | 2260 | list, |
| 2093 | 2261 | listStats, |
| 2262 | summary, | |
| 2094 | 2263 | detail, |
| 2095 | 2264 | activity, |
| 2096 | 2265 | threadComments, |
apps/server/src/server.test.ts+2| ... | ... | @@ -136,6 +136,7 @@ import * as VcsDriver from "./vcs/VcsDriver.ts"; |
| 136 | 136 | import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; |
| 137 | 137 | import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; |
| 138 | 138 | import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; |
| 139 | import * as VcsProcess from "./vcs/VcsProcess.ts"; | |
| 139 | 140 | import * as GitWorkflowService from "./git/GitWorkflowService.ts"; |
| 140 | 141 | import * as ReviewService from "./review/ReviewService.ts"; |
| 141 | 142 | import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; |
| ... | ... | @@ -1041,6 +1042,7 @@ const buildAppUnderTest = (options?: { |
| 1041 | 1042 | Layer.provideMerge(ServerSecretStore.layer), |
| 1042 | 1043 | Layer.provide(workspaceAndProjectServicesLayer), |
| 1043 | 1044 | Layer.provideMerge(FetchHttpClient.layer), |
| 1045 | Layer.provide(VcsProcess.layer), | |
| 1044 | 1046 | Layer.provide(layerConfig), |
| 1045 | 1047 | ); |
| 1046 | 1048 |
apps/server/src/server.ts+2-2| ... | ... | @@ -303,7 +303,6 @@ const PullRequestServiceLive = PullRequestService.layer.pipe( |
| 303 | 303 | Layer.provide(PullRequestProviderRegistry.layer), |
| 304 | 304 | Layer.provide(SourceControlProviderRegistryLayerLive), |
| 305 | 305 | Layer.provide(SourceControlRateLimit.layer), |
| 306 | Layer.provide(VcsProcess.layer), | |
| 307 | 306 | ); |
| 308 | 307 | |
| 309 | 308 | const GitManagerLayerLive = GitManager.layer.pipe( |
| ... | ... | @@ -713,7 +712,8 @@ export const makeServerLayer = Layer.unwrap( |
| 713 | 712 | Layer.provideMerge(HttpServerLive), |
| 714 | 713 | Layer.provide(ApplicationObservabilityLive), |
| 715 | 714 | Layer.provideMerge(FetchHttpClient.layer), |
| 716 | Layer.provideMerge(VcsProcess.layer), | |
| 715 | // PR reads, Git operations, and WebSocket discovery share one process limiter. | |
| 716 | Layer.provide(VcsProcess.layer), | |
| 717 | 717 | Layer.provideMerge(PlatformServicesLive), |
| 718 | 718 | ); |
| 719 | 719 | }), |
apps/server/src/sourceControl/GitHubCli.test.ts+3-1| ... | ... | @@ -66,6 +66,7 @@ describe("GitHubCli.layer", () => { |
| 66 | 66 | headRefName: "feature/pr-threads", |
| 67 | 67 | state: "OPEN", |
| 68 | 68 | mergedAt: null, |
| 69 | updatedAt: "2026-08-24T12:34:56Z", | |
| 69 | 70 | isCrossRepository: true, |
| 70 | 71 | headRepository: { |
| 71 | 72 | nameWithOwner: "octocat/codething-mvp", |
| ... | ... | @@ -91,6 +92,7 @@ describe("GitHubCli.layer", () => { |
| 91 | 92 | baseRefName: "main", |
| 92 | 93 | headRefName: "feature/pr-threads", |
| 93 | 94 | state: "open", |
| 95 | updatedAt: "2026-08-24T12:34:56.000Z", | |
| 94 | 96 | isCrossRepository: true, |
| 95 | 97 | headRepositoryNameWithOwner: "octocat/codething-mvp", |
| 96 | 98 | headRepositoryOwnerLogin: "octocat", |
| ... | ... | @@ -103,7 +105,7 @@ describe("GitHubCli.layer", () => { |
| 103 | 105 | "view", |
| 104 | 106 | "#42", |
| 105 | 107 | "--json", |
| 106 | "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", | |
| 108 | "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", | |
| 107 | 109 | ], |
| 108 | 110 | cwd: "/repo", |
| 109 | 111 | timeoutMs: 30_000, |
apps/server/src/sourceControl/GitHubCli.ts+15-7| ... | ... | @@ -1,6 +1,8 @@ |
| 1 | 1 | import * as Context from "effect/Context"; |
| 2 | import * as DateTime from "effect/DateTime"; | |
| 2 | 3 | import * as Effect from "effect/Effect"; |
| 3 | 4 | import * as Layer from "effect/Layer"; |
| 5 | import * as Option from "effect/Option"; | |
| 4 | 6 | import * as PlatformError from "effect/PlatformError"; |
| 5 | 7 | import * as Result from "effect/Result"; |
| 6 | 8 | import * as Schema from "effect/Schema"; |
| ... | ... | @@ -15,6 +17,7 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 15 | 17 | import { |
| 16 | 18 | decodeGitHubPullRequestJson, |
| 17 | 19 | decodeGitHubPullRequestListJson, |
| 20 | type NormalizedGitHubPullRequestRecord, | |
| 18 | 21 | } from "./gitHubPullRequests.ts"; |
| 19 | 22 | |
| 20 | 23 | const DEFAULT_TIMEOUT_MS = 30_000; |
| ... | ... | @@ -202,11 +205,20 @@ export interface GitHubPullRequestSummary { |
| 202 | 205 | readonly baseRefName: string; |
| 203 | 206 | readonly headRefName: string; |
| 204 | 207 | readonly state?: "open" | "closed" | "merged"; |
| 208 | readonly updatedAt?: string; | |
| 205 | 209 | readonly isCrossRepository?: boolean; |
| 206 | 210 | readonly headRepositoryNameWithOwner?: string | null; |
| 207 | 211 | readonly headRepositoryOwnerLogin?: string | null; |
| 208 | 212 | } |
| 209 | 213 | |
| 214 | function pullRequestSummary(input: NormalizedGitHubPullRequestRecord): GitHubPullRequestSummary { | |
| 215 | const { updatedAt, ...summary } = input; | |
| 216 | return { | |
| 217 | ...summary, | |
| 218 | ...(Option.isSome(updatedAt) ? { updatedAt: DateTime.formatIso(updatedAt.value) } : {}), | |
| 219 | }; | |
| 220 | } | |
| 221 | ||
| 210 | 222 | export interface GitHubRepositoryCloneUrls { |
| 211 | 223 | readonly nameWithOwner: string; |
| 212 | 224 | readonly url: string; |
| ... | ... | @@ -373,9 +385,7 @@ export const make = Effect.gen(function* () { |
| 373 | 385 | ); |
| 374 | 386 | } |
| 375 | 387 | |
| 376 | return Effect.succeed( | |
| 377 | decoded.success.map(({ updatedAt: _updatedAt, ...summary }) => summary), | |
| 378 | ); | |
| 388 | return Effect.succeed(decoded.success.map(pullRequestSummary)); | |
| 379 | 389 | }), |
| 380 | 390 | ), |
| 381 | 391 | ), |
| ... | ... | @@ -388,7 +398,7 @@ export const make = Effect.gen(function* () { |
| 388 | 398 | "view", |
| 389 | 399 | input.reference, |
| 390 | 400 | "--json", |
| 391 | "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", | |
| 401 | "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", | |
| 392 | 402 | ], |
| 393 | 403 | }).pipe( |
| 394 | 404 | Effect.map((result) => result.stdout.trim()), |
| ... | ... | @@ -405,9 +415,7 @@ export const make = Effect.gen(function* () { |
| 405 | 415 | ); |
| 406 | 416 | } |
| 407 | 417 | |
| 408 | return Effect.succeed( | |
| 409 | (({ updatedAt: _updatedAt, ...summary }) => summary)(decoded.success), | |
| 410 | ); | |
| 418 | return Effect.succeed(pullRequestSummary(decoded.success)); | |
| 411 | 419 | }), |
| 412 | 420 | ), |
| 413 | 421 | ), |
apps/server/src/sourceControl/GitHubSourceControlProvider.ts+17-5| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as DateTime from "effect/DateTime"; | |
| 1 | 2 | import * as Effect from "effect/Effect"; |
| 2 | 3 | import * as Layer from "effect/Layer"; |
| 3 | 4 | import * as Option from "effect/Option"; |
| ... | ... | @@ -29,7 +30,10 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq |
| 29 | 30 | baseRefName: summary.baseRefName, |
| 30 | 31 | headRefName: summary.headRefName, |
| 31 | 32 | state: summary.state ?? "open", |
| 32 | updatedAt: Option.none(), | |
| 33 | updatedAt: | |
| 34 | summary.updatedAt === undefined | |
| 35 | ? Option.none() | |
| 36 | : Option.some(DateTime.makeUnsafe(summary.updatedAt)), | |
| 33 | 37 | ...(summary.isCrossRepository !== undefined |
| 34 | 38 | ? { isCrossRepository: summary.isCrossRepository } |
| 35 | 39 | : {}), |
| ... | ... | @@ -162,10 +166,18 @@ export const make = Effect.gen(function* () { |
| 162 | 166 | Effect.flatMap((decoded) => |
| 163 | 167 | Result.isSuccess(decoded) |
| 164 | 168 | ? Effect.succeed( |
| 165 | decoded.success.map((item) => ({ | |
| 166 | ...toChangeRequest(item), | |
| 167 | updatedAt: item.updatedAt, | |
| 168 | })), | |
| 169 | decoded.success.map((item) => { | |
| 170 | const { updatedAt, ...summary } = item; | |
| 171 | return { | |
| 172 | ...toChangeRequest({ | |
| 173 | ...summary, | |
| 174 | ...(Option.isSome(updatedAt) | |
| 175 | ? { updatedAt: DateTime.formatIso(updatedAt.value) } | |
| 176 | : {}), | |
| 177 | }), | |
| 178 | updatedAt, | |
| 179 | }; | |
| 180 | }), | |
| 169 | 181 | ) |
| 170 | 182 | : Effect.fail( |
| 171 | 183 | new GitHubCli.GitHubChangeRequestListDecodeError({ |
apps/server/src/vcs/VcsProcess.test.ts+61| ... | ... | @@ -1,10 +1,14 @@ |
| 1 | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 2 | import { describe, expect, it } from "@effect/vitest"; |
| 3 | 3 | import * as Duration from "effect/Duration"; |
| 4 | import * as Deferred from "effect/Deferred"; | |
| 4 | 5 | import * as Effect from "effect/Effect"; |
| 5 | 6 | import * as Fiber from "effect/Fiber"; |
| 6 | 7 | import * as Layer from "effect/Layer"; |
| 8 | import * as Queue from "effect/Queue"; | |
| 9 | import * as Ref from "effect/Ref"; | |
| 7 | 10 | import { TestClock } from "effect/testing"; |
| 11 | import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 8 | 12 | |
| 9 | 13 | import { |
| 10 | 14 | VcsProcessExitError, |
| ... | ... | @@ -45,6 +49,63 @@ const captureProcessResult = ( |
| 45 | 49 | ); |
| 46 | 50 | |
| 47 | 51 | describe("VcsProcess.run", () => { |
| 52 | it.effect("bounds a synthetic burst of GitHub API processes", () => | |
| 53 | Effect.gen(function* () { | |
| 54 | const gate = yield* Deferred.make<void>(); | |
| 55 | const starts = yield* Queue.unbounded<number>(); | |
| 56 | const active = yield* Ref.make(0); | |
| 57 | const peak = yield* Ref.make(0); | |
| 58 | const total = yield* Ref.make(0); | |
| 59 | const service = yield* VcsProcess.make.pipe( | |
| 60 | Effect.provideService( | |
| 61 | ProcessRunner.ProcessRunner, | |
| 62 | ProcessRunner.ProcessRunner.of({ | |
| 63 | run: () => | |
| 64 | Effect.gen(function* () { | |
| 65 | const count = yield* Ref.updateAndGet(active, (held) => held + 1); | |
| 66 | yield* Ref.update(peak, (held) => Math.max(held, count)); | |
| 67 | yield* Ref.update(total, (held) => held + 1); | |
| 68 | yield* Queue.offer(starts, count); | |
| 69 | yield* Deferred.await(gate); | |
| 70 | return { | |
| 71 | stdout: "", | |
| 72 | stderr: "", | |
| 73 | code: ChildProcessSpawner.ExitCode(0), | |
| 74 | timedOut: false, | |
| 75 | stdoutTruncated: false, | |
| 76 | stderrTruncated: false, | |
| 77 | stdoutInvalidUtf8: false, | |
| 78 | stderrInvalidUtf8: false, | |
| 79 | }; | |
| 80 | }).pipe(Effect.ensuring(Ref.update(active, (count) => count - 1))), | |
| 81 | }), | |
| 82 | ), | |
| 83 | ); | |
| 84 | ||
| 85 | const burst = yield* Effect.all( | |
| 86 | Array.from({ length: 32 }, (_, index) => | |
| 87 | service.run({ | |
| 88 | operation: `synthetic.github.${index}`, | |
| 89 | command: "gh", | |
| 90 | args: ["api", "user"], | |
| 91 | cwd: "/workspace", | |
| 92 | }), | |
| 93 | ), | |
| 94 | { concurrency: "unbounded" }, | |
| 95 | ).pipe(Effect.forkChild); | |
| 96 | ||
| 97 | yield* Effect.all(Array.from({ length: 4 }, () => Queue.take(starts))); | |
| 98 | yield* Effect.yieldNow; | |
| 99 | expect(yield* Queue.size(starts)).toBe(0); | |
| 100 | expect(yield* Ref.get(peak)).toBe(4); | |
| 101 | ||
| 102 | yield* Deferred.succeed(gate, undefined); | |
| 103 | yield* Fiber.join(burst); | |
| 104 | expect(yield* Ref.get(total)).toBe(32); | |
| 105 | expect(yield* Ref.get(peak)).toBe(4); | |
| 106 | }), | |
| 107 | ); | |
| 108 | ||
| 48 | 109 | it.effect("collects stdout", () => |
| 49 | 110 | Effect.gen(function* () { |
| 50 | 111 | const result = yield* run({ |
apps/server/src/vcs/VcsProcess.ts+11-1| ... | ... | @@ -2,6 +2,7 @@ import * as Context from "effect/Context"; |
| 2 | 2 | import * as Effect from "effect/Effect"; |
| 3 | 3 | import * as Layer from "effect/Layer"; |
| 4 | 4 | import * as Match from "effect/Match"; |
| 5 | import * as Semaphore from "effect/Semaphore"; | |
| 5 | 6 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 6 | 7 | |
| 7 | 8 | import { |
| ... | ... | @@ -52,6 +53,8 @@ export class VcsProcess extends Context.Service< |
| 52 | 53 | const DEFAULT_TIMEOUT_MS = 30_000; |
| 53 | 54 | const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; |
| 54 | 55 | const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; |
| 56 | const VCS_PROCESS_CONCURRENCY = 8; | |
| 57 | const GITHUB_PROCESS_CONCURRENCY = 4; | |
| 55 | 58 | |
| 56 | 59 | const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFailureKind => { |
| 57 | 60 | const normalized = stderr.toLowerCase(); |
| ... | ... | @@ -101,8 +104,10 @@ const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFai |
| 101 | 104 | |
| 102 | 105 | export const make = Effect.gen(function* () { |
| 103 | 106 | const processRunner = yield* ProcessRunner.ProcessRunner; |
| 107 | const vcsProcesses = yield* Semaphore.make(VCS_PROCESS_CONCURRENCY); | |
| 108 | const githubProcesses = yield* Semaphore.make(GITHUB_PROCESS_CONCURRENCY); | |
| 104 | 109 | |
| 105 | const run = Effect.fn("VcsProcess.run")(function* (input: VcsProcessInput) { | |
| 110 | const runUnbounded = Effect.fn("VcsProcess.runUnbounded")(function* (input: VcsProcessInput) { | |
| 106 | 111 | const baseError = { |
| 107 | 112 | operation: input.operation, |
| 108 | 113 | command: input.command, |
| ... | ... | @@ -181,6 +186,11 @@ export const make = Effect.gen(function* () { |
| 181 | 186 | } satisfies VcsProcessOutput; |
| 182 | 187 | }); |
| 183 | 188 | |
| 189 | const run = Effect.fn("VcsProcess.run")(function* (input: VcsProcessInput) { | |
| 190 | const bounded = vcsProcesses.withPermits(1)(runUnbounded(input)); | |
| 191 | return yield* input.command === "gh" ? githubProcesses.withPermits(1)(bounded) : bounded; | |
| 192 | }); | |
| 193 | ||
| 184 | 194 | return VcsProcess.of({ run }); |
| 185 | 195 | }); |
| 186 | 196 |
apps/server/src/ws.ts+4-3| ... | ... | @@ -33,7 +33,6 @@ import { |
| 33 | 33 | type OrchestrationEvent, |
| 34 | 34 | type OrchestrationShellStreamEvent, |
| 35 | 35 | type OrchestrationShellStreamItem, |
| 36 | type OrchestrationThreadStreamItem, | |
| 37 | 36 | OrchestrationGetFullThreadDiffError, |
| 38 | 37 | OrchestrationGetSnapshotError, |
| 39 | 38 | OrchestrationSearchThreadsError, |
| ... | ... | @@ -137,7 +136,6 @@ import * as SourceControlProviderRegistry from "./sourceControl/SourceControlPro |
| 137 | 136 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; |
| 138 | 137 | import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; |
| 139 | 138 | import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; |
| 140 | import * as VcsProcess from "./vcs/VcsProcess.ts"; | |
| 141 | 139 | import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; |
| 142 | 140 | import * as SessionStore from "./auth/SessionStore.ts"; |
| 143 | 141 | import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; |
| ... | ... | @@ -1896,6 +1894,10 @@ const makeWsRpcLayer = ( |
| 1896 | 1894 | observeRpcEffect(WS_METHODS.pullRequestsListStats, pullRequests.listStats(input), { |
| 1897 | 1895 | "rpc.aggregate": "pull-requests", |
| 1898 | 1896 | }), |
| 1897 | [WS_METHODS.pullRequestsSummary]: (input) => | |
| 1898 | observeRpcEffect(WS_METHODS.pullRequestsSummary, pullRequests.summary(input), { | |
| 1899 | "rpc.aggregate": "pull-requests", | |
| 1900 | }), | |
| 1899 | 1901 | [WS_METHODS.pullRequestsDetail]: (input) => |
| 1900 | 1902 | observeRpcEffect(WS_METHODS.pullRequestsDetail, pullRequests.detail(input), { |
| 1901 | 1903 | "rpc.aggregate": "pull-requests", |
| ... | ... | @@ -2635,7 +2637,6 @@ export const websocketRpcRouteLayer = Layer.unwrap( |
| 2635 | 2637 | ), |
| 2636 | 2638 | ), |
| 2637 | 2639 | ), |
| 2638 | Layer.provide(VcsProcess.layer), | |
| 2639 | 2640 | ), |
| 2640 | 2641 | ), |
| 2641 | 2642 | ), |
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx-7| ... | ... | @@ -492,13 +492,6 @@ export function PullRequestDetailPanel({ |
| 492 | 492 | const activityQuery = useEnvironmentQuery( |
| 493 | 493 | pullRequestEnvironment.activity({ environmentId, input: reference }), |
| 494 | 494 | ); |
| 495 | // Detail and diff are independent server reads, so the diff for the default view (no commit, | |
| 496 | // no cursor) is started here too rather than waiting for the Code tab to mount. This is one | |
| 497 | // extra cached read per opened pull request even for readers who never open the tab, but it | |
| 498 | // turns the tab's first paint from a cold request into a cache hit. | |
| 499 | const _diffWarmUpQuery = useEnvironmentQuery( | |
| 500 | pullRequestEnvironment.diff({ environmentId, input: { ...reference } }), | |
| 501 | ); | |
| 502 | 495 | const coreDetail = detailQuery.data; |
| 503 | 496 | const activity = activityQuery.data; |
| 504 | 497 | const detail = useMemo( |
apps/web/src/routes/_chat.pull-requests.tsx+11-1| ... | ... | @@ -760,7 +760,16 @@ function PullRequestsRouteView() { |
| 760 | 760 | // Built together so the two reads share one memo, and in the same field order the feed's own |
| 761 | 761 | // input uses: the atoms are keyed by their input, so the Authored tab then reads this answer. |
| 762 | 762 | const partitionTargets = useMemo(() => { |
| 763 | if (!partitionsWanted) return { authored: NO_LIST_TARGETS, reviewing: NO_LIST_TARGETS }; | |
| 763 | // The main list goes first. Besides putting the visible rows on screen sooner, it proves | |
| 764 | // which repositories the host search indexes, so an empty partition does not trigger the | |
| 765 | // expensive per-repository fallback. With no rows at all both partitions are already empty. | |
| 766 | if ( | |
| 767 | !partitionsWanted || | |
| 768 | baselineQuery.data === null || | |
| 769 | baselineQuery.data.entries.length === 0 | |
| 770 | ) { | |
| 771 | return { authored: NO_LIST_TARGETS, reviewing: NO_LIST_TARGETS }; | |
| 772 | } | |
| 764 | 773 | const targetsFor = (involvement: PullRequestInvolvement) => |
| 765 | 774 | environmentQueries.map(({ environmentId, projectIds }) => ({ |
| 766 | 775 | environmentId, |
| ... | ... | @@ -779,6 +788,7 @@ function PullRequestsRouteView() { |
| 779 | 788 | menuFiltered, |
| 780 | 789 | menuFilters, |
| 781 | 790 | partitionsWanted, |
| 791 | baselineQuery.data, | |
| 782 | 792 | environmentQueries, |
| 783 | 793 | scopedProjectId, |
| 784 | 794 | search.host, |
apps/web/src/state/pullRequests.ts+2-2| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | 2 | import { |
| 3 | createLinkedPullRequestDetailAtomFamily, | |
| 3 | createLinkedPullRequestSummaryAtomFamily, | |
| 4 | 4 | createPullRequestEnvironmentAtoms, |
| 5 | 5 | } from "@t3tools/client-runtime/state/pull-requests"; |
| 6 | 6 | import type { |
| ... | ... | @@ -23,7 +23,7 @@ import { formatEnvironmentQueryError } from "./query"; |
| 23 | 23 | |
| 24 | 24 | export const pullRequestEnvironment = createPullRequestEnvironmentAtoms(connectionAtomRuntime); |
| 25 | 25 | export const linkedPullRequestDetailAtom = |
| 26 | createLinkedPullRequestDetailAtomFamily(connectionAtomRuntime); | |
| 26 | createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime); | |
| 27 | 27 | |
| 28 | 28 | export interface EnvironmentQueryTarget<Input> { |
| 29 | 29 | readonly environmentId: EnvironmentId; |
packages/client-runtime/src/state/pullRequests.ts+10-9| ... | ... | @@ -2,6 +2,7 @@ import { |
| 2 | 2 | WS_METHODS, |
| 3 | 3 | type PullRequestDetail, |
| 4 | 4 | type PullRequestDiffInput, |
| 5 | type PullRequestSummary, | |
| 5 | 6 | type VcsStatusResult, |
| 6 | 7 | } from "@t3tools/contracts"; |
| 7 | 8 | import * as Data from "effect/Data"; |
| ... | ... | @@ -31,23 +32,23 @@ export class EnvironmentHttpConnectionNotReadyError extends Data.TaggedError( |
| 31 | 32 | "EnvironmentHttpConnectionNotReadyError", |
| 32 | 33 | )<{ readonly message: string }> {} |
| 33 | 34 | |
| 34 | export const LINKED_PULL_REQUEST_DETAIL_IDLE_TTL_MS = 5_000; | |
| 35 | export const LINKED_PULL_REQUEST_IDLE_TTL_MS = 5_000; | |
| 35 | 36 | |
| 36 | /** Refresh a linked PR while its thread is visible so merges update the sidebar. */ | |
| 37 | export function createLinkedPullRequestDetailAtomFamily<R, E>( | |
| 37 | /** Refresh only the live fields a linked thread renders. */ | |
| 38 | export function createLinkedPullRequestSummaryAtomFamily<R, E>( | |
| 38 | 39 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, |
| 39 | 40 | ) { |
| 40 | 41 | return createEnvironmentRpcQueryAtomFamily(runtime, { |
| 41 | label: "environment-data:pull-requests:linked-detail", | |
| 42 | tag: WS_METHODS.pullRequestsDetail, | |
| 43 | staleTimeMs: 15_000, | |
| 44 | refreshIntervalMs: 30_000, | |
| 45 | idleTtlMs: LINKED_PULL_REQUEST_DETAIL_IDLE_TTL_MS, | |
| 42 | label: "environment-data:pull-requests:linked-summary", | |
| 43 | tag: WS_METHODS.pullRequestsSummary, | |
| 44 | staleTimeMs: 60_000, | |
| 45 | refreshIntervalMs: 60_000, | |
| 46 | idleTtlMs: LINKED_PULL_REQUEST_IDLE_TTL_MS, | |
| 46 | 47 | }); |
| 47 | 48 | } |
| 48 | 49 | |
| 49 | 50 | export function pullRequestDetailToVcsStatus( |
| 50 | detail: PullRequestDetail, | |
| 51 | detail: PullRequestDetail | PullRequestSummary, | |
| 51 | 52 | ): NonNullable<VcsStatusResult["pr"]> { |
| 52 | 53 | return { |
| 53 | 54 | number: detail.number, |
packages/contracts/src/pullRequest.ts+18| ... | ... | @@ -590,6 +590,24 @@ export const PullRequestRef = Schema.Struct({ |
| 590 | 590 | }); |
| 591 | 591 | export type PullRequestRef = typeof PullRequestRef.Type; |
| 592 | 592 | |
| 593 | /** | |
| 594 | * The small live shape a linked thread needs. Keeping it separate from detail means a sidebar | |
| 595 | * status check never loads permissions, repository settings, checks, or base comparison data. | |
| 596 | */ | |
| 597 | export const PullRequestSummary = Schema.Struct({ | |
| 598 | provider: SourceControlProviderKind, | |
| 599 | projectId: ProjectId, | |
| 600 | repository: TrimmedNonEmptyString, | |
| 601 | number: PositiveInt, | |
| 602 | title: TrimmedNonEmptyString, | |
| 603 | url: TrimmedNonEmptyString, | |
| 604 | state: PullRequestState, | |
| 605 | headBranch: TrimmedNonEmptyString, | |
| 606 | baseBranch: TrimmedNonEmptyString, | |
| 607 | updatedAt: IsoDateTime, | |
| 608 | }); | |
| 609 | export type PullRequestSummary = typeof PullRequestSummary.Type; | |
| 610 | ||
| 593 | 611 | /** |
| 594 | 612 | * One row's line counts, read after the listing rather than inside it. On GitHub the pair is |
| 595 | 613 | * 40-60% of the wall clock of the search that answers the whole page — measured over twelve |
packages/contracts/src/rpc.ts+9| ... | ... | @@ -97,6 +97,7 @@ import { |
| 97 | 97 | PullRequestOperationError, |
| 98 | 98 | PullRequestReactionInput, |
| 99 | 99 | PullRequestRef, |
| 100 | PullRequestSummary, | |
| 100 | 101 | PullRequestReviewerCandidateList, |
| 101 | 102 | PullRequestReviewerRequestInput, |
| 102 | 103 | PullRequestSubmitReviewInput, |
| ... | ... | @@ -302,6 +303,7 @@ export const WS_METHODS = { |
| 302 | 303 | // Pull request methods |
| 303 | 304 | pullRequestsList: "pullRequests.list", |
| 304 | 305 | pullRequestsListStats: "pullRequests.listStats", |
| 306 | pullRequestsSummary: "pullRequests.summary", | |
| 305 | 307 | pullRequestsDetail: "pullRequests.detail", |
| 306 | 308 | pullRequestsActivity: "pullRequests.activity", |
| 307 | 309 | pullRequestsThreadComments: "pullRequests.threadComments", |
| ... | ... | @@ -521,6 +523,12 @@ export const WsPullRequestsListStatsRpc = Rpc.make(WS_METHODS.pullRequestsListSt |
| 521 | 523 | error: PullRequestRpcError, |
| 522 | 524 | }); |
| 523 | 525 | |
| 526 | export const WsPullRequestsSummaryRpc = Rpc.make(WS_METHODS.pullRequestsSummary, { | |
| 527 | payload: PullRequestRef, | |
| 528 | success: PullRequestSummary, | |
| 529 | error: PullRequestRpcError, | |
| 530 | }); | |
| 531 | ||
| 524 | 532 | export const WsPullRequestsDetailRpc = Rpc.make(WS_METHODS.pullRequestsDetail, { |
| 525 | 533 | payload: PullRequestRef, |
| 526 | 534 | success: PullRequestDetail, |
| ... | ... | @@ -1063,6 +1071,7 @@ export const WsRpcGroup = RpcGroup.make( |
| 1063 | 1071 | WsCloudInstallRelayClientRpc, |
| 1064 | 1072 | WsPullRequestsListRpc, |
| 1065 | 1073 | WsPullRequestsListStatsRpc, |
| 1074 | WsPullRequestsSummaryRpc, | |
| 1066 | 1075 | WsPullRequestsDetailRpc, |
| 1067 | 1076 | WsPullRequestsActivityRpc, |
| 1068 | 1077 | WsPullRequestsThreadCommentsRpc, |