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 {...@@ -14,6 +14,8 @@ import {
14 ConnectionTransientError,14 ConnectionTransientError,
15 CredentialStore,15 CredentialStore,
16 ProfileStore,16 ProfileStore,
17 GitHubRoutingPermissions,
18 makeGitHubRoutingPermissions,
17} from "@t3tools/client-runtime/connection";19} from "@t3tools/client-runtime/connection";
18import * as Context from "effect/Context";20import * as Context from "effect/Context";
19import * as Effect from "effect/Effect";21import * as Effect from "effect/Effect";
...@@ -39,6 +41,11 @@ function targetPersistenceError(...@@ -39,6 +41,11 @@ function targetPersistenceError(
39export const connectionStorageLayer = Layer.effectContext(41export const connectionStorageLayer = Layer.effectContext(
40 Effect.gen(function* () {42 Effect.gen(function* () {
41 const catalog = yield* CatalogStore.make();43 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
43 const targetStore = ConnectionTargetStore.of({50 const targetStore = ConnectionTargetStore.of({
44 list: catalog.read.pipe(51 list: catalog.read.pipe(
...@@ -138,6 +145,7 @@ export const connectionStorageLayer = Layer.effectContext(...@@ -138,6 +145,7 @@ export const connectionStorageLayer = Layer.effectContext(
138 })),145 })),
139 });146 });
140 return Context.make(ConnectionTargetStore, targetStore).pipe(147 return Context.make(ConnectionTargetStore, targetStore).pipe(
148 Context.add(GitHubRoutingPermissions, githubRoutingPermissions),
141 Context.add(ConnectionRegistrationStore, registrationStore),149 Context.add(ConnectionRegistrationStore, registrationStore),
142 Context.add(ProfileStore.ConnectionProfileStore, profileStore),150 Context.add(ProfileStore.ConnectionProfileStore, profileStore),
143 Context.add(CredentialStore.ConnectionCredentialStore, credentialStore),151 Context.add(CredentialStore.ConnectionCredentialStore, credentialStore),
apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx+2
...@@ -11,6 +11,7 @@ import { AppText as Text } from "../../components/AppText";...@@ -11,6 +11,7 @@ import { AppText as Text } from "../../components/AppText";
11import { cn } from "../../lib/cn";11import { cn } from "../../lib/cn";
12import { useRemoteConnections } from "../../state/use-remote-environment-registry";12import { useRemoteConnections } from "../../state/use-remote-environment-registry";
13import { ConnectionEnvironmentRow } from "./ConnectionEnvironmentRow";13import { ConnectionEnvironmentRow } from "./ConnectionEnvironmentRow";
14import { GitHubRoutingSettings } from "./GitHubRoutingSettings";
1415
15export function ConnectionsRouteScreen() {16export function ConnectionsRouteScreen() {
16 const {17 const {
...@@ -97,6 +98,7 @@ export function ConnectionsRouteScreen() {...@@ -97,6 +98,7 @@ export function ConnectionsRouteScreen() {
97 </Text>98 </Text>
98 </View>99 </View>
99 )}100 )}
101 <GitHubRoutingSettings />
100 </ScrollView>102 </ScrollView>
101 </View>103 </View>
102 );104 );
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";...@@ -10,6 +10,7 @@ import { AppText as Text } from "../../components/AppText";
10import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";10import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
11import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows";11import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows";
12import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow";12import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow";
13import { GitHubRoutingSettings } from "../connection/GitHubRoutingSettings";
13import { splitEnvironmentSections } from "../connection/environmentSections";14import { splitEnvironmentSections } from "../connection/environmentSections";
14import { cn } from "../../lib/cn";15import { cn } from "../../lib/cn";
15import { useUniwindTheme } from "../../lib/useUniwindTheme";16import { useUniwindTheme } from "../../lib/useUniwindTheme";
...@@ -174,6 +175,7 @@ export function SettingsEnvironmentsRouteScreen() {...@@ -174,6 +175,7 @@ export function SettingsEnvironmentsRouteScreen() {
174 }175 }
175 : {})}176 : {})}
176 />177 />
178 <GitHubRoutingSettings />
177 </ScrollView>179 </ScrollView>
178 </View>180 </View>
179 );181 );
apps/server/src/auth/RpcAuthorization.ts+2
...@@ -68,6 +68,8 @@ export const RPC_REQUIRED_SCOPES = {...@@ -68,6 +68,8 @@ export const RPC_REQUIRED_SCOPES = {
68 [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope,68 [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope,
69 [WS_METHODS.pullRequestsListStats]: AuthOrchestrationReadScope,69 [WS_METHODS.pullRequestsListStats]: AuthOrchestrationReadScope,
70 [WS_METHODS.pullRequestsSummary]: AuthOrchestrationReadScope,70 [WS_METHODS.pullRequestsSummary]: AuthOrchestrationReadScope,
71 [WS_METHODS.pullRequestsRouting]: AuthOrchestrationReadScope,
72 [WS_METHODS.pullRequestsRoutingIdentity]: AuthOrchestrationReadScope,
71 [WS_METHODS.pullRequestsStack]: AuthOrchestrationReadScope,73 [WS_METHODS.pullRequestsStack]: AuthOrchestrationReadScope,
72 [WS_METHODS.pullRequestsLinkedThreads]: AuthOrchestrationReadScope,74 [WS_METHODS.pullRequestsLinkedThreads]: AuthOrchestrationReadScope,
73 [WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope,75 [WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope,
apps/server/src/pullRequest/GitHubPullRequestCli.test.ts+182-1
...@@ -1,11 +1,14 @@...@@ -1,11 +1,14 @@
1import { afterEach, assert, expect, it, vi } from "@effect/vitest";1import { afterEach, assert, expect, it, vi } from "@effect/vitest";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as Deferred from "effect/Deferred";
4import * as Fiber from "effect/Fiber";
3import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
4import * as Schema from "effect/Schema";6import * as Schema from "effect/Schema";
5import * as TestClock from "effect/testing/TestClock";7import * as TestClock from "effect/testing/TestClock";
6import { ChildProcessSpawner } from "effect/unstable/process";8import { ChildProcessSpawner } from "effect/unstable/process";
79
8import * as GitHubCli from "../sourceControl/GitHubCli.ts";10import * as GitHubCli from "../sourceControl/GitHubCli.ts";
11import * as VcsProcess from "../vcs/VcsProcess.ts";
9import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts";12import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts";
10import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";13import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";
11import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts";14import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts";
...@@ -192,7 +195,118 @@ afterEach(() => {...@@ -192,7 +195,118 @@ afterEach(() => {
192 mockedGetPullRequest.mockReset();195 mockedGetPullRequest.mockReset();
193});196});
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
195layer("GitHubPullRequestCli.layer", (it) => {255layer("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
196 it.effect("reads linked pull request status with the overview fields in one request", () =>310 it.effect("reads linked pull request status with the overview fields in one request", () =>
197 Effect.gen(function* () {311 Effect.gen(function* () {
198 mockedExecute.mockReturnValueOnce(312 mockedExecute.mockReturnValueOnce(
...@@ -2458,12 +2572,79 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -2458,12 +2572,79 @@ layer("GitHubPullRequestCli.layer", (it) => {
2458 mockedExecute.mockReturnValueOnce(Effect.succeed(output(" ")));2572 mockedExecute.mockReturnValueOnce(Effect.succeed(output(" ")));
2459 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;2573 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
2463 assert.strictEqual(error._tag, "GitHubViewerLoginUnavailableError");2577 assert.strictEqual(error._tag, "GitHubViewerLoginUnavailableError");
2464 }),2578 }),
2465 );2579 );
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
2467 it.effect("sends a whole review as one request body over stdin", () =>2648 it.effect("sends a whole review as one request body over stdin", () =>
2468 Effect.gen(function* () {2649 Effect.gen(function* () {
2469 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));2650 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
apps/server/src/pullRequest/GitHubPullRequestCli.ts+130-8
...@@ -1,11 +1,17 @@...@@ -1,11 +1,17 @@
1import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts";1import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts";
2import * as Context from "effect/Context";2import * as Context from "effect/Context";
3import * as Clock from "effect/Clock";
4import * as NodeCrypto from "node:crypto";
3import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";6import * as Layer from "effect/Layer";
7import * as Redacted from "effect/Redacted";
5import * as Result from "effect/Result";8import * as Result from "effect/Result";
6import * as Schema from "effect/Schema";9import * as Schema from "effect/Schema";
10import * as Semaphore from "effect/Semaphore";
7import {11import {
8 resolvePullRequestAuthorFilter,12 resolvePullRequestAuthorFilter,
13 PositiveInt,
14 TrimmedNonEmptyString,
9 type PullRequestAction,15 type PullRequestAction,
10 type PullRequestStackHead,16 type PullRequestStackHead,
11 type PullRequestActor,17 type PullRequestActor,
...@@ -413,8 +419,24 @@ export interface GitHubPullRequestDiffSlice {...@@ -413,8 +419,24 @@ export interface GitHubPullRequestDiffSlice {
413export class GitHubPullRequestCli extends Context.Service<419export class GitHubPullRequestCli extends Context.Service<
414 GitHubPullRequestCli,420 GitHubPullRequestCli,
415 {421 {
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 >;
416 readonly getViewerLogin: (input: {437 readonly getViewerLogin: (input: {
417 readonly cwd: string;438 readonly cwd: string;
439 readonly host: string;
418 }) => Effect.Effect<string, GitHubPullRequestCliError>;440 }) => Effect.Effect<string, GitHubPullRequestCliError>;
419441
420 readonly listPullRequests: (input: {442 readonly listPullRequests: (input: {
...@@ -991,6 +1013,111 @@ function actionArgs(...@@ -991,6 +1013,111 @@ function actionArgs(
991export const make = Effect.gen(function* () {1013export const make = Effect.gen(function* () {
992 const github = yield* GitHubCli.GitHubCli;1014 const github = yield* GitHubCli.GitHubCli;
993 const graphQlBudget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;1015 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
995 /**1122 /**
996 * The pull request's own node id, which is what a mutation against the pull request itself is1123 * 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* () {...@@ -1468,15 +1595,10 @@ export const make = Effect.gen(function* () {
1468 );1595 );
14691596
1470 return GitHubPullRequestCli.of({1597 return GitHubPullRequestCli.of({
1598 withVerifiedCredential,
1599 getRoutingIdentity,
1471 getViewerLogin: (input) =>1600 getViewerLogin: (input) =>
1472 github.execute({ cwd: input.cwd, args: ["api", "user", "--jq", ".login"] }).pipe(1601 getRoutingIdentity(input).pipe(Effect.map((identity) => identity.viewer)),
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 ),
14801602
1481 listPullRequests: (input) => {1603 listPullRequests: (input) => {
1482 const fallbackMaxRows = Math.max(input.limit + 1, PULL_REQUEST_FALLBACK_MAX_ROWS);1604 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 @@...@@ -1,6 +1,7 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";3import * as Layer from "effect/Layer";
4import * as Redacted from "effect/Redacted";
4import type { PullRequestReaction } from "@t3tools/contracts";5import type { PullRequestReaction } from "@t3tools/contracts";
56
6import * as GitHubCli from "../sourceControl/GitHubCli.ts";7import * as GitHubCli from "../sourceControl/GitHubCli.ts";
...@@ -8,6 +9,44 @@ import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";...@@ -8,6 +9,44 @@ import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";
8import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts";9import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts";
9import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts";10import 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
11it.effect("uses one narrow read for a linked pull request summary", () =>50it.effect("uses one narrow read for a linked pull request summary", () =>
12 Effect.gen(function* () {51 Effect.gen(function* () {
13 let summaryReads = 0;52 let summaryReads = 0;
...@@ -215,6 +254,23 @@ describe("gitHubViewerPermissions", () => {...@@ -215,6 +254,23 @@ describe("gitHubViewerPermissions", () => {
215 description: "GitHub could not determine whether workflows are awaiting approval.",254 description: "GitHub could not determine whether workflows are awaiting approval.",
216 url: null,255 url: null,
217 });256 });
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 }
218 }).pipe(274 }).pipe(
219 Effect.provide(275 Effect.provide(
220 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({276 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
...@@ -250,10 +306,16 @@ describe("gitHubViewerPermissions", () => {...@@ -250,10 +306,16 @@ describe("gitHubViewerPermissions", () => {
250 commits: [],306 commits: [],
251 }),307 }),
252 getRepositoryAccess: () =>308 getRepositoryAccess: () =>
253 Effect.succeed({309 GitHubCli.PinnedGitHubCredential.pipe(
254 canWrite: false,310 Effect.map((credential) => ({
255 mergeCapabilities: { merge: true, squash: true, rebase: true },311 canWrite: false,
256 }),312 mergeCapabilities: {
313 merge: true,
314 squash: credential?.credentialFingerprint !== "restricted",
315 rebase: true,
316 },
317 })),
318 ),
257 getViewerAccess: () =>319 getViewerAccess: () =>
258 Effect.succeed({320 Effect.succeed({
259 canWrite: false,321 canWrite: false,
...@@ -513,6 +575,36 @@ it.effect("propagates workflow discovery rate limits", () =>...@@ -513,6 +575,36 @@ it.effect("propagates workflow discovery rate limits", () =>
513);575);
514576
515describe("getViewerPermissions", () => {577describe("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
516 const layerWithComparison = (608 const layerWithComparison = (
517 comparison: Effect.Effect<{609 comparison: Effect.Effect<{
518 readonly behindBy: number | null;610 readonly behindBy: number | null;
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+40-16
...@@ -11,6 +11,7 @@ import type {...@@ -11,6 +11,7 @@ import type {
11} from "@t3tools/contracts";11} from "@t3tools/contracts";
1212
13import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";13import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";
14import { PinnedGitHubCredential } from "../sourceControl/GitHubCli.ts";
14import {15import {
15 PullRequestProviderError,16 PullRequestProviderError,
16 type PullRequestProviderFailure,17 type PullRequestProviderFailure,
...@@ -198,7 +199,20 @@ export const make = Effect.gen(function* () {...@@ -198,7 +199,20 @@ export const make = Effect.gen(function* () {
198 readonly cwd: string;199 readonly cwd: string;
199 readonly repository: string;200 readonly repository: string;
200 readonly host: string;201 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
203 const fail = (operation: string) => (error: GitHubPullRequestCli.GitHubPullRequestCliError) =>217 const fail = (operation: string) => (error: GitHubPullRequestCli.GitHubPullRequestCliError) =>
204 new PullRequestProviderError({218 new PullRequestProviderError({
...@@ -212,9 +226,17 @@ export const make = Effect.gen(function* () {...@@ -212,9 +226,17 @@ export const make = Effect.gen(function* () {
212 const provider: PullRequestProviderApi = {226 const provider: PullRequestProviderApi = {
213 kind: "github",227 kind: "github",
214 capabilities: CAPABILITIES,228 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
216 getViewer: (input) =>236 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
219 listChangeRequests: (input) =>241 listChangeRequests: (input) =>
220 cli242 cli
...@@ -494,20 +516,22 @@ export const make = Effect.gen(function* () {...@@ -494,20 +516,22 @@ export const make = Effect.gen(function* () {
494 // comparison only resolves through the head ref the detail carries. A failure here516 // comparison only resolves through the head ref the detail carries. A failure here
495 // withholds that one action rather than the whole answer, the way the detail path517 // withholds that one action rather than the whole answer, the way the detail path
496 // leaves the banner unknown.518 // leaves the banner unknown.
497 cli.getPullRequestDetail(input).pipe(519 input.includeUpdateBranch === false
498 Effect.flatMap((pullRequest) =>520 ? Effect.succeed(false)
499 pullRequest.state !== "open" || pullRequest.headRepositoryOwner === null521 : cli.getPullRequestDetail(input).pipe(
500 ? Effect.succeed(false)522 Effect.flatMap((pullRequest) =>
501 : cli523 pullRequest.state !== "open" || pullRequest.headRepositoryOwner === null
502 .getPullRequestBaseComparison({524 ? Effect.succeed(false)
503 ...input,525 : cli
504 headRef: `${pullRequest.headRepositoryOwner}:${pullRequest.headBranch}`,526 .getPullRequestBaseComparison({
505 allowReserve: true,527 ...input,
506 })528 headRef: `${pullRequest.headRepositoryOwner}:${pullRequest.headBranch}`,
507 .pipe(Effect.map((comparison) => comparison.viewerCanUpdate === true)),529 allowReserve: true,
508 ),530 })
509 Effect.orElseSucceed(() => false),531 .pipe(Effect.map((comparison) => comparison.viewerCanUpdate === true)),
510 ),532 ),
533 Effect.orElseSucceed(() => false),
534 ),
511 ],535 ],
512 { concurrency: 2 },536 { concurrency: 2 },
513 ).pipe(537 ).pipe(
apps/server/src/pullRequest/PullRequestProvider.ts+20-1
...@@ -276,6 +276,21 @@ export interface ProviderRepositoryRef {...@@ -276,6 +276,21 @@ export interface ProviderRepositoryRef {
276 * failing at call time.276 * failing at call time.
277 */277 */
278export interface PullRequestProviderApi {278export 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 >;
279 readonly kind: SourceControlProviderKind;294 readonly kind: SourceControlProviderKind;
280 readonly capabilities: PullRequestCapabilities;295 readonly capabilities: PullRequestCapabilities;
281296
...@@ -398,7 +413,11 @@ export interface PullRequestProviderApi {...@@ -398,7 +413,11 @@ export interface PullRequestProviderApi {
398 * is no request at all.413 * is no request at all.
399 */414 */
400 readonly getViewerPermissions: (415 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 },
402 ) => Effect.Effect<PullRequestViewerPermissions, PullRequestProviderError>;421 ) => Effect.Effect<PullRequestViewerPermissions, PullRequestProviderError>;
403422
404 /**423 /**
apps/server/src/pullRequest/PullRequestService.test.ts+197-3
...@@ -16,6 +16,7 @@ import type {...@@ -16,6 +16,7 @@ import type {
16 PullRequestReviewerCapabilities,16 PullRequestReviewerCapabilities,
17 SourceControlProviderKind,17 SourceControlProviderKind,
18} from "@t3tools/contracts";18} from "@t3tools/contracts";
19import { PullRequestOperationError } from "@t3tools/contracts";
1920
20import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";21import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
21import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";22import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
...@@ -974,6 +975,51 @@ it.effect("tries another workspace on the same host for the viewer", () =>...@@ -974,6 +975,51 @@ it.effect("tries another workspace on the same host for the viewer", () =>
974 }),975 }),
975);976);
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
977it.effect("refuses an action the host never claimed it could run", () =>1023it.effect("refuses an action the host never claimed it could run", () =>
978 Effect.gen(function* () {1024 Effect.gen(function* () {
979 let ran = false;1025 let ran = false;
...@@ -3707,9 +3753,7 @@ it.effect("shares linked summaries and reuses them for display without asking th...@@ -3707,9 +3753,7 @@ it.effect("shares linked summaries and reuses them for display without asking th
37073753
3708 yield* TestClock.adjust("61 seconds");3754 yield* TestClock.adjust("61 seconds");
3709 failing = true;3755 failing = true;
3710 const strict = yield* Effect.flip(3756 const strict = yield* Effect.flip(service.summary({ ...reference, allowStale: false }));
3711 service.summary(reference, { recoverTransientFailure: false }),
3712 );
3713 assert.strictEqual(strict._tag, "PullRequestOperationError");3757 assert.strictEqual(strict._tag, "PullRequestOperationError");
37143758
3715 const stale = yield* service.summary(reference);3759 const stale = yield* service.summary(reference);
...@@ -3723,6 +3767,147 @@ it.effect("shares linked summaries and reuses them for display without asking th...@@ -3723,6 +3767,147 @@ it.effect("shares linked summaries and reuses them for display without asking th
3723 }),3767 }),
3724);3768);
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
3726it.effect("answers a known pull request immediately while the host refreshes", () =>3911it.effect("answers a known pull request immediately while the host refreshes", () =>
3727 Effect.gen(function* () {3912 Effect.gen(function* () {
3728 const gate = yield* Deferred.make<void>();3913 const gate = yield* Deferred.make<void>();
...@@ -3895,6 +4080,13 @@ it.effect("does not let a stale detail reopen overwrite a fresher linked summary...@@ -3895,6 +4080,13 @@ it.effect("does not let a stale detail reopen overwrite a fresher linked summary
3895 assert.strictEqual(display.title, "merged title");4080 assert.strictEqual(display.title, "merged title");
3896 assert.strictEqual(display.state, "merged");4081 assert.strictEqual(display.state, "merged");
3897 assert.strictEqual(detailCalls, 2);4082 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");
3898 }),4090 }),
3899);4091);
39004092
...@@ -3978,6 +4170,8 @@ it.effect("keeps recent detail on a transient refresh failure but not after inva...@@ -3978,6 +4170,8 @@ it.effect("keeps recent detail on a transient refresh failure but not after inva
3978 yield* service.detail(reference);4170 yield* service.detail(reference);
3979 yield* TestClock.adjust("16 seconds");4171 yield* TestClock.adjust("16 seconds");
3980 failing = true;4172 failing = true;
4173 const strict = yield* Effect.flip(service.detail({ ...reference, allowStale: false }));
4174 assert.strictEqual(strict._tag, "PullRequestOperationError");
3981 const stale = yield* service.detail(reference);4175 const stale = yield* service.detail(reference);
3982 assert.strictEqual(stale.body, "last good body");4176 assert.strictEqual(stale.body, "last good body");
39834177
apps/server/src/pullRequest/PullRequestService.ts+180-31
...@@ -46,6 +46,9 @@ import {...@@ -46,6 +46,9 @@ import {
46 type PullRequestProviderSummary,46 type PullRequestProviderSummary,
47 type PullRequestReactionInput,47 type PullRequestReactionInput,
48 type PullRequestRef,48 type PullRequestRef,
49 type PullRequestRoutingResult,
50 type PullRequestRoutingIdentityInput,
51 type PullRequestRoutingIdentityResult,
49 type PullRequestReviewVerdict,52 type PullRequestReviewVerdict,
50 type PullRequestReviewerCandidateList,53 type PullRequestReviewerCandidateList,
51 type PullRequestReviewerRequestInput,54 type PullRequestReviewerRequestInput,
...@@ -138,6 +141,14 @@ const VIEWER_CACHE_CAPACITY = 32;...@@ -138,6 +141,14 @@ const VIEWER_CACHE_CAPACITY = 32;
138141
139export type PullRequestError = PullRequestUnavailableError | PullRequestOperationError;142export 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
141export class PullRequestService extends Context.Service<152export class PullRequestService extends Context.Service<
142 PullRequestService,153 PullRequestService,
143 {154 {
...@@ -147,6 +158,16 @@ export class PullRequestService extends Context.Service<...@@ -147,6 +158,16 @@ export class PullRequestService extends Context.Service<
147 readonly listStats: (158 readonly listStats: (
148 input: PullRequestListStatsInput,159 input: PullRequestListStatsInput,
149 ) => Effect.Effect<PullRequestListStatsResult, PullRequestError>;160 ) => 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>;
150 readonly summary: (171 readonly summary: (
151 input: PullRequestRef,172 input: PullRequestRef,
152 options?: { readonly recoverTransientFailure?: boolean },173 options?: { readonly recoverTransientFailure?: boolean },
...@@ -473,6 +494,10 @@ function withRateLimitBackoff(...@@ -473,6 +494,10 @@ function withRateLimitBackoff(
473 kind: api.kind,494 kind: api.kind,
474 capabilities: api.capabilities,495 capabilities: api.capabilities,
475 getViewer: wrap("getViewer", api.getViewer),496 getViewer: wrap("getViewer", api.getViewer),
497 ...(api.getRoutingIdentity === undefined ? {} : { getRoutingIdentity: api.getRoutingIdentity }),
498 ...(api.withVerifiedCredential === undefined
499 ? {}
500 : { withVerifiedCredential: api.withVerifiedCredential }),
476 listChangeRequests: wrap("listChangeRequests", api.listChangeRequests),501 listChangeRequests: wrap("listChangeRequests", api.listChangeRequests),
477 ...(api.listChangeRequestsAcross === undefined502 ...(api.listChangeRequestsAcross === undefined
478 ? {}503 ? {}
...@@ -776,13 +801,19 @@ export const make = Effect.gen(function* () {...@@ -776,13 +801,19 @@ export const make = Effect.gen(function* () {
776 * handed to a provider on the client's word. Read freshly for that reason, rather than taken801 * handed to a provider on the client's word. Read freshly for that reason, rather than taken
777 * from whatever the detail said when the page loaded.802 * from whatever the detail said when the page loaded.
778 */803 */
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 ) =>
780 project.api810 project.api
781 .getViewerPermissions({811 .getViewerPermissions({
782 cwd: project.project.workspaceRoot,812 cwd: project.project.workspaceRoot,
783 repository: project.repository,813 repository: project.repository,
784 host: project.host,814 host: project.host,
785 number: ref.number,815 number: ref.number,
816 includeUpdateBranch,
786 })817 })
787 .pipe(Effect.mapError(toPullRequestError(operation)));818 .pipe(Effect.mapError(toPullRequestError(operation)));
788819
...@@ -1309,7 +1340,92 @@ export const make = Effect.gen(function* () {...@@ -1309,7 +1340,92 @@ export const make = Effect.gen(function* () {
1309 * and a host that cannot say leaves it null rather than failing the read it decorates.1340 * and a host that cannot say leaves it null rather than failing the read it decorates.
1310 */1341 */
1311 const viewerOf = (project: SupportedProject): Effect.Effect<string | null> =>1342 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
1314 const summaryUncached: PullRequestService["Service"]["summary"] = (input) =>1430 const summaryUncached: PullRequestService["Service"]["summary"] = (input) =>
1315 requireProject(input).pipe(1431 requireProject(input).pipe(
...@@ -1620,7 +1736,12 @@ export const make = Effect.gen(function* () {...@@ -1620,7 +1736,12 @@ export const make = Effect.gen(function* () {
1620 // What the host can do and what this account may ask of it are two questions, and both1736 // What the host can do and what this account may ask of it are two questions, and both
1621 // have to say yes. The second is asked last, because it costs a request and the checks1737 // have to say yes. The second is asked last, because it costs a request and the checks
1622 // above do not.1738 // 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(
1624 Effect.flatMap((viewer): Effect.Effect<string, PullRequestError> => {1745 Effect.flatMap((viewer): Effect.Effect<string, PullRequestError> => {
1625 const stackRebase = input.stackNumber !== undefined && input.action === "update-branch";1746 const stackRebase = input.stackNumber !== undefined && input.action === "update-branch";
1626 if (1747 if (
...@@ -2193,6 +2314,12 @@ export const make = Effect.gen(function* () {...@@ -2193,6 +2314,12 @@ export const make = Effect.gen(function* () {
21932314
2194 const context = yield* Effect.context<never>();2315 const context = yield* Effect.context<never>();
2195 const runFork = Effect.runForkWith(context);2316 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
2197 /**2324 /**
2198 * The diff is not live-polled and is expensive enough to keep its stale-while-revalidate path.2325 * 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* () {...@@ -2218,7 +2345,7 @@ export const make = Effect.gen(function* () {
2218 // Run as its own fiber rather than a child: the caller is answered and gone before the2345 // Run as its own fiber rather than a child: the caller is answered and gone before the
2219 // refresh lands. The read still coalesces on the cache key, so ten stale reads in one2346 // refresh lands. The read still coalesces on the cache key, so ten stale reads in one
2220 // window cost one host request — and a failed refresh costs nothing but the retry.2347 // 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));
2222 });2349 });
2223 };2350 };
2224 })();2351 })();
...@@ -2276,9 +2403,7 @@ export const make = Effect.gen(function* () {...@@ -2276,9 +2403,7 @@ export const make = Effect.gen(function* () {
2276 const snapshot = held.get(key);2403 const snapshot = held.get(key);
2277 if (snapshot === undefined) return read(key, effect);2404 if (snapshot === undefined) return read(key, effect);
2278 if (mode === "reuse") return Effect.succeed(snapshot.value);2405 if (mode === "reuse") return Effect.succeed(snapshot.value);
2279 return Effect.sync(() => runFork(Effect.ignore(read(key, effect)))).pipe(2406 return revalidate(read(key, effect)).pipe(Effect.as(snapshot.value));
2280 Effect.as(snapshot.value),
2281 );
2282 };2407 };
2283 return { peek: (key: string) => held.get(key)?.value, read, record, serveHeld };2408 return { peek: (key: string) => held.get(key)?.value, read, record, serveHeld };
2284 };2409 };
...@@ -2300,25 +2425,25 @@ export const make = Effect.gen(function* () {...@@ -2300,25 +2425,25 @@ export const make = Effect.gen(function* () {
2300 Math.max(turnRefreshEpoch, refEpochs.get(refScope(ref)) ?? 0);2425 Math.max(turnRefreshEpoch, refEpochs.get(refScope(ref)) ?? 0);
2301 // Keys carry the reference back out of the cache loader, so the slot layout is shared with2426 // Keys carry the reference back out of the cache loader, so the slot layout is shared with
2302 // `refOfCacheKey` rather than read positionally at every loader.2427 // `refOfCacheKey` rather than read positionally at every loader.
2303 const refCacheKey = (ref: PullRequestRef) =>2428 const refCacheKey = (ref: CredentialRef) =>
2304 JSON.stringify([2429 JSON.stringify([
2305 refEpoch(ref),2430 refEpoch(ref),
2306 ref.projectId,2431 ref.projectId,
2307 ref.host?.toLowerCase() ?? null,2432 ref.host?.toLowerCase() ?? null,
2308 ref.repository.toLowerCase(),2433 ref.repository.toLowerCase(),
2309 ref.number,2434 ref.number,
2435 ref.expectedAccountId ?? null,
2436 ref[credentialNamespace] ?? null,
2310 ]);2437 ]);
2311 const refOfCacheKey = (key: string): PullRequestRef => {2438 const refOfCacheKey = (key: string): PullRequestRef => {
2312 const [, projectId, host, repository, number] = JSON.parse(key) as [2439 const [, projectId, host, repository, number, expectedAccountId, fingerprint] = JSON.parse(
2313 number,2440 key,
2314 string,2441 ) as [number, string, string | null, string, number, string | null, string | null];
2315 string | null,
2316 string,
2317 number,
2318 ];
2319 return {2442 return {
2320 projectId,2443 projectId,
2321 ...(host === null ? {} : { host }),2444 ...(host === null ? {} : { host }),
2445 ...(expectedAccountId === null ? {} : { expectedAccountId }),
2446 ...(fingerprint === null ? {} : { [credentialNamespace]: fingerprint }),
2322 repository,2447 repository,
2323 number,2448 number,
2324 } as PullRequestRef;2449 } as PullRequestRef;
...@@ -2365,7 +2490,7 @@ export const make = Effect.gen(function* () {...@@ -2365,7 +2490,7 @@ export const make = Effect.gen(function* () {
2365 };2490 };
23662491
2367 const persistedRead = Effect.fn("PullRequestService.persistedRead")(function* <A>(2492 const persistedRead = Effect.fn("PullRequestService.persistedRead")(function* <A>(
2368 input: PullRequestRef,2493 input: CredentialRef,
2369 operation: string,2494 operation: string,
2370 codec: Schema.Codec<A, string>,2495 codec: Schema.Codec<A, string>,
2371 read: Effect.Effect<A, PullRequestError>,2496 read: Effect.Effect<A, PullRequestError>,
...@@ -2379,6 +2504,8 @@ export const make = Effect.gen(function* () {...@@ -2379,6 +2504,8 @@ export const make = Effect.gen(function* () {
2379 project.project.id,2504 project.project.id,
2380 project.project.workspaceRoot,2505 project.project.workspaceRoot,
2381 String(input.number),2506 String(input.number),
2507 input.expectedAccountId ?? "",
2508 input[credentialNamespace] ?? "",
2382 ]2509 ]
2383 .map(encodeURIComponent)2510 .map(encodeURIComponent)
2384 .join(":");2511 .join(":");
...@@ -2409,6 +2536,7 @@ export const make = Effect.gen(function* () {...@@ -2409,6 +2536,7 @@ export const make = Effect.gen(function* () {
2409 const cached = persistedRead(input, "summary", summaryCodec, summaryUncached(input));2536 const cached = persistedRead(input, "summary", summaryCodec, summaryUncached(input));
2410 const held = lastGoodSummary.peek(key);2537 const held = lastGoodSummary.peek(key);
2411 return held !== undefined &&2538 return held !== undefined &&
2539 input.allowStale !== false &&
2412 (options?.recoverTransientFailure !== false || held.state === "merged")2540 (options?.recoverTransientFailure !== false || held.state === "merged")
2413 ? Effect.succeed(held)2541 ? Effect.succeed(held)
2414 : cached.pipe(2542 : cached.pipe(
...@@ -2566,18 +2694,17 @@ export const make = Effect.gen(function* () {...@@ -2566,18 +2694,17 @@ export const make = Effect.gen(function* () {
2566 // `serveHeld` returns immediately. Skip the write when that read is older2694 // `serveHeld` returns immediately. Skip the write when that read is older
2567 // than a later strict summary — display reuse would otherwise keep the2695 // than a later strict summary — display reuse would otherwise keep the
2568 // regression and never ask the host again.2696 // regression and never ask the host again.
2569 return lastGoodDetail.serveHeld(2697 const read = Cache.get(detailCache, key).pipe(
2570 key,2698 Effect.tap((value) => {
2571 Cache.get(detailCache, key).pipe(2699 const summary = summaryFromDetail(value, lastGoodSummary.peek(key));
2572 Effect.tap((value) => {2700 return shouldReplaceHeldSummary(key, summary)
2573 const summary = summaryFromDetail(value, lastGoodSummary.peek(key));2701 ? lastGoodSummary.record(key, summary)
2574 return shouldReplaceHeldSummary(key, summary)2702 : Effect.void;
2575 ? lastGoodSummary.record(key, summary)2703 }),
2576 : Effect.void;
2577 }),
2578 ),
2579 "revalidate",
2580 );2704 );
2705 return input.allowStale === false
2706 ? read.pipe(Effect.tap((value) => lastGoodDetail.record(key, value)))
2707 : lastGoodDetail.serveHeld(key, read, "revalidate");
2581 };2708 };
25822709
2583 const activityCache = yield* Cache.makeWith(2710 const activityCache = yield* Cache.makeWith(
...@@ -2758,11 +2885,33 @@ export const make = Effect.gen(function* () {...@@ -2758,11 +2885,33 @@ export const make = Effect.gen(function* () {
2758 }2885 }
2759 });2886 });
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
2761 return PullRequestService.of({2907 return PullRequestService.of({
2908 routing,
2909 routingIdentity,
2910 withRoutingCredential,
2762 list,2911 list,
2763 listStats,2912 listStats,
2764 summary,2913 summary: credentialCached(summary),
2765 stack,2914 stack: credentialCached(stack),
2766 subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe(2915 subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe(
2767 Effect.map((subscription) => Stream.fromSubscription(subscription)),2916 Effect.map((subscription) => Stream.fromSubscription(subscription)),
2768 ),2917 ),
...@@ -2770,8 +2919,8 @@ export const make = Effect.gen(function* () {...@@ -2770,8 +2919,8 @@ export const make = Effect.gen(function* () {
2770 Stream.filter((revision) => revision > 0),2919 Stream.filter((revision) => revision > 0),
2771 ),2920 ),
2772 refreshAfterTurn,2921 refreshAfterTurn,
2773 detail,2922 detail: credentialCached(detail),
2774 activity,2923 activity: credentialCached(activity),
2775 threadComments,2924 threadComments,
2776 diff,2925 diff,
2777 diffFileContents,2926 diffFileContents,
apps/server/src/sourceControl/GitHubCli.test.ts+110
...@@ -1,13 +1,18 @@...@@ -1,13 +1,18 @@
1import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest";1import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest";
2import * as Cache from "effect/Cache";
2import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
4import * as PlatformError from "effect/PlatformError";5import * as PlatformError from "effect/PlatformError";
6import * as Redacted from "effect/Redacted";
7import * as Schema from "effect/Schema";
5import { ChildProcessSpawner } from "effect/unstable/process";8import { ChildProcessSpawner } from "effect/unstable/process";
6import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts";9import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts";
710
8import * as VcsProcess from "../vcs/VcsProcess.ts";11import * as VcsProcess from "../vcs/VcsProcess.ts";
9import * as GitHubCli from "./GitHubCli.ts";12import * as GitHubCli from "./GitHubCli.ts";
1013
14const encodeGitHubCliError = Schema.encodeEffect(Schema.fromJsonString(GitHubCli.GitHubCliError));
15
11const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({16const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({
12 exitCode: ChildProcessSpawner.ExitCode(0),17 exitCode: ChildProcessSpawner.ExitCode(0),
13 stdout,18 stdout,
...@@ -31,6 +36,111 @@ afterEach(() => {...@@ -31,6 +36,111 @@ afterEach(() => {
31});36});
3237
33describe("GitHubCli.layer", () => {38describe("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
34 it("does not classify a missing cwd as an unavailable gh executable", () => {144 it("does not classify a missing cwd as an unavailable gh executable", () => {
35 const context = { command: "gh", cwd: "/repo" } as const;145 const context = { command: "gh", cwd: "/repo" } as const;
36 const missingCwd = new VcsProcessSpawnError({146 const missingCwd = new VcsProcessSpawnError({
apps/server/src/sourceControl/GitHubCli.ts+73-12
...@@ -4,6 +4,7 @@ import * as Effect from "effect/Effect";...@@ -4,6 +4,7 @@ import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";5import * as Option from "effect/Option";
6import * as PlatformError from "effect/PlatformError";6import * as PlatformError from "effect/PlatformError";
7import * as Redacted from "effect/Redacted";
7import * as Result from "effect/Result";8import * as Result from "effect/Result";
8import * as Schema from "effect/Schema";9import * as Schema from "effect/Schema";
910
...@@ -22,6 +23,40 @@ import {...@@ -22,6 +23,40 @@ import {
2223
23const DEFAULT_TIMEOUT_MS = 30_000;24const 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
25const gitHubCliFailureFields = {60const gitHubCliFailureFields = {
26 command: Schema.Literal("gh"),61 command: Schema.Literal("gh"),
27 cwd: Schema.String,62 cwd: Schema.String,
...@@ -237,6 +272,7 @@ export class GitHubCli extends Context.Service<...@@ -237,6 +272,7 @@ export class GitHubCli extends Context.Service<
237 readonly timeoutMs?: number;272 readonly timeoutMs?: number;
238 /** Piped to the child's stdin, for payloads that must never appear in argv. */273 /** Piped to the child's stdin, for payloads that must never appear in argv. */
239 readonly stdin?: string;274 readonly stdin?: string;
275 readonly env?: NodeJS.ProcessEnv;
240 readonly maxOutputBytes?: number;276 readonly maxOutputBytes?: number;
241 }) => Effect.Effect<VcsProcess.VcsProcessOutput, GitHubCliError>;277 }) => Effect.Effect<VcsProcess.VcsProcessOutput, GitHubCliError>;
242278
...@@ -342,18 +378,43 @@ function deriveRepositoryCloneUrlsFromCreateOutput(...@@ -342,18 +378,43 @@ function deriveRepositoryCloneUrlsFromCreateOutput(
342export const make = Effect.gen(function* () {378export const make = Effect.gen(function* () {
343 const process = yield* VcsProcess.VcsProcess;379 const process = yield* VcsProcess.VcsProcess;
344380
345 const execute: GitHubCli["Service"]["execute"] = (input) =>381 const execute: GitHubCli["Service"]["execute"] = Effect.fn("GitHubCli.execute")(
346 process382 function* (input) {
347 .run({383 const credential = yield* PinnedGitHubCredential;
348 operation: "GitHubCli.execute",384 if (credential !== null && !targetsVerifiedHost(input.args, credential.host)) {
349 command: "gh",385 return yield* new GitHubCliCommandError({
350 args: input.args,386 command: "gh",
351 cwd: input.cwd,387 cwd: input.cwd,
352 timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS,388 cause: new Error("The GitHub command does not target the verified credential's host."),
353 ...(input.stdin !== undefined ? { stdin: input.stdin } : {}),389 });
354 ...(input.maxOutputBytes !== undefined ? { maxOutputBytes: input.maxOutputBytes } : {}),390 }
355 })391 const token = credential === null ? undefined : Redacted.value(credential.token);
356 .pipe(Effect.mapError((error) => fromVcsError({ command: "gh", cwd: input.cwd }, error)));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
358 return GitHubCli.of({419 return GitHubCli.of({
359 execute,420 execute,
apps/server/src/ws.ts+89-42
...@@ -630,6 +630,7 @@ const makeWsRpcLayer = (...@@ -630,6 +630,7 @@ const makeWsRpcLayer = (
630 const sourceControlRepositories =630 const sourceControlRepositories =
631 yield* SourceControlRepositoryService.SourceControlRepositoryService;631 yield* SourceControlRepositoryService.SourceControlRepositoryService;
632 const pullRequests = yield* PullRequestService.PullRequestService;632 const pullRequests = yield* PullRequestService.PullRequestService;
633 const withPullRequestViewer = pullRequests.withRoutingCredential;
633 const pullRequestSync = yield* PullRequestSyncReactor.PullRequestSyncReactor;634 const pullRequestSync = yield* PullRequestSyncReactor.PullRequestSyncReactor;
634 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;635 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
635 const sessions = yield* SessionStore.SessionStore;636 const sessions = yield* SessionStore.SessionStore;
...@@ -2156,14 +2157,34 @@ const makeWsRpcLayer = (...@@ -2156,14 +2157,34 @@ const makeWsRpcLayer = (
2156 observeRpcEffect(WS_METHODS.pullRequestsListStats, pullRequests.listStats(input), {2157 observeRpcEffect(WS_METHODS.pullRequestsListStats, pullRequests.listStats(input), {
2157 "rpc.aggregate": "pull-requests",2158 "rpc.aggregate": "pull-requests",
2158 }),2159 }),
2159 [WS_METHODS.pullRequestsSummary]: (input) =>2160 [WS_METHODS.pullRequestsRoutingIdentity]: (input) =>
2160 observeRpcEffect(WS_METHODS.pullRequestsSummary, pullRequests.summary(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), {
2161 "rpc.aggregate": "pull-requests",2170 "rpc.aggregate": "pull-requests",
2162 }),2171 }),
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 ),
2163 [WS_METHODS.pullRequestsStack]: (input) =>2180 [WS_METHODS.pullRequestsStack]: (input) =>
2164 observeRpcEffect(WS_METHODS.pullRequestsStack, pullRequests.stack(input), {2181 observeRpcEffect(
2165 "rpc.aggregate": "pull-requests",2182 WS_METHODS.pullRequestsStack,
2166 }),2183 withPullRequestViewer(input, pullRequests.stack(input)),
2184 {
2185 "rpc.aggregate": "pull-requests",
2186 },
2187 ),
2167 [WS_METHODS.pullRequestsLinkedThreads]: (input) =>2188 [WS_METHODS.pullRequestsLinkedThreads]: (input) =>
2168 observeRpcEffect(2189 observeRpcEffect(
2169 WS_METHODS.pullRequestsLinkedThreads,2190 WS_METHODS.pullRequestsLinkedThreads,
...@@ -2179,17 +2200,25 @@ const makeWsRpcLayer = (...@@ -2179,17 +2200,25 @@ const makeWsRpcLayer = (
2179 { "rpc.aggregate": "pull-requests" },2200 { "rpc.aggregate": "pull-requests" },
2180 ),2201 ),
2181 [WS_METHODS.pullRequestsDetail]: (input) =>2202 [WS_METHODS.pullRequestsDetail]: (input) =>
2182 observeRpcEffect(WS_METHODS.pullRequestsDetail, pullRequests.detail(input), {2203 observeRpcEffect(
2183 "rpc.aggregate": "pull-requests",2204 WS_METHODS.pullRequestsDetail,
2184 }),2205 withPullRequestViewer(input, pullRequests.detail(input)),
2206 {
2207 "rpc.aggregate": "pull-requests",
2208 },
2209 ),
2185 [WS_METHODS.pullRequestsActivity]: (input) =>2210 [WS_METHODS.pullRequestsActivity]: (input) =>
2186 observeRpcEffect(WS_METHODS.pullRequestsActivity, pullRequests.activity(input), {2211 observeRpcEffect(
2187 "rpc.aggregate": "pull-requests",2212 WS_METHODS.pullRequestsActivity,
2188 }),2213 withPullRequestViewer(input, pullRequests.activity(input)),
2214 {
2215 "rpc.aggregate": "pull-requests",
2216 },
2217 ),
2189 [WS_METHODS.pullRequestsThreadComments]: (input) =>2218 [WS_METHODS.pullRequestsThreadComments]: (input) =>
2190 observeRpcEffect(2219 observeRpcEffect(
2191 WS_METHODS.pullRequestsThreadComments,2220 WS_METHODS.pullRequestsThreadComments,
2192 pullRequests.threadComments(input),2221 withPullRequestViewer(input, pullRequests.threadComments(input)),
2193 {2222 {
2194 "rpc.aggregate": "pull-requests",2223 "rpc.aggregate": "pull-requests",
2195 },2224 },
...@@ -2197,61 +2226,75 @@ const makeWsRpcLayer = (...@@ -2197,61 +2226,75 @@ const makeWsRpcLayer = (
2197 [WS_METHODS.pullRequestsDiffFileContents]: (input) =>2226 [WS_METHODS.pullRequestsDiffFileContents]: (input) =>
2198 observeRpcEffect(2227 observeRpcEffect(
2199 WS_METHODS.pullRequestsDiffFileContents,2228 WS_METHODS.pullRequestsDiffFileContents,
2200 pullRequests.diffFileContents(input),2229 withPullRequestViewer(input, pullRequests.diffFileContents(input)),
2201 { "rpc.aggregate": "pull-requests" },2230 { "rpc.aggregate": "pull-requests" },
2202 ),2231 ),
2203 [WS_METHODS.pullRequestsRunAction]: (input) =>2232 [WS_METHODS.pullRequestsRunAction]: (input) =>
2204 observeRpcEffect(2233 observeRpcEffect(
2205 WS_METHODS.pullRequestsRunAction,2234 WS_METHODS.pullRequestsRunAction,
2206 pullRequests2235 withPullRequestViewer(input, pullRequests.runAction(input)).pipe(
2207 .runAction(input)2236 Effect.tap(() =>
2208 .pipe(2237 resolvePullRequestSyncKey(input).pipe(
2209 Effect.tap(() =>2238 Effect.flatMap((key) =>
2210 resolvePullRequestSyncKey(input).pipe(2239 key === null ? Effect.void : pullRequestSync.requestSync(key),
2211 Effect.flatMap((key) =>
2212 key === null ? Effect.void : pullRequestSync.requestSync(key),
2213 ),
2214 ),2240 ),
2215 ),2241 ),
2216 ),2242 ),
2243 ),
2217 { "rpc.aggregate": "pull-requests" },2244 { "rpc.aggregate": "pull-requests" },
2218 ),2245 ),
2219 [WS_METHODS.pullRequestsUpdate]: (input) =>2246 [WS_METHODS.pullRequestsUpdate]: (input) =>
2220 observeRpcEffect(WS_METHODS.pullRequestsUpdate, pullRequests.update(input), {2247 observeRpcEffect(
2221 "rpc.aggregate": "pull-requests",2248 WS_METHODS.pullRequestsUpdate,
2222 }),2249 withPullRequestViewer(input, pullRequests.update(input)),
2250 {
2251 "rpc.aggregate": "pull-requests",
2252 },
2253 ),
2223 [WS_METHODS.pullRequestsComment]: (input) =>2254 [WS_METHODS.pullRequestsComment]: (input) =>
2224 observeRpcEffect(WS_METHODS.pullRequestsComment, pullRequests.comment(input), {2255 observeRpcEffect(
2225 "rpc.aggregate": "pull-requests",2256 WS_METHODS.pullRequestsComment,
2226 }),2257 withPullRequestViewer(input, pullRequests.comment(input)),
2258 {
2259 "rpc.aggregate": "pull-requests",
2260 },
2261 ),
2227 [WS_METHODS.pullRequestsUpdateComment]: (input) =>2262 [WS_METHODS.pullRequestsUpdateComment]: (input) =>
2228 observeRpcEffect(2263 observeRpcEffect(
2229 WS_METHODS.pullRequestsUpdateComment,2264 WS_METHODS.pullRequestsUpdateComment,
2230 pullRequests.updateComment(input),2265 withPullRequestViewer(input, pullRequests.updateComment(input)),
2231 {2266 {
2232 "rpc.aggregate": "pull-requests",2267 "rpc.aggregate": "pull-requests",
2233 },2268 },
2234 ),2269 ),
2235 [WS_METHODS.pullRequestsSubmitReview]: (input) =>2270 [WS_METHODS.pullRequestsSubmitReview]: (input) =>
2236 observeRpcEffect(WS_METHODS.pullRequestsSubmitReview, pullRequests.submitReview(input), {2271 observeRpcEffect(
2237 "rpc.aggregate": "pull-requests",2272 WS_METHODS.pullRequestsSubmitReview,
2238 }),2273 withPullRequestViewer(input, pullRequests.submitReview(input)),
2274 {
2275 "rpc.aggregate": "pull-requests",
2276 },
2277 ),
2239 [WS_METHODS.pullRequestsReplyToThread]: (input) =>2278 [WS_METHODS.pullRequestsReplyToThread]: (input) =>
2240 observeRpcEffect(2279 observeRpcEffect(
2241 WS_METHODS.pullRequestsReplyToThread,2280 WS_METHODS.pullRequestsReplyToThread,
2242 pullRequests.replyToThread(input),2281 withPullRequestViewer(input, pullRequests.replyToThread(input)),
2243 { "rpc.aggregate": "pull-requests" },2282 { "rpc.aggregate": "pull-requests" },
2244 ),2283 ),
2245 [WS_METHODS.pullRequestsSetThreadResolution]: (input) =>2284 [WS_METHODS.pullRequestsSetThreadResolution]: (input) =>
2246 observeRpcEffect(2285 observeRpcEffect(
2247 WS_METHODS.pullRequestsSetThreadResolution,2286 WS_METHODS.pullRequestsSetThreadResolution,
2248 pullRequests.setThreadResolution(input),2287 withPullRequestViewer(input, pullRequests.setThreadResolution(input)),
2249 { "rpc.aggregate": "pull-requests" },2288 { "rpc.aggregate": "pull-requests" },
2250 ),2289 ),
2251 [WS_METHODS.pullRequestsSetReaction]: (input) =>2290 [WS_METHODS.pullRequestsSetReaction]: (input) =>
2252 observeRpcEffect(WS_METHODS.pullRequestsSetReaction, pullRequests.setReaction(input), {2291 observeRpcEffect(
2253 "rpc.aggregate": "pull-requests",2292 WS_METHODS.pullRequestsSetReaction,
2254 }),2293 withPullRequestViewer(input, pullRequests.setReaction(input)),
2294 {
2295 "rpc.aggregate": "pull-requests",
2296 },
2297 ),
2255 [WS_METHODS.pullRequestsInvalidate]: (input) =>2298 [WS_METHODS.pullRequestsInvalidate]: (input) =>
2256 observeRpcEffect(2299 observeRpcEffect(
2257 WS_METHODS.pullRequestsInvalidate,2300 WS_METHODS.pullRequestsInvalidate,
...@@ -2279,25 +2322,29 @@ const makeWsRpcLayer = (...@@ -2279,25 +2322,29 @@ const makeWsRpcLayer = (
2279 [WS_METHODS.pullRequestsReviewerCandidates]: (input) =>2322 [WS_METHODS.pullRequestsReviewerCandidates]: (input) =>
2280 observeRpcEffect(2323 observeRpcEffect(
2281 WS_METHODS.pullRequestsReviewerCandidates,2324 WS_METHODS.pullRequestsReviewerCandidates,
2282 pullRequests.reviewerCandidates(input),2325 withPullRequestViewer(input, pullRequests.reviewerCandidates(input)),
2283 { "rpc.aggregate": "pull-requests" },2326 { "rpc.aggregate": "pull-requests" },
2284 ),2327 ),
2285 [WS_METHODS.pullRequestsRequestReviewers]: (input) =>2328 [WS_METHODS.pullRequestsRequestReviewers]: (input) =>
2286 observeRpcEffect(2329 observeRpcEffect(
2287 WS_METHODS.pullRequestsRequestReviewers,2330 WS_METHODS.pullRequestsRequestReviewers,
2288 pullRequests.requestReviewers(input),2331 withPullRequestViewer(input, pullRequests.requestReviewers(input)),
2289 { "rpc.aggregate": "pull-requests" },2332 { "rpc.aggregate": "pull-requests" },
2290 ),2333 ),
2291 [WS_METHODS.pullRequestsLabelCandidates]: (input) =>2334 [WS_METHODS.pullRequestsLabelCandidates]: (input) =>
2292 observeRpcEffect(2335 observeRpcEffect(
2293 WS_METHODS.pullRequestsLabelCandidates,2336 WS_METHODS.pullRequestsLabelCandidates,
2294 pullRequests.labelCandidates(input),2337 withPullRequestViewer(input, pullRequests.labelCandidates(input)),
2295 { "rpc.aggregate": "pull-requests" },2338 { "rpc.aggregate": "pull-requests" },
2296 ),2339 ),
2297 [WS_METHODS.pullRequestsSetLabels]: (input) =>2340 [WS_METHODS.pullRequestsSetLabels]: (input) =>
2298 observeRpcEffect(WS_METHODS.pullRequestsSetLabels, pullRequests.setLabels(input), {2341 observeRpcEffect(
2299 "rpc.aggregate": "pull-requests",2342 WS_METHODS.pullRequestsSetLabels,
2300 }),2343 withPullRequestViewer(input, pullRequests.setLabels(input)),
2344 {
2345 "rpc.aggregate": "pull-requests",
2346 },
2347 ),
2301 [WS_METHODS.sourceControlLookupRepository]: (input) =>2348 [WS_METHODS.sourceControlLookupRepository]: (input) =>
2302 observeRpcEffect(2349 observeRpcEffect(
2303 WS_METHODS.sourceControlLookupRepository,2350 WS_METHODS.sourceControlLookupRepository,
apps/web/src/components/settings/ConnectionsSettings.tsx+2
...@@ -68,6 +68,7 @@ import {...@@ -68,6 +68,7 @@ import {
68import { searchableSetting } from "./settingsSearch";68import { searchableSetting } from "./settingsSearch";
69import { EnvironmentIconPicker } from "./EnvironmentIconPicker";69import { EnvironmentIconPicker } from "./EnvironmentIconPicker";
70import { LoadBalancingSettings } from "./LoadBalancingSettings";70import { LoadBalancingSettings } from "./LoadBalancingSettings";
71import { GitHubRoutingSettings } from "./GitHubRoutingSettings";
71import { Input } from "../ui/input";72import { Input } from "../ui/input";
72import { CommandShortcut } from "../ui/command";73import { CommandShortcut } from "../ui/command";
73import {74import {
...@@ -3714,6 +3715,7 @@ export function ConnectionsSettings() {...@@ -3714,6 +3715,7 @@ export function ConnectionsSettings() {
3714 savedEnvironments={savedEnvironments}3715 savedEnvironments={savedEnvironments}
3715 />3716 />
3716 </SettingsSection>3717 </SettingsSection>
3718 <GitHubRoutingSettings environments={environments} />
3717 <LoadBalancingSettings environments={loadBalancingEnvironments} />3719 <LoadBalancingSettings environments={loadBalancingEnvironments} />
3718 </SettingsPageContainer>3720 </SettingsPageContainer>
3719 );3721 );
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 = [...@@ -688,6 +688,12 @@ export const SETTINGS_SEARCH_ITEMS = [
688 "automatic machine environment resources cpu memory capacity preference weight shared projects",688 "automatic machine environment resources cpu memory capacity preference weight shared projects",
689 ],689 ],
690 },690 },
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 },
691 {697 {
692 id: "archive",698 id: "archive",
693 title: "Archived threads",699 title: "Archived threads",
apps/web/src/connection/storage.test.ts+101-2
...@@ -1,11 +1,22 @@...@@ -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";
2import { ConnectionCatalogDocument } from "@t3tools/client-runtime/platform";6import { ConnectionCatalogDocument } from "@t3tools/client-runtime/platform";
3import { describe, expect, it } from "@effect/vitest";7import { describe, expect, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
9import * as Deferred from "effect/Deferred";
10import * as Option from "effect/Option";
5import * as Schema from "effect/Schema";11import * as Schema from "effect/Schema";
12import * as Stream from "effect/Stream";
6import { afterEach, vi } from "vite-plus/test";13import { afterEach, vi } from "vite-plus/test";
714
8import { makeCatalogBackend, makeCatalogStore } from "./storage";15import {
16 makeBrowserGitHubRoutingPermissions,
17 makeCatalogBackend,
18 makeCatalogStore,
19} from "./storage";
920
10const emptyCatalog = {21const emptyCatalog = {
11 schemaVersion: 1,22 schemaVersion: 1,
...@@ -16,6 +27,7 @@ const emptyCatalog = {...@@ -16,6 +27,7 @@ const emptyCatalog = {
16 disabledEnvironmentIds: [],27 disabledEnvironmentIds: [],
17} as const;28} as const;
18const decodeCatalog = Schema.decodeUnknownSync(Schema.fromJsonString(ConnectionCatalogDocument));29const decodeCatalog = Schema.decodeUnknownSync(Schema.fromJsonString(ConnectionCatalogDocument));
30const encodeCatalog = Schema.encodeSync(Schema.fromJsonString(ConnectionCatalogDocument));
1931
20afterEach(() => {32afterEach(() => {
21 vi.unstubAllGlobals();33 vi.unstubAllGlobals();
...@@ -76,3 +88,90 @@ describe("makeCatalogBackend", () => {...@@ -76,3 +88,90 @@ describe("makeCatalogBackend", () => {
76 }),88 }),
77 );89 );
78});90});
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 {...@@ -16,8 +16,13 @@ import {
16import { TokenStore } from "@t3tools/client-runtime/authorization";16import { TokenStore } from "@t3tools/client-runtime/authorization";
17import {17import {
18 ConnectionTransientError,18 ConnectionTransientError,
19 ConnectionBlockedError,
19 CredentialStore,20 CredentialStore,
20 ProfileStore,21 ProfileStore,
22 GitHubRoutingPermissions,
23 StoredGitHubRoutingPermission,
24 gitHubRoutingConnectionKey,
25 gitHubRoutingPermissionFor,
21} from "@t3tools/client-runtime/connection";26} from "@t3tools/client-runtime/connection";
22import {27import {
23 EnvironmentId,28 EnvironmentId,
...@@ -32,8 +37,10 @@ import * as Effect from "effect/Effect";...@@ -32,8 +37,10 @@ import * as Effect from "effect/Effect";
32import * as Layer from "effect/Layer";37import * as Layer from "effect/Layer";
33import * as Option from "effect/Option";38import * as Option from "effect/Option";
34import * as Ref from "effect/Ref";39import * as Ref from "effect/Ref";
40import * as Queue from "effect/Queue";
35import * as Schema from "effect/Schema";41import * as Schema from "effect/Schema";
36import * as Semaphore from "effect/Semaphore";42import * as Semaphore from "effect/Semaphore";
43import * as Stream from "effect/Stream";
37import { projectFaviconCache } from "../assets/projectFaviconCache";44import { projectFaviconCache } from "../assets/projectFaviconCache";
3845
39const DATABASE_NAME = "t3code:connection-runtime";46const DATABASE_NAME = "t3code:connection-runtime";
...@@ -368,12 +375,114 @@ export const makeCatalogStore = Effect.fn("web.connectionStorage.makeCatalogStor...@@ -368,12 +375,114 @@ export const makeCatalogStore = Effect.fn("web.connectionStorage.makeCatalogStor
368 return { read, update } satisfies CatalogStore;375 return { read, update } satisfies CatalogStore;
369});376});
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
371export const connectionStorageLayer = Layer.effectContext(479export const connectionStorageLayer = Layer.effectContext(
372 Effect.gen(function* () {480 Effect.gen(function* () {
373 const database = yield* Effect.acquireRelease(openDatabase(), (database) =>481 const database = yield* Effect.acquireRelease(openDatabase(), (database) =>
374 Effect.sync(() => database.close()),482 Effect.sync(() => database.close()),
375 );483 );
376 const catalog = yield* makeCatalogStore(makeCatalogBackend(database));484 const catalog = yield* makeCatalogStore(makeCatalogBackend(database));
485 const githubRoutingPermissions = makeBrowserGitHubRoutingPermissions();
377486
378 const targetStore = ConnectionTargetStore.of({487 const targetStore = ConnectionTargetStore.of({
379 list: catalog.read.pipe(488 list: catalog.read.pipe(
...@@ -670,6 +779,7 @@ export const connectionStorageLayer = Layer.effectContext(...@@ -670,6 +779,7 @@ export const connectionStorageLayer = Layer.effectContext(
670 });779 });
671780
672 return Context.make(ConnectionTargetStore, targetStore).pipe(781 return Context.make(ConnectionTargetStore, targetStore).pipe(
782 Context.add(GitHubRoutingPermissions, githubRoutingPermissions),
673 Context.add(ConnectionRegistrationStore, registrationStore),783 Context.add(ConnectionRegistrationStore, registrationStore),
674 Context.add(ProfileStore.ConnectionProfileStore, profileStore),784 Context.add(ProfileStore.ConnectionProfileStore, profileStore),
675 Context.add(CredentialStore.ConnectionCredentialStore, credentialStore),785 Context.add(CredentialStore.ConnectionCredentialStore, credentialStore),
docs/user/source-control.md+16
...@@ -99,6 +99,22 @@ GitLab calls these merge requests....@@ -99,6 +99,22 @@ GitLab calls these merge requests.
99GitHub, GitLab, and Azure DevOps support auto-merge while checks are outstanding. GitHub also99GitHub, GitLab, and Azure DevOps support auto-merge while checks are outstanding. GitHub also
100supports approving waiting fork workflows and opening a revert pull request for a merged change.100supports 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
102For Azure DevOps, use the host website to view diffs or change comments. Bitbucket does not support118For Azure DevOps, use the host website to view diffs or change comments. Bitbucket does not support
103reopening a declined pull request.119reopening 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";...@@ -3,6 +3,7 @@ export * as Connectivity from "./connectivity.ts";
3export * as CredentialStore from "./credentialStore.ts";3export * as CredentialStore from "./credentialStore.ts";
4export { type ConnectionDriverProgress, type EnvironmentConnectionLease } from "./driver.ts";4export { type ConnectionDriverProgress, type EnvironmentConnectionLease } from "./driver.ts";
5export * from "./errors.ts";5export * from "./errors.ts";
6export * from "./githubRoutingPermissions.ts";
6export * as Connection from "./layer.ts";7export * as Connection from "./layer.ts";
7export * from "./model.ts";8export * from "./model.ts";
8export {9export {
packages/client-runtime/src/connection/registry.test.ts+63-2
...@@ -47,6 +47,10 @@ import {...@@ -47,6 +47,10 @@ import {
47import * as Persistence from "../platform/persistence.ts";47import * as Persistence from "../platform/persistence.ts";
48import * as ConnectionProfileStore from "./profileStore.ts";48import * as ConnectionProfileStore from "./profileStore.ts";
49import * as EnvironmentRegistry from "./registry.ts";49import * as EnvironmentRegistry from "./registry.ts";
50import {
51 GitHubRoutingPermissions,
52 makeGitHubRoutingPermissions,
53} from "./githubRoutingPermissions.ts";
50import * as RpcSession from "../rpc/session.ts";54import * as RpcSession from "../rpc/session.ts";
51import * as EnvironmentSupervisor from "./supervisor.ts";55import * as EnvironmentSupervisor from "./supervisor.ts";
52import * as ConnectionWakeups from "./wakeups.ts";56import * as ConnectionWakeups from "./wakeups.ts";
...@@ -980,10 +984,17 @@ describe("EnvironmentRegistry", () => {...@@ -980,10 +984,17 @@ describe("EnvironmentRegistry", () => {
980 label: "Shadowed relay environment",984 label: "Shadowed relay environment",
981 });985 });
982 const harness = yield* makeHarness([shadowedTarget]);986 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
984 yield* Effect.gen(function* () {994 yield* Effect.gen(function* () {
985 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;995 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
986 yield* registry.registerPlatform(new PrimaryConnectionRegistration({ target: TARGET }));996 yield* registry.registerPlatform(new PrimaryConnectionRegistration({ target: TARGET }));
997 expect(yield* permissions.get(shadowedEntry)).toBe("off");
987998
988 expect(999 expect(
989 (yield* SubscriptionRef.get(registry.entries)).get(TARGET.environmentId)?.target,1000 (yield* SubscriptionRef.get(registry.entries)).get(TARGET.environmentId)?.target,
...@@ -996,7 +1007,11 @@ describe("EnvironmentRegistry", () => {...@@ -996,7 +1007,11 @@ describe("EnvironmentRegistry", () => {
996 (yield* SubscriptionRef.get(registry.entries)).get(TARGET.environmentId)?.target,1007 (yield* SubscriptionRef.get(registry.entries)).get(TARGET.environmentId)?.target,
997 ).toEqual(TARGET);1008 ).toEqual(TARGET);
998 expect((yield* Ref.get(harness.storedTargets)).has(TARGET.environmentId)).toBe(false);1009 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 );
1000 }),1015 }),
1001 );1016 );
10021017
...@@ -1083,6 +1098,20 @@ describe("EnvironmentRegistry", () => {...@@ -1083,6 +1098,20 @@ describe("EnvironmentRegistry", () => {
1083 it.effect("retains a healthy runtime when the platform repeats an identical registration", () =>1098 it.effect("retains a healthy runtime when the platform repeats an identical registration", () =>
1084 Effect.gen(function* () {1099 Effect.gen(function* () {
1085 const harness = yield* makeHarness([]);1100 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
1087 yield* Effect.gen(function* () {1116 yield* Effect.gen(function* () {
1088 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;1117 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
...@@ -1093,11 +1122,43 @@ describe("EnvironmentRegistry", () => {...@@ -1093,11 +1122,43 @@ describe("EnvironmentRegistry", () => {
1093 TARGET.environmentId,1122 TARGET.environmentId,
1094 (state) => state.phase === "connected",1123 (state) => state.phase === "connected",
1095 );1124 );
1125 yield* permissions.set(entry, "read-write");
10961126
1097 yield* registry.registerPlatform(registration);1127 yield* registry.registerPlatform(registration);
10981128
1099 expect(yield* Ref.get(harness.sessions)).toHaveLength(1);1129 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 );
1101 }),1162 }),
1102 );1163 );
11031164
packages/client-runtime/src/connection/registry.ts+57-5
...@@ -34,6 +34,10 @@ import * as Persistence from "../platform/persistence.ts";...@@ -34,6 +34,10 @@ import * as Persistence from "../platform/persistence.ts";
34import * as EnvironmentSupervisor from "./supervisor.ts";34import * as EnvironmentSupervisor from "./supervisor.ts";
35import * as ConnectionDriver from "./driver.ts";35import * as ConnectionDriver from "./driver.ts";
36import * as ConnectionWakeups from "./wakeups.ts";36import * as ConnectionWakeups from "./wakeups.ts";
37import {
38 GitHubRoutingPermissions,
39 gitHubRoutingConnectionKey,
40} from "./githubRoutingPermissions.ts";
3741
38const isSshConnectionProfile = Schema.is(SshConnectionProfile);42const isSshConnectionProfile = Schema.is(SshConnectionProfile);
3943
...@@ -146,6 +150,7 @@ export const make = Effect.gen(function* () {...@@ -146,6 +150,7 @@ export const make = Effect.gen(function* () {
146 const ownedDataCleanup = yield* Persistence.EnvironmentOwnedDataCleanup;150 const ownedDataCleanup = yield* Persistence.EnvironmentOwnedDataCleanup;
147 const profiles = yield* ConnectionProfileStore.ConnectionProfileStore;151 const profiles = yield* ConnectionProfileStore.ConnectionProfileStore;
148 const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore;152 const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore;
153 const githubRoutingPermissions = yield* GitHubRoutingPermissions;
149 const connectivity = yield* Connectivity.Connectivity;154 const connectivity = yield* Connectivity.Connectivity;
150 const driver = yield* ConnectionDriver.ConnectionDriver;155 const driver = yield* ConnectionDriver.ConnectionDriver;
151 const wakeups = yield* ConnectionWakeups.ConnectionWakeups;156 const wakeups = yield* ConnectionWakeups.ConnectionWakeups;
...@@ -418,11 +423,24 @@ export const make = Effect.gen(function* () {...@@ -418,11 +423,24 @@ export const make = Effect.gen(function* () {
418 if ((yield* Ref.get(platformEnvironmentIds)).has(environmentId)) {423 if ((yield* Ref.get(platformEnvironmentIds)).has(environmentId)) {
419 return;424 return;
420 }425 }
421 // Editing a saved environment re-registers it; that must not switch a426 // Editing a saved environment must preserve its disabled state.
422 // disabled one back on.
423 const previous = (yield* SubscriptionRef.get(entries)).get(environmentId);427 const previous = (yield* SubscriptionRef.get(entries)).get(environmentId);
424 const entry: ConnectionCatalogEntry =428 const entry: ConnectionCatalogEntry =
425 previous === undefined ? registered : { ...registered, enabled: previous.enabled };429 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 }
426 yield* registrations.register(registration);444 yield* registrations.register(registration);
427 yield* Ref.update(persistedTargetsByEnvironment, (current) => {445 yield* Ref.update(persistedTargetsByEnvironment, (current) => {
428 const next = new Map(current);446 const next = new Map(current);
...@@ -441,6 +459,29 @@ export const make = Effect.gen(function* () {...@@ -441,6 +459,29 @@ export const make = Effect.gen(function* () {
441 yield* withLeaseLock(459 yield* withLeaseLock(
442 target.environmentId,460 target.environmentId,
443 Effect.gen(function* () {461 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 }
444 yield* Ref.update(platformEnvironmentIds, (current) => {485 yield* Ref.update(platformEnvironmentIds, (current) => {
445 const next = new Set(current);486 const next = new Set(current);
446 next.add(target.environmentId);487 next.add(target.environmentId);
...@@ -462,9 +503,6 @@ export const make = Effect.gen(function* () {...@@ -462,9 +503,6 @@ export const make = Effect.gen(function* () {
462 );503 );
463 }504 }
464505
465 const persistedTarget = (yield* Ref.get(persistedTargetsByEnvironment)).get(
466 target.environmentId,
467 );
468 if (persistedTarget !== undefined) {506 if (persistedTarget !== undefined) {
469 yield* registrations.remove(persistedTarget).pipe(507 yield* registrations.remove(persistedTarget).pipe(
470 Effect.tap(() =>508 Effect.tap(() =>
...@@ -502,6 +540,19 @@ export const make = Effect.gen(function* () {...@@ -502,6 +540,19 @@ export const make = Effect.gen(function* () {
502 environmentId,540 environmentId,
503 Effect.gen(function* () {541 Effect.gen(function* () {
504 const entry = (yield* SubscriptionRef.get(entries)).get(environmentId);542 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;
505 yield* Ref.update(platformEnvironmentIds, (current) => {556 yield* Ref.update(platformEnvironmentIds, (current) => {
506 const next = new Set(current);557 const next = new Set(current);
507 next.delete(environmentId);558 next.delete(environmentId);
...@@ -582,6 +633,7 @@ export const make = Effect.gen(function* () {...@@ -582,6 +633,7 @@ export const make = Effect.gen(function* () {
582 ? yield* profiles.get(target.connectionId)633 ? yield* profiles.get(target.connectionId)
583 : Option.none();634 : Option.none();
584635
636 yield* githubRoutingPermissions.forget(environmentId);
585 yield* registrations.remove(target);637 yield* registrations.remove(target);
586 yield* Ref.update(persistedTargetsByEnvironment, (current) => {638 yield* Ref.update(persistedTargetsByEnvironment, (current) => {
587 const next = new Map(current);639 const next = new Map(current);
packages/client-runtime/src/connection/resolver.test.ts+106-1
...@@ -28,6 +28,11 @@ import {...@@ -28,6 +28,11 @@ import {
28 type ConnectionTarget,28 type ConnectionTarget,
29} from "./model.ts";29} from "./model.ts";
30import * as ConnectionProfileStore from "./profileStore.ts";30import * as ConnectionProfileStore from "./profileStore.ts";
31import {
32 GitHubRoutingPermissions,
33 gitHubRoutingConnectionKey,
34 makeGitHubRoutingPermissions,
35} from "./githubRoutingPermissions.ts";
3136
32const ENVIRONMENT_ID = EnvironmentId.make("environment-1");37const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
33const ENDPOINT = {38const ENDPOINT = {
...@@ -64,6 +69,7 @@ function collectingTracer(spans: Array<string>): Tracer.Tracer {...@@ -64,6 +69,7 @@ function collectingTracer(spans: Array<string>): Tracer.Tracer {
6469
65const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((options?: {70const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((options?: {
66 readonly profiles?: ReadonlyArray<ConnectionProfile>;71 readonly profiles?: ReadonlyArray<ConnectionProfile>;
72 readonly profileStore?: ConnectionProfileStore.ConnectionProfileStore["Service"];
67 readonly credentials?: ReadonlyArray<readonly [string, ConnectionCredential]>;73 readonly credentials?: ReadonlyArray<readonly [string, ConnectionCredential]>;
68 readonly authorizeBearer?: RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["authorizeBearer"];74 readonly authorizeBearer?: RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["authorizeBearer"];
69 readonly authorizeDpop?: RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["authorizeDpop"];75 readonly authorizeDpop?: RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["authorizeDpop"];
...@@ -134,7 +140,10 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o...@@ -134,7 +140,10 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o
134 });140 });
135141
136 const dependencies = Layer.mergeAll(142 const dependencies = Layer.mergeAll(
137 Layer.succeed(ConnectionProfileStore.ConnectionProfileStore, profileStore),143 Layer.succeed(
144 ConnectionProfileStore.ConnectionProfileStore,
145 options?.profileStore ?? profileStore,
146 ),
138 Layer.succeed(ConnectionCredentialStore.ConnectionCredentialStore, credentialStore),147 Layer.succeed(ConnectionCredentialStore.ConnectionCredentialStore, credentialStore),
139 Layer.succeed(148 Layer.succeed(
140 ClientCapabilities.PrimaryEnvironmentAuth,149 ClientCapabilities.PrimaryEnvironmentAuth,
...@@ -376,6 +385,102 @@ describe("ConnectionResolver", () => {...@@ -376,6 +385,102 @@ describe("ConnectionResolver", () => {
376 }),385 }),
377 );386 );
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
379 it.effect("preserves relay authorization failure classification and trace details", () =>484 it.effect("preserves relay authorization failure classification and trace details", () =>
380 Effect.gen(function* () {485 Effect.gen(function* () {
381 const target = new RelayConnectionTarget({486 const target = new RelayConnectionTarget({
packages/client-runtime/src/connection/resolver.ts+18-8
...@@ -17,6 +17,10 @@ import {...@@ -17,6 +17,10 @@ import {
17} from "./catalog.ts";17} from "./catalog.ts";
18import * as ConnectionCredentialStore from "./credentialStore.ts";18import * as ConnectionCredentialStore from "./credentialStore.ts";
19import { credentialMissingError, environmentMismatchError, profileMissingError } from "./errors.ts";19import { credentialMissingError, environmentMismatchError, profileMissingError } from "./errors.ts";
20import {
21 GitHubRoutingPermissions,
22 gitHubRoutingConnectionKey,
23} from "./githubRoutingPermissions.ts";
20import type {24import type {
21 BearerConnectionTarget,25 BearerConnectionTarget,
22 ConnectionTarget,26 ConnectionTarget,
...@@ -192,14 +196,20 @@ const makeSshBroker = Effect.fn("clientRuntime.connection.broker.makeSsh")(funct...@@ -192,14 +196,20 @@ const makeSshBroker = Effect.fn("clientRuntime.connection.broker.makeSsh")(funct
192 expectedEnvironmentId: target.environmentId,196 expectedEnvironmentId: target.environmentId,
193 target: profile.target,197 target: profile.target,
194 });198 });
195 yield* profiles.put(199 const preparedProfile = new SshConnectionProfile({
196 new SshConnectionProfile({200 connectionId: profile.connectionId,
197 connectionId: profile.connectionId,201 environmentId: profile.environmentId,
198 environmentId: profile.environmentId,202 label: profile.label,
199 label: profile.label,203 target: prepared.bootstrap.target,
200 target: prepared.bootstrap.target,204 });
201 }),205 if (
202 );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);
203 const authorized = yield* remote.authorizeBearer({213 const authorized = yield* remote.authorizeBearer({
204 expectedEnvironmentId: target.environmentId,214 expectedEnvironmentId: target.environmentId,
205 httpBaseUrl: prepared.bootstrap.httpBaseUrl,215 httpBaseUrl: prepared.bootstrap.httpBaseUrl,
packages/client-runtime/src/platform/storageDocument.test.ts+132
...@@ -1,6 +1,8 @@...@@ -1,6 +1,8 @@
1import { EnvironmentId } from "@t3tools/contracts";1import { EnvironmentId } from "@t3tools/contracts";
2import { describe, expect, it } from "@effect/vitest";2import { describe, expect, it } from "@effect/vitest";
3import * as Schema from "effect/Schema";3import * as Schema from "effect/Schema";
4import * as Effect from "effect/Effect";
5import * as Option from "effect/Option";
46
5import * as TokenStore from "../authorization/tokenStore.ts";7import * as TokenStore from "../authorization/tokenStore.ts";
6import {8import {
...@@ -13,9 +15,15 @@ import {...@@ -13,9 +15,15 @@ import {
13} from "../connection/catalog.ts";15} from "../connection/catalog.ts";
14import {16import {
15 BearerConnectionTarget,17 BearerConnectionTarget,
18 ConnectionTransientError,
19 PrimaryConnectionTarget,
16 RelayConnectionTarget,20 RelayConnectionTarget,
17 SshConnectionTarget,21 SshConnectionTarget,
18} from "../connection/model.ts";22} from "../connection/model.ts";
23import {
24 GitHubRoutingPermissions,
25 makeGitHubRoutingPermissions,
26} from "../connection/githubRoutingPermissions.ts";
19import {27import {
20 ConnectionCatalogDocument,28 ConnectionCatalogDocument,
21 EMPTY_CONNECTION_CATALOG_DOCUMENT,29 EMPTY_CONNECTION_CATALOG_DOCUMENT,
...@@ -25,6 +33,8 @@ import {...@@ -25,6 +33,8 @@ import {
25 setConnectionEnabledInCatalog,33 setConnectionEnabledInCatalog,
26} from "./storageDocument.ts";34} from "./storageDocument.ts";
2735
36const decodeConnectionCatalogDocument = Schema.decodeUnknownEffect(ConnectionCatalogDocument);
37
28const ENVIRONMENT_ID = EnvironmentId.make("environment-1");38const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
29const decodeCatalogDocument = Schema.decodeUnknownSync(ConnectionCatalogDocument);39const decodeCatalogDocument = Schema.decodeUnknownSync(ConnectionCatalogDocument);
3040
...@@ -61,6 +71,128 @@ const REMOTE_TOKEN = new TokenStore.RemoteDpopAccessToken({...@@ -61,6 +71,128 @@ const REMOTE_TOKEN = new TokenStore.RemoteDpopAccessToken({
61});71});
6272
63describe("ConnectionCatalogDocument", () => {73describe("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
64 it.each([196 it.each([
65 { name: "legacy", accountId: undefined },197 { name: "legacy", accountId: undefined },
66 { name: "account-bound", accountId: "account-1" },198 { name: "account-bound", accountId: "account-1" },
packages/client-runtime/src/platform/storageDocument.ts+11-1
...@@ -9,6 +9,7 @@ import {...@@ -9,6 +9,7 @@ import {
9} from "../connection/catalog.ts";9} from "../connection/catalog.ts";
10import { type ConnectionTarget, PersistedConnectionTarget } from "../connection/model.ts";10import { type ConnectionTarget, PersistedConnectionTarget } from "../connection/model.ts";
11import * as TokenStore from "../authorization/tokenStore.ts";11import * as TokenStore from "../authorization/tokenStore.ts";
12import { StoredGitHubRoutingPermission } from "../connection/githubRoutingPermissions.ts";
1213
13export const StoredConnectionCredential = Schema.Struct({14export const StoredConnectionCredential = Schema.Struct({
14 connectionId: Schema.String,15 connectionId: Schema.String,
...@@ -22,6 +23,7 @@ export const ConnectionCatalogDocument = Schema.Struct({...@@ -22,6 +23,7 @@ export const ConnectionCatalogDocument = Schema.Struct({
22 profiles: Schema.Array(ConnectionProfile),23 profiles: Schema.Array(ConnectionProfile),
23 credentials: Schema.Array(StoredConnectionCredential),24 credentials: Schema.Array(StoredConnectionCredential),
24 remoteDpopTokens: Schema.Array(TokenStore.RemoteDpopAccessToken),25 remoteDpopTokens: Schema.Array(TokenStore.RemoteDpopAccessToken),
26 githubRoutingPermissions: Schema.optionalKey(Schema.Array(StoredGitHubRoutingPermission)),
25 // Saved environments the user switched off. They stay registered with their27 // Saved environments the user switched off. They stay registered with their
26 // credentials and cache but never connect until switched back on. Older28 // credentials and cache but never connect until switched back on. Older
27 // documents predate the key, so decoding defaults it to none.29 // documents predate the key, so decoding defaults it to none.
...@@ -153,7 +155,15 @@ export function removeConnectionFromCatalog(...@@ -153,7 +155,15 @@ export function removeConnectionFromCatalog(
153 document: ConnectionCatalogDocument,155 document: ConnectionCatalogDocument,
154 target: ConnectionTarget,156 target: ConnectionTarget,
155): ConnectionCatalogDocument {157): 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 };
157}167}
158168
159/** Flips the disabled flag for a saved environment; unknown ids are ignored. */169/** 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";...@@ -9,6 +9,11 @@ import * as EnvironmentRegistry from "../connection/registry.ts";
9import type { ConnectionCatalogEntry } from "../connection/catalog.ts";9import type { ConnectionCatalogEntry } from "../connection/catalog.ts";
10import { AVAILABLE_CONNECTION_STATE } from "../connection/model.ts";10import { AVAILABLE_CONNECTION_STATE } from "../connection/model.ts";
11import * as EnvironmentSupervisor from "../connection/supervisor.ts";11import * as EnvironmentSupervisor from "../connection/supervisor.ts";
12import {
13 GitHubRoutingPermissions,
14 type GitHubRoutingPermission,
15 type StoredGitHubRoutingPermission,
16} from "../connection/githubRoutingPermissions.ts";
12import {17import {
13 createAtomCommandScheduler,18 createAtomCommandScheduler,
14 createRuntimeCommand,19 createRuntimeCommand,
...@@ -65,6 +70,32 @@ export function createEnvironmentCatalogAtoms<R, E>(...@@ -65,6 +70,32 @@ export function createEnvironmentCatalogAtoms<R, E>(
65 Option.getOrElse(AsyncResult.value(get(catalogAtom)), () => EMPTY_ENVIRONMENT_CATALOG_STATE),70 Option.getOrElse(AsyncResult.value(get(catalogAtom)), () => EMPTY_ENVIRONMENT_CATALOG_STATE),
66 ).pipe(Atom.withLabel("environment-catalog-value"));71 ).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
68 const networkStatusAtom = runtime.atom(99 const networkStatusAtom = runtime.atom(
69 Stream.unwrap(100 Stream.unwrap(
70 EnvironmentRegistry.EnvironmentRegistry.pipe(101 EnvironmentRegistry.EnvironmentRegistry.pipe(
...@@ -143,6 +174,8 @@ export function createEnvironmentCatalogAtoms<R, E>(...@@ -143,6 +174,8 @@ export function createEnvironmentCatalogAtoms<R, E>(
143 return {174 return {
144 catalogAtom,175 catalogAtom,
145 catalogValueAtom,176 catalogValueAtom,
177 githubRoutingPermissionsValueAtom,
178 setGitHubRoutingPermission,
146 networkStatusAtom,179 networkStatusAtom,
147 networkStatusValueAtom,180 networkStatusValueAtom,
148 stateAtom,181 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 @@...@@ -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";
2import { expect, it } from "@effect/vitest";8import { expect, it } from "@effect/vitest";
3import * as Data from "effect/Data";9import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
...@@ -12,11 +18,15 @@ import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";...@@ -12,11 +18,15 @@ import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
1218
13import {19import {
14 AVAILABLE_CONNECTION_STATE,20 AVAILABLE_CONNECTION_STATE,
21 ConnectionTransientError,
15 PrimaryConnectionTarget,22 PrimaryConnectionTarget,
23 SshConnectionTarget,
16 type PreparedConnection,24 type PreparedConnection,
17 type SupervisorConnectionState,25 type SupervisorConnectionState,
18} from "../connection/model.ts";26} from "../connection/model.ts";
19import * as EnvironmentRegistry from "../connection/registry.ts";27import * as EnvironmentRegistry from "../connection/registry.ts";
28import { SshConnectionProfile, type ConnectionCatalogEntry } from "../connection/catalog.ts";
29import { ConnectionProfileStore } from "../connection/profileStore.ts";
20import * as EnvironmentSupervisor from "../connection/supervisor.ts";30import * as EnvironmentSupervisor from "../connection/supervisor.ts";
21import type { WsRpcProtocolClient } from "../rpc/protocol.ts";31import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
22import type { RpcSession } from "../rpc/session.ts";32import type { RpcSession } from "../rpc/session.ts";
...@@ -26,9 +36,200 @@ import {...@@ -26,9 +36,200 @@ import {
26} from "./pullRequests.ts";36} from "./pullRequests.ts";
27import { PullRequestDiffLoader } from "./pullRequestDiffHttp.ts";37import { PullRequestDiffLoader } from "./pullRequestDiffHttp.ts";
28import { executeAtomQuery } from "./runtime.ts";38import { 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
30class MutationRefused extends Data.TaggedError("MutationRefused") {}49class 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
32const TARGET = new PrimaryConnectionTarget({233const TARGET = new PrimaryConnectionTarget({
33 environmentId: EnvironmentId.make("environment-1"),234 environmentId: EnvironmentId.make("environment-1"),
34 label: "Test environment",235 label: "Test environment",
...@@ -38,7 +239,11 @@ const TARGET = new PrimaryConnectionTarget({...@@ -38,7 +239,11 @@ const TARGET = new PrimaryConnectionTarget({
38239
39function session(client: WsRpcProtocolClient): RpcSession {240function session(client: WsRpcProtocolClient): RpcSession {
40 return {241 return {
41 client,242 client: {
243 ...client,
244 [WS_METHODS.pullRequestsInvalidate]:
245 client[WS_METHODS.pullRequestsInvalidate] ?? (() => Effect.void),
246 },
42 initialConfig: Effect.never,247 initialConfig: Effect.never,
43 subscribeServerConfig: (input) => client.subscribeServerConfig(input),248 subscribeServerConfig: (input) => client.subscribeServerConfig(input),
44 ready: Effect.void,249 ready: Effect.void,
...@@ -47,7 +252,18 @@ function session(client: WsRpcProtocolClient): RpcSession {...@@ -47,7 +252,18 @@ function session(client: WsRpcProtocolClient): RpcSession {
47 };252 };
48}253}
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;
51 const connectionState: SupervisorConnectionState = {267 const connectionState: SupervisorConnectionState = {
52 ...AVAILABLE_CONNECTION_STATE,268 ...AVAILABLE_CONNECTION_STATE,
53 desired: true,269 desired: true,
...@@ -57,7 +273,7 @@ const makeTestRuntime = Effect.fn("makeTestRuntime")(function* (client: WsRpcPro...@@ -57,7 +273,7 @@ const makeTestRuntime = Effect.fn("makeTestRuntime")(function* (client: WsRpcPro
57 generation: 1,273 generation: 1,
58 };274 };
59 const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({275 const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
60 target: TARGET,276 target: originTarget,
61 state: yield* SubscriptionRef.make(connectionState),277 state: yield* SubscriptionRef.make(connectionState),
62 session: yield* SubscriptionRef.make(Option.some(session(client))),278 session: yield* SubscriptionRef.make(Option.some(session(client))),
63 prepared: yield* SubscriptionRef.make(Option.none<PreparedConnection>()),279 prepared: yield* SubscriptionRef.make(Option.none<PreparedConnection>()),
...@@ -65,9 +281,40 @@ const makeTestRuntime = Effect.fn("makeTestRuntime")(function* (client: WsRpcPro...@@ -65,9 +281,40 @@ const makeTestRuntime = Effect.fn("makeTestRuntime")(function* (client: WsRpcPro
65 disconnect: Effect.void,281 disconnect: Effect.void,
66 retryNow: Effect.void,282 retryNow: Effect.void,
67 } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]);283 } 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 };
68 const environmentRegistry = EnvironmentRegistry.EnvironmentRegistry.of({295 const environmentRegistry = EnvironmentRegistry.EnvironmentRegistry.of({
69 run: (_environmentId, effect) =>296 entries: yield* SubscriptionRef.make<ReadonlyMap<EnvironmentId, ConnectionCatalogEntry>>(
70 Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor),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 ),
71 runStream: (_environmentId, stream) =>318 runStream: (_environmentId, stream) =>
72 Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor),319 Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
73 followStream: (_environmentId, stream) =>320 followStream: (_environmentId, stream) =>
...@@ -86,9 +333,427 @@ const makeTestRuntime = Effect.fn("makeTestRuntime")(function* (client: WsRpcPro...@@ -86,9 +333,427 @@ const makeTestRuntime = Effect.fn("makeTestRuntime")(function* (client: WsRpcPro
86 const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) =>333 const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) =>
87 Effect.sync(() => registry.dispose()),334 Effect.sync(() => registry.dispose()),
88 );335 );
89 return { runtime, atoms, registry };336 return { runtime, atoms, registry, environmentRegistry, supervisor };
90});337});
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
92it.effect("keeps concurrent diff file reads on different hosts separate", () =>757it.effect("keeps concurrent diff file reads on different hosts separate", () =>
93 Effect.scoped(758 Effect.scoped(
94 Effect.gen(function* () {759 Effect.gen(function* () {
packages/client-runtime/src/state/pullRequests.ts+23
...@@ -21,6 +21,7 @@ import {...@@ -21,6 +21,7 @@ import {
21 createEnvironmentRpcSubscriptionAtomFamily,21 createEnvironmentRpcSubscriptionAtomFamily,
22 createEnvironmentQueryAtomFamily,22 createEnvironmentQueryAtomFamily,
23} from "./runtime.ts";23} from "./runtime.ts";
24import { createPullRequestRouter } from "./pullRequestRouting.ts";
24import { PullRequestDiffLoader } from "./pullRequestDiffHttp.ts";25import { PullRequestDiffLoader } from "./pullRequestDiffHttp.ts";
25import type { EnvironmentRegistry } from "../connection/registry.ts";26import type { EnvironmentRegistry } from "../connection/registry.ts";
26import { EnvironmentSupervisor } from "../connection/supervisor.ts";27import { EnvironmentSupervisor } from "../connection/supervisor.ts";
...@@ -99,9 +100,11 @@ export function createLinkedPullRequestSummaryAtomFamily<R, E>(...@@ -99,9 +100,11 @@ export function createLinkedPullRequestSummaryAtomFamily<R, E>(
99 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,100 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
100 refreshes = createPullRequestRefreshAtomFamily(runtime),101 refreshes = createPullRequestRefreshAtomFamily(runtime),
101) {102) {
103 const routedRequest = createPullRequestRouter();
102 return createEnvironmentRpcQueryAtomFamily(runtime, {104 return createEnvironmentRpcQueryAtomFamily(runtime, {
103 label: "environment-data:pull-requests:linked-summary",105 label: "environment-data:pull-requests:linked-summary",
104 tag: WS_METHODS.pullRequestsSummary,106 tag: WS_METHODS.pullRequestsSummary,
107 execute: (input) => routedRequest(WS_METHODS.pullRequestsSummary, input),
105 staleTimeMs: 60_000,108 staleTimeMs: 60_000,
106 refreshIntervalMs: 60_000,109 refreshIntervalMs: 60_000,
107 idleTtlMs: LINKED_PULL_REQUEST_IDLE_TTL_MS,110 idleTtlMs: LINKED_PULL_REQUEST_IDLE_TTL_MS,
...@@ -114,9 +117,11 @@ export function createPullRequestStackAtomFamily<R, E>(...@@ -114,9 +117,11 @@ export function createPullRequestStackAtomFamily<R, E>(
114 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,117 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
115 refreshes = createPullRequestRefreshAtomFamily(runtime),118 refreshes = createPullRequestRefreshAtomFamily(runtime),
116) {119) {
120 const routedRequest = createPullRequestRouter();
117 return createEnvironmentRpcQueryAtomFamily(runtime, {121 return createEnvironmentRpcQueryAtomFamily(runtime, {
118 label: "environment-data:pull-requests:stack",122 label: "environment-data:pull-requests:stack",
119 tag: WS_METHODS.pullRequestsStack,123 tag: WS_METHODS.pullRequestsStack,
124 execute: (input) => routedRequest(WS_METHODS.pullRequestsStack, input),
120 staleTimeMs: 60_000,125 staleTimeMs: 60_000,
121 idleTtlMs: LINKED_PULL_REQUEST_IDLE_TTL_MS,126 idleTtlMs: LINKED_PULL_REQUEST_IDLE_TTL_MS,
122 refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }),127 refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }),
...@@ -148,6 +153,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(...@@ -148,6 +153,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(
148) {153) {
149 const refreshes = createPullRequestRefreshAtomFamily(runtime);154 const refreshes = createPullRequestRefreshAtomFamily(runtime);
150 const commandScheduler = createAtomCommandScheduler();155 const commandScheduler = createAtomCommandScheduler();
156 const routedRequest = createPullRequestRouter();
151 const serialPerEnvironment = {157 const serialPerEnvironment = {
152 mode: "serial",158 mode: "serial",
153 key: ({ environmentId }: { readonly environmentId: string }) => environmentId,159 key: ({ environmentId }: { readonly environmentId: string }) => environmentId,
...@@ -156,6 +162,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(...@@ -156,6 +162,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(
156 createEnvironmentRpcQueryAtomFamily(runtime, {162 createEnvironmentRpcQueryAtomFamily(runtime, {
157 label: "environment-data:pull-requests:activity",163 label: "environment-data:pull-requests:activity",
158 tag: WS_METHODS.pullRequestsActivity,164 tag: WS_METHODS.pullRequestsActivity,
165 execute: (input) => routedRequest(WS_METHODS.pullRequestsActivity, input),
159 staleTimeMs: 60_000,166 staleTimeMs: 60_000,
160 refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }),167 refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }),
161 }),168 }),
...@@ -164,6 +171,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(...@@ -164,6 +171,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(
164 createEnvironmentRpcQueryAtomFamily(runtime, {171 createEnvironmentRpcQueryAtomFamily(runtime, {
165 label: "environment-data:pull-requests:detail",172 label: "environment-data:pull-requests:detail",
166 tag: WS_METHODS.pullRequestsDetail,173 tag: WS_METHODS.pullRequestsDetail,
174 execute: (input) => routedRequest(WS_METHODS.pullRequestsDetail, input),
167 staleTimeMs: 60_000,175 staleTimeMs: 60_000,
168 refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }),176 refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }),
169 }),177 }),
...@@ -172,6 +180,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(...@@ -172,6 +180,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(
172 createEnvironmentRpcQueryAtomFamily(runtime, {180 createEnvironmentRpcQueryAtomFamily(runtime, {
173 label: "environment-data:pull-requests:label-candidates",181 label: "environment-data:pull-requests:label-candidates",
174 tag: WS_METHODS.pullRequestsLabelCandidates,182 tag: WS_METHODS.pullRequestsLabelCandidates,
183 execute: (input) => routedRequest(WS_METHODS.pullRequestsLabelCandidates, input),
175 staleTimeMs: 60_000,184 staleTimeMs: 60_000,
176 }),185 }),
177 );186 );
...@@ -179,6 +188,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(...@@ -179,6 +188,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(
179 createEnvironmentRpcQueryAtomFamily(runtime, {188 createEnvironmentRpcQueryAtomFamily(runtime, {
180 label: "environment-data:pull-requests:reviewer-candidates",189 label: "environment-data:pull-requests:reviewer-candidates",
181 tag: WS_METHODS.pullRequestsReviewerCandidates,190 tag: WS_METHODS.pullRequestsReviewerCandidates,
191 execute: (input) => routedRequest(WS_METHODS.pullRequestsReviewerCandidates, input),
182 staleTimeMs: 60_000,192 staleTimeMs: 60_000,
183 }),193 }),
184 );194 );
...@@ -215,6 +225,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(...@@ -215,6 +225,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(
215 threadComments: createEnvironmentRpcCommand(runtime, {225 threadComments: createEnvironmentRpcCommand(runtime, {
216 label: "environment-data:pull-requests:thread-comments",226 label: "environment-data:pull-requests:thread-comments",
217 tag: WS_METHODS.pullRequestsThreadComments,227 tag: WS_METHODS.pullRequestsThreadComments,
228 execute: (input) => routedRequest(WS_METHODS.pullRequestsThreadComments, input),
218 scheduler: commandScheduler,229 scheduler: commandScheduler,
219 concurrency: {230 concurrency: {
220 mode: "singleFlight",231 mode: "singleFlight",
...@@ -241,6 +252,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(...@@ -241,6 +252,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(
241 diffFileContents: createEnvironmentRpcCommand(runtime, {252 diffFileContents: createEnvironmentRpcCommand(runtime, {
242 label: "environment-data:pull-requests:diff-file-contents",253 label: "environment-data:pull-requests:diff-file-contents",
243 tag: WS_METHODS.pullRequestsDiffFileContents,254 tag: WS_METHODS.pullRequestsDiffFileContents,
255 execute: (input) => routedRequest(WS_METHODS.pullRequestsDiffFileContents, input),
244 scheduler: commandScheduler,256 scheduler: commandScheduler,
245 concurrency: {257 concurrency: {
246 mode: "singleFlight",258 mode: "singleFlight",
...@@ -261,24 +273,28 @@ export function createPullRequestEnvironmentAtoms<R, E>(...@@ -261,24 +273,28 @@ export function createPullRequestEnvironmentAtoms<R, E>(
261 runAction: createEnvironmentRpcCommand(runtime, {273 runAction: createEnvironmentRpcCommand(runtime, {
262 label: "environment-data:pull-requests:run-action",274 label: "environment-data:pull-requests:run-action",
263 tag: WS_METHODS.pullRequestsRunAction,275 tag: WS_METHODS.pullRequestsRunAction,
276 execute: (input) => routedRequest(WS_METHODS.pullRequestsRunAction, input),
264 scheduler: commandScheduler,277 scheduler: commandScheduler,
265 concurrency: serialPerEnvironment,278 concurrency: serialPerEnvironment,
266 }),279 }),
267 update: createEnvironmentRpcCommand(runtime, {280 update: createEnvironmentRpcCommand(runtime, {
268 label: "environment-data:pull-requests:update",281 label: "environment-data:pull-requests:update",
269 tag: WS_METHODS.pullRequestsUpdate,282 tag: WS_METHODS.pullRequestsUpdate,
283 execute: (input) => routedRequest(WS_METHODS.pullRequestsUpdate, input),
270 scheduler: commandScheduler,284 scheduler: commandScheduler,
271 concurrency: serialPerEnvironment,285 concurrency: serialPerEnvironment,
272 }),286 }),
273 comment: createEnvironmentRpcCommand(runtime, {287 comment: createEnvironmentRpcCommand(runtime, {
274 label: "environment-data:pull-requests:comment",288 label: "environment-data:pull-requests:comment",
275 tag: WS_METHODS.pullRequestsComment,289 tag: WS_METHODS.pullRequestsComment,
290 execute: (input) => routedRequest(WS_METHODS.pullRequestsComment, input),
276 scheduler: commandScheduler,291 scheduler: commandScheduler,
277 concurrency: serialPerEnvironment,292 concurrency: serialPerEnvironment,
278 }),293 }),
279 updateComment: createEnvironmentRpcCommand(runtime, {294 updateComment: createEnvironmentRpcCommand(runtime, {
280 label: "environment-data:pull-requests:update-comment",295 label: "environment-data:pull-requests:update-comment",
281 tag: WS_METHODS.pullRequestsUpdateComment,296 tag: WS_METHODS.pullRequestsUpdateComment,
297 execute: (input) => routedRequest(WS_METHODS.pullRequestsUpdateComment, input),
282 scheduler: commandScheduler,298 scheduler: commandScheduler,
283 concurrency: serialPerEnvironment,299 concurrency: serialPerEnvironment,
284 onSuccess: ({ environmentId, input: { projectId, host, repository, number } }, registry) =>300 onSuccess: ({ environmentId, input: { projectId, host, repository, number } }, registry) =>
...@@ -294,12 +310,14 @@ export function createPullRequestEnvironmentAtoms<R, E>(...@@ -294,12 +310,14 @@ export function createPullRequestEnvironmentAtoms<R, E>(
294 submitReview: createEnvironmentRpcCommand(runtime, {310 submitReview: createEnvironmentRpcCommand(runtime, {
295 label: "environment-data:pull-requests:submit-review",311 label: "environment-data:pull-requests:submit-review",
296 tag: WS_METHODS.pullRequestsSubmitReview,312 tag: WS_METHODS.pullRequestsSubmitReview,
313 execute: (input) => routedRequest(WS_METHODS.pullRequestsSubmitReview, input),
297 scheduler: commandScheduler,314 scheduler: commandScheduler,
298 concurrency: serialPerEnvironment,315 concurrency: serialPerEnvironment,
299 }),316 }),
300 replyToThread: createEnvironmentRpcCommand(runtime, {317 replyToThread: createEnvironmentRpcCommand(runtime, {
301 label: "environment-data:pull-requests:reply-to-thread",318 label: "environment-data:pull-requests:reply-to-thread",
302 tag: WS_METHODS.pullRequestsReplyToThread,319 tag: WS_METHODS.pullRequestsReplyToThread,
320 execute: (input) => routedRequest(WS_METHODS.pullRequestsReplyToThread, input),
303 scheduler: commandScheduler,321 scheduler: commandScheduler,
304 concurrency: serialPerEnvironment,322 concurrency: serialPerEnvironment,
305 }),323 }),
...@@ -313,6 +331,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(...@@ -313,6 +331,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(
313 requestReviewers: createEnvironmentRpcCommand(runtime, {331 requestReviewers: createEnvironmentRpcCommand(runtime, {
314 label: "environment-data:pull-requests:request-reviewers",332 label: "environment-data:pull-requests:request-reviewers",
315 tag: WS_METHODS.pullRequestsRequestReviewers,333 tag: WS_METHODS.pullRequestsRequestReviewers,
334 execute: (input) => routedRequest(WS_METHODS.pullRequestsRequestReviewers, input),
316 scheduler: commandScheduler,335 scheduler: commandScheduler,
317 concurrency: serialPerEnvironment,336 concurrency: serialPerEnvironment,
318 onSuccess: (target, registry) =>337 onSuccess: (target, registry) =>
...@@ -389,6 +408,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(...@@ -389,6 +408,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(
389 setLabels: createEnvironmentRpcCommand(runtime, {408 setLabels: createEnvironmentRpcCommand(runtime, {
390 label: "environment-data:pull-requests:set-labels",409 label: "environment-data:pull-requests:set-labels",
391 tag: WS_METHODS.pullRequestsSetLabels,410 tag: WS_METHODS.pullRequestsSetLabels,
411 execute: (input) => routedRequest(WS_METHODS.pullRequestsSetLabels, input),
392 scheduler: commandScheduler,412 scheduler: commandScheduler,
393 concurrency: serialPerEnvironment,413 concurrency: serialPerEnvironment,
394 onSuccess: (target, registry) =>414 onSuccess: (target, registry) =>
...@@ -424,12 +444,14 @@ export function createPullRequestEnvironmentAtoms<R, E>(...@@ -424,12 +444,14 @@ export function createPullRequestEnvironmentAtoms<R, E>(
424 setThreadResolution: createEnvironmentRpcCommand(runtime, {444 setThreadResolution: createEnvironmentRpcCommand(runtime, {
425 label: "environment-data:pull-requests:set-thread-resolution",445 label: "environment-data:pull-requests:set-thread-resolution",
426 tag: WS_METHODS.pullRequestsSetThreadResolution,446 tag: WS_METHODS.pullRequestsSetThreadResolution,
447 execute: (input) => routedRequest(WS_METHODS.pullRequestsSetThreadResolution, input),
427 scheduler: commandScheduler,448 scheduler: commandScheduler,
428 concurrency: serialPerEnvironment,449 concurrency: serialPerEnvironment,
429 }),450 }),
430 setReaction: createEnvironmentRpcCommand(runtime, {451 setReaction: createEnvironmentRpcCommand(runtime, {
431 label: "environment-data:pull-requests:set-reaction",452 label: "environment-data:pull-requests:set-reaction",
432 tag: WS_METHODS.pullRequestsSetReaction,453 tag: WS_METHODS.pullRequestsSetReaction,
454 execute: (input) => routedRequest(WS_METHODS.pullRequestsSetReaction, input),
433 scheduler: commandScheduler,455 scheduler: commandScheduler,
434 concurrency: serialPerEnvironment,456 concurrency: serialPerEnvironment,
435 }),457 }),
...@@ -441,6 +463,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(...@@ -441,6 +463,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(
441 invalidate: createEnvironmentRpcCommand(runtime, {463 invalidate: createEnvironmentRpcCommand(runtime, {
442 label: "environment-data:pull-requests:invalidate",464 label: "environment-data:pull-requests:invalidate",
443 tag: WS_METHODS.pullRequestsInvalidate,465 tag: WS_METHODS.pullRequestsInvalidate,
466 execute: (input) => routedRequest(WS_METHODS.pullRequestsInvalidate, input),
444 scheduler: commandScheduler,467 scheduler: commandScheduler,
445 concurrency: serialPerEnvironment,468 concurrency: serialPerEnvironment,
446 }),469 }),
packages/client-runtime/src/state/runtime.ts+25-3
...@@ -11,6 +11,8 @@ import type { ConnectionAttemptError } from "../connection/model.ts";...@@ -11,6 +11,8 @@ import type { ConnectionAttemptError } from "../connection/model.ts";
11import { EnvironmentNotRegisteredError, EnvironmentRegistry } from "../connection/registry.ts";11import { EnvironmentNotRegisteredError, EnvironmentRegistry } from "../connection/registry.ts";
12import {12import {
13 type EnvironmentRpcInput,13 type EnvironmentRpcInput,
14 type EnvironmentRpcSuccess,
15 type EnvironmentRpcFailure,
14 type EnvironmentRpcStreamFailure,16 type EnvironmentRpcStreamFailure,
15 type EnvironmentRpcStreamValue,17 type EnvironmentRpcStreamValue,
16 type EnvironmentSubscriptionRpcTag,18 type EnvironmentSubscriptionRpcTag,
...@@ -588,7 +590,12 @@ export function createEnvironmentSubscriptionAtomFamily<R, ER, Input, A, E>(...@@ -588,7 +590,12 @@ export function createEnvironmentSubscriptionAtomFamily<R, ER, Input, A, E>(
588590
589export function createEnvironmentCommand<R, ER, Input, A, E>(591export function createEnvironmentCommand<R, ER, Input, A, E>(
590 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, ER>,592 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 >,
592) {599) {
593 return createRuntimeCommand(runtime, {600 return createRuntimeCommand(runtime, {
594 label: options.label,601 label: options.label,
...@@ -607,6 +614,13 @@ export function createEnvironmentRpcQueryAtomFamily<R, ER, TTag extends Environm...@@ -607,6 +614,13 @@ export function createEnvironmentRpcQueryAtomFamily<R, ER, TTag extends Environm
607 options: {614 options: {
608 readonly label: string;615 readonly label: string;
609 readonly tag: TTag;616 readonly tag: TTag;
617 readonly execute?: (
618 input: EnvironmentRpcInput<TTag>,
619 ) => Effect.Effect<
620 EnvironmentRpcSuccess<TTag>,
621 EnvironmentRpcFailure<TTag> | EnvironmentRpcUnavailableError,
622 EnvironmentSupervisor | EnvironmentRegistry
623 >;
610 readonly staleTimeMs?: number;624 readonly staleTimeMs?: number;
611 readonly idleTtlMs?: number;625 readonly idleTtlMs?: number;
612 readonly refreshIntervalMs?: number;626 readonly refreshIntervalMs?: number;
...@@ -624,7 +638,8 @@ export function createEnvironmentRpcQueryAtomFamily<R, ER, TTag extends Environm...@@ -624,7 +638,8 @@ export function createEnvironmentRpcQueryAtomFamily<R, ER, TTag extends Environm
624 ? {}638 ? {}
625 : { refreshIntervalMs: options.refreshIntervalMs }),639 : { refreshIntervalMs: options.refreshIntervalMs }),
626 ...(options.refreshTrigger === undefined ? {} : { refreshTrigger: options.refreshTrigger }),640 ...(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),
628 });643 });
629}644}
630645
...@@ -665,6 +680,13 @@ export function createEnvironmentRpcCommand<R, ER, TTag extends EnvironmentUnary...@@ -665,6 +680,13 @@ export function createEnvironmentRpcCommand<R, ER, TTag extends EnvironmentUnary
665 options: {680 options: {
666 readonly label: string;681 readonly label: string;
667 readonly tag: TTag;682 readonly tag: TTag;
683 readonly execute?: (
684 input: EnvironmentRpcInput<TTag>,
685 ) => Effect.Effect<
686 EnvironmentRpcSuccess<TTag>,
687 EnvironmentRpcFailure<TTag> | EnvironmentRpcUnavailableError,
688 EnvironmentSupervisor | EnvironmentRegistry
689 >;
668 readonly scheduler?: AtomCommandScheduler;690 readonly scheduler?: AtomCommandScheduler;
669 readonly concurrency?: AtomCommandConcurrency<{691 readonly concurrency?: AtomCommandConcurrency<{
670 readonly environmentId: EnvironmentIdType;692 readonly environmentId: EnvironmentIdType;
...@@ -695,7 +717,7 @@ export function createEnvironmentRpcCommand<R, ER, TTag extends EnvironmentUnary...@@ -695,7 +717,7 @@ export function createEnvironmentRpcCommand<R, ER, TTag extends EnvironmentUnary
695 environmentId,717 environmentId,
696 input,718 input,
697 };719 };
698 return request(options.tag, input).pipe(720 return (options.execute?.(input) ?? request(options.tag, input)).pipe(
699 Effect.tap(() => options.onSuccess?.(target, registry) ?? Effect.void),721 Effect.tap(() => options.onSuccess?.(target, registry) ?? Effect.void),
700 Effect.ensuring(options.onSettled?.(target, registry) ?? Effect.void),722 Effect.ensuring(options.onSettled?.(target, registry) ?? Effect.void),
701 );723 );
packages/contracts/src/pullRequest.ts+28
...@@ -643,11 +643,39 @@ export type PullRequestListResult = typeof PullRequestListResult.Type;...@@ -643,11 +643,39 @@ export type PullRequestListResult = typeof PullRequestListResult.Type;
643export const PullRequestRef = Schema.Struct({643export const PullRequestRef = Schema.Struct({
644 projectId: ProjectId,644 projectId: ProjectId,
645 host: Schema.optional(TrimmedNonEmptyString),645 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),
646 repository: TrimmedNonEmptyString,650 repository: TrimmedNonEmptyString,
647 number: PositiveInt,651 number: PositiveInt,
648});652});
649export type PullRequestRef = typeof PullRequestRef.Type;653export 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
651export const PullRequestLinkedThreadsResult = Schema.Struct({679export const PullRequestLinkedThreadsResult = Schema.Struct({
652 threads: Schema.Array(680 threads: Schema.Array(
653 Schema.Struct({681 Schema.Struct({
packages/contracts/src/rpc.ts+19
...@@ -115,6 +115,9 @@ import {...@@ -115,6 +115,9 @@ import {
115 PullRequestOperationError,115 PullRequestOperationError,
116 PullRequestReactionInput,116 PullRequestReactionInput,
117 PullRequestRef,117 PullRequestRef,
118 PullRequestRoutingResult,
119 PullRequestRoutingIdentityInput,
120 PullRequestRoutingIdentityResult,
118 PullRequestStack,121 PullRequestStack,
119 PullRequestLinkedThreadsResult,122 PullRequestLinkedThreadsResult,
120 PullRequestSummary,123 PullRequestSummary,
...@@ -371,6 +374,8 @@ export const WS_METHODS = {...@@ -371,6 +374,8 @@ export const WS_METHODS = {
371 pullRequestsList: "pullRequests.list",374 pullRequestsList: "pullRequests.list",
372 pullRequestsListStats: "pullRequests.listStats",375 pullRequestsListStats: "pullRequests.listStats",
373 pullRequestsSummary: "pullRequests.summary",376 pullRequestsSummary: "pullRequests.summary",
377 pullRequestsRouting: "pullRequests.routing",
378 pullRequestsRoutingIdentity: "pullRequests.routingIdentity",
374 pullRequestsStack: "pullRequests.stack",379 pullRequestsStack: "pullRequests.stack",
375 pullRequestsLinkedThreads: "pullRequests.linkedThreads",380 pullRequestsLinkedThreads: "pullRequests.linkedThreads",
376 pullRequestsDetail: "pullRequests.detail",381 pullRequestsDetail: "pullRequests.detail",
...@@ -672,6 +677,18 @@ const WsPullRequestsListStatsRpc = Rpc.make(WS_METHODS.pullRequestsListStats, {...@@ -672,6 +677,18 @@ const WsPullRequestsListStatsRpc = Rpc.make(WS_METHODS.pullRequestsListStats, {
672 error: PullRequestRpcError,677 error: PullRequestRpcError,
673});678});
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
675const WsPullRequestsSummaryRpc = Rpc.make(WS_METHODS.pullRequestsSummary, {692const WsPullRequestsSummaryRpc = Rpc.make(WS_METHODS.pullRequestsSummary, {
676 payload: PullRequestRef,693 payload: PullRequestRef,
677 success: PullRequestSummary,694 success: PullRequestSummary,
...@@ -1316,6 +1333,8 @@ export const WsRpcGroup = RpcGroup.make(...@@ -1316,6 +1333,8 @@ export const WsRpcGroup = RpcGroup.make(
1316 WsPullRequestsListRpc,1333 WsPullRequestsListRpc,
1317 WsPullRequestsListStatsRpc,1334 WsPullRequestsListStatsRpc,
1318 WsPullRequestsSummaryRpc,1335 WsPullRequestsSummaryRpc,
1336 WsPullRequestsRoutingRpc,
1337 WsPullRequestsRoutingIdentityRpc,
1319 WsPullRequestsStackRpc,1338 WsPullRequestsStackRpc,
1320 WsPullRequestsLinkedThreadsRpc,1339 WsPullRequestsLinkedThreadsRpc,
1321 WsPullRequestsDetailRpc,1340 WsPullRequestsDetailRpc,