authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-08-17 02:39:25+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-17 01:39:25+02:00
logba46f922a271942d42faf305ff833af89cb5c3a1
tree272dfdfeddc292d8e6d5940a47c497260c01d666
parentfee10def1afb63cecd9a626aeba2bb063828cf22
signature Signed by PGP key B5690EEEBB952194

fix(pull-requests): protect provider API budgets (#6466)


41 files changed, 1781 insertions(+), 192 deletions(-)

apps/server/src/auth/RpcAuthorization.ts+1
......@@ -56,6 +56,7 @@ export const RPC_REQUIRED_SCOPES = {
5656 [WS_METHODS.pullRequestsListStats]: AuthOrchestrationReadScope,
5757 [WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope,
5858 [WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope,
59 [WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope,
5960 [WS_METHODS.pullRequestsDiffFileContents]: AuthOrchestrationReadScope,
6061 [WS_METHODS.pullRequestsRunAction]: AuthOrchestrationOperateScope,
6162 [WS_METHODS.pullRequestsUpdate]: AuthOrchestrationOperateScope,
apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts+8-6
......@@ -4,6 +4,7 @@ import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3t
44import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts";
55import {
66 PullRequestProviderError,
7 type PullRequestProviderFailure,
78 type ProviderChangeRequest,
89 type ProviderChangeRequestActivity,
910 type ProviderChangeRequestDetail,
......@@ -64,12 +65,13 @@ export const AZURE_DEVOPS_VIEWER_PERMISSIONS: PullRequestViewerPermissions = {
6465};
6566
6667/** The CLI tags that mean the tool itself is unusable, rather than one request failing. */
67function reasonFor(
68export function azureDevOpsProviderFailure(
6869 error: AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCliError,
69): PullRequestProviderError["reason"] {
70 if (error._tag === "AzureDevOpsCliUnavailableError") return "missing-tool";
71 if (error._tag === "AzureDevOpsCliAuthenticationError") return "unauthenticated";
72 return "failed";
70): PullRequestProviderFailure {
71 if (error._tag === "AzureDevOpsCliUnavailableError") return { reason: "missing-tool" };
72 if (error._tag === "AzureDevOpsCliAuthenticationError") return { reason: "unauthenticated" };
73 if (error._tag === "AzureDevOpsCliRateLimitError") return { reason: "rate-limited" };
74 return { reason: "failed" };
7375}
7476
7577function toChangeRequest(pullRequest: AzureDevOpsPullRequest): ProviderChangeRequest {
......@@ -103,7 +105,7 @@ export const make = Effect.gen(function* () {
103105 new PullRequestProviderError({
104106 provider: "azure-devops",
105107 operation,
106 reason: reasonFor(error),
108 ...azureDevOpsProviderFailure(error),
107109 detail: error.detail,
108110 cause: error,
109111 });
apps/server/src/pullRequest/BitbucketPullRequestProvider.test.ts+4-4
......@@ -2,11 +2,11 @@ import { describe, expect, it } from "vite-plus/test";
22
33import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";
44import {
5 bitbucketErrorReason,
5 bitbucketProviderFailure,
66 bitbucketViewerPermissions,
77} from "./BitbucketPullRequestProvider.ts";
88
9describe("bitbucketErrorReason", () => {
9describe("bitbucketProviderFailure", () => {
1010 it("treats only an HTTP 401 as unusable credentials", () => {
1111 const responseError = (status: number) =>
1212 new BitbucketApi.BitbucketResponseError({
......@@ -15,8 +15,8 @@ describe("bitbucketErrorReason", () => {
1515 responseBodyLength: 0,
1616 });
1717
18 expect(bitbucketErrorReason(responseError(401))).toBe("unauthenticated");
19 expect(bitbucketErrorReason(responseError(403))).toBe("failed");
18 expect(bitbucketProviderFailure(responseError(401)).reason).toBe("unauthenticated");
19 expect(bitbucketProviderFailure(responseError(403)).reason).toBe("failed");
2020 });
2121});
2222
apps/server/src/pullRequest/BitbucketPullRequestProvider.ts+12-5
......@@ -4,6 +4,7 @@ import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3t
44import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts";
55import {
66 PullRequestProviderError,
7 type PullRequestProviderFailure,
78 type ProviderChangeRequest,
89 type ProviderChangeRequestActivity,
910 type ProviderChangeRequestDetail,
......@@ -59,15 +60,21 @@ export function bitbucketViewerPermissions(input: {
5960}
6061
6162/** The failures that mean the credentials are the problem, rather than one request. */
62export function bitbucketErrorReason(
63export function bitbucketProviderFailure(
6364 error: BitbucketPullRequestApi.BitbucketPullRequestApiError,
64): PullRequestProviderError["reason"] {
65): PullRequestProviderFailure {
6566 // Bitbucket is read over HTTP with credentials from the environment, so there is no tool to be
6667 // missing: unusable always means the credentials are absent or refused.
6768 if (error._tag === "BitbucketResponseError" && error.status === 401) {
68 return "unauthenticated";
69 return { reason: "unauthenticated" };
6970 }
70 return "failed";
71 if (error._tag === "BitbucketResponseError" && error.status === 429) {
72 return {
73 reason: "rate-limited",
74 ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }),
75 };
76 }
77 return { reason: "failed" };
7178}
7279
7380function toChangeRequest(pullRequest: BitbucketPullRequest): ProviderChangeRequest {
......@@ -100,7 +107,7 @@ export const make = Effect.gen(function* () {
100107 new PullRequestProviderError({
101108 provider: "bitbucket",
102109 operation,
103 reason: bitbucketErrorReason(error),
110 ...bitbucketProviderFailure(error),
104111 // Every Bitbucket failure states its own fact; this names the operation around it, so
105112 // the two do not stack into "failed in x: failed in y: ...".
106113 detail: error.detail,
apps/server/src/pullRequest/GitHubPullRequestCli.test.ts+216-10
......@@ -1,9 +1,11 @@
11import { afterEach, assert, expect, it, vi } from "@effect/vitest";
22import * as Effect from "effect/Effect";
33import * as Layer from "effect/Layer";
4import * as TestClock from "effect/testing/TestClock";
45import { ChildProcessSpawner } from "effect/unstable/process";
56
67import * as GitHubCli from "../sourceControl/GitHubCli.ts";
8import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts";
79import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";
810import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts";
911
......@@ -16,6 +18,7 @@ const layer = it.layer(
1618 execute: mockedExecute,
1719 }),
1820 ),
21 Layer.provide(GitHubGraphQlBudget.layer),
1922 ),
2023);
2124
......@@ -107,9 +110,16 @@ function threadCommentsPage(
107110 ids: ReadonlyArray<string>,
108111 endCursor: string | null,
109112 totalCount: number,
113 pullRequestId = "PR_7",
110114): string {
111115 return JSON.stringify({
112 data: { node: { comments: threadComments(ids, endCursor, totalCount) } },
116 data: {
117 repository: { pullRequest: { id: "PR_7" } },
118 node: {
119 pullRequest: { id: pullRequestId },
120 comments: threadComments(ids, endCursor, totalCount),
121 },
122 },
113123 });
114124}
115125
......@@ -1601,6 +1611,41 @@ layer("GitHubPullRequestCli.layer", (it) => {
16011611 }),
16021612 );
16031613
1614 it.effect("accounts for the avatar lookup in the GraphQL budget", () =>
1615 Effect.gen(function* () {
1616 mockedExecute.mockReturnValueOnce(
1617 Effect.succeed(
1618 output(
1619 // @effect-diagnostics-next-line preferSchemaOverJson:off
1620 JSON.stringify({
1621 data: {
1622 nodes: [{ login: "octocat", avatarUrl: "https://avatars/octocat" }],
1623 rateLimit: {
1624 cost: 1,
1625 limit: 5_000,
1626 remaining: 4_999,
1627 resetAt: "2099-08-13T14:00:00Z",
1628 },
1629 },
1630 }),
1631 ),
1632 ),
1633 );
1634 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
1635
1636 const avatars = yield* cli.listActorAvatars({
1637 cwd: "/w",
1638 repository: "acme/web",
1639 host: "github.com",
1640 ids: ["MDQ6VXNlcjE="],
1641 });
1642
1643 expect(callAt(0).args).toContain("ids[]=MDQ6VXNlcjE=");
1644 expect(callAt(0).args.at(-1)).toContain("rateLimit { cost limit remaining resetAt }");
1645 expect(avatars.get("octocat")).toBe("https://avatars/octocat");
1646 }),
1647 );
1648
16041649 it.effect("fails when the authenticated account has no login", () =>
16051650 Effect.gen(function* () {
16061651 mockedExecute.mockReturnValueOnce(Effect.succeed(output(" ")));
......@@ -2144,7 +2189,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
21442189 }),
21452190 );
21462191
2147 it.effect("finishes a thread longer than one page from the thread's own node", () =>
2192 it.effect("leaves a long thread paged until the reader asks for more", () =>
21482193 Effect.gen(function* () {
21492194 mockedExecute.mockReturnValueOnce(
21502195 Effect.succeed(
......@@ -2156,22 +2201,71 @@ layer("GitHubPullRequestCli.layer", (it) => {
21562201 ),
21572202 ),
21582203 );
2204 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
2205
2206 const conversation = yield* cli.listReviewThreadComments({
2207 cwd: "/w",
2208 repository: "acme/web",
2209 host: "github.com",
2210 number: 7,
2211 });
2212
2213 assert.strictEqual(mockedExecute.mock.calls.length, 1);
2214 expect(conversation.comments.map((comment) => comment.id)).toEqual(["c1"]);
2215 expect(conversation.reviewThreads[0]).toMatchObject({
2216 commentCount: 3,
2217 nextCommentsCursor: "Y3Vyc29yOjI",
2218 });
2219 assert.isTrue(conversation.truncated);
2220 }),
2221 );
2222
2223 it.effect("reads one requested page from a review thread cursor", () =>
2224 Effect.gen(function* () {
21592225 mockedExecute.mockReturnValueOnce(
21602226 Effect.succeed(output(threadCommentsPage(["c2", "c3"], null, 3))),
21612227 );
21622228 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
21632229
2164 const conversation = yield* cli.listReviewThreadComments({
2230 const page = yield* cli.getReviewThreadComments({
21652231 cwd: "/w",
21662232 repository: "acme/web",
21672233 host: "github.com",
21682234 number: 7,
2235 threadId: "PRRT_1",
2236 cursor: "Y3Vyc29yOjI",
21692237 });
21702238
2171 expect(callAt(1).args).toContain("threadId=PRRT_1");
2172 expect(conversation.comments.map((comment) => comment.id)).toEqual(["c1", "c2", "c3"]);
2173 // GitHub's own count, which is what the page shows however much of it was read.
2174 assert.strictEqual(conversation.commentCount, 3);
2239 expect(callAt(0).args).toContain("owner=acme");
2240 expect(callAt(0).args).toContain("name=web");
2241 expect(callAt(0).args).toContain("number=7");
2242 expect(callAt(0).args).toContain("threadId=PRRT_1");
2243 expect(callAt(0).args).toContain("cursor=Y3Vyc29yOjI");
2244 assert.strictEqual(mockedExecute.mock.calls.length, 1);
2245 expect(page.comments.map((comment) => comment.id)).toEqual(["c2", "c3"]);
2246 expect(page.nextCursor).toBeNull();
2247 }),
2248 );
2249
2250 it.effect("refuses a review thread from another pull request", () =>
2251 Effect.gen(function* () {
2252 mockedExecute.mockReturnValueOnce(
2253 Effect.succeed(output(threadCommentsPage(["foreign"], null, 1, "PR_8"))),
2254 );
2255 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
2256
2257 const error = yield* Effect.flip(
2258 cli.getReviewThreadComments({
2259 cwd: "/w",
2260 repository: "acme/web",
2261 host: "github.com",
2262 number: 7,
2263 threadId: "PRRT_FOREIGN",
2264 cursor: "Y3Vyc29yOjI",
2265 }),
2266 );
2267
2268 assert.strictEqual(error._tag, "GitHubSubjectScopeError");
21752269 }),
21762270 );
21772271
......@@ -2242,7 +2336,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
22422336 // The tuples are flattened straight into argv, so a variable without its flag is a
22432337 // positional argument gh refuses outright.
22442338 const args = callAt(0).args;
2245 expect(args).toEqual([
2339 expect(args.slice(0, -2)).toEqual([
22462340 "api",
22472341 "graphql",
22482342 "--hostname",
......@@ -2255,10 +2349,122 @@ layer("GitHubPullRequestCli.layer", (it) => {
22552349 "number=7",
22562350 "-f",
22572351 "headRef=fork:feat/page",
2258 "-f",
2259 `query=${BASE_COMPARISON_GRAPHQL_QUERY}`,
22602352 ]);
22612353 expect(comparison).toEqual({ behindBy: 4, viewerCanUpdate: true });
2354 expect(args.at(-2)).toBe("-f");
2355 expect(args.at(-1)).toContain(`query=${BASE_COMPARISON_GRAPHQL_QUERY.slice(0, -2)}`);
2356 }),
2357 );
2358
2359 it.effect("stops GraphQL reads at the protected reserve until reset", () =>
2360 Effect.gen(function* () {
2361 mockedExecute.mockReturnValue(
2362 Effect.succeed(
2363 output(
2364 // @effect-diagnostics-next-line preferSchemaOverJson:off
2365 JSON.stringify({
2366 data: {
2367 repository: {
2368 pullRequest: {
2369 viewerCanUpdateBranch: true,
2370 baseRef: { compare: { behindBy: 4 } },
2371 },
2372 },
2373 rateLimit: {
2374 cost: 1,
2375 limit: 5_000,
2376 remaining: 500,
2377 resetAt: "2099-08-13T14:00:00Z",
2378 },
2379 },
2380 }),
2381 ),
2382 ),
2383 );
2384 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
2385 const input = {
2386 cwd: "/w",
2387 repository: "acme/web",
2388 host: "github.com",
2389 number: 7,
2390 headRef: "fork:feat/page",
2391 } as const;
2392
2393 yield* cli.getPullRequestBaseComparison(input);
2394 expect(callAt(0).args.at(-1)).toContain("rateLimit { cost limit remaining resetAt }");
2395
2396 const error = yield* Effect.flip(cli.getPullRequestBaseComparison(input));
2397
2398 assert.strictEqual(error._tag, "SourceControlRateLimitPausedError");
2399 if (error._tag !== "SourceControlRateLimitPausedError") return;
2400 assert.strictEqual(error.host, "github.com");
2401 assert.strictEqual(error.retryAt, Date.parse("2099-08-13T14:00:00Z"));
2402 assert.strictEqual(mockedExecute.mock.calls.length, 1);
2403 yield* TestClock.setTime(Date.parse("2100-01-01T00:00:00Z"));
2404 }),
2405 );
2406
2407 it.effect("lets an interactive permission read use the protected reserve", () =>
2408 Effect.gen(function* () {
2409 mockedExecute
2410 .mockReturnValueOnce(
2411 Effect.succeed(
2412 output(
2413 // @effect-diagnostics-next-line preferSchemaOverJson:off
2414 JSON.stringify({
2415 data: {
2416 repository: {
2417 pullRequest: {
2418 viewerCanUpdateBranch: true,
2419 baseRef: { compare: { behindBy: 4 } },
2420 },
2421 },
2422 rateLimit: {
2423 cost: 1,
2424 limit: 5_000,
2425 remaining: 500,
2426 resetAt: "2099-08-13T14:00:00Z",
2427 },
2428 },
2429 }),
2430 ),
2431 ),
2432 )
2433 .mockReturnValueOnce(
2434 Effect.succeed(
2435 output(
2436 // @effect-diagnostics-next-line preferSchemaOverJson:off
2437 JSON.stringify({
2438 data: {
2439 repository: {
2440 viewerPermission: "READ",
2441 pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true },
2442 },
2443 },
2444 }),
2445 ),
2446 ),
2447 );
2448 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
2449
2450 yield* cli.getPullRequestBaseComparison({
2451 cwd: "/w",
2452 repository: "acme/web",
2453 host: "github.com",
2454 number: 7,
2455 headRef: "fork:feat/page",
2456 });
2457 const access = yield* cli.getViewerAccess({
2458 cwd: "/w",
2459 repository: "acme/web",
2460 host: "github.com",
2461 number: 7,
2462 allowReserve: true,
2463 });
2464
2465 assert.strictEqual(mockedExecute.mock.calls.length, 2);
2466 expect(access).toEqual({ canWrite: false, canUpdate: true, didAuthor: true });
2467 yield* TestClock.setTime(Date.parse("2100-01-01T00:00:00Z"));
22622468 }),
22632469 );
22642470
apps/server/src/pullRequest/GitHubPullRequestCli.ts+104-108
......@@ -18,11 +18,13 @@ import {
1818 type PullRequestReviewVerdict,
1919 type PullRequestReviewerCandidateList,
2020 type PullRequestReviewerKind,
21 type PullRequestThreadComment,
21 type PullRequestThreadCommentsResult,
2222 type PullRequestUpdateMethod,
2323} from "@t3tools/contracts";
2424
2525import * as GitHubCli from "../sourceControl/GitHubCli.ts";
26import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts";
27import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts";
2628import {
2729 ACTOR_AVATARS_GRAPHQL_QUERY,
2830 ADD_REACTION_GRAPHQL_MUTATION,
......@@ -247,6 +249,7 @@ export type GitHubPullRequestCliError =
247249 | GitHubDiffFileContentsUnavailableError
248250 | GitHubRepositorySelectorError
249251 | GitHubSubjectScopeError
252 | SourceControlRateLimit.SourceControlRateLimitPausedError
250253 | GitHubViewerLoginUnavailableError;
251254
252255/** A large pull request can produce a multi-megabyte patch; past this it is truncated. */
......@@ -268,15 +271,6 @@ const DIFF_FILES_PAGE_SIZE = 100;
268271 */
269272const REVIEW_THREAD_PAGES = 10;
270273
271/**
272 * And pages of one thread's own comments, for the rare thread longer than a single page. A
273 * thousand replies under one line is already a conversation nobody finishes reading.
274 */
275const REVIEW_THREAD_COMMENT_PAGES = 10;
276
277/** How many over-long threads are finished at once, so a wide conversation is not read serially. */
278const REVIEW_THREAD_CONCURRENCY = 4;
279
280274export interface GitHubPullRequestListBatch {
281275 readonly items: ReadonlyArray<GitHubPullRequestListItem>;
282276 readonly truncated: boolean;
......@@ -385,6 +379,8 @@ export class GitHubPullRequestCli extends Context.Service<
385379 readonly number: number;
386380 /** Qualified `owner:branch`, which is the only form a fork's head resolves under. */
387381 readonly headRef: string;
382 /** Manual action checks may use the quota held back from automatic reads. */
383 readonly allowReserve?: boolean | undefined;
388384 }) => Effect.Effect<GitHubBaseComparison, GitHubPullRequestCliError>;
389385
390386 readonly getPullRequestActivity: (input: {
......@@ -434,6 +430,15 @@ export class GitHubPullRequestCli extends Context.Service<
434430 readonly ids: ReadonlyArray<string>;
435431 }) => Effect.Effect<ReadonlyMap<string, string>, GitHubPullRequestCliError>;
436432
433 readonly getReviewThreadComments: (input: {
434 readonly cwd: string;
435 readonly repository: string;
436 readonly host: string;
437 readonly number: number;
438 readonly threadId: string;
439 readonly cursor: string;
440 }) => Effect.Effect<PullRequestThreadCommentsResult, GitHubPullRequestCliError>;
441
437442 /** One `gh repo view`, which answers what the repository allows and where the viewer stands. */
438443 readonly getRepositoryAccess: (input: {
439444 readonly cwd: string;
......@@ -447,6 +452,8 @@ export class GitHubPullRequestCli extends Context.Service<
447452 readonly repository: string;
448453 readonly host: string;
449454 readonly number: number;
455 /** Manual action checks may use the quota held back from automatic reads. */
456 readonly allowReserve?: boolean | undefined;
450457 }) => Effect.Effect<GitHubViewerAccess, GitHubPullRequestCliError>;
451458
452459 /** Who this pull request may be sent to, and who it has already been sent to. */
......@@ -824,6 +831,7 @@ function actionArgs(
824831
825832export const make = Effect.gen(function* () {
826833 const github = yield* GitHubCli.GitHubCli;
834 const graphQlBudget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;
827835
828836 /**
829837 * The pull request's own node id, which is what a mutation against the pull request itself is
......@@ -841,6 +849,7 @@ export const make = Effect.gen(function* () {
841849 cwd: input.cwd,
842850 host: input.host,
843851 operation: input.operation,
852 allowReserve: true,
844853 variables: [
845854 ["-f", `owner=${owner}`],
846855 ["-f", `name=${name}`],
......@@ -870,6 +879,7 @@ export const make = Effect.gen(function* () {
870879 cwd: input.cwd,
871880 host: input.host,
872881 operation: input.operation,
882 allowReserve: true,
873883 variables: [
874884 ["-f", `owner=${owner}`],
875885 ["-f", `name=${name}`],
......@@ -916,6 +926,7 @@ export const make = Effect.gen(function* () {
916926 readonly cwd: string;
917927 readonly host: string;
918928 readonly operation: string;
929 readonly allowReserve?: boolean | undefined;
919930 /** Variables as `-f` flags, for values this module composed itself. */
920931 readonly variables?: ReadonlyArray<readonly [string, string]>;
921932 /**
......@@ -926,32 +937,40 @@ export const make = Effect.gen(function* () {
926937 readonly privateVariables?: Readonly<Record<string, string>>;
927938 readonly query: string;
928939 readonly decode: (raw: string) => Result.Result<A, unknown>;
929 }): Effect.Effect<A, GitHubPullRequestCliError> =>
930 github
931 .execute(
932 input.privateVariables === undefined
933 ? {
934 cwd: input.cwd,
935 args: [
936 "api",
937 "graphql",
938 "--hostname",
939 input.host,
940 ...(input.variables ?? []).flat(),
941 "-f",
942 `query=${input.query}`,
943 ],
944 }
945 : {
946 cwd: input.cwd,
947 args: ["api", "graphql", "--hostname", input.host, "--input", "-"],
948 stdin: encodeGraphQlRequestJson({
949 query: input.query,
950 variables: input.privateVariables,
951 }),
952 },
940 }): Effect.Effect<A, GitHubPullRequestCliError> => {
941 return graphQlBudget
942 .query(
943 input.host,
944 input.query,
945 input.allowReserve === true ? { allowReserve: true } : undefined,
953946 )
954947 .pipe(
948 Effect.flatMap((query) =>
949 github.execute(
950 input.privateVariables === undefined
951 ? {
952 cwd: input.cwd,
953 args: [
954 "api",
955 "graphql",
956 "--hostname",
957 input.host,
958 ...(input.variables ?? []).flat(),
959 "-f",
960 `query=${query}`,
961 ],
962 }
963 : {
964 cwd: input.cwd,
965 args: ["api", "graphql", "--hostname", input.host, "--input", "-"],
966 stdin: encodeGraphQlRequestJson({
967 query,
968 variables: input.privateVariables,
969 }),
970 },
971 ),
972 ),
973 Effect.tap((result) => graphQlBudget.observe(input.host, result.stdout)),
955974 Effect.flatMap((result) => {
956975 const decoded = input.decode(result.stdout.trim());
957976 return Result.isSuccess(decoded)
......@@ -966,6 +985,7 @@ export const make = Effect.gen(function* () {
966985 );
967986 }),
968987 );
988 };
969989
970990 /**
971991 * One page of the patch, read from the files API. GitHub refuses `pr diff` outright past 300
......@@ -1341,6 +1361,7 @@ export const make = Effect.gen(function* () {
13411361 cwd: input.cwd,
13421362 host: input.host,
13431363 operation: "getPullRequestBaseComparison",
1364 ...(input.allowReserve === true ? { allowReserve: true } : {}),
13441365 variables: [
13451366 ["-f", `owner=${owner}`],
13461367 ["-f", `name=${name}`],
......@@ -1438,6 +1459,36 @@ export const make = Effect.gen(function* () {
14381459
14391460 getPullRequestDiffFileContents,
14401461
1462 getReviewThreadComments: (input) => {
1463 const { owner, name } = parseRepositorySelector(input.repository);
1464 return graphqlRead({
1465 cwd: input.cwd,
1466 host: input.host,
1467 operation: "getReviewThreadComments",
1468 variables: [
1469 ["-f", `owner=${owner}`],
1470 ["-f", `name=${name}`],
1471 ["-F", `number=${input.number}`],
1472 ["-f", `threadId=${input.threadId}`],
1473 cursorVariable(input.cursor),
1474 ],
1475 query: REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY,
1476 decode: decodeReviewThreadCommentsJson,
1477 }).pipe(
1478 Effect.flatMap(({ belongsToPullRequest, comments, nextCursor }) =>
1479 belongsToPullRequest
1480 ? Effect.succeed({ comments, nextCursor })
1481 : Effect.fail(
1482 new GitHubSubjectScopeError({
1483 command: "gh",
1484 cwd: input.cwd,
1485 operation: "getReviewThreadComments",
1486 }),
1487 ),
1488 ),
1489 );
1490 },
1491
14411492 listReviewThreadComments: (input) =>
14421493 Effect.gen(function* () {
14431494 const { owner, name } = parseRepositorySelector(input.repository);
......@@ -1457,25 +1508,6 @@ export const make = Effect.gen(function* () {
14571508 query: REVIEW_THREADS_GRAPHQL_QUERY,
14581509 decode: decodeReviewThreadsJson,
14591510 });
1460 const commentPage = (
1461 threadId: string,
1462 cursor: string,
1463 ): Effect.Effect<
1464 {
1465 readonly comments: ReadonlyArray<PullRequestThreadComment>;
1466 readonly nextCursor: string | null;
1467 },
1468 GitHubPullRequestCliError
1469 > =>
1470 graphqlRead({
1471 cwd: input.cwd,
1472 host: input.host,
1473 operation: "listReviewThreadComments",
1474 variables: [["-f", `threadId=${threadId}`], cursorVariable(cursor)],
1475 query: REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY,
1476 decode: decodeReviewThreadCommentsJson,
1477 });
1478
14791511 const entries: GitHubReviewThreadEntry[] = [];
14801512 const avatarsByLogin = new Map<string, string>();
14811513 const commitStats = new Map<
......@@ -1540,39 +1572,21 @@ export const make = Effect.gen(function* () {
15401572 dismissalPage += 1;
15411573 }
15421574
1543 // Only the threads GitHub said were unfinished cost a request; the rest arrived whole
1544 // with the page they were listed on.
1545 const finished = yield* Effect.forEach(
1546 entries,
1547 (entry) =>
1548 Effect.gen(function* () {
1549 const comments = [...entry.thread.comments];
1550 let commentCursor = entry.nextCommentCursor;
1551 let commentPageCount = 0;
1552 while (commentCursor !== null && commentPageCount < REVIEW_THREAD_COMMENT_PAGES) {
1553 const read = yield* commentPage(entry.thread.id, commentCursor);
1554 comments.push(...read.comments);
1555 commentCursor = read.nextCursor;
1556 commentPageCount += 1;
1557 }
1558 return {
1559 thread: { ...entry.thread, comments },
1560 commentCount: entry.commentCount,
1561 truncated: commentCursor !== null,
1562 };
1563 }),
1564 { concurrency: REVIEW_THREAD_CONCURRENCY },
1565 );
1566
1567 const reviewThreads = finished.map((entry) => entry.thread);
1575 const reviewThreads = entries.map((entry) => ({
1576 ...entry.thread,
1577 commentCount: entry.commentCount,
1578 ...(entry.nextCommentCursor === null
1579 ? {}
1580 : { nextCommentsCursor: entry.nextCommentCursor }),
1581 }));
15681582 return {
15691583 comments: reviewThreadConversation(reviewThreads),
15701584 dismissalsByReviewId,
15711585 reviewThreads,
15721586 // GitHub's own count of each thread, so the number the page shows is the host's even
15731587 // where a bound kept some of the words on GitHub.
1574 commentCount: finished.reduce((total, entry) => total + entry.commentCount, 0),
1575 truncated: cursor !== null || finished.some((entry) => entry.truncated),
1588 commentCount: entries.reduce((total, entry) => total + entry.commentCount, 0),
1589 truncated: cursor !== null || entries.some((entry) => entry.nextCommentCursor !== null),
15761590 reactions,
15771591 reactionsById,
15781592 reviewers,
......@@ -1587,34 +1601,14 @@ export const make = Effect.gen(function* () {
15871601 if (input.ids.length === 0) {
15881602 return Effect.succeed(new Map<string, string>());
15891603 }
1590 return github
1591 .execute({
1592 cwd: input.cwd,
1593 args: [
1594 "api",
1595 "graphql",
1596 "--hostname",
1597 input.host,
1598 ...input.ids.flatMap((id) => ["-f", `ids[]=${id}`]),
1599 "-f",
1600 `query=${ACTOR_AVATARS_GRAPHQL_QUERY}`,
1601 ],
1602 })
1603 .pipe(
1604 Effect.flatMap((result) => {
1605 const decoded = decodeActorAvatarsJson(result.stdout.trim());
1606 return Result.isSuccess(decoded)
1607 ? Effect.succeed(decoded.success)
1608 : Effect.fail(
1609 new GitHubPullRequestReadError({
1610 command: "gh",
1611 cwd: input.cwd,
1612 operation: "listActorAvatars",
1613 cause: decoded.failure,
1614 }),
1615 );
1616 }),
1617 );
1604 return graphqlRead({
1605 cwd: input.cwd,
1606 host: input.host,
1607 operation: "listActorAvatars",
1608 variables: input.ids.map((id) => ["-f", `ids[]=${id}`]),
1609 query: ACTOR_AVATARS_GRAPHQL_QUERY,
1610 decode: decodeActorAvatarsJson,
1611 });
16181612 },
16191613
16201614 getRepositoryAccess: (input) =>
......@@ -1651,6 +1645,7 @@ export const make = Effect.gen(function* () {
16511645 cwd: input.cwd,
16521646 host: input.host,
16531647 operation: "getViewerAccess",
1648 ...(input.allowReserve === true ? { allowReserve: true } : {}),
16541649 variables: [
16551650 ["-f", `owner=${owner}`],
16561651 ["-f", `name=${name}`],
......@@ -1667,6 +1662,7 @@ export const make = Effect.gen(function* () {
16671662 cwd: input.cwd,
16681663 host: input.host,
16691664 operation: "listReviewerCandidates",
1665 allowReserve: true,
16701666 variables: [
16711667 ["-f", `owner=${owner}`],
16721668 ["-f", `name=${name}`],
apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts+33
......@@ -177,6 +177,39 @@ describe("getViewerPermissions", () => {
177177 ),
178178 );
179179
180 it.effect("uses the GraphQL reserve for manual permission checks", () => {
181 let viewerAllowReserve: boolean | undefined;
182 let comparisonAllowReserve: boolean | undefined;
183 return Effect.gen(function* () {
184 const provider = yield* make;
185 yield* provider.getViewerPermissions({
186 cwd: "/w",
187 repository: "acme/web",
188 host: "github.com",
189 number: 7,
190 });
191
192 expect(viewerAllowReserve).toBe(true);
193 expect(comparisonAllowReserve).toBe(true);
194 }).pipe(
195 Effect.provide(
196 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
197 getPullRequestDetail: () => Effect.succeed(openDetail),
198 getPullRequestBaseComparison: (input) =>
199 Effect.sync(() => {
200 comparisonAllowReserve = input.allowReserve;
201 return { behindBy: 3, viewerCanUpdate: true };
202 }),
203 getViewerAccess: (input) =>
204 Effect.sync(() => {
205 viewerAllowReserve = input.allowReserve;
206 return { canWrite: true, canUpdate: true, didAuthor: false };
207 }),
208 }),
209 ),
210 );
211 });
212
180213 it.effect("withholds update-branch when the comparison cannot be read", () =>
181214 Effect.gen(function* () {
182215 const provider = yield* make;
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+16-7
......@@ -9,6 +9,7 @@ import type {
99import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";
1010import {
1111 PullRequestProviderError,
12 type PullRequestProviderFailure,
1213 type ProviderChangeRequestActivity,
1314 type ProviderChangeRequestDetail,
1415 type PullRequestProviderApi,
......@@ -82,12 +83,16 @@ export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequest
8283}
8384
8485/** The CLI tags that mean the tool itself is unusable, rather than one request failing. */
85function reasonFor(
86export function gitHubProviderFailure(
8687 error: GitHubPullRequestCli.GitHubPullRequestCliError,
87): PullRequestProviderError["reason"] {
88 if (error._tag === "GitHubCliUnavailableError") return "missing-tool";
89 if (error._tag === "GitHubCliAuthenticationError") return "unauthenticated";
90 return "failed";
88): PullRequestProviderFailure {
89 if (error._tag === "GitHubCliUnavailableError") return { reason: "missing-tool" };
90 if (error._tag === "GitHubCliAuthenticationError") return { reason: "unauthenticated" };
91 if (error._tag === "GitHubCliRateLimitError") return { reason: "rate-limited" };
92 if (error._tag === "SourceControlRateLimitPausedError") {
93 return { reason: "rate-limited", retryAt: error.retryAt };
94 }
95 return { reason: "failed" };
9196}
9297
9398/**
......@@ -128,7 +133,7 @@ export const make = Effect.gen(function* () {
128133 new PullRequestProviderError({
129134 provider: "github",
130135 operation,
131 reason: reasonFor(error),
136 ...gitHubProviderFailure(error),
132137 detail: error.detail,
133138 cause: error,
134139 });
......@@ -355,10 +360,13 @@ export const make = Effect.gen(function* () {
355360 ),
356361 ),
357362
363 getReviewThreadComments: (input) =>
364 cli.getReviewThreadComments(input).pipe(Effect.mapError(fail("getReviewThreadComments"))),
365
358366 getViewerPermissions: (input) =>
359367 Effect.all(
360368 [
361 cli.getViewerAccess(input),
369 cli.getViewerAccess({ ...input, allowReserve: true }),
362370 // Whether this viewer may update the branch is only on the comparison, and the
363371 // comparison only resolves through the head ref the detail carries. A failure here
364372 // withholds that one action rather than the whole answer, the way the detail path
......@@ -371,6 +379,7 @@ export const make = Effect.gen(function* () {
371379 .getPullRequestBaseComparison({
372380 ...input,
373381 headRef: `${pullRequest.headRepositoryOwner}:${pullRequest.headBranch}`,
382 allowReserve: true,
374383 })
375384 .pipe(Effect.map((comparison) => comparison.viewerCanUpdate === true)),
376385 ),
apps/server/src/pullRequest/GitLabPullRequestProvider.ts+8-6
......@@ -8,6 +8,7 @@ import type {
88import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts";
99import {
1010 PullRequestProviderError,
11 type PullRequestProviderFailure,
1112 type ProviderChangeRequestActivity,
1213 type ProviderChangeRequestDetail,
1314 type PullRequestProviderApi,
......@@ -90,12 +91,13 @@ export function gitLabViewerPermissions(input: {
9091}
9192
9293/** The CLI tags that mean the tool itself is unusable, rather than one request failing. */
93function reasonFor(
94export function gitLabProviderFailure(
9495 error: GitLabPullRequestCli.GitLabPullRequestCliError,
95): PullRequestProviderError["reason"] {
96 if (error._tag === "GitLabCliUnavailableError") return "missing-tool";
97 if (error._tag === "GitLabCliAuthenticationError") return "unauthenticated";
98 return "failed";
96): PullRequestProviderFailure {
97 if (error._tag === "GitLabCliUnavailableError") return { reason: "missing-tool" };
98 if (error._tag === "GitLabCliAuthenticationError") return { reason: "unauthenticated" };
99 if (error._tag === "GitLabCliRateLimitError") return { reason: "rate-limited" };
100 return { reason: "failed" };
99101}
100102
101103export const make = Effect.gen(function* () {
......@@ -105,7 +107,7 @@ export const make = Effect.gen(function* () {
105107 new PullRequestProviderError({
106108 provider: "gitlab",
107109 operation,
108 reason: reasonFor(error),
110 ...gitLabProviderFailure(error),
109111 detail: error.detail,
110112 cause: error,
111113 });
apps/server/src/pullRequest/PullRequestProvider.ts+19-2
......@@ -22,6 +22,7 @@ import type {
2222 PullRequestReviewCommentDraft,
2323 PullRequestReviewDecision,
2424 PullRequestReviewThread,
25 PullRequestThreadCommentsResult,
2526 PullRequestReviewVerdict,
2627 PullRequestReviewerCandidateList,
2728 PullRequestReviewerKind,
......@@ -37,15 +38,17 @@ import { SourceControlProviderKind as SourceControlProviderKindSchema } from "@t
3738 * without knowing which CLI or API produced it.
3839 *
3940 * `reason` is the part the service acts on: a missing or unauthenticated tool disables the
40 * provider for the whole workspace, while anything else is specific to the request.
41 * provider for the whole workspace, a rate limit pauses its host, and anything else is specific
42 * to the request.
4143 */
4244export class PullRequestProviderError extends Schema.TaggedErrorClass<PullRequestProviderError>()(
4345 "PullRequestProviderError",
4446 {
4547 provider: SourceControlProviderKindSchema,
4648 operation: Schema.String,
47 reason: Schema.Literals(["missing-tool", "unauthenticated", "failed"]),
49 reason: Schema.Literals(["missing-tool", "unauthenticated", "rate-limited", "failed"]),
4850 detail: Schema.String,
51 retryAt: Schema.optional(Schema.Number),
4952 cause: Schema.optional(Schema.Defect()),
5053 },
5154) {
......@@ -54,6 +57,11 @@ export class PullRequestProviderError extends Schema.TaggedErrorClass<PullReques
5457 }
5558}
5659
60export interface PullRequestProviderFailure {
61 readonly reason: PullRequestProviderError["reason"];
62 readonly retryAt?: number | undefined;
63}
64
5765/** A change request as the provider sees it, before the service attaches project context. */
5866export interface ProviderChangeRequest {
5967 readonly number: number;
......@@ -297,6 +305,15 @@ export interface PullRequestProviderApi {
297305 input: ProviderRepositoryRef & { readonly number: number },
298306 ) => Effect.Effect<ProviderChangeRequestActivity, PullRequestProviderError>;
299307
308 /** One explicit page after a reader asks to continue an unfinished review thread. */
309 readonly getReviewThreadComments?: (
310 input: ProviderRepositoryRef & {
311 readonly number: number;
312 readonly threadId: string;
313 readonly cursor: string;
314 },
315 ) => Effect.Effect<PullRequestThreadCommentsResult, PullRequestProviderError>;
316
300317 /**
301318 * The same answer `getChangeRequest` carries, on its own. Asked before anything is written, so
302319 * a request that reached the server without going past the page is refused by what the host
apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts created+69
......@@ -0,0 +1,69 @@
1import { assert, it } from "@effect/vitest";
2
3import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts";
4import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";
5import * as GitHubCli from "../sourceControl/GitHubCli.ts";
6import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts";
7import * as GitLabCli from "../sourceControl/GitLabCli.ts";
8import { azureDevOpsProviderFailure } from "./AzureDevOpsPullRequestProvider.ts";
9import { bitbucketProviderFailure } from "./BitbucketPullRequestProvider.ts";
10import { gitHubProviderFailure } from "./GitHubPullRequestProvider.ts";
11import { gitLabProviderFailure } from "./GitLabPullRequestProvider.ts";
12
13const cause = new Error("redacted provider failure");
14
15it("classifies rate limits from every pull-request provider", () => {
16 assert.deepStrictEqual(
17 gitHubProviderFailure(
18 new GitHubCli.GitHubCliRateLimitError({ command: "gh", cwd: "/repo", cause }),
19 ),
20 { reason: "rate-limited" },
21 );
22 assert.deepStrictEqual(
23 gitLabProviderFailure(
24 new GitLabCli.GitLabCliRateLimitError({
25 operation: "execute",
26 command: "glab",
27 cwd: "/repo",
28 cause,
29 }),
30 ),
31 { reason: "rate-limited" },
32 );
33 assert.deepStrictEqual(
34 azureDevOpsProviderFailure(
35 new AzureDevOpsCli.AzureDevOpsCliRateLimitError({
36 operation: "execute",
37 command: "az",
38 cwd: "/repo",
39 argumentCount: 1,
40 cause,
41 }),
42 ),
43 { reason: "rate-limited" },
44 );
45 assert.deepStrictEqual(
46 bitbucketProviderFailure(
47 new BitbucketApi.BitbucketResponseError({
48 operation: "request",
49 status: 429,
50 responseBodyLength: 0,
51 retryAt: 120_000,
52 }),
53 ),
54 { reason: "rate-limited", retryAt: 120_000 },
55 );
56});
57
58it("keeps GitHub's exact retry time", () => {
59 assert.deepStrictEqual(
60 gitHubProviderFailure(
61 new SourceControlRateLimit.SourceControlRateLimitPausedError({
62 provider: "github",
63 host: "github.com",
64 retryAt: 1_786_802_400_000,
65 }),
66 ),
67 { reason: "rate-limited", retryAt: 1_786_802_400_000 },
68 );
69});
apps/server/src/pullRequest/PullRequestProviderRegistry.ts+7-1
......@@ -6,6 +6,7 @@ import type { SourceControlProviderKind } from "@t3tools/contracts";
66import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts";
77import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";
88import * as GitHubCli from "../sourceControl/GitHubCli.ts";
9import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts";
910import * as GitLabCli from "../sourceControl/GitLabCli.ts";
1011import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts";
1112import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts";
......@@ -52,7 +53,12 @@ export const make = Effect.map(
5253);
5354
5455export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe(
55 Layer.provide(GitHubPullRequestCli.layer.pipe(Layer.provide(GitHubCli.layer))),
56 Layer.provide(
57 GitHubPullRequestCli.layer.pipe(
58 Layer.provide(GitHubCli.layer),
59 Layer.provide(GitHubGraphQlBudget.layer),
60 ),
61 ),
5662 Layer.provide(GitLabPullRequestCli.layer.pipe(Layer.provide(GitLabCli.layer))),
5763 Layer.provide(BitbucketPullRequestApi.layer.pipe(Layer.provide(BitbucketApi.layer))),
5864 Layer.provide(AzureDevOpsPullRequestCli.layer.pipe(Layer.provide(AzureDevOpsCli.layer))),
apps/server/src/pullRequest/PullRequestService.test.ts+93
......@@ -11,6 +11,7 @@ import type {
1111
1212import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
1313import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
14import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts";
1415import {
1516 PullRequestProviderError,
1617 type ProviderChangeRequest,
......@@ -169,6 +170,7 @@ function makeService(input: {
169170 updatedAt: "2026-07-01T00:00:00Z",
170171 }),
171172 }),
173 SourceControlRateLimit.layer,
172174 ),
173175 ),
174176 );
......@@ -1339,6 +1341,97 @@ it.effect("reports repositories on a host that could not be read", () =>
13391341 }),
13401342);
13411343
1344it.effect("stops new reads after a rate limit while leaving manual actions available", () =>
1345 Effect.gen(function* () {
1346 let listCalls = 0;
1347 let actionCalls = 0;
1348 const service = yield* makeService({
1349 projects: [
1350 project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }),
1351 ],
1352 providers: [
1353 fakeProvider("github", {
1354 listChangeRequests: () =>
1355 Effect.sync(() => {
1356 listCalls += 1;
1357 }).pipe(
1358 Effect.andThen(
1359 Effect.fail(
1360 new PullRequestProviderError({
1361 provider: "github",
1362 operation: "listChangeRequests",
1363 reason: "rate-limited",
1364 detail: "GitHub API rate limit exceeded.",
1365 }),
1366 ),
1367 ),
1368 ),
1369 runAction: () =>
1370 Effect.sync(() => {
1371 actionCalls += 1;
1372 }),
1373 }),
1374 ],
1375 });
1376
1377 const first = yield* service.list({ state: "open", involvement: "all" });
1378 const paused = yield* service.list({ state: "open", involvement: "authored" });
1379 yield* service.runAction({
1380 projectId: "p1" as ProjectId,
1381 repository: "acme/web",
1382 number: 1,
1383 action: "close",
1384 });
1385
1386 assert.strictEqual(listCalls, 1);
1387 assert.strictEqual(actionCalls, 1);
1388 assert.lengthOf(first.errors, 1);
1389 assert.lengthOf(paused.errors, 1);
1390 }),
1391);
1392
1393it.effect("uses a manual rate limit to pause later reads", () =>
1394 Effect.gen(function* () {
1395 let listCalls = 0;
1396 const service = yield* makeService({
1397 projects: [
1398 project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }),
1399 ],
1400 providers: [
1401 fakeProvider("github", {
1402 listChangeRequests: () =>
1403 Effect.sync(() => {
1404 listCalls += 1;
1405 return { items: [], truncated: false, continues: true };
1406 }),
1407 runAction: () =>
1408 Effect.fail(
1409 new PullRequestProviderError({
1410 provider: "github",
1411 operation: "runAction",
1412 reason: "rate-limited",
1413 detail: "GitHub API rate limit exceeded.",
1414 }),
1415 ),
1416 }),
1417 ],
1418 });
1419
1420 yield* Effect.flip(
1421 service.runAction({
1422 projectId: "p1" as ProjectId,
1423 repository: "acme/web",
1424 number: 1,
1425 action: "close",
1426 }),
1427 );
1428 const error = yield* Effect.flip(service.list({ state: "open", involvement: "all" }));
1429
1430 assert.strictEqual(listCalls, 0);
1431 assert.strictEqual(error._tag, "PullRequestOperationError");
1432 }),
1433);
1434
13421435it.effect("flags a review request for the viewer but not on their own change request", () =>
13431436 Effect.gen(function* () {
13441437 const service = yield* makeService({
apps/server/src/pullRequest/PullRequestService.ts+137-2
......@@ -40,6 +40,8 @@ import {
4040 type PullRequestSubmitReviewInput,
4141 type PullRequestThreadReplyInput,
4242 type PullRequestThreadResolutionInput,
43 type PullRequestThreadCommentsInput,
44 type PullRequestThreadCommentsResult,
4345 type PullRequestUpdateInput,
4446 type SourceControlProviderInfo,
4547 type SourceControlProviderKind,
......@@ -48,11 +50,12 @@ import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/source
4850
4951import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
5052import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
53import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts";
5154import {
5255 type ProviderChangeRequest,
5356 type ProviderListCursor,
5457 type PullRequestProviderApi,
55 type PullRequestProviderError,
58 PullRequestProviderError,
5659} from "./PullRequestProvider.ts";
5760import { PullRequestProviderRegistry } from "./PullRequestProviderRegistry.ts";
5861
......@@ -132,6 +135,9 @@ export class PullRequestService extends Context.Service<
132135 readonly activity: (
133136 input: PullRequestRef,
134137 ) => Effect.Effect<PullRequestActivity, PullRequestError>;
138 readonly threadComments: (
139 input: PullRequestThreadCommentsInput,
140 ) => Effect.Effect<PullRequestThreadCommentsResult, PullRequestError>;
135141 readonly diff: (
136142 input: PullRequestDiffInput,
137143 ) => Effect.Effect<PullRequestDiffResult, PullRequestError>;
......@@ -366,6 +372,103 @@ function toPullRequestError(
366372 : new PullRequestOperationError({ operation, detail: error.detail, cause: error });
367373}
368374
375function withRateLimitBackoff(
376 api: PullRequestProviderApi,
377 host: string,
378 limits: SourceControlRateLimit.SourceControlRateLimit["Service"],
379): PullRequestProviderApi {
380 const key = { provider: api.kind, host };
381 const protect = <A>(
382 operation: string,
383 effect: Effect.Effect<A, PullRequestProviderError>,
384 allowPaused: boolean,
385 ) =>
386 limits.check(key, allowPaused ? { allowPaused: true } : undefined).pipe(
387 Effect.mapError(
388 (error) =>
389 new PullRequestProviderError({
390 provider: api.kind,
391 operation,
392 reason: "rate-limited",
393 detail: error.detail,
394 retryAt: error.retryAt,
395 cause: error,
396 }),
397 ),
398 Effect.flatMap((lease) =>
399 effect.pipe(
400 Effect.tap(() => limits.recordSuccess({ ...key, lease })),
401 Effect.tapError((error) =>
402 error.reason === "rate-limited"
403 ? limits.recordRateLimit({
404 ...key,
405 lease,
406 ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }),
407 })
408 : Effect.void,
409 ),
410 ),
411 ),
412 );
413 const wrap =
414 <Args extends ReadonlyArray<unknown>, A>(
415 operation: string,
416 call: (...args: Args) => Effect.Effect<A, PullRequestProviderError>,
417 allowPaused = false,
418 ) =>
419 (...args: Args) =>
420 protect(operation, call(...args), allowPaused);
421 const interactive = <Args extends ReadonlyArray<unknown>, A>(
422 operation: string,
423 call: (...args: Args) => Effect.Effect<A, PullRequestProviderError>,
424 ) => wrap(operation, call, true);
425
426 return {
427 kind: api.kind,
428 capabilities: api.capabilities,
429 getViewer: wrap("getViewer", api.getViewer),
430 listChangeRequests: wrap("listChangeRequests", api.listChangeRequests),
431 ...(api.listChangeRequestsAcross === undefined
432 ? {}
433 : {
434 listChangeRequestsAcross: wrap("listChangeRequestsAcross", api.listChangeRequestsAcross),
435 }),
436 ...(api.listChangeRequestStats === undefined
437 ? {}
438 : {
439 listChangeRequestStats: wrap("listChangeRequestStats", api.listChangeRequestStats),
440 }),
441 getChangeRequest: wrap("getChangeRequest", api.getChangeRequest),
442 getChangeRequestActivity: wrap("getChangeRequestActivity", api.getChangeRequestActivity),
443 ...(api.getReviewThreadComments === undefined
444 ? {}
445 : {
446 getReviewThreadComments: wrap("getReviewThreadComments", api.getReviewThreadComments),
447 }),
448 getViewerPermissions: interactive("getViewerPermissions", api.getViewerPermissions),
449 getDiff: wrap("getDiff", api.getDiff),
450 ...(api.getDiffFileContents === undefined
451 ? {}
452 : { getDiffFileContents: wrap("getDiffFileContents", api.getDiffFileContents) }),
453 runAction: interactive("runAction", api.runAction),
454 ...(api.updateChangeRequest === undefined
455 ? {}
456 : {
457 updateChangeRequest: interactive("updateChangeRequest", api.updateChangeRequest),
458 }),
459 comment: interactive("comment", api.comment),
460 ...(api.updateComment === undefined
461 ? {}
462 : { updateComment: interactive("updateComment", api.updateComment) }),
463 submitReview: interactive("submitReview", api.submitReview),
464 listReviewerCandidates: interactive("listReviewerCandidates", api.listReviewerCandidates),
465 setReviewerRequest: interactive("setReviewerRequest", api.setReviewerRequest),
466 replyToThread: interactive("replyToThread", api.replyToThread),
467 setReaction: interactive("setReaction", api.setReaction),
468 setThreadResolution: interactive("setThreadResolution", api.setThreadResolution),
469 };
470}
471
369472/**
370473 * The provider-native repository selector. `displayName` is the full path below the host, which
371474 * is what nested GitLab groups need; owner/name is the two-segment fallback for identities
......@@ -394,6 +497,7 @@ export const make = Effect.gen(function* () {
394497 const registry = yield* PullRequestProviderRegistry;
395498 const projections = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
396499 const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry;
500 const rateLimits = yield* SourceControlRateLimit.SourceControlRateLimit;
397501
398502 const refineUnknownProjectKinds = (
399503 projects: ReadonlyArray<OrchestrationProjectShell>,
......@@ -511,7 +615,12 @@ export const make = Effect.gen(function* () {
511615 else counted.projectCount += 1;
512616 continue;
513617 }
514 supported.push({ project, api, repository, host });
618 supported.push({
619 project,
620 api: withRateLimitBackoff(api, host, rateLimits),
621 repository,
622 host,
623 });
515624 }
516625 return { supported, unimplemented, viewerRoots };
517626 }),
......@@ -1116,6 +1225,31 @@ export const make = Effect.gen(function* () {
11161225 ),
11171226 );
11181227
1228 const threadComments: PullRequestService["Service"]["threadComments"] = (input) =>
1229 requireProject(input).pipe(
1230 Effect.flatMap(
1231 (project): Effect.Effect<PullRequestThreadCommentsResult, PullRequestError> => {
1232 const read = project.api.getReviewThreadComments;
1233 if (read === undefined) {
1234 return Effect.fail(
1235 new PullRequestOperationError({
1236 operation: "threadComments",
1237 detail: "This host does not page review thread comments.",
1238 }),
1239 );
1240 }
1241 return read({
1242 cwd: project.project.workspaceRoot,
1243 repository: project.repository,
1244 host: project.host,
1245 number: input.number,
1246 threadId: input.threadId,
1247 cursor: input.cursor,
1248 }).pipe(Effect.mapError(toPullRequestError("threadComments")));
1249 },
1250 ),
1251 );
1252
11191253 const diffUncached: PullRequestService["Service"]["diff"] = (input) =>
11201254 requireProject(input).pipe(
11211255 Effect.flatMap((project) =>
......@@ -1993,6 +2127,7 @@ export const make = Effect.gen(function* () {
19932127 listStats,
19942128 detail,
19952129 activity,
2130 threadComments,
19962131 diff,
19972132 diffFileContents,
19982133 runAction: invalidatedByMutation(runAction),
apps/server/src/pullRequest/gitHubPullRequestJson.test.ts+16
......@@ -604,7 +604,9 @@ describe("review thread decoding", () => {
604604 decodeReviewThreadCommentsJson(
605605 JSON.stringify({
606606 data: {
607 repository: { pullRequest: { id: "PR_1" } },
607608 node: {
609 pullRequest: { id: "PR_1" },
608610 comments: {
609611 pageInfo: { hasNextPage: false, endCursor: "Y3Vyc29yOjk" },
610612 nodes: [{ id: "t9", body: "last", createdAt: "2026-07-01T00:00:00Z" }],
......@@ -623,7 +625,9 @@ describe("reaction decoding", () => {
623625 const commentWithGroups = (reactionGroups: ReadonlyArray<Record<string, unknown>>) =>
624626 JSON.stringify({
625627 data: {
628 repository: { pullRequest: { id: "PR_1" } },
626629 node: {
630 pullRequest: { id: "PR_1" },
627631 comments: {
628632 pageInfo: { hasNextPage: false, endCursor: null },
629633 nodes: [{ id: "t1", body: "nice", createdAt: "2026-07-01T00:00:00Z", reactionGroups }],
......@@ -669,7 +673,9 @@ describe("reaction decoding", () => {
669673 JSON.stringify({
670674 data: {
671675 viewer: { login: "Bilal" },
676 repository: { pullRequest: { id: "PR_1" } },
672677 node: {
678 pullRequest: { id: "PR_1" },
673679 comments: {
674680 pageInfo: { hasNextPage: false, endCursor: null },
675681 nodes: [
......@@ -1009,6 +1015,16 @@ describe("decodePullRequestNodeIdJson", () => {
10091015});
10101016
10111017describe("REVIEW_THREADS_GRAPHQL_QUERY", () => {
1018 it("caps the initial query after the 104-point rate-limit regression", () => {
1019 const match = REVIEW_THREADS_GRAPHQL_QUERY.match(
1020 /reviewThreads\(first: (\d+)[\s\S]*?comments\(first: (\d+)\)/u,
1021 );
1022
1023 expect(match).not.toBeNull();
1024 if (match === null) throw new Error("expected review-thread connections");
1025 expect(Number(match[1]) * Number(match[2])).toBeLessThanOrEqual(1_000);
1026 });
1027
10121028 it("asks for reactionGroups on the pull request itself, its comments, its reviews and each thread's comments", () => {
10131029 expect(REVIEW_THREADS_GRAPHQL_QUERY.match(/reactionGroups/g)).toHaveLength(4);
10141030 // The reviews connection is new: only reactions were ever wanted off it.
apps/server/src/pullRequest/gitHubPullRequestJson.ts+22-3
......@@ -661,6 +661,10 @@ export function pullRequestSearchGraphQlQuery(rows: number): string {
661661 * null for the first page and the last page's `endCursor` after that, so a pull request with
662662 * more threads than one page holds is walked rather than cut off at the first fifty.
663663 *
664 * Only ten comments ride with each thread. A hundred threads times a hundred comments made
665 * GitHub reserve 10,000 nested rows and charge 104 points; unfinished threads are paged from
666 * their own cursor below.
667 *
664668 * Reviewers come from here rather than from `gh pr view --json reviewRequests` for two reasons:
665669 * that field holds only requests still outstanding, so anyone who has already reviewed drops off
666670 * it, and neither it nor any other `gh` JSON field carries an avatar. A reviewer can be a person
......@@ -689,7 +693,7 @@ export const REVIEW_THREADS_GRAPHQL_QUERY = `query($owner: String!, $name: Strin
689693 path
690694 line
691695 diffSide
692 comments(first: ${GRAPHQL_PAGE_SIZE}) {
696 comments(first: 10) {
693697 totalCount
694698 pageInfo { hasNextPage endCursor }
695699 nodes { id author { login avatarUrl } body createdAt url ${REACTION_GROUPS_FIELDS} }
......@@ -740,10 +744,12 @@ export const REVIEW_THREADS_GRAPHQL_QUERY = `query($owner: String!, $name: Strin
740744 * from the inner node itself, so a thread longer than a page is followed on its own — a request
741745 * GitHub makes necessary, and one no ordinary pull request ever provokes.
742746 */
743export const REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY = `query($threadId: ID!, $cursor: String) {
747export const REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $threadId: ID!, $cursor: String) {
744748 viewer { login }
749 repository(owner: $owner, name: $name) { pullRequest(number: $number) { id } }
745750 node(id: $threadId) {
746751 ... on PullRequestReviewThread {
752 pullRequest { id }
747753 comments(first: ${GRAPHQL_PAGE_SIZE}, after: $cursor) {
748754 pageInfo { hasNextPage endCursor }
749755 nodes { id author { login avatarUrl } body createdAt url ${REACTION_GROUPS_FIELDS} }
......@@ -757,8 +763,16 @@ const RawReviewThreadCommentsSchema = Schema.Struct({
757763 viewer: Schema.optional(
758764 Schema.NullOr(Schema.Struct({ login: Schema.optional(Schema.NullOr(Schema.String)) })),
759765 ),
766 repository: Schema.NullOr(
767 Schema.Struct({ pullRequest: Schema.NullOr(Schema.Struct({ id: Schema.String })) }),
768 ),
760769 /** Null for an id that names nothing the viewer can read, which is not a thread to page. */
761 node: Schema.NullOr(Schema.Struct({ comments: Schema.optional(RawThreadCommentsSchema) })),
770 node: Schema.NullOr(
771 Schema.Struct({
772 pullRequest: Schema.optional(Schema.Struct({ id: Schema.String })),
773 comments: Schema.optional(RawThreadCommentsSchema),
774 }),
775 ),
762776 }),
763777});
764778
......@@ -1820,6 +1834,7 @@ export function decodeReviewThreadsJson(
18201834/** The rest of one thread's comments, in the shape the first page already delivered them. */
18211835export function decodeReviewThreadCommentsJson(raw: string): Result.Result<
18221836 {
1837 readonly belongsToPullRequest: boolean;
18231838 readonly comments: ReadonlyArray<PullRequestThreadComment>;
18241839 readonly nextCursor: string | null;
18251840 },
......@@ -1832,6 +1847,10 @@ export function decodeReviewThreadCommentsJson(raw: string): Result.Result<
18321847 const viewer = trimmed(decoded.success.data.viewer?.login);
18331848 const comments = decoded.success.data.node?.comments;
18341849 return Result.succeed({
1850 belongsToPullRequest:
1851 decoded.success.data.repository?.pullRequest?.id !== undefined &&
1852 decoded.success.data.repository?.pullRequest?.id ===
1853 decoded.success.data.node?.pullRequest?.id,
18351854 comments: (comments?.nodes ?? []).map((comment) => ({
18361855 id: comment.id,
18371856 author: toActor(comment.author),
apps/server/src/server.ts+2
......@@ -78,6 +78,7 @@ import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts";
7878import * as GitWorkflowService from "./git/GitWorkflowService.ts";
7979import * as ReviewService from "./review/ReviewService.ts";
8080import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";
81import * as SourceControlRateLimit from "./sourceControl/SourceControlRateLimit.ts";
8182import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
8283import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";
8384import { ObservabilityLive } from "./observability/Layers/Observability.ts";
......@@ -439,6 +440,7 @@ const PullRequestServiceLive = PullRequestService.layer.pipe(
439440 // One registry entry per supported host; the service only knows the registry.
440441 Layer.provide(PullRequestProviderRegistry.layer),
441442 Layer.provide(SourceControlProviderRegistryLayerLive),
443 Layer.provide(SourceControlRateLimit.layer),
442444 Layer.provide(VcsProcess.layer),
443445);
444446
apps/server/src/sourceControl/AzureDevOpsCli.test.ts+21
......@@ -405,4 +405,25 @@ describe("AzureDevOpsCli.layer", () => {
405405 );
406406 }).pipe(Effect.provide(layer)),
407407 );
408
409 it.effect("preserves rate-limit failures as a distinct error", () =>
410 Effect.gen(function* () {
411 const cause = new VcsProcessExitError({
412 operation: "AzureDevOpsCli.execute",
413 command: "az",
414 cwd: "/repo",
415 argumentCount: 2,
416 exitCode: 1,
417 detail: "API rate limit exceeded.",
418 failureKind: "rate-limited",
419 });
420 mockRun.mockReturnValueOnce(Effect.fail(cause));
421
422 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
423 const error = yield* az.execute({ cwd: "/repo", args: ["repos", "list"] }).pipe(Effect.flip);
424
425 assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsCliRateLimitError);
426 assert.strictEqual(error.cause, cause);
427 }).pipe(Effect.provide(layer)),
428 );
408429});
apps/server/src/sourceControl/AzureDevOpsCli.ts+17
......@@ -55,6 +55,19 @@ export class AzureDevOpsCliAuthenticationError extends Schema.TaggedErrorClass<A
5555 }
5656}
5757
58export class AzureDevOpsCliRateLimitError extends Schema.TaggedErrorClass<AzureDevOpsCliRateLimitError>()(
59 "AzureDevOpsCliRateLimitError",
60 azureDevOpsCommandErrorFields,
61) {
62 get detail(): string {
63 return "Azure DevOps API rate limit exceeded.";
64 }
65
66 override get message(): string {
67 return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;
68 }
69}
70
5871export class AzureDevOpsPullRequestNotFoundError extends Schema.TaggedErrorClass<AzureDevOpsPullRequestNotFoundError>()(
5972 "AzureDevOpsPullRequestNotFoundError",
6073 azureDevOpsCommandErrorFields,
......@@ -105,6 +118,9 @@ export class AzureDevOpsCommandFailedError extends Schema.TaggedErrorClass<Azure
105118 if (cause.failureKind === "authentication") {
106119 return new AzureDevOpsCliAuthenticationError(fields);
107120 }
121 if (cause.failureKind === "rate-limited") {
122 return new AzureDevOpsCliRateLimitError(fields);
123 }
108124 if (cause.failureKind === "not-found") {
109125 return new AzureDevOpsPullRequestNotFoundError(fields);
110126 }
......@@ -178,6 +194,7 @@ export class AzureDevOpsRepositoryDecodeError extends Schema.TaggedErrorClass<Az
178194export const AzureDevOpsCliError = Schema.Union([
179195 AzureDevOpsCliUnavailableError,
180196 AzureDevOpsCliAuthenticationError,
197 AzureDevOpsCliRateLimitError,
181198 AzureDevOpsPullRequestNotFoundError,
182199 AzureDevOpsCommandFailedError,
183200 AzureDevOpsPullRequestListDecodeError,
apps/server/src/sourceControl/BitbucketApi.test.ts+19
......@@ -6,6 +6,7 @@ import * as Effect from "effect/Effect";
66import * as FileSystem from "effect/FileSystem";
77import * as Layer from "effect/Layer";
88import * as Option from "effect/Option";
9import * as TestClock from "effect/testing/TestClock";
910import {
1011 HttpClient,
1112 HttpClientError,
......@@ -569,6 +570,24 @@ it.effect("keeps Bitbucket response bodies out of checkout diagnostics", () => {
569570 }).pipe(Effect.provide(layer));
570571});
571572
573it.effect("keeps a 429 Retry-After time on the response error", () => {
574 const { layer } = makeLayer({
575 response: () => new Response("busy", { status: 429, headers: { "Retry-After": "120" } }),
576 });
577
578 return Effect.gen(function* () {
579 yield* TestClock.setTime(1_000);
580 const bitbucket = yield* BitbucketApi.BitbucketApi;
581 const error = yield* bitbucket
582 .request({ method: "GET", url: "/repositories/acme/web" })
583 .pipe(Effect.flip);
584
585 assert.instanceOf(error, BitbucketApi.BitbucketResponseError);
586 assert.strictEqual(error.status, 429);
587 assert.strictEqual(error.retryAt, 121_000);
588 }).pipe(Effect.provide(layer));
589});
590
572591it.effect("preserves Bitbucket response body read failures as their immediate cause", () => {
573592 const cause = new Error("response stream failed");
574593 const { layer } = makeLayer({
apps/server/src/sourceControl/BitbucketApi.ts+24-21
......@@ -1,3 +1,4 @@
1import * as Clock from "effect/Clock";
12import * as Config from "effect/Config";
23import * as Context from "effect/Context";
34import * as Effect from "effect/Effect";
......@@ -29,6 +30,7 @@ import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";
2930import * as SourceControlProvider from "./SourceControlProvider.ts";
3031import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
3132import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
33import { retryAtFromHeader } from "./SourceControlRateLimit.ts";
3234
3335const DEFAULT_API_BASE_URL = "https://api.bitbucket.org/2.0";
3436/** A response body past this is cut short, so one huge diff cannot exhaust the server. */
......@@ -98,6 +100,7 @@ export class BitbucketResponseError extends Schema.TaggedErrorClass<BitbucketRes
98100 operation: BitbucketApiOperation,
99101 status: Schema.Int,
100102 responseBodyLength: NonNegativeInt,
103 retryAt: Schema.optional(Schema.Number),
101104 },
102105) {
103106 get detail(): string {
......@@ -579,28 +582,28 @@ function responseError(
579582): Effect.Effect<never, BitbucketApiError> {
580583 // Bounded like any other body: an error response is no smaller than a successful one, and
581584 // only its length is reported anyway.
582 return collectUint8StreamText({
583 stream: response.stream,
584 maxBytes: DEFAULT_MAX_RESPONSE_BYTES,
585 }).pipe(
586 Effect.mapError(
587 (cause) =>
588 new BitbucketResponseBodyReadError({
589 operation,
590 status: response.status,
591 cause,
592 }),
593 ),
594 Effect.flatMap((collected) =>
595 Effect.fail(
596 new BitbucketResponseError({
597 operation,
598 status: response.status,
599 responseBodyLength: collected.text.length,
600 }),
585 return Effect.gen(function* () {
586 const now = yield* Clock.currentTimeMillis;
587 const collected = yield* collectUint8StreamText({
588 stream: response.stream,
589 maxBytes: DEFAULT_MAX_RESPONSE_BYTES,
590 }).pipe(
591 Effect.mapError(
592 (cause) =>
593 new BitbucketResponseBodyReadError({
594 operation,
595 status: response.status,
596 cause,
597 }),
601598 ),
602 ),
603 );
599 );
600 return yield* new BitbucketResponseError({
601 operation,
602 status: response.status,
603 responseBodyLength: collected.text.length,
604 retryAt: retryAtFromHeader(response.headers["retry-after"], now),
605 });
606 });
604607}
605608
606609export const make = Effect.gen(function* () {
apps/server/src/sourceControl/GitLabCli.test.ts+22
......@@ -364,4 +364,26 @@ layer("GitLabCli.layer", (it) => {
364364 assert.strictEqual(error.cause, cause);
365365 }),
366366 );
367
368 it.effect("preserves rate-limit failures as a distinct error", () =>
369 Effect.gen(function* () {
370 const cause = new VcsProcessExitError({
371 operation: "GitLabCli.execute",
372 command: "glab",
373 cwd: "/repo",
374 exitCode: 1,
375 detail: "API rate limit exceeded.",
376 failureKind: "rate-limited",
377 });
378 mockedRun.mockReturnValueOnce(Effect.fail(cause));
379
380 const glab = yield* GitLabCli.GitLabCli;
381 const error = yield* glab
382 .execute({ cwd: "/repo", args: ["api", "projects"] })
383 .pipe(Effect.flip);
384
385 assert.strictEqual(error._tag, "GitLabCliRateLimitError");
386 assert.strictEqual(error.cause, cause);
387 }),
388 );
367389});
apps/server/src/sourceControl/GitLabCli.ts+16-1
......@@ -61,6 +61,19 @@ export class GitLabCliAuthenticationError extends Schema.TaggedErrorClass<GitLab
6161 }
6262}
6363
64export class GitLabCliRateLimitError extends Schema.TaggedErrorClass<GitLabCliRateLimitError>()(
65 "GitLabCliRateLimitError",
66 gitLabCliExecutionErrorContext,
67) {
68 get detail(): string {
69 return "GitLab API rate limit exceeded.";
70 }
71
72 override get message(): string {
73 return `GitLab CLI failed in ${this.operation}: ${this.detail}`;
74 }
75}
76
6477export class GitLabMergeRequestNotFoundError extends Schema.TaggedErrorClass<GitLabMergeRequestNotFoundError>()(
6578 "GitLabMergeRequestNotFoundError",
6679 {
......@@ -126,8 +139,9 @@ export class GitLabCliCommandError extends Schema.TaggedErrorClass<GitLabCliComm
126139 switch (cause.failureKind) {
127140 case "authentication":
128141 return new GitLabCliAuthenticationError({ ...context, cause });
129 case "not-found":
130142 case "rate-limited":
143 return new GitLabCliRateLimitError({ ...context, cause });
144 case "not-found":
131145 case "command-failed":
132146 case undefined:
133147 return new GitLabCliCommandError({ ...context, cause });
......@@ -214,6 +228,7 @@ export class GitLabNamespaceDecodeError extends Schema.TaggedErrorClass<GitLabNa
214228export const GitLabCliError = Schema.Union([
215229 GitLabCliUnavailableError,
216230 GitLabCliAuthenticationError,
231 GitLabCliRateLimitError,
217232 GitLabMergeRequestNotFoundError,
218233 GitLabCliCommandError,
219234 GitLabMergeRequestListDecodeError,
apps/server/src/sourceControl/SourceControlRateLimit.test.ts created+122
......@@ -0,0 +1,122 @@
1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as TestClock from "effect/testing/TestClock";
4
5import * as SourceControlRateLimit from "./SourceControlRateLimit.ts";
6
7const github = { provider: "github" as const, host: "github.com" };
8
9it("parses Retry-After seconds and HTTP dates", () => {
10 assert.equal(SourceControlRateLimit.retryAtFromHeader("120", 1_000), 121_000);
11 assert.equal(
12 SourceControlRateLimit.retryAtFromHeader("Thu, 01 Jan 1970 00:02:01 GMT", 1_000),
13 121_000,
14 );
15 assert.isUndefined(SourceControlRateLimit.retryAtFromHeader("later", 1_000));
16});
17
18it.effect("backs off repeated rate limits until a successful request", () =>
19 Effect.gen(function* () {
20 yield* TestClock.setTime(0);
21 const limits = yield* SourceControlRateLimit.SourceControlRateLimit;
22
23 const firstLease = yield* limits.check(github);
24 yield* limits.recordRateLimit({ ...github, lease: firstLease });
25 const firstPause = yield* Effect.flip(limits.check(github));
26 assert.deepInclude(firstPause, {
27 _tag: "SourceControlRateLimitPausedError",
28 provider: "github",
29 host: "github.com",
30 retryAt: 30_000,
31 });
32 assert.equal(
33 firstPause.detail,
34 "github requests to github.com are paused until the rate limit resets.",
35 );
36 assert.equal(firstPause.message, firstPause.detail);
37
38 yield* TestClock.adjust("30 seconds");
39 const secondLease = yield* limits.check(github);
40 yield* limits.recordRateLimit({ ...github, lease: secondLease });
41 assert.equal((yield* Effect.flip(limits.check(github))).retryAt, 90_000);
42
43 yield* TestClock.adjust("60 seconds");
44 const successLease = yield* limits.check(github);
45 yield* limits.recordSuccess({ ...github, lease: successLease });
46 const resetLease = yield* limits.check(github);
47 yield* limits.recordRateLimit({ ...github, lease: resetLease });
48 assert.equal((yield* Effect.flip(limits.check(github))).retryAt, 120_000);
49 }).pipe(Effect.provide(SourceControlRateLimit.layer)),
50);
51
52it.effect("honors a provider reset time", () =>
53 Effect.gen(function* () {
54 yield* TestClock.setTime(1_000);
55 const limits = yield* SourceControlRateLimit.SourceControlRateLimit;
56 const lease = yield* limits.check(github);
57
58 yield* limits.recordRateLimit({ ...github, lease, retryAt: 121_000 });
59 assert.equal((yield* Effect.flip(limits.check(github))).retryAt, 121_000);
60
61 yield* TestClock.adjust("119999 millis");
62 assert.equal((yield* Effect.flip(limits.check(github))).retryAt, 121_000);
63 yield* TestClock.adjust("1 millis");
64 assert.equal(yield* limits.check(github), 1);
65 }).pipe(Effect.provide(SourceControlRateLimit.layer)),
66);
67
68it.effect("lets an interactive request through without clearing an active pause", () =>
69 Effect.gen(function* () {
70 yield* TestClock.setTime(1_000);
71 const limits = yield* SourceControlRateLimit.SourceControlRateLimit;
72 const lease = yield* limits.check(github);
73 yield* limits.recordRateLimit({ ...github, lease, retryAt: 121_000 });
74
75 const interactiveLease = yield* limits.check(github, { allowPaused: true });
76 yield* limits.recordSuccess({ ...github, lease: interactiveLease });
77
78 assert.equal(interactiveLease, 1);
79 assert.equal((yield* Effect.flip(limits.check(github))).retryAt, 121_000);
80
81 yield* limits.recordRateLimit({ ...github, lease: interactiveLease, retryAt: 61_000 });
82 assert.equal((yield* Effect.flip(limits.check(github))).retryAt, 121_000);
83 }).pipe(Effect.provide(SourceControlRateLimit.layer)),
84);
85
86it.effect("keeps providers and hosts isolated", () =>
87 Effect.gen(function* () {
88 const limits = yield* SourceControlRateLimit.SourceControlRateLimit;
89 const lease = yield* limits.check(github);
90 yield* limits.recordRateLimit({ ...github, lease });
91
92 assert.equal(yield* limits.check({ provider: "gitlab", host: "github.com" }), 0);
93 assert.equal(yield* limits.check({ provider: "github", host: "github.example.com" }), 0);
94 }).pipe(Effect.provide(SourceControlRateLimit.layer)),
95);
96
97it.effect("does not let an older success clear a concurrent pause", () =>
98 Effect.gen(function* () {
99 const limits = yield* SourceControlRateLimit.SourceControlRateLimit;
100 const firstLease = yield* limits.check(github);
101 const concurrentLease = yield* limits.check(github);
102
103 yield* limits.recordRateLimit({ ...github, lease: firstLease });
104 yield* limits.recordSuccess({ ...github, lease: concurrentLease });
105
106 assert.equal((yield* Effect.flip(limits.check(github))).retryAt, 30_000);
107 }).pipe(Effect.provide(SourceControlRateLimit.layer)),
108);
109
110it.effect("keeps a fresh provider reset from an older request", () =>
111 Effect.gen(function* () {
112 yield* TestClock.setTime(0);
113 const limits = yield* SourceControlRateLimit.SourceControlRateLimit;
114 const staleLease = yield* limits.check(github);
115 yield* limits.recordRateLimit({ ...github, lease: staleLease });
116
117 yield* TestClock.adjust("31 seconds");
118 yield* limits.recordRateLimit({ ...github, lease: staleLease, retryAt: 61_000 });
119
120 assert.equal((yield* Effect.flip(limits.check(github))).retryAt, 61_000);
121 }).pipe(Effect.provide(SourceControlRateLimit.layer)),
122);
apps/server/src/sourceControl/SourceControlRateLimit.ts created+162
......@@ -0,0 +1,162 @@
1import * as Clock from "effect/Clock";
2import * as Context from "effect/Context";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Ref from "effect/Ref";
7import * as Schema from "effect/Schema";
8import {
9 SourceControlProviderKind as SourceControlProviderKindSchema,
10 type SourceControlProviderKind,
11} from "@t3tools/contracts";
12
13const FALLBACK_COOLDOWN = Duration.seconds(30);
14const MAX_FALLBACK_COOLDOWN = Duration.minutes(15);
15
16interface RateLimitKey {
17 readonly provider: SourceControlProviderKind;
18 readonly host: string;
19}
20
21interface RateLimitLease extends RateLimitKey {
22 readonly lease: number;
23}
24
25interface RateLimitEntry {
26 readonly attempt: number;
27 readonly generation: number;
28 readonly retryAt: number;
29}
30
31export class SourceControlRateLimitPausedError extends Schema.TaggedErrorClass<SourceControlRateLimitPausedError>()(
32 "SourceControlRateLimitPausedError",
33 {
34 provider: SourceControlProviderKindSchema,
35 host: Schema.String,
36 retryAt: Schema.Number,
37 },
38) {
39 get detail(): string {
40 return `${this.provider} requests to ${this.host} are paused until the rate limit resets.`;
41 }
42
43 override get message(): string {
44 return this.detail;
45 }
46}
47
48export class SourceControlRateLimit extends Context.Service<
49 SourceControlRateLimit,
50 {
51 readonly check: (
52 key: RateLimitKey,
53 options?: { readonly allowPaused: boolean },
54 ) => Effect.Effect<number, SourceControlRateLimitPausedError>;
55 readonly recordRateLimit: (
56 input: RateLimitLease & { readonly retryAt?: number | undefined },
57 ) => Effect.Effect<void>;
58 readonly recordSuccess: (input: RateLimitLease) => Effect.Effect<void>;
59 }
60>()("t3/sourceControl/SourceControlRateLimit") {}
61
62function normalizedKey(key: RateLimitKey): string {
63 return `${key.provider}\0${key.host.trim().toLowerCase()}`;
64}
65
66function fallbackCooldownMs(attempt: number): number {
67 return Math.min(
68 Duration.toMillis(FALLBACK_COOLDOWN) * 2 ** Math.max(0, attempt - 1),
69 Duration.toMillis(MAX_FALLBACK_COOLDOWN),
70 );
71}
72
73export function retryAtFromHeader(value: string | undefined, now: number): number | undefined {
74 if (value === undefined) return undefined;
75 const normalized = value.trim();
76 if (/^\d+$/u.test(normalized)) {
77 const seconds = Number(normalized);
78 const retryAt = now + seconds * 1_000;
79 return Number.isSafeInteger(retryAt) ? retryAt : undefined;
80 }
81 const retryAt = Date.parse(normalized);
82 return Number.isFinite(retryAt) && retryAt > now ? retryAt : undefined;
83}
84
85export const make = Effect.gen(function* () {
86 const entries = yield* Ref.make<ReadonlyMap<string, RateLimitEntry>>(new Map());
87
88 const check: SourceControlRateLimit["Service"]["check"] = Effect.fn(
89 "SourceControlRateLimit.check",
90 )(function* (input, options) {
91 const now = yield* Clock.currentTimeMillis;
92 const entry = (yield* Ref.get(entries)).get(normalizedKey(input));
93 if (entry !== undefined && entry.retryAt > now && options?.allowPaused !== true) {
94 return yield* new SourceControlRateLimitPausedError({
95 provider: input.provider,
96 host: input.host.trim().toLowerCase(),
97 retryAt: entry.retryAt,
98 });
99 }
100 return entry?.generation ?? 0;
101 });
102
103 const recordRateLimit: SourceControlRateLimit["Service"]["recordRateLimit"] = Effect.fn(
104 "SourceControlRateLimit.recordRateLimit",
105 )(function* (input) {
106 const now = yield* Clock.currentTimeMillis;
107 yield* Ref.update(entries, (current) => {
108 const key = normalizedKey(input);
109 const previous = current.get(key);
110 if (previous !== undefined && previous.generation > input.lease) {
111 if (previous.retryAt <= now && (input.retryAt === undefined || input.retryAt <= now)) {
112 return current;
113 }
114 const retryAt =
115 input.retryAt !== undefined && input.retryAt > previous.retryAt
116 ? input.retryAt
117 : previous.retryAt;
118 if (retryAt === previous.retryAt) return current;
119 const next = new Map(current);
120 next.set(key, { ...previous, retryAt });
121 return next;
122 }
123
124 const attempt = (previous?.attempt ?? 0) + 1;
125 const proposedRetryAt =
126 input.retryAt !== undefined && input.retryAt > now
127 ? input.retryAt
128 : now + fallbackCooldownMs(attempt);
129 const retryAt =
130 previous !== undefined && previous.retryAt > now
131 ? Math.max(previous.retryAt, proposedRetryAt)
132 : proposedRetryAt;
133 const next = new Map(current);
134 next.set(key, {
135 attempt,
136 generation: Math.max(previous?.generation ?? 0, input.lease) + 1,
137 retryAt,
138 });
139 return next;
140 });
141 });
142
143 const recordSuccess: SourceControlRateLimit["Service"]["recordSuccess"] = Effect.fn(
144 "SourceControlRateLimit.recordSuccess",
145 )(function* (input) {
146 const now = yield* Clock.currentTimeMillis;
147 yield* Ref.update(entries, (current) => {
148 const key = normalizedKey(input);
149 const previous = current.get(key);
150 if (previous === undefined || previous.generation !== input.lease || previous.retryAt > now) {
151 return current;
152 }
153 const next = new Map(current);
154 next.set(key, { attempt: 0, generation: previous.generation, retryAt: 0 });
155 return next;
156 });
157 });
158
159 return SourceControlRateLimit.of({ check, recordRateLimit, recordSuccess });
160});
161
162export const layer = Layer.effect(SourceControlRateLimit, make);
apps/server/src/sourceControl/githubGraphQlBudget.test.ts created+189
......@@ -0,0 +1,189 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as TestClock from "effect/testing/TestClock";
4
5import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts";
6
7const RESET_AT = "2026-08-13T14:00:00.000Z";
8const NEXT_RESET_AT = "2026-08-13T15:00:00.000Z";
9const BEFORE_RESET = Date.parse("2026-08-13T13:30:00.000Z");
10const AFTER_RESET = Date.parse("2026-08-13T14:00:01.000Z");
11
12function rateLimit(remaining: number, limit = 5_000, resetAt = RESET_AT): string {
13 return JSON.stringify({
14 data: {
15 viewer: { login: "bilal" },
16 rateLimit: { cost: 14, limit, remaining, resetAt },
17 },
18 });
19}
20
21describe("GitHub GraphQL budget", () => {
22 it.effect("adds rate metadata to a read query", () =>
23 Effect.gen(function* () {
24 const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;
25
26 const query = yield* budget.query(
27 "github.com",
28 'query { repository(owner: "acme", name: "web") { name } }',
29 );
30
31 expect(query).toContain("rateLimit { cost limit remaining resetAt }");
32 expect(query).toContain('repository(owner: "acme", name: "web") { name }');
33 }).pipe(Effect.provide(GitHubGraphQlBudget.layer)),
34 );
35
36 it.effect("protects the last ten percent with the shared provider cooldown error", () =>
37 Effect.gen(function* () {
38 yield* TestClock.setTime(BEFORE_RESET);
39 const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;
40 yield* budget.observe("github.com", rateLimit(500));
41
42 const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }"));
43 expect(error).toMatchObject({
44 _tag: "SourceControlRateLimitPausedError",
45 provider: "github",
46 host: "github.com",
47 retryAt: Date.parse(RESET_AT),
48 });
49 expect(error.message).toBe(
50 "github requests to github.com are paused until the rate limit resets.",
51 );
52
53 yield* TestClock.setTime(AFTER_RESET);
54 expect(yield* budget.query("github.com", "query { viewer { login } }")).toContain(
55 "rateLimit",
56 );
57 }).pipe(Effect.provide(GitHubGraphQlBudget.layer)),
58 );
59
60 it.effect("keeps hosts isolated", () =>
61 Effect.gen(function* () {
62 yield* TestClock.setTime(BEFORE_RESET);
63 const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;
64 yield* budget.observe("github.com", rateLimit(0));
65
66 const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }"));
67 expect(error._tag).toBe("SourceControlRateLimitPausedError");
68 expect(yield* budget.query("github.example.com", "query { viewer { login } }")).toContain(
69 "rateLimit",
70 );
71 }).pipe(Effect.provide(GitHubGraphQlBudget.layer)),
72 );
73
74 it.effect("keeps the lower remaining value from out-of-order responses", () =>
75 Effect.gen(function* () {
76 yield* TestClock.setTime(BEFORE_RESET);
77 const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;
78 yield* budget.observe("github.com", rateLimit(400));
79 yield* budget.observe("github.com", rateLimit(600));
80
81 const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }"));
82 expect(error).toMatchObject({
83 _tag: "SourceControlRateLimitPausedError",
84 retryAt: Date.parse(RESET_AT),
85 });
86 }).pipe(Effect.provide(GitHubGraphQlBudget.layer)),
87 );
88
89 it.effect("ignores a response from an older reset window", () =>
90 Effect.gen(function* () {
91 yield* TestClock.setTime(BEFORE_RESET);
92 const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;
93 yield* budget.observe("github.com", rateLimit(400, 5_000, NEXT_RESET_AT));
94 yield* budget.observe("github.com", rateLimit(1_000, 5_000, RESET_AT));
95
96 const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }"));
97 expect(error).toMatchObject({
98 _tag: "SourceControlRateLimitPausedError",
99 retryAt: Date.parse(NEXT_RESET_AT),
100 });
101 }).pipe(Effect.provide(GitHubGraphQlBudget.layer)),
102 );
103
104 it.effect("accepts a response from a later reset window", () =>
105 Effect.gen(function* () {
106 yield* TestClock.setTime(BEFORE_RESET);
107 const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;
108 yield* budget.observe("github.com", rateLimit(1_000));
109 yield* budget.observe("github.com", rateLimit(400, 5_000, NEXT_RESET_AT));
110
111 const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }"));
112 expect(error).toMatchObject({
113 _tag: "SourceControlRateLimitPausedError",
114 retryAt: Date.parse(NEXT_RESET_AT),
115 });
116 }).pipe(Effect.provide(GitHubGraphQlBudget.layer)),
117 );
118
119 it.effect("allows reads above the reserve", () =>
120 Effect.gen(function* () {
121 yield* TestClock.setTime(BEFORE_RESET);
122 const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;
123 yield* budget.observe("github.com", rateLimit(515));
124
125 expect(yield* budget.query("github.com", "query { viewer { login } }")).toContain(
126 "rateLimit",
127 );
128 }).pipe(Effect.provide(GitHubGraphQlBudget.layer)),
129 );
130
131 it.effect("allows an interactive read to use the reserve", () =>
132 Effect.gen(function* () {
133 yield* TestClock.setTime(BEFORE_RESET);
134 const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;
135 yield* budget.observe("github.com", rateLimit(500));
136
137 expect(
138 yield* budget.query("github.com", "query { viewer { login } }", {
139 allowReserve: true,
140 }),
141 ).toContain("rateLimit");
142 }).pipe(Effect.provide(GitHubGraphQlBudget.layer)),
143 );
144
145 it.effect("reserves an admitted query cost before its response", () =>
146 Effect.gen(function* () {
147 yield* TestClock.setTime(BEFORE_RESET);
148 const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;
149 yield* budget.observe("github.com", rateLimit(514));
150
151 expect(yield* budget.query("github.com", "query { viewer { login } }")).toContain(
152 "rateLimit",
153 );
154 const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }"));
155
156 expect(error).toMatchObject({
157 _tag: "SourceControlRateLimitPausedError",
158 retryAt: Date.parse(RESET_AT),
159 });
160 }).pipe(Effect.provide(GitHubGraphQlBudget.layer)),
161 );
162
163 it.effect("ignores malformed or partial rate metadata", () =>
164 Effect.gen(function* () {
165 yield* TestClock.setTime(BEFORE_RESET);
166 const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;
167 yield* budget.observe("github.com", "{");
168 yield* budget.observe(
169 "github.com",
170 '{"data":{"rateLimit":{"limit":0,"remaining":-1,"resetAt":"never"}}}',
171 );
172
173 expect(yield* budget.query("github.com", "query { viewer { login } }")).toContain(
174 "rateLimit",
175 );
176 }).pipe(Effect.provide(GitHubGraphQlBudget.layer)),
177 );
178
179 it.effect("does not add a read field to a mutation", () =>
180 Effect.gen(function* () {
181 yield* TestClock.setTime(BEFORE_RESET);
182 const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;
183 const mutation = "mutation { addComment(input: {}) { clientMutationId } }";
184 yield* budget.observe("github.com", rateLimit(0));
185
186 expect(yield* budget.query("github.com", mutation)).toBe(mutation);
187 }).pipe(Effect.provide(GitHubGraphQlBudget.layer)),
188 );
189});
apps/server/src/sourceControl/githubGraphQlBudget.ts created+141
......@@ -0,0 +1,141 @@
1import * as Clock from "effect/Clock";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Ref from "effect/Ref";
6import * as Predicate from "effect/Predicate";
7
8import * as SourceControlRateLimit from "./SourceControlRateLimit.ts";
9
10const GRAPHQL_RESERVE_RATIO = 0.1;
11const RATE_LIMIT_SELECTION = "rateLimit { cost limit remaining resetAt }";
12
13interface GraphQlBudgetSnapshot {
14 readonly cost: number;
15 readonly limit: number;
16 readonly remaining: number;
17 readonly resetAtMs: number;
18}
19
20export class GitHubGraphQlBudget extends Context.Service<
21 GitHubGraphQlBudget,
22 {
23 readonly query: (
24 host: string,
25 document: string,
26 options?: { readonly allowReserve: boolean },
27 ) => Effect.Effect<string, SourceControlRateLimit.SourceControlRateLimitPausedError>;
28 readonly observe: (host: string, raw: string) => Effect.Effect<void>;
29 }
30>()("t3/sourceControl/githubGraphQlBudget") {}
31
32function hostKey(host: string): string {
33 return host.trim().toLowerCase();
34}
35
36function snapshotFrom(raw: string): GraphQlBudgetSnapshot | null {
37 try {
38 const parsed: unknown = JSON.parse(raw);
39 if (
40 !Predicate.isObject(parsed) ||
41 !Predicate.isObject(parsed.data) ||
42 !Predicate.isObject(parsed.data.rateLimit)
43 ) {
44 return null;
45 }
46 const { cost, limit, remaining, resetAt } = parsed.data.rateLimit;
47 if (
48 typeof cost !== "number" ||
49 !Number.isFinite(cost) ||
50 cost < 0 ||
51 typeof limit !== "number" ||
52 !Number.isFinite(limit) ||
53 limit <= 0 ||
54 typeof remaining !== "number" ||
55 !Number.isFinite(remaining) ||
56 remaining < 0 ||
57 typeof resetAt !== "string"
58 ) {
59 return null;
60 }
61 const resetAtMs = Date.parse(resetAt);
62 return Number.isFinite(resetAtMs) ? { cost, limit, remaining, resetAtMs } : null;
63 } catch {
64 return null;
65 }
66}
67
68function isReadOperation(document: string): boolean {
69 const operation = document.trimStart();
70 return operation.startsWith("query") || operation.startsWith("{");
71}
72
73function withRateLimit(document: string): string {
74 if (!isReadOperation(document)) return document;
75 const end = document.lastIndexOf("}");
76 if (end === -1 || document.includes(RATE_LIMIT_SELECTION)) return document;
77 return `${document.slice(0, end)}\n ${RATE_LIMIT_SELECTION}\n${document.slice(end)}`;
78}
79
80export const make = Effect.gen(function* () {
81 const snapshots = yield* Ref.make<ReadonlyMap<string, GraphQlBudgetSnapshot>>(new Map());
82
83 const query: GitHubGraphQlBudget["Service"]["query"] = Effect.fn("GitHubGraphQlBudget.query")(
84 function* (host, document, options) {
85 if (!isReadOperation(document)) return document;
86 const now = yield* Clock.currentTimeMillis;
87 const retryAt = yield* Ref.modify(snapshots, (current) => {
88 const key = hostKey(host);
89 const snapshot = current.get(key);
90 if (snapshot === undefined) return [null, current] as const;
91 if (snapshot.resetAtMs <= now) {
92 const next = new Map(current);
93 next.delete(key);
94 return [null, next] as const;
95 }
96 const remaining = Math.max(0, snapshot.remaining - Math.max(1, snapshot.cost));
97 if (options?.allowReserve !== true && remaining < snapshot.limit * GRAPHQL_RESERVE_RATIO) {
98 return [snapshot.resetAtMs, current] as const;
99 }
100 const next = new Map(current);
101 next.set(key, { ...snapshot, remaining });
102 return [null, next] as const;
103 });
104 if (retryAt !== null) {
105 return yield* new SourceControlRateLimit.SourceControlRateLimitPausedError({
106 provider: "github",
107 host: hostKey(host),
108 retryAt,
109 });
110 }
111 return withRateLimit(document);
112 },
113 );
114
115 const observe: GitHubGraphQlBudget["Service"]["observe"] = Effect.fn(
116 "GitHubGraphQlBudget.observe",
117 )(function* (host, raw) {
118 const snapshot = snapshotFrom(raw);
119 if (snapshot === null) return;
120 yield* Ref.update(snapshots, (current) => {
121 const key = hostKey(host);
122 const previous = current.get(key);
123 // Concurrent reads can finish out of order. Quota only falls within one reset window, and
124 // an answer from an older window must not replace the current one.
125 if (
126 previous !== undefined &&
127 (snapshot.resetAtMs < previous.resetAtMs ||
128 (snapshot.resetAtMs === previous.resetAtMs && snapshot.remaining >= previous.remaining))
129 ) {
130 return current;
131 }
132 const next = new Map(current);
133 next.set(key, snapshot);
134 return next;
135 });
136 });
137
138 return GitHubGraphQlBudget.of({ query, observe });
139});
140
141export const layer = Layer.effect(GitHubGraphQlBudget, make);
apps/server/src/vcs/VcsProcess.test.ts+18
......@@ -165,6 +165,24 @@ describe("VcsProcess.run", () => {
165165 }).pipe(provideLive),
166166 );
167167
168 it.effect("classifies HTTP 429 responses as rate limits", () =>
169 Effect.gen(function* () {
170 const providerStderr = "HTTP 429: Too Many Requests. request-id=secret-value";
171 const error = yield* run({
172 operation: "test.rate-limit",
173 command: "node",
174 args: ["-e", "process.stderr.write(process.argv[1]); process.exit(1)", providerStderr],
175 cwd: process.cwd(),
176 }).pipe(Effect.flip);
177
178 expect(error).toMatchObject({
179 detail: "API rate limit exceeded.",
180 failureKind: "rate-limited",
181 });
182 expect(error.message).not.toContain(providerStderr);
183 }).pipe(provideLive),
184 );
185
168186 it.effect("retains spawn causes without exposing process arguments in the error message", () =>
169187 Effect.gen(function* () {
170188 const secretArgument = "--token=super-secret-token";
apps/server/src/vcs/VcsProcess.ts+3-1
......@@ -72,7 +72,9 @@ const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFai
7272 if (
7373 normalized.includes("api rate limit") ||
7474 normalized.includes("rate limit exceeded") ||
75 normalized.includes("secondary rate limit")
75 normalized.includes("secondary rate limit") ||
76 normalized.includes("too many requests") ||
77 normalized.includes("http 429")
7678 ) {
7779 return "rate-limited";
7880 }
apps/server/src/ws.ts+8
......@@ -1659,6 +1659,14 @@ const makeWsRpcLayer = (
16591659 observeRpcEffect(WS_METHODS.pullRequestsActivity, pullRequests.activity(input), {
16601660 "rpc.aggregate": "pull-requests",
16611661 }),
1662 [WS_METHODS.pullRequestsThreadComments]: (input) =>
1663 observeRpcEffect(
1664 WS_METHODS.pullRequestsThreadComments,
1665 pullRequests.threadComments(input),
1666 {
1667 "rpc.aggregate": "pull-requests",
1668 },
1669 ),
16621670 [WS_METHODS.pullRequestsDiffFileContents]: (input) =>
16631671 observeRpcEffect(
16641672 WS_METHODS.pullRequestsDiffFileContents,
apps/web/src/components/pullRequest/PullRequestCodeTab.tsx+19
......@@ -8,6 +8,7 @@ import type {
88 PullRequestRef,
99 PullRequestReviewPosition,
1010 PullRequestReviewThread,
11 PullRequestThreadCommentsResult,
1112} from "@t3tools/contracts";
1213import {
1314 ChevronDownIcon,
......@@ -336,6 +337,9 @@ export function PullRequestCodeTab({
336337 const updateComment = useAtomCommand(pullRequestEnvironment.updateComment, {
337338 reportFailure: false,
338339 });
340 const loadThreadComments = useAtomCommand(pullRequestEnvironment.threadComments, {
341 reportFailure: false,
342 });
339343 const getDiffFileContents = useAtomCommand(pullRequestEnvironment.diffFileContents);
340344 const loadDiffFiles = useMemo(
341345 () =>
......@@ -798,6 +802,20 @@ export function PullRequestCodeTab({
798802 fixPending={pendingFinding === pullRequestFindingKey({ kind: "thread", thread })}
799803 fixLabel={fixFindingLabel}
800804 {...(onFixFinding ? { onFix: () => onFixFinding({ kind: "thread", thread }) } : {})}
805 onLoadMore={async (cursor): Promise<PullRequestThreadCommentsResult | null> => {
806 const result = await loadThreadComments({
807 environmentId,
808 input: { ...reference, threadId: thread.id, cursor },
809 });
810 if (result._tag === "Failure") {
811 toastManager.add({
812 type: "error",
813 title: "More comments could not be loaded",
814 });
815 return null;
816 }
817 return result.value;
818 }}
801819 onReply={(body) =>
802820 runThreadCommand("Reply could not be posted", () =>
803821 replyToThread({
......@@ -833,6 +851,7 @@ export function PullRequestCodeTab({
833851 detail,
834852 environmentId,
835853 fixFindingLabel,
854 loadThreadComments,
836855 onRefresh,
837856 onFixFinding,
838857 pendingFinding,
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+17-5
......@@ -111,6 +111,7 @@ import {
111111 readableFailure,
112112 resolveBaseFreshness,
113113 type PullRequestFinding,
114 shouldRefreshPullRequestActivity,
114115} from "./pullRequestDetail.logic";
115116import { canEditPullRequestChangeRequest } from "./pullRequestEditing.logic";
116117import {
......@@ -511,6 +512,17 @@ export function PullRequestDetailPanel({
511512 detailQuery.refresh();
512513 activityQuery.refresh();
513514 }, [activityQuery.refresh, detailQuery.refresh]);
515 const activityRevision = useRef<{ readonly key: string; readonly updatedAt: string } | null>(
516 null,
517 );
518 useEffect(() => {
519 if (!coreDetail) return;
520 const next = { key: pullRequestKey, updatedAt: coreDetail.updatedAt };
521 if (shouldRefreshPullRequestActivity(activityRevision.current, next)) {
522 activityQuery.refresh();
523 }
524 activityRevision.current = next;
525 }, [activityQuery.refresh, coreDetail, pullRequestKey]);
514526 useEffect(() => {
515527 if (!detail) return;
516528 onStateChange?.({
......@@ -521,11 +533,11 @@ export function PullRequestDetailPanel({
521533 isDraft: detail.isDraft,
522534 });
523535 }, [detail, onStateChange]);
524 // A pull request changes while it is open in front of somebody — a push lands, a check
525 // finishes, a review arrives — so the panel reads it again on the way back to the window and
526 // while a reader sits on it. Keyed by the pull request rather than by the panel, because this
527 // one panel shows a different pull request every time it is opened.
528 useLiveRefresh(refreshDetail, {
536 // Core detail is cheap enough to re-read while this stays open. Activity is heavier, so the
537 // revision effect above reads it only after this same pull request reports a change. Keyed by
538 // the pull request rather than by the panel, because this one panel shows a different pull
539 // request every time it is opened.
540 useLiveRefresh(detailQuery.refresh, {
529541 key: `pull-request:${reference.projectId}:${reference.repository}#${reference.number}`,
530542 });
531543 // The button, on the other hand, goes around the server's cache rather than through it: it is
apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx+72-4
......@@ -6,6 +6,7 @@ import type {
66 EnvironmentId,
77 PullRequestRef,
88 PullRequestReviewThread,
9 PullRequestThreadCommentsResult,
910 PullRequestThreadComment,
1011} from "@t3tools/contracts";
1112import {
......@@ -24,6 +25,10 @@ import { cn } from "~/lib/utils";
2425import { Button } from "../ui/button";
2526import { Textarea } from "../ui/textarea";
2627import { isCommentSubmitShortcut } from "../diffs/commentSubmitShortcut";
28import {
29 editPullRequestThreadComment,
30 mergePullRequestThreadComments,
31} from "./pullRequestDetail.logic";
2732import { PullRequestActorLabel } from "./pullRequestPresentation";
2833import { PullRequestMarkdown } from "./PullRequestMarkdown";
2934import { PullRequestMarkdownEditor } from "./PullRequestMarkdownEditor";
......@@ -98,6 +103,7 @@ export function ReviewThreadCard({
98103 fixLabel = "Fix in a thread",
99104 onFix,
100105 onReply,
106 onLoadMore,
101107 canEditComment,
102108 onEditComment,
103109 onToggleResolved,
......@@ -118,6 +124,8 @@ export function ReviewThreadCard({
118124 onFix?: () => void;
119125 /** Resolves to whether the host took it, so a reply that failed keeps the words it was given. */
120126 onReply: (body: string) => Promise<boolean>;
127 /** Reads one more page only after the reader asks for it. */
128 onLoadMore: (cursor: string) => Promise<PullRequestThreadCommentsResult | null>;
121129 /** Whether this reader wrote this remark, which is what rewriting one takes. */
122130 canEditComment: (comment: PullRequestThreadComment) => boolean;
123131 /** Resolves to whether the host took it, like `onReply`. */
......@@ -132,13 +140,32 @@ export function ReviewThreadCard({
132140 const [editingId, setEditingId] = useState<string | null>(null);
133141 const [savingEdit, setSavingEdit] = useState(false);
134142 const sendingRef = useRef(false);
143 const [loadedPage, setLoadedPage] = useState<
144 (PullRequestThreadCommentsResult & { readonly threadId: string }) | null
145 >(null);
146 const [loadingMore, setLoadingMore] = useState(false);
147 const currentPage = loadedPage?.threadId === thread.id ? loadedPage : null;
148 const comments = mergePullRequestThreadComments(thread.comments, currentPage?.comments ?? []);
149 const nextCommentsCursor =
150 currentPage === null ? (thread.nextCommentsCursor ?? null) : currentPage.nextCursor;
151 const commentCount = thread.commentCount ?? comments.length;
135152
136153 const saveEdit = async (commentId: string, body: string) => {
137154 if (savingEdit) return;
138155 setSavingEdit(true);
139156 const saved = await onEditComment(commentId, body);
140157 setSavingEdit(false);
141 if (saved) setEditingId(null);
158 if (saved) {
159 setLoadedPage((previous) =>
160 previous?.threadId === thread.id
161 ? {
162 ...previous,
163 comments: editPullRequestThreadComment(previous.comments, commentId, body),
164 }
165 : previous,
166 );
167 setEditingId(null);
168 }
142169 };
143170
144171 const send = async () => {
......@@ -149,6 +176,16 @@ export function ReviewThreadCard({
149176 // empty box, and the words have to be written again.
150177 try {
151178 if (await onReply(trimmed)) {
179 // The mutation returns no comment. Keep what the reader loaded and reopen its cursor so
180 // the new reply remains reachable without spending requests until they ask to load it.
181 setLoadedPage((previous) =>
182 previous?.threadId === thread.id
183 ? {
184 ...previous,
185 nextCursor: previous.nextCursor ?? thread.nextCommentsCursor ?? null,
186 }
187 : previous,
188 );
152189 setReply("");
153190 setReplying(false);
154191 }
......@@ -156,6 +193,24 @@ export function ReviewThreadCard({
156193 sendingRef.current = false;
157194 }
158195 };
196 const loadMore = async () => {
197 if (nextCommentsCursor === null || loadingMore) return;
198 setLoadingMore(true);
199 try {
200 const page = await onLoadMore(nextCommentsCursor);
201 if (page === null) return;
202 setLoadedPage((previous) => ({
203 threadId: thread.id,
204 comments: mergePullRequestThreadComments(
205 previous?.threadId === thread.id ? previous.comments : [],
206 page.comments,
207 ),
208 nextCursor: page.nextCursor,
209 }));
210 } finally {
211 setLoadingMore(false);
212 }
213 };
159214
160215 return (
161216 <div
......@@ -175,8 +230,8 @@ export function ReviewThreadCard({
175230 aria-expanded={expanded}
176231 onClick={() => setExpanded((current) => !current)}
177232 >
178 {thread.isResolved ? "Resolved" : "Open"} · {thread.comments.length}{" "}
179 {thread.comments.length === 1 ? "comment" : "comments"}
233 {thread.isResolved ? "Resolved" : "Open"} · {commentCount}{" "}
234 {commentCount === 1 ? "comment" : "comments"}
180235 </button>
181236 {thread.isOutdated ? <span>outdated</span> : null}
182237 {onFix ? (
......@@ -207,7 +262,7 @@ export function ReviewThreadCard({
207262 {expanded ? (
208263 <>
209264 <div className="mt-2 space-y-3">
210 {thread.comments.map((comment) => (
265 {comments.map((comment) => (
211266 <article key={comment.id} className="group min-w-0">
212267 <div className="flex items-center gap-2 text-xs text-muted-foreground">
213268 <PullRequestActorLabel actor={comment.author} className="text-foreground" />
......@@ -255,6 +310,19 @@ export function ReviewThreadCard({
255310 </article>
256311 ))}
257312 </div>
313 {nextCommentsCursor !== null ? (
314 <div className="mt-2">
315 <Button
316 size="xs"
317 variant="ghost"
318 className="px-1"
319 disabled={loadingMore}
320 onClick={() => void loadMore()}
321 >
322 {loadingMore ? "Loading..." : "Load more comments"}
323 </Button>
324 </div>
325 ) : null}
258326
259327 {canReply ? (
260328 replying ? (
apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts+62
......@@ -17,6 +17,7 @@ import {
1717 handoffPrompt,
1818 handoffReviewComments,
1919 isThreadOwnPullRequest,
20 mergePullRequestThreadComments,
2021 orderPullRequestComments,
2122 pullRequestActionMenuHasGroup,
2223 pullRequestActionNeedsHostRefresh,
......@@ -24,9 +25,11 @@ import {
2425 pullRequestFindingKey,
2526 pullRequestHandoffLabels,
2627 readableFailure,
28 shouldRefreshPullRequestActivity,
2729 resolveBaseFreshness,
2830 buildPullRequestTimeline,
2931 describePullRequestState,
32 editPullRequestThreadComment,
3033} from "./pullRequestDetail.logic";
3134import type { ReviewCommentContext } from "~/reviewCommentContext";
3235
......@@ -55,6 +58,65 @@ const TIMELINE_SOURCE: Pick<
5558 closedAt: null,
5659};
5760
61describe("pull request activity refresh", () => {
62 const first = {
63 key: "project:acme/web#7",
64 updatedAt: "2026-08-13T13:00:00Z",
65 };
66
67 it("refreshes activity only after the same pull request changes", () => {
68 expect(
69 shouldRefreshPullRequestActivity(first, {
70 ...first,
71 updatedAt: "2026-08-13T13:01:00Z",
72 }),
73 ).toBe(true);
74 });
75
76 it("does not duplicate the first activity read or carry a revision across pull requests", () => {
77 expect(shouldRefreshPullRequestActivity(null, first)).toBe(false);
78 expect(shouldRefreshPullRequestActivity(first, first)).toBe(false);
79 expect(
80 shouldRefreshPullRequestActivity(first, {
81 key: "project:acme/web#8",
82 updatedAt: "2026-08-13T13:01:00Z",
83 }),
84 ).toBe(false);
85 });
86});
87describe("review thread comment pages", () => {
88 it("appends new comments once and keeps refreshed base comments", () => {
89 expect(
90 mergePullRequestThreadComments(
91 [
92 { id: "c1", body: "refreshed" },
93 { id: "c2", body: "already in base" },
94 ],
95 [
96 { id: "c2", body: "stale page copy" },
97 { id: "c3", body: "next page" },
98 ],
99 ),
100 ).toEqual([
101 { id: "c1", body: "refreshed" },
102 { id: "c2", body: "already in base" },
103 { id: "c3", body: "next page" },
104 ]);
105 });
106
107 it("keeps a loaded comment after its body is edited", () => {
108 const loaded = [
109 { id: "c2", body: "old body" },
110 { id: "c3", body: "another loaded comment" },
111 ];
112
113 expect(editPullRequestThreadComment(loaded, "c2", "saved body")).toEqual([
114 { id: "c2", body: "saved body" },
115 { id: "c3", body: "another loaded comment" },
116 ]);
117 });
118});
119
58120describe("pull request action menu", () => {
59121 it("keeps the group divider when auto-merge is the only action", () => {
60122 expect(pullRequestActionMenuHasGroup(false, true, false)).toBe(true);
apps/web/src/components/pullRequest/pullRequestDetail.logic.ts+29
......@@ -14,6 +14,35 @@ import type {
1414
1515import { inferReviewCommentFenceLanguage, type ReviewCommentContext } from "~/reviewCommentContext";
1616
17/** Activity changes only when the same host resource reports a newer revision. */
18export function shouldRefreshPullRequestActivity(
19 previous: { readonly key: string; readonly updatedAt: string } | null,
20 next: { readonly key: string; readonly updatedAt: string },
21): boolean {
22 return previous !== null && previous.key === next.key && previous.updatedAt !== next.updatedAt;
23}
24/** Appends fetched pages without replacing fresher comments already in the activity response. */
25export function mergePullRequestThreadComments<T extends { readonly id: string }>(
26 base: ReadonlyArray<T>,
27 loaded: ReadonlyArray<T>,
28): ReadonlyArray<T> {
29 const seen = new Set(base.map((comment) => comment.id));
30 return [
31 ...base,
32 ...loaded.filter((comment) => {
33 if (seen.has(comment.id)) return false;
34 seen.add(comment.id);
35 return true;
36 }),
37 ];
38}
39
40export function editPullRequestThreadComment<
41 T extends { readonly id: string; readonly body: string },
42>(comments: ReadonlyArray<T>, commentId: string, body: string): ReadonlyArray<T> {
43 return comments.map((comment) => (comment.id === commentId ? { ...comment, body } : comment));
44}
45
1746/**
1847 * Whether the pull request on a right-panel surface is the thread's own one. Repository and
1948 * number are not enough: one environment can hold two checkouts of the same repository under
apps/web/src/hooks/useLiveRefresh.test.ts+11-1
......@@ -9,6 +9,12 @@ import {
99 shouldRefreshOnInterval,
1010} from "./useLiveRefresh";
1111
12describe("live refresh cadence", () => {
13 it("waits five minutes between automatic host reads", () => {
14 expect(LIVE_REFRESH_INTERVAL_MS).toBe(5 * 60_000);
15 });
16});
17
1218describe("shouldLiveRefresh", () => {
1319 const at = (now: number, lastRefreshedAt: number, visible = true) =>
1420 shouldLiveRefresh({ visible, now, lastRefreshedAt });
......@@ -79,12 +85,16 @@ describe("shouldRefreshOnInterval", () => {
7985 expect(tick(LIVE_REFRESH_INTERVAL_MS, LIVE_REFRESH_INTERVAL_MS - 1_000)).toBe(true);
8086 });
8187
88 it("reads on the first interval after an untouched mount", () => {
89 expect(tick(LIVE_REFRESH_INTERVAL_MS + 1_000, 0)).toBe(true);
90 });
91
8292 it("stops reading for a window left showing on a desk nobody is at", () => {
8393 expect(tick(LIVE_REFRESH_IDLE_AFTER_MS + 60_000, 0)).toBe(false);
8494 });
8595
8696 it("starts reading again once the reader touches the window", () => {
8797 const away = LIVE_REFRESH_IDLE_AFTER_MS + 60_000;
88 expect(tick(away + LIVE_REFRESH_INTERVAL_MS, away)).toBe(true);
98 expect(tick(away + LIVE_REFRESH_MIN_INTERVAL_MS, away)).toBe(true);
8999 });
90100});
apps/web/src/hooks/useLiveRefresh.ts+5-4
......@@ -18,16 +18,17 @@ import { useEffect, useId, useRef } from "react";
1818/** Long enough that alt-tabbing through windows does not become a request per tab stop. */
1919export const LIVE_REFRESH_MIN_INTERVAL_MS = 10_000;
2020
21/** Slow enough to be cheap on the host, quick enough that a reader is not reading last minute. */
22export const LIVE_REFRESH_INTERVAL_MS = 60_000;
21/** Slow enough to preserve host quota while still updating a view left open. */
22export const LIVE_REFRESH_INTERVAL_MS = 5 * 60_000;
2323
2424/**
25 * How long a showing window goes untouched before it stops reading. A window left open on a
25 * How long a showing window goes untouched before it stops reading. This leaves one minute after
26 * the five-minute interval for the first timer tick to run. A window left open on a
2627 * monitor nobody is sitting at is showing in every sense the browser knows about, and polling it
2728 * until morning spends a night of somebody's rate limit on an answer nobody read. Their next
2829 * click, key or scroll starts the interval up again.
2930 */
30export const LIVE_REFRESH_IDLE_AFTER_MS = 5 * 60_000;
31export const LIVE_REFRESH_IDLE_AFTER_MS = 6 * 60_000;
3132
3233/**
3334 * Whether a view should read again now. Separate from the hook because the rule is the whole of
apps/web/src/routes/_chat.pull-requests.tsx-1
......@@ -972,7 +972,6 @@ function PullRequestsRouteView() {
972972 useLiveRefresh(
973973 () => {
974974 refreshList();
975 baselineQuery.refresh();
976975 authoredQuery.refresh();
977976 reviewingQuery.refresh();
978977 },
packages/client-runtime/src/state/pullRequests.ts+10
......@@ -61,6 +61,16 @@ export function createPullRequestEnvironmentAtoms<R, E>(
6161 tag: WS_METHODS.pullRequestsActivity,
6262 staleTimeMs: 15_000,
6363 }),
64 threadComments: createEnvironmentRpcCommand(runtime, {
65 label: "environment-data:pull-requests:thread-comments",
66 tag: WS_METHODS.pullRequestsThreadComments,
67 scheduler: commandScheduler,
68 concurrency: {
69 mode: "singleFlight",
70 key: ({ environmentId, input }) =>
71 JSON.stringify([environmentId, input.threadId, input.cursor]),
72 },
73 }),
6474 diff: createEnvironmentQueryAtomFamily(runtime, {
6575 label: "environment-data:pull-requests:diff",
6676 staleTimeMs: 60_000,
packages/contracts/src/pullRequest.ts+17
......@@ -241,6 +241,10 @@ export const PullRequestReviewThread = Schema.Struct({
241241 */
242242 isOutdated: Schema.Boolean,
243243 comments: Schema.Array(PullRequestThreadComment),
244 /** Host-reported total, when this thread was read in pages. */
245 commentCount: Schema.optional(NonNegativeInt),
246 /** Opaque cursor for the next comment page. Absent once this thread is whole. */
247 nextCommentsCursor: Schema.optional(TrimmedNonEmptyString),
244248});
245249export type PullRequestReviewThread = typeof PullRequestReviewThread.Type;
246250
......@@ -904,6 +908,19 @@ export const PullRequestSubmitReviewInput = Schema.Struct({
904908});
905909export type PullRequestSubmitReviewInput = typeof PullRequestSubmitReviewInput.Type;
906910
911export const PullRequestThreadCommentsInput = Schema.Struct({
912 ...PullRequestRef.fields,
913 threadId: TrimmedNonEmptyString,
914 cursor: TrimmedNonEmptyString,
915});
916export type PullRequestThreadCommentsInput = typeof PullRequestThreadCommentsInput.Type;
917
918export const PullRequestThreadCommentsResult = Schema.Struct({
919 comments: Schema.Array(PullRequestThreadComment),
920 nextCursor: Schema.NullOr(TrimmedNonEmptyString),
921});
922export type PullRequestThreadCommentsResult = typeof PullRequestThreadCommentsResult.Type;
923
907924export const PullRequestThreadReplyInput = Schema.Struct({
908925 ...PullRequestRef.fields,
909926 threadId: TrimmedNonEmptyString,
packages/contracts/src/rpc.ts+10
......@@ -86,6 +86,8 @@ import {
8686 PullRequestReviewerCandidateList,
8787 PullRequestReviewerRequestInput,
8888 PullRequestSubmitReviewInput,
89 PullRequestThreadCommentsInput,
90 PullRequestThreadCommentsResult,
8991 PullRequestThreadReplyInput,
9092 PullRequestThreadResolutionInput,
9193 PullRequestUnavailableError,
......@@ -281,6 +283,7 @@ export const WS_METHODS = {
281283 pullRequestsListStats: "pullRequests.listStats",
282284 pullRequestsDetail: "pullRequests.detail",
283285 pullRequestsActivity: "pullRequests.activity",
286 pullRequestsThreadComments: "pullRequests.threadComments",
284287 pullRequestsDiffFileContents: "pullRequests.diffFileContents",
285288 pullRequestsRunAction: "pullRequests.runAction",
286289 pullRequestsUpdate: "pullRequests.update",
......@@ -502,6 +505,12 @@ export const WsPullRequestsActivityRpc = Rpc.make(WS_METHODS.pullRequestsActivit
502505 error: PullRequestRpcError,
503506});
504507
508export const WsPullRequestsThreadCommentsRpc = Rpc.make(WS_METHODS.pullRequestsThreadComments, {
509 payload: PullRequestThreadCommentsInput,
510 success: PullRequestThreadCommentsResult,
511 error: PullRequestRpcError,
512});
513
505514export const WsPullRequestsDiffFileContentsRpc = Rpc.make(WS_METHODS.pullRequestsDiffFileContents, {
506515 payload: PullRequestDiffFileContentsInput,
507516 success: PullRequestDiffFileContentsResult,
......@@ -1001,6 +1010,7 @@ export const WsRpcGroup = RpcGroup.make(
10011010 WsPullRequestsListStatsRpc,
10021011 WsPullRequestsDetailRpc,
10031012 WsPullRequestsActivityRpc,
1013 WsPullRequestsThreadCommentsRpc,
10041014 WsPullRequestsDiffFileContentsRpc,
10051015 WsPullRequestsRunActionRpc,
10061016 WsPullRequestsUpdateRpc,