authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-13 03:33:13-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-13 03:33:13-03:00
logdb6e0531e4faac0fc5ac0e12088b01bed6e7b851
treedd89758188cd9edd6a0e20cd3c44d16cd2097674
parent8b3ddf51c59a54411cd02b321cb07f39c927df09
signature Signed by PGP key B5690EEEBB952194

feat(github): route pull request operations across matching accounts (#11367)


36 files changed, 3297 insertions(+), 147 deletions(-)

apps/mobile/src/connection/storage.ts+8
......@@ -14,6 +14,8 @@ import {
1414 ConnectionTransientError,
1515 CredentialStore,
1616 ProfileStore,
17 GitHubRoutingPermissions,
18 makeGitHubRoutingPermissions,
1719} from "@t3tools/client-runtime/connection";
1820import * as Context from "effect/Context";
1921import * as Effect from "effect/Effect";
......@@ -39,6 +41,11 @@ function targetPersistenceError(
3941export const connectionStorageLayer = Layer.effectContext(
4042 Effect.gen(function* () {
4143 const catalog = yield* CatalogStore.make();
44 const githubRoutingPermissions = yield* makeGitHubRoutingPermissions({
45 read: catalog.read.pipe(Effect.map((document) => document.githubRoutingPermissions ?? [])),
46 write: (githubRoutingPermissions) =>
47 catalog.update((document) => ({ ...document, githubRoutingPermissions })),
48 });
4249
4350 const targetStore = ConnectionTargetStore.of({
4451 list: catalog.read.pipe(
......@@ -138,6 +145,7 @@ export const connectionStorageLayer = Layer.effectContext(
138145 })),
139146 });
140147 return Context.make(ConnectionTargetStore, targetStore).pipe(
148 Context.add(GitHubRoutingPermissions, githubRoutingPermissions),
141149 Context.add(ConnectionRegistrationStore, registrationStore),
142150 Context.add(ProfileStore.ConnectionProfileStore, profileStore),
143151 Context.add(CredentialStore.ConnectionCredentialStore, credentialStore),
apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx+2
......@@ -11,6 +11,7 @@ import { AppText as Text } from "../../components/AppText";
1111import { cn } from "../../lib/cn";
1212import { useRemoteConnections } from "../../state/use-remote-environment-registry";
1313import { ConnectionEnvironmentRow } from "./ConnectionEnvironmentRow";
14import { GitHubRoutingSettings } from "./GitHubRoutingSettings";
1415
1516export function ConnectionsRouteScreen() {
1617 const {
......@@ -97,6 +98,7 @@ export function ConnectionsRouteScreen() {
9798 </Text>
9899 </View>
99100 )}
101 <GitHubRoutingSettings />
100102 </ScrollView>
101103 </View>
102104 );
apps/mobile/src/features/connection/GitHubRoutingSettings.tsx created+126
......@@ -0,0 +1,126 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
3 connectionCatalogDisplayUrl,
4 gitHubRoutingConnectionKey,
5 gitHubRoutingPermissionFor,
6 type GitHubRoutingPermission,
7} from "@t3tools/client-runtime/connection";
8import { useState } from "react";
9import { Alert, Pressable, View } from "react-native";
10
11import { AppText as Text } from "../../components/AppText";
12import { SymbolView } from "../../components/AppSymbol";
13import { environmentCatalog } from "../../connection/catalog";
14import { useAtomCommand } from "../../state/use-atom-command";
15import { SettingsSection } from "../settings/components/SettingsSection";
16
17const options: ReadonlyArray<{
18 value: GitHubRoutingPermission;
19 label: string;
20 description: string;
21}> = [
22 { value: "off", label: "Off", description: "Keep GitHub requests on this environment." },
23 {
24 value: "read",
25 label: "Read PRs",
26 description: "Share PR data with other enabled environments.",
27 },
28 {
29 value: "read-write",
30 label: "Read and act",
31 description: "Actions may use broader GitHub permissions than the original environment.",
32 },
33];
34
35export function GitHubRoutingSettings() {
36 const catalog = useAtomValue(environmentCatalog.catalogValueAtom);
37 const permissions = useAtomValue(environmentCatalog.githubRoutingPermissionsValueAtom);
38 const update = useAtomCommand(environmentCatalog.setGitHubRoutingPermission);
39 const [expanded, setExpanded] = useState<string | null>(null);
40 const [saving, setSaving] = useState(false);
41 if (catalog.entries.size === 0) return null;
42
43 return (
44 <View className="mt-5 gap-3">
45 <SettingsSection title="GitHub routing">
46 {[...catalog.entries.values()].map((entry, index) => {
47 const environmentId = entry.target.environmentId;
48 const selected = gitHubRoutingPermissionFor(entry, permissions);
49 const disabled = !catalog.isReady || saving || gitHubRoutingConnectionKey(entry) === null;
50 return (
51 <View
52 key={environmentId}
53 className={index === 0 ? undefined : "border-t border-border-subtle"}
54 >
55 <Pressable
56 accessibilityRole="button"
57 accessibilityLabel={`${entry.target.label} GitHub routing`}
58 accessibilityState={{ expanded: expanded === environmentId }}
59 className="flex-row items-center gap-3 p-4"
60 onPress={() => setExpanded(expanded === environmentId ? null : environmentId)}
61 >
62 <View className="min-w-0 flex-1 gap-0.5">
63 <Text className="text-base font-t3-bold text-foreground">
64 {entry.target.label}
65 </Text>
66 <Text className="text-xs text-foreground-muted" numberOfLines={1}>
67 {connectionCatalogDisplayUrl(entry) ?? "T3 Connect"}
68 </Text>
69 </View>
70 <Text className="text-sm text-foreground-muted">
71 {options.find((option) => option.value === selected)?.label}
72 </Text>
73 <SymbolView
74 name={expanded === environmentId ? "chevron.up" : "chevron.down"}
75 size={12}
76 tintColorClassName="accent-icon-muted"
77 />
78 </Pressable>
79 {expanded === environmentId
80 ? options.map((option) => (
81 <Pressable
82 key={option.value}
83 accessibilityRole="radio"
84 accessibilityState={{ checked: selected === option.value, disabled }}
85 disabled={disabled}
86 className="flex-row items-center gap-4 border-t border-border-subtle p-4 disabled:opacity-50"
87 onPress={() => {
88 setSaving(true);
89 void update({ environmentId, permission: option.value }).then((result) => {
90 setSaving(false);
91 if (result._tag === "Failure")
92 Alert.alert(
93 "Could not save GitHub routing permission",
94 "Try again before leaving this screen.",
95 );
96 });
97 }}
98 >
99 <View className="min-w-0 flex-1 gap-1">
100 <Text className="text-base text-foreground">{option.label}</Text>
101 <Text className="text-sm leading-normal text-foreground-muted">
102 {option.description}
103 </Text>
104 </View>
105 {selected === option.value ? (
106 <SymbolView
107 name="checkmark"
108 size={18}
109 tintColorClassName="accent-icon"
110 weight="semibold"
111 />
112 ) : null}
113 </Pressable>
114 ))
115 : null}
116 </View>
117 );
118 })}
119 </SettingsSection>
120 <Text className="px-2 text-sm text-foreground-muted">
121 Choose environments you trust to share PR data and use each other's GitHub access. Enable
122 both environments. This applies only to this client.
123 </Text>
124 </View>
125 );
126}
apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx+2
......@@ -10,6 +10,7 @@ import { AppText as Text } from "../../components/AppText";
1010import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
1111import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows";
1212import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow";
13import { GitHubRoutingSettings } from "../connection/GitHubRoutingSettings";
1314import { splitEnvironmentSections } from "../connection/environmentSections";
1415import { cn } from "../../lib/cn";
1516import { useUniwindTheme } from "../../lib/useUniwindTheme";
......@@ -174,6 +175,7 @@ export function SettingsEnvironmentsRouteScreen() {
174175 }
175176 : {})}
176177 />
178 <GitHubRoutingSettings />
177179 </ScrollView>
178180 </View>
179181 );
apps/server/src/auth/RpcAuthorization.ts+2
......@@ -68,6 +68,8 @@ export const RPC_REQUIRED_SCOPES = {
6868 [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope,
6969 [WS_METHODS.pullRequestsListStats]: AuthOrchestrationReadScope,
7070 [WS_METHODS.pullRequestsSummary]: AuthOrchestrationReadScope,
71 [WS_METHODS.pullRequestsRouting]: AuthOrchestrationReadScope,
72 [WS_METHODS.pullRequestsRoutingIdentity]: AuthOrchestrationReadScope,
7173 [WS_METHODS.pullRequestsStack]: AuthOrchestrationReadScope,
7274 [WS_METHODS.pullRequestsLinkedThreads]: AuthOrchestrationReadScope,
7375 [WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope,
apps/server/src/pullRequest/GitHubPullRequestCli.test.ts+182-1
......@@ -1,11 +1,14 @@
11import { afterEach, assert, expect, it, vi } from "@effect/vitest";
22import * as Effect from "effect/Effect";
3import * as Deferred from "effect/Deferred";
4import * as Fiber from "effect/Fiber";
35import * as Layer from "effect/Layer";
46import * as Schema from "effect/Schema";
57import * as TestClock from "effect/testing/TestClock";
68import { ChildProcessSpawner } from "effect/unstable/process";
79
810import * as GitHubCli from "../sourceControl/GitHubCli.ts";
11import * as VcsProcess from "../vcs/VcsProcess.ts";
912import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts";
1013import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";
1114import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts";
......@@ -192,7 +195,118 @@ afterEach(() => {
192195 mockedGetPullRequest.mockReset();
193196});
194197
198it.effect(
199 "keeps a verified credential through an auth switch and separates token fingerprints",
200 () =>
201 Effect.gen(function* () {
202 let activeToken = "broad-credential";
203 const commands: VcsProcess.VcsProcessInput[] = [];
204 const github = yield* GitHubCli.make.pipe(
205 Effect.provideService(VcsProcess.VcsProcess, {
206 run: (input) =>
207 Effect.sync(() => {
208 commands.push(input);
209 if (input.args[0] === "auth") return output(activeToken);
210 if (input.args[0] === "api") return output('{"id":123,"login":"same-account"}');
211 return output("");
212 }),
213 }),
214 );
215 const cli = yield* GitHubPullRequestCli.make.pipe(
216 Effect.provideService(GitHubCli.GitHubCli, github),
217 Effect.provide(GitHubGraphQlBudget.layer),
218 );
219 const input = { cwd: "/repo", host: "github.com" };
220 const first = yield* cli.withVerifiedCredential(input, (identity) =>
221 Effect.gen(function* () {
222 activeToken = "restricted-credential";
223 expect(yield* cli.getViewerLogin(input)).toBe("same-account");
224 yield* cli.commentOnPullRequest({
225 ...input,
226 repository: "owner/repo",
227 number: 1,
228 body: "comment",
229 });
230 return identity;
231 }),
232 );
233 const second = yield* cli.withVerifiedCredential(input, Effect.succeed);
234 expect(first.accountId).toBe(second.accountId);
235 expect(first.credentialFingerprint).not.toBe(second.credentialFingerprint);
236 expect(encodeJson([first, second])).not.toContain("broad-credential");
237 expect(encodeJson([first, second])).not.toContain("restricted-credential");
238 expect(commands.find((command) => command.args[0] === "pr")?.env).toMatchObject({
239 GH_TOKEN: "broad-credential",
240 GITHUB_TOKEN: "broad-credential",
241 GH_DEBUG: "",
242 });
243 expect(
244 commands
245 .filter((command) => command.args[0] === "api")
246 .map((command) => command.env?.GH_TOKEN),
247 ).toEqual(["broad-credential", "restricted-credential"]);
248 expect(yield* cli.getRoutingIdentity(input)).toEqual({
249 accountId: "123",
250 viewer: "same-account",
251 });
252 }),
253);
254
195255layer("GitHubPullRequestCli.layer", (it) => {
256 it.effect("coalesces concurrent identity verification for the same host and credential", () =>
257 Effect.gen(function* () {
258 mockedExecute.mockImplementation((input) =>
259 input.args[0] === "auth"
260 ? Effect.succeed(output("shared-credential"))
261 : Effect.yieldNow.pipe(Effect.as(output('{"id":123,"login":"viewer"}'))),
262 );
263 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
264 const results = yield* Effect.all(
265 Array.from({ length: 4 }, () =>
266 cli.getRoutingIdentity({ cwd: "/w", host: "github.identity-flight.test" }),
267 ),
268 { concurrency: 4 },
269 );
270 expect(results).toEqual(
271 Array.from({ length: 4 }, () => ({ accountId: "123", viewer: "viewer" })),
272 );
273 expect(mockedExecute.mock.calls.filter(([input]) => input.args[0] === "api")).toHaveLength(1);
274 }),
275 );
276
277 it.effect(
278 "lets another identity reader continue when the first verification is interrupted",
279 () =>
280 Effect.gen(function* () {
281 const firstStarted = yield* Deferred.make<void>();
282 const secondStarted = yield* Deferred.make<void>();
283 let tokens = 0;
284 let verifications = 0;
285 mockedExecute.mockImplementation((input) =>
286 Effect.gen(function* () {
287 if (input.args[0] === "auth") {
288 if (++tokens === 2) yield* Deferred.succeed(secondStarted, undefined);
289 return output("cancel-credential");
290 }
291 if (++verifications === 1) {
292 yield* Deferred.succeed(firstStarted, undefined);
293 return yield* Effect.never;
294 }
295 return output('{"id":123,"login":"viewer"}');
296 }),
297 );
298 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
299 const input = { cwd: "/w", host: "github.identity-cancel.test" };
300 const first = yield* cli.getRoutingIdentity(input).pipe(Effect.forkChild);
301 yield* Deferred.await(firstStarted);
302 const second = yield* cli.getRoutingIdentity(input).pipe(Effect.forkChild);
303 yield* Deferred.await(secondStarted);
304 yield* Fiber.interrupt(first);
305 expect(yield* Fiber.join(second)).toEqual({ accountId: "123", viewer: "viewer" });
306 expect(verifications).toBe(2);
307 }),
308 );
309
196310 it.effect("reads linked pull request status with the overview fields in one request", () =>
197311 Effect.gen(function* () {
198312 mockedExecute.mockReturnValueOnce(
......@@ -2458,12 +2572,79 @@ layer("GitHubPullRequestCli.layer", (it) => {
24582572 mockedExecute.mockReturnValueOnce(Effect.succeed(output(" ")));
24592573 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
24602574
2461 const error = yield* Effect.flip(cli.getViewerLogin({ cwd: "/w" }));
2575 const error = yield* Effect.flip(cli.getViewerLogin({ cwd: "/w", host: "github.com" }));
24622576
24632577 assert.strictEqual(error._tag, "GitHubViewerLoginUnavailableError");
24642578 }),
24652579 );
24662580
2581 it.effect("looks up the authenticated account on the requested enterprise host", () =>
2582 Effect.gen(function* () {
2583 mockedExecute
2584 .mockReturnValueOnce(Effect.succeed(output("enterprise-test-credential")))
2585 .mockReturnValueOnce(Effect.succeed(output('{"id":456,"login":"enterprise-user"}')));
2586 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
2587
2588 const login = yield* cli.getViewerLogin({ cwd: "/w", host: "github.acme.com" });
2589
2590 expect(login).toBe("enterprise-user");
2591 expect(callAt(0).args).toEqual(["auth", "token", "--hostname", "github.acme.com"]);
2592 expect(callAt(1).args).toEqual(["api", "user", "--hostname", "github.acme.com"]);
2593 }),
2594 );
2595
2596 it.effect("reuses verified credentials offline and refuses an unverified replacement", () =>
2597 Effect.gen(function* () {
2598 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
2599 const input = { cwd: "/w", host: "github.identity-cache.test" };
2600 mockedExecute
2601 .mockReturnValueOnce(Effect.succeed(output("test-credential-a")))
2602 .mockReturnValueOnce(Effect.succeed(output('{"id":123,"login":"maria-rcks"}')));
2603 expect(yield* cli.getRoutingIdentity(input)).toEqual({
2604 accountId: "123",
2605 viewer: "maria-rcks",
2606 });
2607 expect(callAt(1).env).toMatchObject({
2608 GH_ENTERPRISE_TOKEN: "test-credential-a",
2609 GH_DEBUG: "",
2610 });
2611
2612 mockedExecute.mockReturnValueOnce(Effect.succeed(output("test-credential-a")));
2613 expect(yield* cli.getRoutingIdentity(input)).toEqual({
2614 accountId: "123",
2615 viewer: "maria-rcks",
2616 });
2617 expect(mockedExecute).toHaveBeenCalledTimes(3);
2618
2619 mockedExecute
2620 .mockReturnValueOnce(Effect.succeed(output("test-credential-b")))
2621 .mockReturnValueOnce(
2622 Effect.fail(
2623 new GitHubCli.GitHubCliCommandError({
2624 command: "gh",
2625 cwd: "/w",
2626 cause: new Error("upstream failed with test-credential-b"),
2627 }),
2628 ),
2629 );
2630 const failure = yield* cli.getRoutingIdentity(input).pipe(Effect.flip);
2631 expect(failure._tag).toBe("GitHubViewerLoginUnavailableError");
2632 expect(String(failure)).not.toContain("test-credential-b");
2633 expect(callAt(4).env).toMatchObject({
2634 GH_ENTERPRISE_TOKEN: "test-credential-b",
2635 GH_DEBUG: "",
2636 });
2637
2638 mockedExecute
2639 .mockReturnValueOnce(Effect.succeed(output("test-credential-b")))
2640 .mockReturnValueOnce(Effect.succeed(output('{"id":456,"login":"maria-rcks"}')));
2641 expect(yield* cli.getRoutingIdentity(input)).toEqual({
2642 accountId: "456",
2643 viewer: "maria-rcks",
2644 });
2645 }),
2646 );
2647
24672648 it.effect("sends a whole review as one request body over stdin", () =>
24682649 Effect.gen(function* () {
24692650 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
apps/server/src/pullRequest/GitHubPullRequestCli.ts+130-8
......@@ -1,11 +1,17 @@
11import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts";
22import * as Context from "effect/Context";
3import * as Clock from "effect/Clock";
4import * as NodeCrypto from "node:crypto";
35import * as Effect from "effect/Effect";
46import * as Layer from "effect/Layer";
7import * as Redacted from "effect/Redacted";
58import * as Result from "effect/Result";
69import * as Schema from "effect/Schema";
10import * as Semaphore from "effect/Semaphore";
711import {
812 resolvePullRequestAuthorFilter,
13 PositiveInt,
14 TrimmedNonEmptyString,
915 type PullRequestAction,
1016 type PullRequestStackHead,
1117 type PullRequestActor,
......@@ -413,8 +419,24 @@ export interface GitHubPullRequestDiffSlice {
413419export class GitHubPullRequestCli extends Context.Service<
414420 GitHubPullRequestCli,
415421 {
422 readonly withVerifiedCredential: <A, E, R>(
423 input: { readonly cwd: string; readonly host: string },
424 use: (identity: {
425 readonly accountId: string;
426 readonly viewer: string;
427 readonly credentialFingerprint: string;
428 }) => Effect.Effect<A, E, R>,
429 ) => Effect.Effect<A, E | GitHubPullRequestCliError, R>;
430 readonly getRoutingIdentity: (input: {
431 readonly cwd: string;
432 readonly host: string;
433 }) => Effect.Effect<
434 { readonly accountId: string; readonly viewer: string },
435 GitHubPullRequestCliError
436 >;
416437 readonly getViewerLogin: (input: {
417438 readonly cwd: string;
439 readonly host: string;
418440 }) => Effect.Effect<string, GitHubPullRequestCliError>;
419441
420442 readonly listPullRequests: (input: {
......@@ -991,6 +1013,111 @@ function actionArgs(
9911013export const make = Effect.gen(function* () {
9921014 const github = yield* GitHubCli.GitHubCli;
9931015 const graphQlBudget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;
1016 const routingIdentities = new Map<
1017 string,
1018 {
1019 at: number;
1020 value: { accountId: string; viewer: string };
1021 }
1022 >();
1023 const identityLocks = new Map<string, { gate: Semaphore.Semaphore; users: number }>();
1024 const decodeRoutingIdentity = Schema.decodeUnknownEffect(
1025 Schema.fromJsonString(
1026 Schema.Struct({
1027 id: PositiveInt,
1028 login: TrimmedNonEmptyString,
1029 }),
1030 ),
1031 );
1032 const captureVerifiedCredential = Effect.fn("GitHubPullRequestCli.captureVerifiedCredential")(
1033 function* (input: { readonly cwd: string; readonly host: string }) {
1034 const unavailable = () =>
1035 new GitHubViewerLoginUnavailableError({ command: "gh", cwd: input.cwd });
1036 const host = input.host.toLowerCase();
1037 const pinned = yield* GitHubCli.PinnedGitHubCredential;
1038 if (pinned !== null && pinned.host !== host) return yield* unavailable();
1039 // Only the digest is retained. Never attach credential lookup output to an error.
1040 const token =
1041 pinned !== null
1042 ? Redacted.value(pinned.token)
1043 : (yield* github
1044 .execute({
1045 cwd: input.cwd,
1046 args: ["auth", "token", "--hostname", host],
1047 env: { GH_DEBUG: "" },
1048 })
1049 .pipe(Effect.mapError(unavailable))).stdout.trim();
1050 if (!token) return yield* unavailable();
1051 const key = `${host}:${NodeCrypto.createHash("sha256").update(token).digest("hex")}`;
1052 const credential = { host, token: Redacted.make(token), credentialFingerprint: key };
1053 // A cold page may ask several times. Wait per credential and check again after the
1054 // first verification; cancellation releases the next waiter without losing its request.
1055 return yield* Effect.acquireUseRelease(
1056 Effect.sync(() => {
1057 const lock = identityLocks.get(key) ?? { gate: Semaphore.makeUnsafe(1), users: 0 };
1058 lock.users++;
1059 identityLocks.set(key, lock);
1060 return lock;
1061 }),
1062 (lock) =>
1063 lock.gate.withPermit(
1064 Effect.gen(function* () {
1065 const now = yield* Clock.currentTimeMillis;
1066 const cached = routingIdentities.get(key);
1067 if (cached !== undefined && now - cached.at < 10 * 60_000)
1068 return { ...credential, ...cached.value };
1069 // Pin this read so an auth switch cannot poison its cache entry.
1070 const response = yield* github
1071 .execute({
1072 cwd: input.cwd,
1073 args: ["api", "user", "--hostname", host],
1074 env: {
1075 GH_HOST: host,
1076 GH_TOKEN: token,
1077 GITHUB_TOKEN: token,
1078 GH_ENTERPRISE_TOKEN: token,
1079 GITHUB_ENTERPRISE_TOKEN: token,
1080 GH_DEBUG: "",
1081 },
1082 })
1083 .pipe(Effect.mapError(unavailable));
1084 const identity = yield* decodeRoutingIdentity(response.stdout).pipe(
1085 Effect.mapError(unavailable),
1086 );
1087 const value = { accountId: String(identity.id), viewer: identity.login };
1088 if (routingIdentities.size >= 128)
1089 routingIdentities.delete(routingIdentities.keys().next().value!);
1090 routingIdentities.set(key, { at: now, value });
1091 return { ...credential, ...value };
1092 }),
1093 ),
1094 (lock) =>
1095 Effect.sync(() => {
1096 lock.users--;
1097 if (lock.users === 0) identityLocks.delete(key);
1098 }),
1099 );
1100 },
1101 );
1102 const withVerifiedCredential: GitHubPullRequestCli["Service"]["withVerifiedCredential"] = (
1103 input,
1104 use,
1105 ) =>
1106 captureVerifiedCredential(input).pipe(
1107 Effect.flatMap(({ host, token, accountId, viewer, credentialFingerprint }) =>
1108 use({ accountId, viewer, credentialFingerprint }).pipe(
1109 Effect.provideService(GitHubCli.PinnedGitHubCredential, {
1110 host,
1111 token,
1112 credentialFingerprint,
1113 }),
1114 ),
1115 ),
1116 );
1117 const getRoutingIdentity: GitHubPullRequestCli["Service"]["getRoutingIdentity"] = (input) =>
1118 captureVerifiedCredential(input).pipe(
1119 Effect.map(({ accountId, viewer }) => ({ accountId, viewer })),
1120 );
9941121
9951122 /**
9961123 * The pull request's own node id, which is what a mutation against the pull request itself is
......@@ -1468,15 +1595,10 @@ export const make = Effect.gen(function* () {
14681595 );
14691596
14701597 return GitHubPullRequestCli.of({
1598 withVerifiedCredential,
1599 getRoutingIdentity,
14711600 getViewerLogin: (input) =>
1472 github.execute({ cwd: input.cwd, args: ["api", "user", "--jq", ".login"] }).pipe(
1473 Effect.flatMap((result) => {
1474 const login = result.stdout.trim();
1475 return login.length > 0
1476 ? Effect.succeed(login)
1477 : Effect.fail(new GitHubViewerLoginUnavailableError({ command: "gh", cwd: input.cwd }));
1478 }),
1479 ),
1601 getRoutingIdentity(input).pipe(Effect.map((identity) => identity.viewer)),
14801602
14811603 listPullRequests: (input) => {
14821604 const fallbackMaxRows = Math.max(input.limit + 1, PULL_REQUEST_FALLBACK_MAX_ROWS);
apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts+96-4
......@@ -1,6 +1,7 @@
11import { describe, expect, it } from "@effect/vitest";
22import * as Effect from "effect/Effect";
33import * as Layer from "effect/Layer";
4import * as Redacted from "effect/Redacted";
45import type { PullRequestReaction } from "@t3tools/contracts";
56
67import * as GitHubCli from "../sourceControl/GitHubCli.ts";
......@@ -8,6 +9,44 @@ import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";
89import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts";
910import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts";
1011
12it.effect("maps credential verification failures without relabeling operation failures", () =>
13 Effect.gen(function* () {
14 let verificationFails = true;
15 let operations = 0;
16 const provider = yield* make.pipe(
17 Effect.provide(
18 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
19 withVerifiedCredential: (_input, use) =>
20 verificationFails
21 ? Effect.fail(
22 new GitHubPullRequestCli.GitHubViewerLoginUnavailableError({
23 command: "gh",
24 cwd: "/w",
25 }),
26 )
27 : use({ accountId: "123", viewer: "viewer", credentialFingerprint: "fingerprint" }),
28 }),
29 ),
30 );
31 const verify = provider.withVerifiedCredential;
32 if (verify === undefined)
33 return yield* Effect.die("credential verification was not implemented");
34 const input = { cwd: "/w", host: "github.com" };
35 const operation = () =>
36 Effect.sync(() => {
37 operations++;
38 }).pipe(Effect.andThen(Effect.fail("operation-failed")));
39 expect(yield* verify(input, operation).pipe(Effect.flip)).toMatchObject({
40 _tag: "PullRequestProviderError",
41 operation: "routeIdentity",
42 });
43 expect(operations).toBe(0);
44 verificationFails = false;
45 expect(yield* verify(input, operation).pipe(Effect.flip)).toBe("operation-failed");
46 expect(operations).toBe(1);
47 }),
48);
49
1150it.effect("uses one narrow read for a linked pull request summary", () =>
1251 Effect.gen(function* () {
1352 let summaryReads = 0;
......@@ -215,6 +254,23 @@ describe("gitHubViewerPermissions", () => {
215254 description: "GitHub could not determine whether workflows are awaiting approval.",
216255 url: null,
217256 });
257 for (const fingerprint of ["broad", "restricted", "broad"]) {
258 const scoped = yield* provider
259 .getChangeRequest({
260 cwd: "/w",
261 repository: "acme/web",
262 host: "github.com",
263 number: 7,
264 })
265 .pipe(
266 Effect.provideService(GitHubCli.PinnedGitHubCredential, {
267 host: "github.com",
268 token: Redacted.make("credential"),
269 credentialFingerprint: fingerprint,
270 }),
271 );
272 expect(scoped.mergeCapabilities.squash).toBe(fingerprint !== "restricted");
273 }
218274 }).pipe(
219275 Effect.provide(
220276 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
......@@ -250,10 +306,16 @@ describe("gitHubViewerPermissions", () => {
250306 commits: [],
251307 }),
252308 getRepositoryAccess: () =>
253 Effect.succeed({
254 canWrite: false,
255 mergeCapabilities: { merge: true, squash: true, rebase: true },
256 }),
309 GitHubCli.PinnedGitHubCredential.pipe(
310 Effect.map((credential) => ({
311 canWrite: false,
312 mergeCapabilities: {
313 merge: true,
314 squash: credential?.credentialFingerprint !== "restricted",
315 rebase: true,
316 },
317 })),
318 ),
257319 getViewerAccess: () =>
258320 Effect.succeed({
259321 canWrite: false,
......@@ -513,6 +575,36 @@ it.effect("propagates workflow discovery rate limits", () =>
513575);
514576
515577describe("getViewerPermissions", () => {
578 it.effect("checks fresh access without reading branch details for unrelated operations", () => {
579 let accessReads = 0;
580 return Effect.gen(function* () {
581 const provider = yield* make;
582 const permissions = yield* provider.getViewerPermissions({
583 cwd: "/w",
584 repository: "acme/web",
585 host: "github.com",
586 number: 7,
587 includeUpdateBranch: false,
588 });
589
590 expect(accessReads).toBe(1);
591 expect(permissions.actions).toContain("merge");
592 expect(permissions.actions).not.toContain("update-branch");
593 }).pipe(
594 Effect.provide(
595 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
596 getPullRequestDetail: () => Effect.die("Unexpected detail read"),
597 getPullRequestBaseComparison: () => Effect.die("Unexpected comparison read"),
598 getViewerAccess: () =>
599 Effect.sync(() => {
600 accessReads++;
601 return { canWrite: true, canTriage: true, canUpdate: true, didAuthor: false };
602 }),
603 }),
604 ),
605 );
606 });
607
516608 const layerWithComparison = (
517609 comparison: Effect.Effect<{
518610 readonly behindBy: number | null;
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+40-16
......@@ -11,6 +11,7 @@ import type {
1111} from "@t3tools/contracts";
1212
1313import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";
14import { PinnedGitHubCredential } from "../sourceControl/GitHubCli.ts";
1415import {
1516 PullRequestProviderError,
1617 type PullRequestProviderFailure,
......@@ -198,7 +199,20 @@ export const make = Effect.gen(function* () {
198199 readonly cwd: string;
199200 readonly repository: string;
200201 readonly host: string;
201 }) => Cache.get(repositoryAccessCache, JSON.stringify([input.cwd, input.repository, input.host]));
202 }) =>
203 PinnedGitHubCredential.pipe(
204 Effect.flatMap((credential) =>
205 Cache.get(
206 repositoryAccessCache,
207 JSON.stringify([
208 input.cwd,
209 input.repository,
210 input.host,
211 credential?.credentialFingerprint ?? null,
212 ]),
213 ),
214 ),
215 );
202216
203217 const fail = (operation: string) => (error: GitHubPullRequestCli.GitHubPullRequestCliError) =>
204218 new PullRequestProviderError({
......@@ -212,9 +226,17 @@ export const make = Effect.gen(function* () {
212226 const provider: PullRequestProviderApi = {
213227 kind: "github",
214228 capabilities: CAPABILITIES,
229 getRoutingIdentity: (input) =>
230 cli.getRoutingIdentity(input).pipe(Effect.mapError(fail("routeIdentity"))),
231 withVerifiedCredential: (input, use) =>
232 cli
233 .withVerifiedCredential(input, (identity) => use(identity).pipe(Effect.result))
234 .pipe(Effect.mapError(fail("routeIdentity")), Effect.flatMap(Effect.fromResult)),
215235
216236 getViewer: (input) =>
217 cli.getViewerLogin({ cwd: input.cwd }).pipe(Effect.mapError(fail("getViewer"))),
237 cli
238 .getViewerLogin({ cwd: input.cwd, host: input.host ?? "github.com" })
239 .pipe(Effect.mapError(fail("getViewer"))),
218240
219241 listChangeRequests: (input) =>
220242 cli
......@@ -494,20 +516,22 @@ export const make = Effect.gen(function* () {
494516 // comparison only resolves through the head ref the detail carries. A failure here
495517 // withholds that one action rather than the whole answer, the way the detail path
496518 // leaves the banner unknown.
497 cli.getPullRequestDetail(input).pipe(
498 Effect.flatMap((pullRequest) =>
499 pullRequest.state !== "open" || pullRequest.headRepositoryOwner === null
500 ? Effect.succeed(false)
501 : cli
502 .getPullRequestBaseComparison({
503 ...input,
504 headRef: `${pullRequest.headRepositoryOwner}:${pullRequest.headBranch}`,
505 allowReserve: true,
506 })
507 .pipe(Effect.map((comparison) => comparison.viewerCanUpdate === true)),
508 ),
509 Effect.orElseSucceed(() => false),
510 ),
519 input.includeUpdateBranch === false
520 ? Effect.succeed(false)
521 : cli.getPullRequestDetail(input).pipe(
522 Effect.flatMap((pullRequest) =>
523 pullRequest.state !== "open" || pullRequest.headRepositoryOwner === null
524 ? Effect.succeed(false)
525 : cli
526 .getPullRequestBaseComparison({
527 ...input,
528 headRef: `${pullRequest.headRepositoryOwner}:${pullRequest.headBranch}`,
529 allowReserve: true,
530 })
531 .pipe(Effect.map((comparison) => comparison.viewerCanUpdate === true)),
532 ),
533 Effect.orElseSucceed(() => false),
534 ),
511535 ],
512536 { concurrency: 2 },
513537 ).pipe(
apps/server/src/pullRequest/PullRequestProvider.ts+20-1
......@@ -276,6 +276,21 @@ export interface ProviderRepositoryRef {
276276 * failing at call time.
277277 */
278278export interface PullRequestProviderApi {
279 readonly withVerifiedCredential?: <A, E, R>(
280 input: { readonly cwd: string; readonly host: string },
281 use: (identity: {
282 readonly accountId: string;
283 readonly viewer: string;
284 readonly credentialFingerprint: string;
285 }) => Effect.Effect<A, E, R>,
286 ) => Effect.Effect<A, E | PullRequestProviderError, R>;
287 readonly getRoutingIdentity?: (input: {
288 readonly cwd: string;
289 readonly host: string;
290 }) => Effect.Effect<
291 { readonly accountId: string; readonly viewer: string },
292 PullRequestProviderError
293 >;
279294 readonly kind: SourceControlProviderKind;
280295 readonly capabilities: PullRequestCapabilities;
281296
......@@ -398,7 +413,11 @@ export interface PullRequestProviderApi {
398413 * is no request at all.
399414 */
400415 readonly getViewerPermissions: (
401 input: ProviderRepositoryRef & { readonly number: number },
416 input: ProviderRepositoryRef & {
417 readonly number: number;
418 /** Skip branch comparison when checking permission for an unrelated operation. */
419 readonly includeUpdateBranch?: boolean;
420 },
402421 ) => Effect.Effect<PullRequestViewerPermissions, PullRequestProviderError>;
403422
404423 /**
apps/server/src/pullRequest/PullRequestService.test.ts+197-3
......@@ -16,6 +16,7 @@ import type {
1616 PullRequestReviewerCapabilities,
1717 SourceControlProviderKind,
1818} from "@t3tools/contracts";
19import { PullRequestOperationError } from "@t3tools/contracts";
1920
2021import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
2122import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
......@@ -974,6 +975,51 @@ it.effect("tries another workspace on the same host for the viewer", () =>
974975 }),
975976);
976977
978it.effect("routing verifies the current account on the requested host without caching it", () =>
979 Effect.gen(function* () {
980 let viewer = "first-account";
981 const service = yield* makeService({
982 projects: [
983 project({
984 id: "p1",
985 title: "web",
986 workspaceRoot: "/a",
987 repository: "acme/web",
988 host: "github.example.test",
989 }),
990 ],
991 providers: [
992 fakeProvider("github", {
993 getRoutingIdentity: (input) => {
994 assert.deepStrictEqual(input, { cwd: "/a", host: "github.example.test" });
995 return Effect.succeed({
996 viewer,
997 accountId: viewer === "first-account" ? "123" : "456",
998 });
999 },
1000 }),
1001 ],
1002 });
1003 const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
1004 assert.deepStrictEqual(yield* service.routing(ref), {
1005 host: "github.example.test",
1006 provider: "github",
1007 viewer: "first-account",
1008 accountId: "123",
1009 projectTitle: "web",
1010 workspaceRoot: "/a",
1011 });
1012 viewer = "second-account";
1013 assert.strictEqual((yield* service.routing(ref)).viewer, "second-account");
1014 viewer = " ";
1015 const failure = yield* service.routing(ref).pipe(Effect.flip);
1016 assert.strictEqual(failure._tag, "PullRequestOperationError");
1017 if (failure._tag === "PullRequestOperationError") {
1018 assert.strictEqual(failure.operation, "routeIdentity");
1019 }
1020 }),
1021);
1022
9771023it.effect("refuses an action the host never claimed it could run", () =>
9781024 Effect.gen(function* () {
9791025 let ran = false;
......@@ -3707,9 +3753,7 @@ it.effect("shares linked summaries and reuses them for display without asking th
37073753
37083754 yield* TestClock.adjust("61 seconds");
37093755 failing = true;
3710 const strict = yield* Effect.flip(
3711 service.summary(reference, { recoverTransientFailure: false }),
3712 );
3756 const strict = yield* Effect.flip(service.summary({ ...reference, allowStale: false }));
37133757 assert.strictEqual(strict._tag, "PullRequestOperationError");
37143758
37153759 const stale = yield* service.summary(reference);
......@@ -3723,6 +3767,147 @@ it.effect("shares linked summaries and reuses them for display without asking th
37233767 }),
37243768);
37253769
3770it.effect("keeps routed summaries and details separate when the GitHub account changes", () =>
3771 Effect.gen(function* () {
3772 for (const operation of ["summary", "detail"] as const) {
3773 let failing = false;
3774 let calls = 0;
3775 const read = () =>
3776 Effect.suspend(() => {
3777 calls += 1;
3778 return failing
3779 ? Effect.fail(requestFailed)
3780 : Effect.succeed(hostedChangeRequest("account A content"));
3781 });
3782 const service = yield* makeService({
3783 projects: [
3784 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
3785 ],
3786 providers: [
3787 fakeProvider("github", { getChangeRequestSummary: read, getChangeRequest: read }),
3788 ],
3789 });
3790 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
3791 yield* service[operation]({ ...reference, expectedAccountId: "101" });
3792 failing = true;
3793
3794 for (const allowStale of [false, true]) {
3795 const error = yield* Effect.flip(
3796 service[operation]({ ...reference, expectedAccountId: "202", allowStale }),
3797 );
3798 assert.strictEqual(error._tag, "PullRequestOperationError");
3799 }
3800 assert.strictEqual(calls, 3);
3801 }
3802 }),
3803);
3804
3805it.effect("isolates routed caches for two credentials belonging to the same account", () =>
3806 Effect.gen(function* () {
3807 for (const operation of ["summary", "detail"] as const) {
3808 let credential = "broad";
3809 let calls = 0;
3810 const read = () =>
3811 Effect.suspend(() => {
3812 calls += 1;
3813 return credential === "broad"
3814 ? Effect.succeed(hostedChangeRequest("private content"))
3815 : Effect.fail(requestFailed);
3816 });
3817 const service = yield* makeService({
3818 projects: [
3819 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
3820 ],
3821 providers: [
3822 fakeProvider("github", {
3823 withVerifiedCredential: (_, use) =>
3824 Effect.suspend(() =>
3825 use({
3826 accountId: "101",
3827 viewer: "octocat",
3828 credentialFingerprint: credential,
3829 }),
3830 ),
3831 getChangeRequest: read,
3832 getChangeRequestSummary: read,
3833 }),
3834 ],
3835 });
3836 const reference = {
3837 projectId: "p1" as ProjectId,
3838 repository: "acme/web",
3839 number: 1,
3840 host: "github.com",
3841 expectedAccountId: "101",
3842 };
3843 yield* service.withRoutingCredential(reference, service[operation](reference));
3844 credential = "restricted";
3845 for (const allowStale of [false, true]) {
3846 const error = yield* Effect.flip(
3847 service.withRoutingCredential(
3848 reference,
3849 service[operation]({ ...reference, allowStale }),
3850 ),
3851 );
3852 assert.strictEqual(error._tag, "PullRequestOperationError");
3853 }
3854 assert.strictEqual(calls, 3);
3855 }
3856 }),
3857);
3858
3859it.effect("rejects mismatched routing credentials before use and preserves action errors", () =>
3860 Effect.gen(function* () {
3861 let operations = 0;
3862 const service = yield* makeService({
3863 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
3864 providers: [
3865 fakeProvider("github", {
3866 getRoutingIdentity: () => Effect.succeed({ accountId: "101", viewer: "octocat" }),
3867 withVerifiedCredential: (_, use) =>
3868 use({
3869 accountId: "101",
3870 viewer: "octocat",
3871 credentialFingerprint: "credential-a",
3872 }),
3873 }),
3874 ],
3875 });
3876 const reference = {
3877 projectId: "p1" as ProjectId,
3878 repository: "acme/web",
3879 number: 1,
3880 host: "github.com",
3881 expectedAccountId: "202",
3882 };
3883 const actionError = new PullRequestOperationError({
3884 operation: "runAction",
3885 detail: "ambiguous",
3886 });
3887 const operation = Effect.sync(() => {
3888 operations += 1;
3889 }).pipe(Effect.andThen(Effect.fail(actionError)));
3890 const rejected = yield* Effect.flip(service.withRoutingCredential(reference, operation));
3891 assert.strictEqual(rejected._tag, "PullRequestOperationError");
3892 if (rejected._tag === "PullRequestOperationError")
3893 assert.strictEqual(rejected.operation, "routeIdentity");
3894 assert.strictEqual(operations, 0);
3895 assert.strictEqual(
3896 yield* Effect.flip(
3897 service.withRoutingCredential({ ...reference, expectedAccountId: "101" }, operation),
3898 ),
3899 actionError,
3900 );
3901 assert.strictEqual(operations, 1);
3902 assert.deepStrictEqual(yield* service.routingIdentity({ host: "github.com" }), {
3903 accountId: "101",
3904 viewer: "octocat",
3905 host: "github.com",
3906 provider: "github",
3907 });
3908 }),
3909);
3910
37263911it.effect("answers a known pull request immediately while the host refreshes", () =>
37273912 Effect.gen(function* () {
37283913 const gate = yield* Deferred.make<void>();
......@@ -3895,6 +4080,13 @@ it.effect("does not let a stale detail reopen overwrite a fresher linked summary
38954080 assert.strictEqual(display.title, "merged title");
38964081 assert.strictEqual(display.state, "merged");
38974082 assert.strictEqual(detailCalls, 2);
4083
4084 summaryTitle = "updated after merge";
4085 yield* TestClock.adjust("61 seconds");
4086 assert.strictEqual((yield* service.summary(reference)).title, "merged title");
4087 const refreshed = yield* service.summary({ ...reference, allowStale: false });
4088 assert.strictEqual(refreshed.title, "updated after merge");
4089 assert.strictEqual(refreshed.state, "merged");
38984090 }),
38994091);
39004092
......@@ -3978,6 +4170,8 @@ it.effect("keeps recent detail on a transient refresh failure but not after inva
39784170 yield* service.detail(reference);
39794171 yield* TestClock.adjust("16 seconds");
39804172 failing = true;
4173 const strict = yield* Effect.flip(service.detail({ ...reference, allowStale: false }));
4174 assert.strictEqual(strict._tag, "PullRequestOperationError");
39814175 const stale = yield* service.detail(reference);
39824176 assert.strictEqual(stale.body, "last good body");
39834177
apps/server/src/pullRequest/PullRequestService.ts+180-31
......@@ -46,6 +46,9 @@ import {
4646 type PullRequestProviderSummary,
4747 type PullRequestReactionInput,
4848 type PullRequestRef,
49 type PullRequestRoutingResult,
50 type PullRequestRoutingIdentityInput,
51 type PullRequestRoutingIdentityResult,
4952 type PullRequestReviewVerdict,
5053 type PullRequestReviewerCandidateList,
5154 type PullRequestReviewerRequestInput,
......@@ -138,6 +141,14 @@ const VIEWER_CACHE_CAPACITY = 32;
138141
139142export type PullRequestError = PullRequestUnavailableError | PullRequestOperationError;
140143
144const routingCredential = Context.Reference<{
145 readonly credentialFingerprint: string;
146 readonly viewer: string;
147} | null>("t3/PullRequestService/routingCredential", { defaultValue: () => null });
148// Internal only: the client cannot choose its cache's credential namespace.
149const credentialNamespace = Symbol("pullRequestCredentialNamespace");
150type CredentialRef = PullRequestRef & { readonly [credentialNamespace]?: string };
151
141152export class PullRequestService extends Context.Service<
142153 PullRequestService,
143154 {
......@@ -147,6 +158,16 @@ export class PullRequestService extends Context.Service<
147158 readonly listStats: (
148159 input: PullRequestListStatsInput,
149160 ) => Effect.Effect<PullRequestListStatsResult, PullRequestError>;
161 readonly routing: (
162 input: PullRequestRef,
163 ) => Effect.Effect<PullRequestRoutingResult, PullRequestError>;
164 readonly routingIdentity: (
165 input: PullRequestRoutingIdentityInput,
166 ) => Effect.Effect<PullRequestRoutingIdentityResult, PullRequestError>;
167 readonly withRoutingCredential: <A, E, R>(
168 input: PullRequestRef,
169 operation: Effect.Effect<A, E, R>,
170 ) => Effect.Effect<A, E | PullRequestError, R>;
150171 readonly summary: (
151172 input: PullRequestRef,
152173 options?: { readonly recoverTransientFailure?: boolean },
......@@ -473,6 +494,10 @@ function withRateLimitBackoff(
473494 kind: api.kind,
474495 capabilities: api.capabilities,
475496 getViewer: wrap("getViewer", api.getViewer),
497 ...(api.getRoutingIdentity === undefined ? {} : { getRoutingIdentity: api.getRoutingIdentity }),
498 ...(api.withVerifiedCredential === undefined
499 ? {}
500 : { withVerifiedCredential: api.withVerifiedCredential }),
476501 listChangeRequests: wrap("listChangeRequests", api.listChangeRequests),
477502 ...(api.listChangeRequestsAcross === undefined
478503 ? {}
......@@ -776,13 +801,19 @@ export const make = Effect.gen(function* () {
776801 * handed to a provider on the client's word. Read freshly for that reason, rather than taken
777802 * from whatever the detail said when the page loaded.
778803 */
779 const viewerPermissionsOf = (project: SupportedProject, ref: PullRequestRef, operation: string) =>
804 const viewerPermissionsOf = (
805 project: SupportedProject,
806 ref: PullRequestRef,
807 operation: string,
808 includeUpdateBranch = false,
809 ) =>
780810 project.api
781811 .getViewerPermissions({
782812 cwd: project.project.workspaceRoot,
783813 repository: project.repository,
784814 host: project.host,
785815 number: ref.number,
816 includeUpdateBranch,
786817 })
787818 .pipe(Effect.mapError(toPullRequestError(operation)));
788819
......@@ -1309,7 +1340,92 @@ export const make = Effect.gen(function* () {
13091340 * and a host that cannot say leaves it null rather than failing the read it decorates.
13101341 */
13111342 const viewerOf = (project: SupportedProject): Effect.Effect<string | null> =>
1312 resolveViewers([project], new Map()).pipe(Effect.map(([resolved]) => resolved?.viewer ?? null));
1343 routingCredential.pipe(
1344 Effect.flatMap((credential) =>
1345 credential !== null
1346 ? Effect.succeed(credential.viewer)
1347 : resolveViewers([project], new Map()).pipe(
1348 Effect.map(([resolved]) => resolved?.viewer ?? null),
1349 ),
1350 ),
1351 );
1352
1353 const routingIdentity: PullRequestService["Service"]["routingIdentity"] = Effect.fn(
1354 "PullRequestService.routingIdentity",
1355 )(function* (input) {
1356 const host = input.host.toLowerCase();
1357 const { supported } = yield* listWorkspaceProjects({ host });
1358 const project = supported.find((candidate) => candidate.api.kind === "github");
1359 const api = registry.get("github");
1360 if (project === undefined || api?.getRoutingIdentity === undefined) {
1361 return yield* new PullRequestUnavailableError({ reason: "provider-unsupported" });
1362 }
1363 const identity = yield* api
1364 .getRoutingIdentity({
1365 cwd: project.project.workspaceRoot,
1366 host,
1367 })
1368 .pipe(Effect.mapError(toPullRequestError("routeIdentity")));
1369 return { ...identity, host, provider: "github" as const };
1370 });
1371
1372 const withRoutingCredential: PullRequestService["Service"]["withRoutingCredential"] = (
1373 input,
1374 operation,
1375 ) =>
1376 Effect.gen(function* () {
1377 if (input.expectedAccountId === undefined) return yield* operation;
1378 const rejected = () =>
1379 new PullRequestOperationError({
1380 operation: "routeIdentity",
1381 detail: "The GitHub account could not be verified before starting the operation.",
1382 });
1383 const project = yield* requireProject(input).pipe(Effect.mapError(rejected));
1384 const api = project.api.kind === "github" ? registry.get("github") : null;
1385 if (
1386 api?.withVerifiedCredential === undefined ||
1387 input.host?.toLowerCase() !== project.host.toLowerCase()
1388 ) {
1389 return yield* rejected();
1390 }
1391 const result = yield* api
1392 .withVerifiedCredential(
1393 { cwd: project.project.workspaceRoot, host: project.host },
1394 (identity) =>
1395 identity.accountId === input.expectedAccountId
1396 ? operation.pipe(Effect.provideService(routingCredential, identity), Effect.result)
1397 : Effect.fail(rejected()),
1398 )
1399 .pipe(Effect.catchTag("PullRequestProviderError", () => Effect.fail(rejected())));
1400 return yield* Effect.fromResult(result);
1401 });
1402
1403 const routing = Effect.fn("PullRequestService.routing")(function* (input: PullRequestRef) {
1404 const project = yield* requireProject(input);
1405 const api = project.api.kind === "github" ? registry.get("github") : null;
1406 if (api?.getRoutingIdentity === undefined) {
1407 return yield* new PullRequestUnavailableError({ reason: "provider-unsupported" });
1408 }
1409 const identity = yield* api
1410 .getRoutingIdentity({
1411 cwd: project.project.workspaceRoot,
1412 host: project.host,
1413 })
1414 .pipe(Effect.mapError(toPullRequestError("routeIdentity")));
1415 if (!identity.viewer.trim() || !identity.accountId.trim()) {
1416 return yield* new PullRequestOperationError({
1417 operation: "routeIdentity",
1418 detail: "The signed-in account could not be verified.",
1419 });
1420 }
1421 return {
1422 host: project.host,
1423 provider: project.api.kind,
1424 ...identity,
1425 projectTitle: project.project.title,
1426 workspaceRoot: project.project.workspaceRoot,
1427 };
1428 });
13131429
13141430 const summaryUncached: PullRequestService["Service"]["summary"] = (input) =>
13151431 requireProject(input).pipe(
......@@ -1620,7 +1736,12 @@ export const make = Effect.gen(function* () {
16201736 // What the host can do and what this account may ask of it are two questions, and both
16211737 // have to say yes. The second is asked last, because it costs a request and the checks
16221738 // above do not.
1623 return viewerPermissionsOf(project, input, "runAction").pipe(
1739 return viewerPermissionsOf(
1740 project,
1741 input,
1742 "runAction",
1743 input.action === "update-branch",
1744 ).pipe(
16241745 Effect.flatMap((viewer): Effect.Effect<string, PullRequestError> => {
16251746 const stackRebase = input.stackNumber !== undefined && input.action === "update-branch";
16261747 if (
......@@ -2193,6 +2314,12 @@ export const make = Effect.gen(function* () {
21932314
21942315 const context = yield* Effect.context<never>();
21952316 const runFork = Effect.runForkWith(context);
2317 const revalidate = <A, E>(read: Effect.Effect<A, E>) =>
2318 Effect.context<never>().pipe(
2319 Effect.flatMap((caller) =>
2320 Effect.sync(() => runFork(Effect.ignore(read).pipe(Effect.provideContext(caller)))),
2321 ),
2322 );
21962323
21972324 /**
21982325 * The diff is not live-polled and is expensive enough to keep its stale-while-revalidate path.
......@@ -2218,7 +2345,7 @@ export const make = Effect.gen(function* () {
22182345 // Run as its own fiber rather than a child: the caller is answered and gone before the
22192346 // refresh lands. The read still coalesces on the cache key, so ten stale reads in one
22202347 // window cost one host request — and a failed refresh costs nothing but the retry.
2221 return Effect.sync(() => runFork(Effect.ignore(recorded))).pipe(Effect.as(snapshot.value));
2348 return revalidate(recorded).pipe(Effect.as(snapshot.value));
22222349 });
22232350 };
22242351 })();
......@@ -2276,9 +2403,7 @@ export const make = Effect.gen(function* () {
22762403 const snapshot = held.get(key);
22772404 if (snapshot === undefined) return read(key, effect);
22782405 if (mode === "reuse") return Effect.succeed(snapshot.value);
2279 return Effect.sync(() => runFork(Effect.ignore(read(key, effect)))).pipe(
2280 Effect.as(snapshot.value),
2281 );
2406 return revalidate(read(key, effect)).pipe(Effect.as(snapshot.value));
22822407 };
22832408 return { peek: (key: string) => held.get(key)?.value, read, record, serveHeld };
22842409 };
......@@ -2300,25 +2425,25 @@ export const make = Effect.gen(function* () {
23002425 Math.max(turnRefreshEpoch, refEpochs.get(refScope(ref)) ?? 0);
23012426 // Keys carry the reference back out of the cache loader, so the slot layout is shared with
23022427 // `refOfCacheKey` rather than read positionally at every loader.
2303 const refCacheKey = (ref: PullRequestRef) =>
2428 const refCacheKey = (ref: CredentialRef) =>
23042429 JSON.stringify([
23052430 refEpoch(ref),
23062431 ref.projectId,
23072432 ref.host?.toLowerCase() ?? null,
23082433 ref.repository.toLowerCase(),
23092434 ref.number,
2435 ref.expectedAccountId ?? null,
2436 ref[credentialNamespace] ?? null,
23102437 ]);
23112438 const refOfCacheKey = (key: string): PullRequestRef => {
2312 const [, projectId, host, repository, number] = JSON.parse(key) as [
2313 number,
2314 string,
2315 string | null,
2316 string,
2317 number,
2318 ];
2439 const [, projectId, host, repository, number, expectedAccountId, fingerprint] = JSON.parse(
2440 key,
2441 ) as [number, string, string | null, string, number, string | null, string | null];
23192442 return {
23202443 projectId,
23212444 ...(host === null ? {} : { host }),
2445 ...(expectedAccountId === null ? {} : { expectedAccountId }),
2446 ...(fingerprint === null ? {} : { [credentialNamespace]: fingerprint }),
23222447 repository,
23232448 number,
23242449 } as PullRequestRef;
......@@ -2365,7 +2490,7 @@ export const make = Effect.gen(function* () {
23652490 };
23662491
23672492 const persistedRead = Effect.fn("PullRequestService.persistedRead")(function* <A>(
2368 input: PullRequestRef,
2493 input: CredentialRef,
23692494 operation: string,
23702495 codec: Schema.Codec<A, string>,
23712496 read: Effect.Effect<A, PullRequestError>,
......@@ -2379,6 +2504,8 @@ export const make = Effect.gen(function* () {
23792504 project.project.id,
23802505 project.project.workspaceRoot,
23812506 String(input.number),
2507 input.expectedAccountId ?? "",
2508 input[credentialNamespace] ?? "",
23822509 ]
23832510 .map(encodeURIComponent)
23842511 .join(":");
......@@ -2409,6 +2536,7 @@ export const make = Effect.gen(function* () {
24092536 const cached = persistedRead(input, "summary", summaryCodec, summaryUncached(input));
24102537 const held = lastGoodSummary.peek(key);
24112538 return held !== undefined &&
2539 input.allowStale !== false &&
24122540 (options?.recoverTransientFailure !== false || held.state === "merged")
24132541 ? Effect.succeed(held)
24142542 : cached.pipe(
......@@ -2566,18 +2694,17 @@ export const make = Effect.gen(function* () {
25662694 // `serveHeld` returns immediately. Skip the write when that read is older
25672695 // than a later strict summary — display reuse would otherwise keep the
25682696 // regression and never ask the host again.
2569 return lastGoodDetail.serveHeld(
2570 key,
2571 Cache.get(detailCache, key).pipe(
2572 Effect.tap((value) => {
2573 const summary = summaryFromDetail(value, lastGoodSummary.peek(key));
2574 return shouldReplaceHeldSummary(key, summary)
2575 ? lastGoodSummary.record(key, summary)
2576 : Effect.void;
2577 }),
2578 ),
2579 "revalidate",
2697 const read = Cache.get(detailCache, key).pipe(
2698 Effect.tap((value) => {
2699 const summary = summaryFromDetail(value, lastGoodSummary.peek(key));
2700 return shouldReplaceHeldSummary(key, summary)
2701 ? lastGoodSummary.record(key, summary)
2702 : Effect.void;
2703 }),
25802704 );
2705 return input.allowStale === false
2706 ? read.pipe(Effect.tap((value) => lastGoodDetail.record(key, value)))
2707 : lastGoodDetail.serveHeld(key, read, "revalidate");
25812708 };
25822709
25832710 const activityCache = yield* Cache.makeWith(
......@@ -2758,11 +2885,33 @@ export const make = Effect.gen(function* () {
27582885 }
27592886 });
27602887
2888 const credentialCached =
2889 <I extends PullRequestRef, Args extends ReadonlyArray<unknown>, A, E>(
2890 read: (input: I, ...args: Args) => Effect.Effect<A, E>,
2891 ) =>
2892 (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 );
2906
27612907 return PullRequestService.of({
2908 routing,
2909 routingIdentity,
2910 withRoutingCredential,
27622911 list,
27632912 listStats,
2764 summary,
2765 stack,
2913 summary: credentialCached(summary),
2914 stack: credentialCached(stack),
27662915 subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe(
27672916 Effect.map((subscription) => Stream.fromSubscription(subscription)),
27682917 ),
......@@ -2770,8 +2919,8 @@ export const make = Effect.gen(function* () {
27702919 Stream.filter((revision) => revision > 0),
27712920 ),
27722921 refreshAfterTurn,
2773 detail,
2774 activity,
2922 detail: credentialCached(detail),
2923 activity: credentialCached(activity),
27752924 threadComments,
27762925 diff,
27772926 diffFileContents,
apps/server/src/sourceControl/GitHubCli.test.ts+110
......@@ -1,13 +1,18 @@
11import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest";
2import * as Cache from "effect/Cache";
23import * as Effect from "effect/Effect";
34import * as Layer from "effect/Layer";
45import * as PlatformError from "effect/PlatformError";
6import * as Redacted from "effect/Redacted";
7import * as Schema from "effect/Schema";
58import { ChildProcessSpawner } from "effect/unstable/process";
69import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts";
710
811import * as VcsProcess from "../vcs/VcsProcess.ts";
912import * as GitHubCli from "./GitHubCli.ts";
1013
14const encodeGitHubCliError = Schema.encodeEffect(Schema.fromJsonString(GitHubCli.GitHubCliError));
15
1116const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({
1217 exitCode: ChildProcessSpawner.ExitCode(0),
1318 stdout,
......@@ -31,6 +36,111 @@ afterEach(() => {
3136});
3237
3338describe("GitHubCli.layer", () => {
39 it.effect("pins concurrent cached commands to their own verified credentials", () =>
40 Effect.gen(function* () {
41 mockRun.mockImplementation((input) =>
42 Effect.succeed(processOutput(input.env?.GH_TOKEN ?? "ambient")),
43 );
44 const gh = yield* GitHubCli.GitHubCli;
45 // Constructed outside either request, like the PR service's read caches.
46 const cache = yield* Cache.make({
47 lookup: (host: string) =>
48 gh.execute({
49 cwd: "/repo",
50 args: ["api", "user", "--hostname", host],
51 env: { GH_DEBUG: "api", GH_TOKEN: "changed-after-verification" },
52 }),
53 capacity: 2,
54 timeToLive: "1 minute",
55 });
56 const results = yield* Effect.all(
57 ["github.com", "github.example.test"].map((host, index) =>
58 Cache.get(cache, host).pipe(
59 Effect.provideService(GitHubCli.PinnedGitHubCredential, {
60 host,
61 token: Redacted.make(`credential-${index}`),
62 credentialFingerprint: `fingerprint-${index}`,
63 }),
64 ),
65 ),
66 { concurrency: 2 },
67 );
68 expect(results.map((result) => result.stdout)).toEqual(["credential-0", "credential-1"]);
69 for (const [input] of mockRun.mock.calls) {
70 expect(input.env).toMatchObject({
71 GH_HOST: input.args[3],
72 GH_DEBUG: "",
73 GH_TOKEN: input.env?.GITHUB_TOKEN,
74 GH_ENTERPRISE_TOKEN: input.env?.GH_TOKEN,
75 GITHUB_ENTERPRISE_TOKEN: input.env?.GH_TOKEN,
76 });
77 }
78 expect((yield* gh.execute({ cwd: "/repo", args: ["api", "user"] })).stdout).toBe("ambient");
79 }).pipe(Effect.provide(layer)),
80 );
81
82 it.effect("refuses other or implicit hosts before exposing a scoped credential to gh", () =>
83 Effect.gen(function* () {
84 const gh = yield* GitHubCli.GitHubCli;
85 for (const args of [
86 ["api", "user", "--hostname", "other.example.test"],
87 ["api", "user", "--hostname=other.example.test"],
88 ["pr", "view", "1", "--repo", "other.example.test/owner/repo"],
89 ["repo", "view", "other.example.test/owner/repo", "--json", "name"],
90 ["api", "https://other.example.test/user", "--hostname", "github.com"],
91 ["api", "user"],
92 ]) {
93 const failure = yield* gh.execute({ cwd: "/repo", args }).pipe(
94 Effect.provideService(GitHubCli.PinnedGitHubCredential, {
95 host: "github.com",
96 token: Redacted.make("secret-credential"),
97 credentialFingerprint: "fingerprint",
98 }),
99 Effect.flip,
100 );
101 expect(failure._tag).toBe("GitHubCliCommandError");
102 expect(yield* encodeGitHubCliError(failure)).not.toContain("secret-credential");
103 }
104 expect(mockRun).not.toHaveBeenCalled();
105 }).pipe(Effect.provide(layer)),
106 );
107
108 it.effect("pins repository-targeted writes on enterprise hosts", () =>
109 Effect.gen(function* () {
110 mockRun.mockReturnValue(Effect.succeed(processOutput("")));
111 const gh = yield* GitHubCli.GitHubCli;
112 yield* gh
113 .execute({
114 cwd: "/repo",
115 args: ["pr", "merge", "1", "--repo", "github.example.test/owner/repo"],
116 })
117 .pipe(
118 Effect.provideService(GitHubCli.PinnedGitHubCredential, {
119 host: "github.example.test",
120 token: Redacted.make("enterprise-credential"),
121 credentialFingerprint: "fingerprint",
122 }),
123 );
124 yield* gh
125 .execute({
126 cwd: "/repo",
127 args: ["repo", "view", "github.example.test/owner/repo", "--json", "name"],
128 })
129 .pipe(
130 Effect.provideService(GitHubCli.PinnedGitHubCredential, {
131 host: "github.example.test",
132 token: Redacted.make("enterprise-credential"),
133 credentialFingerprint: "fingerprint",
134 }),
135 );
136 expect(mockRun.mock.calls[0]?.[0].env).toMatchObject({
137 GH_HOST: "github.example.test",
138 GH_ENTERPRISE_TOKEN: "enterprise-credential",
139 GH_DEBUG: "",
140 });
141 }).pipe(Effect.provide(layer)),
142 );
143
34144 it("does not classify a missing cwd as an unavailable gh executable", () => {
35145 const context = { command: "gh", cwd: "/repo" } as const;
36146 const missingCwd = new VcsProcessSpawnError({
apps/server/src/sourceControl/GitHubCli.ts+73-12
......@@ -4,6 +4,7 @@ import * as Effect from "effect/Effect";
44import * as Layer from "effect/Layer";
55import * as Option from "effect/Option";
66import * as PlatformError from "effect/PlatformError";
7import * as Redacted from "effect/Redacted";
78import * as Result from "effect/Result";
89import * as Schema from "effect/Schema";
910
......@@ -22,6 +23,40 @@ import {
2223
2324const DEFAULT_TIMEOUT_MS = 30_000;
2425
26/** Server-local credential scope; never put its value in RPC payloads or cache keys. */
27export const PinnedGitHubCredential = Context.Reference<{
28 readonly host: string;
29 readonly token: Redacted.Redacted<string>;
30 readonly credentialFingerprint: string;
31} | null>("t3/sourceControl/PinnedGitHubCredential", { defaultValue: () => null });
32
33function targetsVerifiedHost(args: ReadonlyArray<string>, host: string): boolean {
34 const hosts: Array<string | null> = [];
35 const repositoryHost = (repository: string | undefined) => {
36 if (repository === undefined) return null;
37 if (/^https?:\/\//i.test(repository)) {
38 try {
39 return new URL(repository).host.toLowerCase();
40 } catch {
41 return null;
42 }
43 }
44 const parts = repository.split("/");
45 return parts.length === 3 ? parts[0]!.toLowerCase() : null;
46 };
47 if (args[0] === "repo" && args[1] === "view") hosts.push(repositoryHost(args[2]));
48 for (let index = 0; index < args.length; index++) {
49 const arg = args[index]!;
50 if (arg === "--hostname") hosts.push(args[++index]?.toLowerCase() ?? null);
51 else if (arg.startsWith("--hostname=")) hosts.push(arg.slice(11).toLowerCase());
52 else if (arg === "--repo" || arg === "-R") hosts.push(repositoryHost(args[++index]));
53 else if (arg.startsWith("--repo=")) hosts.push(repositoryHost(arg.slice(7)));
54 else if (arg.startsWith("-R")) hosts.push(repositoryHost(arg.slice(2)));
55 else if (/^https?:\/\//i.test(arg)) hosts.push(repositoryHost(arg));
56 }
57 return hosts.length > 0 && hosts.every((target) => target === host);
58}
59
2560const gitHubCliFailureFields = {
2661 command: Schema.Literal("gh"),
2762 cwd: Schema.String,
......@@ -237,6 +272,7 @@ export class GitHubCli extends Context.Service<
237272 readonly timeoutMs?: number;
238273 /** Piped to the child's stdin, for payloads that must never appear in argv. */
239274 readonly stdin?: string;
275 readonly env?: NodeJS.ProcessEnv;
240276 readonly maxOutputBytes?: number;
241277 }) => Effect.Effect<VcsProcess.VcsProcessOutput, GitHubCliError>;
242278
......@@ -342,18 +378,43 @@ function deriveRepositoryCloneUrlsFromCreateOutput(
342378export const make = Effect.gen(function* () {
343379 const process = yield* VcsProcess.VcsProcess;
344380
345 const execute: GitHubCli["Service"]["execute"] = (input) =>
346 process
347 .run({
348 operation: "GitHubCli.execute",
349 command: "gh",
350 args: input.args,
351 cwd: input.cwd,
352 timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
353 ...(input.stdin !== undefined ? { stdin: input.stdin } : {}),
354 ...(input.maxOutputBytes !== undefined ? { maxOutputBytes: input.maxOutputBytes } : {}),
355 })
356 .pipe(Effect.mapError((error) => fromVcsError({ command: "gh", cwd: input.cwd }, error)));
381 const execute: GitHubCli["Service"]["execute"] = Effect.fn("GitHubCli.execute")(
382 function* (input) {
383 const credential = yield* PinnedGitHubCredential;
384 if (credential !== null && !targetsVerifiedHost(input.args, credential.host)) {
385 return yield* new GitHubCliCommandError({
386 command: "gh",
387 cwd: input.cwd,
388 cause: new Error("The GitHub command does not target the verified credential's host."),
389 });
390 }
391 const token = credential === null ? undefined : Redacted.value(credential.token);
392 const env =
393 credential === null
394 ? input.env
395 : {
396 ...input.env,
397 GH_HOST: credential.host,
398 GH_TOKEN: token,
399 GITHUB_TOKEN: token,
400 GH_ENTERPRISE_TOKEN: token,
401 GITHUB_ENTERPRISE_TOKEN: token,
402 GH_DEBUG: "",
403 };
404 return yield* process
405 .run({
406 operation: "GitHubCli.execute",
407 command: "gh",
408 args: input.args,
409 cwd: input.cwd,
410 timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
411 ...(input.stdin !== undefined ? { stdin: input.stdin } : {}),
412 ...(env !== undefined ? { env } : {}),
413 ...(input.maxOutputBytes !== undefined ? { maxOutputBytes: input.maxOutputBytes } : {}),
414 })
415 .pipe(Effect.mapError((error) => fromVcsError({ command: "gh", cwd: input.cwd }, error)));
416 },
417 );
357418
358419 return GitHubCli.of({
359420 execute,
apps/server/src/ws.ts+89-42
......@@ -630,6 +630,7 @@ const makeWsRpcLayer = (
630630 const sourceControlRepositories =
631631 yield* SourceControlRepositoryService.SourceControlRepositoryService;
632632 const pullRequests = yield* PullRequestService.PullRequestService;
633 const withPullRequestViewer = pullRequests.withRoutingCredential;
633634 const pullRequestSync = yield* PullRequestSyncReactor.PullRequestSyncReactor;
634635 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
635636 const sessions = yield* SessionStore.SessionStore;
......@@ -2156,14 +2157,34 @@ const makeWsRpcLayer = (
21562157 observeRpcEffect(WS_METHODS.pullRequestsListStats, pullRequests.listStats(input), {
21572158 "rpc.aggregate": "pull-requests",
21582159 }),
2159 [WS_METHODS.pullRequestsSummary]: (input) =>
2160 observeRpcEffect(WS_METHODS.pullRequestsSummary, pullRequests.summary(input), {
2160 [WS_METHODS.pullRequestsRoutingIdentity]: (input) =>
2161 observeRpcEffect(
2162 WS_METHODS.pullRequestsRoutingIdentity,
2163 pullRequests.routingIdentity(input),
2164 {
2165 "rpc.aggregate": "pull-requests",
2166 },
2167 ),
2168 [WS_METHODS.pullRequestsRouting]: (input) =>
2169 observeRpcEffect(WS_METHODS.pullRequestsRouting, pullRequests.routing(input), {
21612170 "rpc.aggregate": "pull-requests",
21622171 }),
2172 [WS_METHODS.pullRequestsSummary]: (input) =>
2173 observeRpcEffect(
2174 WS_METHODS.pullRequestsSummary,
2175 withPullRequestViewer(input, pullRequests.summary(input)),
2176 {
2177 "rpc.aggregate": "pull-requests",
2178 },
2179 ),
21632180 [WS_METHODS.pullRequestsStack]: (input) =>
2164 observeRpcEffect(WS_METHODS.pullRequestsStack, pullRequests.stack(input), {
2165 "rpc.aggregate": "pull-requests",
2166 }),
2181 observeRpcEffect(
2182 WS_METHODS.pullRequestsStack,
2183 withPullRequestViewer(input, pullRequests.stack(input)),
2184 {
2185 "rpc.aggregate": "pull-requests",
2186 },
2187 ),
21672188 [WS_METHODS.pullRequestsLinkedThreads]: (input) =>
21682189 observeRpcEffect(
21692190 WS_METHODS.pullRequestsLinkedThreads,
......@@ -2179,17 +2200,25 @@ const makeWsRpcLayer = (
21792200 { "rpc.aggregate": "pull-requests" },
21802201 ),
21812202 [WS_METHODS.pullRequestsDetail]: (input) =>
2182 observeRpcEffect(WS_METHODS.pullRequestsDetail, pullRequests.detail(input), {
2183 "rpc.aggregate": "pull-requests",
2184 }),
2203 observeRpcEffect(
2204 WS_METHODS.pullRequestsDetail,
2205 withPullRequestViewer(input, pullRequests.detail(input)),
2206 {
2207 "rpc.aggregate": "pull-requests",
2208 },
2209 ),
21852210 [WS_METHODS.pullRequestsActivity]: (input) =>
2186 observeRpcEffect(WS_METHODS.pullRequestsActivity, pullRequests.activity(input), {
2187 "rpc.aggregate": "pull-requests",
2188 }),
2211 observeRpcEffect(
2212 WS_METHODS.pullRequestsActivity,
2213 withPullRequestViewer(input, pullRequests.activity(input)),
2214 {
2215 "rpc.aggregate": "pull-requests",
2216 },
2217 ),
21892218 [WS_METHODS.pullRequestsThreadComments]: (input) =>
21902219 observeRpcEffect(
21912220 WS_METHODS.pullRequestsThreadComments,
2192 pullRequests.threadComments(input),
2221 withPullRequestViewer(input, pullRequests.threadComments(input)),
21932222 {
21942223 "rpc.aggregate": "pull-requests",
21952224 },
......@@ -2197,61 +2226,75 @@ const makeWsRpcLayer = (
21972226 [WS_METHODS.pullRequestsDiffFileContents]: (input) =>
21982227 observeRpcEffect(
21992228 WS_METHODS.pullRequestsDiffFileContents,
2200 pullRequests.diffFileContents(input),
2229 withPullRequestViewer(input, pullRequests.diffFileContents(input)),
22012230 { "rpc.aggregate": "pull-requests" },
22022231 ),
22032232 [WS_METHODS.pullRequestsRunAction]: (input) =>
22042233 observeRpcEffect(
22052234 WS_METHODS.pullRequestsRunAction,
2206 pullRequests
2207 .runAction(input)
2208 .pipe(
2209 Effect.tap(() =>
2210 resolvePullRequestSyncKey(input).pipe(
2211 Effect.flatMap((key) =>
2212 key === null ? Effect.void : pullRequestSync.requestSync(key),
2213 ),
2235 withPullRequestViewer(input, pullRequests.runAction(input)).pipe(
2236 Effect.tap(() =>
2237 resolvePullRequestSyncKey(input).pipe(
2238 Effect.flatMap((key) =>
2239 key === null ? Effect.void : pullRequestSync.requestSync(key),
22142240 ),
22152241 ),
22162242 ),
2243 ),
22172244 { "rpc.aggregate": "pull-requests" },
22182245 ),
22192246 [WS_METHODS.pullRequestsUpdate]: (input) =>
2220 observeRpcEffect(WS_METHODS.pullRequestsUpdate, pullRequests.update(input), {
2221 "rpc.aggregate": "pull-requests",
2222 }),
2247 observeRpcEffect(
2248 WS_METHODS.pullRequestsUpdate,
2249 withPullRequestViewer(input, pullRequests.update(input)),
2250 {
2251 "rpc.aggregate": "pull-requests",
2252 },
2253 ),
22232254 [WS_METHODS.pullRequestsComment]: (input) =>
2224 observeRpcEffect(WS_METHODS.pullRequestsComment, pullRequests.comment(input), {
2225 "rpc.aggregate": "pull-requests",
2226 }),
2255 observeRpcEffect(
2256 WS_METHODS.pullRequestsComment,
2257 withPullRequestViewer(input, pullRequests.comment(input)),
2258 {
2259 "rpc.aggregate": "pull-requests",
2260 },
2261 ),
22272262 [WS_METHODS.pullRequestsUpdateComment]: (input) =>
22282263 observeRpcEffect(
22292264 WS_METHODS.pullRequestsUpdateComment,
2230 pullRequests.updateComment(input),
2265 withPullRequestViewer(input, pullRequests.updateComment(input)),
22312266 {
22322267 "rpc.aggregate": "pull-requests",
22332268 },
22342269 ),
22352270 [WS_METHODS.pullRequestsSubmitReview]: (input) =>
2236 observeRpcEffect(WS_METHODS.pullRequestsSubmitReview, pullRequests.submitReview(input), {
2237 "rpc.aggregate": "pull-requests",
2238 }),
2271 observeRpcEffect(
2272 WS_METHODS.pullRequestsSubmitReview,
2273 withPullRequestViewer(input, pullRequests.submitReview(input)),
2274 {
2275 "rpc.aggregate": "pull-requests",
2276 },
2277 ),
22392278 [WS_METHODS.pullRequestsReplyToThread]: (input) =>
22402279 observeRpcEffect(
22412280 WS_METHODS.pullRequestsReplyToThread,
2242 pullRequests.replyToThread(input),
2281 withPullRequestViewer(input, pullRequests.replyToThread(input)),
22432282 { "rpc.aggregate": "pull-requests" },
22442283 ),
22452284 [WS_METHODS.pullRequestsSetThreadResolution]: (input) =>
22462285 observeRpcEffect(
22472286 WS_METHODS.pullRequestsSetThreadResolution,
2248 pullRequests.setThreadResolution(input),
2287 withPullRequestViewer(input, pullRequests.setThreadResolution(input)),
22492288 { "rpc.aggregate": "pull-requests" },
22502289 ),
22512290 [WS_METHODS.pullRequestsSetReaction]: (input) =>
2252 observeRpcEffect(WS_METHODS.pullRequestsSetReaction, pullRequests.setReaction(input), {
2253 "rpc.aggregate": "pull-requests",
2254 }),
2291 observeRpcEffect(
2292 WS_METHODS.pullRequestsSetReaction,
2293 withPullRequestViewer(input, pullRequests.setReaction(input)),
2294 {
2295 "rpc.aggregate": "pull-requests",
2296 },
2297 ),
22552298 [WS_METHODS.pullRequestsInvalidate]: (input) =>
22562299 observeRpcEffect(
22572300 WS_METHODS.pullRequestsInvalidate,
......@@ -2279,25 +2322,29 @@ const makeWsRpcLayer = (
22792322 [WS_METHODS.pullRequestsReviewerCandidates]: (input) =>
22802323 observeRpcEffect(
22812324 WS_METHODS.pullRequestsReviewerCandidates,
2282 pullRequests.reviewerCandidates(input),
2325 withPullRequestViewer(input, pullRequests.reviewerCandidates(input)),
22832326 { "rpc.aggregate": "pull-requests" },
22842327 ),
22852328 [WS_METHODS.pullRequestsRequestReviewers]: (input) =>
22862329 observeRpcEffect(
22872330 WS_METHODS.pullRequestsRequestReviewers,
2288 pullRequests.requestReviewers(input),
2331 withPullRequestViewer(input, pullRequests.requestReviewers(input)),
22892332 { "rpc.aggregate": "pull-requests" },
22902333 ),
22912334 [WS_METHODS.pullRequestsLabelCandidates]: (input) =>
22922335 observeRpcEffect(
22932336 WS_METHODS.pullRequestsLabelCandidates,
2294 pullRequests.labelCandidates(input),
2337 withPullRequestViewer(input, pullRequests.labelCandidates(input)),
22952338 { "rpc.aggregate": "pull-requests" },
22962339 ),
22972340 [WS_METHODS.pullRequestsSetLabels]: (input) =>
2298 observeRpcEffect(WS_METHODS.pullRequestsSetLabels, pullRequests.setLabels(input), {
2299 "rpc.aggregate": "pull-requests",
2300 }),
2341 observeRpcEffect(
2342 WS_METHODS.pullRequestsSetLabels,
2343 withPullRequestViewer(input, pullRequests.setLabels(input)),
2344 {
2345 "rpc.aggregate": "pull-requests",
2346 },
2347 ),
23012348 [WS_METHODS.sourceControlLookupRepository]: (input) =>
23022349 observeRpcEffect(
23032350 WS_METHODS.sourceControlLookupRepository,
apps/web/src/components/settings/ConnectionsSettings.tsx+2
......@@ -68,6 +68,7 @@ import {
6868import { searchableSetting } from "./settingsSearch";
6969import { EnvironmentIconPicker } from "./EnvironmentIconPicker";
7070import { LoadBalancingSettings } from "./LoadBalancingSettings";
71import { GitHubRoutingSettings } from "./GitHubRoutingSettings";
7172import { Input } from "../ui/input";
7273import { CommandShortcut } from "../ui/command";
7374import {
......@@ -3714,6 +3715,7 @@ export function ConnectionsSettings() {
37143715 savedEnvironments={savedEnvironments}
37153716 />
37163717 </SettingsSection>
3718 <GitHubRoutingSettings environments={environments} />
37173719 <LoadBalancingSettings environments={loadBalancingEnvironments} />
37183720 </SettingsPageContainer>
37193721 );
apps/web/src/components/settings/GitHubRoutingSettings.tsx created+86
......@@ -0,0 +1,86 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
3 gitHubRoutingConnectionKey,
4 gitHubRoutingPermissionFor,
5 type GitHubRoutingPermission,
6} from "@t3tools/client-runtime/connection";
7import { useState } from "react";
8
9import { environmentCatalog } from "~/connection/catalog";
10import type { EnvironmentPresentation } from "~/state/environments";
11import { useAtomCommand } from "~/state/use-atom-command";
12import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
13import { toastManager } from "../ui/toast";
14import { SettingsRow, SettingsSection } from "./settingsLayout";
15import { searchableSetting } from "./settingsSearch";
16
17const options: ReadonlyArray<{ value: GitHubRoutingPermission; label: string }> = [
18 { value: "off", label: "Off" },
19 { value: "read", label: "Read PRs" },
20 { value: "read-write", label: "Read and act" },
21];
22
23export function GitHubRoutingSettings({
24 environments,
25}: {
26 readonly environments: ReadonlyArray<EnvironmentPresentation>;
27}) {
28 const permissions = useAtomValue(environmentCatalog.githubRoutingPermissionsValueAtom);
29 const catalog = useAtomValue(environmentCatalog.catalogValueAtom);
30 const update = useAtomCommand(environmentCatalog.setGitHubRoutingPermission);
31 const [saving, setSaving] = useState(false);
32
33 return (
34 <SettingsSection {...searchableSetting("github-routing")}>
35 <SettingsRow
36 title="Share GitHub access"
37 description="Choose environments you trust to share PR data and use each other's GitHub access. Enable both environments. Read and act may use broader permissions than the original environment. This applies only to this client."
38 />
39 {environments.map((environment) => (
40 <SettingsRow
41 key={environment.environmentId}
42 title={environment.label}
43 description={environment.displayUrl ?? "T3 Connect"}
44 control={
45 <Select
46 items={options}
47 value={gitHubRoutingPermissionFor(environment.entry, permissions)}
48 disabled={
49 !catalog.isReady || saving || gitHubRoutingConnectionKey(environment.entry) === null
50 }
51 onValueChange={(permission) => {
52 if (permission === null) return;
53 setSaving(true);
54 void update({ environmentId: environment.environmentId, permission }).then(
55 (result) => {
56 setSaving(false);
57 if (result._tag === "Failure")
58 toastManager.add({
59 type: "error",
60 title: "Could not save GitHub routing permission",
61 });
62 },
63 );
64 }}
65 >
66 <SelectTrigger
67 size="sm"
68 className="w-full sm:w-40"
69 aria-label={`${environment.label} GitHub routing`}
70 >
71 <SelectValue />
72 </SelectTrigger>
73 <SelectPopup align="end" alignItemWithTrigger={false}>
74 {options.map(({ value, label }) => (
75 <SelectItem key={value} value={value}>
76 {label}
77 </SelectItem>
78 ))}
79 </SelectPopup>
80 </Select>
81 }
82 />
83 ))}
84 </SettingsSection>
85 );
86}
apps/web/src/components/settings/settingsSearch.ts+6
......@@ -688,6 +688,12 @@ export const SETTINGS_SEARCH_ITEMS = [
688688 "automatic machine environment resources cpu memory capacity preference weight shared projects",
689689 ],
690690 },
691 {
692 id: "github-routing",
693 title: "GitHub routing",
694 to: "/settings/connections",
695 searchTerms: ["pull request trusted environments shared credentials permissions read actions"],
696 },
691697 {
692698 id: "archive",
693699 title: "Archived threads",
apps/web/src/connection/storage.test.ts+101-2
......@@ -1,11 +1,22 @@
1import { ConnectionTransientError } from "@t3tools/client-runtime/connection";
1import {
2 ConnectionTransientError,
3 PrimaryConnectionTarget,
4} from "@t3tools/client-runtime/connection";
5import { EnvironmentId } from "@t3tools/contracts";
26import { ConnectionCatalogDocument } from "@t3tools/client-runtime/platform";
37import { describe, expect, it } from "@effect/vitest";
48import * as Effect from "effect/Effect";
9import * as Deferred from "effect/Deferred";
10import * as Option from "effect/Option";
511import * as Schema from "effect/Schema";
12import * as Stream from "effect/Stream";
613import { afterEach, vi } from "vite-plus/test";
714
8import { makeCatalogBackend, makeCatalogStore } from "./storage";
15import {
16 makeBrowserGitHubRoutingPermissions,
17 makeCatalogBackend,
18 makeCatalogStore,
19} from "./storage";
920
1021const emptyCatalog = {
1122 schemaVersion: 1,
......@@ -16,6 +27,7 @@ const emptyCatalog = {
1627 disabledEnvironmentIds: [],
1728} as const;
1829const decodeCatalog = Schema.decodeUnknownSync(Schema.fromJsonString(ConnectionCatalogDocument));
30const encodeCatalog = Schema.encodeSync(Schema.fromJsonString(ConnectionCatalogDocument));
1931
2032afterEach(() => {
2133 vi.unstubAllGlobals();
......@@ -76,3 +88,90 @@ describe("makeCatalogBackend", () => {
7688 }),
7789 );
7890});
91
92describe("browser GitHub routing permissions", () => {
93 it.effect("revokes across runtimes before storage events and resists stale catalog writes", () =>
94 Effect.gen(function* () {
95 const values = new Map<string, string>();
96 const localStorage: Storage = {
97 get length() {
98 return values.size;
99 },
100 key: (index) => [...values.keys()][index] ?? null,
101 getItem: (key) => values.get(key) ?? null,
102 setItem: (key, value) => {
103 values.set(key, value);
104 },
105 removeItem: (key) => {
106 values.delete(key);
107 },
108 clear: () => {
109 values.clear();
110 },
111 };
112 const firstBrowser = Object.assign(new EventTarget(), { localStorage });
113 const secondBrowser = Object.assign(new EventTarget(), { localStorage });
114 const first = makeBrowserGitHubRoutingPermissions(firstBrowser);
115 const second = makeBrowserGitHubRoutingPermissions(secondBrowser);
116 const entry = {
117 target: new PrimaryConnectionTarget({
118 environmentId: EnvironmentId.make("first"),
119 label: "First",
120 httpBaseUrl: "http://localhost:3000",
121 wsBaseUrl: "ws://localhost:3000",
122 }),
123 profile: Option.none(),
124 enabled: true,
125 };
126 const other = {
127 ...entry,
128 target: new PrimaryConnectionTarget({
129 ...entry.target,
130 environmentId: EnvironmentId.make("second"),
131 }),
132 };
133 expect(yield* first.get(entry)).toBe("off");
134 yield* first.set(entry, "read-write");
135 expect(yield* second.get(entry)).toBe("read-write");
136 const oldPermissions = Option.getOrThrow(yield* Stream.runHead(first.changes));
137 const staleCatalog = yield* makeCatalogStore({
138 read: Effect.succeed(
139 encodeCatalog({ ...emptyCatalog, githubRoutingPermissions: oldPermissions }),
140 ),
141 write: () => Effect.void,
142 });
143 yield* staleCatalog.read;
144 const listening = yield* Deferred.make<void>();
145 const revoked = yield* Deferred.make<void>();
146 yield* second.changes.pipe(
147 Stream.runForEach((permissions) =>
148 Deferred.succeed(permissions.length > 0 ? listening : revoked, undefined),
149 ),
150 Effect.forkChild,
151 );
152 yield* Deferred.await(listening);
153
154 yield* first.set(entry, "off");
155 expect(yield* second.get(entry)).toBe("off");
156 secondBrowser.dispatchEvent(Object.assign(new Event("storage"), { key: null }));
157 yield* Deferred.await(revoked);
158 yield* second.set(other, "read");
159 yield* staleCatalog.update((document) => ({ ...document, accountId: "updated" }));
160 expect(yield* second.get(entry)).toBe("off");
161 expect(yield* first.get(other)).toBe("read");
162 expect(yield* makeBrowserGitHubRoutingPermissions(firstBrowser).get(entry)).toBe("off");
163
164 yield* first.set(entry, "read-write");
165 yield* second.forget(entry.target.environmentId);
166 expect(yield* first.get(entry)).toBe("off");
167 expect(yield* first.get(other)).toBe("read");
168 vi.spyOn(localStorage, "setItem").mockImplementation(() => {
169 throw new Error("Storage unavailable");
170 });
171 expect(yield* first.set(entry, "read-write").pipe(Effect.flip)).toBeInstanceOf(
172 ConnectionTransientError,
173 );
174 expect(yield* second.get(entry)).toBe("off");
175 }).pipe(Effect.scoped),
176 );
177});
apps/web/src/connection/storage.ts+110
......@@ -16,8 +16,13 @@ import {
1616import { TokenStore } from "@t3tools/client-runtime/authorization";
1717import {
1818 ConnectionTransientError,
19 ConnectionBlockedError,
1920 CredentialStore,
2021 ProfileStore,
22 GitHubRoutingPermissions,
23 StoredGitHubRoutingPermission,
24 gitHubRoutingConnectionKey,
25 gitHubRoutingPermissionFor,
2126} from "@t3tools/client-runtime/connection";
2227import {
2328 EnvironmentId,
......@@ -32,8 +37,10 @@ import * as Effect from "effect/Effect";
3237import * as Layer from "effect/Layer";
3338import * as Option from "effect/Option";
3439import * as Ref from "effect/Ref";
40import * as Queue from "effect/Queue";
3541import * as Schema from "effect/Schema";
3642import * as Semaphore from "effect/Semaphore";
43import * as Stream from "effect/Stream";
3744import { projectFaviconCache } from "../assets/projectFaviconCache";
3845
3946const DATABASE_NAME = "t3code:connection-runtime";
......@@ -368,12 +375,114 @@ export const makeCatalogStore = Effect.fn("web.connectionStorage.makeCatalogStor
368375 return { read, update } satisfies CatalogStore;
369376});
370377
378const GITHUB_ROUTING_KEY_PREFIX = "t3code:github-routing:";
379const GITHUB_ROUTING_CHANGED = "t3code:github-routing-changed";
380const isStoredGitHubRoutingPermission = Schema.is(StoredGitHubRoutingPermission);
381const encodeStoredGitHubRoutingPermission = Schema.encodeSync(
382 Schema.fromJsonString(StoredGitHubRoutingPermission),
383);
384
385/** Each grant has its own key so stale tabs and unrelated catalog saves cannot restore trust. */
386export function makeBrowserGitHubRoutingPermissions(
387 browser: Pick<Window, "localStorage"> & EventTarget = window,
388) {
389 const read = (key: string): StoredGitHubRoutingPermission | null => {
390 try {
391 const raw = browser.localStorage.getItem(key);
392 const value: unknown = raw === null ? null : JSON.parse(raw);
393 return isStoredGitHubRoutingPermission(value) &&
394 key === `${GITHUB_ROUTING_KEY_PREFIX}${value.environmentId}`
395 ? value
396 : null;
397 } catch {
398 return null;
399 }
400 };
401 const readAll = (): ReadonlyArray<StoredGitHubRoutingPermission> => {
402 try {
403 const values: StoredGitHubRoutingPermission[] = [];
404 const storage = browser.localStorage;
405 for (let index = 0; index < storage.length; index++) {
406 const key = storage.key(index);
407 if (key?.startsWith(GITHUB_ROUTING_KEY_PREFIX)) {
408 const value = read(key);
409 if (value !== null) values.push(value);
410 }
411 }
412 return values;
413 } catch {
414 return [];
415 }
416 };
417 const write = (environmentId: EnvironmentId, value: StoredGitHubRoutingPermission | null) =>
418 Effect.try({
419 try: () => {
420 const key = `${GITHUB_ROUTING_KEY_PREFIX}${environmentId}`;
421 if (value === null) browser.localStorage.removeItem(key);
422 else browser.localStorage.setItem(key, encodeStoredGitHubRoutingPermission(value));
423 browser.dispatchEvent(new Event(GITHUB_ROUTING_CHANGED));
424 },
425 catch: (cause) => catalogError("save GitHub routing permissions in", cause),
426 });
427 return GitHubRoutingPermissions.of({
428 get: (entry) =>
429 Effect.sync(() => {
430 const value = read(`${GITHUB_ROUTING_KEY_PREFIX}${entry.target.environmentId}`);
431 return gitHubRoutingPermissionFor(entry, value === null ? [] : [value]);
432 }),
433 changes: Stream.callback<ReadonlyArray<StoredGitHubRoutingPermission>>((queue) =>
434 Effect.acquireRelease(
435 Effect.sync(() => {
436 const listener = (event: Event) => {
437 if (event.type === "storage") {
438 const key = (event as StorageEvent).key;
439 if (key !== null && !key?.startsWith(GITHUB_ROUTING_KEY_PREFIX)) return;
440 }
441 Queue.offerUnsafe(queue, readAll());
442 };
443 browser.addEventListener("storage", listener);
444 browser.addEventListener(GITHUB_ROUTING_CHANGED, listener);
445 Queue.offerUnsafe(queue, readAll());
446 return listener;
447 }),
448 (listener) =>
449 Effect.sync(() => {
450 browser.removeEventListener("storage", listener);
451 browser.removeEventListener(GITHUB_ROUTING_CHANGED, listener);
452 }),
453 ).pipe(Effect.asVoid),
454 ),
455 set: (entry, permission) => {
456 const connectionKey = gitHubRoutingConnectionKey(entry);
457 if (connectionKey === null)
458 return Effect.fail(
459 new ConnectionBlockedError({
460 reason: "configuration",
461 detail: "This environment does not have a saved connection endpoint.",
462 }),
463 );
464 return write(
465 entry.target.environmentId,
466 permission === "off"
467 ? null
468 : {
469 environmentId: entry.target.environmentId,
470 connectionKey,
471 permission,
472 },
473 );
474 },
475 forget: (environmentId) => write(environmentId, null),
476 });
477}
478
371479export const connectionStorageLayer = Layer.effectContext(
372480 Effect.gen(function* () {
373481 const database = yield* Effect.acquireRelease(openDatabase(), (database) =>
374482 Effect.sync(() => database.close()),
375483 );
376484 const catalog = yield* makeCatalogStore(makeCatalogBackend(database));
485 const githubRoutingPermissions = makeBrowserGitHubRoutingPermissions();
377486
378487 const targetStore = ConnectionTargetStore.of({
379488 list: catalog.read.pipe(
......@@ -670,6 +779,7 @@ export const connectionStorageLayer = Layer.effectContext(
670779 });
671780
672781 return Context.make(ConnectionTargetStore, targetStore).pipe(
782 Context.add(GitHubRoutingPermissions, githubRoutingPermissions),
673783 Context.add(ConnectionRegistrationStore, registrationStore),
674784 Context.add(ProfileStore.ConnectionProfileStore, profileStore),
675785 Context.add(CredentialStore.ConnectionCredentialStore, credentialStore),
docs/user/source-control.md+16
......@@ -99,6 +99,22 @@ GitLab calls these merge requests.
9999GitHub, GitLab, and Azure DevOps support auto-merge while checks are outstanding. GitHub also
100100supports approving waiting fork workflows and opening a revert pull request for a merged change.
101101
102GitHub routing is off by default. In Settings → Connections (Environments on mobile), choose
103**Read PRs** or **Read and act** for each environment you trust to share GitHub access.
104Enable both the original environment and the environment answering its requests on this client.
105**Read and act** can use broader GitHub permissions than the original environment's credential;
106only enable it for environments you control and trust. Changing a saved endpoint or removing an
107environment clears its permission.
108
109GitHub review details, linked PR status, and permitted review actions can then use another
110connected environment signed in to the same GitHub account. Each needs a project on that host.
111A connected local environment is preferred for actions and can answer slow or failed reads.
112Browsers and mobile clients need a paired environment to use its GitHub CLI credentials.
113Credentials stay on their machines. Previously verified credentials remain usable for routing
114for ten minutes during a GitHub outage; new credentials must be verified first. An action with
115an uncertain result is never automatically retried elsewhere. Listings, diffs, and checkout or
116PR creation from Git actions continue to use the project's environment.
117
102118For Azure DevOps, use the host website to view diffs or change comments. Bitbucket does not support
103119reopening a declined pull request.
104120
packages/client-runtime/src/connection/githubRoutingPermissions.ts created+147
......@@ -0,0 +1,147 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5import * as Schema from "effect/Schema";
6import * as Semaphore from "effect/Semaphore";
7import * as Stream from "effect/Stream";
8import * as SubscriptionRef from "effect/SubscriptionRef";
9
10import type { ConnectionCatalogEntry } from "./catalog.ts";
11import { ConnectionBlockedError, type ConnectionAttemptError } from "./model.ts";
12
13export const GitHubRoutingPermission = Schema.Literals(["off", "read", "read-write"]);
14export type GitHubRoutingPermission = typeof GitHubRoutingPermission.Type;
15export const StoredGitHubRoutingPermission = Schema.Struct({
16 environmentId: EnvironmentId,
17 connectionKey: Schema.String,
18 permission: GitHubRoutingPermission,
19});
20export type StoredGitHubRoutingPermission = typeof StoredGitHubRoutingPermission.Type;
21
22/** Trust belongs to the saved endpoint, never to an environment id advertised by a server alone. */
23export function gitHubRoutingConnectionKey(entry: ConnectionCatalogEntry): string | null {
24 const target = entry.target;
25 if (target._tag === "RelayConnectionTarget")
26 return JSON.stringify([target._tag, target.environmentId]);
27 const profile = Option.getOrNull(entry.profile);
28 if (target._tag === "SshConnectionTarget") {
29 if (profile?._tag !== "SshConnectionProfile") return null;
30 const { alias, hostname, username, port } = profile.target;
31 return JSON.stringify([target._tag, target.environmentId, alias, hostname, username, port]);
32 }
33 const baseUrls =
34 target._tag === "PrimaryConnectionTarget"
35 ? [target.httpBaseUrl, target.wsBaseUrl]
36 : profile?._tag === "BearerConnectionProfile"
37 ? [profile.httpBaseUrl, profile.wsBaseUrl]
38 : null;
39 if (baseUrls === null) return null;
40 try {
41 const urls = baseUrls.map((baseUrl) => new URL(baseUrl));
42 if (
43 !["http:", "https:"].includes(urls[0]!.protocol) ||
44 !["ws:", "wss:"].includes(urls[1]!.protocol) ||
45 urls.some((url) => url.username || url.password)
46 )
47 return null;
48 return JSON.stringify([
49 target._tag,
50 target.environmentId,
51 ...urls.map((url) => url.href.replace(/\/+$/, "")),
52 ]);
53 } catch {
54 return null;
55 }
56}
57
58export function gitHubRoutingPermissionFor(
59 entry: ConnectionCatalogEntry,
60 permissions: ReadonlyArray<StoredGitHubRoutingPermission>,
61): GitHubRoutingPermission {
62 const key = gitHubRoutingConnectionKey(entry);
63 return key === null
64 ? "off"
65 : (permissions.find((permission) => permission.connectionKey === key)?.permission ?? "off");
66}
67
68export class GitHubRoutingPermissions extends Context.Reference<{
69 readonly get: (entry: ConnectionCatalogEntry) => Effect.Effect<GitHubRoutingPermission>;
70 readonly changes: Stream.Stream<ReadonlyArray<StoredGitHubRoutingPermission>>;
71 readonly set: (
72 entry: ConnectionCatalogEntry,
73 permission: GitHubRoutingPermission,
74 ) => Effect.Effect<void, ConnectionAttemptError>;
75 readonly forget: (environmentId: EnvironmentId) => Effect.Effect<void, ConnectionAttemptError>;
76}>("@t3tools/client-runtime/connection/GitHubRoutingPermissions", {
77 defaultValue: () => ({
78 get: () => Effect.succeed("off"),
79 changes: Stream.succeed([]),
80 set: () =>
81 Effect.fail(
82 new ConnectionBlockedError({
83 reason: "unsupported",
84 detail: "GitHub routing preferences are unavailable on this client.",
85 }),
86 ),
87 forget: () => Effect.void,
88 }),
89}) {}
90
91export const makeGitHubRoutingPermissions = Effect.fn("makeGitHubRoutingPermissions")(
92 function* (storage: {
93 readonly read: Effect.Effect<
94 ReadonlyArray<StoredGitHubRoutingPermission>,
95 ConnectionAttemptError
96 >;
97 readonly write: (
98 permissions: ReadonlyArray<StoredGitHubRoutingPermission>,
99 ) => Effect.Effect<void, ConnectionAttemptError>;
100 }) {
101 const state = yield* SubscriptionRef.make(yield* storage.read);
102 const lock = yield* Semaphore.make(1);
103 const update = (
104 transform: (
105 current: ReadonlyArray<StoredGitHubRoutingPermission>,
106 ) => ReadonlyArray<StoredGitHubRoutingPermission>,
107 ) =>
108 lock.withPermits(1)(
109 Effect.gen(function* () {
110 const current = yield* SubscriptionRef.get(state);
111 const next = transform(current);
112 if (next === current) return;
113 yield* storage.write(next);
114 yield* SubscriptionRef.set(state, next);
115 }),
116 );
117 return GitHubRoutingPermissions.of({
118 get: (entry) =>
119 SubscriptionRef.get(state).pipe(
120 Effect.map((permissions) => gitHubRoutingPermissionFor(entry, permissions)),
121 ),
122 changes: SubscriptionRef.changes(state),
123 set: (entry, permission) => {
124 const connectionKey = gitHubRoutingConnectionKey(entry);
125 if (connectionKey === null)
126 return Effect.fail(
127 new ConnectionBlockedError({
128 reason: "configuration",
129 detail: "This environment does not have a saved connection endpoint.",
130 }),
131 );
132 return update((current) => [
133 ...current.filter((value) => value.environmentId !== entry.target.environmentId),
134 ...(permission === "off"
135 ? []
136 : [{ environmentId: entry.target.environmentId, connectionKey, permission }]),
137 ]);
138 },
139 forget: (environmentId) =>
140 update((current) =>
141 current.some((permission) => permission.environmentId === environmentId)
142 ? current.filter((permission) => permission.environmentId !== environmentId)
143 : current,
144 ),
145 });
146 },
147);
packages/client-runtime/src/connection/index.ts+1
......@@ -3,6 +3,7 @@ export * as Connectivity from "./connectivity.ts";
33export * as CredentialStore from "./credentialStore.ts";
44export { type ConnectionDriverProgress, type EnvironmentConnectionLease } from "./driver.ts";
55export * from "./errors.ts";
6export * from "./githubRoutingPermissions.ts";
67export * as Connection from "./layer.ts";
78export * from "./model.ts";
89export {
packages/client-runtime/src/connection/registry.test.ts+63-2
......@@ -47,6 +47,10 @@ import {
4747import * as Persistence from "../platform/persistence.ts";
4848import * as ConnectionProfileStore from "./profileStore.ts";
4949import * as EnvironmentRegistry from "./registry.ts";
50import {
51 GitHubRoutingPermissions,
52 makeGitHubRoutingPermissions,
53} from "./githubRoutingPermissions.ts";
5054import * as RpcSession from "../rpc/session.ts";
5155import * as EnvironmentSupervisor from "./supervisor.ts";
5256import * as ConnectionWakeups from "./wakeups.ts";
......@@ -980,10 +984,17 @@ describe("EnvironmentRegistry", () => {
980984 label: "Shadowed relay environment",
981985 });
982986 const harness = yield* makeHarness([shadowedTarget]);
987 const permissions = yield* makeGitHubRoutingPermissions({
988 read: Effect.succeed([]),
989 write: () => Effect.void,
990 });
991 const shadowedEntry = { target: shadowedTarget, profile: Option.none(), enabled: true };
992 yield* permissions.set(shadowedEntry, "read-write");
983993
984994 yield* Effect.gen(function* () {
985995 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
986996 yield* registry.registerPlatform(new PrimaryConnectionRegistration({ target: TARGET }));
997 expect(yield* permissions.get(shadowedEntry)).toBe("off");
987998
988999 expect(
9891000 (yield* SubscriptionRef.get(registry.entries)).get(TARGET.environmentId)?.target,
......@@ -996,7 +1007,11 @@ describe("EnvironmentRegistry", () => {
9961007 (yield* SubscriptionRef.get(registry.entries)).get(TARGET.environmentId)?.target,
9971008 ).toEqual(TARGET);
9981009 expect((yield* Ref.get(harness.storedTargets)).has(TARGET.environmentId)).toBe(false);
999 }).pipe(Effect.provide(harness.layer), Effect.scoped);
1010 }).pipe(
1011 Effect.provide(harness.layer),
1012 Effect.provideService(GitHubRoutingPermissions, permissions),
1013 Effect.scoped,
1014 );
10001015 }),
10011016 );
10021017
......@@ -1083,6 +1098,20 @@ describe("EnvironmentRegistry", () => {
10831098 it.effect("retains a healthy runtime when the platform repeats an identical registration", () =>
10841099 Effect.gen(function* () {
10851100 const harness = yield* makeHarness([]);
1101 let failWrites = false;
1102 const permissions = yield* makeGitHubRoutingPermissions({
1103 read: Effect.succeed([]),
1104 write: () =>
1105 failWrites
1106 ? Effect.fail(
1107 new ConnectionTransientError({
1108 reason: "remote-unavailable",
1109 detail: "Storage unavailable",
1110 }),
1111 )
1112 : Effect.void,
1113 });
1114 const entry = { target: TARGET, profile: Option.none(), enabled: true };
10861115
10871116 yield* Effect.gen(function* () {
10881117 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
......@@ -1093,11 +1122,43 @@ describe("EnvironmentRegistry", () => {
10931122 TARGET.environmentId,
10941123 (state) => state.phase === "connected",
10951124 );
1125 yield* permissions.set(entry, "read-write");
10961126
10971127 yield* registry.registerPlatform(registration);
10981128
10991129 expect(yield* Ref.get(harness.sessions)).toHaveLength(1);
1100 }).pipe(Effect.provide(harness.layer), Effect.scoped);
1130 expect(yield* permissions.get(entry)).toBe("read-write");
1131
1132 const changedTarget = new PrimaryConnectionTarget({
1133 ...TARGET,
1134 httpBaseUrl: "https://changed.example.test",
1135 });
1136 failWrites = true;
1137 yield* registry.registerPlatform(
1138 new PrimaryConnectionRegistration({ target: changedTarget }),
1139 );
1140 expect(
1141 (yield* SubscriptionRef.get(registry.entries)).get(TARGET.environmentId)?.target,
1142 ).toEqual(TARGET);
1143 yield* registry.reconcilePlatform([]);
1144 expect((yield* SubscriptionRef.get(registry.entries)).has(TARGET.environmentId)).toBe(true);
1145
1146 failWrites = false;
1147 yield* registry.registerPlatform(
1148 new PrimaryConnectionRegistration({ target: changedTarget }),
1149 );
1150 expect(yield* permissions.get(entry)).toBe("off");
1151 yield* registry.registerPlatform(registration);
1152 expect(yield* permissions.get(entry)).toBe("off");
1153 yield* permissions.set(entry, "read-write");
1154 yield* registry.reconcilePlatform([]);
1155 yield* registry.registerPlatform(registration);
1156 expect(yield* permissions.get(entry)).toBe("off");
1157 }).pipe(
1158 Effect.provide(harness.layer),
1159 Effect.provideService(GitHubRoutingPermissions, permissions),
1160 Effect.scoped,
1161 );
11011162 }),
11021163 );
11031164
packages/client-runtime/src/connection/registry.ts+57-5
......@@ -34,6 +34,10 @@ import * as Persistence from "../platform/persistence.ts";
3434import * as EnvironmentSupervisor from "./supervisor.ts";
3535import * as ConnectionDriver from "./driver.ts";
3636import * as ConnectionWakeups from "./wakeups.ts";
37import {
38 GitHubRoutingPermissions,
39 gitHubRoutingConnectionKey,
40} from "./githubRoutingPermissions.ts";
3741
3842const isSshConnectionProfile = Schema.is(SshConnectionProfile);
3943
......@@ -146,6 +150,7 @@ export const make = Effect.gen(function* () {
146150 const ownedDataCleanup = yield* Persistence.EnvironmentOwnedDataCleanup;
147151 const profiles = yield* ConnectionProfileStore.ConnectionProfileStore;
148152 const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore;
153 const githubRoutingPermissions = yield* GitHubRoutingPermissions;
149154 const connectivity = yield* Connectivity.Connectivity;
150155 const driver = yield* ConnectionDriver.ConnectionDriver;
151156 const wakeups = yield* ConnectionWakeups.ConnectionWakeups;
......@@ -418,11 +423,24 @@ export const make = Effect.gen(function* () {
418423 if ((yield* Ref.get(platformEnvironmentIds)).has(environmentId)) {
419424 return;
420425 }
421 // Editing a saved environment re-registers it; that must not switch a
422 // disabled one back on.
426 // Editing a saved environment must preserve its disabled state.
423427 const previous = (yield* SubscriptionRef.get(entries)).get(environmentId);
424428 const entry: ConnectionCatalogEntry =
425429 previous === undefined ? registered : { ...registered, enabled: previous.enabled };
430 if (
431 previous !== undefined &&
432 gitHubRoutingConnectionKey(previous) !== gitHubRoutingConnectionKey(entry)
433 ) {
434 yield* githubRoutingPermissions.forget(environmentId).pipe(
435 Effect.mapError(
436 (error) =>
437 new Persistence.ConnectionPersistenceError({
438 operation: "register-connection",
439 message: error.message,
440 }),
441 ),
442 );
443 }
426444 yield* registrations.register(registration);
427445 yield* Ref.update(persistedTargetsByEnvironment, (current) => {
428446 const next = new Map(current);
......@@ -441,6 +459,29 @@ export const make = Effect.gen(function* () {
441459 yield* withLeaseLock(
442460 target.environmentId,
443461 Effect.gen(function* () {
462 const previous = (yield* SubscriptionRef.get(entries)).get(target.environmentId);
463 const persistedTarget = (yield* Ref.get(persistedTargetsByEnvironment)).get(
464 target.environmentId,
465 );
466 if (
467 persistedTarget !== undefined ||
468 (previous !== undefined &&
469 gitHubRoutingConnectionKey(previous) !== gitHubRoutingConnectionKey(entry))
470 ) {
471 const revoked = yield* githubRoutingPermissions.forget(target.environmentId).pipe(
472 Effect.tapError((error) =>
473 Effect.logWarning(
474 "Could not clear GitHub routing permission for a platform environment.",
475 {
476 environmentId: target.environmentId,
477 error,
478 },
479 ),
480 ),
481 Effect.exit,
482 );
483 if (Exit.isFailure(revoked)) return;
484 }
444485 yield* Ref.update(platformEnvironmentIds, (current) => {
445486 const next = new Set(current);
446487 next.add(target.environmentId);
......@@ -462,9 +503,6 @@ export const make = Effect.gen(function* () {
462503 );
463504 }
464505
465 const persistedTarget = (yield* Ref.get(persistedTargetsByEnvironment)).get(
466 target.environmentId,
467 );
468506 if (persistedTarget !== undefined) {
469507 yield* registrations.remove(persistedTarget).pipe(
470508 Effect.tap(() =>
......@@ -502,6 +540,19 @@ export const make = Effect.gen(function* () {
502540 environmentId,
503541 Effect.gen(function* () {
504542 const entry = (yield* SubscriptionRef.get(entries)).get(environmentId);
543 const revoked = yield* githubRoutingPermissions.forget(environmentId).pipe(
544 Effect.tapError((error) =>
545 Effect.logWarning(
546 "Could not clear GitHub routing permission after platform removal.",
547 {
548 environmentId,
549 error,
550 },
551 ),
552 ),
553 Effect.exit,
554 );
555 if (Exit.isFailure(revoked)) return;
505556 yield* Ref.update(platformEnvironmentIds, (current) => {
506557 const next = new Set(current);
507558 next.delete(environmentId);
......@@ -582,6 +633,7 @@ export const make = Effect.gen(function* () {
582633 ? yield* profiles.get(target.connectionId)
583634 : Option.none();
584635
636 yield* githubRoutingPermissions.forget(environmentId);
585637 yield* registrations.remove(target);
586638 yield* Ref.update(persistedTargetsByEnvironment, (current) => {
587639 const next = new Map(current);
packages/client-runtime/src/connection/resolver.test.ts+106-1
......@@ -28,6 +28,11 @@ import {
2828 type ConnectionTarget,
2929} from "./model.ts";
3030import * as ConnectionProfileStore from "./profileStore.ts";
31import {
32 GitHubRoutingPermissions,
33 gitHubRoutingConnectionKey,
34 makeGitHubRoutingPermissions,
35} from "./githubRoutingPermissions.ts";
3136
3237const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
3338const ENDPOINT = {
......@@ -64,6 +69,7 @@ function collectingTracer(spans: Array<string>): Tracer.Tracer {
6469
6570const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((options?: {
6671 readonly profiles?: ReadonlyArray<ConnectionProfile>;
72 readonly profileStore?: ConnectionProfileStore.ConnectionProfileStore["Service"];
6773 readonly credentials?: ReadonlyArray<readonly [string, ConnectionCredential]>;
6874 readonly authorizeBearer?: RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["authorizeBearer"];
6975 readonly authorizeDpop?: RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["authorizeDpop"];
......@@ -134,7 +140,10 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o
134140 });
135141
136142 const dependencies = Layer.mergeAll(
137 Layer.succeed(ConnectionProfileStore.ConnectionProfileStore, profileStore),
143 Layer.succeed(
144 ConnectionProfileStore.ConnectionProfileStore,
145 options?.profileStore ?? profileStore,
146 ),
138147 Layer.succeed(ConnectionCredentialStore.ConnectionCredentialStore, credentialStore),
139148 Layer.succeed(
140149 ClientCapabilities.PrimaryEnvironmentAuth,
......@@ -376,6 +385,102 @@ describe("ConnectionResolver", () => {
376385 }),
377386 );
378387
388 for (const scenario of ["unchanged", "changed", "revocation-failed"] as const) {
389 it.effect(`handles ${scenario} SSH routing consent before saving or authorizing`, () =>
390 Effect.gen(function* () {
391 const calls: string[] = [];
392 const target = new SshConnectionTarget({
393 environmentId: ENVIRONMENT_ID,
394 label: "SSH",
395 connectionId: "ssh-1",
396 });
397 const profile = new SshConnectionProfile({
398 connectionId: target.connectionId,
399 environmentId: ENVIRONMENT_ID,
400 label: "SSH",
401 target: SSH_TARGET,
402 });
403 const entry = catalogEntry(target, Option.some(profile));
404 const preparedTarget =
405 scenario === "unchanged"
406 ? SSH_TARGET
407 : { ...SSH_TARGET, hostname: "replacement.example.test" };
408 const failure = new ConnectionTransientError({
409 reason: "remote-unavailable",
410 detail: "Could not persist routing consent.",
411 });
412 const permissions = yield* makeGitHubRoutingPermissions({
413 read: Effect.succeed([
414 {
415 environmentId: ENVIRONMENT_ID,
416 connectionKey: gitHubRoutingConnectionKey(entry)!,
417 permission: "read-write",
418 },
419 ]),
420 write: () =>
421 Effect.sync(() => {
422 calls.push("revoke");
423 }).pipe(
424 Effect.andThen(scenario === "revocation-failed" ? Effect.fail(failure) : Effect.void),
425 ),
426 });
427 let savedProfile: ConnectionProfile = profile;
428 const brokerLayer = yield* makeDependencies({
429 profileStore: {
430 get: () => Effect.sync(() => Option.some(savedProfile)),
431 put: (value) =>
432 Effect.sync(() => {
433 calls.push("profile");
434 savedProfile = value;
435 }),
436 remove: () => Effect.die("unused"),
437 },
438 prepareSsh: () =>
439 Effect.succeed({
440 bootstrap: {
441 target: preparedTarget,
442 httpBaseUrl: "http://127.0.0.1:4010",
443 wsBaseUrl: "ws://127.0.0.1:4010",
444 pairingToken: null,
445 },
446 bearerToken: "ssh-bearer",
447 }),
448 authorizeBearer: (input) =>
449 Effect.sync(() => {
450 calls.push("authorize");
451 return {
452 environmentId: input.expectedEnvironmentId,
453 label: "SSH",
454 httpBaseUrl: input.httpBaseUrl,
455 socketUrl: "ws://127.0.0.1:4010/ws?wsTicket=ssh",
456 httpAuthorization: { _tag: "Bearer" as const, token: input.bearerToken },
457 };
458 }),
459 });
460 const broker = yield* ConnectionResolver.ConnectionResolver.pipe(
461 Effect.provide(brokerLayer),
462 );
463 const prepare = broker
464 .prepare(entry)
465 .pipe(Effect.provideService(GitHubRoutingPermissions, permissions));
466 if (scenario === "revocation-failed") {
467 expect(yield* Effect.flip(prepare)).toBe(failure);
468 expect(savedProfile).toBe(profile);
469 expect(calls).toEqual(["revoke"]);
470 } else {
471 expect((yield* prepare).socketUrl).toContain("wsTicket=ssh");
472 expect(savedProfile).toMatchObject({ target: preparedTarget });
473 expect(calls).toEqual(
474 scenario === "unchanged"
475 ? ["profile", "authorize"]
476 : ["revoke", "profile", "authorize"],
477 );
478 }
479 expect(yield* permissions.get(entry)).toBe(scenario === "changed" ? "off" : "read-write");
480 }),
481 );
482 }
483
379484 it.effect("preserves relay authorization failure classification and trace details", () =>
380485 Effect.gen(function* () {
381486 const target = new RelayConnectionTarget({
packages/client-runtime/src/connection/resolver.ts+18-8
......@@ -17,6 +17,10 @@ import {
1717} from "./catalog.ts";
1818import * as ConnectionCredentialStore from "./credentialStore.ts";
1919import { credentialMissingError, environmentMismatchError, profileMissingError } from "./errors.ts";
20import {
21 GitHubRoutingPermissions,
22 gitHubRoutingConnectionKey,
23} from "./githubRoutingPermissions.ts";
2024import type {
2125 BearerConnectionTarget,
2226 ConnectionTarget,
......@@ -192,14 +196,20 @@ const makeSshBroker = Effect.fn("clientRuntime.connection.broker.makeSsh")(funct
192196 expectedEnvironmentId: target.environmentId,
193197 target: profile.target,
194198 });
195 yield* profiles.put(
196 new SshConnectionProfile({
197 connectionId: profile.connectionId,
198 environmentId: profile.environmentId,
199 label: profile.label,
200 target: prepared.bootstrap.target,
201 }),
202 );
199 const preparedProfile = new SshConnectionProfile({
200 connectionId: profile.connectionId,
201 environmentId: profile.environmentId,
202 label: profile.label,
203 target: prepared.bootstrap.target,
204 });
205 if (
206 gitHubRoutingConnectionKey(entry) !==
207 gitHubRoutingConnectionKey({ ...entry, profile: Option.some(preparedProfile) })
208 ) {
209 const permissions = yield* GitHubRoutingPermissions;
210 yield* permissions.forget(target.environmentId);
211 }
212 yield* profiles.put(preparedProfile);
203213 const authorized = yield* remote.authorizeBearer({
204214 expectedEnvironmentId: target.environmentId,
205215 httpBaseUrl: prepared.bootstrap.httpBaseUrl,
packages/client-runtime/src/platform/storageDocument.test.ts+132
......@@ -1,6 +1,8 @@
11import { EnvironmentId } from "@t3tools/contracts";
22import { describe, expect, it } from "@effect/vitest";
33import * as Schema from "effect/Schema";
4import * as Effect from "effect/Effect";
5import * as Option from "effect/Option";
46
57import * as TokenStore from "../authorization/tokenStore.ts";
68import {
......@@ -13,9 +15,15 @@ import {
1315} from "../connection/catalog.ts";
1416import {
1517 BearerConnectionTarget,
18 ConnectionTransientError,
19 PrimaryConnectionTarget,
1620 RelayConnectionTarget,
1721 SshConnectionTarget,
1822} from "../connection/model.ts";
23import {
24 GitHubRoutingPermissions,
25 makeGitHubRoutingPermissions,
26} from "../connection/githubRoutingPermissions.ts";
1927import {
2028 ConnectionCatalogDocument,
2129 EMPTY_CONNECTION_CATALOG_DOCUMENT,
......@@ -25,6 +33,8 @@ import {
2533 setConnectionEnabledInCatalog,
2634} from "./storageDocument.ts";
2735
36const decodeConnectionCatalogDocument = Schema.decodeUnknownEffect(ConnectionCatalogDocument);
37
2838const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
2939const decodeCatalogDocument = Schema.decodeUnknownSync(ConnectionCatalogDocument);
3040
......@@ -61,6 +71,128 @@ const REMOTE_TOKEN = new TokenStore.RemoteDpopAccessToken({
6171});
6272
6373describe("ConnectionCatalogDocument", () => {
74 it.effect("persists explicit GitHub trust and forgets it when a connection is removed", () =>
75 Effect.gen(function* () {
76 let document = EMPTY_CONNECTION_CATALOG_DOCUMENT;
77 const entry = { target: BEARER_TARGET, profile: Option.some(BEARER_PROFILE), enabled: true };
78 const storage = {
79 read: Effect.sync(() => document.githubRoutingPermissions ?? []),
80 write: (githubRoutingPermissions: NonNullable<typeof document.githubRoutingPermissions>) =>
81 Effect.gen(function* () {
82 document = yield* decodeConnectionCatalogDocument({
83 ...document,
84 githubRoutingPermissions,
85 }).pipe(Effect.orDie);
86 }),
87 };
88 const permissions = yield* makeGitHubRoutingPermissions(storage);
89 expect(yield* permissions.get(entry)).toBe("off");
90 yield* permissions.set(entry, "read");
91 const restarted = yield* makeGitHubRoutingPermissions(storage);
92 expect(yield* restarted.get(entry)).toBe("read");
93
94 const changedEndpoint = {
95 ...entry,
96 profile: Option.some(
97 new BearerConnectionProfile({
98 ...BEARER_PROFILE,
99 httpBaseUrl: "https://different.example.test",
100 }),
101 ),
102 };
103 expect(yield* restarted.get(changedEndpoint)).toBe("off");
104 expect(
105 yield* restarted.get({
106 ...entry,
107 profile: Option.some(
108 new BearerConnectionProfile({
109 ...BEARER_PROFILE,
110 wsBaseUrl: "wss://different.example.test",
111 }),
112 ),
113 }),
114 ).toBe("off");
115 expect(
116 yield* restarted.get({ target: RELAY_TARGET, profile: Option.none(), enabled: true }),
117 ).toBe("off");
118 yield* restarted.set(entry, "read-write");
119 expect(yield* restarted.get(entry)).toBe("read-write");
120 yield* restarted.forget(ENVIRONMENT_ID);
121 expect(yield* restarted.get(entry)).toBe("off");
122 const afterRemoval = yield* makeGitHubRoutingPermissions(storage);
123 expect(yield* afterRemoval.get(entry)).toBe("off");
124
125 yield* permissions.set(entry, "read");
126 document = removeConnectionFromCatalog(document, BEARER_TARGET);
127 const afterCatalogRemoval = yield* makeGitHubRoutingPermissions(storage);
128 expect(yield* afterCatalogRemoval.get(entry)).toBe("off");
129 }),
130 );
131
132 it.effect("requires explicit trust for primary, relay, and SSH connections independently", () =>
133 Effect.gen(function* () {
134 const permissions = yield* makeGitHubRoutingPermissions({
135 read: Effect.succeed([]),
136 write: () => Effect.void,
137 });
138 const entries = [
139 {
140 target: new PrimaryConnectionTarget({
141 environmentId: ENVIRONMENT_ID,
142 label: "Local",
143 httpBaseUrl: "http://localhost:3000",
144 wsBaseUrl: "ws://localhost:3000",
145 }),
146 profile: Option.none(),
147 enabled: true,
148 },
149 { target: RELAY_TARGET, profile: Option.none(), enabled: true },
150 {
151 enabled: true,
152 target: new SshConnectionTarget({
153 environmentId: ENVIRONMENT_ID,
154 label: "SSH",
155 connectionId: "ssh-1",
156 }),
157 profile: Option.some(
158 new SshConnectionProfile({
159 environmentId: ENVIRONMENT_ID,
160 label: "SSH",
161 connectionId: "ssh-1",
162 target: { alias: "work", hostname: "work.example.test", username: "maria", port: 22 },
163 }),
164 ),
165 },
166 ];
167 for (const entry of entries) {
168 expect(yield* permissions.get(entry)).toBe("off");
169 yield* permissions.set(entry, "read");
170 expect(yield* permissions.get(entry)).toBe("read");
171 yield* permissions.set(entry, "off");
172 expect(yield* permissions.get(entry)).toBe("off");
173 }
174 }),
175 );
176
177 it.effect("does not enable GitHub routing when permission persistence fails", () =>
178 Effect.gen(function* () {
179 const entry = { target: BEARER_TARGET, profile: Option.some(BEARER_PROFILE), enabled: true };
180 expect(yield* (yield* GitHubRoutingPermissions).get(entry)).toBe("off");
181 const permissions = yield* makeGitHubRoutingPermissions({
182 read: Effect.succeed([]),
183 write: () =>
184 Effect.fail(
185 new ConnectionTransientError({
186 reason: "remote-unavailable",
187 detail: "storage unavailable",
188 }),
189 ),
190 });
191 yield* permissions.set(entry, "read-write").pipe(Effect.flip);
192 expect(yield* permissions.get(entry)).toBe("off");
193 }),
194 );
195
64196 it.each([
65197 { name: "legacy", accountId: undefined },
66198 { name: "account-bound", accountId: "account-1" },
packages/client-runtime/src/platform/storageDocument.ts+11-1
......@@ -9,6 +9,7 @@ import {
99} from "../connection/catalog.ts";
1010import { type ConnectionTarget, PersistedConnectionTarget } from "../connection/model.ts";
1111import * as TokenStore from "../authorization/tokenStore.ts";
12import { StoredGitHubRoutingPermission } from "../connection/githubRoutingPermissions.ts";
1213
1314export const StoredConnectionCredential = Schema.Struct({
1415 connectionId: Schema.String,
......@@ -22,6 +23,7 @@ export const ConnectionCatalogDocument = Schema.Struct({
2223 profiles: Schema.Array(ConnectionProfile),
2324 credentials: Schema.Array(StoredConnectionCredential),
2425 remoteDpopTokens: Schema.Array(TokenStore.RemoteDpopAccessToken),
26 githubRoutingPermissions: Schema.optionalKey(Schema.Array(StoredGitHubRoutingPermission)),
2527 // Saved environments the user switched off. They stay registered with their
2628 // credentials and cache but never connect until switched back on. Older
2729 // documents predate the key, so decoding defaults it to none.
......@@ -153,7 +155,15 @@ export function removeConnectionFromCatalog(
153155 document: ConnectionCatalogDocument,
154156 target: ConnectionTarget,
155157): ConnectionCatalogDocument {
156 return removeConnectionMetadata(document, target, true);
158 const next = removeConnectionMetadata(document, target, true);
159 return document.githubRoutingPermissions === undefined
160 ? next
161 : {
162 ...next,
163 githubRoutingPermissions: document.githubRoutingPermissions.filter(
164 (permission) => permission.environmentId !== target.environmentId,
165 ),
166 };
157167}
158168
159169/** Flips the disabled flag for a saved environment; unknown ids are ignored. */
packages/client-runtime/src/state/connections.ts+33
......@@ -9,6 +9,11 @@ import * as EnvironmentRegistry from "../connection/registry.ts";
99import type { ConnectionCatalogEntry } from "../connection/catalog.ts";
1010import { AVAILABLE_CONNECTION_STATE } from "../connection/model.ts";
1111import * as EnvironmentSupervisor from "../connection/supervisor.ts";
12import {
13 GitHubRoutingPermissions,
14 type GitHubRoutingPermission,
15 type StoredGitHubRoutingPermission,
16} from "../connection/githubRoutingPermissions.ts";
1217import {
1318 createAtomCommandScheduler,
1419 createRuntimeCommand,
......@@ -65,6 +70,32 @@ export function createEnvironmentCatalogAtoms<R, E>(
6570 Option.getOrElse(AsyncResult.value(get(catalogAtom)), () => EMPTY_ENVIRONMENT_CATALOG_STATE),
6671 ).pipe(Atom.withLabel("environment-catalog-value"));
6772
73 const githubRoutingPermissionsAtom = runtime.atom(
74 Stream.unwrap(GitHubRoutingPermissions.pipe(Effect.map((permissions) => permissions.changes))),
75 { initialValue: [] as ReadonlyArray<StoredGitHubRoutingPermission> },
76 );
77 const githubRoutingPermissionsValueAtom = Atom.make((get) =>
78 Option.getOrElse(AsyncResult.value(get(githubRoutingPermissionsAtom)), () => []),
79 ).pipe(Atom.withLabel("environment-github-routing-permissions"));
80 const setGitHubRoutingPermission = createRuntimeCommand(runtime, {
81 label: "environment-catalog:github-routing-permission",
82 scheduler: commandScheduler,
83 concurrency: serial,
84 execute: Effect.fn(function* (input: {
85 readonly environmentId: EnvironmentIdType;
86 readonly permission: GitHubRoutingPermission;
87 }) {
88 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
89 const entry = (yield* SubscriptionRef.get(registry.entries)).get(input.environmentId);
90 if (entry === undefined)
91 return yield* new EnvironmentRegistry.EnvironmentNotRegisteredError({
92 environmentId: input.environmentId,
93 });
94 const permissions = yield* GitHubRoutingPermissions;
95 yield* permissions.set(entry, input.permission);
96 }),
97 });
98
6899 const networkStatusAtom = runtime.atom(
69100 Stream.unwrap(
70101 EnvironmentRegistry.EnvironmentRegistry.pipe(
......@@ -143,6 +174,8 @@ export function createEnvironmentCatalogAtoms<R, E>(
143174 return {
144175 catalogAtom,
145176 catalogValueAtom,
177 githubRoutingPermissionsValueAtom,
178 setGitHubRoutingPermission,
146179 networkStatusAtom,
147180 networkStatusValueAtom,
148181 stateAtom,
packages/client-runtime/src/state/pullRequestRouting.ts created+384
......@@ -0,0 +1,384 @@
1import {
2 WS_METHODS,
3 PullRequestRef,
4 PullRequestInvalidateInput,
5 type EnvironmentId,
6} from "@t3tools/contracts";
7import * as Effect from "effect/Effect";
8import * as Cause from "effect/Cause";
9import * as Option from "effect/Option";
10import * as Schema from "effect/Schema";
11import * as SubscriptionRef from "effect/SubscriptionRef";
12
13import { EnvironmentRegistry, EnvironmentNotRegisteredError } from "../connection/registry.ts";
14import { EnvironmentSupervisor } from "../connection/supervisor.ts";
15import {
16 GitHubRoutingPermissions,
17 gitHubRoutingConnectionKey,
18} from "../connection/githubRoutingPermissions.ts";
19import type { ConnectionCatalogEntry } from "../connection/catalog.ts";
20import { ConnectionProfileStore } from "../connection/profileStore.ts";
21import {
22 request,
23 EnvironmentRpcUnavailableError,
24 type EnvironmentRpcInput,
25 type EnvironmentUnaryRpcTag,
26} from "../rpc/client.ts";
27
28const reads = new Set<string>([
29 WS_METHODS.pullRequestsSummary,
30 WS_METHODS.pullRequestsStack,
31 WS_METHODS.pullRequestsDetail,
32 WS_METHODS.pullRequestsActivity,
33 WS_METHODS.pullRequestsThreadComments,
34 WS_METHODS.pullRequestsDiffFileContents,
35 WS_METHODS.pullRequestsReviewerCandidates,
36 WS_METHODS.pullRequestsLabelCandidates,
37]);
38const writes = new Set<string>([
39 WS_METHODS.pullRequestsRunAction,
40 WS_METHODS.pullRequestsUpdate,
41 WS_METHODS.pullRequestsComment,
42 WS_METHODS.pullRequestsUpdateComment,
43 WS_METHODS.pullRequestsSubmitReview,
44 WS_METHODS.pullRequestsReplyToThread,
45 WS_METHODS.pullRequestsSetReaction,
46 WS_METHODS.pullRequestsSetThreadResolution,
47 WS_METHODS.pullRequestsRequestReviewers,
48 WS_METHODS.pullRequestsSetLabels,
49]);
50const isRef = Schema.is(PullRequestRef);
51const isInvalidation = Schema.is(PullRequestInvalidateInput);
52interface RoutedRead {
53 origin: EnvironmentId;
54 reference: PullRequestRef;
55 targets: Set<EnvironmentId>;
56}
57const routedReads = new WeakMap<EnvironmentRegistry["Service"], Map<string, RoutedRead>>();
58const isUnregistered = Schema.is(EnvironmentNotRegisteredError);
59const encodeKey = Schema.encodeSync(
60 Schema.fromJsonString(Schema.Array(Schema.NullOr(Schema.String))),
61);
62
63function isLocal(entry: ConnectionCatalogEntry): boolean {
64 const url =
65 entry.target._tag === "PrimaryConnectionTarget"
66 ? entry.target.httpBaseUrl
67 : Option.isSome(entry.profile) && entry.profile.value._tag === "BearerConnectionProfile"
68 ? entry.profile.value.httpBaseUrl
69 : undefined;
70 if (url === undefined) return false;
71 try {
72 return ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname);
73 } catch {
74 return false;
75 }
76}
77
78/** A guard rejection is returned before the operation starts. Other write failures are ambiguous. */
79function rejectedBeforeDispatch(error: unknown): boolean {
80 return (
81 typeof error === "object" &&
82 error !== null &&
83 "_tag" in error &&
84 (error._tag === "EnvironmentRpcUnavailableError" ||
85 error._tag === "EnvironmentNotRegisteredError" ||
86 (error._tag === "PullRequestOperationError" &&
87 "operation" in error &&
88 error.operation === "routeIdentity"))
89 );
90}
91
92const routingAllowed = Effect.fn("PullRequestRouting.allowed")(function* (
93 registry: EnvironmentRegistry["Service"],
94 originId: EnvironmentId,
95 destinationId: EnvironmentId,
96 write: boolean,
97) {
98 const entries = yield* SubscriptionRef.get(registry.entries);
99 const origin = entries.get(originId);
100 const destination = entries.get(destinationId);
101 if (!origin?.enabled || !destination?.enabled) return false;
102 const permissions = yield* GitHubRoutingPermissions;
103 const source = yield* permissions.get(origin);
104 const target = yield* permissions.get(destination);
105 const allowed = write
106 ? source === "read-write" && target === "read-write"
107 : source !== "off" && target !== "off";
108 if (!allowed) return false;
109 for (const entry of [origin, destination]) {
110 if (entry.target._tag !== "SshConnectionTarget") continue;
111 const profiles = yield* Effect.serviceOption(ConnectionProfileStore);
112 if (Option.isNone(profiles)) return false;
113 const profile = yield* profiles.value
114 .get(entry.target.connectionId)
115 .pipe(Effect.orElseSucceed(() => Option.none()));
116 const key = gitHubRoutingConnectionKey(entry);
117 if (key === null || key !== gitHubRoutingConnectionKey({ ...entry, profile })) return false;
118 }
119 return true;
120});
121
122function matchesReference(reference: PullRequestRef, filter: PullRequestRef): boolean {
123 return (
124 reference.projectId === filter.projectId &&
125 reference.repository.toLowerCase() === filter.repository.toLowerCase() &&
126 reference.number === filter.number &&
127 (filter.host === undefined || reference.host?.toLowerCase() === filter.host.toLowerCase())
128 );
129}
130
131const invalidateTarget = Effect.fn("PullRequestRouting.invalidateTarget")(function* (
132 registry: EnvironmentRegistry["Service"],
133 origin: EnvironmentId,
134 target: EnvironmentId,
135 inputs: ReadonlyArray<PullRequestInvalidateInput>,
136) {
137 if (target !== origin && !(yield* routingAllowed(registry, origin, target, false))) return;
138 yield* Effect.forEach(
139 inputs,
140 (input) =>
141 registry.run(target, request(WS_METHODS.pullRequestsInvalidate, input)).pipe(
142 // GitHub already accepted the write. A stalled reader must not delay its confirmation.
143 Effect.timeoutOption("1 second"),
144 Effect.orElseSucceed(() => undefined),
145 ),
146 { concurrency: 3, discard: true },
147 );
148});
149
150/** Credentials stay on their environments. Only a verified host and account cross the wire. */
151export function createPullRequestRouter() {
152 const routedRequest = Effect.fn("PullRequestRouting.request")(function* <
153 T extends EnvironmentUnaryRpcTag,
154 >(tag: T, input: EnvironmentRpcInput<T>, sourceRead?: ReturnType<typeof request<T>>) {
155 const source = sourceRead ?? request(tag, input);
156 if (tag === WS_METHODS.pullRequestsInvalidate && isInvalidation(input)) {
157 const result = yield* request(tag, input);
158 const origin = yield* EnvironmentSupervisor;
159 const registry = yield* EnvironmentRegistry;
160 const used = routedReads.get(registry);
161 const targets = new Map<EnvironmentId, PullRequestRef>();
162 for (const entry of used?.values() ?? []) {
163 if (entry.origin !== origin.target.environmentId) continue;
164 const ref = input.reference;
165 if (ref !== undefined && !matchesReference(entry.reference, ref)) continue;
166 for (const target of entry.targets) targets.set(target, entry.reference);
167 if (input.reference !== undefined)
168 targets.set(origin.target.environmentId, entry.reference);
169 }
170 yield* Effect.forEach(
171 targets,
172 ([target, reference]) =>
173 invalidateTarget(registry, origin.target.environmentId, target, [
174 input.reference === undefined ? {} : { reference },
175 ]),
176 { concurrency: 4, discard: true },
177 );
178 return result;
179 }
180 if ((!reads.has(tag) && !writes.has(tag)) || !isRef(input)) {
181 return yield* request(tag, input);
182 }
183 const ref = input;
184 const origin = yield* EnvironmentSupervisor;
185 const registry = yield* EnvironmentRegistry;
186 const entries = yield* SubscriptionRef.get(registry.entries);
187 const sourceEntry = entries.get(origin.target.environmentId);
188 const used = routedReads.get(registry) ?? new Map<string, RoutedRead>();
189 routedReads.set(registry, used);
190 const refKey = encodeKey([
191 origin.target.environmentId,
192 ref.projectId,
193 ref.host?.toLowerCase() ?? null,
194 ref.repository.toLowerCase(),
195 String(ref.number),
196 ]);
197 const finish = (operation: ReturnType<typeof request<T>>) =>
198 operation.pipe(
199 Effect.tap(() => {
200 if (!writes.has(tag) || used.size === 0) return Effect.void;
201 const targets = new Map<EnvironmentId, PullRequestRef[]>([
202 [origin.target.environmentId, [ref]],
203 ]);
204 for (const entry of used.values()) {
205 if (
206 entry.origin !== origin.target.environmentId ||
207 !matchesReference(entry.reference, ref)
208 )
209 continue;
210 for (const target of [...entry.targets, origin.target.environmentId]) {
211 const refs = targets.get(target) ?? [];
212 if (!refs.some((existing) => existing.host === entry.reference.host))
213 refs.push(entry.reference);
214 targets.set(target, refs);
215 }
216 }
217 return Effect.forEach(
218 targets,
219 ([target, refs]) =>
220 invalidateTarget(registry, origin.target.environmentId, target, [
221 ...refs.map((reference) => ({ reference })),
222 {},
223 ]),
224 { concurrency: 4, discard: true },
225 );
226 }),
227 );
228 const alternatives = [];
229 for (const [id, entry] of entries) {
230 if (id === origin.target.environmentId) continue;
231 if (!(yield* routingAllowed(registry, origin.target.environmentId, id, writes.has(tag))))
232 continue;
233 const connected = yield* registry
234 .run(id, EnvironmentSupervisor.pipe(Effect.flatMap((s) => SubscriptionRef.get(s.session))))
235 .pipe(Effect.orElseSucceed(() => Option.none()));
236 if (Option.isSome(connected)) alternatives.push({ id, local: isLocal(entry) });
237 }
238 if (alternatives.length === 0) return yield* finish(source);
239
240 const identity = yield* request(WS_METHODS.pullRequestsRouting, ref).pipe(
241 Effect.timeout("2 seconds"),
242 Effect.catchCause((cause) =>
243 Cause.hasInterrupts(cause) ? Effect.interrupt : Effect.succeed(null),
244 ),
245 );
246 // Old servers and unknown accounts retain the existing path.
247 if (identity === null || identity.provider !== "github") return yield* finish(source);
248
249 const routedInput = {
250 ...input,
251 host: identity.host,
252 expectedAccountId: identity.accountId,
253 };
254 const guardedSource = sourceRead ?? (yield* Effect.cached(request(tag, routedInput)));
255 const local = alternatives.filter((entry) => entry.local);
256 const remote = alternatives.filter((entry) => !entry.local);
257 const sourceLocal = sourceEntry !== undefined && isLocal(sourceEntry);
258 const candidates = [
259 ...(sourceLocal && writes.has(tag) ? [origin.target.environmentId] : []),
260 ...local.map((entry) => entry.id),
261 ...(!sourceLocal && writes.has(tag) ? [origin.target.environmentId] : []),
262 ...remote.map((entry) => entry.id),
263 ...(reads.has(tag) ? [origin.target.environmentId] : []),
264 ];
265 const run = (id: EnvironmentId): ReturnType<typeof request<T>> =>
266 (id === origin.target.environmentId
267 ? guardedSource
268 : registry.run(id, request(tag, routedInput))
269 ).pipe(
270 Effect.tap(() =>
271 Effect.sync(() => {
272 const entry = used.get(refKey) ?? {
273 origin: origin.target.environmentId,
274 reference: { ...ref, host: identity.host },
275 targets: new Set<EnvironmentId>(),
276 };
277 entry.targets.add(id);
278 if (used.size >= 256 && !used.has(refKey)) {
279 const oldest = used.keys().next().value;
280 if (oldest !== undefined) used.delete(oldest);
281 }
282 used.set(refKey, entry);
283 }),
284 ),
285 Effect.mapError((error) =>
286 isUnregistered(error)
287 ? new EnvironmentRpcUnavailableError({
288 environmentId: id,
289 message: "The environment was removed.",
290 })
291 : error,
292 ),
293 );
294 const visit = (index: number): ReturnType<typeof request<T>> => {
295 const id = candidates[index];
296 if (id === undefined) return guardedSource;
297 return Effect.gen(function* () {
298 if (id !== origin.target.environmentId) {
299 if (!(yield* routingAllowed(registry, origin.target.environmentId, id, writes.has(tag))))
300 return yield* visit(index + 1);
301 // An older server would discard expectedAccountId. Verify it implements the guard first.
302 const alternate = yield* registry
303 .run(id, request(WS_METHODS.pullRequestsRoutingIdentity, { host: identity.host }))
304 .pipe(
305 Effect.timeout("2 seconds"),
306 Effect.catchCause((cause) =>
307 Cause.hasInterrupts(cause) ? Effect.interrupt : Effect.succeed(null),
308 ),
309 );
310 if (
311 alternate === null ||
312 alternate.provider !== "github" ||
313 alternate.host.toLowerCase() !== identity.host.toLowerCase() ||
314 alternate.accountId !== identity.accountId
315 ) {
316 return yield* visit(index + 1);
317 }
318 if (!(yield* routingAllowed(registry, origin.target.environmentId, id, writes.has(tag))))
319 return yield* visit(index + 1);
320 }
321 return yield* run(id).pipe(
322 Effect.catch((error) => {
323 if (
324 (reads.has(tag) || rejectedBeforeDispatch(error)) &&
325 index + 1 < candidates.length
326 ) {
327 return visit(index + 1);
328 }
329 return Effect.fail(error);
330 }),
331 Effect.map((result) =>
332 typeof result === "object" && result !== null && "projectId" in result
333 ? {
334 ...result,
335 projectId: ref.projectId,
336 ...(tag === WS_METHODS.pullRequestsDetail
337 ? { projectTitle: identity.projectTitle, workspaceRoot: identity.workspaceRoot }
338 : {}),
339 }
340 : result,
341 ),
342 );
343 });
344 };
345 return yield* finish(visit(0));
346 });
347
348 return Effect.fn("PullRequestRouting.readOrWrite")(function* <T extends EnvironmentUnaryRpcTag>(
349 tag: T,
350 input: EnvironmentRpcInput<T>,
351 ) {
352 if (!reads.has(tag) || !isRef(input)) return yield* routedRequest(tag, input);
353 const registry = yield* EnvironmentRegistry;
354 const entries = yield* SubscriptionRef.get(registry.entries);
355 if (entries.size < 2) return yield* request(tag, input);
356 const origin = yield* EnvironmentSupervisor;
357 let allowed = false;
358 for (const id of entries.keys()) {
359 if (
360 id !== origin.target.environmentId &&
361 (yield* routingAllowed(registry, origin.target.environmentId, id, false))
362 ) {
363 allowed = true;
364 break;
365 }
366 }
367 if (!allowed) return yield* request(tag, input);
368 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")),
375 ).pipe(
376 Effect.catch((error) =>
377 input.allowStale !== false &&
378 (tag === WS_METHODS.pullRequestsSummary || tag === WS_METHODS.pullRequestsDetail)
379 ? request(tag, input)
380 : Effect.fail(error),
381 ),
382 );
383 });
384}
packages/client-runtime/src/state/pullRequests.test.ts+672-7
......@@ -1,4 +1,10 @@
1import { EnvironmentId, ProjectId, WS_METHODS, type PullRequestStack } from "@t3tools/contracts";
1import {
2 EnvironmentId,
3 ProjectId,
4 PullRequestOperationError,
5 WS_METHODS,
6 type PullRequestStack,
7} from "@t3tools/contracts";
28import { expect, it } from "@effect/vitest";
39import * as Data from "effect/Data";
410import * as Effect from "effect/Effect";
......@@ -12,11 +18,15 @@ import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
1218
1319import {
1420 AVAILABLE_CONNECTION_STATE,
21 ConnectionTransientError,
1522 PrimaryConnectionTarget,
23 SshConnectionTarget,
1624 type PreparedConnection,
1725 type SupervisorConnectionState,
1826} from "../connection/model.ts";
1927import * as EnvironmentRegistry from "../connection/registry.ts";
28import { SshConnectionProfile, type ConnectionCatalogEntry } from "../connection/catalog.ts";
29import { ConnectionProfileStore } from "../connection/profileStore.ts";
2030import * as EnvironmentSupervisor from "../connection/supervisor.ts";
2131import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
2232import type { RpcSession } from "../rpc/session.ts";
......@@ -26,9 +36,200 @@ import {
2636} from "./pullRequests.ts";
2737import { PullRequestDiffLoader } from "./pullRequestDiffHttp.ts";
2838import { executeAtomQuery } from "./runtime.ts";
39import { createPullRequestRouter } from "./pullRequestRouting.ts";
40import { GitHubRoutingPermissions } from "../connection/githubRoutingPermissions.ts";
41
42const trustedRouting = {
43 get: () => Effect.succeed("read-write" as const),
44 changes: Stream.empty,
45 set: () => Effect.void,
46 forget: () => Effect.void,
47};
2948
3049class MutationRefused extends Data.TaggedError("MutationRefused") {}
3150
51for (const scenario of [
52 "prefers the local environment with the same github account",
53 "falls back before mutation when the local account differs",
54 "never retries an ambiguous mutation failure",
55 "returns a fast source read without checking alternate identities",
56 "keeps single-environment requests free of identity lookups",
57 "keeps a local origin ahead of another local environment",
58 "keeps mutations on an old origin server without retrying them",
59 "skips an old alternate server before dispatching a mutation",
60 "returns a successful mutation when source invalidation stalls",
61 "does not dispatch after routing permission is revoked during the probe",
62 "keeps mutations on the origin when the alternate is disabled",
63 "does not dispatch after the alternate is disabled during the probe",
64 "preserves a local source account rejection when alternate accounts differ",
65] as const) {
66 (scenario === "returns a successful mutation when source invalidation stalls"
67 ? it.live
68 : it.effect)(scenario, () =>
69 Effect.scoped(
70 Effect.gen(function* () {
71 const calls: string[] = [];
72 const inputs: unknown[] = [];
73 let trusted = true;
74 let disableAlternate = Effect.void;
75 const switchedAccount =
76 scenario === "preserves a local source account rejection when alternate accounts differ";
77 const mismatch =
78 scenario === "falls back before mutation when the local account differs" ||
79 switchedAccount;
80 const ambiguous = scenario === "never retries an ambiguous mutation failure";
81 const reading =
82 scenario === "returns a fast source read without checking alternate identities";
83 const single = scenario === "keeps single-environment requests free of identity lookups";
84 const localOrigin =
85 scenario === "keeps a local origin ahead of another local environment" || switchedAccount;
86 const oldOrigin =
87 scenario === "keeps mutations on an old origin server without retrying them";
88 const oldAlternate =
89 scenario === "skips an old alternate server before dispatching a mutation";
90 const failure = new PullRequestOperationError({
91 operation: switchedAccount ? "routeIdentity" : "runAction",
92 detail: "connection lost after dispatch",
93 });
94 const clientFor = (local: boolean) => {
95 const name = local ? "local" : "origin";
96 return {
97 [local ? WS_METHODS.pullRequestsRoutingIdentity : WS_METHODS.pullRequestsRouting]: (
98 input: unknown,
99 ) =>
100 Effect.gen(function* () {
101 if (local) expect(input).toEqual({ host: "github.com" });
102 calls.push(`${name}:identity`);
103 if (
104 local &&
105 scenario === "does not dispatch after the alternate is disabled during the probe"
106 )
107 yield* disableAlternate;
108 if (
109 local &&
110 scenario ===
111 "does not dispatch after routing permission is revoked during the probe"
112 )
113 trusted = false;
114 if ((!local && oldOrigin) || (local && oldAlternate)) {
115 return yield* Effect.die(
116 `Unknown request tag: ${WS_METHODS.pullRequestsRouting}`,
117 );
118 }
119 return {
120 host: "github.com",
121 provider: "github",
122 viewer: "maria-rcks",
123 accountId: local && mismatch ? "456" : "123",
124 };
125 }),
126 [WS_METHODS.pullRequestsRunAction]: (input: unknown) =>
127 Effect.gen(function* () {
128 calls.push(`${name}:mutation`);
129 inputs.push(input);
130 if (
131 !local &&
132 switchedAccount &&
133 (input as { expectedAccountId?: string }).expectedAccountId === "123"
134 )
135 return yield* failure;
136 if (local && ambiguous) return yield* failure;
137 }),
138 [WS_METHODS.pullRequestsSummary]: (input: { allowStale?: boolean }) =>
139 Effect.gen(function* () {
140 calls.push(`${name}:read`);
141 expect(input.allowStale).toBe(false);
142 if (local) return yield* failure;
143 return null;
144 }),
145 [WS_METHODS.pullRequestsInvalidate]: () =>
146 Effect.gen(function* () {
147 calls.push(`${name}:invalidate`);
148 if (
149 !local &&
150 scenario === "returns a successful mutation when source invalidation stalls"
151 ) {
152 return yield* Effect.never;
153 }
154 }),
155 } as unknown as WsRpcProtocolClient;
156 };
157 const { environmentRegistry, supervisor } = yield* makeTestRuntime(
158 clientFor(false),
159 single ? undefined : clientFor(true),
160 localOrigin,
161 );
162 disableAlternate = SubscriptionRef.update(
163 environmentRegistry.entries,
164 (entries) =>
165 new Map(
166 [...entries].map(([id, entry]) => [
167 id,
168 id === TARGET.environmentId ? entry : { ...entry, enabled: false },
169 ]),
170 ),
171 );
172 const disabled =
173 scenario === "keeps mutations on the origin when the alternate is disabled";
174 const disabledDuringProbe =
175 scenario === "does not dispatch after the alternate is disabled during the probe";
176 if (disabled) yield* disableAlternate;
177 const input = {
178 projectId: ProjectId.make("project-1"),
179 host: "github.com",
180 repository: "acme/web",
181 number: 7,
182 action: "merge" as const,
183 };
184 const route = createPullRequestRouter();
185 const result = yield* (
186 reading
187 ? route(WS_METHODS.pullRequestsSummary, input)
188 : route(WS_METHODS.pullRequestsRunAction, input)
189 ).pipe(
190 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),
191 Effect.provideService(GitHubRoutingPermissions, {
192 ...trustedRouting,
193 get: () => Effect.succeed(trusted ? "read-write" : "off"),
194 }),
195 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
196 Effect.result,
197 );
198
199 if (switchedAccount) {
200 expect(result).toMatchObject({ _tag: "Failure", failure });
201 expect(calls.filter((call) => call.endsWith(":mutation"))).toEqual(["origin:mutation"]);
202 expect(inputs).toEqual([{ ...input, expectedAccountId: "123" }]);
203 } else if (ambiguous) {
204 expect(result).toMatchObject({ _tag: "Failure", failure });
205 expect(calls.filter((call) => call.endsWith(":mutation"))).toEqual(["local:mutation"]);
206 } else {
207 expect(result._tag).toBe("Success");
208 if (single || disabled) {
209 expect(calls.filter((call) => !call.endsWith(":invalidate"))).toEqual([
210 "origin:mutation",
211 ]);
212 } else if (reading) expect(calls).toEqual(["origin:read"]);
213 else if (
214 mismatch ||
215 localOrigin ||
216 oldOrigin ||
217 oldAlternate ||
218 !trusted ||
219 disabledDuringProbe
220 )
221 expect(calls.filter((call) => call.endsWith(":mutation"))).toEqual(["origin:mutation"]);
222 else {
223 expect(calls.filter((call) => call.endsWith(":mutation"))).toEqual(["local:mutation"]);
224 expect(calls).toContain("origin:invalidate");
225 expect(inputs).toEqual([{ ...input, expectedAccountId: "123" }]);
226 }
227 }
228 }),
229 ),
230 );
231}
232
32233const TARGET = new PrimaryConnectionTarget({
33234 environmentId: EnvironmentId.make("environment-1"),
34235 label: "Test environment",
......@@ -38,7 +239,11 @@ const TARGET = new PrimaryConnectionTarget({
38239
39240function session(client: WsRpcProtocolClient): RpcSession {
40241 return {
41 client,
242 client: {
243 ...client,
244 [WS_METHODS.pullRequestsInvalidate]:
245 client[WS_METHODS.pullRequestsInvalidate] ?? (() => Effect.void),
246 },
42247 initialConfig: Effect.never,
43248 subscribeServerConfig: (input) => client.subscribeServerConfig(input),
44249 ready: Effect.void,
......@@ -47,7 +252,18 @@ function session(client: WsRpcProtocolClient): RpcSession {
47252 };
48253}
49254
50const makeTestRuntime = Effect.fn("makeTestRuntime")(function* (client: WsRpcProtocolClient) {
255const makeTestRuntime = Effect.fn("makeTestRuntime")(function* (
256 client: WsRpcProtocolClient,
257 localClient?: WsRpcProtocolClient,
258 localOrigin = false,
259) {
260 const originTarget = localOrigin
261 ? new PrimaryConnectionTarget({
262 ...TARGET,
263 httpBaseUrl: "http://localhost:3774",
264 wsBaseUrl: "ws://localhost:3774",
265 })
266 : TARGET;
51267 const connectionState: SupervisorConnectionState = {
52268 ...AVAILABLE_CONNECTION_STATE,
53269 desired: true,
......@@ -57,7 +273,7 @@ const makeTestRuntime = Effect.fn("makeTestRuntime")(function* (client: WsRpcPro
57273 generation: 1,
58274 };
59275 const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
60 target: TARGET,
276 target: originTarget,
61277 state: yield* SubscriptionRef.make(connectionState),
62278 session: yield* SubscriptionRef.make(Option.some(session(client))),
63279 prepared: yield* SubscriptionRef.make(Option.none<PreparedConnection>()),
......@@ -65,9 +281,40 @@ const makeTestRuntime = Effect.fn("makeTestRuntime")(function* (client: WsRpcPro
65281 disconnect: Effect.void,
66282 retryNow: Effect.void,
67283 } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]);
284 const localTarget = new PrimaryConnectionTarget({
285 environmentId: EnvironmentId.make("local-environment"),
286 label: "Local environment",
287 httpBaseUrl: "http://localhost:3773",
288 wsBaseUrl: "ws://localhost:3773",
289 });
290 const localSupervisor = {
291 ...supervisor,
292 target: localTarget,
293 session: yield* SubscriptionRef.make(Option.some(session(localClient ?? client))),
294 };
68295 const environmentRegistry = EnvironmentRegistry.EnvironmentRegistry.of({
69 run: (_environmentId, effect) =>
70 Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
296 entries: yield* SubscriptionRef.make<ReadonlyMap<EnvironmentId, ConnectionCatalogEntry>>(
297 new Map([
298 [
299 originTarget.environmentId,
300 { target: originTarget, profile: Option.none(), enabled: true },
301 ],
302 ...(localClient === undefined
303 ? []
304 : [
305 [
306 localTarget.environmentId,
307 { target: localTarget, profile: Option.none(), enabled: true },
308 ] as const,
309 ]),
310 ]),
311 ),
312 run: (environmentId, effect) =>
313 Effect.provideService(
314 effect,
315 EnvironmentSupervisor.EnvironmentSupervisor,
316 environmentId === localTarget.environmentId ? localSupervisor : supervisor,
317 ),
71318 runStream: (_environmentId, stream) =>
72319 Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
73320 followStream: (_environmentId, stream) =>
......@@ -86,9 +333,427 @@ const makeTestRuntime = Effect.fn("makeTestRuntime")(function* (client: WsRpcPro
86333 const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) =>
87334 Effect.sync(() => registry.dispose()),
88335 );
89 return { runtime, atoms, registry };
336 return { runtime, atoms, registry, environmentRegistry, supervisor };
90337});
91338
339for (const permission of ["default", "origin-off", "destination-off", "read-only"] as const) {
340 it.effect(`does not probe another environment with ${permission} routing permission`, () =>
341 Effect.scoped(
342 Effect.gen(function* () {
343 const calls: string[] = [];
344 const failure = new PullRequestOperationError({
345 operation: "summary",
346 detail: "source failed",
347 });
348 const client = {
349 [WS_METHODS.pullRequestsSummary]: () =>
350 Effect.suspend(() => {
351 calls.push("source-read");
352 return Effect.fail(failure);
353 }),
354 [WS_METHODS.pullRequestsRunAction]: () =>
355 Effect.sync(() => {
356 calls.push("source-write");
357 }),
358 } as unknown as WsRpcProtocolClient;
359 const alternate = {
360 [WS_METHODS.pullRequestsRoutingIdentity]: () =>
361 Effect.die("untrusted environment was contacted"),
362 } as unknown as WsRpcProtocolClient;
363 const { environmentRegistry, supervisor } = yield* makeTestRuntime(client, alternate);
364 const ref = {
365 projectId: ProjectId.make("project-1"),
366 repository: "private/repo",
367 number: 7,
368 };
369 const request = Effect.gen(function* () {
370 const route = createPullRequestRouter();
371 if (permission !== "read-only")
372 yield* route(WS_METHODS.pullRequestsSummary, ref).pipe(Effect.flip);
373 yield* route(WS_METHODS.pullRequestsRunAction, { ...ref, action: "merge" });
374 }).pipe(
375 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),
376 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
377 );
378 yield* permission === "default"
379 ? request
380 : request.pipe(
381 Effect.provideService(GitHubRoutingPermissions, {
382 ...trustedRouting,
383 get: (entry) =>
384 Effect.succeed(
385 permission === "read-only"
386 ? "read"
387 : (entry.target.environmentId === TARGET.environmentId) ===
388 (permission === "origin-off")
389 ? "off"
390 : "read-write",
391 ),
392 }),
393 );
394 expect(calls).toEqual(
395 permission === "read-only" ? ["source-write"] : ["source-read", "source-write"],
396 );
397 }),
398 ),
399 );
400}
401
402for (const side of ["origin", "destination"] as const) {
403 for (const stored of ["matching", "changed", "missing", "unavailable", "failed"] as const) {
404 it.effect(`checks the current ${side} SSH profile before routing with ${stored} storage`, () =>
405 Effect.scoped(
406 Effect.gen(function* () {
407 const calls: string[] = [];
408 const identity = {
409 host: "github.com",
410 provider: "github",
411 viewer: "maria",
412 accountId: "123",
413 };
414 const client = {
415 [WS_METHODS.pullRequestsRouting]: () =>
416 Effect.sync(() => {
417 calls.push("source-probe");
418 return identity;
419 }),
420 [WS_METHODS.pullRequestsRunAction]: (input: { expectedAccountId?: string }) =>
421 Effect.gen(function* () {
422 if (input.expectedAccountId !== undefined) {
423 return yield* new PullRequestOperationError({
424 operation: "routeIdentity",
425 detail: "Source guard refused.",
426 });
427 }
428 calls.push("source-write");
429 }),
430 [WS_METHODS.pullRequestsInvalidate]: () => Effect.void,
431 } as unknown as WsRpcProtocolClient;
432 const alternate = {
433 [WS_METHODS.pullRequestsRoutingIdentity]: () =>
434 Effect.sync(() => {
435 calls.push("alternate-probe");
436 return identity;
437 }),
438 [WS_METHODS.pullRequestsRunAction]: () =>
439 Effect.sync(() => {
440 calls.push("alternate-write");
441 }),
442 [WS_METHODS.pullRequestsInvalidate]: () => Effect.void,
443 } as unknown as WsRpcProtocolClient;
444 const { environmentRegistry, supervisor } = yield* makeTestRuntime(client, alternate);
445 const environmentId =
446 side === "origin" ? TARGET.environmentId : EnvironmentId.make("local-environment");
447 const profile = new SshConnectionProfile({
448 connectionId: "ssh-1",
449 environmentId,
450 label: "SSH",
451 target: { alias: "work", hostname: "work.example.test", username: "maria", port: 22 },
452 });
453 yield* SubscriptionRef.update(environmentRegistry.entries, (entries) =>
454 new Map(entries).set(environmentId, {
455 target: new SshConnectionTarget({
456 environmentId,
457 connectionId: profile.connectionId,
458 label: "SSH",
459 }),
460 profile: Option.some(profile),
461 enabled: true,
462 }),
463 );
464 const read = Effect.suspend(() =>
465 stored === "failed"
466 ? Effect.fail(
467 new ConnectionTransientError({
468 reason: "remote-unavailable",
469 detail: "Profile storage unavailable.",
470 }),
471 )
472 : Effect.succeed(
473 stored === "missing"
474 ? Option.none()
475 : Option.some(
476 stored === "changed"
477 ? new SshConnectionProfile({
478 ...profile,
479 target: { ...profile.target, hostname: "replacement.example.test" },
480 })
481 : profile,
482 ),
483 ),
484 );
485 const route = createPullRequestRouter()(WS_METHODS.pullRequestsRunAction, {
486 projectId: ProjectId.make("project-1"),
487 repository: "private/repo",
488 number: 7,
489 action: "merge",
490 }).pipe(
491 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),
492 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
493 // This also represents a user enabling the stale catalog entry after re-resolution.
494 Effect.provideService(GitHubRoutingPermissions, trustedRouting),
495 );
496 yield* stored === "unavailable"
497 ? route
498 : route.pipe(
499 Effect.provideService(ConnectionProfileStore, {
500 get: () => read,
501 put: () => Effect.die("unused"),
502 remove: () => Effect.die("unused"),
503 }),
504 );
505 expect(calls).toEqual(
506 stored === "matching"
507 ? ["source-probe", "alternate-probe", "alternate-write"]
508 : ["source-write"],
509 );
510 }),
511 ),
512 );
513 }
514}
515
516for (const probe of ["origin", "alternate"] as const) {
517 it.live(`bounds a stalled ${probe} metadata probe without repeating a strict source read`, () =>
518 Effect.scoped(
519 Effect.gen(function* () {
520 let sourceReads = 0;
521 const identity = {
522 host: "github.com",
523 provider: "github",
524 viewer: "maria",
525 accountId: "123",
526 };
527 const client = {
528 [WS_METHODS.pullRequestsRouting]: () =>
529 probe === "origin" ? Effect.never : Effect.succeed(identity),
530 [WS_METHODS.pullRequestsSummary]: () =>
531 Effect.suspend(() => {
532 sourceReads += 1;
533 return Effect.fail(
534 new PullRequestOperationError({ operation: "summary", detail: "source failed" }),
535 );
536 }),
537 } as unknown as WsRpcProtocolClient;
538 const alternate = {
539 [WS_METHODS.pullRequestsRoutingIdentity]: () => Effect.never,
540 } as unknown as WsRpcProtocolClient;
541 const { environmentRegistry, supervisor } = yield* makeTestRuntime(client, alternate);
542 const error = yield* createPullRequestRouter()(WS_METHODS.pullRequestsSummary, {
543 projectId: ProjectId.make("project-1"),
544 repository: "acme/web",
545 number: 7,
546 allowStale: false,
547 }).pipe(
548 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),
549 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
550 Effect.provideService(GitHubRoutingPermissions, trustedRouting),
551 Effect.flip,
552 );
553 expect(error._tag).toBe("PullRequestOperationError");
554 expect(sourceReads).toBe(1);
555 }),
556 ),
557 );
558}
559
560for (const source of ["pending", "pending-local", "failed", "offline"] as const) {
561 it.live(
562 source === "offline"
563 ? "returns held source data only after both fresh paths fail"
564 : `hedges a ${source} source read to local and interrupts the losing read`,
565 () =>
566 Effect.scoped(
567 Effect.gen(function* () {
568 let interrupted = false;
569 const calls: string[] = [];
570 const clientFor = (local: boolean) =>
571 ({
572 [local ? WS_METHODS.pullRequestsRoutingIdentity : WS_METHODS.pullRequestsRouting]:
573 () =>
574 Effect.succeed({
575 host: "github.com",
576 provider: "github",
577 viewer: "maria-rcks",
578 accountId: "123",
579 }),
580 [WS_METHODS.pullRequestsSummary]: (input: { allowStale?: boolean }) =>
581 Effect.gen(function* () {
582 calls.push(local ? "local" : input.allowStale === false ? "origin" : "held");
583 if (source === "offline" && !local && input.allowStale === undefined) {
584 return { state: "open" };
585 }
586 expect(input.allowStale).toBe(false);
587 if (local && source !== "offline") return null;
588 if (source !== "pending" && source !== "pending-local")
589 return yield* new PullRequestOperationError({
590 operation: "summary",
591 detail: "github unreachable",
592 });
593 return yield* Effect.never.pipe(
594 Effect.onInterrupt(() =>
595 Effect.sync(() => {
596 interrupted = true;
597 }),
598 ),
599 );
600 }),
601 }) as unknown as WsRpcProtocolClient;
602 const { environmentRegistry, supervisor } = yield* makeTestRuntime(
603 clientFor(false),
604 clientFor(true),
605 source === "pending-local",
606 );
607 const result = yield* createPullRequestRouter()(WS_METHODS.pullRequestsSummary, {
608 projectId: ProjectId.make("project-1"),
609 repository: "acme/web",
610 number: 7,
611 }).pipe(
612 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),
613 Effect.provideService(GitHubRoutingPermissions, trustedRouting),
614 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
615 );
616 if (source === "offline") {
617 expect(result).toEqual({ state: "open" });
618 expect(calls).toEqual(["origin", "local", "held"]);
619 } else {
620 expect(result).toBeNull();
621 expect(calls).toEqual(["origin", "local"]);
622 }
623 expect(interrupted).toBe(source === "pending" || source === "pending-local");
624 }),
625 ),
626 );
627}
628
629it.live("keeps source workspace metadata when an alternate answers a detail read", () =>
630 Effect.scoped(
631 Effect.gen(function* () {
632 const clientFor = (local: boolean) =>
633 ({
634 [local ? WS_METHODS.pullRequestsRoutingIdentity : WS_METHODS.pullRequestsRouting]: () =>
635 Effect.succeed({
636 host: "github.com",
637 provider: "github",
638 viewer: "maria-rcks",
639 accountId: "123",
640 projectTitle: local ? "local project" : "source project",
641 workspaceRoot: local ? "/Users/local/repo" : "/srv/source/repo",
642 }),
643 [WS_METHODS.pullRequestsDetail]: () =>
644 local
645 ? Effect.succeed({
646 projectId: "local-project",
647 projectTitle: "local project",
648 workspaceRoot: "/Users/local/repo",
649 title: "github title",
650 })
651 : Effect.never,
652 }) as unknown as WsRpcProtocolClient;
653 const { environmentRegistry, supervisor } = yield* makeTestRuntime(
654 clientFor(false),
655 clientFor(true),
656 );
657 const result = yield* createPullRequestRouter()(WS_METHODS.pullRequestsDetail, {
658 projectId: ProjectId.make("project-1"),
659 repository: "acme/web",
660 number: 7,
661 }).pipe(
662 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),
663 Effect.provideService(GitHubRoutingPermissions, trustedRouting),
664 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
665 );
666 expect(result).toEqual({
667 projectId: "project-1",
668 projectTitle: "source project",
669 workspaceRoot: "/srv/source/repo",
670 title: "github title",
671 });
672 }),
673 ),
674);
675
676it.live(
677 "refreshes identity before writes and invalidates prior readers across router instances",
678 () =>
679 Effect.scoped(
680 Effect.gen(function* () {
681 let originAccountId = "123";
682 const mutations: { environment: string; expectedAccountId: string }[] = [];
683 const invalidations: { environment: string; input: unknown }[] = [];
684 const identities: string[] = [];
685 const clientFor = (local: boolean) => {
686 const environment = local ? "local" : "origin";
687 return {
688 [local ? WS_METHODS.pullRequestsRoutingIdentity : WS_METHODS.pullRequestsRouting]: () =>
689 Effect.sync(() => {
690 identities.push(environment);
691 return {
692 host: "github.com",
693 provider: "github",
694 viewer: "maria-rcks",
695 accountId: local ? "123" : originAccountId,
696 };
697 }),
698 [WS_METHODS.pullRequestsSummary]: () => (local ? Effect.succeed(null) : Effect.never),
699 [WS_METHODS.pullRequestsRunAction]: (input: { expectedAccountId: string }) =>
700 Effect.sync(() => {
701 mutations.push({ environment, expectedAccountId: input.expectedAccountId });
702 }),
703 [WS_METHODS.pullRequestsInvalidate]: (input: unknown) =>
704 Effect.sync(() => {
705 invalidations.push({ environment, input });
706 }),
707 } as unknown as WsRpcProtocolClient;
708 };
709 const { environmentRegistry, supervisor } = yield* makeTestRuntime(
710 clientFor(false),
711 clientFor(true),
712 );
713 const reference = {
714 projectId: ProjectId.make("project-1"),
715 repository: "acme/web",
716 number: 7,
717 };
718 const hostedReference = { ...reference, host: "github.com", allowStale: false };
719 const route = createPullRequestRouter();
720 yield* Effect.gen(function* () {
721 yield* route(WS_METHODS.pullRequestsSummary, reference);
722 originAccountId = "456";
723 yield* route(WS_METHODS.pullRequestsRunAction, { ...reference, action: "merge" });
724
725 expect(identities.filter((environment) => environment === "origin")).toHaveLength(2);
726 expect(mutations).toEqual([{ environment: "origin", expectedAccountId: "456" }]);
727 expect(invalidations).toEqual(
728 expect.arrayContaining([
729 { environment: "origin", input: { reference: expect.objectContaining(reference) } },
730 { environment: "origin", input: { reference: hostedReference } },
731 { environment: "local", input: { reference: hostedReference } },
732 { environment: "origin", input: {} },
733 { environment: "local", input: {} },
734 ]),
735 );
736
737 invalidations.length = 0;
738 const refresh = createPullRequestRouter();
739 yield* refresh(WS_METHODS.pullRequestsInvalidate, { reference });
740 expect(invalidations).toContainEqual({
741 environment: "local",
742 input: { reference: hostedReference },
743 });
744
745 invalidations.length = 0;
746 yield* refresh(WS_METHODS.pullRequestsInvalidate, {});
747 expect(invalidations).toContainEqual({ environment: "local", input: {} });
748 }).pipe(
749 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),
750 Effect.provideService(GitHubRoutingPermissions, trustedRouting),
751 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
752 );
753 }),
754 ),
755);
756
92757it.effect("keeps concurrent diff file reads on different hosts separate", () =>
93758 Effect.scoped(
94759 Effect.gen(function* () {
packages/client-runtime/src/state/pullRequests.ts+23
......@@ -21,6 +21,7 @@ import {
2121 createEnvironmentRpcSubscriptionAtomFamily,
2222 createEnvironmentQueryAtomFamily,
2323} from "./runtime.ts";
24import { createPullRequestRouter } from "./pullRequestRouting.ts";
2425import { PullRequestDiffLoader } from "./pullRequestDiffHttp.ts";
2526import type { EnvironmentRegistry } from "../connection/registry.ts";
2627import { EnvironmentSupervisor } from "../connection/supervisor.ts";
......@@ -99,9 +100,11 @@ export function createLinkedPullRequestSummaryAtomFamily<R, E>(
99100 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
100101 refreshes = createPullRequestRefreshAtomFamily(runtime),
101102) {
103 const routedRequest = createPullRequestRouter();
102104 return createEnvironmentRpcQueryAtomFamily(runtime, {
103105 label: "environment-data:pull-requests:linked-summary",
104106 tag: WS_METHODS.pullRequestsSummary,
107 execute: (input) => routedRequest(WS_METHODS.pullRequestsSummary, input),
105108 staleTimeMs: 60_000,
106109 refreshIntervalMs: 60_000,
107110 idleTtlMs: LINKED_PULL_REQUEST_IDLE_TTL_MS,
......@@ -114,9 +117,11 @@ export function createPullRequestStackAtomFamily<R, E>(
114117 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
115118 refreshes = createPullRequestRefreshAtomFamily(runtime),
116119) {
120 const routedRequest = createPullRequestRouter();
117121 return createEnvironmentRpcQueryAtomFamily(runtime, {
118122 label: "environment-data:pull-requests:stack",
119123 tag: WS_METHODS.pullRequestsStack,
124 execute: (input) => routedRequest(WS_METHODS.pullRequestsStack, input),
120125 staleTimeMs: 60_000,
121126 idleTtlMs: LINKED_PULL_REQUEST_IDLE_TTL_MS,
122127 refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }),
......@@ -148,6 +153,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(
148153) {
149154 const refreshes = createPullRequestRefreshAtomFamily(runtime);
150155 const commandScheduler = createAtomCommandScheduler();
156 const routedRequest = createPullRequestRouter();
151157 const serialPerEnvironment = {
152158 mode: "serial",
153159 key: ({ environmentId }: { readonly environmentId: string }) => environmentId,
......@@ -156,6 +162,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(
156162 createEnvironmentRpcQueryAtomFamily(runtime, {
157163 label: "environment-data:pull-requests:activity",
158164 tag: WS_METHODS.pullRequestsActivity,
165 execute: (input) => routedRequest(WS_METHODS.pullRequestsActivity, input),
159166 staleTimeMs: 60_000,
160167 refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }),
161168 }),
......@@ -164,6 +171,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(
164171 createEnvironmentRpcQueryAtomFamily(runtime, {
165172 label: "environment-data:pull-requests:detail",
166173 tag: WS_METHODS.pullRequestsDetail,
174 execute: (input) => routedRequest(WS_METHODS.pullRequestsDetail, input),
167175 staleTimeMs: 60_000,
168176 refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }),
169177 }),
......@@ -172,6 +180,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(
172180 createEnvironmentRpcQueryAtomFamily(runtime, {
173181 label: "environment-data:pull-requests:label-candidates",
174182 tag: WS_METHODS.pullRequestsLabelCandidates,
183 execute: (input) => routedRequest(WS_METHODS.pullRequestsLabelCandidates, input),
175184 staleTimeMs: 60_000,
176185 }),
177186 );
......@@ -179,6 +188,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(
179188 createEnvironmentRpcQueryAtomFamily(runtime, {
180189 label: "environment-data:pull-requests:reviewer-candidates",
181190 tag: WS_METHODS.pullRequestsReviewerCandidates,
191 execute: (input) => routedRequest(WS_METHODS.pullRequestsReviewerCandidates, input),
182192 staleTimeMs: 60_000,
183193 }),
184194 );
......@@ -215,6 +225,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(
215225 threadComments: createEnvironmentRpcCommand(runtime, {
216226 label: "environment-data:pull-requests:thread-comments",
217227 tag: WS_METHODS.pullRequestsThreadComments,
228 execute: (input) => routedRequest(WS_METHODS.pullRequestsThreadComments, input),
218229 scheduler: commandScheduler,
219230 concurrency: {
220231 mode: "singleFlight",
......@@ -241,6 +252,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(
241252 diffFileContents: createEnvironmentRpcCommand(runtime, {
242253 label: "environment-data:pull-requests:diff-file-contents",
243254 tag: WS_METHODS.pullRequestsDiffFileContents,
255 execute: (input) => routedRequest(WS_METHODS.pullRequestsDiffFileContents, input),
244256 scheduler: commandScheduler,
245257 concurrency: {
246258 mode: "singleFlight",
......@@ -261,24 +273,28 @@ export function createPullRequestEnvironmentAtoms<R, E>(
261273 runAction: createEnvironmentRpcCommand(runtime, {
262274 label: "environment-data:pull-requests:run-action",
263275 tag: WS_METHODS.pullRequestsRunAction,
276 execute: (input) => routedRequest(WS_METHODS.pullRequestsRunAction, input),
264277 scheduler: commandScheduler,
265278 concurrency: serialPerEnvironment,
266279 }),
267280 update: createEnvironmentRpcCommand(runtime, {
268281 label: "environment-data:pull-requests:update",
269282 tag: WS_METHODS.pullRequestsUpdate,
283 execute: (input) => routedRequest(WS_METHODS.pullRequestsUpdate, input),
270284 scheduler: commandScheduler,
271285 concurrency: serialPerEnvironment,
272286 }),
273287 comment: createEnvironmentRpcCommand(runtime, {
274288 label: "environment-data:pull-requests:comment",
275289 tag: WS_METHODS.pullRequestsComment,
290 execute: (input) => routedRequest(WS_METHODS.pullRequestsComment, input),
276291 scheduler: commandScheduler,
277292 concurrency: serialPerEnvironment,
278293 }),
279294 updateComment: createEnvironmentRpcCommand(runtime, {
280295 label: "environment-data:pull-requests:update-comment",
281296 tag: WS_METHODS.pullRequestsUpdateComment,
297 execute: (input) => routedRequest(WS_METHODS.pullRequestsUpdateComment, input),
282298 scheduler: commandScheduler,
283299 concurrency: serialPerEnvironment,
284300 onSuccess: ({ environmentId, input: { projectId, host, repository, number } }, registry) =>
......@@ -294,12 +310,14 @@ export function createPullRequestEnvironmentAtoms<R, E>(
294310 submitReview: createEnvironmentRpcCommand(runtime, {
295311 label: "environment-data:pull-requests:submit-review",
296312 tag: WS_METHODS.pullRequestsSubmitReview,
313 execute: (input) => routedRequest(WS_METHODS.pullRequestsSubmitReview, input),
297314 scheduler: commandScheduler,
298315 concurrency: serialPerEnvironment,
299316 }),
300317 replyToThread: createEnvironmentRpcCommand(runtime, {
301318 label: "environment-data:pull-requests:reply-to-thread",
302319 tag: WS_METHODS.pullRequestsReplyToThread,
320 execute: (input) => routedRequest(WS_METHODS.pullRequestsReplyToThread, input),
303321 scheduler: commandScheduler,
304322 concurrency: serialPerEnvironment,
305323 }),
......@@ -313,6 +331,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(
313331 requestReviewers: createEnvironmentRpcCommand(runtime, {
314332 label: "environment-data:pull-requests:request-reviewers",
315333 tag: WS_METHODS.pullRequestsRequestReviewers,
334 execute: (input) => routedRequest(WS_METHODS.pullRequestsRequestReviewers, input),
316335 scheduler: commandScheduler,
317336 concurrency: serialPerEnvironment,
318337 onSuccess: (target, registry) =>
......@@ -389,6 +408,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(
389408 setLabels: createEnvironmentRpcCommand(runtime, {
390409 label: "environment-data:pull-requests:set-labels",
391410 tag: WS_METHODS.pullRequestsSetLabels,
411 execute: (input) => routedRequest(WS_METHODS.pullRequestsSetLabels, input),
392412 scheduler: commandScheduler,
393413 concurrency: serialPerEnvironment,
394414 onSuccess: (target, registry) =>
......@@ -424,12 +444,14 @@ export function createPullRequestEnvironmentAtoms<R, E>(
424444 setThreadResolution: createEnvironmentRpcCommand(runtime, {
425445 label: "environment-data:pull-requests:set-thread-resolution",
426446 tag: WS_METHODS.pullRequestsSetThreadResolution,
447 execute: (input) => routedRequest(WS_METHODS.pullRequestsSetThreadResolution, input),
427448 scheduler: commandScheduler,
428449 concurrency: serialPerEnvironment,
429450 }),
430451 setReaction: createEnvironmentRpcCommand(runtime, {
431452 label: "environment-data:pull-requests:set-reaction",
432453 tag: WS_METHODS.pullRequestsSetReaction,
454 execute: (input) => routedRequest(WS_METHODS.pullRequestsSetReaction, input),
433455 scheduler: commandScheduler,
434456 concurrency: serialPerEnvironment,
435457 }),
......@@ -441,6 +463,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(
441463 invalidate: createEnvironmentRpcCommand(runtime, {
442464 label: "environment-data:pull-requests:invalidate",
443465 tag: WS_METHODS.pullRequestsInvalidate,
466 execute: (input) => routedRequest(WS_METHODS.pullRequestsInvalidate, input),
444467 scheduler: commandScheduler,
445468 concurrency: serialPerEnvironment,
446469 }),
packages/client-runtime/src/state/runtime.ts+25-3
......@@ -11,6 +11,8 @@ import type { ConnectionAttemptError } from "../connection/model.ts";
1111import { EnvironmentNotRegisteredError, EnvironmentRegistry } from "../connection/registry.ts";
1212import {
1313 type EnvironmentRpcInput,
14 type EnvironmentRpcSuccess,
15 type EnvironmentRpcFailure,
1416 type EnvironmentRpcStreamFailure,
1517 type EnvironmentRpcStreamValue,
1618 type EnvironmentSubscriptionRpcTag,
......@@ -588,7 +590,12 @@ export function createEnvironmentSubscriptionAtomFamily<R, ER, Input, A, E>(
588590
589591export function createEnvironmentCommand<R, ER, Input, A, E>(
590592 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, ER>,
591 options: EnvironmentCommandAtomOptions<Input, A, E, EnvironmentSupervisor | R>,
593 options: EnvironmentCommandAtomOptions<
594 Input,
595 A,
596 E,
597 EnvironmentSupervisor | EnvironmentRegistry | R
598 >,
592599) {
593600 return createRuntimeCommand(runtime, {
594601 label: options.label,
......@@ -607,6 +614,13 @@ export function createEnvironmentRpcQueryAtomFamily<R, ER, TTag extends Environm
607614 options: {
608615 readonly label: string;
609616 readonly tag: TTag;
617 readonly execute?: (
618 input: EnvironmentRpcInput<TTag>,
619 ) => Effect.Effect<
620 EnvironmentRpcSuccess<TTag>,
621 EnvironmentRpcFailure<TTag> | EnvironmentRpcUnavailableError,
622 EnvironmentSupervisor | EnvironmentRegistry
623 >;
610624 readonly staleTimeMs?: number;
611625 readonly idleTtlMs?: number;
612626 readonly refreshIntervalMs?: number;
......@@ -624,7 +638,8 @@ export function createEnvironmentRpcQueryAtomFamily<R, ER, TTag extends Environm
624638 ? {}
625639 : { refreshIntervalMs: options.refreshIntervalMs }),
626640 ...(options.refreshTrigger === undefined ? {} : { refreshTrigger: options.refreshTrigger }),
627 execute: (input: EnvironmentRpcInput<TTag>) => request(options.tag, input),
641 execute: (input: EnvironmentRpcInput<TTag>) =>
642 options.execute?.(input) ?? request(options.tag, input),
628643 });
629644}
630645
......@@ -665,6 +680,13 @@ export function createEnvironmentRpcCommand<R, ER, TTag extends EnvironmentUnary
665680 options: {
666681 readonly label: string;
667682 readonly tag: TTag;
683 readonly execute?: (
684 input: EnvironmentRpcInput<TTag>,
685 ) => Effect.Effect<
686 EnvironmentRpcSuccess<TTag>,
687 EnvironmentRpcFailure<TTag> | EnvironmentRpcUnavailableError,
688 EnvironmentSupervisor | EnvironmentRegistry
689 >;
668690 readonly scheduler?: AtomCommandScheduler;
669691 readonly concurrency?: AtomCommandConcurrency<{
670692 readonly environmentId: EnvironmentIdType;
......@@ -695,7 +717,7 @@ export function createEnvironmentRpcCommand<R, ER, TTag extends EnvironmentUnary
695717 environmentId,
696718 input,
697719 };
698 return request(options.tag, input).pipe(
720 return (options.execute?.(input) ?? request(options.tag, input)).pipe(
699721 Effect.tap(() => options.onSuccess?.(target, registry) ?? Effect.void),
700722 Effect.ensuring(options.onSettled?.(target, registry) ?? Effect.void),
701723 );
packages/contracts/src/pullRequest.ts+28
......@@ -643,11 +643,39 @@ export type PullRequestListResult = typeof PullRequestListResult.Type;
643643export const PullRequestRef = Schema.Struct({
644644 projectId: ProjectId,
645645 host: Schema.optional(TrimmedNonEmptyString),
646 /** Refuse a routed operation unless this GitHub account still owns the active credential. */
647 expectedAccountId: Schema.optional(TrimmedNonEmptyString),
648 /** Let another environment answer when this one's cached response has expired. */
649 allowStale: Schema.optional(Schema.Boolean),
646650 repository: TrimmedNonEmptyString,
647651 number: PositiveInt,
648652});
649653export type PullRequestRef = typeof PullRequestRef.Type;
650654
655/** Account discovery never needs the originating project's private repository metadata. */
656export const PullRequestRoutingIdentityInput = Schema.Struct({
657 host: TrimmedNonEmptyString,
658});
659export type PullRequestRoutingIdentityInput = typeof PullRequestRoutingIdentityInput.Type;
660
661export const PullRequestRoutingIdentityResult = Schema.Struct({
662 accountId: TrimmedNonEmptyString,
663 host: TrimmedNonEmptyString,
664 provider: Schema.Literal("github"),
665 viewer: TrimmedNonEmptyString,
666});
667export type PullRequestRoutingIdentityResult = typeof PullRequestRoutingIdentityResult.Type;
668
669export const PullRequestRoutingResult = Schema.Struct({
670 accountId: TrimmedNonEmptyString,
671 host: TrimmedNonEmptyString,
672 provider: SourceControlProviderKind,
673 viewer: TrimmedNonEmptyString,
674 projectTitle: TrimmedNonEmptyString,
675 workspaceRoot: TrimmedNonEmptyString,
676});
677export type PullRequestRoutingResult = typeof PullRequestRoutingResult.Type;
678
651679export const PullRequestLinkedThreadsResult = Schema.Struct({
652680 threads: Schema.Array(
653681 Schema.Struct({
packages/contracts/src/rpc.ts+19
......@@ -115,6 +115,9 @@ import {
115115 PullRequestOperationError,
116116 PullRequestReactionInput,
117117 PullRequestRef,
118 PullRequestRoutingResult,
119 PullRequestRoutingIdentityInput,
120 PullRequestRoutingIdentityResult,
118121 PullRequestStack,
119122 PullRequestLinkedThreadsResult,
120123 PullRequestSummary,
......@@ -371,6 +374,8 @@ export const WS_METHODS = {
371374 pullRequestsList: "pullRequests.list",
372375 pullRequestsListStats: "pullRequests.listStats",
373376 pullRequestsSummary: "pullRequests.summary",
377 pullRequestsRouting: "pullRequests.routing",
378 pullRequestsRoutingIdentity: "pullRequests.routingIdentity",
374379 pullRequestsStack: "pullRequests.stack",
375380 pullRequestsLinkedThreads: "pullRequests.linkedThreads",
376381 pullRequestsDetail: "pullRequests.detail",
......@@ -672,6 +677,18 @@ const WsPullRequestsListStatsRpc = Rpc.make(WS_METHODS.pullRequestsListStats, {
672677 error: PullRequestRpcError,
673678});
674679
680const WsPullRequestsRoutingRpc = Rpc.make(WS_METHODS.pullRequestsRouting, {
681 payload: PullRequestRef,
682 success: PullRequestRoutingResult,
683 error: PullRequestRpcError,
684});
685
686const WsPullRequestsRoutingIdentityRpc = Rpc.make(WS_METHODS.pullRequestsRoutingIdentity, {
687 payload: PullRequestRoutingIdentityInput,
688 success: PullRequestRoutingIdentityResult,
689 error: PullRequestRpcError,
690});
691
675692const WsPullRequestsSummaryRpc = Rpc.make(WS_METHODS.pullRequestsSummary, {
676693 payload: PullRequestRef,
677694 success: PullRequestSummary,
......@@ -1316,6 +1333,8 @@ export const WsRpcGroup = RpcGroup.make(
13161333 WsPullRequestsListRpc,
13171334 WsPullRequestsListStatsRpc,
13181335 WsPullRequestsSummaryRpc,
1336 WsPullRequestsRoutingRpc,
1337 WsPullRequestsRoutingIdentityRpc,
13191338 WsPullRequestsStackRpc,
13201339 WsPullRequestsLinkedThreadsRpc,
13211340 WsPullRequestsDetailRpc,