diff --git a/apps/mobile/src/features/projects/AddProjectScreen.logic.test.ts b/apps/mobile/src/features/projects/AddProjectScreen.logic.test.ts index 3464bfda2604..9443cb082615 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.logic.test.ts +++ b/apps/mobile/src/features/projects/AddProjectScreen.logic.test.ts @@ -2,7 +2,13 @@ import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connect import { EnvironmentId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { resolveAddProjectEnvironment } from "./AddProjectScreen.logic"; +import { + buildRepositorySearchTarget, + buildSearchedRepositoryDestination, + groupRepositorySearchResults, + repositorySearchEmptyState, + resolveAddProjectEnvironment, +} from "./AddProjectScreen.logic"; const ENVIRONMENT_A = EnvironmentId.make("environment-a"); const ENVIRONMENT_B = EnvironmentId.make("environment-b"); @@ -39,3 +45,120 @@ describe("resolveAddProjectEnvironment", () => { ).toBe(ENVIRONMENT_B); }); }); + +describe("buildRepositorySearchTarget", () => { + const settled = { + environmentId: ENVIRONMENT_A, + provider: "github" as const, + }; + + it("fires no request below the minimum query length", () => { + expect(buildRepositorySearchTarget({ ...settled, query: "t", debouncedQuery: "t" })).toBeNull(); + }); + + it("subscribes once the query is long enough and has settled", () => { + expect( + buildRepositorySearchTarget({ ...settled, query: " t3 ", debouncedQuery: "t3" }), + ).toEqual({ + environmentId: ENVIRONMENT_A, + input: { provider: "github", query: "t3" }, + }); + }); + + it("blanks results while the query is still settling", () => { + expect( + buildRepositorySearchTarget({ ...settled, query: "t3co", debouncedQuery: "t3" }), + ).toBeNull(); + }); +}); + +describe("buildSearchedRepositoryDestination", () => { + it("navigates with the selected repository instead of looking it up again", () => { + expect( + buildSearchedRepositoryDestination({ + environmentId: ENVIRONMENT_A, + source: "github", + result: { + nameWithOwner: "t3dotgg/t3code", + url: "https://github.com/t3dotgg/t3code", + sshUrl: "git@github.com:t3dotgg/t3code.git", + ownedByViewer: true, + }, + }), + ).toEqual({ + environmentId: ENVIRONMENT_A, + source: "github", + remoteUrl: "https://github.com/t3dotgg/t3code", + repositoryTitle: "t3dotgg/t3code", + repositoryName: "t3code", + }); + }); +}); + +describe("groupRepositorySearchResults", () => { + function result(nameWithOwner: string, ownedByViewer: boolean) { + return { + nameWithOwner, + url: `https://github.com/${nameWithOwner}`, + sshUrl: `git@github.com:${nameWithOwner}.git`, + ownedByViewer, + }; + } + + it("keeps the server ranking within a group and drops groups with no rows", () => { + expect( + groupRepositorySearchResults( + [result("me/one", true), result("other/two", false), result("me/three", true)], + "github", + ), + ).toEqual([ + { + key: "owned", + label: "Your repositories", + results: [result("me/one", true), result("me/three", true)], + }, + { key: "other", label: "GitHub", results: [result("other/two", false)] }, + ]); + expect(groupRepositorySearchResults([result("me/one", true)], "github")).toEqual([ + { key: "owned", label: "Your repositories", results: [result("me/one", true)] }, + ]); + }); +}); + +describe("repositorySearchEmptyState", () => { + const settled = { supported: true, error: null, isPending: false, canSearch: true } as const; + + it("shows the ordinary empty state when a supported provider returns nothing", () => { + expect(repositorySearchEmptyState({ source: "github", ...settled })).toBe( + "No repositories match. Press Enter to look up the exact path.", + ); + }); + + it("points at the exact-path input when the provider cannot search", () => { + expect(repositorySearchEmptyState({ source: "github", ...settled, supported: false })).toBe( + "Search is unavailable for GitHub. Enter owner/repo and press Enter.", + ); + }); + + it("reports a failed search the same way, since the way out is the same", () => { + expect(repositorySearchEmptyState({ source: "github", ...settled, error: "gh exited 1" })).toBe( + "Search is unavailable for GitHub. Enter owner/repo and press Enter.", + ); + }); + + it("expresses loading as a string, because the list has no spinner", () => { + expect(repositorySearchEmptyState({ source: "github", ...settled, isPending: true })).toBe( + "Searching repositories…", + ); + }); + + it("says nothing before the query is long enough, since the placeholder already prompts", () => { + expect( + repositorySearchEmptyState({ source: "github", ...settled, canSearch: false }), + ).toBeNull(); + }); + + it("leaves the Git URL source alone", () => { + expect(repositorySearchEmptyState({ source: "url", ...settled })).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/projects/AddProjectScreen.logic.ts b/apps/mobile/src/features/projects/AddProjectScreen.logic.ts index b208a1719aba..f318228bd22b 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.logic.ts +++ b/apps/mobile/src/features/projects/AddProjectScreen.logic.ts @@ -1,6 +1,18 @@ -import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects"; +import { + addProjectRemoteSourceLabel, + addProjectRemoteSourcePathHint, + canCreateProjectInEnvironment, + getCloneDirectoryName, + getDefaultCloneUrl, + type AddProjectRemoteProviderKind, + type AddProjectRemoteSource, +} from "@t3tools/client-runtime/operations/projects"; import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; -import type { EnvironmentId } from "@t3tools/contracts"; +import type { + EnvironmentId, + SourceControlRepositorySearchInput, + SourceControlRepositorySearchResult, +} from "@t3tools/contracts"; export function resolveAddProjectEnvironment< T extends { @@ -24,3 +36,104 @@ export function resolveAddProjectEnvironment< ) ?? null ); } + +export const REPOSITORY_SEARCH_DEBOUNCE_MS = 200; +export const REPOSITORY_SEARCH_MIN_QUERY_LENGTH = 2; + +/** + * Target for the repository search query, or null to leave it unsubscribed. Null below the minimum + * length keeps short prefixes off the wire, and null while the typed query still differs from the + * debounced one blanks the list rather than leaving a previous owner prefix's matches on screen. + */ +export function buildRepositorySearchTarget(input: { + readonly environmentId: EnvironmentId | null; + readonly provider: AddProjectRemoteProviderKind | null; + readonly query: string; + readonly debouncedQuery: string; +}): { + readonly environmentId: EnvironmentId; + readonly input: SourceControlRepositorySearchInput; +} | null { + const query = input.query.trim(); + if (input.environmentId === null || input.provider === null) return null; + if (query.length < REPOSITORY_SEARCH_MIN_QUERY_LENGTH) return null; + if (query !== input.debouncedQuery.trim()) return null; + return { + environmentId: input.environmentId, + input: { provider: input.provider, query }, + }; +} + +/** A searched row already carries both clone URLs, so selecting one skips the lookup round trip. */ +export function buildSearchedRepositoryDestination(input: { + readonly environmentId: EnvironmentId; + readonly source: AddProjectRemoteProviderKind; + readonly result: SourceControlRepositorySearchResult; +}): { + readonly environmentId: EnvironmentId; + readonly source: AddProjectRemoteProviderKind; + readonly remoteUrl: string; + readonly repositoryTitle: string; + readonly repositoryName: string; +} { + return { + environmentId: input.environmentId, + source: input.source, + remoteUrl: getDefaultCloneUrl({ + provider: input.source, + url: input.result.url, + sshUrl: input.result.sshUrl, + }), + repositoryTitle: input.result.nameWithOwner, + repositoryName: getCloneDirectoryName(input.result.nameWithOwner), + }; +} + +export interface RepositorySearchGroup { + readonly key: string; + readonly label: string; + readonly results: ReadonlyArray; +} + +/** + * Splits results into the two rendered groups. Ranking, the 20-result cap, and description + * truncation all happen server-side, so group membership is the only client decision left. + */ +export function groupRepositorySearchResults( + results: ReadonlyArray, + source: AddProjectRemoteProviderKind, +): ReadonlyArray { + const owned = results.filter((result) => result.ownedByViewer); + const others = results.filter((result) => !result.ownedByViewer); + const groups: RepositorySearchGroup[] = []; + if (owned.length > 0) { + groups.push({ key: "owned", label: "Your repositories", results: owned }); + } + if (others.length > 0) { + groups.push({ key: "other", label: addProjectRemoteSourceLabel(source), results: others }); + } + return groups; +} + +/** + * The one line rendered under the input when the search has no rows to show. Both empty successes + * stay here rather than becoming errors: a paused rate-limit circuit answers `supported: true` with + * no results and gets the ordinary empty state, while a provider that cannot search answers + * `supported: false` and gets the affordance pointing back at the exact-path input. Loading is a + * string because this screen has no spinner for the list. + */ +export function repositorySearchEmptyState(input: { + readonly source: AddProjectRemoteSource; + readonly supported: boolean; + readonly error: string | null; + readonly isPending: boolean; + readonly canSearch: boolean; +}): string | null { + if (input.source === "url") return null; + if (!input.canSearch) return null; + if (!input.supported || input.error !== null) { + return `Search is unavailable for ${addProjectRemoteSourceLabel(input.source)}. Enter ${addProjectRemoteSourcePathHint(input.source)} and press Enter.`; + } + if (input.isPending) return "Searching repositories…"; + return "No repositories match. Press Enter to look up the exact path."; +} diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index b48c7a0bdd94..f6415c296c1a 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -31,6 +31,10 @@ import { inferProjectTitleFromPath, isWindowsPlatform, } from "@t3tools/client-runtime/state/projects"; +import { + resolveRepositorySearchAnswer, + type RepositorySearchAnswer, +} from "@t3tools/client-runtime/state/source-control"; import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts"; import { CommonActions, StackActions, useNavigation } from "@react-navigation/native"; import { SymbolView } from "../../components/AppSymbol"; @@ -47,6 +51,7 @@ import { useProjects, useServerConfigs } from "../../state/entities"; import { filesystemEnvironment } from "../../state/filesystem"; import { projectEnvironment } from "../../state/projects"; import { useEnvironmentQuery } from "../../state/query"; +import { useDebouncedValue } from "../../state/queries"; import { sourceControlEnvironment } from "../../state/sourceControl"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { ErrorBanner } from "../../components/ErrorBanner"; @@ -60,7 +65,15 @@ import { useRemoteEnvironmentRuntime, useSavedRemoteConnections, } from "../../state/use-remote-environment-registry"; -import { resolveAddProjectEnvironment } from "./AddProjectScreen.logic"; +import { + buildRepositorySearchTarget, + buildSearchedRepositoryDestination, + groupRepositorySearchResults, + repositorySearchEmptyState, + resolveAddProjectEnvironment, + REPOSITORY_SEARCH_DEBOUNCE_MS, + REPOSITORY_SEARCH_MIN_QUERY_LENGTH, +} from "./AddProjectScreen.logic"; interface EnvironmentOption { readonly environmentId: EnvironmentId; @@ -644,6 +657,102 @@ function useEnvironmentFromParam( return resolveAddProjectEnvironment(environmentOptions, environmentId); } +function RepositorySearchResults(props: { + readonly environment: EnvironmentOption; + readonly source: AddProjectRemoteSource; + readonly query: string; + /** Called before navigating so the parent can invalidate a pending exact-path lookup. */ + readonly onSelectResult: () => void; +}) { + const navigation = useNavigation(); + const iconColor = useThemeColor("--color-icon"); + const provider = addProjectRemoteSourceProvider(props.source); + const debouncedQuery = useDebouncedValue(props.query, REPOSITORY_SEARCH_DEBOUNCE_MS); + const searchTarget = useMemo( + () => + buildRepositorySearchTarget({ + environmentId: props.environment.environmentId, + provider, + query: props.query, + debouncedQuery, + }), + [debouncedQuery, props.environment.environmentId, props.query, provider], + ); + // A null target leaves the query unsubscribed, so a short prefix never reaches the wire and a + // still-settling query renders blank instead of keeping the previous prefix's matches on screen. + const searchState = useEnvironmentQuery( + searchTarget === null ? null : sourceControlEnvironment.repositorySearch(searchTarget), + ); + const canSearch = + provider !== null && props.query.trim().length >= REPOSITORY_SEARCH_MIN_QUERY_LENGTH; + const groups = useMemo( + () => + provider === null + ? [] + : groupRepositorySearchResults(searchState.data?.results ?? [], provider), + [provider, searchState.data?.results], + ); + // Sticky per environment+provider, so a provider already known unsupported keeps its exact-path + // affordance instead of flashing "Searching repositories…" on every keystroke's fresh atom. + const answerMemoryRef = useRef | null>(null); + answerMemoryRef.current ??= new Map(); + const answer = resolveRepositorySearchAnswer({ + memory: answerMemoryRef.current, + environmentId: props.environment.environmentId, + provider, + canSearch, + data: searchState.data, + error: searchState.error, + }); + const emptyStateMessage = repositorySearchEmptyState({ + source: props.source, + supported: answer.supported, + error: answer.error, + isPending: canSearch && (searchTarget === null || searchState.isPending), + canSearch, + }); + + if (provider === null || groups.length === 0) { + return emptyStateMessage === null ? null : ( + {emptyStateMessage} + ); + } + + return ( + <> + {groups.map((group) => ( + + {group.label} + + {group.results.map((result, index) => ( + } + isFirst={index === 0} + onPress={() => { + props.onSelectResult(); + navigation.dispatch( + StackActions.push( + "AddProjectDestination", + buildSearchedRepositoryDestination({ + environmentId: props.environment.environmentId, + source: provider, + result, + }), + ), + ); + }} + /> + ))} + + + ))} + + ); +} + export function AddProjectRepositoryScreen(props: { readonly environmentId?: string | string[]; readonly source?: string | string[]; @@ -657,6 +766,10 @@ export function AddProjectRepositoryScreen(props: { const [repositoryInput, setRepositoryInput] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); + // Bumped whenever a competing action supersedes a pending exact-path lookup (selecting a search + // result). The continuation bails when its captured value is stale. isFocused alone is not + // enough: popping the pushed destination re-focuses this screen while the lookup still settles. + const lookupGenerationRef = useRef(0); const lookupRepository = useCallback(async () => { if (!environment || repositoryInput.trim().length === 0 || isSubmitting) return; @@ -678,6 +791,7 @@ export function AddProjectRepositoryScreen(props: { return; } + const generation = ++lookupGenerationRef.current; const result = await lookupRepositoryQuery({ environmentId: environment.environmentId, input: { @@ -685,6 +799,13 @@ export function AddProjectRepositoryScreen(props: { repository: repositoryInput.trim(), }, }); + // A stale lookup must not push a destination the user did not ask for: the generation catches + // a search result selected while it was pending (even after popping back here), and the focus + // check catches this screen no longer being current (popped, or something else on top). + if (generation !== lookupGenerationRef.current || !navigation.isFocused()) { + setIsSubmitting(false); + return; + } if (AsyncResult.isFailure(result)) { setError(errorMessage(Cause.squash(result.cause))); } else { @@ -727,6 +848,14 @@ export function AddProjectRepositoryScreen(props: { onPress={() => void lookupRepository()} loading={isSubmitting} /> + { + lookupGenerationRef.current += 1; + }} + /> ) : ( diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 28ceac4cec99..b578dffc62a8 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -74,6 +74,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.pullRequestsReviewerCandidates]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsRequestReviewers]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope, + [WS_METHODS.sourceControlSearchRepositories]: AuthOrchestrationReadScope, [WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope, [WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope, diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 9e2cf15ecb72..679b27c4f871 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -562,6 +562,9 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { cwd: input.cwd, args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"], }).pipe(Effect.map((result) => JSON.parse(result.stdout))), + // GitManager never searches repositories; the fake only has to satisfy + // the service interface. + searchRepositories: () => Effect.succeed([]), createRepository: (input) => Effect.fail( new GitHubCli.GitHubCliCommandError({ diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index d5bebe3d5000..77be3c1675f9 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -80,6 +80,7 @@ import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; +import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; import * as SourceControlRateLimit from "./sourceControl/SourceControlRateLimit.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; @@ -452,6 +453,15 @@ const PullRequestServiceLive = PullRequestService.layer.pipe( Layer.provide(VcsProcess.layer), ); +// The websocket route resolves this once and hands the same instance to every +// connection, so provider CLI caches and rate-limit circuits are shared by all +// clients (GitHub's search quota is 30 requests a minute). +const SourceControlDiscoveryLive = SourceControlDiscovery.layer.pipe( + Layer.provide(SourceControlProviderRegistryLayerLive), + Layer.provide(SourceControlRateLimit.layer), + Layer.provide(VcsProcess.layer), +); + export const makeRoutesLayer = Layer.mergeAll( Layer.mergeAll( HttpApiBuilder.layer(EnvironmentHttpApi).pipe( @@ -475,6 +485,7 @@ export const makeRoutesLayer = Layer.mergeAll( Layer.provide(PullRequestServiceLive), Layer.provide(PreviewAutomationBroker.layer), Layer.provide(ServerSelfUpdate.layer), + Layer.provide(SourceControlDiscoveryLive), Layer.provide(commandReadinessLayer), Layer.provide(browserApiCorsLayer), Layer.provide(httpCompressionLayer), diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts index 21db25e79912..836dfc7d3599 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts @@ -1,8 +1,9 @@ -import { assert, it } from "@effect/vitest"; +import { assert, expect, it, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts"; @@ -132,3 +133,19 @@ it.effect("uses Azure CLI repository detection for default branch lookup", () => assert.strictEqual(cwdInput, "/repo"); }), ); + +it.effect("reports repository search as unsupported without running an Azure CLI command", () => + Effect.gen(function* () { + const run = vi.fn(); + const provider = yield* AzureDevOpsSourceControlProvider.make.pipe( + Effect.provide( + AzureDevOpsCli.layer.pipe(Layer.provide(Layer.mock(VcsProcess.VcsProcess)({ run }))), + ), + ); + + const output = yield* provider.searchRepositories({ cwd: "/repo", query: "t3code" }); + + assert.deepStrictEqual(output, { supported: false, results: [] }); + expect(run).not.toHaveBeenCalled(); + }), +); diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index 2f147452f9ec..1791ae4c4c74 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -173,6 +173,9 @@ export const make = Effect.gen(function* () { }), ), ), + // Repository search is GitHub only for now. Azure DevOps answers as data rather than + // failing, so a search-as-you-type caller renders "not supported" instead of an error. + searchRepositories: () => Effect.succeed({ supported: false, results: [] }), createRepository: (input) => azure.createRepository(input).pipe( Effect.mapError( diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts index eeb4c8fbdd2a..ae04d992e378 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts @@ -1,4 +1,4 @@ -import { assert, it } from "@effect/vitest"; +import { assert, expect, it, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -166,3 +166,15 @@ it.effect("uses Bitbucket API repository detection for default branch lookup", ( assert.strictEqual(cwdInput, "/repo"); }), ); + +it.effect("reports repository search as unsupported without calling the Bitbucket API", () => + Effect.gen(function* () { + const request = vi.fn(); + const provider = yield* makeProvider({ request }); + + const output = yield* provider.searchRepositories({ cwd: "/repo", query: "t3code" }); + + assert.deepStrictEqual(output, { supported: false, results: [] }); + expect(request).not.toHaveBeenCalled(); + }), +); diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts index 59fab76e5277..11a4fb22b7ce 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts @@ -125,6 +125,9 @@ export const make = Effect.gen(function* () { }), ), ), + // Repository search is GitHub only for now. Bitbucket answers as data rather than + // failing, so a search-as-you-type caller renders "not supported" instead of an error. + searchRepositories: () => Effect.succeed({ supported: false, results: [] }), createRepository: (input) => bitbucket.createRepository(input).pipe( Effect.mapError( diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 964ed3d021c1..79c2c36820e4 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -1,12 +1,17 @@ import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts"; +import * as TestClock from "effect/testing/TestClock"; + import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubCli from "./GitHubCli.ts"; +import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ exitCode: ChildProcessSpawner.ExitCode(0), @@ -26,6 +31,37 @@ const layer = GitHubCli.layer.pipe( ), ); +/** + * Repository search reads the clock and the GitHub circuit, so its tests build the + * CLI over a rate limiter they can pause rather than over `GitHubCli.layer`, which + * provides one of its own. + */ +const searchLayer = Layer.effect(GitHubCli.GitHubCli, GitHubCli.make).pipe( + Layer.provide(Layer.mock(VcsProcess.VcsProcess)({ run: mockRun })), + Layer.provideMerge(SourceControlRateLimit.layer), +); + +/** + * The real `GitHubCli.layer` built alongside an outer `SourceControlRateLimit`, + * the way `server.ts` builds it next to the pull-request subsystem's. Both sides + * reference the same module-level layer, so without `Layer.fresh` inside + * `GitHubCli.layer` Effect would memoize them into one shared circuit. + */ +const sharedCircuitLayer = GitHubCli.layer.pipe( + Layer.provide(Layer.mock(VcsProcess.VcsProcess)({ run: mockRun })), + Layer.provideMerge(SourceControlRateLimit.layer), +); + +/** The gh subcommand of every spawn so far, in order. */ +const spawnedSubcommands = () => + mockRun.mock.calls.map((call) => call[0].args.slice(0, 2).join(" ")); + +const ownedRepository = (nameWithOwner: string) => ({ + nameWithOwner, + url: `https://github.com/${nameWithOwner}`, + sshUrl: `git@github.com:${nameWithOwner}.git`, +}); + afterEach(() => { mockRun.mockReset(); }); @@ -292,6 +328,42 @@ describe("GitHubCli.layer", () => { }).pipe(Effect.provide(layer)), ); + it.effect("maps a repository resolution failure to GitHubRepositoryNotFoundError", () => + Effect.gen(function* () { + const exitError = VcsProcessExitError.fromProcessExit( + { + operation: "GitHubCli.execute", + command: "gh", + cwd: "/repo", + argumentCount: 5, + }, + { + exitCode: 1, + stderr: + "GraphQL: Could not resolve to a Repository with the name 'octocat/nope'. (repository)", + stderrTruncated: false, + }, + "repository-not-found", + ); + assert.strictEqual(exitError.detail, "Repository not found."); + mockRun.mockReturnValueOnce(Effect.fail(exitError)); + + const gh = yield* GitHubCli.GitHubCli; + const error = yield* Effect.flip( + gh.getRepositoryCloneUrls({ + cwd: "/repo", + repository: "octocat/nope", + }), + ); + + assert.strictEqual(error._tag, "GitHubRepositoryNotFoundError"); + assert.strictEqual( + error.detail, + "Repository not found. Check the owner/repo path and try again.", + ); + }).pipe(Effect.provide(layer)), + ); + it.effect("creates repositories and parses clone URLs from create output", () => Effect.gen(function* () { mockRun.mockReturnValueOnce( @@ -403,4 +475,563 @@ describe("GitHubCli.layer", () => { assert.notInclude(error.message, "user ID"); }).pipe(Effect.provide(layer)), ); + + it.effect("searches owned repositories and public repositories with the documented argv", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + description: "Minimal GUI for coding agents", + isFork: false, + isPrivate: false, + nameWithOwner: "octocat/codething-mvp", + sshUrl: "git@github.com:octocat/codething-mvp.git", + stargazerCount: 42, + url: "https://github.com/octocat/codething-mvp", + }, + { + description: "", + isFork: true, + isPrivate: true, + nameWithOwner: "octocat/dotfiles", + sshUrl: "git@github.com:octocat/dotfiles.git", + stargazerCount: 0, + url: "https://github.com/octocat/dotfiles", + }, + ]), + ), + ), + ); + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + description: "Another take on codething", + isFork: true, + fullName: "acme/codething-tools", + isPrivate: false, + stargazersCount: 900, + url: "https://github.com/acme/codething-tools", + }, + { + description: "", + isFork: false, + fullName: "octocat/codething-mvp", + isPrivate: false, + stargazersCount: 42, + url: "https://github.com/octocat/codething-mvp", + }, + ]), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const results = yield* gh.searchRepositories({ cwd: "/repo", query: "codething" }); + + expect(mockRun).toHaveBeenCalledTimes(2); + expect(mockRun).toHaveBeenNthCalledWith(1, { + operation: "GitHubCli.execute", + command: "gh", + args: [ + "repo", + "list", + "--json", + "nameWithOwner,url,sshUrl,stargazerCount,isFork,isPrivate,description", + "--limit", + "100", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + expect(mockRun).toHaveBeenNthCalledWith(2, { + operation: "GitHubCli.execute", + command: "gh", + args: [ + "search", + "repos", + "codething", + "--json", + "fullName,url,stargazersCount,isFork,description,isPrivate", + "--limit", + "20", + "--sort", + "stars", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + + // Owned repositories keep gh's `stargazerCount`/`sshUrl`; search results + // report `stargazersCount` and carry no ssh URL at all. + assert.deepStrictEqual(results, [ + { + nameWithOwner: "octocat/codething-mvp", + url: "https://github.com/octocat/codething-mvp", + sshUrl: "git@github.com:octocat/codething-mvp.git", + ownedByViewer: true, + description: "Minimal GUI for coding agents", + starCount: 42, + isFork: false, + isPrivate: false, + }, + { + nameWithOwner: "acme/codething-tools", + url: "https://github.com/acme/codething-tools", + sshUrl: "git@github.com:acme/codething-tools.git", + ownedByViewer: false, + description: "Another take on codething", + starCount: 900, + isFork: true, + isPrivate: false, + }, + ]); + }).pipe(Effect.provide(layer)), + ); + + it.effect("strips shell metacharacters and spaces from the query before it reaches argv", () => + Effect.gen(function* () { + mockRun.mockReturnValue(Effect.succeed(processOutput("[]"))); + + const gh = yield* GitHubCli.GitHubCli; + yield* gh.searchRepositories({ + cwd: "/repo", + query: "foo; rm -rf ~ && echo `id`", + }); + + const searchArgs = mockRun.mock.calls[1]?.[0]?.args; + assert.deepStrictEqual(searchArgs, [ + "search", + "repos", + "foorm-rfechoid", + "--json", + "fullName,url,stargazersCount,isFork,description,isPrivate", + "--limit", + "20", + "--sort", + "stars", + ]); + }).pipe(Effect.provide(layer)), + ); + + it.effect("never lets a query become a gh flag", () => + Effect.gen(function* () { + mockRun.mockReturnValue(Effect.succeed(processOutput("[]"))); + + const gh = yield* GitHubCli.GitHubCli; + yield* gh.searchRepositories({ cwd: "/repo", query: "--limit 9999" }); + + assert.strictEqual(mockRun.mock.calls[1]?.[0]?.args?.[2], "limit9999"); + }).pipe(Effect.provide(layer)), + ); + + it.effect("caps the query length", () => + Effect.gen(function* () { + mockRun.mockReturnValue(Effect.succeed(processOutput("[]"))); + + const gh = yield* GitHubCli.GitHubCli; + yield* gh.searchRepositories({ cwd: "/repo", query: "a".repeat(500) }); + + assert.strictEqual(mockRun.mock.calls[1]?.[0]?.args?.[2], "a".repeat(128)); + }).pipe(Effect.provide(layer)), + ); + + it.effect("runs no gh command when the query sanitizes to nothing", () => + Effect.gen(function* () { + const gh = yield* GitHubCli.GitHubCli; + const results = yield* gh.searchRepositories({ cwd: "/repo", query: "!!! ???" }); + + assert.deepStrictEqual(results, []); + expect(mockRun).not.toHaveBeenCalled(); + }).pipe(Effect.provide(layer)), + ); + + it.effect("dedups an owned repository against a search row that disagrees on casing", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + description: "Minimal GUI for coding agents", + isFork: false, + isPrivate: false, + nameWithOwner: "Octocat/CodeThing-MVP", + sshUrl: "git@github.com:Octocat/CodeThing-MVP.git", + stargazerCount: 42, + url: "https://github.com/Octocat/CodeThing-MVP", + }, + ]), + ), + ), + ); + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + description: "Minimal GUI for coding agents", + isFork: false, + fullName: "octocat/codething-mvp", + isPrivate: false, + stargazersCount: 42, + url: "https://github.com/octocat/codething-mvp", + }, + ]), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const results = yield* gh.searchRepositories({ cwd: "/repo", query: "codething" }); + + assert.deepStrictEqual( + results.map((result) => result.nameWithOwner), + ["Octocat/CodeThing-MVP"], + ); + }).pipe(Effect.provide(layer)), + ); + + it.effect("degrades to local matches when gh returns unusable search JSON", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput("[]"))); + mockRun.mockReturnValueOnce(Effect.succeed(processOutput("not json"))); + + const gh = yield* GitHubCli.GitHubCli; + const results = yield* gh.searchRepositories({ cwd: "/repo", query: "codething" }); + + expect(mockRun).toHaveBeenCalledTimes(2); + assert.deepStrictEqual(results, []); + }).pipe(Effect.provide(layer)), + ); + it.effect("reuses the owned repository listing for a minute of typing", () => + Effect.gen(function* () { + mockRun.mockReturnValue(Effect.succeed(processOutput("[]"))); + + const gh = yield* GitHubCli.GitHubCli; + yield* gh.searchRepositories({ cwd: "/repo", query: "codething" }); + yield* TestClock.adjust("59 seconds"); + yield* gh.searchRepositories({ cwd: "/repo", query: "codethings" }); + + assert.deepStrictEqual(spawnedSubcommands(), ["repo list", "search repos", "search repos"]); + + yield* TestClock.adjust("2 seconds"); + yield* gh.searchRepositories({ cwd: "/repo", query: "codethingy" }); + + assert.deepStrictEqual(spawnedSubcommands(), [ + "repo list", + "search repos", + "search repos", + "repo list", + "search repos", + ]); + }).pipe(Effect.provide(searchLayer)), + ); + + it.effect("serves a repeated query from the search cache", () => + Effect.gen(function* () { + mockRun.mockReturnValue(Effect.succeed(processOutput("[]"))); + + const gh = yield* GitHubCli.GitHubCli; + yield* gh.searchRepositories({ cwd: "/repo", query: "codething" }); + yield* TestClock.adjust("29 seconds"); + yield* gh.searchRepositories({ cwd: "/repo", query: "codething" }); + + assert.deepStrictEqual(spawnedSubcommands(), ["repo list", "search repos"]); + + yield* TestClock.adjust("2 seconds"); + yield* gh.searchRepositories({ cwd: "/repo", query: "codething" }); + + assert.deepStrictEqual(spawnedSubcommands(), ["repo list", "search repos", "search repos"]); + }).pipe(Effect.provide(searchLayer)), + ); + + it.effect("shares one gh request between concurrent identical searches", () => + Effect.gen(function* () { + const release = yield* Deferred.make(); + mockRun.mockImplementation(() => + Deferred.await(release).pipe(Effect.as(processOutput("[]"))), + ); + + const gh = yield* GitHubCli.GitHubCli; + const search = gh.searchRepositories({ cwd: "/repo", query: "codething" }); + const first = yield* Effect.forkChild(search, { startImmediately: true }); + const second = yield* Effect.forkChild(search, { startImmediately: true }); + + // Both callers are in flight, yet only one repo listing has spawned. + expect(mockRun).toHaveBeenCalledTimes(1); + + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(first); + yield* Fiber.join(second); + + assert.deepStrictEqual(spawnedSubcommands(), ["repo list", "search repos"]); + }).pipe(Effect.provide(searchLayer)), + ); + + it.effect("ages a cached listing from fetch completion, not fetch start", () => + Effect.gen(function* () { + mockRun.mockImplementation((input) => + input.args[0] === "repo" + ? Effect.sleep("30 seconds").pipe(Effect.as(processOutput("[]"))) + : Effect.succeed(processOutput("[]")), + ); + + const gh = yield* GitHubCli.GitHubCli; + const first = yield* Effect.forkChild( + gh.searchRepositories({ cwd: "/repo", query: "codething" }), + { startImmediately: true }, + ); + yield* TestClock.adjust("30 seconds"); + yield* Fiber.join(first); + + // 45 seconds after the listing landed, 75 after it was asked for. A + // listing stamped at fetch start would wrongly count as expired here. + yield* TestClock.adjust("45 seconds"); + yield* gh.searchRepositories({ cwd: "/repo", query: "codethings" }); + + assert.deepStrictEqual(spawnedSubcommands(), ["repo list", "search repos", "search repos"]); + }).pipe(Effect.provide(searchLayer)), + ); + + it.effect("never spends a search request on a two-character query", () => + Effect.gen(function* () { + mockRun.mockReturnValue(Effect.succeed(processOutput("[]"))); + + const gh = yield* GitHubCli.GitHubCli; + yield* gh.searchRepositories({ cwd: "/repo", query: "co" }); + + assert.deepStrictEqual(spawnedSubcommands(), ["repo list"]); + + yield* gh.searchRepositories({ cwd: "/repo", query: "cod" }); + + assert.deepStrictEqual(spawnedSubcommands(), ["repo list", "search repos"]); + }).pipe(Effect.provide(searchLayer)), + ); + + it.effect("asks GitHub only when the viewer's own repositories do not fill the list", () => + Effect.gen(function* () { + const ownedFor = (count: number) => + JSON.stringify( + Array.from({ length: count }, (_, index) => + ownedRepository(`octocat/codething-${index}`), + ), + ); + mockRun.mockImplementation((input) => + Effect.succeed( + processOutput( + input.args[0] === "search" ? "[]" : ownedFor(input.cwd === "/five" ? 5 : 4), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + yield* gh.searchRepositories({ cwd: "/five", query: "codething" }); + + assert.deepStrictEqual(spawnedSubcommands(), ["repo list"]); + + yield* gh.searchRepositories({ cwd: "/four", query: "codething" }); + + assert.deepStrictEqual(spawnedSubcommands(), ["repo list", "repo list", "search repos"]); + }).pipe(Effect.provide(searchLayer)), + ); + + it.effect("keeps local matches when the global search fails", () => + Effect.gen(function* () { + mockRun.mockImplementation((input) => + input.args[0] === "repo" + ? Effect.succeed( + processOutput(JSON.stringify([ownedRepository("octocat/codething-mvp")])), + ) + : Effect.fail( + new VcsProcessExitError({ + operation: "GitHubCli.execute", + command: "gh", + cwd: "/repo", + exitCode: 1, + failureKind: "rate-limited", + detail: "API rate limit exceeded.", + stderrLength: 82, + stderrTruncated: false, + }), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const results = yield* gh.searchRepositories({ cwd: "/repo", query: "codething" }); + + assert.deepStrictEqual(spawnedSubcommands(), ["repo list", "search repos"]); + assert.deepStrictEqual( + results.map((result) => result.nameWithOwner), + ["octocat/codething-mvp"], + ); + }).pipe(Effect.provide(searchLayer)), + ); + + it.effect("serves the stale cached search when a refresh fails", () => + Effect.gen(function* () { + let searchCalls = 0; + mockRun.mockImplementation((input) => { + if (input.args[0] === "repo") { + return Effect.succeed(processOutput("[]")); + } + searchCalls += 1; + return Effect.succeed( + processOutput( + searchCalls === 1 + ? `[{ "fullName": "acme/codething-tools", "url": "https://github.com/acme/codething-tools" }]` + : "not json", + ), + ); + }); + + const gh = yield* GitHubCli.GitHubCli; + yield* gh.searchRepositories({ cwd: "/repo", query: "codething" }); + + // The cached search rows are 31 seconds old, past their 30 second TTL, + // and the refresh comes back unusable. The stale rows still answer. + yield* TestClock.adjust("31 seconds"); + const results = yield* gh.searchRepositories({ cwd: "/repo", query: "codething" }); + + assert.deepStrictEqual(spawnedSubcommands(), ["repo list", "search repos", "search repos"]); + assert.deepStrictEqual( + results.map((result) => result.nameWithOwner), + ["acme/codething-tools"], + ); + }).pipe(Effect.provide(searchLayer)), + ); + + it.effect("runs no gh command while the GitHub circuit is open", () => + Effect.gen(function* () { + mockRun.mockReturnValue(Effect.succeed(processOutput("[]"))); + const limits = yield* SourceControlRateLimit.SourceControlRateLimit; + const key = { provider: "github" as const, host: "github.com" }; + const lease = yield* limits.check(key); + yield* limits.recordRateLimit({ ...key, lease }); + + const gh = yield* GitHubCli.GitHubCli; + const results = yield* gh.searchRepositories({ cwd: "/repo", query: "codething" }); + + assert.deepStrictEqual(results, []); + expect(mockRun).not.toHaveBeenCalled(); + }).pipe(Effect.provide(searchLayer)), + ); + + it.effect("opens the circuit when gh reports a GitHub rate limit", () => + Effect.gen(function* () { + mockRun.mockReturnValue( + Effect.fail( + new VcsProcessExitError({ + operation: "GitHubCli.execute", + command: "gh", + cwd: "/repo", + exitCode: 1, + failureKind: "rate-limited", + detail: "API rate limit exceeded.", + stderrLength: 82, + stderrTruncated: false, + }), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const error = yield* gh + .searchRepositories({ cwd: "/repo", query: "codething" }) + .pipe(Effect.flip); + assert.strictEqual(error._tag, "GitHubCliRateLimitError"); + + const limits = yield* SourceControlRateLimit.SourceControlRateLimit; + const paused = yield* Effect.flip(limits.check({ provider: "github", host: "github.com" })); + assert.strictEqual(paused._tag, "SourceControlRateLimitPausedError"); + }).pipe(Effect.provide(searchLayer)), + ); + + it.effect("keeps searching while another subsystem's github circuit is paused", () => + Effect.gen(function* () { + mockRun.mockReturnValue(Effect.succeed(processOutput("[]"))); + const limits = yield* SourceControlRateLimit.SourceControlRateLimit; + const key = { provider: "github" as const, host: "github.com" }; + const lease = yield* limits.check(key); + yield* limits.recordRateLimit({ ...key, lease }); + + const gh = yield* GitHubCli.GitHubCli; + const results = yield* gh.searchRepositories({ cwd: "/repo", query: "codething" }); + + assert.deepStrictEqual(results, []); + assert.deepStrictEqual(spawnedSubcommands(), ["repo list", "search repos"]); + }).pipe(Effect.provide(sharedCircuitLayer)), + ); + + it.effect("marks a searched repository the capped listing missed as the viewer's own", () => + Effect.gen(function* () { + mockRun.mockImplementation((input) => + input.args[0] === "repo" + ? Effect.succeed(processOutput(JSON.stringify([ownedRepository("octocat/unrelated")]))) + : Effect.succeed( + processOutput( + JSON.stringify([ + { + fullName: "Octocat/codething-mvp", + url: "https://github.com/octocat/codething-mvp", + }, + { + fullName: "acme/codething-tools", + url: "https://github.com/acme/codething-tools", + }, + ]), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const results = yield* gh.searchRepositories({ cwd: "/repo", query: "codething" }); + + assert.deepStrictEqual( + results.map((result) => [result.nameWithOwner, result.ownedByViewer]), + [ + ["Octocat/codething-mvp", true], + ["acme/codething-tools", false], + ], + ); + }).pipe(Effect.provide(layer)), + ); + + it.effect("omits an empty description instead of forwarding it", () => + Effect.gen(function* () { + mockRun.mockImplementation((input) => + input.args[0] === "repo" + ? Effect.succeed(processOutput("[]")) + : Effect.succeed( + processOutput( + JSON.stringify([ + { + fullName: "acme/codething-tools", + url: "https://github.com/acme/codething-tools", + description: "", + }, + ]), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const results = yield* gh.searchRepositories({ cwd: "/repo", query: "codething" }); + + assert.deepStrictEqual(results, [ + { + nameWithOwner: "acme/codething-tools", + url: "https://github.com/acme/codething-tools", + sshUrl: "git@github.com:acme/codething-tools.git", + ownedByViewer: false, + }, + ]); + }).pipe(Effect.provide(layer)), + ); }); diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 974574cbd20e..a59796f6d187 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -1,12 +1,18 @@ +import * as Cache from "effect/Cache"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; +import * as Ref from "effect/Ref"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { + NonNegativeInt, TrimmedNonEmptyString, + type SourceControlRepositorySearchResult, type SourceControlRepositoryVisibility, type VcsError, } from "@t3tools/contracts"; @@ -16,6 +22,7 @@ import { decodeGitHubPullRequestJson, decodeGitHubPullRequestListJson, } from "./gitHubPullRequests.ts"; +import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; const DEFAULT_TIMEOUT_MS = 30_000; @@ -77,6 +84,19 @@ export class GitHubPullRequestNotFoundError extends Schema.TaggedErrorClass()( + "GitHubRepositoryNotFoundError", + gitHubCliFailureFields, +) { + get detail(): string { + return "Repository not found. Check the owner/repo path and try again."; + } + + override get message(): string { + return `GitHub CLI failed in execute: ${this.detail}`; + } +} + export class GitHubCliCommandError extends Schema.TaggedErrorClass()( "GitHubCliCommandError", gitHubCliFailureFields, @@ -148,16 +168,31 @@ export class GitHubRepositoryDecodeError extends Schema.TaggedErrorClass()( + "GitHubRepositorySearchDecodeError", + gitHubCliDecodeFields, +) { + get detail(): string { + return "GitHub CLI returned invalid repository search JSON."; + } + + override get message(): string { + return `GitHub CLI failed in searchRepositories: ${this.detail}`; + } +} + export const GitHubCliError = Schema.Union([ GitHubCliUnavailableError, GitHubCliAuthenticationError, GitHubCliRateLimitError, GitHubPullRequestNotFoundError, + GitHubRepositoryNotFoundError, GitHubCliCommandError, GitHubPullRequestListDecodeError, GitHubChangeRequestListDecodeError, GitHubPullRequestDecodeError, GitHubRepositoryDecodeError, + GitHubRepositorySearchDecodeError, ]); export type GitHubCliError = typeof GitHubCliError.Type; @@ -190,6 +225,9 @@ export function fromVcsError( if (error.failureKind === "not-found") { return new GitHubPullRequestNotFoundError({ ...context, cause: error }); } + if (error.failureKind === "repository-not-found") { + return new GitHubRepositoryNotFoundError({ ...context, cause: error }); + } } return new GitHubCliCommandError({ ...context, cause: error }); @@ -241,6 +279,21 @@ export class GitHubCli extends Context.Service< readonly repository: string; }) => Effect.Effect; + /** + * Repositories matching a free-text query: the viewer's own repositories + * that contain the query, then public repositories GitHub search returns. + * The query is sanitized here, so no caller can widen what reaches argv. + * + * Built for a search-as-you-type field: the owned listing is cached per + * working directory, the global search runs only when it can earn its + * request, and both go through the GitHub circuit breaker. A paused circuit + * yields whatever is cached, or nothing, rather than an error. + */ + readonly searchRepositories: (input: { + readonly cwd: string; + readonly query: string; + }) => Effect.Effect, GitHubCliError>; + readonly createRepository: (input: { readonly cwd: string; readonly repository: string; @@ -323,8 +376,166 @@ function deriveRepositoryCloneUrlsFromCreateOutput( }; } +/** Longest query we hand to `gh`. Repository names are far shorter than this. */ +const SEARCH_QUERY_MAX_LENGTH = 128; + +/** + * The viewer's own repositories change on the scale of days and are matched + * locally, so one listing covers a whole typing session. + */ +const OWNED_REPOSITORIES_TTL_MS = 60_000; + +/** + * Search rows are cached per sanitized query: long enough to absorb backspacing + * and retyping, short enough that a repository created mid-session turns up. + */ +const SEARCHED_REPOSITORIES_TTL_MS = 30_000; + +/** + * GitHub allows 30 search requests a minute, far tighter than the 5000 an hour + * the core API allows, so a query has to say something before it costs one. + */ +const MIN_GLOBAL_SEARCH_QUERY_LENGTH = 3; + +/** + * Local matches from this count up already fill the visible part of the + * dropdown, so a global search would only add rows below the fold. + */ +const SUFFICIENT_LOCAL_MATCHES = 5; + +/** A fast typist produces one cache entry per keystroke; bound both caches. */ +const SEARCH_CACHE_CAPACITY = 64; + +/** Joins cwd and query into one cache key. NUL cannot appear in either part. */ +const SEARCH_KEY_SEPARATOR = String.fromCharCode(0); + +/** `gh` talks to github.com, and the circuit is keyed by provider and host. */ +const GITHUB_RATE_LIMIT_KEY = { provider: "github", host: "github.com" } as const; + +interface SearchCacheEntry { + readonly fetchedAt: number; + readonly value: A; +} + +/** Bounded insert-ordered map. The oldest fetch is evicted first. */ +function storeCacheEntry( + current: ReadonlyMap>, + key: string, + entry: SearchCacheEntry, +): ReadonlyMap> { + const next = new Map(current); + next.delete(key); + next.set(key, entry); + for (const oldest of next.keys()) { + if (next.size <= SEARCH_CACHE_CAPACITY) break; + next.delete(oldest); + } + return next; +} + +/** + * The search query is free user text, and the only client-supplied value in + * this file that reaches `gh` argv. Keep it to characters that can appear in an + * owner or repository name, drop leading dashes so it can never be read as a + * flag, and cap the length. Applied inside the service so callers cannot skip + * it. Exported so result ranking can normalize with the same rule the search + * actually ran under. + */ +export function sanitizeSearchQuery(query: string): string { + return query + .replace(/[^A-Za-z0-9._/-]/g, "") + .replace(/^-+/, "") + .slice(0, SEARCH_QUERY_MAX_LENGTH); +} + +/** `gh repo list` reports stars as `stargazerCount` and ships an ssh URL. */ +const RawGitHubOwnedRepositoriesSchema = Schema.Array( + Schema.Struct({ + nameWithOwner: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, + sshUrl: TrimmedNonEmptyString, + stargazerCount: Schema.optional(NonNegativeInt), + isFork: Schema.optional(Schema.Boolean), + isPrivate: Schema.optional(Schema.Boolean), + description: Schema.optional(Schema.NullOr(Schema.String)), + }), +); +const decodeRawGitHubOwnedRepositories = Schema.decodeEffect( + Schema.fromJsonString(RawGitHubOwnedRepositoriesSchema), +); +type RawOwnedRepository = Schema.Schema.Type[number]; + +/** + * `gh search repos` uses a different vocabulary from `gh repo list`: the name + * is `fullName`, stars are `stargazersCount`, and there is no ssh URL, so we + * derive one. `isFork` means the same thing in both. + */ +const RawGitHubSearchedRepositoriesSchema = Schema.Array( + Schema.Struct({ + fullName: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, + stargazersCount: Schema.optional(NonNegativeInt), + isFork: Schema.optional(Schema.Boolean), + isPrivate: Schema.optional(Schema.Boolean), + description: Schema.optional(Schema.NullOr(Schema.String)), + }), +); +const decodeRawGitHubSearchedRepositories = Schema.decodeEffect( + Schema.fromJsonString(RawGitHubSearchedRepositoriesSchema), +); +type RawSearchedRepository = Schema.Schema.Type[number]; + +function deriveSshUrl(nameWithOwner: string, url: string): string { + try { + return `git@${new URL(url).host}:${nameWithOwner}.git`; + } catch { + return `git@github.com:${nameWithOwner}.git`; + } +} + +/** gh reports a missing description as `null` or `""`; both mean absent. */ +function optionalDescription(value: string | null | undefined) { + return value !== undefined && value !== null && value !== "" ? { description: value } : {}; +} + +/** The owner segment of `owner/name`, lowercased the way GitHub compares logins. */ +function repositoryOwner(nameWithOwner: string): string { + const separator = nameWithOwner.indexOf("/"); + return (separator === -1 ? nameWithOwner : nameWithOwner.slice(0, separator)).toLowerCase(); +} + +function normalizeOwnedRepository(raw: RawOwnedRepository): SourceControlRepositorySearchResult { + return { + nameWithOwner: raw.nameWithOwner, + url: raw.url, + sshUrl: raw.sshUrl, + ownedByViewer: true, + ...optionalDescription(raw.description), + ...(raw.stargazerCount !== undefined ? { starCount: raw.stargazerCount } : {}), + ...(raw.isFork !== undefined ? { isFork: raw.isFork } : {}), + ...(raw.isPrivate !== undefined ? { isPrivate: raw.isPrivate } : {}), + }; +} + +function normalizeSearchedRepository( + raw: RawSearchedRepository, + viewerLogin: string | undefined, +): SourceControlRepositorySearchResult { + return { + nameWithOwner: raw.fullName, + url: raw.url, + sshUrl: deriveSshUrl(raw.fullName, raw.url), + ownedByViewer: viewerLogin !== undefined && repositoryOwner(raw.fullName) === viewerLogin, + ...optionalDescription(raw.description), + ...(raw.stargazersCount !== undefined ? { starCount: raw.stargazersCount } : {}), + ...(raw.isFork !== undefined ? { isFork: raw.isFork } : {}), + ...(raw.isPrivate !== undefined ? { isPrivate: raw.isPrivate } : {}), + }; +} + export const make = Effect.gen(function* () { const process = yield* VcsProcess.VcsProcess; + const limits = yield* SourceControlRateLimit.SourceControlRateLimit; const execute: GitHubCli["Service"]["execute"] = (input) => process @@ -339,6 +550,146 @@ export const make = Effect.gen(function* () { }) .pipe(Effect.mapError((error) => fromVcsError({ command: "gh", cwd: input.cwd }, error))); + const ownedRepositoriesCache = yield* Ref.make< + ReadonlyMap>> + >(new Map()); + const searchedRepositoriesCache = yield* Ref.make< + ReadonlyMap>> + >(new Map()); + + /** + * One gh request under the GitHub circuit breaker. `null` means the circuit is + * open and nothing ran, which is the caller's cue to serve whatever it already + * has. `request` is a thunk so an open circuit builds no command at all. + */ + const guarded = (request: () => Effect.Effect) => + limits.check(GITHUB_RATE_LIMIT_KEY).pipe( + Effect.flatMap((lease) => + request().pipe( + Effect.tap(() => limits.recordSuccess({ ...GITHUB_RATE_LIMIT_KEY, lease })), + Effect.tapError((error) => + error._tag === "GitHubCliRateLimitError" + ? limits.recordRateLimit({ ...GITHUB_RATE_LIMIT_KEY, lease }) + : Effect.void, + ), + ), + ), + Effect.catchTags({ SourceControlRateLimitPausedError: () => Effect.succeed(null) }), + ); + + /** Fresh means fetched within the TTL. A clock that stepped backward reads as stale. */ + const isFreshAt = (fetchedAt: number, now: number, ttlMs: number) => + now >= fetchedAt && now - fetchedAt < ttlMs; + + /** The viewer's repositories, at most one `gh repo list` per minute per cwd. */ + const fetchOwnedRepositories = (cwd: string) => + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + const cached = (yield* Ref.get(ownedRepositoriesCache)).get(cwd); + if (cached !== undefined && isFreshAt(cached.fetchedAt, now, OWNED_REPOSITORIES_TTL_MS)) { + return cached.value; + } + + const fetched = yield* guarded(() => + execute({ + cwd, + args: [ + "repo", + "list", + "--json", + "nameWithOwner,url,sshUrl,stargazerCount,isFork,isPrivate,description", + "--limit", + "100", + ], + }).pipe( + Effect.flatMap((output) => + decodeRawGitHubOwnedRepositories(output.stdout.trim()).pipe( + Effect.mapError( + (cause) => new GitHubRepositorySearchDecodeError({ command: "gh", cwd, cause }), + ), + ), + ), + ), + ); + if (fetched === null) { + return cached?.value ?? []; + } + + // Stamp completion, not start: a slow fetch must not age its own entry. + const fetchedAt = yield* Clock.currentTimeMillis; + yield* Ref.update(ownedRepositoriesCache, (current) => + storeCacheEntry(current, cwd, { fetchedAt, value: fetched }), + ); + return fetched; + }); + + /** Public repositories for one sanitized query, cached for a few keystrokes. */ + const fetchSearchedRepositories = (key: string) => + Effect.gen(function* () { + const separator = key.indexOf(SEARCH_KEY_SEPARATOR); + const cwd = key.slice(0, separator); + const query = key.slice(separator + 1); + const now = yield* Clock.currentTimeMillis; + const cached = (yield* Ref.get(searchedRepositoriesCache)).get(key); + if (cached !== undefined && isFreshAt(cached.fetchedAt, now, SEARCHED_REPOSITORIES_TTL_MS)) { + return cached.value; + } + + const fetched = yield* guarded(() => + execute({ + cwd, + args: [ + "search", + "repos", + query, + "--json", + "fullName,url,stargazersCount,isFork,description,isPrivate", + "--limit", + "20", + "--sort", + "stars", + ], + }).pipe( + Effect.flatMap((output) => + decodeRawGitHubSearchedRepositories(output.stdout.trim()).pipe( + Effect.mapError( + (cause) => new GitHubRepositorySearchDecodeError({ command: "gh", cwd, cause }), + ), + ), + ), + ), + ); + if (fetched === null) { + return cached?.value ?? []; + } + + // Stamp completion, not start: a slow fetch must not age its own entry. + const fetchedAt = yield* Clock.currentTimeMillis; + yield* Ref.update(searchedRepositoriesCache, (current) => + storeCacheEntry(current, key, { fetchedAt, value: fetched }), + ); + return fetched; + }); + + /** + * Zero time-to-live makes these caches pure in-flight shares: concurrent + * callers for one key await the same lookup, and the entry is dropped the + * moment it settles. Values live in the Ref caches above, which also answer + * with stale rows while the circuit is paused. + */ + const ownedRepositoriesInFlight = yield* Cache.makeWith(fetchOwnedRepositories, { + capacity: SEARCH_CACHE_CAPACITY, + timeToLive: () => Duration.zero, + }); + const ownedRepositories = (cwd: string) => Cache.get(ownedRepositoriesInFlight, cwd); + + const searchedRepositoriesInFlight = yield* Cache.makeWith(fetchSearchedRepositories, { + capacity: SEARCH_CACHE_CAPACITY, + timeToLive: () => Duration.zero, + }); + const searchedRepositories = (cwd: string, query: string) => + Cache.get(searchedRepositoriesInFlight, cwd + SEARCH_KEY_SEPARATOR + query); + return GitHubCli.of({ execute, listOpenPullRequests: (input) => @@ -432,6 +783,69 @@ export const make = Effect.gen(function* () { ), Effect.map(normalizeRepositoryCloneUrls), ), + searchRepositories: (input) => + Effect.gen(function* () { + const query = sanitizeSearchQuery(input.query); + if (query.length === 0) { + return []; + } + + // `gh repo list` takes no query, so the viewer's repositories are matched + // here against the cached listing. That is the common keystroke: no spawn. + const owned = yield* ownedRepositories(input.cwd); + const needle = query.toLowerCase(); + const localMatches = owned.filter((raw) => + raw.nameWithOwner.toLowerCase().includes(needle), + ); + + // `gh repo list` lists repositories under the viewer's own login, so any + // row names the viewer. The listing is capped at 100 rows, which makes + // membership in it under-report ownership; comparing owner segments does + // not, so a searched repository the capped listing missed still lands + // under the viewer's own group. + const viewerLogin = + owned[0] !== undefined ? repositoryOwner(owned[0].nameWithOwner) : undefined; + + // A failed global search degrades to the stale cached rows for this + // query, the same answer the paused circuit already gives, and to the + // local rows alone when nothing is cached. + const searched = + query.length >= MIN_GLOBAL_SEARCH_QUERY_LENGTH && + localMatches.length < SUFFICIENT_LOCAL_MATCHES + ? yield* searchedRepositories(input.cwd, query).pipe( + Effect.catch((error) => + Effect.logWarning("GitHub repository search failed; serving cached matches", { + error, + }).pipe( + Effect.flatMap(() => Ref.get(searchedRepositoriesCache)), + Effect.map( + (cache) => + cache.get(input.cwd + SEARCH_KEY_SEPARATOR + query)?.value ?? + ([] as ReadonlyArray), + ), + ), + ), + ) + : []; + + // Owned repositories come first, and one the viewer owns is never + // repeated by the public search below it. GitHub compares repository + // names case-insensitively, and the two commands can disagree on + // casing, so the dedup key is lowercased. + const results = localMatches.map(normalizeOwnedRepository); + const seen = new Set(results.map((result) => result.nameWithOwner.toLowerCase())); + + for (const raw of searched) { + const dedupKey = raw.fullName.toLowerCase(); + if (seen.has(dedupKey)) { + continue; + } + seen.add(dedupKey); + results.push(normalizeSearchedRepository(raw, viewerLogin)); + } + + return results; + }), createRepository: (input) => execute({ cwd: input.cwd, @@ -475,4 +889,15 @@ export const make = Effect.gen(function* () { }); }); -export const layer = Layer.effect(GitHubCli, make); +/** + * The circuit breaker is private to this layer. GitHub bills the search endpoint + * (30 requests a minute) separately from the core quota the pull-request + * subsystem tracks with its own `SourceControlRateLimit`, so the two circuits are + * deliberately independent rather than one pausing the other. `Layer.fresh` is + * what makes that true: the pull-request subsystem provides the same module-level + * `SourceControlRateLimit.layer`, and without it Effect would memoize both into + * one shared instance keyed identically by provider and host. + */ +export const layer = Layer.effect(GitHubCli, make).pipe( + Layer.provide(Layer.fresh(SourceControlRateLimit.layer)), +); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 1381271e6bbc..adbb71004b9b 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -1,14 +1,16 @@ -import { assert, it } from "@effect/vitest"; +import { assert, expect, it, vi } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { ChildProcessSpawner } from "effect/unstable/process"; +import type { SourceControlRepositorySearchResult } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubCli from "./GitHubCli.ts"; import { parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; +import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; const processResult = ( stdout: string, @@ -207,6 +209,171 @@ it.effect("creates GitHub PRs through provider-neutral input names", () => }), ); +const SEARCH_DESCRIPTION_CAP = 160; + +function searchResult( + overrides: Partial & { readonly nameWithOwner: string }, +): SourceControlRepositorySearchResult { + return { + url: `https://github.com/${overrides.nameWithOwner}`, + sshUrl: `git@github.com:${overrides.nameWithOwner}.git`, + ownedByViewer: false, + starCount: 0, + ...overrides, + }; +} + +it.effect("ranks, caps, and trims the repository search results it returns", () => + Effect.gen(function* () { + // Every adjacent pair below is decided by a different rule, so dropping or + // reordering any one of them changes this expectation. + const owned = searchResult({ + nameWithOwner: "mark/t3code-tools", + ownedByViewer: true, + starCount: 1, + description: "a".repeat(400), + }); + const ownedSubstring = searchResult({ + nameWithOwner: "mark/awesome-t3code", + ownedByViewer: true, + starCount: 9000, + }); + const prefixPopular = searchResult({ + nameWithOwner: "pingdotgg/t3code", + starCount: 5000, + description: "Short and untouched.", + }); + const prefixQuiet = searchResult({ nameWithOwner: "forks/t3code-mirror", starCount: 100 }); + const substringPopular = searchResult({ nameWithOwner: "legacy/old-t3code", starCount: 4000 }); + const filler = Array.from({ length: 20 }, (_unused, index) => + searchResult({ nameWithOwner: `filler/pack-t3code-${index}` }), + ); + + let searchInput: { readonly cwd: string; readonly query: string } | null = null; + const provider = yield* makeProvider({ + searchRepositories: (input) => { + searchInput = input; + return Effect.succeed([ + ...filler, + substringPopular, + prefixPopular, + ownedSubstring, + prefixQuiet, + owned, + ]); + }, + }); + + const output = yield* provider.searchRepositories({ cwd: "/repo", query: "t3code" }); + + assert.deepStrictEqual(searchInput, { cwd: "/repo", query: "t3code" }); + assert.strictEqual(output.supported, true); + assert.strictEqual(output.results.length, 20); + assert.deepStrictEqual( + output.results.slice(0, 5).map((result) => result.nameWithOwner), + [ + "mark/t3code-tools", + "mark/awesome-t3code", + "pingdotgg/t3code", + "forks/t3code-mirror", + "legacy/old-t3code", + ], + ); + assert.strictEqual(output.results[0]?.description, "a".repeat(SEARCH_DESCRIPTION_CAP)); + assert.strictEqual(output.results[2]?.description, "Short and untouched."); + }), +); + +it.effect("ranks a spaced query the way the search actually ran it", () => + Effect.gen(function* () { + // "t3 code" reaches gh as "t3code" after sanitizing, so ranking must use + // the sanitized form too: the exact-name match beats the popular substring. + const exact = searchResult({ nameWithOwner: "pingdotgg/t3code", starCount: 10 }); + const popularSubstring = searchResult({ + nameWithOwner: "acme/uses-t3code-inside", + starCount: 5000, + }); + const provider = yield* makeProvider({ + searchRepositories: () => Effect.succeed([popularSubstring, exact]), + }); + + const output = yield* provider.searchRepositories({ cwd: "/repo", query: "t3 code" }); + + assert.strictEqual(output.supported, true); + assert.deepStrictEqual( + output.results.map((result) => result.nameWithOwner), + ["pingdotgg/t3code", "acme/uses-t3code-inside"], + ); + }), +); + +it.effect("redacts search queries in provider errors while keeping the CLI cause", () => + Effect.gen(function* () { + const cause = new GitHubCli.GitHubRepositorySearchDecodeError({ + command: "gh", + cwd: "/repo", + cause: new Error("raw upstream detail that should remain in the cause"), + }); + const provider = yield* makeProvider({ + searchRepositories: () => Effect.fail(cause), + }); + + const error = yield* provider + .searchRepositories({ + cwd: "/repo", + query: "https://user:secret@github.com/pingdotgg/t3code?token=secret", + }) + .pipe(Effect.flip); + + assert.deepStrictEqual( + { + provider: error.provider, + operation: error.operation, + command: error.command, + cwd: error.cwd, + repository: error.repository, + detail: error.detail, + }, + { + provider: "github", + operation: "searchRepositories", + command: "gh", + cwd: "/repo", + repository: "https://github.com/pingdotgg/t3code", + detail: "GitHub CLI returned invalid repository search JSON.", + }, + ); + assert.strictEqual(error.cause, cause); + assert.equal(error.message.includes("raw upstream detail"), false); + }), +); + +// Search-as-you-type would raise one toast per keystroke if a paused circuit failed, +// so the whole path down to the process boundary has to answer with data instead. +it.effect("answers with empty results instead of an error while the GitHub circuit is open", () => + Effect.gen(function* () { + const run = vi.fn(); + const limits = yield* SourceControlRateLimit.SourceControlRateLimit; + const key = { provider: "github" as const, host: "github.com" }; + const lease = yield* limits.check(key); + yield* limits.recordRateLimit({ ...key, lease }); + + const provider = yield* GitHubSourceControlProvider.make.pipe( + Effect.provide( + Layer.effect(GitHubCli.GitHubCli, GitHubCli.make).pipe( + Layer.provide(Layer.mock(VcsProcess.VcsProcess)({ run })), + Layer.provide(Layer.succeed(SourceControlRateLimit.SourceControlRateLimit, limits)), + ), + ), + ); + + const output = yield* provider.searchRepositories({ cwd: "/repo", query: "codething" }); + + assert.deepStrictEqual(output, { supported: true, results: [] }); + expect(run).not.toHaveBeenCalled(); + }).pipe(Effect.provide(SourceControlRateLimit.layer)), +); + it("accepts active authenticated GitHub accounts when another account fails", () => { const auth = GitHubSourceControlProvider.discovery.parseAuth( processResult( diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 3dcc8ab826a6..36398e8b1bfe 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -6,6 +6,7 @@ import { SourceControlProviderError, type ChangeRequest, type ChangeRequestState, + type SourceControlRepositorySearchResult, } from "@t3tools/contracts"; import * as GitHubCli from "./GitHubCli.ts"; @@ -42,6 +43,70 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq }; } +/** One dropdown's worth of rows. Ranking already puts the useful ones first. */ +const MAX_SEARCH_RESULTS = 20; + +/** + * GitHub descriptions run to 350 characters and this payload is rebuilt on every + * keystroke, so descriptions are trimmed to about one list row before they cross + * the socket. Clients that want the full text read the repository itself. + */ +const MAX_SEARCH_RESULT_DESCRIPTION_LENGTH = 160; + +/** True when the query starts the repository name, or the whole `owner/name`. */ +function matchesSearchPrefix(result: SourceControlRepositorySearchResult, query: string): boolean { + if (query.length === 0) { + return false; + } + const nameWithOwner = result.nameWithOwner.toLowerCase(); + const name = nameWithOwner.slice(nameWithOwner.lastIndexOf("/") + 1); + return nameWithOwner.startsWith(query) || name.startsWith(query); +} + +/** Own repositories first, then prefix matches over substring matches, then most stars. */ +function compareSearchResults(query: string) { + return ( + left: SourceControlRepositorySearchResult, + right: SourceControlRepositorySearchResult, + ) => { + if (left.ownedByViewer !== right.ownedByViewer) { + return left.ownedByViewer ? -1 : 1; + } + const leftPrefix = matchesSearchPrefix(left, query); + const rightPrefix = matchesSearchPrefix(right, query); + if (leftPrefix !== rightPrefix) { + return leftPrefix ? -1 : 1; + } + return (right.starCount ?? 0) - (left.starCount ?? 0); + }; +} + +function trimSearchResultDescription( + result: SourceControlRepositorySearchResult, +): SourceControlRepositorySearchResult { + if ( + result.description === undefined || + result.description.length <= MAX_SEARCH_RESULT_DESCRIPTION_LENGTH + ) { + return result; + } + return { + ...result, + description: result.description.slice(0, MAX_SEARCH_RESULT_DESCRIPTION_LENGTH).trimEnd(), + }; +} + +function rankSearchResults( + results: ReadonlyArray, + query: string, +): ReadonlyArray { + // Rank against the query as it actually searched: "t3 code" matched as "t3code". + return [...results] + .sort(compareSearchResults(GitHubCli.sanitizeSearchQuery(query).toLowerCase())) + .slice(0, MAX_SEARCH_RESULTS) + .map(trimSearchResultDescription); +} + function parseGitHubAuth(input: SourceControlAuthProbeInput) { const output = combinedAuthOutput(input); const authStatus = parseGitHubAuthStatus(input.stdout); @@ -253,6 +318,28 @@ export const make = Effect.gen(function* () { input.repository, ), detail: error.detail, + // GitHubCliError details are compile-time constants, so they are + // safe to show the user in place of the generic fallback. + userDetail: error.detail, + cause: error, + }), + ), + ), + searchRepositories: (input) => + github.searchRepositories({ cwd: input.cwd, query: input.query }).pipe( + Effect.map((results) => ({ + supported: true, + results: rankSearchResults(results, input.query), + })), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "searchRepositories", + command: error.command, + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue(input.query), + detail: error.detail, cause: error, }), ), diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index 9a9fc3360247..0c5cce099a88 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -142,6 +142,7 @@ export class GitLabCliCommandError extends Schema.TaggedErrorClass + Effect.gen(function* () { + const run = vi.fn(); + const provider = yield* GitLabSourceControlProvider.make.pipe( + Effect.provide( + GitLabCli.layer.pipe(Layer.provide(Layer.mock(VcsProcess.VcsProcess)({ run }))), + ), + ); + + const output = yield* provider.searchRepositories({ cwd: "/repo", query: "t3code" }); + + assert.deepStrictEqual(output, { supported: false, results: [] }); + expect(run).not.toHaveBeenCalled(); + }), +); diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts index 2cba12f1b3f7..fd403d8c9e44 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts @@ -197,6 +197,9 @@ export const make = Effect.gen(function* () { }), ), ), + // Repository search is GitHub only for now. GitLab answers as data rather than + // failing, so a search-as-you-type caller renders "not supported" instead of an error. + searchRepositories: () => Effect.succeed({ supported: false, results: [] }), createRepository: (input) => gitlab.createRepository(input).pipe( Effect.mapError( diff --git a/apps/server/src/sourceControl/SourceControlProvider.ts b/apps/server/src/sourceControl/SourceControlProvider.ts index 5f93dbcaa425..adcbd10640a0 100644 --- a/apps/server/src/sourceControl/SourceControlProvider.ts +++ b/apps/server/src/sourceControl/SourceControlProvider.ts @@ -7,6 +7,7 @@ import type { SourceControlProviderInfo, SourceControlProviderKind, SourceControlRepositoryCloneUrls, + SourceControlRepositorySearchOutput, SourceControlRepositoryVisibility, } from "@t3tools/contracts"; @@ -111,6 +112,15 @@ export class SourceControlProvider extends Context.Service< readonly context?: SourceControlProviderContext; readonly repository: string; }) => Effect.Effect; + /** + * Answers `supported: false` rather than failing when the provider cannot search, so a + * search-as-you-type caller renders that as state instead of an error on every keystroke. + */ + readonly searchRepositories: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly query: string; + }) => Effect.Effect; readonly createRepository: (input: { readonly cwd: string; readonly repository: string; diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index 9fe089a4184c..6c06db4117eb 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -98,6 +98,9 @@ function unsupportedProvider( repository: SourceControlProvider.transportSafeSourceControlErrorValue(input.repository), detail: `No ${kind} source control provider is registered.`, }), + // Search answers as data, never as an error: an unregistered provider must not raise a + // toast on every keystroke of a search-as-you-type field. + searchRepositories: () => Effect.succeed({ supported: false, results: [] }), createRepository: (input) => new SourceControlProviderError({ provider: kind, @@ -180,6 +183,11 @@ function bindProviderContext( ...input, context: input.context ?? context, }), + searchRepositories: (input) => + provider.searchRepositories({ + ...input, + context: input.context ?? context, + }), createRepository: (input) => provider.createRepository(input), getDefaultBranch: (input) => provider.getDefaultBranch({ diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 861da9a10e05..cd0cdecde495 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -36,6 +36,7 @@ function makeProvider( getChangeRequest: () => unsupported("getChangeRequest"), createChangeRequest: () => unsupported("createChangeRequest"), getRepositoryCloneUrls: () => Effect.succeed(CLONE_URLS), + searchRepositories: () => unsupported("searchRepositories"), createRepository: () => Effect.succeed(CLONE_URLS), getDefaultBranch: () => Effect.succeed(null), checkoutChangeRequest: () => unsupported("checkoutChangeRequest"), @@ -117,6 +118,33 @@ it.effect("looks up repositories through the requested provider without search", }).pipe(Effect.provide(makeLayer({ provider }))); }); +it.effect("searches repositories through the requested provider", () => { + const calls: Array<{ cwd: string; query: string }> = []; + const searchResult = { + supported: true, + results: [{ ...CLONE_URLS, ownedByViewer: true }], + }; + const provider = makeProvider({ + searchRepositories: (input) => + Effect.sync(() => { + calls.push({ cwd: input.cwd, query: input.query }); + return searchResult; + }), + }); + + return Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const result = yield* service.searchRepositories({ + provider: "github", + query: "t3code", + cwd: "/workspace", + }); + + assert.deepStrictEqual(result, searchResult); + assert.deepStrictEqual(calls, [{ cwd: "/workspace", query: "t3code" }]); + }).pipe(Effect.provide(makeLayer({ provider }))); +}); + it.effect("preserves provider failures without deriving the repository message from them", () => { const providerCause = new SourceControlProviderError({ provider: "github", @@ -150,6 +178,41 @@ it.effect("preserves provider failures without deriving the repository message f }).pipe(Effect.provide(makeLayer({ provider }))); }); +it.effect("surfaces the provider's curated user detail when one is set", () => { + const providerCause = new SourceControlProviderError({ + provider: "github", + operation: "getRepositoryCloneUrls", + cwd: "/workspace", + repository: "octocat/nope", + detail: "gh stderr that stays server-side", + userDetail: "Repository not found. Check the owner/repo path and try again.", + }); + const provider = makeProvider({ + getRepositoryCloneUrls: () => Effect.fail(providerCause), + }); + + return Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const error = yield* Effect.flip( + service.lookupRepository({ + provider: "github", + repository: "octocat/nope", + cwd: "/workspace", + }), + ); + + assert.strictEqual( + error.detail, + "Repository not found. Check the owner/repo path and try again.", + ); + assert.strictEqual( + error.message, + "Source control repository operation lookupRepository failed for github: Repository not found. Check the owner/repo path and try again.", + ); + assert.strictEqual(error.cause, providerCause); + }).pipe(Effect.provide(makeLayer({ provider }))); +}); + it.effect("clones a looked-up repository into the requested destination", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index 1b46369e25c4..62755dfe4c87 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -7,6 +7,7 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { + SourceControlProviderError, SourceControlRepositoryError, type SourceControlCloneRepositoryInput, type SourceControlCloneRepositoryResult, @@ -17,12 +18,15 @@ import { type SourceControlRepositoryCloneUrls, type SourceControlRepositoryInfo, type SourceControlRepositoryLookupInput, + type SourceControlRepositorySearchInput, + type SourceControlRepositorySearchOutput, } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError); +const isSourceControlProviderError = Schema.is(SourceControlProviderError); export class SourceControlRepositoryService extends Context.Service< SourceControlRepositoryService, @@ -30,6 +34,9 @@ export class SourceControlRepositoryService extends Context.Service< readonly lookupRepository: ( input: SourceControlRepositoryLookupInput, ) => Effect.Effect; + readonly searchRepositories: ( + input: SourceControlRepositorySearchInput, + ) => Effect.Effect; readonly cloneRepository: ( input: SourceControlCloneRepositoryInput, ) => Effect.Effect; @@ -46,7 +53,12 @@ function mapRepositoryError(operation: string, provider: SourceControlProviderKi : new SourceControlRepositoryError({ operation, provider, - detail: "The source control operation could not be completed.", + // Provider `detail` may quote provider output, so only the curated + // `userDetail` opt-in reaches the client; everything else stays + // behind the generic sentence. + detail: + (isSourceControlProviderError(cause) ? cause.userDetail : undefined) ?? + "The source control operation could not be completed.", cause, }), ); @@ -126,6 +138,20 @@ export const make = Effect.gen(function* () { return toRepositoryInfo(providerKind, urls); }); + const searchRepositories = Effect.fn("SourceControlRepositoryService.searchRepositories")( + function* (input: SourceControlRepositorySearchInput) { + const providerKind = yield* ensureConcreteProvider({ + operation: "searchRepositories", + provider: input.provider, + }); + const provider = yield* providers.get(providerKind); + return yield* provider.searchRepositories({ + cwd: input.cwd ?? config.cwd, + query: input.query.trim(), + }); + }, + ); + const normalizeDestinationPath = Effect.fn("SourceControlRepositoryService.normalizeDestination")( function* (destinationPath: string) { const trimmed = destinationPath.trim(); @@ -278,6 +304,8 @@ export const make = Effect.gen(function* () { return SourceControlRepositoryService.of({ lookupRepository: (input) => lookupRepository(input).pipe(mapRepositoryError("lookupRepository", input.provider)), + searchRepositories: (input) => + searchRepositories(input).pipe(mapRepositoryError("searchRepositories", input.provider)), cloneRepository: (input) => cloneRepository(input).pipe( mapRepositoryError("cloneRepository", input.provider ?? "unknown"), diff --git a/apps/server/src/vcs/VcsProcess.test.ts b/apps/server/src/vcs/VcsProcess.test.ts index bd3e5b4cdce2..6337c274c6b4 100644 --- a/apps/server/src/vcs/VcsProcess.test.ts +++ b/apps/server/src/vcs/VcsProcess.test.ts @@ -315,3 +315,26 @@ describe("VcsProcess.run", () => { }).pipe(provideLive), ); }); + +describe("classifyNonZeroExit", () => { + it("classifies a gh repository resolution failure as repository-not-found", () => { + expect( + VcsProcess.classifyNonZeroExit( + "gh", + "GraphQL: Could not resolve to a Repository with the name 'octocat/nope'. (repository)", + ), + ).toBe("repository-not-found"); + }); + + it("keeps gh pull request resolution failures as not-found", () => { + expect( + VcsProcess.classifyNonZeroExit("gh", "GraphQL: Could not resolve to a PullRequest."), + ).toBe("not-found"); + }); + + it("does not classify repository resolution failures for other commands", () => { + expect(VcsProcess.classifyNonZeroExit("git", "could not resolve to a repository")).toBe( + "command-failed", + ); + }); +}); diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts index ec245fa13604..d52ad160dbe9 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -53,7 +53,7 @@ const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; -const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFailureKind => { +export const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFailureKind => { const normalized = stderr.toLowerCase(); if ( @@ -79,6 +79,10 @@ const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFai return "rate-limited"; } + if (command === "gh" && normalized.includes("could not resolve to a repository")) { + return "repository-not-found"; + } + if ( (command === "gh" && (normalized.includes("could not resolve to a pullrequest") || diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 226c82cdb1ac..4d382efe742d 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -122,15 +122,6 @@ import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; -import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; -import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; -import * as GitHubCli from "./sourceControl/GitHubCli.ts"; -import * as GitLabCli from "./sourceControl/GitLabCli.ts"; -import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; -import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; -import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; -import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; -import * as VcsProcess from "./vcs/VcsProcess.ts"; import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as SessionStore from "./auth/SessionStore.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; @@ -1886,6 +1877,14 @@ const makeWsRpcLayer = ( "rpc.aggregate": "source-control", }, ), + [WS_METHODS.sourceControlSearchRepositories]: (input) => + observeRpcEffect( + WS_METHODS.sourceControlSearchRepositories, + sourceControlRepositories.searchRepositories(input), + { + "rpc.aggregate": "source-control", + }, + ), [WS_METHODS.sourceControlCloneRepository]: (input) => observeRpcEffect( WS_METHODS.sourceControlCloneRepository, @@ -2462,6 +2461,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker; const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; const pullRequests = yield* PullRequestService.PullRequestService; + const sourceControlDiscovery = yield* SourceControlDiscovery.SourceControlDiscovery; return HttpRouter.add( "GET", "/ws", @@ -2495,25 +2495,13 @@ export const websocketRpcRouteLayer = Layer.unwrap( // One server-lifetime service means clients share the same PR caches, and a WS // mutation invalidates the HTTP diff cache that every client reads from. Layer.provide(Layer.succeed(PullRequestService.PullRequestService, pullRequests)), + // Built once at server lifetime like PullRequestService above: every + // connection shares the same provider CLI caches and rate-limit + // circuits instead of minting fresh ones per client. Layer.provide( - SourceControlDiscovery.layer.pipe( - Layer.provide( - SourceControlProviderRegistry.layer.pipe( - Layer.provide( - Layer.mergeAll( - AzureDevOpsCli.layer, - BitbucketApi.layer, - GitHubCli.layer, - GitLabCli.layer, - ), - ), - Layer.provideMerge(GitVcsDriver.layer), - Layer.provide( - VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer)), - ), - ), - ), - Layer.provide(VcsProcess.layer), + Layer.succeed( + SourceControlDiscovery.SourceControlDiscovery, + sourceControlDiscovery, ), ), ), diff --git a/apps/web/src/components/CommandPalette.test.ts b/apps/web/src/components/CommandPalette.test.ts new file mode 100644 index 000000000000..eadce56b6484 --- /dev/null +++ b/apps/web/src/components/CommandPalette.test.ts @@ -0,0 +1,148 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + looksLikeRepositoryPath, + repositoryResultItemValue, + repositoryStepEmptyState, + repositoryStepEnterAction, +} from "./CommandPalette"; + +const environmentId = EnvironmentId.make("environment-a"); + +describe("repositoryStepEnterAction", () => { + const pathQuery = { queryIsRepositoryPath: true, searchCanAnswer: true } as const; + + it("selects the highlighted repository instead of looking up the raw text", () => { + expect( + repositoryStepEnterAction({ + highlightedItemValue: repositoryResultItemValue(environmentId, "t3dotgg/t3code"), + hasPrimaryModifier: false, + ...pathQuery, + }), + ).toBe("select-highlighted-repository"); + }); + + it("looks up the typed path when no repository row is highlighted", () => { + expect( + repositoryStepEnterAction({ + highlightedItemValue: null, + hasPrimaryModifier: false, + ...pathQuery, + }), + ).toBe("lookup-typed-repository"); + expect( + repositoryStepEnterAction({ + highlightedItemValue: "browse:/home/user/projects", + hasPrimaryModifier: false, + ...pathQuery, + }), + ).toBe("lookup-typed-repository"); + }); + + it("keeps the exact-path lookup reachable with the primary modifier", () => { + expect( + repositoryStepEnterAction({ + highlightedItemValue: repositoryResultItemValue(environmentId, "t3dotgg/t3code"), + hasPrimaryModifier: true, + ...pathQuery, + }), + ).toBe("lookup-typed-repository"); + }); + + it("leaves a bare search term with the search instead of erroring through the lookup", () => { + expect( + repositoryStepEnterAction({ + highlightedItemValue: null, + hasPrimaryModifier: false, + queryIsRepositoryPath: false, + searchCanAnswer: true, + }), + ).toBe("continue-search"); + }); + + it("looks up a bare term when the search cannot answer", () => { + expect( + repositoryStepEnterAction({ + highlightedItemValue: null, + hasPrimaryModifier: false, + queryIsRepositoryPath: false, + searchCanAnswer: false, + }), + ).toBe("lookup-typed-repository"); + }); + + it("forces the lookup of a bare term with the primary modifier", () => { + expect( + repositoryStepEnterAction({ + highlightedItemValue: null, + hasPrimaryModifier: true, + queryIsRepositoryPath: false, + searchCanAnswer: true, + }), + ).toBe("lookup-typed-repository"); + }); +}); + +describe("looksLikeRepositoryPath", () => { + it("treats any slash as a repository path, covering nested groups and clone URLs", () => { + expect(looksLikeRepositoryPath("t3dotgg/t3code")).toBe(true); + expect(looksLikeRepositoryPath("group/subgroup/project")).toBe(true); + expect(looksLikeRepositoryPath("https://github.com/t3dotgg/t3code.git")).toBe(true); + }); + + it("treats a bare word as a search term", () => { + expect(looksLikeRepositoryPath("t3code")).toBe(false); + }); +}); + +describe("repositoryResultItemValue", () => { + it("keys a row by environment and repository so identical names across environments stay distinct", () => { + expect(repositoryResultItemValue(environmentId, "t3dotgg/t3code")).toBe( + "repo:environment-a:t3dotgg/t3code", + ); + expect( + repositoryResultItemValue(EnvironmentId.make("environment-b"), "t3dotgg/t3code"), + ).not.toBe(repositoryResultItemValue(environmentId, "t3dotgg/t3code")); + }); +}); + +describe("repositoryStepEmptyState", () => { + const settled = { supported: true, error: null, isPending: false, canSearch: true } as const; + + it("points a no-match query at the full path, since a bare term no longer submits", () => { + expect(repositoryStepEmptyState({ source: "github", search: settled })).toBe( + "No repositories match. Enter owner/repo and press Enter to look it up.", + ); + }); + + it("points at the exact-path input when the provider cannot search", () => { + expect( + repositoryStepEmptyState({ source: "gitlab", search: { ...settled, supported: false } }), + ).toBe("Search is unavailable for GitLab. Enter group/project and press Enter."); + }); + + it("reports the search failing the same way, since the way out is the same", () => { + expect( + repositoryStepEmptyState({ source: "github", search: { ...settled, error: "gh exited 1" } }), + ).toBe("Search is unavailable for GitHub. Enter owner/repo and press Enter."); + }); + + it("expresses loading as a string, because the palette has no spinner", () => { + expect( + repositoryStepEmptyState({ source: "github", search: { ...settled, isPending: true } }), + ).toBe("Searching repositories…"); + }); + + it("prompts for a path before the query is long enough to search", () => { + expect( + repositoryStepEmptyState({ source: "github", search: { ...settled, canSearch: false } }), + ).toBe("Enter a repository path and press Enter to look it up."); + }); + + it("leaves the Git URL source alone", () => { + expect(repositoryStepEmptyState({ source: "url", search: settled })).toBe( + "Enter a Git clone URL and press Enter to continue.", + ); + }); +}); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index c5ec3f095167..e96905356496 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -30,6 +30,7 @@ import { type SourceControlDiscoveryResult, type SourceControlProviderKind, type SourceControlRepositoryInfo, + type SourceControlRepositorySearchResult, PRIMARY_LOCAL_ENVIRONMENT_ID, } from "@t3tools/contracts"; import { useNavigate, useParams } from "@tanstack/react-router"; @@ -77,7 +78,7 @@ import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useThreadShells } from "../state/entities"; -import { useThreadSearch } from "../state/queries"; +import { useRepositorySearch, useThreadSearch, type RepositorySearchView } from "../state/queries"; import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; import { appendBrowsePathSegment, @@ -119,6 +120,7 @@ import { buildThreadActionItems, enumerateCommandPaletteItems, type CommandPaletteActionItem, + type CommandPaletteGroup, type CommandPaletteOpenIntent, type CommandPaletteSubmenuItem, type CommandPaletteView, @@ -273,6 +275,72 @@ function remoteProjectSourceProvider( return source === "url" ? null : source; } +const REPOSITORY_RESULT_VALUE_PREFIX = "repo:"; + +/** Row key for one searched repository. Environment-scoped so the same name in two environments stays distinct. */ +export function repositoryResultItemValue( + environmentId: EnvironmentId, + nameWithOwner: string, +): string { + return `${REPOSITORY_RESULT_VALUE_PREFIX}${environmentId}:${nameWithOwner}`; +} + +function isRepositoryResultValue(value: string | null): boolean { + return value?.startsWith(REPOSITORY_RESULT_VALUE_PREFIX) ?? false; +} + +/** True when the typed text names a repository rather than a search term: any "/" covers owner/repo, nested groups, and clone URLs. */ +export function looksLikeRepositoryPath(query: string): boolean { + return query.includes("/"); +} + +/** + * Enter in the repository step runs the highlighted search result, or looks up the typed path when + * no result is highlighted. Mirrors how a highlighted browse row takes Enter from the path submit, + * including the primary modifier as the escape hatch back to the exact-path lookup. A bare search + * term stays with the search ("continue-search") instead of erroring through the exact-path lookup; + * lookup still owns Enter when the search cannot answer (unsupported, failed, or query too short). + */ +export function repositoryStepEnterAction(input: { + readonly highlightedItemValue: string | null; + readonly hasPrimaryModifier: boolean; + readonly queryIsRepositoryPath: boolean; + readonly searchCanAnswer: boolean; +}): "select-highlighted-repository" | "lookup-typed-repository" | "continue-search" { + if (isRepositoryResultValue(input.highlightedItemValue) && !input.hasPrimaryModifier) { + return "select-highlighted-repository"; + } + if (input.hasPrimaryModifier || input.queryIsRepositoryPath || !input.searchCanAnswer) { + return "lookup-typed-repository"; + } + return "continue-search"; +} + +/** + * The repository step has no spinner, so every state of the search reads as an empty-state string. + * Both empty successes stay here rather than becoming errors: a paused rate-limit circuit answers + * `supported: true` with no results and gets the ordinary empty state, while a provider that cannot + * search answers `supported: false` and gets the affordance pointing back at the exact-path input. + */ +export function repositoryStepEmptyState(input: { + readonly source: AddProjectRemoteSource; + readonly search: Pick; +}): string { + if (input.source === "url") { + return "Enter a Git clone URL and press Enter to continue."; + } + if (!input.search.supported || input.search.error !== null) { + return `Search is unavailable for ${remoteProjectSourceLabel(input.source)}. Enter ${remoteProjectSourcePathHint(input.source)} and press Enter.`; + } + if (input.search.isPending) { + return "Searching repositories…"; + } + if (!input.search.canSearch) { + return "Enter a repository path and press Enter to look it up."; + } + return `No repositories match. Enter ${remoteProjectSourcePathHint(input.source)} and press Enter to look it up.`; +} + function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: string): ReactNode { switch (source) { case "github": @@ -639,7 +707,18 @@ function OpenCommandPaletteDialog(props: { null, ); const [isPickingProjectFolder, setIsPickingProjectFolder] = useState(false); - const [addProjectCloneFlow, setAddProjectCloneFlow] = useState(null); + const [addProjectCloneFlow, setAddProjectCloneFlowState] = useState( + null, + ); + // Mirrors addProjectCloneFlow so the exact-path lookup continuation can tell whether the flow + // moved on (a search result was selected, or the flow was cancelled) while it was in flight. + // Written synchronously by the wrapper setter: an effect-synced mirror leaves a window between + // a transition's commit and its effects where a settling lookup still compares equal. + const addProjectCloneFlowRef = useRef(null); + const setAddProjectCloneFlow = useCallback((flow: AddProjectCloneFlow | null): void => { + addProjectCloneFlowRef.current = flow; + setAddProjectCloneFlowState(flow); + }, []); const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false); const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false); const projectGroupingSettings = useMemo( @@ -826,6 +905,16 @@ function OpenCommandPaletteDialog(props: { input: {}, }), ); + const repositorySearchFlow = + addProjectCloneFlow?.step === "repository" ? addProjectCloneFlow : null; + const repositorySearch = useRepositorySearch({ + environmentId: repositorySearchFlow?.environmentId ?? null, + provider: + repositorySearchFlow === null + ? null + : remoteProjectSourceProvider(repositorySearchFlow.source), + query: repositorySearchFlow === null ? "" : query, + }); const browseEnvironmentPlatform = getEnvironmentBrowsePlatform( browseEnvironment?.serverConfig?.environment.platform.os, ); @@ -1225,6 +1314,7 @@ function OpenCommandPaletteDialog(props: { getBrowseCwdForEnvironment, prefetchBrowsePath, pushPaletteView, + setAddProjectCloneFlow, ], ); @@ -1238,7 +1328,7 @@ function OpenCommandPaletteDialog(props: { initialQuery: "", }); }, - [pushPaletteView], + [pushPaletteView, setAddProjectCloneFlow], ); const openSourceControlSettings = useCallback(() => { @@ -1372,6 +1462,7 @@ function OpenCommandPaletteDialog(props: { buildAddProjectSourceGroups, environments, pushPaletteView, + setAddProjectCloneFlow, sourceControlDiscovery.data, ], ); @@ -1482,6 +1573,7 @@ function OpenCommandPaletteDialog(props: { openIntent, projectThreadItems, pushPaletteView, + setAddProjectCloneFlow, ]); const actionItems: Array = []; @@ -1839,6 +1931,52 @@ function OpenCommandPaletteDialog(props: { return getAddProjectInitialQueryForEnvironment(environmentId); } + function enterCloneDestinationStep(input: { + readonly environmentId: EnvironmentId; + readonly source: AddProjectRemoteSource; + readonly repositoryInput: string; + readonly repository: SourceControlRepositoryInfo; + }): void { + setAddProjectCloneFlow({ + step: "confirm", + environmentId: input.environmentId, + source: input.source, + repositoryInput: input.repositoryInput, + repository: input.repository, + remoteUrl: getDefaultCloneUrl(input.repository), + }); + setHighlightedItemValue(null); + setQuery( + getCloneDestinationPath( + getDefaultCloneParentPath(input.environmentId), + getCloneDirectoryName(input.repository.nameWithOwner), + ), + ); + setBrowseGeneration((generation) => generation + 1); + } + + /** A searched row already carries the clone URLs, so selecting one skips the lookup round trip. */ + function selectSearchedRepository(result: SourceControlRepositorySearchResult): void { + if (addProjectCloneFlow?.step !== "repository") { + return; + } + const provider = remoteProjectSourceProvider(addProjectCloneFlow.source); + if (provider === null) { + return; + } + enterCloneDestinationStep({ + environmentId: addProjectCloneFlow.environmentId, + source: addProjectCloneFlow.source, + repositoryInput: result.nameWithOwner, + repository: { + provider, + nameWithOwner: result.nameWithOwner, + url: result.url, + sshUrl: result.sshUrl, + }, + }); + } + async function submitAddProjectCloneFlow(destinationPathInput?: string): Promise { if (!addProjectCloneFlow) { return; @@ -1889,6 +2027,13 @@ function OpenCommandPaletteDialog(props: { }, }); setIsRemoteProjectLookingUp(false); + // Every flow transition replaces the flow object, so a mismatch means a search result was + // selected or the flow was cancelled while the lookup was pending. Drop the stale response + // instead of clobbering the newer state (same commit-only-if-current shape as + // createBrowseNavigationCoordinator). + if (addProjectCloneFlowRef.current !== addProjectCloneFlow) { + return; + } if (lookupResult._tag === "Failure") { if (!isAtomCommandInterrupted(lookupResult)) { toastManager.add( @@ -1901,22 +2046,12 @@ function OpenCommandPaletteDialog(props: { } return; } - const repository = lookupResult.value; - const destinationPath = getCloneDestinationPath( - getDefaultCloneParentPath(addProjectCloneFlow.environmentId), - getCloneDirectoryName(repository.nameWithOwner), - ); - setAddProjectCloneFlow({ - step: "confirm", + enterCloneDestinationStep({ environmentId: addProjectCloneFlow.environmentId, source: addProjectCloneFlow.source, repositoryInput: rawRepository, - repository, - remoteUrl: getDefaultCloneUrl(repository), + repository: lookupResult.value, }); - setHighlightedItemValue(null); - setQuery(destinationPath); - setBrowseGeneration((generation) => generation + 1); return; } @@ -2064,9 +2199,53 @@ function OpenCommandPaletteDialog(props: { }; }, [addProjectCloneFlow]); + // Ranking, the 20-result cap, and description truncation all happen server-side; the only client + // decision left is which of the two groups a row belongs to. + const repositorySearchGroups: CommandPaletteGroup[] = []; + if (repositorySearchFlow !== null && repositorySearch.results.length > 0) { + const ownedItems: CommandPaletteActionItem[] = []; + const otherItems: CommandPaletteActionItem[] = []; + for (const result of repositorySearch.results) { + const item: CommandPaletteActionItem = { + kind: "action", + value: repositoryResultItemValue(repositorySearchFlow.environmentId, result.nameWithOwner), + searchTerms: [result.nameWithOwner], + title: result.nameWithOwner, + ...(result.description + ? { description: {result.description} } + : {}), + icon: remoteProjectSourceIcon(repositorySearchFlow.source, ITEM_ICON_CLASS), + keepOpen: true, + run: async () => { + selectSearchedRepository(result); + }, + }; + (result.ownedByViewer ? ownedItems : otherItems).push(item); + } + if (ownedItems.length > 0) { + repositorySearchGroups.push({ + value: "repositories:owned", + label: "Your repositories", + items: ownedItems, + }); + } + if (otherItems.length > 0) { + repositorySearchGroups.push({ + value: "repositories:other", + label: remoteProjectSourceLabel(repositorySearchFlow.source), + items: otherItems, + }); + } + } + + const repositoryStepEmptyStateMessage = + repositorySearchFlow === null + ? null + : repositoryStepEmptyState({ source: repositorySearchFlow.source, search: repositorySearch }); + let displayedGroups: CommandPaletteView["groups"] = filteredGroups; if (addProjectCloneFlow?.step === "repository") { - displayedGroups = []; + displayedGroups = repositorySearchGroups; } else if (addProjectCloneFlow?.step === "confirm") { displayedGroups = relativePathNeedsActiveProject ? [] : cloneDestinationBrowseGroups; } else if (isBrowsing) { @@ -2078,6 +2257,7 @@ function OpenCommandPaletteDialog(props: { getCommandPaletteInputPlaceholder(paletteMode); const isSubmenu = paletteMode === "submenu" || paletteMode === "submenu-browse"; const hasHighlightedBrowseItem = highlightedItemValue?.startsWith("browse:") ?? false; + const hasHighlightedRepositoryItem = isRepositoryResultValue(highlightedItemValue); const canSubmitBrowsePath = isBrowsing && !relativePathNeedsActiveProject && @@ -2105,6 +2285,27 @@ function OpenCommandPaletteDialog(props: { : "Lookup" : null; const isRemoteProjectPending = isRemoteProjectLookingUp || isRemoteProjectCloning; + const repositorySearchCanAnswer = + repositorySearchFlow !== null && + repositorySearch.supported && + repositorySearch.error === null && + repositorySearch.canSearch; + // What plain Enter does right now, shared by the key handler and the input accessory so the + // advertised shortcut never names a key that would do nothing. + const repositoryPlainEnterAction = + addProjectCloneFlow?.step === "repository" + ? repositoryStepEnterAction({ + highlightedItemValue, + hasPrimaryModifier: false, + queryIsRepositoryPath: looksLikeRepositoryPath(query.trim()), + searchCanAnswer: repositorySearchCanAnswer, + }) + : null; + const remoteProjectShortcutIsModified = + repositoryPlainEnterAction !== null && repositoryPlainEnterAction !== "lookup-typed-repository"; + const remoteProjectShortcutLabel = remoteProjectShortcutIsModified + ? `${submitModifierLabel} Enter` + : "Enter"; const canSubmitRemoteProjectFlow = addProjectCloneFlow?.step === "repository" && query.trim().length > 0 && @@ -2169,8 +2370,20 @@ function OpenCommandPaletteDialog(props: { } if (addProjectCloneFlow?.step === "repository" && event.key === "Enter") { + const enterAction = repositoryStepEnterAction({ + highlightedItemValue, + hasPrimaryModifier: isPrimaryModifierPressed(event), + queryIsRepositoryPath: looksLikeRepositoryPath(query.trim()), + searchCanAnswer: repositorySearchCanAnswer, + }); + if (enterAction === "select-highlighted-repository") { + // Leave the event alone so the highlighted row runs itself. + return; + } event.preventDefault(); - void submitAddProjectCloneFlow(); + if (enterAction === "lookup-typed-repository") { + void submitAddProjectCloneFlow(); + } return; } @@ -2340,8 +2553,11 @@ function OpenCommandPaletteDialog(props: { variant="outline" size="xs" tabIndex={-1} - className="absolute inset-e-2.5 top-1/2 gap-1.5 pe-1 ps-2 -translate-y-1/2" - aria-label={`${remoteProjectButtonLabel ?? "Continue"} (Enter)`} + className={cn( + "absolute inset-e-2.5 top-1/2 pe-1 ps-2 -translate-y-1/2", + remoteProjectShortcutIsModified ? "gap-1" : "gap-1.5", + )} + aria-label={`${remoteProjectButtonLabel ?? "Continue"} (${remoteProjectShortcutLabel})`} disabled={!canSubmitRemoteProjectFlow} onMouseDown={(event) => { event.preventDefault(); @@ -2354,10 +2570,12 @@ function OpenCommandPaletteDialog(props: { > {isRemoteProjectPending ? "Working" : remoteProjectButtonLabel} - Enter + {remoteProjectShortcutLabel} - {remoteProjectButtonLabel ?? "Continue"} (Enter) + + {remoteProjectButtonLabel ?? "Continue"} ({remoteProjectShortcutLabel}) + ) : isBrowsing ? ( @@ -2408,7 +2626,12 @@ function OpenCommandPaletteDialog(props: { const footerActionLabel = addProjectCloneFlow?.step === "repository" - ? (remoteProjectButtonLabel ?? "Continue") + ? repositoryPlainEnterAction === "select-highlighted-repository" + ? "Select" + : repositoryPlainEnterAction === "lookup-typed-repository" + ? (remoteProjectButtonLabel ?? "Continue") + : // Plain Enter stays with the search, so there is no action to hint. + undefined : !canSubmitBrowsePath || hasHighlightedBrowseItem ? "Select" : undefined; @@ -2437,7 +2660,9 @@ function OpenCommandPaletteDialog(props: { // inner input must reserve enough room for the full action label. className: addProjectCloneFlow?.step === "repository" - ? "*:data-[slot=autocomplete-input]:pe-32!" + ? remoteProjectShortcutIsModified + ? "*:data-[slot=autocomplete-input]:pe-38!" + : "*:data-[slot=autocomplete-input]:pe-32!" : isBrowsing ? browseInputEndPaddingClass({ willCreateProjectPath, @@ -2495,13 +2720,8 @@ function OpenCommandPaletteDialog(props: { isActionsOnly={isActionsOnly} keybindings={keybindings} onExecuteItem={executeItem} - {...(addProjectCloneFlow?.step === "repository" - ? { - emptyStateMessage: - addProjectCloneFlow.source === "url" - ? "Enter a Git clone URL and press Enter to continue." - : "Enter a repository path and press Enter to look it up.", - } + {...(repositoryStepEmptyStateMessage !== null + ? { emptyStateMessage: repositoryStepEmptyStateMessage } : addProjectCloneFlow?.step === "confirm" ? { emptyStateMessage: "Choose a destination path and press Enter to clone." } : relativePathNeedsActiveProject diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 094db94c4dcf..4e420b46def8 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -8,12 +8,18 @@ import { makeThreadSearchKey, type EnvironmentThreadSearchMatch, } from "@t3tools/client-runtime/state/thread-search"; +import { + resolveRepositorySearchAnswer, + type RepositorySearchAnswer, +} from "@t3tools/client-runtime/state/source-control"; import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; import type { EnvironmentId, OrchestrationThread, ProjectContentMatch, ProjectEntryKind, + SourceControlProviderKind, + SourceControlRepositorySearchResult, ThreadId, VcsListRefsResult, VcsRef, @@ -21,13 +27,14 @@ import type { import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { orchestrationEnvironment } from "./orchestration"; import { isPaginatedBranchesNextPagePending } from "./paginatedBranches"; import { projectContentSearch, projectEnvironment } from "./projects"; import { useEnvironmentQuery } from "./query"; +import { sourceControlEnvironment } from "./sourceControl"; import { useEnvironmentThread } from "./threads"; import { vcsEnvironment } from "./vcs"; @@ -36,11 +43,15 @@ const COMPOSER_PATH_SEARCH_LIMIT = 80; const PROJECT_CONTENT_SEARCH_DEBOUNCE_MS = 120; const PROJECT_CONTENT_SEARCH_LIMIT = 500; const THREAD_SEARCH_DEBOUNCE_MS = 200; +const REPOSITORY_SEARCH_DEBOUNCE_MS = 200; +const REPOSITORY_SEARCH_MIN_QUERY_LENGTH = 2; const VCS_REF_LIST_LIMIT = 100; const EMPTY_REFS: ReadonlyArray = []; const EMPTY_CONTENT_MATCHES: ReadonlyArray = []; const INITIAL_BRANCH_CURSORS = [undefined] as const; const EMPTY_THREAD_SEARCH_MATCHES: ReadonlyArray = Object.freeze([]); +const EMPTY_REPOSITORY_SEARCH_RESULTS: ReadonlyArray = + Object.freeze([]); const EMPTY_THREAD_SEARCH_ATOM = Atom.make({ matches: EMPTY_THREAD_SEARCH_MATCHES, isLoading: false, @@ -103,6 +114,70 @@ export function useThreadSearch( }; } +export interface RepositorySearchTarget { + readonly environmentId: EnvironmentId | null; + readonly provider: SourceControlProviderKind | null; + readonly query: string; +} + +export interface RepositorySearchView { + readonly results: ReadonlyArray; + /** False only once a provider answers that it cannot search, so callers show an affordance instead of an error. */ + readonly supported: boolean; + readonly error: string | null; + readonly isPending: boolean; + /** True while the query is long enough to search, whether or not results have arrived. */ + readonly canSearch: boolean; +} + +/** + * Debounced repository search for the add-project flow. Results blank while the query settles, + * because a previous owner prefix's matches actively mislead, and each settled query subscribes to + * its own atom so a late response cannot overwrite a newer one. `supported` and `error` stay sticky + * per environment+provider across keystrokes, so a provider already known unsupported keeps its + * exact-path affordance instead of flashing the searching state on every character. + */ +export function useRepositorySearch(target: RepositorySearchTarget): RepositorySearchView { + const normalizedQuery = target.query.trim(); + const debouncedQuery = useDebouncedValue(normalizedQuery, REPOSITORY_SEARCH_DEBOUNCE_MS); + const environmentId = target.environmentId; + const provider = target.provider; + const answerMemoryRef = useRef | null>(null); + answerMemoryRef.current ??= new Map(); + const canSearch = + environmentId !== null && + provider !== null && + normalizedQuery.length >= REPOSITORY_SEARCH_MIN_QUERY_LENGTH; + const isDebouncing = canSearch && normalizedQuery !== debouncedQuery; + const settledQuery = canSearch && !isDebouncing ? debouncedQuery : null; + const result = useEnvironmentQuery( + settledQuery === null || environmentId === null || provider === null + ? null + : sourceControlEnvironment.repositorySearch({ + environmentId, + input: { provider, query: settledQuery }, + }), + ); + const answer = resolveRepositorySearchAnswer({ + memory: answerMemoryRef.current, + environmentId, + provider, + canSearch, + data: result.data, + error: result.error, + }); + + return { + results: isDebouncing + ? EMPTY_REPOSITORY_SEARCH_RESULTS + : (result.data?.results ?? EMPTY_REPOSITORY_SEARCH_RESULTS), + supported: answer.supported, + error: answer.error, + isPending: canSearch && (isDebouncing || result.isPending), + canSearch, + }; +} + export function useThreadDetail( environmentId: EnvironmentId | null, threadId: ThreadId | null, diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 916536bbe736..0233502165a5 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -21,6 +21,16 @@ T3 Code works with the platforms your team already uses: - Choose **GitHub repository**, **GitLab repository**, **Bitbucket repository**, **Azure DevOps repository**, or paste any **Git URL** - Enter the repository path (`owner/repo`, `group/project`, `workspace/repository`, or `project/repository`) or a full Git URL, pick a destination, and start coding +**Search GitHub by name** + +- On the repository step, type at least two characters and T3 Code searches GitHub as you type +- **Your repositories** are listed first, then other matches under **GitHub** +- Each row shows the repository name and, when available, its description. Pick one to go straight to choosing a destination +- Typing an exact `owner/repo` path and pressing Enter still works, on every provider +- On the web, arrow keys move through the results and Enter picks the highlighted one. With nothing highlighted, Enter looks up the path you typed +- On mobile, tap a result to pick it +- GitLab, Bitbucket, and Azure DevOps cannot be searched yet. They say so on the repository step, and still take an exact path + **Publish local projects to the cloud** - Have a local Git repository without a remote? diff --git a/packages/client-runtime/src/state/sourceControl.test.ts b/packages/client-runtime/src/state/sourceControl.test.ts index 393be8e3227d..81fe6569310f 100644 --- a/packages/client-runtime/src/state/sourceControl.test.ts +++ b/packages/client-runtime/src/state/sourceControl.test.ts @@ -22,7 +22,11 @@ import * as Persistence from "../platform/persistence.ts"; import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import type { RpcSession } from "../rpc/session.ts"; -import { createSourceControlEnvironmentAtoms } from "./sourceControl.ts"; +import { + createSourceControlEnvironmentAtoms, + resolveRepositorySearchAnswer, + type RepositorySearchAnswer, +} from "./sourceControl.ts"; import { vcsRefsCacheStateAtom } from "./vcsRefInvalidation.ts"; const TARGET = new PrimaryConnectionTarget({ @@ -56,6 +60,121 @@ function session(client: WsRpcProtocolClient): RpcSession { }; } +describe("resolveRepositorySearchAnswer", () => { + const environmentId = EnvironmentId.make("environment-1"); + const pending = { canSearch: true, data: null, error: null } as const; + + it("defaults to supported with no error before any answer lands", () => { + expect( + resolveRepositorySearchAnswer({ + memory: new Map(), + environmentId, + provider: "github", + ...pending, + }), + ).toEqual({ supported: true, error: null }); + }); + + it("keeps an unsupported provider unsupported across a new keystroke's pending query", () => { + const memory = new Map(); + expect( + resolveRepositorySearchAnswer({ + memory, + environmentId, + provider: "gitlab", + canSearch: true, + data: { supported: false }, + error: null, + }), + ).toEqual({ supported: false, error: null }); + expect( + resolveRepositorySearchAnswer({ memory, environmentId, provider: "gitlab", ...pending }), + ).toEqual({ supported: false, error: null }); + }); + + it("scopes the memory to the environment and provider", () => { + const memory = new Map(); + resolveRepositorySearchAnswer({ + memory, + environmentId, + provider: "gitlab", + canSearch: true, + data: { supported: false }, + error: null, + }); + expect( + resolveRepositorySearchAnswer({ memory, environmentId, provider: "github", ...pending }), + ).toEqual({ supported: true, error: null }); + expect( + resolveRepositorySearchAnswer({ + memory, + environmentId: EnvironmentId.make("environment-2"), + provider: "gitlab", + ...pending, + }), + ).toEqual({ supported: true, error: null }); + }); + + it("carries a failure across the next pending query until a settled success replaces it", () => { + const memory = new Map(); + resolveRepositorySearchAnswer({ + memory, + environmentId, + provider: "github", + canSearch: true, + data: null, + error: "gh exited 1", + }); + expect( + resolveRepositorySearchAnswer({ memory, environmentId, provider: "github", ...pending }), + ).toEqual({ supported: true, error: "gh exited 1" }); + expect( + resolveRepositorySearchAnswer({ + memory, + environmentId, + provider: "github", + canSearch: true, + data: { supported: true }, + error: null, + }), + ).toEqual({ supported: true, error: null }); + expect( + resolveRepositorySearchAnswer({ memory, environmentId, provider: "github", ...pending }), + ).toEqual({ supported: true, error: null }); + }); + + it("ignores the memory while the query is too short to search", () => { + const memory = new Map([ + [`${environmentId}:gitlab`, { supported: false, error: null }], + ]); + expect( + resolveRepositorySearchAnswer({ + memory, + environmentId, + provider: "gitlab", + canSearch: false, + data: null, + error: null, + }), + ).toEqual({ supported: true, error: null }); + }); + + it("records nothing without an environment and provider", () => { + const memory = new Map(); + expect( + resolveRepositorySearchAnswer({ + memory, + environmentId: null, + provider: null, + canSearch: false, + data: null, + error: null, + }), + ).toEqual({ supported: true, error: null }); + expect(memory.size).toBe(0); + }); +}); + describe("source control environment atoms", () => { it.effect("invalidates cached refs after successful and failed publishing", () => Effect.scoped( diff --git a/packages/client-runtime/src/state/sourceControl.ts b/packages/client-runtime/src/state/sourceControl.ts index c1598b49eaeb..118a012770bc 100644 --- a/packages/client-runtime/src/state/sourceControl.ts +++ b/packages/client-runtime/src/state/sourceControl.ts @@ -1,5 +1,11 @@ -import { WS_METHODS } from "@t3tools/contracts"; -import { Atom } from "effect/unstable/reactivity"; +import { + WS_METHODS, + type EnvironmentId, + type SourceControlProviderKind, + type SourceControlRepositorySearchInput, + type SourceControlRepositorySearchOutput, +} from "@t3tools/contracts"; +import { Atom, type AsyncResult } from "effect/unstable/reactivity"; import { createAtomCommandScheduler, @@ -24,6 +30,10 @@ export function createSourceControlEnvironmentAtoms( label: "environment-data:source-control:repository", tag: WS_METHODS.sourceControlLookupRepository, }), + repositorySearch: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:source-control:repository-search", + tag: WS_METHODS.sourceControlSearchRepositories, + }), cloneRepository: createEnvironmentRpcCommand(runtime, { label: "environment-data:source-control:clone-repository", tag: WS_METHODS.sourceControlCloneRepository, @@ -46,3 +56,67 @@ export function createSourceControlEnvironmentAtoms( }), }; } + +export interface RepositorySearchAnswer { + readonly supported: boolean; + readonly error: string | null; +} + +const DEFAULT_REPOSITORY_SEARCH_ANSWER: RepositorySearchAnswer = { supported: true, error: null }; + +/** + * Sticky `supported`/`error` for the repository search views. Each settled query subscribes to its + * own atom, so every keystroke starts a pending atom that knows nothing; without memory the UI + * flashes the searching state and rediscovers `supported: false` on every character. Search support + * is static per provider, so the last settled answer for an environment+provider stands in while + * the next query is pending, and the next settled answer (including a recovery from a transient + * error) replaces it. Callers own `memory` and scope it to the mounted view. + */ +export function resolveRepositorySearchAnswer(input: { + readonly memory: Map; + readonly environmentId: EnvironmentId | null; + readonly provider: SourceControlProviderKind | null; + /** False when the query is too short to search; remembered answers do not apply there. */ + readonly canSearch: boolean; + readonly data: { readonly supported: boolean } | null; + readonly error: string | null; +}): RepositorySearchAnswer { + const key = + input.environmentId !== null && input.provider !== null + ? `${input.environmentId}:${input.provider}` + : null; + const settled = + input.data !== null || input.error !== null + ? { supported: input.data?.supported ?? true, error: input.error } + : null; + if (settled !== null) { + if (key !== null) input.memory.set(key, settled); + return settled; + } + if (!input.canSearch || key === null) return DEFAULT_REPOSITORY_SEARCH_ANSWER; + return input.memory.get(key) ?? DEFAULT_REPOSITORY_SEARCH_ANSWER; +} + +type AssertTrue = T; +type Exact = [A] extends [B] ? ([B] extends [A] ? true : false) : false; + +type RepositorySearchAtomFamily = ReturnType< + typeof createSourceControlEnvironmentAtoms +>["repositorySearch"]; +type RepositorySearchValue = + ReturnType extends Atom.Atom< + AsyncResult.AsyncResult + > + ? A + : never; + +/** + * Compile-time proof that `repositorySearch` carries the repository search contract, so + * dropping the key or pointing it at another RPC tag fails here instead of in web or mobile. + */ +type _RepositorySearchTakesSearchInput = AssertTrue< + Exact[0]["input"], SourceControlRepositorySearchInput> +>; +type _RepositorySearchYieldsSearchOutput = AssertTrue< + Exact +>; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 14363cfedff9..24b4b490048d 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -203,6 +203,8 @@ import { SourceControlRepositoryError, SourceControlRepositoryInfo, SourceControlRepositoryLookupInput, + SourceControlRepositorySearchInput, + SourceControlRepositorySearchOutput, } from "./sourceControl.ts"; import { VcsError } from "./vcs.ts"; @@ -317,6 +319,7 @@ export const WS_METHODS = { // Source control methods sourceControlLookupRepository: "sourceControl.lookupRepository", + sourceControlSearchRepositories: "sourceControl.searchRepositories", sourceControlCloneRepository: "sourceControl.cloneRepository", sourceControlPublishRepository: "sourceControl.publishRepository", @@ -621,6 +624,15 @@ export const WsSourceControlLookupRepositoryRpc = Rpc.make( }, ); +export const WsSourceControlSearchRepositoriesRpc = Rpc.make( + WS_METHODS.sourceControlSearchRepositories, + { + payload: SourceControlRepositorySearchInput, + success: SourceControlRepositorySearchOutput, + error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]), + }, +); + export const WsSourceControlCloneRepositoryRpc = Rpc.make(WS_METHODS.sourceControlCloneRepository, { payload: SourceControlCloneRepositoryInput, success: SourceControlCloneRepositoryResult, @@ -1059,6 +1071,7 @@ export const WsRpcGroup = RpcGroup.make( WsPullRequestsReviewerCandidatesRpc, WsPullRequestsRequestReviewersRpc, WsSourceControlLookupRepositoryRpc, + WsSourceControlSearchRepositoriesRpc, WsSourceControlCloneRepositoryRpc, WsSourceControlPublishRepositoryRpc, WsProjectsListEntriesRpc, diff --git a/packages/contracts/src/sourceControl.test.ts b/packages/contracts/src/sourceControl.test.ts new file mode 100644 index 000000000000..35d054f318e1 --- /dev/null +++ b/packages/contracts/src/sourceControl.test.ts @@ -0,0 +1,97 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { + SourceControlRepositorySearchInput, + SourceControlRepositorySearchOutput, + SourceControlRepositorySearchResult, +} from "./sourceControl.ts"; + +const decodeSearchResult = Schema.decodeUnknownSync(SourceControlRepositorySearchResult); +const decodeSearchInput = Schema.decodeUnknownSync(SourceControlRepositorySearchInput); +const decodeSearchOutput = Schema.decodeUnknownSync(SourceControlRepositorySearchOutput); + +describe("SourceControlRepositorySearchResult", () => { + it("decodes a repository the viewer owns", () => { + const parsed = decodeSearchResult({ + nameWithOwner: "pingdotgg/t3code", + url: "https://github.com/pingdotgg/t3code", + sshUrl: "git@github.com:pingdotgg/t3code.git", + ownedByViewer: true, + description: "A minimal GUI for coding agents", + starCount: 1234, + isFork: false, + isPrivate: false, + }); + + expect(parsed.nameWithOwner).toBe("pingdotgg/t3code"); + expect(parsed.ownedByViewer).toBe(true); + expect(parsed.starCount).toBe(1234); + }); + + it("leaves the optional metadata undefined when the provider omits it", () => { + const parsed = decodeSearchResult({ + nameWithOwner: "octocat/hello-world", + url: "https://github.com/octocat/hello-world", + sshUrl: "git@github.com:octocat/hello-world.git", + ownedByViewer: false, + }); + + expect(parsed.description).toBeUndefined(); + expect(parsed.starCount).toBeUndefined(); + expect(parsed.isFork).toBeUndefined(); + expect(parsed.isPrivate).toBeUndefined(); + }); + + it("rejects a result without nameWithOwner", () => { + expect(() => + decodeSearchResult({ + url: "https://github.com/octocat/hello-world", + sshUrl: "git@github.com:octocat/hello-world.git", + ownedByViewer: false, + }), + ).toThrow(); + }); +}); + +describe("SourceControlRepositorySearchInput", () => { + it("decodes a query without a cwd", () => { + const parsed = decodeSearchInput({ provider: "github", query: "t3code" }); + + expect(parsed.provider).toBe("github"); + expect(parsed.query).toBe("t3code"); + expect(parsed.cwd).toBeUndefined(); + }); + + it("decodes a query scoped to a working directory", () => { + const parsed = decodeSearchInput({ provider: "github", query: "t3code", cwd: "/repo" }); + + expect(parsed.cwd).toBe("/repo"); + }); +}); + +describe("SourceControlRepositorySearchOutput", () => { + it("decodes an unsupported provider as data instead of an error", () => { + const parsed = decodeSearchOutput({ supported: false, results: [] }); + + expect(parsed.supported).toBe(false); + expect(parsed.results).toEqual([]); + }); + + it("decodes supported results", () => { + const parsed = decodeSearchOutput({ + supported: true, + results: [ + { + nameWithOwner: "pingdotgg/t3code", + url: "https://github.com/pingdotgg/t3code", + sshUrl: "git@github.com:pingdotgg/t3code.git", + ownedByViewer: true, + }, + ], + }); + + expect(parsed.results).toHaveLength(1); + expect(parsed.results[0]?.nameWithOwner).toBe("pingdotgg/t3code"); + }); +}); diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index 104aadd9161f..d87d664625ce 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -1,5 +1,5 @@ import * as Schema from "effect/Schema"; -import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { VcsDriverKind } from "./vcs.ts"; export const SourceControlProviderKind = Schema.Literals([ @@ -64,6 +64,33 @@ export const SourceControlRepositoryLookupInput = Schema.Struct({ }); export type SourceControlRepositoryLookupInput = typeof SourceControlRepositoryLookupInput.Type; +/** One repository a provider returned for a search query. Optional fields are provider metadata that not every provider reports. */ +export const SourceControlRepositorySearchResult = Schema.Struct({ + nameWithOwner: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, + sshUrl: TrimmedNonEmptyString, + ownedByViewer: Schema.Boolean, + description: Schema.optional(Schema.String), + starCount: Schema.optional(NonNegativeInt), + isFork: Schema.optional(Schema.Boolean), + isPrivate: Schema.optional(Schema.Boolean), +}); +export type SourceControlRepositorySearchResult = typeof SourceControlRepositorySearchResult.Type; + +export const SourceControlRepositorySearchInput = Schema.Struct({ + provider: SourceControlProviderKind, + query: TrimmedNonEmptyString, + cwd: Schema.optional(TrimmedNonEmptyString), +}); +export type SourceControlRepositorySearchInput = typeof SourceControlRepositorySearchInput.Type; + +/** `supported` is false when the provider cannot search, so callers render that as state instead of erroring on every keystroke. */ +export const SourceControlRepositorySearchOutput = Schema.Struct({ + supported: Schema.Boolean, + results: Schema.Array(SourceControlRepositorySearchResult), +}); +export type SourceControlRepositorySearchOutput = typeof SourceControlRepositorySearchOutput.Type; + export const SourceControlCloneRepositoryInput = Schema.Struct({ provider: Schema.optional(SourceControlProviderKind), repository: Schema.optional(TrimmedNonEmptyString), @@ -160,6 +187,13 @@ export class SourceControlProviderError extends Schema.TaggedErrorClass