| author | |
| committer | |
| log | 2ce3e4ae74deb63bda7aaf059949a12aaaefed56 |
| tree | 5292f030d633c2e15474f629e79ee9ae464809b1 |
| parent | 49f849ecce3667abab7ce9e6aaa42dfd2e3c45d7 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>11 files changed, 49 insertions(+), 31 deletions(-)
apps/server/src/pullRequest/PullRequestService.test.ts+2| ... | ... | @@ -463,6 +463,8 @@ function fakeProvider( |
| 463 | 463 | setReaction: () => Effect.void, |
| 464 | 464 | listReviewerCandidates: () => Effect.succeed({ candidates: [], truncated: false }), |
| 465 | 465 | setReviewerRequest: () => Effect.void, |
| 466 | // GitHub's own merge message rewrite, which the service reads instead of the kind. | |
| 467 | ...(kind === "github" ? { mergeMessageRewrite: (message: string) => message } : {}), | |
| 466 | 468 | ...overrides, |
| 467 | 469 | }; |
| 468 | 470 | } |
apps/server/src/pullRequest/PullRequestService.ts+12-7| ... | ... | @@ -77,7 +77,6 @@ import { |
| 77 | 77 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; |
| 78 | 78 | import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; |
| 79 | 79 | |
| 80 | import { AllowGitHubReserve } from "@t3tools/source-control-github/server/GitHubApi"; | |
| 81 | 80 | import * as ProjectService from "../project/ProjectService.ts"; |
| 82 | 81 | import * as ServerSettings from "../serverSettings.ts"; |
| 83 | 82 | import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; |
| ... | ... | @@ -557,7 +556,7 @@ function withRateLimitBackoff( |
| 557 | 556 | ), |
| 558 | 557 | Effect.flatMap((lease) => |
| 559 | 558 | effect.pipe( |
| 560 | Effect.provideService(AllowGitHubReserve, allowPaused), | |
| 559 | Effect.provideService(SourceControlRateLimit.Interactive, allowPaused), | |
| 561 | 560 | Effect.tap(() => limits.recordSuccess({ ...key, lease })), |
| 562 | 561 | Effect.tapError((error) => |
| 563 | 562 | error.reason === "rate-limited" |
| ... | ... | @@ -587,6 +586,9 @@ function withRateLimitBackoff( |
| 587 | 586 | const wrapped = { |
| 588 | 587 | kind: api.kind, |
| 589 | 588 | capabilities: api.capabilities, |
| 589 | ...(api.mergeMessageRewrite === undefined | |
| 590 | ? {} | |
| 591 | : { mergeMessageRewrite: api.mergeMessageRewrite }), | |
| 590 | 592 | // Refused during a pause like any other read, except for the caller that asks for the |
| 591 | 593 | // bypass: a lookup that failed is not held, so letting every background read through would |
| 592 | 594 | // spawn this host's CLI on each of them and re-extend the pause it was already in. |
| ... | ... | @@ -1558,8 +1560,10 @@ export const make = Effect.gen(function* () { |
| 1558 | 1560 | )(function* (input) { |
| 1559 | 1561 | const host = input.host.toLowerCase(); |
| 1560 | 1562 | const { supported } = yield* listWorkspaceProjects({ host }); |
| 1561 | const project = supported.find((candidate) => candidate.api.kind === "github"); | |
| 1562 | const api = registry.get("github"); | |
| 1563 | const project = supported.find( | |
| 1564 | (candidate) => registry.get(candidate.api.kind)?.getRoutingIdentity !== undefined, | |
| 1565 | ); | |
| 1566 | const api = project === undefined ? null : registry.get(project.api.kind); | |
| 1563 | 1567 | if (project === undefined || api?.getRoutingIdentity === undefined) { |
| 1564 | 1568 | return yield* new PullRequestUnavailableError({ reason: "provider-unsupported" }); |
| 1565 | 1569 | } |
| ... | ... | @@ -1569,6 +1573,7 @@ export const make = Effect.gen(function* () { |
| 1569 | 1573 | host, |
| 1570 | 1574 | }) |
| 1571 | 1575 | .pipe(Effect.mapError(toPullRequestError("routeIdentity"))); |
| 1576 | // Only GitHub reports a routing identity, and the contract names it. | |
| 1572 | 1577 | return { ...identity, host, provider: "github" as const }; |
| 1573 | 1578 | }); |
| 1574 | 1579 | |
| ... | ... | @@ -1584,7 +1589,7 @@ export const make = Effect.gen(function* () { |
| 1584 | 1589 | detail: "The GitHub account could not be verified before starting the operation.", |
| 1585 | 1590 | }); |
| 1586 | 1591 | const project = yield* requireProject(input).pipe(Effect.mapError(rejected)); |
| 1587 | const api = project.api.kind === "github" ? registry.get("github") : null; | |
| 1592 | const api = registry.get(project.api.kind); | |
| 1588 | 1593 | if ( |
| 1589 | 1594 | api?.withVerifiedCredential === undefined || |
| 1590 | 1595 | input.host?.toLowerCase() !== project.host.toLowerCase() |
| ... | ... | @@ -1605,7 +1610,7 @@ export const make = Effect.gen(function* () { |
| 1605 | 1610 | |
| 1606 | 1611 | const routing = Effect.fn("PullRequestService.routing")(function* (input: PullRequestRef) { |
| 1607 | 1612 | const project = yield* requireProject(input); |
| 1608 | const api = project.api.kind === "github" ? registry.get("github") : null; | |
| 1613 | const api = registry.get(project.api.kind); | |
| 1609 | 1614 | if (api?.getRoutingIdentity === undefined) { |
| 1610 | 1615 | return yield* new PullRequestUnavailableError({ reason: "provider-unsupported" }); |
| 1611 | 1616 | } |
| ... | ... | @@ -2047,7 +2052,7 @@ export const make = Effect.gen(function* () { |
| 2047 | 2052 | ); |
| 2048 | 2053 | } |
| 2049 | 2054 | const mergeSettings = |
| 2050 | project.api.kind === "github" && | |
| 2055 | project.api.mergeMessageRewrite !== undefined && | |
| 2051 | 2056 | input.stackNumber === undefined && |
| 2052 | 2057 | (input.action === "merge" || input.action === "enable-auto-merge") |
| 2053 | 2058 | ? serverSettings.getSettings.pipe( |
packages/source-control-core/src/server/PullRequestProvider.ts+8-1| ... | ... | @@ -345,6 +345,13 @@ export interface PullRequestProviderApi { |
| 345 | 345 | >; |
| 346 | 346 | readonly kind: SourceControlProviderKind; |
| 347 | 347 | readonly capabilities: PullRequestCapabilities; |
| 348 | /** | |
| 349 | * Rewrites the message a merge will use, for a host that lets the merge carry custom text. | |
| 350 | * Absent means the host always writes its own message, so nothing is read to decide on one. | |
| 351 | * Today's only rewrite strips agent credits (`mergeMessage.removeAgentCredits`) when the | |
| 352 | * project asks for it. | |
| 353 | */ | |
| 354 | readonly mergeMessageRewrite?: (message: string) => string; | |
| 348 | 355 | |
| 349 | 356 | /** The signed-in account, which is what involvement filtering compares against. */ |
| 350 | 357 | readonly getViewer: (input: { |
| ... | ... | @@ -561,7 +568,7 @@ export interface PullRequestProviderApi { |
| 561 | 568 | readonly action: PullRequestAction; |
| 562 | 569 | readonly stackNumber?: number; |
| 563 | 570 | readonly expectedStackHeads?: ReadonlyArray<PullRequestStackHead>; |
| 564 | /** GitHub merge message cleanup; ignored by hosts without support. */ | |
| 571 | /** Apply `mergeMessageRewrite` to the merge message; never sent to a host without one. */ | |
| 565 | 572 | readonly removeAgentCreditsOnMerge?: boolean; |
| 566 | 573 | /** Meaningful for `merge` and `enable-auto-merge`; absent takes the host's own default. */ |
| 567 | 574 | readonly mergeMethod?: PullRequestMergeMethod; |
packages/source-control-core/src/server/SourceControlRateLimit.ts+10| ... | ... | @@ -20,6 +20,16 @@ export const CredentialScope = Context.Reference<string>( |
| 20 | 20 | }, |
| 21 | 21 | ); |
| 22 | 22 | |
| 23 | /** | |
| 24 | * Set by interactive callers (a user's read or write, not a background sweep). A host may let | |
| 25 | * requests made under it spend a reserved quota and go through a rate-limit pause: a user acting | |
| 26 | * on a change request should not be refused because a background read exhausted the quota. | |
| 27 | */ | |
| 28 | export const Interactive = Context.Reference<boolean>( | |
| 29 | "@t3tools/source-control-core/server/SourceControlRateLimit/Interactive", | |
| 30 | { defaultValue: () => false }, | |
| 31 | ); | |
| 32 | ||
| 23 | 33 | interface RateLimitKey { |
| 24 | 34 | readonly provider: SourceControlProviderKind; |
| 25 | 35 | readonly host: string; |
packages/source-control-github/src/server/GitHubApi.test.ts+2-2| ... | ... | @@ -275,7 +275,7 @@ describe("GitHubApi", () => { |
| 275 | 275 | retryAt: reset * 1000, |
| 276 | 276 | }); |
| 277 | 277 | // A user's own request may spend the reserve, and GraphQL has a quota of its own. |
| 278 | yield* api.rest(read).pipe(Effect.provideService(GitHubApi.AllowGitHubReserve, true)); | |
| 278 | yield* api.rest(read).pipe(Effect.provideService(SourceControlRateLimit.Interactive, true)); | |
| 279 | 279 | yield* api.graphql({ host: "github.com", operation: "x", query: "query { viewer { id } }" }); |
| 280 | 280 | expect(requests).toHaveLength(3); |
| 281 | 281 | // The reset gives the background its quota back. |
| ... | ... | @@ -344,7 +344,7 @@ describe("GitHubApi", () => { |
| 344 | 344 | method: "PUT", |
| 345 | 345 | path: "repos/acme/web/pulls/7/merge", |
| 346 | 346 | }) |
| 347 | .pipe(Effect.provideService(GitHubApi.AllowGitHubReserve, true)); | |
| 347 | .pipe(Effect.provideService(SourceControlRateLimit.Interactive, true)); | |
| 348 | 348 | expect(merged.status).toBe(200); |
| 349 | 349 | expect(requests).toHaveLength(2); |
| 350 | 350 | }).pipe(Effect.provide(layer)); |
packages/source-control-github/src/server/GitHubApi.ts+3-13| ... | ... | @@ -34,16 +34,6 @@ export const PinnedGitHubCredential = Context.Reference<{ |
| 34 | 34 | defaultValue: () => null, |
| 35 | 35 | }); |
| 36 | 36 | |
| 37 | /** | |
| 38 | * Set by interactive callers (a user's read or write, not a background sweep). Requests made | |
| 39 | * under it may spend the GraphQL reserve and go through a rate-limit pause: a user acting on a | |
| 40 | * pull request should not be refused because a background read exhausted the quota. | |
| 41 | */ | |
| 42 | export const AllowGitHubReserve = Context.Reference<boolean>( | |
| 43 | "@t3tools/source-control-github/server/GitHubApi/AllowGitHubReserve", | |
| 44 | { defaultValue: () => false }, | |
| 45 | ); | |
| 46 | ||
| 47 | 37 | export class GitHubApiRequestError extends Schema.TaggedError<GitHubApiRequestError>()( |
| 48 | 38 | "GitHubApiRequestError", |
| 49 | 39 | { host: Schema.String, operation: Schema.String, cause: Schema.Defect() }, |
| ... | ... | @@ -137,7 +127,7 @@ export interface GitHubRestInput { |
| 137 | 127 | readonly maxResponseBytes?: number; |
| 138 | 128 | /** Defaults to 30 seconds; a whole pull request's patch may need longer. */ |
| 139 | 129 | readonly timeout?: Duration.Input; |
| 140 | /** Overrides `AllowGitHubReserve` for this one request. */ | |
| 130 | /** Overrides `SourceControlRateLimit.Interactive` for this one request. */ | |
| 141 | 131 | readonly allowReserve?: boolean; |
| 142 | 132 | } |
| 143 | 133 | |
| ... | ... | @@ -525,7 +515,7 @@ export const make = Effect.gen(function* () { |
| 525 | 515 | input.body === undefined |
| 526 | 516 | ? withEtag |
| 527 | 517 | : withEtag.pipe(HttpClientRequest.bodyJsonUnsafe(input.body)); |
| 528 | return AllowGitHubReserve.pipe( | |
| 518 | return SourceControlRateLimit.Interactive.pipe( | |
| 529 | 519 | Effect.flatMap((interactive) => |
| 530 | 520 | send({ |
| 531 | 521 | host: input.host, |
| ... | ... | @@ -545,7 +535,7 @@ export const make = Effect.gen(function* () { |
| 545 | 535 | const host = normalizeHost(input.host); |
| 546 | 536 | const { fingerprint } = yield* credential(host); |
| 547 | 537 | const scope = (yield* SourceControlRateLimit.CredentialScope) || fingerprint; |
| 548 | const allowReserve = input.allowReserve ?? (yield* AllowGitHubReserve); | |
| 538 | const allowReserve = input.allowReserve ?? (yield* SourceControlRateLimit.Interactive); | |
| 549 | 539 | // The document, never its variables: user text (bodies, search terms) travels as variables. |
| 550 | 540 | yield* Effect.annotateCurrentSpan({ |
| 551 | 541 | "github.operation": input.operation, |
packages/source-control-github/src/server/GitHubPullRequestApi.test.ts+4-3| ... | ... | @@ -12,7 +12,6 @@ import * as Redacted from "effect/Redacted"; |
| 12 | 12 | |
| 13 | 13 | import { HttpClient, HttpClientResponse } from "effect/http"; |
| 14 | 14 | |
| 15 | import { AllowGitHubReserve } from "./GitHubApi.ts"; | |
| 16 | 15 | import * as GitHubApi from "./GitHubApi.ts"; |
| 17 | 16 | import * as GitHubCredentials from "./GitHubCredentials.ts"; |
| 18 | 17 | import * as GitHubQuota from "./GitHubQuota.ts"; |
| ... | ... | @@ -78,7 +77,7 @@ const mockApi = Layer.effect( |
| 78 | 77 | const quota = yield* GitHubQuota.GitHubQuota; |
| 79 | 78 | return GitHubApi.GitHubApi.of({ |
| 80 | 79 | graphql: (input) => |
| 81 | GitHubApi.AllowGitHubReserve.pipe( | |
| 80 | SourceControlRateLimit.Interactive.pipe( | |
| 82 | 81 | Effect.flatMap((interactive) => |
| 83 | 82 | quota.admit(input.host, "graphql", { |
| 84 | 83 | allowReserve: input.allowReserve ?? interactive, |
| ... | ... | @@ -3864,7 +3863,9 @@ layer("GitHubPullRequestApi.layer", (it) => { |
| 3864 | 3863 | const error = yield* Effect.flip(cli.getPullRequestDetail(input)); |
| 3865 | 3864 | expect(error._tag).toBe("SourceControlRateLimitPausedError"); |
| 3866 | 3865 | expect(mockedExecute).toHaveBeenCalledOnce(); |
| 3867 | yield* cli.getPullRequestDetail(input).pipe(Effect.provideService(AllowGitHubReserve, true)); | |
| 3866 | yield* cli | |
| 3867 | .getPullRequestDetail(input) | |
| 3868 | .pipe(Effect.provideService(SourceControlRateLimit.Interactive, true)); | |
| 3868 | 3869 | expect(mockedExecute).toHaveBeenCalledTimes(2); |
| 3869 | 3870 | }), |
| 3870 | 3871 | ); |
packages/source-control-github/src/server/GitHubPullRequestApi.ts+1-1| ... | ... | @@ -1526,7 +1526,7 @@ export const make = Effect.gen(function* () { |
| 1526 | 1526 | |
| 1527 | 1527 | const getPullRequestDetail: GitHubPullRequestApi["Service"]["getPullRequestDetail"] = (input) => { |
| 1528 | 1528 | const { owner, name } = parseRepositorySelector(input.repository); |
| 1529 | return GitHubApi.AllowGitHubReserve.pipe( | |
| 1529 | return SourceControlRateLimit.Interactive.pipe( | |
| 1530 | 1530 | Effect.flatMap((allowReserve) => |
| 1531 | 1531 | graphqlRead({ |
| 1532 | 1532 | allowReserve, |
packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts+2-1| ... | ... | @@ -7,6 +7,7 @@ import type { PullRequestReaction } from "@t3tools/contracts"; |
| 7 | 7 | |
| 8 | 8 | import { decodePullRequestDetailJson } from "./gitHubPullRequestJson.ts"; |
| 9 | 9 | import * as GitHubApi from "./GitHubApi.ts"; |
| 10 | import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; | |
| 10 | 11 | import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; |
| 11 | 12 | import type { GitHubPullRequestCore } from "./gitHubPullRequestJson.ts"; |
| 12 | 13 | import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts"; |
| ... | ... | @@ -725,7 +726,7 @@ describe("getViewerPermissions", () => { |
| 725 | 726 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ |
| 726 | 727 | revalidateChecks: (_input, read) => read, |
| 727 | 728 | getPullRequestDetail: () => |
| 728 | GitHubApi.AllowGitHubReserve.pipe( | |
| 729 | SourceControlRateLimit.Interactive.pipe( | |
| 729 | 730 | Effect.tap((allowReserve) => Effect.sync(() => onDetail(allowReserve))), |
| 730 | 731 | Effect.flatMap(() => detail), |
| 731 | 732 | ), |
packages/source-control-github/src/server/GitHubPullRequestProvider.ts+4-2| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { removeAgentCredits } from "@t3tools/source-control-core/server/mergeMessage"; | |
| 1 | 2 | import * as Effect from "effect/Effect"; |
| 2 | 3 | import type { |
| 3 | 4 | PullRequestActor, |
| ... | ... | @@ -7,7 +8,7 @@ import type { |
| 7 | 8 | PullRequestViewerPermissions, |
| 8 | 9 | } from "@t3tools/contracts"; |
| 9 | 10 | |
| 10 | import * as GitHubApi from "./GitHubApi.ts"; | |
| 11 | import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; | |
| 11 | 12 | import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; |
| 12 | 13 | import { |
| 13 | 14 | PullRequestProviderError, |
| ... | ... | @@ -262,6 +263,7 @@ export const make = Effect.gen(function* () { |
| 262 | 263 | const provider: PullRequestProviderApi = { |
| 263 | 264 | kind: "github", |
| 264 | 265 | capabilities: CAPABILITIES, |
| 266 | mergeMessageRewrite: removeAgentCredits, | |
| 265 | 267 | getRoutingIdentity: (input) => |
| 266 | 268 | cli.getRoutingIdentity(input).pipe(Effect.mapError(fail("routeIdentity"))), |
| 267 | 269 | withVerifiedCredential: (input, use) => |
| ... | ... | @@ -526,7 +528,7 @@ export const make = Effect.gen(function* () { |
| 526 | 528 | // comparison, so one read usually answers what used to take three. When that heavier read |
| 527 | 529 | // fails, the light access read still answers, withholding only update-branch. |
| 528 | 530 | return cli.getPullRequestDetail(input).pipe( |
| 529 | Effect.provideService(GitHubApi.AllowGitHubReserve, true), | |
| 531 | Effect.provideService(SourceControlRateLimit.Interactive, true), | |
| 530 | 532 | Effect.map((pullRequest) => |
| 531 | 533 | gitHubViewerPermissions({ |
| 532 | 534 | ...pullRequest.viewerAccess, |
packages/source-control-github/src/server/GitHubSourceControlProvider.ts+1-1| ... | ... | @@ -963,7 +963,7 @@ export const make = Effect.gen(function* () { |
| 963 | 963 | }, |
| 964 | 964 | listChangeRequests: (input) => |
| 965 | 965 | // An open lookup is a user waiting on a status; the rest may be a background sweep. |
| 966 | (input.state === "open" ? Effect.succeed(true) : GitHubApi.AllowGitHubReserve).pipe( | |
| 966 | (input.state === "open" ? Effect.succeed(true) : SourceControlRateLimit.Interactive).pipe( | |
| 967 | 967 | Effect.flatMap((allowReserve) => |
| 968 | 968 | listByHead({ |
| 969 | 969 | cwd: input.cwd, |