From d7a485d1f17b3d798ce7a6813254538ef323d091 Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:50:02 +1000 Subject: [PATCH 1/4] fix(github): search repositories by name --- .../features/projects/AddProjectScreen.tsx | 140 ++++++++- apps/server/src/auth/RpcAuthorization.ts | 1 + apps/server/src/git/GitManager.test.ts | 1 + .../src/sourceControl/GitHubCli.test.ts | 159 ++++++++++ apps/server/src/sourceControl/GitHubCli.ts | 212 +++++++++++-- .../GitHubSourceControlProvider.ts | 15 + .../sourceControl/SourceControlProvider.ts | 7 + .../SourceControlRepositoryService.test.ts | 38 +++ .../SourceControlRepositoryService.ts | 23 ++ apps/server/src/ws.ts | 8 + apps/web/src/components/CommandPalette.tsx | 291 ++++++++++++++++-- docs/user/source-control.md | 3 +- .../src/operations/projects.test.ts | 3 + .../client-runtime/src/operations/projects.ts | 8 +- .../client-runtime/src/state/sourceControl.ts | 4 + packages/contracts/src/ipc.ts | 5 + packages/contracts/src/rpc.ts | 13 + packages/contracts/src/sourceControl.ts | 10 + 18 files changed, 883 insertions(+), 58 deletions(-) diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index b48c7a0bdd94..918885842dd7 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -11,6 +11,7 @@ import { getCloneDestinationPath, getCloneDirectoryName, getDefaultCloneUrl, + isGitHubRepositoryShorthand, normalizePastedCloneUrl, resolveAddProjectPath, sortAddProjectProviderSources, @@ -31,7 +32,12 @@ import { inferProjectTitleFromPath, isWindowsPlatform, } from "@t3tools/client-runtime/state/projects"; -import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { + CommandId, + type EnvironmentId, + ProjectId, + type SourceControlRepositoryInfo, +} from "@t3tools/contracts"; import { CommonActions, StackActions, useNavigation } from "@react-navigation/native"; import { SymbolView } from "../../components/AppSymbol"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; @@ -55,6 +61,7 @@ import { useThemeColor } from "../../lib/useThemeColor"; import { uuidv4 } from "../../lib/uuid"; import { useAtomCommand } from "../../state/use-atom-command"; import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; +import { useDebouncedValue } from "../../state/queries"; import { useRemoteConnectionStatus, useRemoteEnvironmentRuntime, @@ -72,6 +79,8 @@ interface EnvironmentOption { readonly connectionErrorTraceId: string | null; } +const REPOSITORY_SEARCH_DEBOUNCE_MS = 100; + const environmentOptionOrder = Order.mapInput( Order.Struct({ label: Order.String, @@ -651,12 +660,84 @@ export function AddProjectRepositoryScreen(props: { const lookupRepositoryQuery = useAtomQueryRunner(sourceControlEnvironment.repository, { reportFailure: false, }); + const searchRepositoriesQuery = useAtomQueryRunner(sourceControlEnvironment.repositorySearch, { + reportFailure: false, + }); const navigation = useNavigation(); + const iconColor = useThemeColor("--color-icon"); const environment = useEnvironmentFromParam(props.environmentId); const source = sourceFromParam(props.source); const [repositoryInput, setRepositoryInput] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); + const [repositories, setRepositories] = + useState | null>(null); + const normalizedRepositoryInput = repositoryInput.trim(); + const debouncedRepositoryInput = useDebouncedValue( + normalizedRepositoryInput, + REPOSITORY_SEARCH_DEBOUNCE_MS, + ); + const githubRepositorySearchEnvironmentId = + source === "github" ? (environment?.environmentId ?? null) : null; + + useEffect(() => { + if (githubRepositorySearchEnvironmentId === null) { + return; + } + if ( + normalizedRepositoryInput.length === 0 || + normalizedRepositoryInput !== debouncedRepositoryInput + ) { + setIsSubmitting(normalizedRepositoryInput.length > 0); + return; + } + + let cancelled = false; + setError(null); + setIsSubmitting(true); + void searchRepositoriesQuery({ + environmentId: githubRepositorySearchEnvironmentId, + input: { + provider: "github", + query: debouncedRepositoryInput, + }, + }).then((result) => { + if (cancelled) { + return; + } + setIsSubmitting(false); + if (AsyncResult.isFailure(result)) { + setError(errorMessage(Cause.squash(result.cause))); + } else { + setRepositories(result.value); + } + }); + + return () => { + cancelled = true; + }; + }, [ + debouncedRepositoryInput, + githubRepositorySearchEnvironmentId, + normalizedRepositoryInput, + searchRepositoriesQuery, + ]); + + const selectRepository = useCallback( + (repository: SourceControlRepositoryInfo) => { + if (!environment) return; + navigation.dispatch( + StackActions.push("AddProjectDestination", { + environmentId: environment.environmentId, + source, + remoteUrl: getDefaultCloneUrl(repository), + repositoryTitle: repository.nameWithOwner, + repositoryName: getCloneDirectoryName(repository.nameWithOwner), + }), + ); + }, + [environment, navigation, source], + ); const lookupRepository = useCallback(async () => { if (!environment || repositoryInput.trim().length === 0 || isSubmitting) return; @@ -678,6 +759,11 @@ export function AddProjectRepositoryScreen(props: { return; } + if (provider === "github" && !isGitHubRepositoryShorthand(repositoryInput)) { + setIsSubmitting(false); + return; + } + const result = await lookupRepositoryQuery({ environmentId: environment.environmentId, input: { @@ -710,7 +796,13 @@ export function AddProjectRepositoryScreen(props: { { + if (value.trim() !== normalizedRepositoryInput) { + setRepositories(null); + setError(null); + } + setRepositoryInput(value); + }} autoCapitalize="none" autoCorrect={false} placeholder={ @@ -718,15 +810,45 @@ export function AddProjectRepositoryScreen(props: { ? "https://github.com/org/repo.git" : addProjectRemoteSourcePathHint(source) } - returnKeyType="next" + returnKeyType={source === "github" ? "done" : "next"} onSubmitEditing={() => void lookupRepository()} /> - void lookupRepository()} - loading={isSubmitting} - /> + {source === "github" ? null : ( + void lookupRepository()} + loading={isSubmitting} + /> + )} + {source === "github" && isSubmitting ? ( + + + + ) : null} + {repositories ? ( + <> + Repositories + + {repositories.length === 0 ? ( + + No repositories found. + + ) : ( + repositories.map((repository, index) => ( + } + isFirst={index === 0} + onPress={() => selectRepository(repository)} + /> + )) + )} + + + ) : null} ) : ( 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..0b7f3cb7e8cd 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -562,6 +562,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { cwd: input.cwd, args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"], }).pipe(Effect.map((result) => JSON.parse(result.stdout))), + searchRepositories: () => Effect.succeed([]), createRepository: (input) => Effect.fail( new GitHubCli.GitHubCliCommandError({ diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 964ed3d021c1..b78ec03b0d85 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -16,6 +16,33 @@ const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ stderrTruncated: false, }); +const repositorySearchOutput = (...fullNames: ReadonlyArray): VcsProcess.VcsProcessOutput => + processOutput( + JSON.stringify( + fullNames.map((fullName) => ({ fullName, url: `https://github.com/${fullName}` })), + ), + ); + +const repositorySearchArgs = (input: { + readonly query: string; + readonly owner?: string; + readonly includeForks: boolean; +}): ReadonlyArray => [ + "search", + "repos", + "--match", + "name", + ...(input.owner === undefined ? [] : ["--owner", input.owner]), + "--include-forks", + String(input.includeForks), + "--limit", + "20", + "--json", + "fullName,url", + "--", + input.query, +]; + const mockRun = vi.fn(); const layer = GitHubCli.layer.pipe( @@ -289,6 +316,138 @@ describe("GitHubCli.layer", () => { url: "https://github.com/octocat/codething-mvp", sshUrl: "git@github.com:octocat/codething-mvp.git", }); + expect(mockRun).toHaveBeenCalledTimes(1); + expect(mockRun).toHaveBeenCalledWith({ + operation: "GitHubCli.execute", + command: "gh", + args: ["repo", "view", "octocat/codething-mvp", "--json", "nameWithOwner,url,sshUrl"], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("searches repositories with the authenticated owner's matches first", () => + Effect.gen(function* () { + mockRun + .mockReturnValueOnce(Effect.succeed(processOutput("current-user\n"))) + .mockReturnValueOnce(Effect.succeed(repositorySearchOutput("current-user/skills"))) + .mockReturnValueOnce( + Effect.succeed( + repositorySearchOutput( + "mattpocock/skills", + "current-user/skills", + "someone-else/skills", + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.searchRepositories({ + cwd: "/repo", + query: "skills", + }); + + assert.deepStrictEqual( + result.map((repository) => repository.nameWithOwner), + ["current-user/skills", "mattpocock/skills", "someone-else/skills"], + ); + assert.equal(result[0]?.sshUrl, "git@github.com:current-user/skills.git"); + expect(mockRun).toHaveBeenCalledTimes(3); + expect(mockRun).toHaveBeenNthCalledWith(1, { + operation: "GitHubCli.execute", + command: "gh", + args: ["api", "user", "--hostname", "github.com", "--jq", ".login"], + cwd: "/repo", + timeoutMs: 30_000, + }); + expect(mockRun).toHaveBeenNthCalledWith(2, { + operation: "GitHubCli.execute", + command: "gh", + args: repositorySearchArgs({ query: "skills", owner: "current-user", includeForks: true }), + cwd: "/repo", + timeoutMs: 30_000, + }); + expect(mockRun).toHaveBeenNthCalledWith(3, { + operation: "GitHubCli.execute", + command: "gh", + args: repositorySearchArgs({ query: "skills", includeForks: false }), + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("returns an exact owner and repository path before other owner matches", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed(repositorySearchOutput("octocat/codething-tools", "octocat/codething-mvp")), + ); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.searchRepositories({ + cwd: "/repo", + query: " octocat/codething-mvp ", + }); + + assert.deepStrictEqual(result, [ + { + nameWithOwner: "octocat/codething-mvp", + url: "https://github.com/octocat/codething-mvp", + sshUrl: "git@github.com:octocat/codething-mvp.git", + }, + { + nameWithOwner: "octocat/codething-tools", + url: "https://github.com/octocat/codething-tools", + sshUrl: "git@github.com:octocat/codething-tools.git", + }, + ]); + expect(mockRun).toHaveBeenCalledTimes(1); + }).pipe(Effect.provide(layer)), + ); + + it.effect("searches within an owner for a partial repository path", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(repositorySearchOutput("octocat/codething-mvp"))); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.searchRepositories({ cwd: "/repo", query: "octocat/code" }); + + assert.deepStrictEqual( + result.map((repository) => repository.nameWithOwner), + ["octocat/codething-mvp"], + ); + expect(mockRun).toHaveBeenCalledTimes(1); + }).pipe(Effect.provide(layer)), + ); + + it.effect("returns no repository search results when there are no matches", () => + Effect.gen(function* () { + mockRun + .mockReturnValueOnce(Effect.succeed(processOutput("current-user\n"))) + .mockReturnValueOnce(Effect.succeed(processOutput("[]"))) + .mockReturnValueOnce(Effect.succeed(processOutput("[]"))); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.searchRepositories({ cwd: "/repo", query: "does-not-exist" }); + + assert.deepStrictEqual(result, []); + expect(mockRun).toHaveBeenCalledTimes(3); + }).pipe(Effect.provide(layer)), + ); + + it.effect("reports a missing authenticated account without fabricating a cause", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const gh = yield* GitHubCli.GitHubCli; + const error = yield* gh + .searchRepositories({ cwd: "/repo", query: "skills" }) + .pipe(Effect.flip); + + assert.equal(error._tag, "GitHubCliAuthenticationError"); + assert.notProperty(error, "cause"); + expect(mockRun).toHaveBeenCalledTimes(1); }).pipe(Effect.provide(layer)), ); diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 974574cbd20e..d2c199cd0f29 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -1,5 +1,8 @@ +import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; import * as Result from "effect/Result"; @@ -18,6 +21,8 @@ import { } from "./gitHubPullRequests.ts"; const DEFAULT_TIMEOUT_MS = 30_000; +const GITHUB_DOT_COM = "github.com"; +const AUTH_ACCOUNT_CACHE_TTL = Duration.minutes(5); const gitHubCliFailureFields = { command: Schema.Literal("gh"), @@ -40,7 +45,11 @@ export class GitHubCliUnavailableError extends Schema.TaggedErrorClass()( "GitHubCliAuthenticationError", - gitHubCliFailureFields, + { + command: Schema.Literal("gh"), + cwd: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, ) { get detail(): string { return "GitHub CLI is not authenticated. Run `gh auth login` and retry."; @@ -148,6 +157,19 @@ 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, @@ -158,6 +180,7 @@ export const GitHubCliError = Schema.Union([ GitHubChangeRequestListDecodeError, GitHubPullRequestDecodeError, GitHubRepositoryDecodeError, + GitHubRepositorySearchDecodeError, ]); export type GitHubCliError = typeof GitHubCliError.Type; @@ -241,6 +264,11 @@ export class GitHubCli extends Context.Service< readonly repository: string; }) => Effect.Effect; + readonly searchRepositories: (input: { + readonly cwd: string; + readonly query: string; + }) => Effect.Effect, GitHubCliError>; + readonly createRepository: (input: { readonly cwd: string; readonly repository: string; @@ -276,6 +304,14 @@ const decodeRawGitHubRepositoryCloneUrls = Schema.decodeEffect( Schema.fromJsonString(RawGitHubRepositoryCloneUrlsSchema), ); +const RawGitHubRepositorySearchResultSchema = Schema.Struct({ + fullName: TrimmedNonEmptyString, + url: Schema.URLFromString, +}); +const decodeRawGitHubRepositorySearchResults = Schema.decodeEffect( + Schema.fromJsonString(Schema.Array(RawGitHubRepositorySearchResultSchema)), +); + function normalizeRepositoryCloneUrls( raw: Schema.Schema.Type, ): GitHubRepositoryCloneUrls { @@ -286,6 +322,16 @@ function normalizeRepositoryCloneUrls( }; } +function normalizeRepositorySearchResult( + raw: Schema.Schema.Type, +): GitHubRepositoryCloneUrls { + return { + nameWithOwner: raw.fullName, + url: raw.url.toString(), + sshUrl: `git@${GITHUB_DOT_COM}:${raw.fullName}.git`, + }; +} + /** * `gh repo create` prints the canonical URL of the new repository on stdout * (e.g. `https://github.com/owner/repo`). Reading it back here avoids a @@ -339,6 +385,148 @@ export const make = Effect.gen(function* () { }) .pipe(Effect.mapError((error) => fromVcsError({ command: "gh", cwd: input.cwd }, error))); + const authenticatedAccountCache = yield* Cache.makeWith( + (cwd) => + execute({ + cwd, + args: ["api", "user", "--hostname", GITHUB_DOT_COM, "--jq", ".login"], + }).pipe( + Effect.flatMap((result) => { + const account = result.stdout.trim(); + return account.length > 0 + ? Effect.succeed(account) + : Effect.fail( + new GitHubCliAuthenticationError({ + command: "gh", + cwd, + }), + ); + }), + ), + { + capacity: 16, + timeToLive: (exit) => (Exit.isSuccess(exit) ? AUTH_ACCOUNT_CACHE_TTL : Duration.zero), + }, + ); + + const searchGitHubRepositories = Effect.fn("GitHubCli.searchGitHubRepositories")( + function* (input: { + readonly cwd: string; + readonly query: string; + readonly owner?: string; + readonly includeForks: boolean; + }) { + const result = yield* execute({ + cwd: input.cwd, + args: [ + "search", + "repos", + "--match", + "name", + ...(input.owner === undefined ? [] : ["--owner", input.owner]), + "--include-forks", + String(input.includeForks), + "--limit", + "20", + "--json", + "fullName,url", + "--", + input.query, + ], + }); + const repositories = yield* decodeRawGitHubRepositorySearchResults(result.stdout.trim()).pipe( + Effect.mapError( + (cause) => + new GitHubRepositorySearchDecodeError({ + command: "gh", + cwd: input.cwd, + cause, + }), + ), + ); + return repositories.map(normalizeRepositorySearchResult); + }, + ); + + const getRepositoryCloneUrls = Effect.fn("GitHubCli.getRepositoryCloneUrls")(function* (input: { + readonly cwd: string; + readonly repository: string; + }) { + const repository = input.repository.trim(); + const result = yield* execute({ + cwd: input.cwd, + args: ["repo", "view", repository, "--json", "nameWithOwner,url,sshUrl"], + }); + const raw = result.stdout.trim(); + const urls = yield* decodeRawGitHubRepositoryCloneUrls(raw).pipe( + Effect.mapError( + (cause) => + new GitHubRepositoryDecodeError({ + command: "gh", + cwd: input.cwd, + cause, + }), + ), + ); + return normalizeRepositoryCloneUrls(urls); + }); + + const searchRepositories = Effect.fn("GitHubCli.searchRepositories")(function* (input: { + readonly cwd: string; + readonly query: string; + }) { + const query = input.query.trim(); + if (query.length === 0) { + return []; + } + + const slashIndex = query.indexOf("/"); + if (slashIndex >= 0) { + const owner = query.slice(0, slashIndex).trim(); + const repositoryName = query.slice(slashIndex + 1).trim(); + if (owner.length === 0 || repositoryName.length === 0) { + return []; + } + const repositories = yield* searchGitHubRepositories({ + cwd: input.cwd, + query: repositoryName, + owner, + includeForks: true, + }); + const exactNameWithOwner = `${owner}/${repositoryName}`.toLowerCase(); + return [...repositories].sort((left, right) => { + const leftIsExact = left.nameWithOwner.toLowerCase() === exactNameWithOwner; + const rightIsExact = right.nameWithOwner.toLowerCase() === exactNameWithOwner; + return Number(rightIsExact) - Number(leftIsExact); + }); + } + + const account = yield* Cache.get(authenticatedAccountCache, input.cwd); + const [ownerMatches, globalMatches] = yield* Effect.all( + [ + searchGitHubRepositories({ + cwd: input.cwd, + query, + owner: account, + includeForks: true, + }), + searchGitHubRepositories({ + cwd: input.cwd, + query, + includeForks: false, + }), + ], + { concurrency: "unbounded" }, + ); + const repositories = new Map(); + for (const repository of [...ownerMatches, ...globalMatches]) { + if (!repositories.has(repository.nameWithOwner)) { + repositories.set(repository.nameWithOwner, repository); + } + } + return [...repositories.values()].slice(0, 20); + }); + return GitHubCli.of({ execute, listOpenPullRequests: (input) => @@ -412,26 +600,8 @@ export const make = Effect.gen(function* () { ), ), ), - getRepositoryCloneUrls: (input) => - execute({ - cwd: input.cwd, - args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"], - }).pipe( - Effect.map((result) => result.stdout.trim()), - Effect.flatMap((raw) => - decodeRawGitHubRepositoryCloneUrls(raw).pipe( - Effect.mapError( - (cause) => - new GitHubRepositoryDecodeError({ - command: "gh", - cwd: input.cwd, - cause, - }), - ), - ), - ), - Effect.map(normalizeRepositoryCloneUrls), - ), + getRepositoryCloneUrls, + searchRepositories, createRepository: (input) => execute({ cwd: input.cwd, diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 3dcc8ab826a6..d32365f55c5a 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -257,6 +257,21 @@ export const make = Effect.gen(function* () { }), ), ), + searchRepositories: (input) => + github.searchRepositories(input).pipe( + 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, + }), + ), + ), createRepository: (input) => github.createRepository(input).pipe( Effect.mapError( diff --git a/apps/server/src/sourceControl/SourceControlProvider.ts b/apps/server/src/sourceControl/SourceControlProvider.ts index 5f93dbcaa425..f9615dd1a8de 100644 --- a/apps/server/src/sourceControl/SourceControlProvider.ts +++ b/apps/server/src/sourceControl/SourceControlProvider.ts @@ -111,6 +111,13 @@ export class SourceControlProvider extends Context.Service< readonly context?: SourceControlProviderContext; readonly repository: string; }) => Effect.Effect; + readonly searchRepositories?: (input: { + readonly cwd: string; + readonly query: string; + }) => Effect.Effect< + ReadonlyArray, + SourceControlProviderError + >; readonly createRepository: (input: { readonly cwd: string; readonly repository: string; diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 861da9a10e05..63f02bfda319 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -117,6 +117,44 @@ it.effect("looks up repositories through the requested provider without search", }).pipe(Effect.provide(makeLayer({ provider }))); }); +it.effect("returns all repository search results in provider order", () => { + const calls: Array<{ cwd: string; query: string }> = []; + const provider = makeProvider({ + searchRepositories: (input) => + Effect.sync(() => { + calls.push(input); + return [ + CLONE_URLS, + { + nameWithOwner: "mattpocock/t3code", + url: "https://github.com/mattpocock/t3code", + sshUrl: "git@github.com:mattpocock/t3code.git", + }, + ]; + }), + }); + + return Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const result = yield* service.searchRepositories({ + provider: "github", + query: "t3code", + cwd: "/workspace", + }); + + assert.deepStrictEqual(result, [ + { provider: "github", ...CLONE_URLS }, + { + provider: "github", + nameWithOwner: "mattpocock/t3code", + url: "https://github.com/mattpocock/t3code", + sshUrl: "git@github.com:mattpocock/t3code.git", + }, + ]); + 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", diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index 1b46369e25c4..a0a7d32a1d4d 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -17,6 +17,8 @@ import { type SourceControlRepositoryCloneUrls, type SourceControlRepositoryInfo, type SourceControlRepositoryLookupInput, + type SourceControlRepositorySearchInput, + type SourceControlRepositorySearchResult, } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; @@ -30,6 +32,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; @@ -126,6 +131,22 @@ 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); + const cwd = input.cwd ?? config.cwd; + const query = input.query.trim(); + const urls = provider.searchRepositories + ? yield* provider.searchRepositories({ cwd, query }) + : [yield* provider.getRepositoryCloneUrls({ cwd, repository: query })]; + return urls.map((repository) => toRepositoryInfo(providerKind, repository)); + }, + ); + const normalizeDestinationPath = Effect.fn("SourceControlRepositoryService.normalizeDestination")( function* (destinationPath: string) { const trimmed = destinationPath.trim(); @@ -276,6 +297,8 @@ export const make = Effect.gen(function* () { ); return SourceControlRepositoryService.of({ + searchRepositories: (input) => + searchRepositories(input).pipe(mapRepositoryError("searchRepositories", input.provider)), lookupRepository: (input) => lookupRepository(input).pipe(mapRepositoryError("lookupRepository", input.provider)), cloneRepository: (input) => diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 226c82cdb1ac..a6b805ecf3c4 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1886,6 +1886,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, diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index c5ec3f095167..878d017b17c3 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -7,6 +7,7 @@ import { getCloneDestinationPath, getCloneDirectoryName, getDefaultCloneUrl, + isGitHubRepositoryShorthand, normalizePastedCloneUrl, } from "@t3tools/client-runtime/operations/projects"; import { connectionStatusText } from "@t3tools/client-runtime/connection"; @@ -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 { useDebouncedValue, useThreadSearch } from "../state/queries"; import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; import { appendBrowsePathSegment, @@ -169,6 +170,7 @@ import { import type { Project } from "../types"; const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = []; +const REPOSITORY_SEARCH_DEBOUNCE_MS = 100; function projectFavicon(project: Project) { return ( @@ -213,6 +215,8 @@ type AddProjectCloneFlow = readonly step: "repository"; readonly environmentId: EnvironmentId; readonly source: AddProjectRemoteSource; + readonly repositories: ReadonlyArray | null; + readonly repositoryQuery: string | null; } | { readonly step: "confirm"; @@ -221,6 +225,7 @@ type AddProjectCloneFlow = readonly repositoryInput: string; readonly repository: SourceControlRepositoryInfo | null; readonly remoteUrl: string; + readonly searchResults: ReadonlyArray | null; }; const REMOTE_PROJECT_SOURCES: ReadonlyArray = [ @@ -255,7 +260,7 @@ function remoteProjectSourceLabel(source: AddProjectRemoteSource): string { function remoteProjectSourcePathHint(source: AddProjectRemoteSource): string { switch (source) { case "github": - return "owner/repo"; + return "owner/repo or repository name"; case "gitlab": return "group/project"; case "bitbucket": @@ -291,6 +296,7 @@ function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: stri function remoteProjectInputPlaceholder(flow: AddProjectCloneFlow | null): string | null { if (!flow) return null; if (flow.step === "confirm") return null; + if (flow.source === "github") return "Search repositories"; if (flow.source === "url") { return "Enter Git clone URL"; } @@ -575,6 +581,9 @@ function OpenCommandPaletteDialog(props: { const lookupRepository = useAtomQueryRunner(sourceControlEnvironment.repository, { reportFailure: false, }); + const searchRepositories = useAtomQueryRunner(sourceControlEnvironment.repositorySearch, { + reportFailure: false, + }); const loadBrowsePath = useAtomQueryRunner(filesystemEnvironment.browse, { reportFailure: false, reportDefect: false, @@ -642,6 +651,85 @@ function OpenCommandPaletteDialog(props: { const [addProjectCloneFlow, setAddProjectCloneFlow] = useState(null); const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false); const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false); + const githubRepositorySearchEnvironmentId = + addProjectCloneFlow?.source === "github" && addProjectCloneFlow.step === "repository" + ? addProjectCloneFlow.environmentId + : null; + const normalizedRepositorySearchQuery = + githubRepositorySearchEnvironmentId === null ? "" : query.trim(); + const hasCurrentGithubRepositorySearchResults = + addProjectCloneFlow?.step === "repository" && + addProjectCloneFlow.source === "github" && + addProjectCloneFlow.repositories !== null && + addProjectCloneFlow.repositoryQuery === normalizedRepositorySearchQuery; + const debouncedRepositorySearchQuery = useDebouncedValue( + normalizedRepositorySearchQuery, + REPOSITORY_SEARCH_DEBOUNCE_MS, + ); + + useEffect(() => { + if (githubRepositorySearchEnvironmentId === null) { + setIsRemoteProjectLookingUp(false); + return; + } + if (hasCurrentGithubRepositorySearchResults) { + setIsRemoteProjectLookingUp(false); + return; + } + if ( + normalizedRepositorySearchQuery.length === 0 || + normalizedRepositorySearchQuery !== debouncedRepositorySearchQuery + ) { + setIsRemoteProjectLookingUp(normalizedRepositorySearchQuery.length > 0); + return; + } + + let cancelled = false; + setIsRemoteProjectLookingUp(true); + void searchRepositories({ + environmentId: githubRepositorySearchEnvironmentId, + input: { + provider: "github", + query: debouncedRepositorySearchQuery, + }, + }).then((searchResult) => { + if (cancelled) { + return; + } + setIsRemoteProjectLookingUp(false); + if (searchResult._tag === "Failure") { + return; + } + + setAddProjectCloneFlow((currentFlow) => { + if ( + currentFlow?.source !== "github" || + currentFlow.environmentId !== githubRepositorySearchEnvironmentId || + currentFlow.step !== "repository" + ) { + return currentFlow; + } + return { + step: "repository", + environmentId: currentFlow.environmentId, + source: "github", + repositories: searchResult.value, + repositoryQuery: debouncedRepositorySearchQuery, + }; + }); + setHighlightedItemValue(null); + }); + + return () => { + cancelled = true; + }; + }, [ + debouncedRepositorySearchQuery, + githubRepositorySearchEnvironmentId, + hasCurrentGithubRepositorySearchResults, + normalizedRepositorySearchQuery, + searchRepositories, + ]); const projectGroupingSettings = useMemo( () => selectProjectGroupingSettings(clientSettings), [clientSettings], @@ -1177,6 +1265,7 @@ function OpenCommandPaletteDialog(props: { } function popView(): void { + setIsRemoteProjectLookingUp(false); browseNavigation.invalidate(); setAddProjectCloneFlow(null); if (viewStack.length <= 1) { @@ -1187,7 +1276,53 @@ function OpenCommandPaletteDialog(props: { setQuery(""); } + function backAddProjectCloneFlow(): void { + if (!addProjectCloneFlow) { + popView(); + return; + } + + setIsRemoteProjectLookingUp(false); + browseNavigation.invalidate(); + setHighlightedItemValue(null); + if (addProjectCloneFlow.step === "repository") { + popView(); + return; + } + if (addProjectCloneFlow.searchResults) { + setAddProjectCloneFlow({ + step: "repository", + environmentId: addProjectCloneFlow.environmentId, + source: "github", + repositories: addProjectCloneFlow.searchResults, + repositoryQuery: addProjectCloneFlow.repositoryInput, + }); + setQuery(addProjectCloneFlow.repositoryInput); + } else { + setAddProjectCloneFlow({ + step: "repository", + environmentId: addProjectCloneFlow.environmentId, + source: addProjectCloneFlow.source, + repositories: null, + repositoryQuery: null, + }); + setQuery(addProjectCloneFlow.repositoryInput); + } + setBrowseGeneration((generation) => generation + 1); + } + function handleQueryChange(nextQuery: string): void { + if ( + githubRepositorySearchEnvironmentId !== null && + nextQuery.trim() !== normalizedRepositorySearchQuery + ) { + setIsRemoteProjectLookingUp(nextQuery.trim().length > 0); + setAddProjectCloneFlow((currentFlow) => + currentFlow?.source === "github" && currentFlow.step === "repository" + ? { ...currentFlow, repositories: null, repositoryQuery: null } + : currentFlow, + ); + } browseNavigation.invalidate(); setHighlightedItemValue(null); setQuery(nextQuery); @@ -1231,7 +1366,13 @@ function OpenCommandPaletteDialog(props: { const startAddProjectClone = useCallback( (environmentId: EnvironmentId, source: AddProjectRemoteSource): void => { setAddProjectEnvironmentId(environmentId); - setAddProjectCloneFlow({ step: "repository", environmentId, source }); + setAddProjectCloneFlow({ + step: "repository", + environmentId, + source, + repositories: null, + repositoryQuery: null, + }); pushPaletteView({ addonIcon: remoteProjectSourceIcon(source, ADDON_ICON_CLASS), groups: [], @@ -1839,6 +1980,33 @@ function OpenCommandPaletteDialog(props: { return getAddProjectInitialQueryForEnvironment(environmentId); } + function selectAddProjectRepository(repository: SourceControlRepositoryInfo): void { + if ( + addProjectCloneFlow?.step !== "repository" || + addProjectCloneFlow.source !== "github" || + addProjectCloneFlow.repositories === null + ) { + return; + } + setIsRemoteProjectLookingUp(false); + const destinationPath = getCloneDestinationPath( + getDefaultCloneParentPath(addProjectCloneFlow.environmentId), + getCloneDirectoryName(repository.nameWithOwner), + ); + setAddProjectCloneFlow({ + step: "confirm", + environmentId: addProjectCloneFlow.environmentId, + source: addProjectCloneFlow.source, + repositoryInput: query.trim(), + repository, + remoteUrl: getDefaultCloneUrl(repository), + searchResults: addProjectCloneFlow.repositories, + }); + setHighlightedItemValue(null); + setQuery(destinationPath); + setBrowseGeneration((generation) => generation + 1); + } + async function submitAddProjectCloneFlow(destinationPathInput?: string): Promise { if (!addProjectCloneFlow) { return; @@ -1873,6 +2041,7 @@ function OpenCommandPaletteDialog(props: { repositoryInput: rawRepository, repository: null, remoteUrl: normalizePastedCloneUrl(rawRepository), + searchResults: null, }); setHighlightedItemValue(null); setQuery(destinationPath); @@ -1880,6 +2049,10 @@ function OpenCommandPaletteDialog(props: { return; } + if (provider === "github" && !isGitHubRepositoryShorthand(rawRepository)) { + return; + } + setIsRemoteProjectLookingUp(true); const lookupResult = await lookupRepository({ environmentId: addProjectCloneFlow.environmentId, @@ -1913,6 +2086,7 @@ function OpenCommandPaletteDialog(props: { repositoryInput: rawRepository, repository, remoteUrl: getDefaultCloneUrl(repository), + searchResults: null, }); setHighlightedItemValue(null); setQuery(destinationPath); @@ -1920,6 +2094,10 @@ function OpenCommandPaletteDialog(props: { return; } + if (addProjectCloneFlow.step !== "confirm") { + return; + } + const rawDestination = (destinationPathInput ?? query).trim(); if (rawDestination.length === 0 || isRemoteProjectCloning) { return; @@ -2064,9 +2242,42 @@ function OpenCommandPaletteDialog(props: { }; }, [addProjectCloneFlow]); + const repositorySearchGroups: CommandPaletteView["groups"] = + addProjectCloneFlow?.step === "repository" && + addProjectCloneFlow.source === "github" && + addProjectCloneFlow.repositories !== null + ? [ + { + value: "repositories", + label: "Repositories", + items: addProjectCloneFlow.repositories.map((repository) => ({ + kind: "action" as const, + value: `repository:${repository.nameWithOwner}`, + searchTerms: [repository.nameWithOwner, repository.url], + title: repository.nameWithOwner, + description: repository.url, + icon: , + keepOpen: true, + run: async () => selectAddProjectRepository(repository), + })), + }, + ] + : []; + const isGitHubRepositorySearchPending = + githubRepositorySearchEnvironmentId !== null && + normalizedRepositorySearchQuery.length > 0 && + !hasCurrentGithubRepositorySearchResults && + (normalizedRepositorySearchQuery !== debouncedRepositorySearchQuery || + isRemoteProjectLookingUp); + let displayedGroups: CommandPaletteView["groups"] = filteredGroups; if (addProjectCloneFlow?.step === "repository") { - displayedGroups = []; + displayedGroups = + addProjectCloneFlow.source !== "github" || + normalizedRepositorySearchQuery.length === 0 || + isGitHubRepositorySearchPending + ? [] + : repositorySearchGroups; } else if (addProjectCloneFlow?.step === "confirm") { displayedGroups = relativePathNeedsActiveProject ? [] : cloneDestinationBrowseGroups; } else if (isBrowsing) { @@ -2077,6 +2288,10 @@ function OpenCommandPaletteDialog(props: { remoteProjectInputPlaceholder(addProjectCloneFlow) ?? getCommandPaletteInputPlaceholder(paletteMode); const isSubmenu = paletteMode === "submenu" || paletteMode === "submenu-browse"; + const shouldAutoHighlightRepositoryResult = + addProjectCloneFlow?.step === "repository" && + addProjectCloneFlow.source === "github" && + displayedGroups.some((group) => group.items.length > 0); const hasHighlightedBrowseItem = highlightedItemValue?.startsWith("browse:") ?? false; const canSubmitBrowsePath = isBrowsing && @@ -2110,6 +2325,15 @@ function OpenCommandPaletteDialog(props: { query.trim().length > 0 && canCreateProjectInEnvironment(browseEnvironment?.connection.phase) && !isRemoteProjectPending; + const shouldOfferGitHubRepositoryLookup = + addProjectCloneFlow?.step === "repository" && + addProjectCloneFlow.source === "github" && + isGitHubRepositoryShorthand(query) && + !shouldAutoHighlightRepositoryResult && + !isGitHubRepositorySearchPending; + const shouldShowRemoteProjectAccessory = + addProjectCloneFlow?.step === "repository" && + (addProjectCloneFlow.source !== "github" || shouldOfferGitHubRepositoryLookup); const fileManagerName = getLocalFileManagerName(navigator.platform); const canOpenProjectFromFileManager = isBrowsing && @@ -2168,7 +2392,11 @@ function OpenCommandPaletteDialog(props: { } } - if (addProjectCloneFlow?.step === "repository" && event.key === "Enter") { + if ( + addProjectCloneFlow?.step === "repository" && + (addProjectCloneFlow.source !== "github" || shouldOfferGitHubRepositoryLookup) && + event.key === "Enter" + ) { event.preventDefault(); void submitAddProjectCloneFlow(); return; @@ -2191,7 +2419,7 @@ function OpenCommandPaletteDialog(props: { if (event.key === "Backspace" && query === "" && isSubmenu) { event.preventDefault(); - popView(); + backAddProjectCloneFlow(); } } @@ -2332,7 +2560,8 @@ function OpenCommandPaletteDialog(props: { ]); const inputAccessory = - addProjectCloneFlow?.step === "repository" ? ( + addProjectCloneFlow?.step === "repository" && + (addProjectCloneFlow.source !== "github" || shouldOfferGitHubRepositoryLookup) ? ( ) : null; - const footerActionLabel = - addProjectCloneFlow?.step === "repository" - ? (remoteProjectButtonLabel ?? "Continue") - : !canSubmitBrowsePath || hasHighlightedBrowseItem - ? "Select" - : undefined; + const footerActionLabel = shouldShowRemoteProjectAccessory + ? (remoteProjectButtonLabel ?? "Continue") + : !canSubmitBrowsePath || hasHighlightedBrowseItem + ? "Select" + : undefined; const footerTrailing = canOpenProjectFromFileManager ? ( @@ -2500,7 +2731,17 @@ function OpenCommandPaletteDialog(props: { 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.", + : addProjectCloneFlow.source === "github" + ? normalizedRepositorySearchQuery.length === 0 + ? "Start typing to search GitHub repositories." + : isGitHubRepositorySearchPending + ? "Searching repositories…" + : addProjectCloneFlow.repositories === null + ? "Unable to search repositories. Try again." + : addProjectCloneFlow.repositories.length === 0 + ? "No repositories found." + : "Start typing to search GitHub repositories." + : "Enter a repository path and press Enter to look it up.", } : addProjectCloneFlow?.step === "confirm" ? { emptyStateMessage: "Choose a destination path and press Enter to clone." } diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 916536bbe736..67ea088a0e4e 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -19,7 +19,8 @@ T3 Code works with the platforms your team already uses: - Open the Command Palette (`Cmd/Ctrl + K`) → **Add Project** - 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 +- For GitHub, start typing a repository name and choose from the matches; repositories owned by your signed-in account appear first +- For other providers, enter the repository path (`group/project`, `workspace/repository`, or `project/repository`) or paste a full Git URL, then pick a destination and start coding **Publish local projects to the cloud** diff --git a/packages/client-runtime/src/operations/projects.test.ts b/packages/client-runtime/src/operations/projects.test.ts index cbbd121959d5..75574a42c144 100644 --- a/packages/client-runtime/src/operations/projects.test.ts +++ b/packages/client-runtime/src/operations/projects.test.ts @@ -17,6 +17,7 @@ import { getCloneDestinationPath, getCloneDirectoryName, getDefaultCloneUrl, + isGitHubRepositoryShorthand, normalizePastedCloneUrl, resolveAddProjectPath, sortAddProjectProviderSources, @@ -49,6 +50,8 @@ describe("add project shared logic", () => { }); it("routes owner/repository shorthand to GitHub over HTTPS", () => { + expect(isGitHubRepositoryShorthand("imputnet/helium")).toBe(true); + expect(isGitHubRepositoryShorthand("skills")).toBe(false); expect(normalizePastedCloneUrl("imputnet/helium")).toBe( "https://github.com/imputnet/helium.git", ); diff --git a/packages/client-runtime/src/operations/projects.ts b/packages/client-runtime/src/operations/projects.ts index 3f5fc3667f31..853a3021da8f 100644 --- a/packages/client-runtime/src/operations/projects.ts +++ b/packages/client-runtime/src/operations/projects.ts @@ -88,7 +88,7 @@ export function addProjectRemoteSourceLabel(source: AddProjectRemoteSource): str export function addProjectRemoteSourcePathHint(source: AddProjectRemoteSource): string { switch (source) { case "github": - return "owner/repo"; + return "owner/repo or repository name"; case "gitlab": return "group/project"; case "bitbucket": @@ -109,10 +109,14 @@ export function addProjectRemoteSourceProvider( const GITHUB_REPOSITORY_SHORTHAND = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]+(?:\.git)?$/; +export function isGitHubRepositoryShorthand(input: string): boolean { + return GITHUB_REPOSITORY_SHORTHAND.test(input.trim()); +} + /** Treat the common owner/repository shorthand as a public GitHub HTTPS URL. */ export function normalizePastedCloneUrl(input: string): string { const trimmed = input.trim(); - if (!GITHUB_REPOSITORY_SHORTHAND.test(trimmed)) return trimmed; + if (!isGitHubRepositoryShorthand(trimmed)) return trimmed; const repository = trimmed.endsWith(".git") ? trimmed : `${trimmed}.git`; return `https://github.com/${repository}`; } diff --git a/packages/client-runtime/src/state/sourceControl.ts b/packages/client-runtime/src/state/sourceControl.ts index c1598b49eaeb..b5de039dc743 100644 --- a/packages/client-runtime/src/state/sourceControl.ts +++ b/packages/client-runtime/src/state/sourceControl.ts @@ -24,6 +24,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, diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index e753596f3d33..ade2378e7077 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -100,6 +100,8 @@ import type { SourceControlPublishRepositoryResult, SourceControlRepositoryInfo, SourceControlRepositoryLookupInput, + SourceControlRepositorySearchInput, + SourceControlRepositorySearchResult, } from "./sourceControl.ts"; export interface ContextMenuItem { @@ -1311,6 +1313,9 @@ export interface EnvironmentApi { createUrl: (input: AssetCreateUrlInput) => Promise; }; sourceControl: { + searchRepositories: ( + input: SourceControlRepositorySearchInput, + ) => Promise; lookupRepository: ( input: SourceControlRepositoryLookupInput, ) => Promise; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 14363cfedff9..f0bd7c810f41 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -203,6 +203,8 @@ import { SourceControlRepositoryError, SourceControlRepositoryInfo, SourceControlRepositoryLookupInput, + SourceControlRepositorySearchInput, + SourceControlRepositorySearchResult, } 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: SourceControlRepositorySearchResult, + 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.ts b/packages/contracts/src/sourceControl.ts index 104aadd9161f..01974acf6f94 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -64,6 +64,16 @@ export const SourceControlRepositoryLookupInput = Schema.Struct({ }); export type SourceControlRepositoryLookupInput = typeof SourceControlRepositoryLookupInput.Type; +export const SourceControlRepositorySearchInput = Schema.Struct({ + provider: SourceControlProviderKind, + query: TrimmedNonEmptyString, + cwd: Schema.optional(TrimmedNonEmptyString), +}); +export type SourceControlRepositorySearchInput = typeof SourceControlRepositorySearchInput.Type; + +export const SourceControlRepositorySearchResult = Schema.Array(SourceControlRepositoryInfo); +export type SourceControlRepositorySearchResult = typeof SourceControlRepositorySearchResult.Type; + export const SourceControlCloneRepositoryInput = Schema.Struct({ provider: Schema.optional(SourceControlProviderKind), repository: Schema.optional(TrimmedNonEmptyString), From 91ad37335f1ca7f516aadd12a9e1c6ebc9b1046c Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:05:15 +1000 Subject: [PATCH 2/4] refactor(mobile): simplify repository search state --- .../features/projects/AddProjectScreen.tsx | 73 ++++++------------- 1 file changed, 22 insertions(+), 51 deletions(-) diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 918885842dd7..03415aa6a27b 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -660,9 +660,6 @@ export function AddProjectRepositoryScreen(props: { const lookupRepositoryQuery = useAtomQueryRunner(sourceControlEnvironment.repository, { reportFailure: false, }); - const searchRepositoriesQuery = useAtomQueryRunner(sourceControlEnvironment.repositorySearch, { - reportFailure: false, - }); const navigation = useNavigation(); const iconColor = useThemeColor("--color-icon"); const environment = useEnvironmentFromParam(props.environmentId); @@ -670,8 +667,6 @@ export function AddProjectRepositoryScreen(props: { const [repositoryInput, setRepositoryInput] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); - const [repositories, setRepositories] = - useState | null>(null); const normalizedRepositoryInput = repositoryInput.trim(); const debouncedRepositoryInput = useDebouncedValue( normalizedRepositoryInput, @@ -679,49 +674,26 @@ export function AddProjectRepositoryScreen(props: { ); const githubRepositorySearchEnvironmentId = source === "github" ? (environment?.environmentId ?? null) : null; - - useEffect(() => { - if (githubRepositorySearchEnvironmentId === null) { - return; - } - if ( - normalizedRepositoryInput.length === 0 || - normalizedRepositoryInput !== debouncedRepositoryInput - ) { - setIsSubmitting(normalizedRepositoryInput.length > 0); - return; - } - - let cancelled = false; - setError(null); - setIsSubmitting(true); - void searchRepositoriesQuery({ - environmentId: githubRepositorySearchEnvironmentId, - input: { - provider: "github", - query: debouncedRepositoryInput, - }, - }).then((result) => { - if (cancelled) { - return; - } - setIsSubmitting(false); - if (AsyncResult.isFailure(result)) { - setError(errorMessage(Cause.squash(result.cause))); - } else { - setRepositories(result.value); - } - }); - - return () => { - cancelled = true; - }; - }, [ - debouncedRepositoryInput, - githubRepositorySearchEnvironmentId, - normalizedRepositoryInput, - searchRepositoriesQuery, - ]); + const settledRepositoryInput = + normalizedRepositoryInput.length > 0 && normalizedRepositoryInput === debouncedRepositoryInput + ? debouncedRepositoryInput + : null; + const repositorySearch = useEnvironmentQuery( + githubRepositorySearchEnvironmentId !== null && settledRepositoryInput !== null + ? sourceControlEnvironment.repositorySearch({ + environmentId: githubRepositorySearchEnvironmentId, + input: { + provider: "github", + query: settledRepositoryInput, + }, + }) + : null, + ); + const repositories = repositorySearch.data; + const isRepositorySearchPending = + normalizedRepositoryInput.length > 0 && + (normalizedRepositoryInput !== debouncedRepositoryInput || repositorySearch.isPending); + const visibleError = error ?? repositorySearch.error; const selectRepository = useCallback( (repository: SourceControlRepositoryInfo) => { @@ -790,7 +762,7 @@ export function AddProjectRepositoryScreen(props: { return ( - {error ? : null} + {visibleError ? : null} {environment ? ( <> { if (value.trim() !== normalizedRepositoryInput) { - setRepositories(null); setError(null); } setRepositoryInput(value); @@ -821,7 +792,7 @@ export function AddProjectRepositoryScreen(props: { loading={isSubmitting} /> )} - {source === "github" && isSubmitting ? ( + {source === "github" && isRepositorySearchPending ? ( From 2a4c55e255c0828a71a0f9733b36980fc81c3b22 Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:37:18 +1000 Subject: [PATCH 3/4] refactor(github): simplify repository search flow --- .../src/sourceControl/GitHubCli.test.ts | 38 +---- apps/server/src/sourceControl/GitHubCli.ts | 81 +++------ apps/web/src/components/CommandPalette.tsx | 158 ++++-------------- 3 files changed, 57 insertions(+), 220 deletions(-) diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index b78ec03b0d85..a28353721b90 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -316,21 +316,12 @@ describe("GitHubCli.layer", () => { url: "https://github.com/octocat/codething-mvp", sshUrl: "git@github.com:octocat/codething-mvp.git", }); - expect(mockRun).toHaveBeenCalledTimes(1); - expect(mockRun).toHaveBeenCalledWith({ - operation: "GitHubCli.execute", - command: "gh", - args: ["repo", "view", "octocat/codething-mvp", "--json", "nameWithOwner,url,sshUrl"], - cwd: "/repo", - timeoutMs: 30_000, - }); }).pipe(Effect.provide(layer)), ); it.effect("searches repositories with the authenticated owner's matches first", () => Effect.gen(function* () { mockRun - .mockReturnValueOnce(Effect.succeed(processOutput("current-user\n"))) .mockReturnValueOnce(Effect.succeed(repositorySearchOutput("current-user/skills"))) .mockReturnValueOnce( Effect.succeed( @@ -353,22 +344,15 @@ describe("GitHubCli.layer", () => { ["current-user/skills", "mattpocock/skills", "someone-else/skills"], ); assert.equal(result[0]?.sshUrl, "git@github.com:current-user/skills.git"); - expect(mockRun).toHaveBeenCalledTimes(3); + expect(mockRun).toHaveBeenCalledTimes(2); expect(mockRun).toHaveBeenNthCalledWith(1, { operation: "GitHubCli.execute", command: "gh", - args: ["api", "user", "--hostname", "github.com", "--jq", ".login"], + args: repositorySearchArgs({ query: "skills", owner: "@me", includeForks: true }), cwd: "/repo", timeoutMs: 30_000, }); expect(mockRun).toHaveBeenNthCalledWith(2, { - operation: "GitHubCli.execute", - command: "gh", - args: repositorySearchArgs({ query: "skills", owner: "current-user", includeForks: true }), - cwd: "/repo", - timeoutMs: 30_000, - }); - expect(mockRun).toHaveBeenNthCalledWith(3, { operation: "GitHubCli.execute", command: "gh", args: repositorySearchArgs({ query: "skills", includeForks: false }), @@ -424,7 +408,6 @@ describe("GitHubCli.layer", () => { it.effect("returns no repository search results when there are no matches", () => Effect.gen(function* () { mockRun - .mockReturnValueOnce(Effect.succeed(processOutput("current-user\n"))) .mockReturnValueOnce(Effect.succeed(processOutput("[]"))) .mockReturnValueOnce(Effect.succeed(processOutput("[]"))); @@ -432,22 +415,7 @@ describe("GitHubCli.layer", () => { const result = yield* gh.searchRepositories({ cwd: "/repo", query: "does-not-exist" }); assert.deepStrictEqual(result, []); - expect(mockRun).toHaveBeenCalledTimes(3); - }).pipe(Effect.provide(layer)), - ); - - it.effect("reports a missing authenticated account without fabricating a cause", () => - Effect.gen(function* () { - mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); - - const gh = yield* GitHubCli.GitHubCli; - const error = yield* gh - .searchRepositories({ cwd: "/repo", query: "skills" }) - .pipe(Effect.flip); - - assert.equal(error._tag, "GitHubCliAuthenticationError"); - assert.notProperty(error, "cause"); - expect(mockRun).toHaveBeenCalledTimes(1); + expect(mockRun).toHaveBeenCalledTimes(2); }).pipe(Effect.provide(layer)), ); diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index d2c199cd0f29..07aa6f3aef2e 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -1,8 +1,5 @@ -import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; import * as Result from "effect/Result"; @@ -22,7 +19,6 @@ import { const DEFAULT_TIMEOUT_MS = 30_000; const GITHUB_DOT_COM = "github.com"; -const AUTH_ACCOUNT_CACHE_TTL = Duration.minutes(5); const gitHubCliFailureFields = { command: Schema.Literal("gh"), @@ -45,11 +41,7 @@ export class GitHubCliUnavailableError extends Schema.TaggedErrorClass()( "GitHubCliAuthenticationError", - { - command: Schema.Literal("gh"), - cwd: Schema.String, - cause: Schema.optional(Schema.Defect()), - }, + gitHubCliFailureFields, ) { get detail(): string { return "GitHub CLI is not authenticated. Run `gh auth login` and retry."; @@ -385,30 +377,6 @@ export const make = Effect.gen(function* () { }) .pipe(Effect.mapError((error) => fromVcsError({ command: "gh", cwd: input.cwd }, error))); - const authenticatedAccountCache = yield* Cache.makeWith( - (cwd) => - execute({ - cwd, - args: ["api", "user", "--hostname", GITHUB_DOT_COM, "--jq", ".login"], - }).pipe( - Effect.flatMap((result) => { - const account = result.stdout.trim(); - return account.length > 0 - ? Effect.succeed(account) - : Effect.fail( - new GitHubCliAuthenticationError({ - command: "gh", - cwd, - }), - ); - }), - ), - { - capacity: 16, - timeToLive: (exit) => (Exit.isSuccess(exit) ? AUTH_ACCOUNT_CACHE_TTL : Duration.zero), - }, - ); - const searchGitHubRepositories = Effect.fn("GitHubCli.searchGitHubRepositories")( function* (input: { readonly cwd: string; @@ -448,29 +416,6 @@ export const make = Effect.gen(function* () { }, ); - const getRepositoryCloneUrls = Effect.fn("GitHubCli.getRepositoryCloneUrls")(function* (input: { - readonly cwd: string; - readonly repository: string; - }) { - const repository = input.repository.trim(); - const result = yield* execute({ - cwd: input.cwd, - args: ["repo", "view", repository, "--json", "nameWithOwner,url,sshUrl"], - }); - const raw = result.stdout.trim(); - const urls = yield* decodeRawGitHubRepositoryCloneUrls(raw).pipe( - Effect.mapError( - (cause) => - new GitHubRepositoryDecodeError({ - command: "gh", - cwd: input.cwd, - cause, - }), - ), - ); - return normalizeRepositoryCloneUrls(urls); - }); - const searchRepositories = Effect.fn("GitHubCli.searchRepositories")(function* (input: { readonly cwd: string; readonly query: string; @@ -501,13 +446,12 @@ export const make = Effect.gen(function* () { }); } - const account = yield* Cache.get(authenticatedAccountCache, input.cwd); const [ownerMatches, globalMatches] = yield* Effect.all( [ searchGitHubRepositories({ cwd: input.cwd, query, - owner: account, + owner: "@me", includeForks: true, }), searchGitHubRepositories({ @@ -600,7 +544,26 @@ export const make = Effect.gen(function* () { ), ), ), - getRepositoryCloneUrls, + getRepositoryCloneUrls: (input) => + execute({ + cwd: input.cwd, + args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeRawGitHubRepositoryCloneUrls(raw).pipe( + Effect.mapError( + (cause) => + new GitHubRepositoryDecodeError({ + command: "gh", + cwd: input.cwd, + cause, + }), + ), + ), + ), + Effect.map(normalizeRepositoryCloneUrls), + ), searchRepositories, createRepository: (input) => execute({ diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 878d017b17c3..3fca1a29913c 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -215,8 +215,6 @@ type AddProjectCloneFlow = readonly step: "repository"; readonly environmentId: EnvironmentId; readonly source: AddProjectRemoteSource; - readonly repositories: ReadonlyArray | null; - readonly repositoryQuery: string | null; } | { readonly step: "confirm"; @@ -225,7 +223,6 @@ type AddProjectCloneFlow = readonly repositoryInput: string; readonly repository: SourceControlRepositoryInfo | null; readonly remoteUrl: string; - readonly searchResults: ReadonlyArray | null; }; const REMOTE_PROJECT_SOURCES: ReadonlyArray = [ @@ -581,9 +578,6 @@ function OpenCommandPaletteDialog(props: { const lookupRepository = useAtomQueryRunner(sourceControlEnvironment.repository, { reportFailure: false, }); - const searchRepositories = useAtomQueryRunner(sourceControlEnvironment.repositorySearch, { - reportFailure: false, - }); const loadBrowsePath = useAtomQueryRunner(filesystemEnvironment.browse, { reportFailure: false, reportDefect: false, @@ -657,79 +651,31 @@ function OpenCommandPaletteDialog(props: { : null; const normalizedRepositorySearchQuery = githubRepositorySearchEnvironmentId === null ? "" : query.trim(); - const hasCurrentGithubRepositorySearchResults = - addProjectCloneFlow?.step === "repository" && - addProjectCloneFlow.source === "github" && - addProjectCloneFlow.repositories !== null && - addProjectCloneFlow.repositoryQuery === normalizedRepositorySearchQuery; const debouncedRepositorySearchQuery = useDebouncedValue( normalizedRepositorySearchQuery, REPOSITORY_SEARCH_DEBOUNCE_MS, ); - - useEffect(() => { - if (githubRepositorySearchEnvironmentId === null) { - setIsRemoteProjectLookingUp(false); - return; - } - if (hasCurrentGithubRepositorySearchResults) { - setIsRemoteProjectLookingUp(false); - return; - } - if ( - normalizedRepositorySearchQuery.length === 0 || - normalizedRepositorySearchQuery !== debouncedRepositorySearchQuery - ) { - setIsRemoteProjectLookingUp(normalizedRepositorySearchQuery.length > 0); - return; - } - - let cancelled = false; - setIsRemoteProjectLookingUp(true); - void searchRepositories({ - environmentId: githubRepositorySearchEnvironmentId, - input: { - provider: "github", - query: debouncedRepositorySearchQuery, - }, - }).then((searchResult) => { - if (cancelled) { - return; - } - setIsRemoteProjectLookingUp(false); - if (searchResult._tag === "Failure") { - return; - } - - setAddProjectCloneFlow((currentFlow) => { - if ( - currentFlow?.source !== "github" || - currentFlow.environmentId !== githubRepositorySearchEnvironmentId || - currentFlow.step !== "repository" - ) { - return currentFlow; - } - return { - step: "repository", - environmentId: currentFlow.environmentId, - source: "github", - repositories: searchResult.value, - repositoryQuery: debouncedRepositorySearchQuery, - }; - }); - setHighlightedItemValue(null); - }); - - return () => { - cancelled = true; - }; - }, [ - debouncedRepositorySearchQuery, - githubRepositorySearchEnvironmentId, - hasCurrentGithubRepositorySearchResults, - normalizedRepositorySearchQuery, - searchRepositories, - ]); + const settledRepositorySearchQuery = + normalizedRepositorySearchQuery.length > 0 && + normalizedRepositorySearchQuery === debouncedRepositorySearchQuery + ? debouncedRepositorySearchQuery + : null; + const repositorySearch = useEnvironmentQuery( + githubRepositorySearchEnvironmentId !== null && settledRepositorySearchQuery !== null + ? sourceControlEnvironment.repositorySearch({ + environmentId: githubRepositorySearchEnvironmentId, + input: { + provider: "github", + query: settledRepositorySearchQuery, + }, + }) + : null, + ); + const repositorySearchResults = repositorySearch.data; + const isGitHubRepositorySearchPending = + normalizedRepositorySearchQuery.length > 0 && + (normalizedRepositorySearchQuery !== debouncedRepositorySearchQuery || + repositorySearch.isPending); const projectGroupingSettings = useMemo( () => selectProjectGroupingSettings(clientSettings), [clientSettings], @@ -1289,40 +1235,16 @@ function OpenCommandPaletteDialog(props: { popView(); return; } - if (addProjectCloneFlow.searchResults) { - setAddProjectCloneFlow({ - step: "repository", - environmentId: addProjectCloneFlow.environmentId, - source: "github", - repositories: addProjectCloneFlow.searchResults, - repositoryQuery: addProjectCloneFlow.repositoryInput, - }); - setQuery(addProjectCloneFlow.repositoryInput); - } else { - setAddProjectCloneFlow({ - step: "repository", - environmentId: addProjectCloneFlow.environmentId, - source: addProjectCloneFlow.source, - repositories: null, - repositoryQuery: null, - }); - setQuery(addProjectCloneFlow.repositoryInput); - } + setAddProjectCloneFlow({ + step: "repository", + environmentId: addProjectCloneFlow.environmentId, + source: addProjectCloneFlow.source, + }); + setQuery(addProjectCloneFlow.repositoryInput); setBrowseGeneration((generation) => generation + 1); } function handleQueryChange(nextQuery: string): void { - if ( - githubRepositorySearchEnvironmentId !== null && - nextQuery.trim() !== normalizedRepositorySearchQuery - ) { - setIsRemoteProjectLookingUp(nextQuery.trim().length > 0); - setAddProjectCloneFlow((currentFlow) => - currentFlow?.source === "github" && currentFlow.step === "repository" - ? { ...currentFlow, repositories: null, repositoryQuery: null } - : currentFlow, - ); - } browseNavigation.invalidate(); setHighlightedItemValue(null); setQuery(nextQuery); @@ -1370,8 +1292,6 @@ function OpenCommandPaletteDialog(props: { step: "repository", environmentId, source, - repositories: null, - repositoryQuery: null, }); pushPaletteView({ addonIcon: remoteProjectSourceIcon(source, ADDON_ICON_CLASS), @@ -1981,11 +1901,7 @@ function OpenCommandPaletteDialog(props: { } function selectAddProjectRepository(repository: SourceControlRepositoryInfo): void { - if ( - addProjectCloneFlow?.step !== "repository" || - addProjectCloneFlow.source !== "github" || - addProjectCloneFlow.repositories === null - ) { + if (addProjectCloneFlow?.step !== "repository" || addProjectCloneFlow.source !== "github") { return; } setIsRemoteProjectLookingUp(false); @@ -2000,7 +1916,6 @@ function OpenCommandPaletteDialog(props: { repositoryInput: query.trim(), repository, remoteUrl: getDefaultCloneUrl(repository), - searchResults: addProjectCloneFlow.repositories, }); setHighlightedItemValue(null); setQuery(destinationPath); @@ -2041,7 +1956,6 @@ function OpenCommandPaletteDialog(props: { repositoryInput: rawRepository, repository: null, remoteUrl: normalizePastedCloneUrl(rawRepository), - searchResults: null, }); setHighlightedItemValue(null); setQuery(destinationPath); @@ -2086,7 +2000,6 @@ function OpenCommandPaletteDialog(props: { repositoryInput: rawRepository, repository, remoteUrl: getDefaultCloneUrl(repository), - searchResults: null, }); setHighlightedItemValue(null); setQuery(destinationPath); @@ -2245,12 +2158,12 @@ function OpenCommandPaletteDialog(props: { const repositorySearchGroups: CommandPaletteView["groups"] = addProjectCloneFlow?.step === "repository" && addProjectCloneFlow.source === "github" && - addProjectCloneFlow.repositories !== null + repositorySearchResults !== null ? [ { value: "repositories", label: "Repositories", - items: addProjectCloneFlow.repositories.map((repository) => ({ + items: repositorySearchResults.map((repository) => ({ kind: "action" as const, value: `repository:${repository.nameWithOwner}`, searchTerms: [repository.nameWithOwner, repository.url], @@ -2263,13 +2176,6 @@ function OpenCommandPaletteDialog(props: { }, ] : []; - const isGitHubRepositorySearchPending = - githubRepositorySearchEnvironmentId !== null && - normalizedRepositorySearchQuery.length > 0 && - !hasCurrentGithubRepositorySearchResults && - (normalizedRepositorySearchQuery !== debouncedRepositorySearchQuery || - isRemoteProjectLookingUp); - let displayedGroups: CommandPaletteView["groups"] = filteredGroups; if (addProjectCloneFlow?.step === "repository") { displayedGroups = @@ -2736,9 +2642,9 @@ function OpenCommandPaletteDialog(props: { ? "Start typing to search GitHub repositories." : isGitHubRepositorySearchPending ? "Searching repositories…" - : addProjectCloneFlow.repositories === null + : repositorySearch.error !== null ? "Unable to search repositories. Try again." - : addProjectCloneFlow.repositories.length === 0 + : repositorySearchResults?.length === 0 ? "No repositories found." : "Start typing to search GitHub repositories." : "Enter a repository path and press Enter to look it up.", From 6cf30bd0d762572ce5469f865805a68a1566ef1c Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:22:38 +1000 Subject: [PATCH 4/4] fix(github): avoid repository search rate limits --- .../src/sourceControl/GitHubCli.test.ts | 93 +++++------ apps/server/src/sourceControl/GitHubCli.ts | 157 +++++++++--------- 2 files changed, 117 insertions(+), 133 deletions(-) diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index a28353721b90..8110e08dd77a 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -16,33 +16,25 @@ const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ stderrTruncated: false, }); -const repositorySearchOutput = (...fullNames: ReadonlyArray): VcsProcess.VcsProcessOutput => +const repositorySearchResult = (nameWithOwner: string) => ({ + nameWithOwner, + url: `https://github.com/${nameWithOwner}`, + sshUrl: `git@github.com:${nameWithOwner}.git`, +}); + +const repositorySearchOutput = (input: { + readonly owner: ReadonlyArray; + readonly global?: ReadonlyArray; +}): VcsProcess.VcsProcessOutput => processOutput( - JSON.stringify( - fullNames.map((fullName) => ({ fullName, url: `https://github.com/${fullName}` })), - ), + JSON.stringify({ + data: { + owner: { nodes: input.owner.map(repositorySearchResult) }, + global: { nodes: (input.global ?? []).map(repositorySearchResult) }, + }, + }), ); -const repositorySearchArgs = (input: { - readonly query: string; - readonly owner?: string; - readonly includeForks: boolean; -}): ReadonlyArray => [ - "search", - "repos", - "--match", - "name", - ...(input.owner === undefined ? [] : ["--owner", input.owner]), - "--include-forks", - String(input.includeForks), - "--limit", - "20", - "--json", - "fullName,url", - "--", - input.query, -]; - const mockRun = vi.fn(); const layer = GitHubCli.layer.pipe( @@ -321,17 +313,14 @@ describe("GitHubCli.layer", () => { it.effect("searches repositories with the authenticated owner's matches first", () => Effect.gen(function* () { - mockRun - .mockReturnValueOnce(Effect.succeed(repositorySearchOutput("current-user/skills"))) - .mockReturnValueOnce( - Effect.succeed( - repositorySearchOutput( - "mattpocock/skills", - "current-user/skills", - "someone-else/skills", - ), - ), - ); + mockRun.mockReturnValueOnce( + Effect.succeed( + repositorySearchOutput({ + owner: ["current-user/skills"], + global: ["mattpocock/skills", "current-user/skills", "someone-else/skills"], + }), + ), + ); const gh = yield* GitHubCli.GitHubCli; const result = yield* gh.searchRepositories({ @@ -344,18 +333,16 @@ describe("GitHubCli.layer", () => { ["current-user/skills", "mattpocock/skills", "someone-else/skills"], ); assert.equal(result[0]?.sshUrl, "git@github.com:current-user/skills.git"); - expect(mockRun).toHaveBeenCalledTimes(2); - expect(mockRun).toHaveBeenNthCalledWith(1, { - operation: "GitHubCli.execute", - command: "gh", - args: repositorySearchArgs({ query: "skills", owner: "@me", includeForks: true }), - cwd: "/repo", - timeoutMs: 30_000, - }); - expect(mockRun).toHaveBeenNthCalledWith(2, { + expect(mockRun).toHaveBeenCalledTimes(1); + expect(mockRun).toHaveBeenCalledWith({ operation: "GitHubCli.execute", command: "gh", - args: repositorySearchArgs({ query: "skills", includeForks: false }), + args: expect.arrayContaining([ + "api", + "graphql", + "ownerQuery=skills in:name user:@me fork:true", + "globalQuery=skills in:name fork:false", + ]), cwd: "/repo", timeoutMs: 30_000, }); @@ -365,7 +352,11 @@ describe("GitHubCli.layer", () => { it.effect("returns an exact owner and repository path before other owner matches", () => Effect.gen(function* () { mockRun.mockReturnValueOnce( - Effect.succeed(repositorySearchOutput("octocat/codething-tools", "octocat/codething-mvp")), + Effect.succeed( + repositorySearchOutput({ + owner: ["octocat/codething-tools", "octocat/codething-mvp"], + }), + ), ); const gh = yield* GitHubCli.GitHubCli; @@ -392,7 +383,9 @@ describe("GitHubCli.layer", () => { it.effect("searches within an owner for a partial repository path", () => Effect.gen(function* () { - mockRun.mockReturnValueOnce(Effect.succeed(repositorySearchOutput("octocat/codething-mvp"))); + mockRun.mockReturnValueOnce( + Effect.succeed(repositorySearchOutput({ owner: ["octocat/codething-mvp"] })), + ); const gh = yield* GitHubCli.GitHubCli; const result = yield* gh.searchRepositories({ cwd: "/repo", query: "octocat/code" }); @@ -407,15 +400,13 @@ describe("GitHubCli.layer", () => { it.effect("returns no repository search results when there are no matches", () => Effect.gen(function* () { - mockRun - .mockReturnValueOnce(Effect.succeed(processOutput("[]"))) - .mockReturnValueOnce(Effect.succeed(processOutput("[]"))); + mockRun.mockReturnValueOnce(Effect.succeed(repositorySearchOutput({ owner: [] }))); const gh = yield* GitHubCli.GitHubCli; const result = yield* gh.searchRepositories({ cwd: "/repo", query: "does-not-exist" }); assert.deepStrictEqual(result, []); - expect(mockRun).toHaveBeenCalledTimes(2); + expect(mockRun).toHaveBeenCalledTimes(1); }).pipe(Effect.provide(layer)), ); diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 07aa6f3aef2e..04862f31f5b8 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -18,7 +18,28 @@ import { } from "./gitHubPullRequests.ts"; const DEFAULT_TIMEOUT_MS = 30_000; -const GITHUB_DOT_COM = "github.com"; +const SEARCH_REPOSITORIES_QUERY = ` + query SearchRepositories($ownerQuery: String!, $globalQuery: String!) { + owner: search(query: $ownerQuery, type: REPOSITORY, first: 20) { + nodes { + ... on Repository { + nameWithOwner + url + sshUrl + } + } + } + global: search(query: $globalQuery, type: REPOSITORY, first: 20) { + nodes { + ... on Repository { + nameWithOwner + url + sshUrl + } + } + } + } +`.trim(); const gitHubCliFailureFields = { command: Schema.Literal("gh"), @@ -297,11 +318,19 @@ const decodeRawGitHubRepositoryCloneUrls = Schema.decodeEffect( ); const RawGitHubRepositorySearchResultSchema = Schema.Struct({ - fullName: TrimmedNonEmptyString, - url: Schema.URLFromString, + nameWithOwner: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, + sshUrl: TrimmedNonEmptyString, }); const decodeRawGitHubRepositorySearchResults = Schema.decodeEffect( - Schema.fromJsonString(Schema.Array(RawGitHubRepositorySearchResultSchema)), + Schema.fromJsonString( + Schema.Struct({ + data: Schema.Struct({ + owner: Schema.Struct({ nodes: Schema.Array(RawGitHubRepositorySearchResultSchema) }), + global: Schema.Struct({ nodes: Schema.Array(RawGitHubRepositorySearchResultSchema) }), + }), + }), + ), ); function normalizeRepositoryCloneUrls( @@ -314,16 +343,6 @@ function normalizeRepositoryCloneUrls( }; } -function normalizeRepositorySearchResult( - raw: Schema.Schema.Type, -): GitHubRepositoryCloneUrls { - return { - nameWithOwner: raw.fullName, - url: raw.url.toString(), - sshUrl: `git@${GITHUB_DOT_COM}:${raw.fullName}.git`, - }; -} - /** * `gh repo create` prints the canonical URL of the new repository on stdout * (e.g. `https://github.com/owner/repo`). Reading it back here avoids a @@ -377,45 +396,6 @@ export const make = Effect.gen(function* () { }) .pipe(Effect.mapError((error) => fromVcsError({ command: "gh", cwd: input.cwd }, error))); - const searchGitHubRepositories = Effect.fn("GitHubCli.searchGitHubRepositories")( - function* (input: { - readonly cwd: string; - readonly query: string; - readonly owner?: string; - readonly includeForks: boolean; - }) { - const result = yield* execute({ - cwd: input.cwd, - args: [ - "search", - "repos", - "--match", - "name", - ...(input.owner === undefined ? [] : ["--owner", input.owner]), - "--include-forks", - String(input.includeForks), - "--limit", - "20", - "--json", - "fullName,url", - "--", - input.query, - ], - }); - const repositories = yield* decodeRawGitHubRepositorySearchResults(result.stdout.trim()).pipe( - Effect.mapError( - (cause) => - new GitHubRepositorySearchDecodeError({ - command: "gh", - cwd: input.cwd, - cause, - }), - ), - ); - return repositories.map(normalizeRepositorySearchResult); - }, - ); - const searchRepositories = Effect.fn("GitHubCli.searchRepositories")(function* (input: { readonly cwd: string; readonly query: string; @@ -426,49 +406,62 @@ export const make = Effect.gen(function* () { } const slashIndex = query.indexOf("/"); - if (slashIndex >= 0) { + let ownerQuery: string; + let globalQuery: string; + let exactNameWithOwner: string | null = null; + if (slashIndex < 0) { + ownerQuery = `${query} in:name user:@me fork:true`; + globalQuery = `${query} in:name fork:false`; + } else { const owner = query.slice(0, slashIndex).trim(); const repositoryName = query.slice(slashIndex + 1).trim(); if (owner.length === 0 || repositoryName.length === 0) { return []; } - const repositories = yield* searchGitHubRepositories({ - cwd: input.cwd, - query: repositoryName, - owner, - includeForks: true, - }); - const exactNameWithOwner = `${owner}/${repositoryName}`.toLowerCase(); - return [...repositories].sort((left, right) => { - const leftIsExact = left.nameWithOwner.toLowerCase() === exactNameWithOwner; - const rightIsExact = right.nameWithOwner.toLowerCase() === exactNameWithOwner; - return Number(rightIsExact) - Number(leftIsExact); - }); + ownerQuery = `${repositoryName} in:name user:${owner} fork:true`; + globalQuery = ownerQuery; + exactNameWithOwner = `${owner}/${repositoryName}`.toLowerCase(); } - const [ownerMatches, globalMatches] = yield* Effect.all( - [ - searchGitHubRepositories({ - cwd: input.cwd, - query, - owner: "@me", - includeForks: true, - }), - searchGitHubRepositories({ - cwd: input.cwd, - query, - includeForks: false, - }), + const result = yield* execute({ + cwd: input.cwd, + args: [ + "api", + "graphql", + "-f", + `query=${SEARCH_REPOSITORIES_QUERY}`, + "-f", + `ownerQuery=${ownerQuery}`, + "-f", + `globalQuery=${globalQuery}`, ], - { concurrency: "unbounded" }, + }); + const response = yield* decodeRawGitHubRepositorySearchResults(result.stdout.trim()).pipe( + Effect.mapError( + (cause) => + new GitHubRepositorySearchDecodeError({ + command: "gh", + cwd: input.cwd, + cause, + }), + ), ); const repositories = new Map(); - for (const repository of [...ownerMatches, ...globalMatches]) { + for (const repository of [...response.data.owner.nodes, ...response.data.global.nodes]) { if (!repositories.has(repository.nameWithOwner)) { repositories.set(repository.nameWithOwner, repository); } } - return [...repositories.values()].slice(0, 20); + return [...repositories.values()] + .sort((left, right) => { + if (exactNameWithOwner === null) { + return 0; + } + const leftIsExact = left.nameWithOwner.toLowerCase() === exactNameWithOwner; + const rightIsExact = right.nameWithOwner.toLowerCase() === exactNameWithOwner; + return Number(rightIsExact) - Number(leftIsExact); + }) + .slice(0, 20); }); return GitHubCli.of({