| author | |
| committer | |
| log | db6e0531e4faac0fc5ac0e12088b01bed6e7b851 |
| tree | dd89758188cd9edd6a0e20cd3c44d16cd2097674 |
| parent | 8b3ddf51c59a54411cd02b321cb07f39c927df09 |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 18 | import * as Context from "effect/Context"; | 20 | import * as Context from "effect/Context"; |
| 19 | import * as Effect from "effect/Effect"; | 21 | import * as Effect from "effect/Effect"; |
| ... | @@ -39,6 +41,11 @@ function targetPersistenceError( | ... | @@ -39,6 +41,11 @@ function targetPersistenceError( |
| 39 | export const connectionStorageLayer = Layer.effectContext( | 41 | export 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 | }); | ||
| 42 | 49 | ||
| 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"; |
| 11 | import { cn } from "../../lib/cn"; | 11 | import { cn } from "../../lib/cn"; |
| 12 | import { useRemoteConnections } from "../../state/use-remote-environment-registry"; | 12 | import { useRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 13 | import { ConnectionEnvironmentRow } from "./ConnectionEnvironmentRow"; | 13 | import { ConnectionEnvironmentRow } from "./ConnectionEnvironmentRow"; |
| 14 | import { GitHubRoutingSettings } from "./GitHubRoutingSettings"; | ||
| 14 | 15 | ||
| 15 | export function ConnectionsRouteScreen() { | 16 | export 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 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | connectionCatalogDisplayUrl, | ||
| 4 | gitHubRoutingConnectionKey, | ||
| 5 | gitHubRoutingPermissionFor, | ||
| 6 | type GitHubRoutingPermission, | ||
| 7 | } from "@t3tools/client-runtime/connection"; | ||
| 8 | import { useState } from "react"; | ||
| 9 | import { Alert, Pressable, View } from "react-native"; | ||
| 10 | |||
| 11 | import { AppText as Text } from "../../components/AppText"; | ||
| 12 | import { SymbolView } from "../../components/AppSymbol"; | ||
| 13 | import { environmentCatalog } from "../../connection/catalog"; | ||
| 14 | import { useAtomCommand } from "../../state/use-atom-command"; | ||
| 15 | import { SettingsSection } from "../settings/components/SettingsSection"; | ||
| 16 | |||
| 17 | const 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 | |||
| 35 | export 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"; |
| 10 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | 10 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 11 | import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows"; | 11 | import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows"; |
| 12 | import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow"; | 12 | import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow"; |
| 13 | import { GitHubRoutingSettings } from "../connection/GitHubRoutingSettings"; | ||
| 13 | import { splitEnvironmentSections } from "../connection/environmentSections"; | 14 | import { splitEnvironmentSections } from "../connection/environmentSections"; |
| 14 | import { cn } from "../../lib/cn"; | 15 | import { cn } from "../../lib/cn"; |
| 15 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; | 16 | import { 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 @@ |
| 1 | import { afterEach, assert, expect, it, vi } from "@effect/vitest"; | 1 | import { afterEach, assert, expect, it, vi } from "@effect/vitest"; |
| 2 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Deferred from "effect/Deferred"; | ||
| 4 | import * as Fiber from "effect/Fiber"; | ||
| 3 | import * as Layer from "effect/Layer"; | 5 | import * as Layer from "effect/Layer"; |
| 4 | import * as Schema from "effect/Schema"; | 6 | import * as Schema from "effect/Schema"; |
| 5 | import * as TestClock from "effect/testing/TestClock"; | 7 | import * as TestClock from "effect/testing/TestClock"; |
| 6 | import { ChildProcessSpawner } from "effect/unstable/process"; | 8 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 7 | 9 | ||
| 8 | import * as GitHubCli from "../sourceControl/GitHubCli.ts"; | 10 | import * as GitHubCli from "../sourceControl/GitHubCli.ts"; |
| 11 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | ||
| 9 | import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; | 12 | import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; |
| 10 | import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; | 13 | import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; |
| 11 | import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts"; | 14 | import { 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 | }); |
| 194 | 197 | ||
| 198 | it.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 | |||
| 195 | layer("GitHubPullRequestCli.layer", (it) => { | 255 | layer("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; |
| 2460 | 2574 | ||
| 2461 | const error = yield* Effect.flip(cli.getViewerLogin({ cwd: "/w" })); | 2575 | const error = yield* Effect.flip(cli.getViewerLogin({ cwd: "/w", host: "github.com" })); |
| 2462 | 2576 | ||
| 2463 | assert.strictEqual(error._tag, "GitHubViewerLoginUnavailableError"); | 2577 | assert.strictEqual(error._tag, "GitHubViewerLoginUnavailableError"); |
| 2464 | }), | 2578 | }), |
| 2465 | ); | 2579 | ); |
| 2466 | 2580 | ||
| 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 @@ |
| 1 | import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts"; | 1 | import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts"; |
| 2 | import * as Context from "effect/Context"; | 2 | import * as Context from "effect/Context"; |
| 3 | import * as Clock from "effect/Clock"; | ||
| 4 | import * as NodeCrypto from "node:crypto"; | ||
| 3 | import * as Effect from "effect/Effect"; | 5 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; | 6 | import * as Layer from "effect/Layer"; |
| 7 | import * as Redacted from "effect/Redacted"; | ||
| 5 | import * as Result from "effect/Result"; | 8 | import * as Result from "effect/Result"; |
| 6 | import * as Schema from "effect/Schema"; | 9 | import * as Schema from "effect/Schema"; |
| 10 | import * as Semaphore from "effect/Semaphore"; | ||
| 7 | import { | 11 | import { |
| 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 { |
| 413 | export class GitHubPullRequestCli extends Context.Service< | 419 | export 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>; |
| 419 | 441 | ||
| 420 | readonly listPullRequests: (input: { | 442 | readonly listPullRequests: (input: { |
| ... | @@ -991,6 +1013,111 @@ function actionArgs( | ... | @@ -991,6 +1013,111 @@ function actionArgs( |
| 991 | export const make = Effect.gen(function* () { | 1013 | export 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 | ); | ||
| 994 | 1121 | ||
| 995 | /** | 1122 | /** |
| 996 | * The pull request's own node id, which is what a mutation against the pull request itself is | 1123 | * 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 | ); |
| 1469 | 1596 | ||
| 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 | ), | ||
| 1480 | 1602 | ||
| 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 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Layer from "effect/Layer"; | 3 | import * as Layer from "effect/Layer"; |
| 4 | import * as Redacted from "effect/Redacted"; | ||
| 4 | import type { PullRequestReaction } from "@t3tools/contracts"; | 5 | import type { PullRequestReaction } from "@t3tools/contracts"; |
| 5 | 6 | ||
| 6 | import * as GitHubCli from "../sourceControl/GitHubCli.ts"; | 7 | import * 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"; |
| 8 | import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts"; | 9 | import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts"; |
| 9 | import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts"; | 10 | import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts"; |
| 10 | 11 | ||
| 12 | it.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 | |||
| 11 | it.effect("uses one narrow read for a linked pull request summary", () => | 50 | it.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 | ); |
| 514 | 576 | ||
| 515 | describe("getViewerPermissions", () => { | 577 | describe("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"; |
| 12 | 12 | ||
| 13 | import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; | 13 | import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; |
| 14 | import { PinnedGitHubCredential } from "../sourceControl/GitHubCli.ts"; | ||
| 14 | import { | 15 | import { |
| 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 | ); | ||
| 202 | 216 | ||
| 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)), | ||
| 215 | 235 | ||
| 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"))), | ||
| 218 | 240 | ||
| 219 | listChangeRequests: (input) => | 241 | listChangeRequests: (input) => |
| 220 | cli | 242 | 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 here | 516 | // 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 path | 517 | // 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 === null | 521 | : cli.getPullRequestDetail(input).pipe( |
| 500 | ? Effect.succeed(false) | 522 | Effect.flatMap((pullRequest) => |
| 501 | : cli | 523 | 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 | */ |
| 278 | export interface PullRequestProviderApi { | 278 | export 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; |
| 281 | 296 | ||
| ... | @@ -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>; |
| 403 | 422 | ||
| 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"; |
| 19 | import { PullRequestOperationError } from "@t3tools/contracts"; | ||
| 19 | 20 | ||
| 20 | import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; | 21 | import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 21 | import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; | 22 | import * 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 | ); |
| 976 | 977 | ||
| 978 | it.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 | |||
| 977 | it.effect("refuses an action the host never claimed it could run", () => | 1023 | it.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 |
| 3707 | 3753 | ||
| 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"); |
| 3714 | 3758 | ||
| 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 | ); |
| 3725 | 3769 | ||
| 3770 | it.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 | |||
| 3805 | it.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 | |||
| 3859 | it.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 | |||
| 3726 | it.effect("answers a known pull request immediately while the host refreshes", () => | 3911 | it.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 | ); |
| 3900 | 4092 | ||
| ... | @@ -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"); |
| 3983 | 4177 |
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; |
| 138 | 141 | ||
| 139 | export type PullRequestError = PullRequestUnavailableError | PullRequestOperationError; | 142 | export type PullRequestError = PullRequestUnavailableError | PullRequestOperationError; |
| 140 | 143 | ||
| 144 | const 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. | ||
| 149 | const credentialNamespace = Symbol("pullRequestCredentialNamespace"); | ||
| 150 | type CredentialRef = PullRequestRef & { readonly [credentialNamespace]?: string }; | ||
| 151 | |||
| 141 | export class PullRequestService extends Context.Service< | 152 | export 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 === undefined | 502 | ...(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 taken | 801 | * 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.api | 810 | 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))); |
| 788 | 819 | ||
| ... | @@ -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 | }); | ||
| 1313 | 1429 | ||
| 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 both | 1736 | // 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 checks | 1737 | // 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* () { |
| 2193 | 2314 | ||
| 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 | ); | ||
| 2196 | 2323 | ||
| 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 the | 2345 | // 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 one | 2346 | // 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 with | 2426 | // 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 | }; |
| 2366 | 2491 | ||
| 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 older | 2694 | // `serveHeld` returns immediately. Skip the write when that read is older |
| 2567 | // than a later strict summary — display reuse would otherwise keep the | 2695 | // 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 | }; |
| 2582 | 2709 | ||
| 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 | }); |
| 2760 | 2887 | ||
| 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 @@ |
| 1 | import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest"; | 1 | import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest"; |
| 2 | import * as Cache from "effect/Cache"; | ||
| 2 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 3 | import * as Layer from "effect/Layer"; | 4 | import * as Layer from "effect/Layer"; |
| 4 | import * as PlatformError from "effect/PlatformError"; | 5 | import * as PlatformError from "effect/PlatformError"; |
| 6 | import * as Redacted from "effect/Redacted"; | ||
| 7 | import * as Schema from "effect/Schema"; | ||
| 5 | import { ChildProcessSpawner } from "effect/unstable/process"; | 8 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 6 | import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts"; | 9 | import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts"; |
| 7 | 10 | ||
| 8 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | 11 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 9 | import * as GitHubCli from "./GitHubCli.ts"; | 12 | import * as GitHubCli from "./GitHubCli.ts"; |
| 10 | 13 | ||
| 14 | const encodeGitHubCliError = Schema.encodeEffect(Schema.fromJsonString(GitHubCli.GitHubCliError)); | ||
| 15 | |||
| 11 | const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ | 16 | const 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 | }); |
| 32 | 37 | ||
| 33 | describe("GitHubCli.layer", () => { | 38 | describe("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"; |
| 4 | import * as Layer from "effect/Layer"; | 4 | import * as Layer from "effect/Layer"; |
| 5 | import * as Option from "effect/Option"; | 5 | import * as Option from "effect/Option"; |
| 6 | import * as PlatformError from "effect/PlatformError"; | 6 | import * as PlatformError from "effect/PlatformError"; |
| 7 | import * as Redacted from "effect/Redacted"; | ||
| 7 | import * as Result from "effect/Result"; | 8 | import * as Result from "effect/Result"; |
| 8 | import * as Schema from "effect/Schema"; | 9 | import * as Schema from "effect/Schema"; |
| 9 | 10 | ||
| ... | @@ -22,6 +23,40 @@ import { | ... | @@ -22,6 +23,40 @@ import { |
| 22 | 23 | ||
| 23 | const DEFAULT_TIMEOUT_MS = 30_000; | 24 | const DEFAULT_TIMEOUT_MS = 30_000; |
| 24 | 25 | ||
| 26 | /** Server-local credential scope; never put its value in RPC payloads or cache keys. */ | ||
| 27 | export 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 | |||
| 33 | function 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 | |||
| 25 | const gitHubCliFailureFields = { | 60 | const 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>; |
| 242 | 278 | ||
| ... | @@ -342,18 +378,43 @@ function deriveRepositoryCloneUrlsFromCreateOutput( | ... | @@ -342,18 +378,43 @@ function deriveRepositoryCloneUrlsFromCreateOutput( |
| 342 | export const make = Effect.gen(function* () { | 378 | export const make = Effect.gen(function* () { |
| 343 | const process = yield* VcsProcess.VcsProcess; | 379 | const process = yield* VcsProcess.VcsProcess; |
| 344 | 380 | ||
| 345 | const execute: GitHubCli["Service"]["execute"] = (input) => | 381 | const execute: GitHubCli["Service"]["execute"] = Effect.fn("GitHubCli.execute")( |
| 346 | process | 382 | 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 | ); | ||
| 357 | 418 | ||
| 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 | pullRequests | 2235 | 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 { |
| 68 | import { searchableSetting } from "./settingsSearch"; | 68 | import { searchableSetting } from "./settingsSearch"; |
| 69 | import { EnvironmentIconPicker } from "./EnvironmentIconPicker"; | 69 | import { EnvironmentIconPicker } from "./EnvironmentIconPicker"; |
| 70 | import { LoadBalancingSettings } from "./LoadBalancingSettings"; | 70 | import { LoadBalancingSettings } from "./LoadBalancingSettings"; |
| 71 | import { GitHubRoutingSettings } from "./GitHubRoutingSettings"; | ||
| 71 | import { Input } from "../ui/input"; | 72 | import { Input } from "../ui/input"; |
| 72 | import { CommandShortcut } from "../ui/command"; | 73 | import { CommandShortcut } from "../ui/command"; |
| 73 | import { | 74 | import { |
| ... | @@ -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 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | gitHubRoutingConnectionKey, | ||
| 4 | gitHubRoutingPermissionFor, | ||
| 5 | type GitHubRoutingPermission, | ||
| 6 | } from "@t3tools/client-runtime/connection"; | ||
| 7 | import { useState } from "react"; | ||
| 8 | |||
| 9 | import { environmentCatalog } from "~/connection/catalog"; | ||
| 10 | import type { EnvironmentPresentation } from "~/state/environments"; | ||
| 11 | import { useAtomCommand } from "~/state/use-atom-command"; | ||
| 12 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; | ||
| 13 | import { toastManager } from "../ui/toast"; | ||
| 14 | import { SettingsRow, SettingsSection } from "./settingsLayout"; | ||
| 15 | import { searchableSetting } from "./settingsSearch"; | ||
| 16 | |||
| 17 | const 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 | |||
| 23 | export 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 @@ |
| 1 | import { ConnectionTransientError } from "@t3tools/client-runtime/connection"; | 1 | import { |
| 2 | ConnectionTransientError, | ||
| 3 | PrimaryConnectionTarget, | ||
| 4 | } from "@t3tools/client-runtime/connection"; | ||
| 5 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import { ConnectionCatalogDocument } from "@t3tools/client-runtime/platform"; | 6 | import { ConnectionCatalogDocument } from "@t3tools/client-runtime/platform"; |
| 3 | import { describe, expect, it } from "@effect/vitest"; | 7 | import { describe, expect, it } from "@effect/vitest"; |
| 4 | import * as Effect from "effect/Effect"; | 8 | import * as Effect from "effect/Effect"; |
| 9 | import * as Deferred from "effect/Deferred"; | ||
| 10 | import * as Option from "effect/Option"; | ||
| 5 | import * as Schema from "effect/Schema"; | 11 | import * as Schema from "effect/Schema"; |
| 12 | import * as Stream from "effect/Stream"; | ||
| 6 | import { afterEach, vi } from "vite-plus/test"; | 13 | import { afterEach, vi } from "vite-plus/test"; |
| 7 | 14 | ||
| 8 | import { makeCatalogBackend, makeCatalogStore } from "./storage"; | 15 | import { |
| 16 | makeBrowserGitHubRoutingPermissions, | ||
| 17 | makeCatalogBackend, | ||
| 18 | makeCatalogStore, | ||
| 19 | } from "./storage"; | ||
| 9 | 20 | ||
| 10 | const emptyCatalog = { | 21 | const 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; |
| 18 | const decodeCatalog = Schema.decodeUnknownSync(Schema.fromJsonString(ConnectionCatalogDocument)); | 29 | const decodeCatalog = Schema.decodeUnknownSync(Schema.fromJsonString(ConnectionCatalogDocument)); |
| 30 | const encodeCatalog = Schema.encodeSync(Schema.fromJsonString(ConnectionCatalogDocument)); | ||
| 19 | 31 | ||
| 20 | afterEach(() => { | 32 | afterEach(() => { |
| 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 | |||
| 92 | describe("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 { |
| 16 | import { TokenStore } from "@t3tools/client-runtime/authorization"; | 16 | import { TokenStore } from "@t3tools/client-runtime/authorization"; |
| 17 | import { | 17 | import { |
| 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"; |
| 22 | import { | 27 | import { |
| 23 | EnvironmentId, | 28 | EnvironmentId, |
| ... | @@ -32,8 +37,10 @@ import * as Effect from "effect/Effect"; | ... | @@ -32,8 +37,10 @@ import * as Effect from "effect/Effect"; |
| 32 | import * as Layer from "effect/Layer"; | 37 | import * as Layer from "effect/Layer"; |
| 33 | import * as Option from "effect/Option"; | 38 | import * as Option from "effect/Option"; |
| 34 | import * as Ref from "effect/Ref"; | 39 | import * as Ref from "effect/Ref"; |
| 40 | import * as Queue from "effect/Queue"; | ||
| 35 | import * as Schema from "effect/Schema"; | 41 | import * as Schema from "effect/Schema"; |
| 36 | import * as Semaphore from "effect/Semaphore"; | 42 | import * as Semaphore from "effect/Semaphore"; |
| 43 | import * as Stream from "effect/Stream"; | ||
| 37 | import { projectFaviconCache } from "../assets/projectFaviconCache"; | 44 | import { projectFaviconCache } from "../assets/projectFaviconCache"; |
| 38 | 45 | ||
| 39 | const DATABASE_NAME = "t3code:connection-runtime"; | 46 | const 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 | }); |
| 370 | 377 | ||
| 378 | const GITHUB_ROUTING_KEY_PREFIX = "t3code:github-routing:"; | ||
| 379 | const GITHUB_ROUTING_CHANGED = "t3code:github-routing-changed"; | ||
| 380 | const isStoredGitHubRoutingPermission = Schema.is(StoredGitHubRoutingPermission); | ||
| 381 | const 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. */ | ||
| 386 | export 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 | |||
| 371 | export const connectionStorageLayer = Layer.effectContext( | 479 | export 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(); | ||
| 377 | 486 | ||
| 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 | }); |
| 671 | 780 | ||
| 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. |
| 99 | GitHub, GitLab, and Azure DevOps support auto-merge while checks are outstanding. GitHub also | 99 | GitHub, GitLab, and Azure DevOps support auto-merge while checks are outstanding. GitHub also |
| 100 | supports approving waiting fork workflows and opening a revert pull request for a merged change. | 100 | supports approving waiting fork workflows and opening a revert pull request for a merged change. |
| 101 | 101 | ||
| 102 | GitHub 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. | ||
| 104 | Enable 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; | ||
| 106 | only enable it for environments you control and trust. Changing a saved endpoint or removing an | ||
| 107 | environment clears its permission. | ||
| 108 | |||
| 109 | GitHub review details, linked PR status, and permitted review actions can then use another | ||
| 110 | connected environment signed in to the same GitHub account. Each needs a project on that host. | ||
| 111 | A connected local environment is preferred for actions and can answer slow or failed reads. | ||
| 112 | Browsers and mobile clients need a paired environment to use its GitHub CLI credentials. | ||
| 113 | Credentials stay on their machines. Previously verified credentials remain usable for routing | ||
| 114 | for ten minutes during a GitHub outage; new credentials must be verified first. An action with | ||
| 115 | an uncertain result is never automatically retried elsewhere. Listings, diffs, and checkout or | ||
| 116 | PR creation from Git actions continue to use the project's environment. | ||
| 117 | |||
| 102 | For Azure DevOps, use the host website to view diffs or change comments. Bitbucket does not support | 118 | For Azure DevOps, use the host website to view diffs or change comments. Bitbucket does not support |
| 103 | reopening a declined pull request. | 119 | reopening a declined pull request. |
| 104 | 120 |
packages/client-runtime/src/connection/githubRoutingPermissions.ts created+147| ... | @@ -0,0 +1,147 @@ | ||
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import * as Context from "effect/Context"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Option from "effect/Option"; | ||
| 5 | import * as Schema from "effect/Schema"; | ||
| 6 | import * as Semaphore from "effect/Semaphore"; | ||
| 7 | import * as Stream from "effect/Stream"; | ||
| 8 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 9 | |||
| 10 | import type { ConnectionCatalogEntry } from "./catalog.ts"; | ||
| 11 | import { ConnectionBlockedError, type ConnectionAttemptError } from "./model.ts"; | ||
| 12 | |||
| 13 | export const GitHubRoutingPermission = Schema.Literals(["off", "read", "read-write"]); | ||
| 14 | export type GitHubRoutingPermission = typeof GitHubRoutingPermission.Type; | ||
| 15 | export const StoredGitHubRoutingPermission = Schema.Struct({ | ||
| 16 | environmentId: EnvironmentId, | ||
| 17 | connectionKey: Schema.String, | ||
| 18 | permission: GitHubRoutingPermission, | ||
| 19 | }); | ||
| 20 | export type StoredGitHubRoutingPermission = typeof StoredGitHubRoutingPermission.Type; | ||
| 21 | |||
| 22 | /** Trust belongs to the saved endpoint, never to an environment id advertised by a server alone. */ | ||
| 23 | export 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 | |||
| 58 | export 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 | |||
| 68 | export 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 | |||
| 91 | export 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"; |
| 3 | export * as CredentialStore from "./credentialStore.ts"; | 3 | export * as CredentialStore from "./credentialStore.ts"; |
| 4 | export { type ConnectionDriverProgress, type EnvironmentConnectionLease } from "./driver.ts"; | 4 | export { type ConnectionDriverProgress, type EnvironmentConnectionLease } from "./driver.ts"; |
| 5 | export * from "./errors.ts"; | 5 | export * from "./errors.ts"; |
| 6 | export * from "./githubRoutingPermissions.ts"; | ||
| 6 | export * as Connection from "./layer.ts"; | 7 | export * as Connection from "./layer.ts"; |
| 7 | export * from "./model.ts"; | 8 | export * from "./model.ts"; |
| 8 | export { | 9 | export { |
packages/client-runtime/src/connection/registry.test.ts+63-2| ... | @@ -47,6 +47,10 @@ import { | ... | @@ -47,6 +47,10 @@ import { |
| 47 | import * as Persistence from "../platform/persistence.ts"; | 47 | import * as Persistence from "../platform/persistence.ts"; |
| 48 | import * as ConnectionProfileStore from "./profileStore.ts"; | 48 | import * as ConnectionProfileStore from "./profileStore.ts"; |
| 49 | import * as EnvironmentRegistry from "./registry.ts"; | 49 | import * as EnvironmentRegistry from "./registry.ts"; |
| 50 | import { | ||
| 51 | GitHubRoutingPermissions, | ||
| 52 | makeGitHubRoutingPermissions, | ||
| 53 | } from "./githubRoutingPermissions.ts"; | ||
| 50 | import * as RpcSession from "../rpc/session.ts"; | 54 | import * as RpcSession from "../rpc/session.ts"; |
| 51 | import * as EnvironmentSupervisor from "./supervisor.ts"; | 55 | import * as EnvironmentSupervisor from "./supervisor.ts"; |
| 52 | import * as ConnectionWakeups from "./wakeups.ts"; | 56 | import * 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"); | ||
| 983 | 993 | ||
| 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"); | ||
| 987 | 998 | ||
| 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 | ); |
| 1002 | 1017 | ||
| ... | @@ -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 }; | ||
| 1086 | 1115 | ||
| 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"); | ||
| 1096 | 1126 | ||
| 1097 | yield* registry.registerPlatform(registration); | 1127 | yield* registry.registerPlatform(registration); |
| 1098 | 1128 | ||
| 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 | ); |
| 1103 | 1164 |
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"; |
| 34 | import * as EnvironmentSupervisor from "./supervisor.ts"; | 34 | import * as EnvironmentSupervisor from "./supervisor.ts"; |
| 35 | import * as ConnectionDriver from "./driver.ts"; | 35 | import * as ConnectionDriver from "./driver.ts"; |
| 36 | import * as ConnectionWakeups from "./wakeups.ts"; | 36 | import * as ConnectionWakeups from "./wakeups.ts"; |
| 37 | import { | ||
| 38 | GitHubRoutingPermissions, | ||
| 39 | gitHubRoutingConnectionKey, | ||
| 40 | } from "./githubRoutingPermissions.ts"; | ||
| 37 | 41 | ||
| 38 | const isSshConnectionProfile = Schema.is(SshConnectionProfile); | 42 | const isSshConnectionProfile = Schema.is(SshConnectionProfile); |
| 39 | 43 | ||
| ... | @@ -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 a | 426 | // 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 | } |
| 464 | 505 | ||
| 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(); |
| 584 | 635 | ||
| 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"; |
| 30 | import * as ConnectionProfileStore from "./profileStore.ts"; | 30 | import * as ConnectionProfileStore from "./profileStore.ts"; |
| 31 | import { | ||
| 32 | GitHubRoutingPermissions, | ||
| 33 | gitHubRoutingConnectionKey, | ||
| 34 | makeGitHubRoutingPermissions, | ||
| 35 | } from "./githubRoutingPermissions.ts"; | ||
| 31 | 36 | ||
| 32 | const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); | 37 | const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); |
| 33 | const ENDPOINT = { | 38 | const ENDPOINT = { |
| ... | @@ -64,6 +69,7 @@ function collectingTracer(spans: Array<string>): Tracer.Tracer { | ... | @@ -64,6 +69,7 @@ function collectingTracer(spans: Array<string>): Tracer.Tracer { |
| 64 | 69 | ||
| 65 | const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((options?: { | 70 | const 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 | }); |
| 135 | 141 | ||
| 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 | ); |
| 378 | 387 | ||
| 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"; |
| 18 | import * as ConnectionCredentialStore from "./credentialStore.ts"; | 18 | import * as ConnectionCredentialStore from "./credentialStore.ts"; |
| 19 | import { credentialMissingError, environmentMismatchError, profileMissingError } from "./errors.ts"; | 19 | import { credentialMissingError, environmentMismatchError, profileMissingError } from "./errors.ts"; |
| 20 | import { | ||
| 21 | GitHubRoutingPermissions, | ||
| 22 | gitHubRoutingConnectionKey, | ||
| 23 | } from "./githubRoutingPermissions.ts"; | ||
| 20 | import type { | 24 | import 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 @@ |
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | 1 | import { EnvironmentId } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it } from "@effect/vitest"; | 2 | import { describe, expect, it } from "@effect/vitest"; |
| 3 | import * as Schema from "effect/Schema"; | 3 | import * as Schema from "effect/Schema"; |
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as Option from "effect/Option"; | ||
| 4 | 6 | ||
| 5 | import * as TokenStore from "../authorization/tokenStore.ts"; | 7 | import * as TokenStore from "../authorization/tokenStore.ts"; |
| 6 | import { | 8 | import { |
| ... | @@ -13,9 +15,15 @@ import { | ... | @@ -13,9 +15,15 @@ import { |
| 13 | } from "../connection/catalog.ts"; | 15 | } from "../connection/catalog.ts"; |
| 14 | import { | 16 | import { |
| 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"; |
| 23 | import { | ||
| 24 | GitHubRoutingPermissions, | ||
| 25 | makeGitHubRoutingPermissions, | ||
| 26 | } from "../connection/githubRoutingPermissions.ts"; | ||
| 19 | import { | 27 | import { |
| 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"; |
| 27 | 35 | ||
| 36 | const decodeConnectionCatalogDocument = Schema.decodeUnknownEffect(ConnectionCatalogDocument); | ||
| 37 | |||
| 28 | const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); | 38 | const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); |
| 29 | const decodeCatalogDocument = Schema.decodeUnknownSync(ConnectionCatalogDocument); | 39 | const decodeCatalogDocument = Schema.decodeUnknownSync(ConnectionCatalogDocument); |
| 30 | 40 | ||
| ... | @@ -61,6 +71,128 @@ const REMOTE_TOKEN = new TokenStore.RemoteDpopAccessToken({ | ... | @@ -61,6 +71,128 @@ const REMOTE_TOKEN = new TokenStore.RemoteDpopAccessToken({ |
| 61 | }); | 71 | }); |
| 62 | 72 | ||
| 63 | describe("ConnectionCatalogDocument", () => { | 73 | describe("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"; |
| 10 | import { type ConnectionTarget, PersistedConnectionTarget } from "../connection/model.ts"; | 10 | import { type ConnectionTarget, PersistedConnectionTarget } from "../connection/model.ts"; |
| 11 | import * as TokenStore from "../authorization/tokenStore.ts"; | 11 | import * as TokenStore from "../authorization/tokenStore.ts"; |
| 12 | import { StoredGitHubRoutingPermission } from "../connection/githubRoutingPermissions.ts"; | ||
| 12 | 13 | ||
| 13 | export const StoredConnectionCredential = Schema.Struct({ | 14 | export 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 their | 27 | // Saved environments the user switched off. They stay registered with their |
| 26 | // credentials and cache but never connect until switched back on. Older | 28 | // 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 | } |
| 158 | 168 | ||
| 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"; |
| 9 | import type { ConnectionCatalogEntry } from "../connection/catalog.ts"; | 9 | import type { ConnectionCatalogEntry } from "../connection/catalog.ts"; |
| 10 | import { AVAILABLE_CONNECTION_STATE } from "../connection/model.ts"; | 10 | import { AVAILABLE_CONNECTION_STATE } from "../connection/model.ts"; |
| 11 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; | 11 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; |
| 12 | import { | ||
| 13 | GitHubRoutingPermissions, | ||
| 14 | type GitHubRoutingPermission, | ||
| 15 | type StoredGitHubRoutingPermission, | ||
| 16 | } from "../connection/githubRoutingPermissions.ts"; | ||
| 12 | import { | 17 | import { |
| 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")); |
| 67 | 72 | ||
| 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 @@ | ||
| 1 | import { | ||
| 2 | WS_METHODS, | ||
| 3 | PullRequestRef, | ||
| 4 | PullRequestInvalidateInput, | ||
| 5 | type EnvironmentId, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | import * as Cause from "effect/Cause"; | ||
| 9 | import * as Option from "effect/Option"; | ||
| 10 | import * as Schema from "effect/Schema"; | ||
| 11 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 12 | |||
| 13 | import { EnvironmentRegistry, EnvironmentNotRegisteredError } from "../connection/registry.ts"; | ||
| 14 | import { EnvironmentSupervisor } from "../connection/supervisor.ts"; | ||
| 15 | import { | ||
| 16 | GitHubRoutingPermissions, | ||
| 17 | gitHubRoutingConnectionKey, | ||
| 18 | } from "../connection/githubRoutingPermissions.ts"; | ||
| 19 | import type { ConnectionCatalogEntry } from "../connection/catalog.ts"; | ||
| 20 | import { ConnectionProfileStore } from "../connection/profileStore.ts"; | ||
| 21 | import { | ||
| 22 | request, | ||
| 23 | EnvironmentRpcUnavailableError, | ||
| 24 | type EnvironmentRpcInput, | ||
| 25 | type EnvironmentUnaryRpcTag, | ||
| 26 | } from "../rpc/client.ts"; | ||
| 27 | |||
| 28 | const 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 | ]); | ||
| 38 | const 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 | ]); | ||
| 50 | const isRef = Schema.is(PullRequestRef); | ||
| 51 | const isInvalidation = Schema.is(PullRequestInvalidateInput); | ||
| 52 | interface RoutedRead { | ||
| 53 | origin: EnvironmentId; | ||
| 54 | reference: PullRequestRef; | ||
| 55 | targets: Set<EnvironmentId>; | ||
| 56 | } | ||
| 57 | const routedReads = new WeakMap<EnvironmentRegistry["Service"], Map<string, RoutedRead>>(); | ||
| 58 | const isUnregistered = Schema.is(EnvironmentNotRegisteredError); | ||
| 59 | const encodeKey = Schema.encodeSync( | ||
| 60 | Schema.fromJsonString(Schema.Array(Schema.NullOr(Schema.String))), | ||
| 61 | ); | ||
| 62 | |||
| 63 | function 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. */ | ||
| 79 | function 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 | |||
| 92 | const 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 | |||
| 122 | function 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 | |||
| 131 | const 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. */ | ||
| 151 | export 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 @@ |
| 1 | import { EnvironmentId, ProjectId, WS_METHODS, type PullRequestStack } from "@t3tools/contracts"; | 1 | import { |
| 2 | EnvironmentId, | ||
| 3 | ProjectId, | ||
| 4 | PullRequestOperationError, | ||
| 5 | WS_METHODS, | ||
| 6 | type PullRequestStack, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 2 | import { expect, it } from "@effect/vitest"; | 8 | import { expect, it } from "@effect/vitest"; |
| 3 | import * as Data from "effect/Data"; | 9 | import * as Data from "effect/Data"; |
| 4 | import * as Effect from "effect/Effect"; | 10 | import * 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"; |
| 12 | 18 | ||
| 13 | import { | 19 | import { |
| 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"; |
| 19 | import * as EnvironmentRegistry from "../connection/registry.ts"; | 27 | import * as EnvironmentRegistry from "../connection/registry.ts"; |
| 28 | import { SshConnectionProfile, type ConnectionCatalogEntry } from "../connection/catalog.ts"; | ||
| 29 | import { ConnectionProfileStore } from "../connection/profileStore.ts"; | ||
| 20 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; | 30 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; |
| 21 | import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; | 31 | import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; |
| 22 | import type { RpcSession } from "../rpc/session.ts"; | 32 | import type { RpcSession } from "../rpc/session.ts"; |
| ... | @@ -26,9 +36,200 @@ import { | ... | @@ -26,9 +36,200 @@ import { |
| 26 | } from "./pullRequests.ts"; | 36 | } from "./pullRequests.ts"; |
| 27 | import { PullRequestDiffLoader } from "./pullRequestDiffHttp.ts"; | 37 | import { PullRequestDiffLoader } from "./pullRequestDiffHttp.ts"; |
| 28 | import { executeAtomQuery } from "./runtime.ts"; | 38 | import { executeAtomQuery } from "./runtime.ts"; |
| 39 | import { createPullRequestRouter } from "./pullRequestRouting.ts"; | ||
| 40 | import { GitHubRoutingPermissions } from "../connection/githubRoutingPermissions.ts"; | ||
| 41 | |||
| 42 | const trustedRouting = { | ||
| 43 | get: () => Effect.succeed("read-write" as const), | ||
| 44 | changes: Stream.empty, | ||
| 45 | set: () => Effect.void, | ||
| 46 | forget: () => Effect.void, | ||
| 47 | }; | ||
| 29 | 48 | ||
| 30 | class MutationRefused extends Data.TaggedError("MutationRefused") {} | 49 | class MutationRefused extends Data.TaggedError("MutationRefused") {} |
| 31 | 50 | ||
| 51 | for (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 | |||
| 32 | const TARGET = new PrimaryConnectionTarget({ | 233 | const 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({ |
| 38 | 239 | ||
| 39 | function session(client: WsRpcProtocolClient): RpcSession { | 240 | function 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 | } |
| 49 | 254 | ||
| 50 | const makeTestRuntime = Effect.fn("makeTestRuntime")(function* (client: WsRpcProtocolClient) { | 255 | const 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 | }); |
| 91 | 338 | ||
| 339 | for (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 | |||
| 402 | for (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 | |||
| 516 | for (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 | |||
| 560 | for (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 | |||
| 629 | it.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 | |||
| 676 | it.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 | |||
| 92 | it.effect("keeps concurrent diff file reads on different hosts separate", () => | 757 | it.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"; |
| 24 | import { createPullRequestRouter } from "./pullRequestRouting.ts"; | ||
| 24 | import { PullRequestDiffLoader } from "./pullRequestDiffHttp.ts"; | 25 | import { PullRequestDiffLoader } from "./pullRequestDiffHttp.ts"; |
| 25 | import type { EnvironmentRegistry } from "../connection/registry.ts"; | 26 | import type { EnvironmentRegistry } from "../connection/registry.ts"; |
| 26 | import { EnvironmentSupervisor } from "../connection/supervisor.ts"; | 27 | import { 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"; |
| 11 | import { EnvironmentNotRegisteredError, EnvironmentRegistry } from "../connection/registry.ts"; | 11 | import { EnvironmentNotRegisteredError, EnvironmentRegistry } from "../connection/registry.ts"; |
| 12 | import { | 12 | import { |
| 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>( |
| 588 | 590 | ||
| 589 | export function createEnvironmentCommand<R, ER, Input, A, E>( | 591 | export 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 | } |
| 630 | 645 | ||
| ... | @@ -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; |
| 643 | export const PullRequestRef = Schema.Struct({ | 643 | export 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 | }); |
| 649 | export type PullRequestRef = typeof PullRequestRef.Type; | 653 | export type PullRequestRef = typeof PullRequestRef.Type; |
| 650 | 654 | ||
| 655 | /** Account discovery never needs the originating project's private repository metadata. */ | ||
| 656 | export const PullRequestRoutingIdentityInput = Schema.Struct({ | ||
| 657 | host: TrimmedNonEmptyString, | ||
| 658 | }); | ||
| 659 | export type PullRequestRoutingIdentityInput = typeof PullRequestRoutingIdentityInput.Type; | ||
| 660 | |||
| 661 | export const PullRequestRoutingIdentityResult = Schema.Struct({ | ||
| 662 | accountId: TrimmedNonEmptyString, | ||
| 663 | host: TrimmedNonEmptyString, | ||
| 664 | provider: Schema.Literal("github"), | ||
| 665 | viewer: TrimmedNonEmptyString, | ||
| 666 | }); | ||
| 667 | export type PullRequestRoutingIdentityResult = typeof PullRequestRoutingIdentityResult.Type; | ||
| 668 | |||
| 669 | export const PullRequestRoutingResult = Schema.Struct({ | ||
| 670 | accountId: TrimmedNonEmptyString, | ||
| 671 | host: TrimmedNonEmptyString, | ||
| 672 | provider: SourceControlProviderKind, | ||
| 673 | viewer: TrimmedNonEmptyString, | ||
| 674 | projectTitle: TrimmedNonEmptyString, | ||
| 675 | workspaceRoot: TrimmedNonEmptyString, | ||
| 676 | }); | ||
| 677 | export type PullRequestRoutingResult = typeof PullRequestRoutingResult.Type; | ||
| 678 | |||
| 651 | export const PullRequestLinkedThreadsResult = Schema.Struct({ | 679 | export 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 | }); |
| 674 | 679 | ||
| 680 | const WsPullRequestsRoutingRpc = Rpc.make(WS_METHODS.pullRequestsRouting, { | ||
| 681 | payload: PullRequestRef, | ||
| 682 | success: PullRequestRoutingResult, | ||
| 683 | error: PullRequestRpcError, | ||
| 684 | }); | ||
| 685 | |||
| 686 | const WsPullRequestsRoutingIdentityRpc = Rpc.make(WS_METHODS.pullRequestsRoutingIdentity, { | ||
| 687 | payload: PullRequestRoutingIdentityInput, | ||
| 688 | success: PullRequestRoutingIdentityResult, | ||
| 689 | error: PullRequestRpcError, | ||
| 690 | }); | ||
| 691 | |||
| 675 | const WsPullRequestsSummaryRpc = Rpc.make(WS_METHODS.pullRequestsSummary, { | 692 | const 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, |