authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-09-16 01:30:58+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-15 15:30:58-07:00
logf4600d77dd7c2fa9f10e8f4500882e427fcc7e26
tree0cd5bab04d8947624e943639009a70e2fbd9cd64
parentb84f63bb13f13b1d0ee8537c4d244d6f1efdb987
signature Signed by PGP key B5690EEEBB952194

fix: reduce GitHub quota use with sharing enabled (#11888)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

20 files changed, 1020 insertions(+), 273 deletions(-)

apps/server/integration/OrchestrationEngineHarness.integration.ts+1-1
......@@ -352,7 +352,7 @@ export const makeOrchestrationIntegrationHarness = (
352352 Layer.provideMerge(runtimeServicesLayer),
353353 Layer.provideMerge(
354354 Layer.mock(PullRequestService.PullRequestService)({
355 refreshAfterTurn: Effect.void,
355 refreshAfterTurn: () => Effect.void,
356356 }),
357357 ),
358358 Layer.provideMerge(
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+1-1
......@@ -336,7 +336,7 @@ describe("CheckpointReactor", () => {
336336 prefix: "t3-checkpoint-reactor-test-",
337337 });
338338 const pullRequestRefreshes: number[] = [];
339 const refreshAfterTurn = Effect.sync(() => void pullRequestRefreshes.push(1));
339 const refreshAfterTurn = () => Effect.sync(() => void pullRequestRefreshes.push(1));
340340 const vcsStatusBroadcasterLayer = Layer.succeed(VcsStatusBroadcaster, {
341341 getStatus: () => Effect.die("getStatus should not be called in this test"),
342342 refreshLocalStatus: (cwd: string) =>
apps/server/src/orchestration/Layers/CheckpointReactor.ts+1-1
......@@ -886,7 +886,7 @@ const make = Effect.gen(function* () {
886886 (startedTurnId === undefined && !thread.session?.activeTurnId))
887887 ) {
888888 pending.delete(event.threadId);
889 yield* pullRequests.refreshAfterTurn;
889 yield* pullRequests.refreshAfterTurn(thread.projectId);
890890 }
891891 if (
892892 event.type === "turn.aborted" &&
apps/server/src/pullRequest/GitHubPullRequestCli.test.ts+2
......@@ -9,6 +9,7 @@ import { ChildProcessSpawner } from "effect/unstable/process";
99
1010import * as GitHubCli from "../sourceControl/GitHubCli.ts";
1111import * as VcsProcess from "../vcs/VcsProcess.ts";
12import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts";
1213import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts";
1314import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";
1415import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts";
......@@ -202,6 +203,7 @@ it.effect(
202203 let activeToken = "broad-credential";
203204 const commands: VcsProcess.VcsProcessInput[] = [];
204205 const github = yield* GitHubCli.make.pipe(
206 Effect.provide(Layer.merge(GitHubGraphQlBudget.layer, SourceControlRateLimit.layer)),
205207 Effect.provideService(VcsProcess.VcsProcess, {
206208 run: (input) =>
207209 Effect.sync(() => {
apps/server/src/pullRequest/GitHubPullRequestCli.ts+1
......@@ -1106,6 +1106,7 @@ export const make = Effect.gen(function* () {
11061106 captureVerifiedCredential(input).pipe(
11071107 Effect.flatMap(({ host, token, accountId, viewer, credentialFingerprint }) =>
11081108 use({ accountId, viewer, credentialFingerprint }).pipe(
1109 Effect.provideService(SourceControlRateLimit.CredentialScope, credentialFingerprint),
11091110 Effect.provideService(GitHubCli.PinnedGitHubCredential, {
11101111 host,
11111112 token,
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+5-1
......@@ -109,7 +109,11 @@ export function gitHubProviderFailure(
109109): PullRequestProviderFailure {
110110 if (error._tag === "GitHubCliUnavailableError") return { reason: "missing-tool" };
111111 if (error._tag === "GitHubCliAuthenticationError") return { reason: "unauthenticated" };
112 if (error._tag === "GitHubCliRateLimitError") return { reason: "rate-limited" };
112 if (error._tag === "GitHubCliRateLimitError")
113 return {
114 reason: "rate-limited",
115 ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }),
116 };
113117 if (error._tag === "SourceControlRateLimitPausedError") {
114118 return { reason: "rate-limited", retryAt: error.retryAt };
115119 }
apps/server/src/pullRequest/PullRequestReadCache.test.ts+175-9
......@@ -5,18 +5,12 @@ import * as Deferred from "effect/Deferred";
55import * as Effect from "effect/Effect";
66import * as Fiber from "effect/Fiber";
77import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
98import * as TestClock from "effect/testing/TestClock";
109import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore";
11import * as Persistence from "effect/unstable/persistence/Persistence";
1210import * as PullRequestReadCache from "./PullRequestReadCache.ts";
1311
1412const cacheLayer = (directory: string) =>
15 PullRequestReadCache.make.pipe(
16 Effect.provide(
17 Persistence.layerKvs.pipe(Layer.provideMerge(KeyValueStore.layerFileSystem(directory))),
18 ),
19 );
13 PullRequestReadCache.make.pipe(Effect.provide(KeyValueStore.layerFileSystem(directory)));
2014
2115it.layer(NodeServices.layer)("PR filesystem cache", (it) => {
2216 it.effect("reuses files after restart and respects the original expiry", () =>
......@@ -52,15 +46,106 @@ it.layer(NodeServices.layer)("PR filesystem cache", (it) => {
5246 Effect.andThen(Deferred.await(release)),
5347 Effect.as("old"),
5448 ),
49 ["pr"],
5550 )
5651 .pipe(Effect.forkChild);
5752 yield* Deferred.await(started);
58 const invalidate = yield* cache.invalidate.pipe(Effect.forkChild({ startImmediately: true }));
53 const invalidate = yield* cache
54 .invalidate("pr")
55 .pipe(Effect.forkChild({ startImmediately: true }));
5956 yield* Deferred.succeed(release, undefined);
6057 yield* Fiber.join(read);
6158 yield* Fiber.join(invalidate);
6259 const restarted = yield* cacheLayer(directory);
63 assert.strictEqual(yield* restarted.get("summary", Effect.succeed("new")), "new");
60 assert.strictEqual(yield* restarted.get("summary", Effect.succeed("new"), ["pr"]), "new");
61 }),
62 );
63
64 it.effect("invalidates only the changed scope across restarts and coalesces its next reads", () =>
65 Effect.gen(function* () {
66 const fs = yield* FileSystem.FileSystem;
67 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pr-cache-" });
68 let reads = 0;
69 const lookup = Effect.sync(() => String(++reads));
70 const cache = yield* cacheLayer(directory);
71 yield* cache.get("first", lookup, ["project", "pr-1"]);
72 yield* cache.get("second", lookup, ["project", "pr-2"]);
73 yield* cache.get("third", lookup, ["other-project", "pr-3"]);
74 yield* cache.invalidate("pr-1");
75 const restarted = yield* cacheLayer(directory);
76 const answers = yield* Effect.all(
77 Array.from({ length: 10 }, () => restarted.get("first", lookup, ["project", "pr-1"])),
78 { concurrency: 10 },
79 );
80 assert.deepStrictEqual(answers, Array(10).fill("4"));
81 assert.strictEqual(yield* restarted.get("second", lookup, ["project", "pr-2"]), "2");
82 yield* restarted.invalidate("project");
83 const again = yield* cacheLayer(directory);
84 assert.strictEqual(yield* again.get("second", lookup, ["project", "pr-2"]), "5");
85 assert.strictEqual(yield* again.get("third", lookup, ["other-project", "pr-3"]), "3");
86 assert.strictEqual(reads, 5);
87 const files = (yield* fs.readDirectory(directory)).length;
88 for (let index = 0; index < 3; index++) {
89 yield* again.invalidate("pr-1");
90 yield* again.get("first", lookup, ["project", "pr-1"]);
91 }
92 assert.strictEqual((yield* fs.readDirectory(directory)).length, files);
93 }),
94 );
95
96 it.effect("shares a pending refresh without blocking an unrelated cached PR", () =>
97 Effect.gen(function* () {
98 const cache = yield* PullRequestReadCache.make;
99 yield* cache.get("first", Effect.succeed("old"), ["pr-1"]);
100 yield* cache.get("second", Effect.succeed("warm"), ["pr-2"]);
101 yield* cache.invalidate("pr-1");
102 const started = yield* Deferred.make<void>();
103 const release = yield* Deferred.make<void>();
104 let reads = 0;
105 const refresh = cache.get(
106 "first",
107 Effect.gen(function* () {
108 reads++;
109 yield* Deferred.succeed(started, undefined);
110 yield* Deferred.await(release);
111 return "fresh";
112 }),
113 ["pr-1"],
114 );
115 const pending = yield* Effect.all(
116 Array.from({ length: 10 }, () => refresh),
117 {
118 concurrency: 10,
119 },
120 ).pipe(Effect.forkChild);
121 yield* Deferred.await(started);
122 assert.strictEqual(yield* cache.get("second", Effect.die("cache miss"), ["pr-2"]), "warm");
123 yield* Deferred.succeed(release, undefined);
124 assert.deepStrictEqual(yield* Fiber.join(pending), Array(10).fill("fresh"));
125 assert.strictEqual(reads, 1);
126 }).pipe(Effect.provide(KeyValueStore.layerMemory)),
127 );
128
129 it.effect("compacts expired scope records without discarding fresh PR data", () =>
130 Effect.gen(function* () {
131 const fs = yield* FileSystem.FileSystem;
132 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pr-cache-" });
133 const cache = yield* cacheLayer(directory);
134 yield* cache.get("summary", Effect.succeed("old"), ["pr"]);
135 yield* cache.invalidate("pr");
136 for (let index = 0; index < 100; index++) yield* cache.invalidate(`pr-${index}`);
137 assert.strictEqual((yield* fs.readDirectory(directory)).length, 2);
138 const before = (yield* fs.stat(`${directory}/revisions`)).size;
139 yield* TestClock.adjust("59 seconds");
140 assert.strictEqual(yield* cache.get("summary", Effect.succeed("fresh"), ["pr"]), "fresh");
141 yield* TestClock.adjust("1 second");
142 yield* cache.invalidate("other-pr");
143 assert.isTrue((yield* fs.stat(`${directory}/revisions`)).size < before);
144 const restarted = yield* cacheLayer(directory);
145 assert.strictEqual(
146 yield* restarted.get("summary", Effect.die("cache miss"), ["pr"]),
147 "fresh",
148 );
64149 }),
65150 );
66151
......@@ -75,4 +160,85 @@ it.layer(NodeServices.layer)("PR filesystem cache", (it) => {
75160 assert.strictEqual(yield* restarted.get("summary", Effect.succeed("recovered")), "recovered");
76161 }),
77162 );
163
164 it.effect("resumes caching after a failed scope read", () =>
165 Effect.gen(function* () {
166 const backing = yield* KeyValueStore.KeyValueStore;
167 let fail = true;
168 let reads = 0;
169 const cache = yield* PullRequestReadCache.make.pipe(
170 Effect.provideService(KeyValueStore.KeyValueStore, {
171 ...backing,
172 get: (key) =>
173 Effect.suspend(() => {
174 if (!fail) return backing.get(key);
175 fail = false;
176 return Effect.fail(
177 new KeyValueStore.KeyValueStoreError({ method: "get", message: "unavailable" }),
178 );
179 }),
180 }),
181 );
182 const read = cache.get(
183 "summary",
184 Effect.sync(() => String(++reads)),
185 ["pr"],
186 );
187 assert.strictEqual(yield* read, "1");
188 assert.strictEqual(yield* read, "2");
189 assert.strictEqual(yield* read, "2");
190 }).pipe(Effect.provide(KeyValueStore.layerMemory)),
191 );
192
193 it.effect("cancels abandoned reads without blocking invalidation", () =>
194 Effect.gen(function* () {
195 const cache = yield* PullRequestReadCache.make;
196 const started = yield* Deferred.make<void>();
197 const read = yield* cache
198 .get("summary", Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)), [
199 "pr",
200 ])
201 .pipe(Effect.forkChild);
202 yield* Deferred.await(started);
203 yield* Fiber.interrupt(read);
204 yield* cache.invalidate("pr");
205 assert.strictEqual(yield* cache.get("summary", Effect.succeed("fresh"), ["pr"]), "fresh");
206 }).pipe(Effect.provide(KeyValueStore.layerMemory)),
207 );
208
209 it.effect(
210 "finishes the in-memory revision update when invalidation is canceled after writing",
211 () =>
212 Effect.gen(function* () {
213 const backing = yield* KeyValueStore.KeyValueStore;
214 const written = yield* Deferred.make<void>();
215 const release = yield* Deferred.make<void>();
216 const cache = yield* PullRequestReadCache.make.pipe(
217 Effect.provideService(KeyValueStore.KeyValueStore, {
218 ...backing,
219 set: (key, value) =>
220 backing
221 .set(key, value)
222 .pipe(
223 Effect.andThen(
224 key === "revisions"
225 ? Deferred.succeed(written, undefined).pipe(
226 Effect.andThen(Deferred.await(release)),
227 )
228 : Effect.void,
229 ),
230 ),
231 }),
232 );
233 yield* cache.get("summary", Effect.succeed("old"), ["pr"]);
234 const invalidation = yield* cache.invalidate("pr").pipe(Effect.forkChild);
235 yield* Deferred.await(written);
236 const interrupt = yield* Fiber.interrupt(invalidation).pipe(
237 Effect.forkChild({ startImmediately: true }),
238 );
239 yield* Deferred.succeed(release, undefined);
240 yield* Fiber.join(interrupt);
241 assert.strictEqual(yield* cache.get("summary", Effect.succeed("fresh"), ["pr"]), "fresh");
242 }).pipe(Effect.provide(KeyValueStore.layerMemory)),
243 );
78244});
apps/server/src/pullRequest/PullRequestReadCache.ts+92-34
......@@ -5,7 +5,6 @@ import * as Hash from "effect/Hash";
55import { PullRequestOperationError, PullRequestUnavailableError } from "@t3tools/contracts";
66import * as Crypto from "effect/Crypto";
77import * as Encoding from "effect/Encoding";
8import * as Option from "effect/Option";
98import * as Context from "effect/Context";
109import * as Duration from "effect/Duration";
1110import * as Effect from "effect/Effect";
......@@ -22,19 +21,35 @@ import { ServerConfig } from "../config.ts";
2221
2322const CONCURRENT_READS = 512;
2423type ReadError = PullRequestOperationError | PullRequestUnavailableError;
25
24const revisionCodec = Schema.fromJsonString(
25 Schema.Record(
26 Schema.String,
27 Schema.Struct({ revision: Schema.String, expiresAt: Schema.Finite }),
28 ),
29);
2630class Read extends Persistable.Class<{
27 payload: { key: string; lookup: Effect.Effect<string, ReadError> };
31 payload: { key: string; revision: string; lookup: Effect.Effect<string, ReadError> };
2832}>()("PullRequestRead", {
2933 primaryKey: ({ key }) => key,
30 success: Schema.Struct({ payload: Schema.String, expiresAt: Schema.Finite }),
34 success: Schema.Struct({
35 payload: Schema.String,
36 expiresAt: Schema.Finite,
37 revision: Schema.optionalKey(Schema.String),
38 }),
3139 error: Schema.Union([PullRequestOperationError, PullRequestUnavailableError]),
3240}) {
41 matchesRevision(revision: string | undefined): boolean {
42 const stored = revision?.split(":") ?? [];
43 return this.revision
44 .split(":")
45 .every((value, index) => value === "" || value === stored[index]);
46 }
47
3348 [Equal.symbol](that: unknown): boolean {
34 return that instanceof Read && that.key === this.key;
49 return that instanceof Read && that.key === this.key && that.revision === this.revision;
3550 }
3651 [Hash.symbol](): number {
37 return Hash.string(this.key);
52 return Hash.string(`${this.key}:${this.revision}`);
3853 }
3954}
4055
......@@ -44,8 +59,9 @@ export class PullRequestReadCache extends Context.Service<
4459 readonly get: (
4560 key: string,
4661 lookup: Effect.Effect<string, ReadError>,
62 scopes?: ReadonlyArray<string>,
4763 ) => Effect.Effect<string, ReadError>;
48 readonly invalidate: Effect.Effect<void>;
64 readonly invalidate: (scope: string) => Effect.Effect<void>;
4965 }
5066>()("t3/pullRequest/PullRequestReadCache") {}
5167
......@@ -55,6 +71,18 @@ export const make = Effect.gen(function* () {
5571 const clock = yield* Clock.Clock;
5672 let enabled = true;
5773 const lock = yield* Semaphore.make(CONCURRENT_READS);
74 const digest = (key: string) =>
75 crypto.digest("SHA-256", new TextEncoder().encode(key)).pipe(Effect.map(Encoding.encodeHex));
76 const revisions = yield* Cache.makeWith(
77 () =>
78 backing
79 .get("revisions")
80 .pipe(Effect.flatMap((raw) => Schema.decodeUnknownEffect(revisionCodec)(raw ?? "{}"))),
81 {
82 capacity: 1,
83 timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero),
84 },
85 );
5886 const timeToLive: Persistable.TimeToLiveFn<Read> = (exit) =>
5987 Exit.isSuccess(exit)
6088 ? Duration.millis(Math.max(0, exit.value.expiresAt - clock.currentTimeMillisUnsafe()))
......@@ -62,7 +90,11 @@ export const make = Effect.gen(function* () {
6290 const cache = yield* PersistedCache.make(
6391 (request: Read) =>
6492 request.lookup.pipe(
65 Effect.map((payload) => ({ payload, expiresAt: clock.currentTimeMillisUnsafe() + 60_000 })),
93 Effect.map((payload) => ({
94 payload,
95 expiresAt: clock.currentTimeMillisUnsafe() + 60_000,
96 revision: request.revision,
97 })),
6698 ),
6799 {
68100 storeId: "pr-v2",
......@@ -70,36 +102,63 @@ export const make = Effect.gen(function* () {
70102 inMemoryTTL: timeToLive,
71103 inMemoryCapacity: CONCURRENT_READS,
72104 },
105 ).pipe(Effect.provide(Persistence.layerKvs));
106 const refreshes = yield* Cache.makeWith(
107 Effect.fn("PullRequestReadCache.refresh")(function* (request: Read) {
108 const stored = yield* cache.get(request);
109 if (request.matchesRevision(stored.revision)) return stored;
110 yield* cache.invalidate(request);
111 return yield* cache.get(request);
112 }),
113 { capacity: CONCURRENT_READS, timeToLive: () => Duration.zero },
73114 );
74115 return PullRequestReadCache.of({
75 get: Effect.fn("PullRequestReadCache.get")(function* (key, lookup) {
116 get: Effect.fn("PullRequestReadCache.get")(function* (key, lookup, scopes = []) {
76117 if (!enabled) return yield* lookup;
77 const digest = yield* crypto
78 .digest("SHA-256", new TextEncoder().encode(key))
79 .pipe(Effect.option);
80 if (Option.isNone(digest)) return yield* lookup;
81118 const read = yield* Effect.cached(lookup);
82 return yield* cache
83 .get(new Read({ key: Encoding.encodeHex(digest.value), lookup: read }))
84 .pipe(
85 Effect.map((result) => result.payload),
86 Effect.catchTags({
87 PersistenceError: () => read,
88 SchemaError: () => read,
89 }),
90 Effect.uninterruptible,
91 lock.withPermits(1),
92 );
119 return yield* Effect.gen(function* () {
120 const current = yield* Cache.get(revisions, undefined);
121 const now = clock.currentTimeMillisUnsafe();
122 const revision = scopes
123 .map((scope) => {
124 const value = current[scope];
125 return value !== undefined && value.expiresAt > now ? value.revision : "";
126 })
127 .join(":");
128 const request = new Read({ key: yield* digest(key), revision, lookup: read });
129 const stored = yield* cache.get(request);
130 return (
131 request.matchesRevision(stored.revision) ? stored : yield* Cache.get(refreshes, request)
132 ).payload;
133 }).pipe(
134 Effect.catchTags({
135 PlatformError: () => read,
136 KeyValueStoreError: () => read,
137 PersistenceError: () => read,
138 SchemaError: () => read,
139 }),
140 lock.withPermits(1),
141 );
93142 }),
94 // Let existing reads finish before clearing, so they cannot repopulate stale entries.
95 invalidate: Cache.invalidateAll(cache.inMemory).pipe(
96 Effect.andThen(backing.clear),
97 Effect.catch(() => {
98 enabled = false;
99 return Effect.logWarning("PR cache disabled after clearing failed");
100 }),
101 lock.withPermits(CONCURRENT_READS),
102 ),
143 invalidate: (scope) =>
144 Effect.gen(function* () {
145 const now = clock.currentTimeMillisUnsafe();
146 const current = yield* Cache.get(revisions, undefined);
147 const next = Object.fromEntries(
148 Object.entries(current).filter(([, value]) => value.expiresAt > now),
149 );
150 next[scope] = { revision: yield* crypto.randomUUIDv4, expiresAt: now + 60_000 };
151 const encoded = yield* Schema.encodeEffect(revisionCodec)(next);
152 yield* backing.set("revisions", encoded);
153 yield* Cache.set(revisions, undefined, next);
154 }).pipe(
155 Effect.catch(() => {
156 enabled = false;
157 return Effect.logWarning("PR cache disabled after clearing failed");
158 }),
159 Effect.uninterruptible,
160 lock.withPermits(CONCURRENT_READS),
161 ),
103162 });
104163});
105164
......@@ -108,7 +167,6 @@ export const layer = Layer.unwrap(
108167 const config = yield* ServerConfig;
109168 const path = yield* Path.Path;
110169 return Layer.effect(PullRequestReadCache, make).pipe(
111 Layer.provide(Persistence.layerKvs),
112170 Layer.provide(
113171 KeyValueStore.layerFileSystem(
114172 path.join(config.providerStatusCacheDir, "pull-requests"),
apps/server/src/pullRequest/PullRequestService.test.ts+227-98
......@@ -1,6 +1,5 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
22import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore";
3import * as Persistence from "effect/unstable/persistence/Persistence";
43import { assert, it } from "@effect/vitest";
54import * as Deferred from "effect/Deferred";
65import * as Effect from "effect/Effect";
......@@ -204,7 +203,6 @@ function makeService(input: {
204203 }),
205204 SourceControlRateLimit.layer,
206205 Layer.effect(PullRequestReadCache.PullRequestReadCache, PullRequestReadCache.make).pipe(
207 Layer.provide(Persistence.layerKvs),
208206 Layer.provide(KeyValueStore.layerMemory),
209207 Layer.provide(NodeServices.layer),
210208 ),
......@@ -3117,6 +3115,107 @@ it.effect("a listing narrowed to some projects is its own cache entry", () =>
31173115 }),
31183116);
31193117
3118it.effect("keeps unrelated PRs warm after a mutation, explicit refresh, and project turn", () =>
3119 Effect.gen(function* () {
3120 const calls: string[] = [];
3121 const service = yield* makeService({
3122 projects: [
3123 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
3124 project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }),
3125 ],
3126 providers: [
3127 fakeProvider("github", {
3128 getChangeRequest: (input) =>
3129 Effect.sync(() => {
3130 calls.push(`${input.repository}/${input.number}`);
3131 return { ...hostedChangeRequest("body"), number: input.number };
3132 }),
3133 }),
3134 ],
3135 });
3136 const refs = [
3137 { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 },
3138 { projectId: "p1" as ProjectId, repository: "acme/web", number: 2 },
3139 { projectId: "p2" as ProjectId, repository: "acme/docs", number: 3 },
3140 ];
3141 const readAll = Effect.forEach(refs, (ref) => service.summary({ ...ref, allowStale: false }));
3142 yield* readAll;
3143 yield* service.invalidate({ reference: { ...refs[0]!, host: "github.com" } });
3144 yield* readAll;
3145 assert.deepStrictEqual(calls, ["acme/web/1", "acme/web/2", "acme/docs/3", "acme/web/1"]);
3146 yield* service.comment({ ...refs[0]!, body: "hello" });
3147 yield* readAll;
3148 assert.deepStrictEqual(calls.slice(4), ["acme/web/1"]);
3149 yield* service.refreshAfterTurn("p1" as ProjectId);
3150 yield* readAll;
3151 assert.deepStrictEqual(calls.slice(5), ["acme/web/1", "acme/web/2"]);
3152 }),
3153);
3154
3155it.effect(
3156 "keeps matching PR numbers on different hosts separate and refreshes the serving project",
3157 () =>
3158 Effect.gen(function* () {
3159 const hosts: string[] = [];
3160 const service = yield* makeService({
3161 projects: [
3162 project({ id: "p1", title: "public", workspaceRoot: "/a", repository: "acme/web" }),
3163 project({
3164 id: "p2",
3165 title: "enterprise",
3166 workspaceRoot: "/b",
3167 repository: "acme/web",
3168 host: "enterprise.test",
3169 }),
3170 ],
3171 providers: [
3172 fakeProvider("github", {
3173 getChangeRequest: (input) =>
3174 Effect.sync(() => {
3175 hosts.push(input.host);
3176 return hostedChangeRequest("body");
3177 }),
3178 }),
3179 ],
3180 });
3181 const own = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
3182 const other = { ...own, host: "enterprise.test" };
3183 const readBoth = Effect.all([service.summary(own), service.summary(other)]);
3184 yield* readBoth;
3185 yield* service.invalidate({ reference: { ...own, host: "github.com" } });
3186 yield* readBoth;
3187 assert.deepStrictEqual(hosts, ["github.com", "enterprise.test", "github.com"]);
3188 yield* service.invalidate({ reference: own });
3189 yield* readBoth;
3190 assert.deepStrictEqual(hosts.slice(3), ["github.com"]);
3191 yield* service.refreshAfterTurn("p2" as ProjectId);
3192 yield* readBoth;
3193 assert.deepStrictEqual(hosts.slice(4), ["enterprise.test"]);
3194 }),
3195);
3196
3197it.effect("does not revive old summaries when project epochs are evicted", () =>
3198 Effect.gen(function* () {
3199 let title = "old";
3200 const service = yield* makeService({
3201 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
3202 providers: [
3203 fakeProvider("github", {
3204 getChangeRequest: () => Effect.succeed({ ...hostedChangeRequest("body"), title }),
3205 }),
3206 ],
3207 });
3208 const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
3209 assert.strictEqual((yield* service.summary(ref))?.title, "old");
3210 title = "new";
3211 yield* service.refreshAfterTurn(ref.projectId);
3212 assert.strictEqual((yield* service.summary(ref))?.title, "new");
3213 for (let index = 0; index < 2048; index++)
3214 yield* service.refreshAfterTurn(`project-${index}` as ProjectId);
3215 assert.strictEqual((yield* service.summary(ref))?.title, "new");
3216 }),
3217);
3218
31203219it.effect("explicit and turn invalidations make the next listing ask the host again", () =>
31213220 Effect.gen(function* () {
31223221 let hostCalls = 0;
......@@ -3148,7 +3247,7 @@ it.effect("explicit and turn invalidations make the next listing ask the host ag
31483247 yield* service.invalidate({ reference });
31493248 yield* service.list({ state: "open" });
31503249 assert.strictEqual(hostCalls, 2);
3151 yield* service.refreshAfterTurn;
3250 yield* service.refreshAfterTurn("p1" as ProjectId);
31523251 const refresh = Option.getOrThrow(yield* Stream.runHead(service.subscribeRefreshes));
31533252 yield* service.list({ state: "open" });
31543253 assert.isAbove(refresh, 0);
......@@ -3551,7 +3650,7 @@ it.effect(
35513650 yield* service.listStats({ refs: [ref(1), ref(2), ref(3)] });
35523651 assert.deepStrictEqual(asked, [[1, 2], [3], [2], [1, 2, 3]]);
35533652
3554 yield* service.refreshAfterTurn;
3653 yield* service.refreshAfterTurn("p1" as ProjectId);
35553654 yield* service.listStats({ refs: [ref(1)] });
35563655 assert.deepStrictEqual(asked, [[1, 2], [3], [2], [1, 2, 3], [1]]);
35573656
......@@ -3768,9 +3867,9 @@ it.effect("shares linked summaries and reuses them for display without asking th
37683867 }),
37693868);
37703869
3771it.effect("keeps routed summaries and details separate when the GitHub account changes", () =>
3870it.effect("keeps routed reads separate when the GitHub account changes", () =>
37723871 Effect.gen(function* () {
3773 for (const operation of ["summary", "detail"] as const) {
3872 for (const operation of ["summary", "detail", "diff"] as const) {
37743873 let failing = false;
37753874 let calls = 0;
37763875 const read = () =>
......@@ -3785,16 +3884,27 @@ it.effect("keeps routed summaries and details separate when the GitHub account c
37853884 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
37863885 ],
37873886 providers: [
3788 fakeProvider("github", { getChangeRequestSummary: read, getChangeRequest: read }),
3887 fakeProvider("github", {
3888 getChangeRequestSummary: read,
3889 getChangeRequest: read,
3890 getDiff: () =>
3891 read().pipe(
3892 Effect.as({ patch: "private patch", truncated: false, nextCursor: null }),
3893 ),
3894 }),
37893895 ],
37903896 });
3897 const readOperation = (input: Parameters<typeof service.diff>[0]) =>
3898 Effect.gen(function* () {
3899 yield* service[operation](input);
3900 });
37913901 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
3792 yield* service[operation]({ ...reference, expectedAccountId: "101" });
3902 yield* readOperation({ ...reference, expectedAccountId: "101" });
37933903 failing = true;
37943904
37953905 for (const allowStale of [false, true]) {
37963906 const error = yield* Effect.flip(
3797 service[operation]({ ...reference, expectedAccountId: "202", allowStale }),
3907 readOperation({ ...reference, expectedAccountId: "202", allowStale }),
37983908 );
37993909 assert.strictEqual(error._tag, "PullRequestOperationError");
38003910 }
......@@ -3805,7 +3915,7 @@ it.effect("keeps routed summaries and details separate when the GitHub account c
38053915
38063916it.effect("isolates routed caches for two credentials belonging to the same account", () =>
38073917 Effect.gen(function* () {
3808 for (const operation of ["summary", "detail"] as const) {
3918 for (const operation of ["summary", "detail", "diff"] as const) {
38093919 let credential = "broad";
38103920 let calls = 0;
38113921 const read = () =>
......@@ -3831,9 +3941,17 @@ it.effect("isolates routed caches for two credentials belonging to the same acco
38313941 ),
38323942 getChangeRequest: read,
38333943 getChangeRequestSummary: read,
3944 getDiff: () =>
3945 read().pipe(
3946 Effect.as({ patch: "private patch", truncated: false, nextCursor: null }),
3947 ),
38343948 }),
38353949 ],
38363950 });
3951 const readOperation = (input: Parameters<typeof service.diff>[0]) =>
3952 Effect.gen(function* () {
3953 yield* service[operation](input);
3954 });
38373955 const reference = {
38383956 projectId: "p1" as ProjectId,
38393957 repository: "acme/web",
......@@ -3841,14 +3959,11 @@ it.effect("isolates routed caches for two credentials belonging to the same acco
38413959 host: "github.com",
38423960 expectedAccountId: "101",
38433961 };
3844 yield* service.withRoutingCredential(reference, service[operation](reference));
3962 yield* service.withRoutingCredential(reference, readOperation(reference));
38453963 credential = "restricted";
38463964 for (const allowStale of [false, true]) {
38473965 const error = yield* Effect.flip(
3848 service.withRoutingCredential(
3849 reference,
3850 service[operation]({ ...reference, allowStale }),
3851 ),
3966 service.withRoutingCredential(reference, readOperation({ ...reference, allowStale })),
38523967 );
38533968 assert.strictEqual(error._tag, "PullRequestOperationError");
38543969 }
......@@ -4354,90 +4469,104 @@ it.effect('resolves an author filter of "me" to the viewer before narrowing a ho
43544469 }),
43554470);
43564471
4357it.effect("authorizes stack rebases independently of whether the selected layer is behind", () =>
4358 Effect.gen(function* () {
4359 let taken = 0;
4360 let summaryReads = 0;
4361 let mutationFails = false;
4362 let stackRebase = true;
4363 let stackActions = true;
4364 const capabilities = {
4365 diff: true,
4366 comment: true,
4367 actions: ["update-branch"] as const,
4368 mergeMethods: ["merge"] as const,
4369 updateMethods: ["rebase"] as const,
4370 get stackActions() {
4371 return stackActions;
4372 },
4373 search: true,
4374 reactions: true,
4375 review: FULL_REVIEW,
4376 reviewers: FULL_REVIEWERS,
4377 };
4378 const service = yield* makeService({
4379 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
4380 providers: [
4381 fakeProvider("github", {
4382 capabilities,
4383 getViewerPermissions: () =>
4384 Effect.succeed({
4385 actions: [],
4386 stackRebase,
4387 comment: true,
4388 resolve: false,
4389 verdicts: [],
4390 requestReviewers: false,
4391 }),
4392 getChangeRequestSummary: () =>
4393 Effect.sync(() => {
4394 summaryReads++;
4395 return changeRequest(8, "2026-07-01T00:00:00Z");
4472for (const crossHost of [false, true]) {
4473 it.effect(
4474 `authorizes stack rebases and refreshes sibling layers (cross-host: ${crossHost})`,
4475 () =>
4476 Effect.gen(function* () {
4477 let taken = 0;
4478 let summaryReads = 0;
4479 let mutationFails = false;
4480 let stackRebase = true;
4481 let stackActions = true;
4482 const capabilities = {
4483 diff: true,
4484 comment: true,
4485 actions: ["update-branch"] as const,
4486 mergeMethods: ["merge"] as const,
4487 updateMethods: ["rebase"] as const,
4488 get stackActions() {
4489 return stackActions;
4490 },
4491 search: true,
4492 reactions: true,
4493 review: FULL_REVIEW,
4494 reviewers: FULL_REVIEWERS,
4495 };
4496 const service = yield* makeService({
4497 projects: [
4498 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
4499 project({
4500 id: "p2",
4501 title: "enterprise",
4502 workspaceRoot: "/b",
4503 repository: "acme/web",
4504 host: "enterprise.test",
43964505 }),
4397 runAction: () =>
4398 Effect.gen(function* () {
4399 taken++;
4400 if (mutationFails) return yield* requestFailed;
4506 ],
4507 providers: [
4508 fakeProvider("github", {
4509 capabilities,
4510 getViewerPermissions: () =>
4511 Effect.succeed({
4512 actions: [],
4513 stackRebase,
4514 comment: true,
4515 resolve: false,
4516 verdicts: [],
4517 requestReviewers: false,
4518 }),
4519 getChangeRequestSummary: () =>
4520 Effect.sync(() => {
4521 summaryReads++;
4522 return changeRequest(8, "2026-07-01T00:00:00Z");
4523 }),
4524 runAction: () =>
4525 Effect.gen(function* () {
4526 taken++;
4527 if (mutationFails) return yield* requestFailed;
4528 }),
44014529 }),
4402 }),
4403 ],
4404 });
4405 const input = {
4406 projectId: "p1" as ProjectId,
4407 repository: "acme/web",
4408 number: 3,
4409 action: "update-branch" as const,
4410 updateMethod: "rebase" as const,
4411 stackNumber: 50,
4412 expectedStackHeads: [{ number: 3, headSha: "ccc" }],
4413 };
4414 yield* service.runAction(input);
4415 assert.strictEqual(taken, 1);
4416 const unrelated = { ...input, number: 8 };
4417 yield* service.summary(unrelated);
4418 assert.strictEqual(summaryReads, 1);
4419 stackRebase = false;
4420 assert.strictEqual(
4421 (yield* Effect.flip(service.runAction(input)))._tag,
4422 "PullRequestOperationError",
4423 );
4424 stackRebase = true;
4425 stackActions = false;
4426 assert.strictEqual(
4427 (yield* Effect.flip(service.runAction(input)))._tag,
4428 "PullRequestOperationError",
4429 );
4430 assert.strictEqual(taken, 1);
4431 yield* service.summary(unrelated);
4432 assert.strictEqual(summaryReads, 1);
4433 stackActions = true;
4434 mutationFails = true;
4435 yield* Effect.flip(service.runAction(input));
4436 assert.strictEqual(taken, 2);
4437 yield* service.summary(unrelated);
4438 assert.strictEqual(summaryReads, 2);
4439 }),
4440);
4530 ],
4531 });
4532 const input = {
4533 ...(crossHost ? { host: "enterprise.test" } : {}),
4534 projectId: "p1" as ProjectId,
4535 repository: "acme/web",
4536 number: 3,
4537 action: "update-branch" as const,
4538 updateMethod: "rebase" as const,
4539 stackNumber: 50,
4540 expectedStackHeads: [{ number: 3, headSha: "ccc" }],
4541 };
4542 yield* service.runAction(input);
4543 assert.strictEqual(taken, 1);
4544 const unrelated = { ...input, number: 8 };
4545 yield* service.summary(unrelated);
4546 assert.strictEqual(summaryReads, 1);
4547 stackRebase = false;
4548 assert.strictEqual(
4549 (yield* Effect.flip(service.runAction(input)))._tag,
4550 "PullRequestOperationError",
4551 );
4552 stackRebase = true;
4553 stackActions = false;
4554 assert.strictEqual(
4555 (yield* Effect.flip(service.runAction(input)))._tag,
4556 "PullRequestOperationError",
4557 );
4558 assert.strictEqual(taken, 1);
4559 yield* service.summary(unrelated);
4560 assert.strictEqual(summaryReads, 1);
4561 stackActions = true;
4562 mutationFails = true;
4563 yield* Effect.flip(service.runAction(input));
4564 assert.strictEqual(taken, 2);
4565 yield* service.summary(unrelated);
4566 assert.strictEqual(summaryReads, 2);
4567 }),
4568 );
4569}
44414570
44424571it.effect("refuses a way of updating a branch that the host or the viewer does not allow", () =>
44434572 Effect.gen(function* () {
......@@ -4773,7 +4902,7 @@ it.effect("forgets the cached detail after a rewrite or terminal turn", () =>
47734902 yield* service.detail(reference);
47744903 assert.strictEqual(coreCalls, 2);
47754904
4776 yield* service.refreshAfterTurn;
4905 yield* service.refreshAfterTurn("p1" as ProjectId);
47774906 yield* service.detail(reference);
47784907 assert.strictEqual(coreCalls, 3);
47794908 }),
apps/server/src/pullRequest/PullRequestService.ts+99-60
......@@ -24,6 +24,7 @@ import {
2424 pullRequestProviderRequirement,
2525 resolvePullRequestAuthorFilter,
2626 type OrchestrationProjectShell,
27 type ProjectId,
2728 type PullRequestAction,
2829 type PullRequestActionInput,
2930 type PullRequestActivity,
......@@ -67,6 +68,7 @@ import {
6768} from "@t3tools/contracts";
6869import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";
6970
71import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts";
7072import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
7173import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
7274import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts";
......@@ -186,7 +188,7 @@ export class PullRequestService extends Context.Service<
186188 Scope.Scope
187189 >;
188190 readonly subscribeRefreshes: Stream.Stream<number>;
189 readonly refreshAfterTurn: Effect.Effect<void>;
191 readonly refreshAfterTurn: (projectId: ProjectId) => Effect.Effect<void>;
190192 readonly detail: (input: PullRequestRef) => Effect.Effect<PullRequestDetail, PullRequestError>;
191193 readonly activity: (
192194 input: PullRequestRef,
......@@ -464,6 +466,7 @@ function withRateLimitBackoff(
464466 ),
465467 Effect.flatMap((lease) =>
466468 effect.pipe(
469 Effect.provideService(AllowGitHubReserve, allowPaused),
467470 Effect.tap(() => limits.recordSuccess({ ...key, lease })),
468471 Effect.tapError((error) =>
469472 error.reason === "rate-limited"
......@@ -794,6 +797,18 @@ export const make = Effect.gen(function* () {
794797 }),
795798 );
796799
800 const canonicalRef = Effect.fn("PullRequestService.canonicalRef")(function* <
801 I extends PullRequestRef,
802 >(input: I) {
803 const project = yield* requireProject(input);
804 return {
805 ...input,
806 projectId: project.project.id,
807 host: project.host,
808 repository: project.repository,
809 };
810 });
811
797812 /**
798813 * What the signed-in account may do with this change request, asked of the host itself. Every
799814 * write goes through it: the page hides what a viewer may not do, and a request that arrived
......@@ -1783,7 +1798,11 @@ export const make = Effect.gen(function* () {
17831798 .pipe(
17841799 // Once the authorized provider action starts, a failure may leave partial
17851800 // remote updates. Validation and permission failures above changed nothing.
1786 Effect.ensuring(input.stackNumber === undefined ? Effect.void : refreshAfterTurn),
1801 Effect.ensuring(
1802 input.stackNumber === undefined
1803 ? Effect.void
1804 : refreshAfterTurn(project.project.id),
1805 ),
17871806 Effect.mapError(toPullRequestError("runAction")),
17881807 Effect.as(
17891808 project.api.kind === "azure-devops"
......@@ -2416,13 +2435,22 @@ export const make = Effect.gen(function* () {
24162435 // scope re-entering `refEpochs` after eviction can never mint a key an old entry still has.
24172436 let epochCounter = 0;
24182437 let listingsEpoch = 0;
2419 let turnRefreshEpoch = 0;
24202438 const refEpochs = new Map<string, number>();
2439 const projectEpochs = new Map<ProjectId, number>();
2440 let projectEpochFloor = 0;
24212441 const REF_EPOCH_CAPACITY = 2_048;
24222442 const refScope = (ref: PullRequestRef) =>
2423 `${ref.projectId} ${ref.host?.toLowerCase() ?? ""} ${ref.repository.toLowerCase()} ${ref.number}`;
2443 JSON.stringify([
2444 ref.projectId,
2445 ref.host?.toLowerCase() ?? "",
2446 ref.repository.toLowerCase(),
2447 ref.number,
2448 ]);
24242449 const refEpoch = (ref: PullRequestRef) =>
2425 Math.max(turnRefreshEpoch, refEpochs.get(refScope(ref)) ?? 0);
2450 Math.max(
2451 projectEpochs.get(ref.projectId) ?? projectEpochFloor,
2452 refEpochs.get(refScope(ref)) ?? 0,
2453 );
24262454 // Keys carry the reference back out of the cache loader, so the slot layout is shared with
24272455 // `refOfCacheKey` rather than read positionally at every loader.
24282456 const refCacheKey = (ref: CredentialRef) =>
......@@ -2524,7 +2552,10 @@ export const make = Effect.gen(function* () {
25242552 ),
25252553 ),
25262554 );
2527 const payload = yield* readCache.get(key, encodedRead);
2555 const payload = yield* readCache.get(key, encodedRead, [
2556 `project:${input.projectId}`,
2557 refScope(input),
2558 ]);
25282559 const decoded = yield* Schema.decodeUnknownEffect(codec)(payload).pipe(Effect.option);
25292560 return Option.isSome(decoded) ? decoded.value : yield* lookup;
25302561 });
......@@ -2723,20 +2754,9 @@ export const make = Effect.gen(function* () {
27232754
27242755 const diffCache = yield* Cache.makeWith(
27252756 (key: string) => {
2726 const [, projectId, host, repository, number, cursor, commit] = JSON.parse(key) as [
2727 number,
2728 string,
2729 string | null,
2730 string,
2731 number,
2732 string | null,
2733 string | null,
2734 ];
2757 const [reference, cursor, commit] = JSON.parse(key) as [string, string | null, string | null];
27352758 return diffUncached({
2736 projectId,
2737 ...(host === null ? {} : { host }),
2738 repository,
2739 number,
2759 ...refOfCacheKey(reference),
27402760 ...(cursor === null ? {} : { cursor }),
27412761 ...(commit === null ? {} : { commit }),
27422762 } as PullRequestDiffInput);
......@@ -2745,18 +2765,14 @@ export const make = Effect.gen(function* () {
27452765 capacity: DIFF_CACHE_CAPACITY,
27462766 timeToLive: (exit, key) => {
27472767 if (!Exit.isSuccess(exit)) return Duration.zero;
2748 const commit = (JSON.parse(key) as ReadonlyArray<unknown>)[6];
2768 const commit = (JSON.parse(key) as ReadonlyArray<unknown>)[2];
27492769 return commit === null ? DIFF_CACHE_TTL : COMMIT_DIFF_CACHE_TTL;
27502770 },
27512771 },
27522772 );
27532773 const diff: PullRequestService["Service"]["diff"] = (input) => {
27542774 const key = JSON.stringify([
2755 refEpoch(input),
2756 input.projectId,
2757 input.host?.toLowerCase() ?? null,
2758 input.repository.toLowerCase(),
2759 input.number,
2775 refCacheKey(input),
27602776 input.cursor ?? null,
27612777 input.commit ?? null,
27622778 input.commit === undefined
......@@ -2826,7 +2842,14 @@ export const make = Effect.gen(function* () {
28262842 const invalidate: PullRequestService["Service"]["invalidate"] = (input) => {
28272843 const reference = input.reference;
28282844 if (reference !== undefined) {
2829 return readCache.invalidate.pipe(Effect.andThen(Effect.sync(() => bumpRefEpoch(reference))));
2845 return canonicalRef(reference).pipe(
2846 Effect.flatMap((ref) =>
2847 readCache
2848 .invalidate(refScope(ref))
2849 .pipe(Effect.andThen(Effect.sync(() => bumpRefEpoch(ref)))),
2850 ),
2851 Effect.ignore,
2852 );
28302853 }
28312854 return Effect.sync(() => {
28322855 listingsEpoch = ++epochCounter;
......@@ -2834,12 +2857,22 @@ export const make = Effect.gen(function* () {
28342857 }).pipe(Effect.andThen(Cache.invalidateAll(viewerFlights)));
28352858 };
28362859
2837 const refreshAfterTurn: PullRequestService["Service"]["refreshAfterTurn"] = Effect.suspend(() => {
2838 turnRefreshEpoch = listingsEpoch = ++epochCounter;
2839 return readCache.invalidate.pipe(
2840 Effect.andThen(SubscriptionRef.set(pullRequestRefreshes, turnRefreshEpoch)),
2841 );
2842 });
2860 const refreshAfterTurn: PullRequestService["Service"]["refreshAfterTurn"] = (projectId) =>
2861 Effect.suspend(() => {
2862 listingsEpoch = ++epochCounter;
2863 projectEpochs.delete(projectId);
2864 if (projectEpochs.size >= REF_EPOCH_CAPACITY) {
2865 const oldest = projectEpochs.keys().next().value;
2866 if (oldest !== undefined) {
2867 projectEpochFloor = projectEpochs.get(oldest)!;
2868 projectEpochs.delete(oldest);
2869 }
2870 }
2871 projectEpochs.set(projectId, listingsEpoch);
2872 return readCache
2873 .invalidate(`project:${projectId}`)
2874 .pipe(Effect.andThen(SubscriptionRef.set(pullRequestRefreshes, listingsEpoch)));
2875 });
28432876
28442877 // A mutation's own client re-reads right after it, and every other client's next read must
28452878 // see the action too — so a write forgets the change request it touched and the listings its
......@@ -2849,22 +2882,28 @@ export const make = Effect.gen(function* () {
28492882 method: (input: I) => Effect.Effect<void, PullRequestError>,
28502883 ): ((input: I) => Effect.Effect<void, PullRequestError>) =>
28512884 (input) =>
2852 readCache.invalidate.pipe(
2853 Effect.andThen(method(input)),
2854 Effect.ensuring(readCache.invalidate),
2855 Effect.tap(() =>
2856 Effect.sync(() => {
2857 bumpRefEpoch(input);
2858 listingsEpoch = ++epochCounter;
2859 }),
2860 ),
2861 );
2885 Effect.gen(function* () {
2886 const ref = yield* canonicalRef(input);
2887 yield* readCache.invalidate(refScope(ref)).pipe(
2888 Effect.andThen(method(input)),
2889 Effect.ensuring(readCache.invalidate(refScope(ref))),
2890 Effect.tap(() =>
2891 Effect.sync(() => {
2892 bumpRefEpoch(ref);
2893 listingsEpoch = ++epochCounter;
2894 }),
2895 ),
2896 );
2897 });
28622898 const runActionAndInvalidate: PullRequestService["Service"]["runAction"] = Effect.fn(
28632899 "PullRequestService.runActionAndInvalidate",
28642900 )(function* (input) {
2865 yield* readCache.invalidate;
2866 const repository = yield* runAction(input).pipe(Effect.ensuring(readCache.invalidate));
2867 bumpRefEpoch({ ...input, repository });
2901 const ref = yield* canonicalRef(input);
2902 yield* readCache.invalidate(refScope(ref));
2903 const repository = yield* runAction(input).pipe(
2904 Effect.ensuring(readCache.invalidate(refScope(ref))),
2905 );
2906 bumpRefEpoch({ ...ref, repository });
28682907 listingsEpoch = ++epochCounter;
28692908 if (input.action === "merge") {
28702909 // A successful merge action can merely enqueue the PR or enable auto-merge.
......@@ -2890,26 +2929,26 @@ export const make = Effect.gen(function* () {
28902929 read: (input: I, ...args: Args) => Effect.Effect<A, E>,
28912930 ) =>
28922931 (input: I, ...args: Args) =>
2893 routingCredential.pipe(
2894 Effect.flatMap((credential) =>
2895 read(
2896 credential === null
2897 ? input
2898 : {
2899 ...input,
2900 [credentialNamespace]: credential.credentialFingerprint,
2901 },
2902 ...args,
2903 ),
2904 ),
2905 );
2932 Effect.gen(function* () {
2933 const ref = yield* canonicalRef(input);
2934 const credential = yield* routingCredential;
2935 return yield* read(
2936 credential === null
2937 ? ref
2938 : { ...ref, [credentialNamespace]: credential.credentialFingerprint },
2939 ...args,
2940 );
2941 });
29062942
29072943 return PullRequestService.of({
29082944 routing,
29092945 routingIdentity,
29102946 withRoutingCredential,
29112947 list,
2912 listStats,
2948 listStats: (input) =>
2949 Effect.forEach(input.refs, (ref) => canonicalRef(ref).pipe(Effect.option)).pipe(
2950 Effect.flatMap((refs) => listStats({ ...input, refs: refs.flatMap(Option.toArray) })),
2951 ),
29132952 summary: credentialCached(summary),
29142953 stack: credentialCached(stack),
29152954 subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe(
......@@ -2922,7 +2961,7 @@ export const make = Effect.gen(function* () {
29222961 detail: credentialCached(detail),
29232962 activity: credentialCached(activity),
29242963 threadComments,
2925 diff,
2964 diff: credentialCached(diff),
29262965 diffFileContents,
29272966 runAction: runActionAndInvalidate,
29282967 update: invalidatedByMutation(update),
apps/server/src/sourceControl/GitHubCli.test.ts+112-1
......@@ -1,5 +1,8 @@
11import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest";
22import * as Cache from "effect/Cache";
3import * as TestClock from "effect/testing/TestClock";
4import * as Clock from "effect/Clock";
5import * as DateTime from "effect/DateTime";
36import * as Effect from "effect/Effect";
47import * as Layer from "effect/Layer";
58import * as PlatformError from "effect/PlatformError";
......@@ -10,6 +13,8 @@ import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts";
1013
1114import * as VcsProcess from "../vcs/VcsProcess.ts";
1215import * as GitHubCli from "./GitHubCli.ts";
16import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts";
17import * as SourceControlRateLimit from "./SourceControlRateLimit.ts";
1318
1419const encodeGitHubCliError = Schema.encodeEffect(Schema.fromJsonString(GitHubCli.GitHubCliError));
1520
......@@ -21,12 +26,18 @@ const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({
2126 stderrTruncated: false,
2227});
2328
29const quotaOutput = (remaining = 5000, resetAt = "2099-01-01T00:00:00Z") =>
30 processOutput(
31 JSON.stringify({ data: { rateLimit: { cost: 1, limit: 5000, remaining, resetAt } } }),
32 );
33
2434const mockRun = vi.fn<VcsProcess.VcsProcess["Service"]["run"]>();
2535
2636const layer = GitHubCli.layer.pipe(
2737 Layer.provide(
2838 Layer.mock(VcsProcess.VcsProcess)({
29 run: mockRun,
39 run: (input) =>
40 input.args[1] === "rate_limit" ? Effect.succeed(quotaOutput()) : mockRun(input),
3041 }),
3142 ),
3243);
......@@ -35,7 +46,98 @@ afterEach(() => {
3546 mockRun.mockReset();
3647});
3748
49it.effect("shares quota checks, preserves the reserve, and resumes after reset", () =>
50 Effect.gen(function* () {
51 let probes = 0;
52 const commands: string[] = [];
53 let remaining = 501;
54 let resetAt = DateTime.formatIso(
55 DateTime.makeUnsafe((yield* Clock.currentTimeMillis) + 60_000),
56 );
57 const gh = yield* GitHubCli.make.pipe(
58 Effect.provideService(VcsProcess.VcsProcess, {
59 run: (input) =>
60 Effect.sync(() => {
61 if (input.args[1] === "rate_limit") {
62 probes++;
63 assert.strictEqual(input.args[3], "enterprise.test");
64 return quotaOutput(remaining, resetAt);
65 }
66 commands.push(input.args.slice(0, 2).join(" "));
67 return processOutput("[]");
68 }),
69 }),
70 );
71 const read = (command: string) =>
72 gh.execute({
73 cwd: "/repo",
74 args:
75 command === "repo"
76 ? ["repo", "view", "enterprise.test/acme/web", "--json", "name"]
77 : ["pr", command, "--repo=enterprise.test/acme/web", "--json", "number"],
78 });
79 yield* read("list");
80 const failure = yield* read("view").pipe(Effect.flip);
81 assert.strictEqual(failure._tag, "GitHubCliRateLimitError");
82 assert.strictEqual(probes, 1);
83 assert.deepStrictEqual(commands, ["pr list"]);
84 yield* read("view").pipe(Effect.provideService(GitHubCli.AllowGitHubReserve, true));
85 yield* gh.execute({ cwd: "/repo", args: ["pr", "merge", "1"] });
86 assert.deepStrictEqual(commands, ["pr list", "pr view", "pr merge"]);
87 remaining = 0;
88 yield* TestClock.adjust("30 seconds");
89 yield* read("repo").pipe(Effect.flip);
90 assert.strictEqual(probes, 2);
91 yield* TestClock.adjust("30 seconds");
92 remaining = 5000;
93 resetAt = DateTime.formatIso(DateTime.makeUnsafe((yield* Clock.currentTimeMillis) + 60_000));
94 yield* Effect.all([read("list"), read("repo")], { concurrency: 2 });
95 assert.strictEqual(probes, 3);
96 assert.deepStrictEqual(commands.slice(3).toSorted(), ["pr list", "repo view"]);
97 }).pipe(Effect.provide(Layer.merge(GitHubGraphQlBudget.layer, SourceControlRateLimit.layer))),
98);
99
38100describe("GitHubCli.layer", () => {
101 it.effect("shares the registry budget with CLI reads through nested layer providers", () =>
102 Effect.gen(function* () {
103 const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;
104 const gh = yield* GitHubCli.GitHubCli;
105 yield* budget.observe("github.com", quotaOutput(0).stdout);
106 const error = yield* gh.execute({ cwd: "/repo", args: ["pr", "list"] }).pipe(Effect.flip);
107 assert.strictEqual(error._tag, "GitHubCliRateLimitError");
108 expect(mockRun).not.toHaveBeenCalled();
109 }).pipe(Effect.provide(layer.pipe(Layer.provide(GitHubGraphQlBudget.layer)))),
110 );
111
112 it.effect("keeps quota snapshots separate for verified credentials on the same host", () =>
113 Effect.gen(function* () {
114 let reads = 0;
115 const gh = yield* GitHubCli.make.pipe(
116 Effect.provideService(VcsProcess.VcsProcess, {
117 run: (input) =>
118 Effect.sync(() => {
119 if (input.args[1] === "rate_limit")
120 return quotaOutput(input.env?.GH_TOKEN === "empty" ? 0 : 5000);
121 reads++;
122 return processOutput("[]");
123 }),
124 }),
125 );
126 const read = (token: string) =>
127 gh.execute({ cwd: "/repo", args: ["pr", "list", "--repo", "github.com/acme/web"] }).pipe(
128 Effect.provideService(GitHubCli.PinnedGitHubCredential, {
129 host: "github.com",
130 token: Redacted.make(token),
131 credentialFingerprint: token,
132 }),
133 );
134 yield* read("empty").pipe(Effect.flip);
135 yield* read("healthy");
136 yield* read("empty").pipe(Effect.flip);
137 assert.strictEqual(reads, 1);
138 }).pipe(Effect.provide(Layer.merge(GitHubGraphQlBudget.layer, SourceControlRateLimit.layer))),
139 );
140
39141 it.effect("pins concurrent cached commands to their own verified credentials", () =>
40142 Effect.gen(function* () {
41143 mockRun.mockImplementation((input) =>
......@@ -523,6 +625,15 @@ describe("GitHubCli.layer", () => {
523625 assert.include(error.detail, "gh api rate_limit");
524626 assert.strictEqual(error.cause, cause);
525627 assert.notInclude(error.message, "user ID");
628 const paused = yield* gh
629 .execute({ cwd: "/other-repo", args: ["pr", "list"] })
630 .pipe(Effect.flip);
631 assert.strictEqual(paused._tag, "GitHubCliRateLimitError");
632 expect(mockRun).toHaveBeenCalledTimes(1);
633 yield* TestClock.adjust("30 seconds");
634 mockRun.mockReturnValueOnce(Effect.succeed(processOutput("[]")));
635 yield* gh.execute({ cwd: "/other-repo", args: ["pr", "list"] });
636 expect(mockRun).toHaveBeenCalledTimes(2);
526637 }).pipe(Effect.provide(layer)),
527638 );
528639});
apps/server/src/sourceControl/GitHubCli.ts+115-4
......@@ -1,3 +1,6 @@
1import * as Cache from "effect/Cache";
2import * as Duration from "effect/Duration";
3import * as Exit from "effect/Exit";
14import * as Context from "effect/Context";
25import * as DateTime from "effect/DateTime";
36import * as Effect from "effect/Effect";
......@@ -15,6 +18,8 @@ import {
1518} from "@t3tools/contracts";
1619
1720import * as VcsProcess from "../vcs/VcsProcess.ts";
21import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts";
22import * as SourceControlRateLimit from "./SourceControlRateLimit.ts";
1823import {
1924 decodeGitHubPullRequestJson,
2025 decodeGitHubPullRequestListJson,
......@@ -30,7 +35,12 @@ export const PinnedGitHubCredential = Context.Reference<{
3035 readonly credentialFingerprint: string;
3136} | null>("t3/sourceControl/PinnedGitHubCredential", { defaultValue: () => null });
3237
33function targetsVerifiedHost(args: ReadonlyArray<string>, host: string): boolean {
38export const AllowGitHubReserve = Context.Reference<boolean>(
39 "t3/sourceControl/AllowGitHubReserve",
40 { defaultValue: () => false },
41);
42
43function commandHosts(args: ReadonlyArray<string>): Array<string | null> {
3444 const hosts: Array<string | null> = [];
3545 const repositoryHost = (repository: string | undefined) => {
3646 if (repository === undefined) return null;
......@@ -54,6 +64,11 @@ function targetsVerifiedHost(args: ReadonlyArray<string>, host: string): boolean
5464 else if (arg.startsWith("-R")) hosts.push(repositoryHost(arg.slice(2)));
5565 else if (/^https?:\/\//i.test(arg)) hosts.push(repositoryHost(arg));
5666 }
67 return hosts;
68}
69
70function targetsVerifiedHost(args: ReadonlyArray<string>, host: string): boolean {
71 const hosts = commandHosts(args);
5772 return hosts.length > 0 && hosts.every((target) => target === host);
5873}
5974
......@@ -91,7 +106,7 @@ export class GitHubCliAuthenticationError extends Schema.TaggedError<GitHubCliAu
91106
92107export class GitHubCliRateLimitError extends Schema.TaggedError<GitHubCliRateLimitError>()(
93108 "GitHubCliRateLimitError",
94 gitHubCliFailureFields,
109 { ...gitHubCliFailureFields, retryAt: Schema.optionalKey(Schema.Finite) },
95110) {
96111 get detail(): string {
97112 return "GitHub API rate limit exceeded. Run `gh api rate_limit` to inspect the quota and reset time.";
......@@ -274,17 +289,21 @@ export class GitHubCli extends Context.Service<
274289 readonly stdin?: string;
275290 readonly env?: NodeJS.ProcessEnv;
276291 readonly maxOutputBytes?: number;
292 readonly rateLimitHost?: string;
293 readonly allowReserve?: boolean;
277294 }) => Effect.Effect<VcsProcess.VcsProcessOutput, GitHubCliError>;
278295
279296 readonly listOpenPullRequests: (input: {
280297 readonly cwd: string;
281298 readonly headSelector: string;
282299 readonly limit?: number;
300 readonly rateLimitHost?: string;
283301 }) => Effect.Effect<ReadonlyArray<GitHubPullRequestSummary>, GitHubCliError>;
284302
285303 readonly getPullRequest: (input: {
286304 readonly cwd: string;
287305 readonly reference: string;
306 readonly rateLimitHost?: string;
288307 }) => Effect.Effect<GitHubPullRequestSummary, GitHubCliError>;
289308
290309 readonly getRepositoryCloneUrls: (input: {
......@@ -308,6 +327,7 @@ export class GitHubCli extends Context.Service<
308327
309328 readonly getDefaultBranch: (input: {
310329 readonly cwd: string;
330 readonly rateLimitHost?: string;
311331 }) => Effect.Effect<string | null, GitHubCliError>;
312332
313333 readonly checkoutPullRequest: (input: {
......@@ -377,8 +397,10 @@ function deriveRepositoryCloneUrlsFromCreateOutput(
377397/** @public Service construction is part of the canonical Effect module API. */
378398export const make = Effect.gen(function* () {
379399 const process = yield* VcsProcess.VcsProcess;
400 const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;
401 const limits = yield* SourceControlRateLimit.SourceControlRateLimit;
380402
381 const execute: GitHubCli["Service"]["execute"] = Effect.fn("GitHubCli.execute")(
403 const executeRaw: GitHubCli["Service"]["execute"] = Effect.fn("GitHubCli.executeRaw")(
382404 function* (input) {
383405 const credential = yield* PinnedGitHubCredential;
384406 if (credential !== null && !targetsVerifiedHost(input.args, credential.host)) {
......@@ -416,11 +438,94 @@ export const make = Effect.gen(function* () {
416438 },
417439 );
418440
441 const quota = yield* Cache.makeWith(
442 (key: string) => {
443 const host = key.split("\0")[0]!;
444 return executeRaw({
445 cwd: globalThis.process.cwd(),
446 args: [
447 "api",
448 "rate_limit",
449 "--hostname",
450 host,
451 "--jq",
452 ".resources.graphql | {data:{rateLimit:{cost:1,limit:.limit,remaining:.remaining,resetAt:(.reset|todateiso8601)}}}",
453 ],
454 }).pipe(
455 Effect.tap((result) => budget.observe(host, result.stdout)),
456 Effect.asVoid,
457 );
458 },
459 {
460 capacity: 32,
461 timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.seconds(30) : Duration.zero),
462 },
463 );
464 const execute: GitHubCli["Service"]["execute"] = Effect.fn("GitHubCli.execute")(
465 function* (input) {
466 const [command, action] = input.args;
467 if (
468 !(
469 (command === "pr" && (action === "list" || action === "view")) ||
470 (command === "repo" && action === "view")
471 )
472 )
473 return yield* executeRaw(input);
474 const credential = yield* PinnedGitHubCredential;
475 if (credential !== null && !targetsVerifiedHost(input.args, credential.host))
476 return yield* executeRaw(input);
477 const allowReserve = input.allowReserve ?? (yield* AllowGitHubReserve);
478 const host = (
479 credential?.host ??
480 commandHosts(input.args).find((host) => host !== null) ??
481 input.rateLimitHost ??
482 input.env?.GH_HOST ??
483 globalThis.process.env.GH_HOST ??
484 "github.com"
485 ).toLowerCase();
486 const key = { provider: "github" as const, host };
487 const guarded = Effect.gen(function* () {
488 const lease = yield* limits.check(key, allowReserve ? { allowPaused: true } : undefined);
489 return yield* Effect.gen(function* () {
490 yield* Cache.get(quota, `${host}\0${credential?.credentialFingerprint ?? ""}`);
491 yield* budget.query(host, "query {}", allowReserve ? { allowReserve: true } : undefined);
492 return yield* executeRaw(input);
493 }).pipe(
494 Effect.tap(() => limits.recordSuccess({ ...key, lease })),
495 Effect.tapError((error) =>
496 error._tag === "GitHubCliRateLimitError"
497 ? limits.recordRateLimit({ ...key, lease })
498 : Effect.void,
499 ),
500 );
501 });
502 return yield* guarded.pipe(
503 Effect.provideService(
504 SourceControlRateLimit.CredentialScope,
505 credential?.credentialFingerprint ?? (yield* SourceControlRateLimit.CredentialScope),
506 ),
507 Effect.catchTags({
508 SourceControlRateLimitPausedError: (cause) =>
509 Effect.fail(
510 new GitHubCliRateLimitError({
511 command: "gh",
512 cwd: input.cwd,
513 retryAt: cause.retryAt,
514 cause,
515 }),
516 ),
517 }),
518 );
519 },
520 );
521
419522 return GitHubCli.of({
420523 execute,
421524 listOpenPullRequests: (input) =>
422525 execute({
423526 cwd: input.cwd,
527 ...(input.rateLimitHost === undefined ? {} : { rateLimitHost: input.rateLimitHost }),
528 allowReserve: true,
424529 args: [
425530 "pr",
426531 "list",
......@@ -458,6 +563,8 @@ export const make = Effect.gen(function* () {
458563 getPullRequest: (input) =>
459564 execute({
460565 cwd: input.cwd,
566 ...(input.rateLimitHost === undefined ? {} : { rateLimitHost: input.rateLimitHost }),
567 allowReserve: true,
461568 args: [
462569 "pr",
463570 "view",
......@@ -533,6 +640,7 @@ export const make = Effect.gen(function* () {
533640 getDefaultBranch: (input) =>
534641 execute({
535642 cwd: input.cwd,
643 ...(input.rateLimitHost === undefined ? {} : { rateLimitHost: input.rateLimitHost }),
536644 args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"],
537645 }).pipe(
538646 Effect.map((value) => {
......@@ -548,4 +656,7 @@ export const make = Effect.gen(function* () {
548656 });
549657});
550658
551export const layer = Layer.effect(GitHubCli, make);
659export const layer = Layer.effect(GitHubCli, make).pipe(
660 Layer.provideMerge(GitHubGraphQlBudget.layer),
661 Layer.provideMerge(SourceControlRateLimit.layer),
662);
apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts+27
......@@ -30,6 +30,33 @@ function makeProvider(github: Partial<GitHubCli.GitHubCli["Service"]>) {
3030 );
3131}
3232
33it.effect("uses the enterprise quota for a current-repository default branch read", () =>
34 Effect.gen(function* () {
35 const provider = yield* GitHubSourceControlProvider.make.pipe(
36 Effect.provide(GitHubCli.layer),
37 Effect.provideService(VcsProcess.VcsProcess, {
38 run: (input) =>
39 Effect.sync(() => {
40 if (input.args[1] !== "rate_limit") return processResult("main");
41 assert.strictEqual(input.args[3], "enterprise.test");
42 return processResult(
43 '{"data":{"rateLimit":{"cost":1,"limit":5000,"remaining":5000,"resetAt":"2099-01-01T00:00:00Z"}}}',
44 );
45 }),
46 }),
47 );
48 const branch = yield* provider.getDefaultBranch({
49 cwd: "/enterprise-repo",
50 context: {
51 provider: { kind: "github", name: "GitHub Enterprise", baseUrl: "https://enterprise.test" },
52 remoteName: "origin",
53 remoteUrl: "https://enterprise.test/acme/web.git",
54 },
55 });
56 assert.strictEqual(branch, "main");
57 }),
58);
59
3360it.effect("maps GitHub PR summaries into provider-neutral change requests", () =>
3461 Effect.gen(function* () {
3562 const provider = yield* makeProvider({
apps/server/src/sourceControl/GitHubSourceControlProvider.ts+48-28
......@@ -127,6 +127,9 @@ export const make = Effect.gen(function* () {
127127 .listOpenPullRequests({
128128 cwd: input.cwd,
129129 headSelector: input.headSelector,
130 ...(input.context === undefined
131 ? {}
132 : { rateLimitHost: new URL(input.context.provider.baseUrl).host }),
130133 ...(input.limit !== undefined ? { limit: input.limit } : {}),
131134 })
132135 .pipe(
......@@ -152,6 +155,9 @@ export const make = Effect.gen(function* () {
152155 return github
153156 .execute({
154157 cwd: input.cwd,
158 ...(input.context === undefined
159 ? {}
160 : { rateLimitHost: new URL(input.context.provider.baseUrl).host }),
155161 args: [
156162 "pr",
157163 "list",
......@@ -267,23 +273,30 @@ export const make = Effect.gen(function* () {
267273 },
268274 listChangeRequests,
269275 getChangeRequest: (input) =>
270 github.getPullRequest(input).pipe(
271 Effect.map(toChangeRequest),
272 Effect.mapError(
273 (error) =>
274 new SourceControlProviderError({
275 provider: "github",
276 operation: "getChangeRequest",
277 command: error.command,
278 cwd: input.cwd,
279 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
280 input.reference,
281 ),
282 detail: error.detail,
283 cause: error,
284 }),
276 github
277 .getPullRequest({
278 ...input,
279 ...(input.context === undefined
280 ? {}
281 : { rateLimitHost: new URL(input.context.provider.baseUrl).host }),
282 })
283 .pipe(
284 Effect.map(toChangeRequest),
285 Effect.mapError(
286 (error) =>
287 new SourceControlProviderError({
288 provider: "github",
289 operation: "getChangeRequest",
290 command: error.command,
291 cwd: input.cwd,
292 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
293 input.reference,
294 ),
295 detail: error.detail,
296 cause: error,
297 }),
298 ),
285299 ),
286 ),
287300 createChangeRequest: (input) =>
288301 github
289302 .createPullRequest({
......@@ -344,19 +357,26 @@ export const make = Effect.gen(function* () {
344357 ),
345358 ),
346359 getDefaultBranch: (input) =>
347 github.getDefaultBranch(input).pipe(
348 Effect.mapError(
349 (error) =>
350 new SourceControlProviderError({
351 provider: "github",
352 operation: "getDefaultBranch",
353 command: error.command,
354 cwd: input.cwd,
355 detail: error.detail,
356 cause: error,
357 }),
360 github
361 .getDefaultBranch({
362 ...input,
363 ...(input.context === undefined
364 ? {}
365 : { rateLimitHost: new URL(input.context.provider.baseUrl).host }),
366 })
367 .pipe(
368 Effect.mapError(
369 (error) =>
370 new SourceControlProviderError({
371 provider: "github",
372 operation: "getDefaultBranch",
373 command: error.command,
374 cwd: input.cwd,
375 detail: error.detail,
376 cause: error,
377 }),
378 ),
358379 ),
359 ),
360380 checkoutChangeRequest: (input) =>
361381 github.checkoutPullRequest(input).pipe(
362382 Effect.mapError(
apps/server/src/sourceControl/SourceControlRateLimit.test.ts+16
......@@ -6,6 +6,22 @@ import * as SourceControlRateLimit from "./SourceControlRateLimit.ts";
66
77const github = { provider: "github" as const, host: "github.com" };
88
9it.effect("isolates cooldowns for verified credentials on the same host", () =>
10 Effect.gen(function* () {
11 const limits = yield* SourceControlRateLimit.SourceControlRateLimit;
12 yield* limits
13 .recordRateLimit({ ...github, lease: 0 })
14 .pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, "first"));
15 yield* limits
16 .check(github)
17 .pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, "second"));
18 const error = yield* limits
19 .check(github)
20 .pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, "first"), Effect.flip);
21 assert.strictEqual(error._tag, "SourceControlRateLimitPausedError");
22 }).pipe(Effect.provide(SourceControlRateLimit.layer)),
23);
24
925it("parses Retry-After seconds and HTTP dates", () => {
1026 assert.equal(SourceControlRateLimit.retryAtFromHeader("120", 1_000), 121_000);
1127 assert.equal(
apps/server/src/sourceControl/SourceControlRateLimit.ts+10-5
......@@ -13,6 +13,10 @@ import {
1313const FALLBACK_COOLDOWN = Duration.seconds(30);
1414const MAX_FALLBACK_COOLDOWN = Duration.minutes(15);
1515
16export const CredentialScope = Context.Reference<string>("t3/sourceControl/CredentialScope", {
17 defaultValue: () => "",
18});
19
1620interface RateLimitKey {
1721 readonly provider: SourceControlProviderKind;
1822 readonly host: string;
......@@ -59,8 +63,8 @@ export class SourceControlRateLimit extends Context.Service<
5963 }
6064>()("t3/sourceControl/SourceControlRateLimit") {}
6165
62function normalizedKey(key: RateLimitKey): string {
63 return `${key.provider}\0${key.host.trim().toLowerCase()}`;
66function normalizedKey(key: RateLimitKey, scope: string): string {
67 return `${key.provider}\0${key.host.trim().toLowerCase()}\0${scope}`;
6468}
6569
6670function fallbackCooldownMs(attempt: number): number {
......@@ -90,7 +94,8 @@ export const make = Effect.gen(function* () {
9094 "SourceControlRateLimit.check",
9195 )(function* (input, options) {
9296 const now = yield* Clock.currentTimeMillis;
93 const entry = (yield* Ref.get(entries)).get(normalizedKey(input));
97 const key = normalizedKey(input, yield* CredentialScope);
98 const entry = (yield* Ref.get(entries)).get(key);
9499 if (entry !== undefined && entry.retryAt > now && options?.allowPaused !== true) {
95100 return yield* new SourceControlRateLimitPausedError({
96101 provider: input.provider,
......@@ -105,8 +110,8 @@ export const make = Effect.gen(function* () {
105110 "SourceControlRateLimit.recordRateLimit",
106111 )(function* (input) {
107112 const now = yield* Clock.currentTimeMillis;
113 const key = normalizedKey(input, yield* CredentialScope);
108114 yield* Ref.update(entries, (current) => {
109 const key = normalizedKey(input);
110115 const previous = current.get(key);
111116 if (previous !== undefined && previous.generation > input.lease) {
112117 if (previous.retryAt <= now && (input.retryAt === undefined || input.retryAt <= now)) {
......@@ -145,8 +150,8 @@ export const make = Effect.gen(function* () {
145150 "SourceControlRateLimit.recordSuccess",
146151 )(function* (input) {
147152 const now = yield* Clock.currentTimeMillis;
153 const key = normalizedKey(input, yield* CredentialScope);
148154 yield* Ref.update(entries, (current) => {
149 const key = normalizedKey(input);
150155 const previous = current.get(key);
151156 if (previous === undefined || previous.generation !== input.lease || previous.retryAt > now) {
152157 return current;
apps/server/src/sourceControl/githubGraphQlBudget.test.ts+31
......@@ -3,6 +3,7 @@ import * as Effect from "effect/Effect";
33import * as TestClock from "effect/testing/TestClock";
44
55import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts";
6import { CredentialScope } from "./SourceControlRateLimit.ts";
67
78const RESET_AT = "2026-08-13T14:00:00.000Z";
89const NEXT_RESET_AT = "2026-08-13T15:00:00.000Z";
......@@ -71,6 +72,23 @@ describe("GitHub GraphQL budget", () => {
7172 }).pipe(Effect.provide(GitHubGraphQlBudget.layer)),
7273 );
7374
75 it.effect("isolates query reservations and observations by credential", () =>
76 Effect.gen(function* () {
77 yield* TestClock.setTime(BEFORE_RESET);
78 const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;
79 const query = budget.query("github.com", "query { viewer { login } }");
80 yield* budget
81 .observe("github.com", rateLimit(0))
82 .pipe(Effect.provideService(CredentialScope, "first"));
83 yield* budget
84 .observe("github.com", rateLimit(5000))
85 .pipe(Effect.provideService(CredentialScope, "second"));
86 yield* query.pipe(Effect.provideService(CredentialScope, "second"));
87 const error = yield* query.pipe(Effect.provideService(CredentialScope, "first"), Effect.flip);
88 expect(error.retryAt).toBe(Date.parse(RESET_AT));
89 }).pipe(Effect.provide(GitHubGraphQlBudget.layer)),
90 );
91
7492 it.effect("keeps the lower remaining value from out-of-order responses", () =>
7593 Effect.gen(function* () {
7694 yield* TestClock.setTime(BEFORE_RESET);
......@@ -181,6 +199,19 @@ describe("GitHub GraphQL budget", () => {
181199 }).pipe(Effect.provide(GitHubGraphQlBudget.layer)),
182200 );
183201
202 it.effect("stops interactive reads when the reserve is exhausted", () =>
203 Effect.gen(function* () {
204 yield* TestClock.setTime(BEFORE_RESET);
205 const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;
206 yield* budget.observe("github.com", rateLimit(1, 5000, RESET_AT, 1));
207 yield* budget.query("github.com", "query { viewer { login } }", { allowReserve: true });
208 const error = yield* budget
209 .query("github.com", "query { viewer { login } }", { allowReserve: true })
210 .pipe(Effect.flip);
211 expect(error.retryAt).toBe(Date.parse(RESET_AT));
212 }).pipe(Effect.provide(GitHubGraphQlBudget.layer)),
213 );
214
184215 it.effect("ignores malformed or partial rate metadata", () =>
185216 Effect.gen(function* () {
186217 yield* TestClock.setTime(BEFORE_RESET);
apps/server/src/sourceControl/githubGraphQlBudget.ts+7-4
......@@ -85,8 +85,8 @@ export const make = Effect.gen(function* () {
8585 function* (host, document, options) {
8686 if (!isReadOperation(document)) return document;
8787 const now = yield* Clock.currentTimeMillis;
88 const key = `${hostKey(host)}\0${yield* SourceControlRateLimit.CredentialScope}`;
8889 const retryAt = yield* Ref.modify(snapshots, (current) => {
89 const key = hostKey(host);
9090 const snapshot = current.get(key);
9191 if (snapshot === undefined) return [null, current] as const;
9292 if (snapshot.resetAtMs <= now) {
......@@ -94,8 +94,11 @@ export const make = Effect.gen(function* () {
9494 next.delete(key);
9595 return [null, next] as const;
9696 }
97 const remaining = Math.max(0, snapshot.remaining - Math.max(1, snapshot.cost));
98 if (options?.allowReserve !== true && remaining < snapshot.limit * GRAPHQL_RESERVE_RATIO) {
97 const remaining = snapshot.remaining - Math.max(1, snapshot.cost);
98 if (
99 remaining < 0 ||
100 (options?.allowReserve !== true && remaining < snapshot.limit * GRAPHQL_RESERVE_RATIO)
101 ) {
99102 return [snapshot.resetAtMs, current] as const;
100103 }
101104 const next = new Map(current);
......@@ -118,8 +121,8 @@ export const make = Effect.gen(function* () {
118121 )(function* (host, raw) {
119122 const snapshot = snapshotFrom(raw);
120123 if (snapshot === null) return;
124 const key = `${hostKey(host)}\0${yield* SourceControlRateLimit.CredentialScope}`;
121125 yield* Ref.update(snapshots, (current) => {
122 const key = hostKey(host);
123126 const previous = current.get(key);
124127 // Concurrent reads can finish out of order. Quota only falls within one reset window, and
125128 // an answer from an older window must not replace the current one.
packages/client-runtime/src/state/pullRequestRouting.ts+22-7
......@@ -49,6 +49,17 @@ const writes = new Set<string>([
4949]);
5050const isRef = Schema.is(PullRequestRef);
5151const isInvalidation = Schema.is(PullRequestInvalidateInput);
52const readTimeout = (environmentId: EnvironmentId) =>
53 Effect.timeoutOrElse({
54 duration: "30 seconds",
55 orElse: () =>
56 Effect.fail(
57 new EnvironmentRpcUnavailableError({
58 environmentId,
59 message: "The environment did not respond to the PR request.",
60 }),
61 ),
62 });
5263interface RoutedRead {
5364 origin: EnvironmentId;
5465 reference: PullRequestRef;
......@@ -318,7 +329,8 @@ export function createPullRequestRouter() {
318329 if (!(yield* routingAllowed(registry, origin.target.environmentId, id, writes.has(tag))))
319330 return yield* visit(index + 1);
320331 }
321 return yield* run(id).pipe(
332 const operation = run(id);
333 return yield* (reads.has(tag) ? operation.pipe(readTimeout(id)) : operation).pipe(
322334 Effect.catch((error) => {
323335 if (
324336 (reads.has(tag) || rejectedBeforeDispatch(error)) &&
......@@ -366,12 +378,15 @@ export function createPullRequestRouter() {
366378 }
367379 if (!allowed) return yield* request(tag, input);
368380 const strictInput = { ...input, allowStale: false };
369 const source = yield* Effect.cached(request(tag, strictInput));
370 // Cached source reads usually finish before another environment can verify its account.
371 // Hedge slow reads only; never race mutations or retry an ambiguous write.
372 return yield* Effect.race(
373 source,
374 routedRequest(tag, strictInput, source).pipe(Effect.delay("75 millis")),
381 const source = yield* Effect.cached(
382 request(tag, strictInput).pipe(readTimeout(origin.target.environmentId)),
383 );
384 const sourceEntry = entries.get(origin.target.environmentId);
385 const routed = routedRequest(tag, strictInput, source);
386 return yield* (
387 sourceEntry !== undefined && isLocal(sourceEntry)
388 ? source.pipe(Effect.catch(() => routed))
389 : routed
375390 ).pipe(
376391 Effect.catch((error) =>
377392 input.allowStale !== false &&
packages/client-runtime/src/state/pullRequests.test.ts+28-19
......@@ -7,6 +7,9 @@ import {
77} from "@t3tools/contracts";
88import { expect, it } from "@effect/vitest";
99import * as Data from "effect/Data";
10import * as Deferred from "effect/Deferred";
11import * as Fiber from "effect/Fiber";
12import * as TestClock from "effect/testing/TestClock";
1013import * as Effect from "effect/Effect";
1114import * as Latch from "effect/Latch";
1215import * as Layer from "effect/Layer";
......@@ -52,7 +55,7 @@ for (const scenario of [
5255 "prefers the local environment with the same github account",
5356 "falls back before mutation when the local account differs",
5457 "never retries an ambiguous mutation failure",
55 "returns a fast source read without checking alternate identities",
58 "returns a fast local source read without checking alternate identities",
5659 "keeps single-environment requests free of identity lookups",
5760 "keeps a local origin ahead of another local environment",
5861 "keeps mutations on an old origin server without retrying them",
......@@ -79,10 +82,12 @@ for (const scenario of [
7982 switchedAccount;
8083 const ambiguous = scenario === "never retries an ambiguous mutation failure";
8184 const reading =
82 scenario === "returns a fast source read without checking alternate identities";
85 scenario === "returns a fast local source read without checking alternate identities";
8386 const single = scenario === "keeps single-environment requests free of identity lookups";
8487 const localOrigin =
85 scenario === "keeps a local origin ahead of another local environment" || switchedAccount;
88 scenario === "keeps a local origin ahead of another local environment" ||
89 switchedAccount ||
90 reading;
8691 const oldOrigin =
8792 scenario === "keeps mutations on an old origin server without retrying them";
8893 const oldAlternate =
......@@ -557,15 +562,15 @@ for (const probe of ["origin", "alternate"] as const) {
557562 );
558563}
559564
560for (const source of ["pending", "pending-local", "failed", "offline"] as const) {
561 it.live(
565for (const source of ["pending", "pending-local", "failed-local", "failed", "offline"] as const) {
566 it.effect(
562567 source === "offline"
563568 ? "returns held source data only after both fresh paths fail"
564 : `hedges a ${source} source read to local and interrupts the losing read`,
569 : `uses one shared reader with a ${source} source`,
565570 () =>
566571 Effect.scoped(
567572 Effect.gen(function* () {
568 let interrupted = false;
573 const started = yield* Deferred.make<void>();
569574 const calls: string[] = [];
570575 const clientFor = (local: boolean) =>
571576 ({
......@@ -590,21 +595,16 @@ for (const source of ["pending", "pending-local", "failed", "offline"] as const)
590595 operation: "summary",
591596 detail: "github unreachable",
592597 });
593 return yield* Effect.never.pipe(
594 Effect.onInterrupt(() =>
595 Effect.sync(() => {
596 interrupted = true;
597 }),
598 ),
599 );
598 yield* Deferred.succeed(started, undefined);
599 return yield* Effect.never;
600600 }),
601601 }) as unknown as WsRpcProtocolClient;
602602 const { environmentRegistry, supervisor } = yield* makeTestRuntime(
603603 clientFor(false),
604604 clientFor(true),
605 source === "pending-local",
605 source === "failed-local" || source === "pending-local",
606606 );
607 const result = yield* createPullRequestRouter()(WS_METHODS.pullRequestsSummary, {
607 const request = createPullRequestRouter()(WS_METHODS.pullRequestsSummary, {
608608 projectId: ProjectId.make("project-1"),
609609 repository: "acme/web",
610610 number: 7,
......@@ -613,14 +613,23 @@ for (const source of ["pending", "pending-local", "failed", "offline"] as const)
613613 Effect.provideService(GitHubRoutingPermissions, trustedRouting),
614614 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
615615 );
616 const fiber = yield* request.pipe(Effect.forkChild);
617 if (source === "pending-local") {
618 yield* Deferred.await(started);
619 yield* TestClock.adjust("30 seconds");
620 }
621 const result = yield* Fiber.join(fiber);
616622 if (source === "offline") {
617623 expect(result).toEqual({ state: "open" });
618 expect(calls).toEqual(["origin", "local", "held"]);
624 expect(calls).toEqual(["local", "origin", "held"]);
619625 } else {
620626 expect(result).toBeNull();
621 expect(calls).toEqual(["origin", "local"]);
627 expect(calls).toEqual(
628 source === "failed-local" || source === "pending-local"
629 ? ["origin", "local"]
630 : ["local"],
631 );
622632 }
623 expect(interrupted).toBe(source === "pending" || source === "pending-local");
624633 }),
625634 ),
626635 );