diff --git a/frontend/__tests__/components/features/github/github-repo-selector.test.tsx b/frontend/__tests__/components/features/github/github-repo-selector.test.tsx new file mode 100644 index 000000000000..57bb67299350 --- /dev/null +++ b/frontend/__tests__/components/features/github/github-repo-selector.test.tsx @@ -0,0 +1,76 @@ +import { screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { renderWithProviders } from "test-utils"; +import { GitHubRepositorySelector } from "#/components/features/github/github-repo-selector"; +import OpenHands from "#/api/open-hands"; +import * as GitHubAPI from "#/api/github"; + +describe("GitHubRepositorySelector", () => { + const onInputChangeMock = vi.fn(); + const onSelectMock = vi.fn(); + + it("should render the search input", () => { + renderWithProviders( + , + ); + + expect( + screen.getByPlaceholderText("Select a GitHub project"), + ).toBeInTheDocument(); + }); + + it("should show the GitHub login button in OSS mode", () => { + const getConfigSpy = vi.spyOn(OpenHands, "getConfig"); + getConfigSpy.mockResolvedValue({ + APP_MODE: "oss", + APP_SLUG: "openhands", + GITHUB_CLIENT_ID: "test-client-id", + POSTHOG_CLIENT_KEY: "test-posthog-key", + }); + + renderWithProviders( + , + ); + + expect(screen.getByTestId("github-repo-selector")).toBeInTheDocument(); + }); + + it("should show the search results", () => { + const mockSearchedRepos = [ + { + id: 1, + full_name: "test/repo1", + stargazers_count: 100, + }, + { + id: 2, + full_name: "test/repo2", + stargazers_count: 200, + }, + ]; + + const searchPublicRepositoriesSpy = vi.spyOn( + GitHubAPI, + "searchPublicRepositories", + ); + searchPublicRepositoriesSpy.mockResolvedValue(mockSearchedRepos); + + renderWithProviders( + , + ); + + expect(screen.getByTestId("github-repo-selector")).toBeInTheDocument(); + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e91caba167a6..e6fdb410a8b7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -83,7 +83,7 @@ "postcss": "^8.4.47", "prettier": "^3.4.2", "tailwindcss": "^3.4.17", - "typescript": "^5.6.3", + "typescript": "^5.7.2", "vite-plugin-svgr": "^4.2.0", "vite-tsconfig-paths": "^5.1.4", "vitest": "^1.6.0" diff --git a/frontend/package.json b/frontend/package.json index a4b4b7c38e69..b3a40a98a514 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -110,7 +110,7 @@ "postcss": "^8.4.47", "prettier": "^3.4.2", "tailwindcss": "^3.4.17", - "typescript": "^5.6.3", + "typescript": "^5.7.2", "vite-plugin-svgr": "^4.2.0", "vite-tsconfig-paths": "^5.1.4", "vitest": "^1.6.0" diff --git a/frontend/src/api/github.ts b/frontend/src/api/github.ts index 492955ae69d9..b2b9697f9b5f 100644 --- a/frontend/src/api/github.ts +++ b/frontend/src/api/github.ts @@ -104,6 +104,31 @@ export const retrieveGitHubUser = async () => { return user; }; +export const searchPublicRepositories = async ( + query: string, + per_page = 5, + sort: "" | "updated" | "stars" | "forks" = "stars", + order: "desc" | "asc" = "desc", +): Promise => { + const sanitizedQuery = query.trim(); + if (!sanitizedQuery) { + return []; + } + + const response = await github.get<{ items: GitHubRepository[] }>( + "/search/repositories", + { + params: { + q: sanitizedQuery, + per_page, + sort, + order, + }, + }, + ); + return response.data.items; +}; + export const retrieveLatestGitHubCommit = async ( repository: string, ): Promise => { diff --git a/frontend/src/components/features/github/github-repo-selector.tsx b/frontend/src/components/features/github/github-repo-selector.tsx index 4352fd9b42d8..50f1d5c009df 100644 --- a/frontend/src/components/features/github/github-repo-selector.tsx +++ b/frontend/src/components/features/github/github-repo-selector.tsx @@ -5,60 +5,49 @@ import posthog from "posthog-js"; import { setSelectedRepository } from "#/state/initial-query-slice"; import { useConfig } from "#/hooks/query/use-config"; +interface GitHubRepositoryWithPublic extends GitHubRepository { + is_public?: boolean; +} + interface GitHubRepositorySelectorProps { + onInputChange: (value: string) => void; onSelect: () => void; - repositories: GitHubRepository[]; + repositories: GitHubRepositoryWithPublic[]; } export function GitHubRepositorySelector({ + onInputChange, onSelect, repositories, }: GitHubRepositorySelectorProps) { const { data: config } = useConfig(); const [selectedKey, setSelectedKey] = React.useState(null); - // Add option to install app onto more repos - const finalRepositories = - config?.APP_MODE === "saas" - ? [{ id: -1000, full_name: "Add more repositories..." }, ...repositories] - : repositories; - const dispatch = useDispatch(); const handleRepoSelection = (id: string | null) => { - const repo = finalRepositories.find((r) => r.id.toString() === id); - if (id === "-1000") { - if (config?.APP_SLUG) - window.open( - `https://github.com/apps/${config.APP_SLUG}/installations/new`, - "_blank", - ); - } else if (repo) { - // set query param - dispatch(setSelectedRepository(repo.full_name)); - posthog.capture("repository_selected"); - onSelect(); - setSelectedKey(id); + const repo = repositories.find((r) => r.id.toString() === id); + if (!repo) return; + + if (repo.id === -1000) { + window.open( + `https://github.com/apps/${config?.APP_SLUG}/installations/new`, + "_blank", + ); + return; } + + dispatch(setSelectedRepository(repo.full_name)); + posthog.capture("repository_selected"); + onSelect(); + setSelectedKey(id); }; const handleClearSelection = () => { - // clear query param dispatch(setSelectedRepository(null)); }; - const emptyContent = config?.APP_SLUG ? ( - - Add more repositories... - - ) : ( - "No results found." - ); + const emptyContent = "No results found."; return ( handleRepoSelection(id?.toString() ?? null)} - clearButtonProps={{ onClick: handleClearSelection }} + onInputChange={onInputChange} + clearButtonProps={{ onPress: handleClearSelection }} listboxProps={{ emptyContent, }} > - {finalRepositories.map((repo) => ( + {(item) => ( - {repo.full_name} + + {item.full_name} + {item.is_public && !!item.stargazers_count && ( + + ({item.stargazers_count}⭐) + + )} + - ))} + )} ); } diff --git a/frontend/src/components/features/github/github-repositories-suggestion-box.tsx b/frontend/src/components/features/github/github-repositories-suggestion-box.tsx index f5bd2a740068..b6420f6336ce 100644 --- a/frontend/src/components/features/github/github-repositories-suggestion-box.tsx +++ b/frontend/src/components/features/github/github-repositories-suggestion-box.tsx @@ -6,22 +6,54 @@ import { ModalButton } from "#/components/shared/buttons/modal-button"; import { ConnectToGitHubModal } from "#/components/shared/modals/connect-to-github-modal"; import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; import { isGitHubErrorReponse } from "#/api/github-axios-instance"; +import { useAppRepositories } from "#/hooks/query/use-app-repositories"; +import { useSearchRepositories } from "#/hooks/query/use-search-repositories"; +import { useUserRepositories } from "#/hooks/query/use-user-repositories"; +import { sanitizeQuery } from "#/utils/sanitize-query"; +import { useDebounce } from "#/hooks/use-debounce"; +import { useConfig } from "#/hooks/query/use-config"; interface GitHubRepositoriesSuggestionBoxProps { handleSubmit: () => void; - repositories: GitHubRepository[]; gitHubAuthUrl: string | null; user: GitHubErrorReponse | GitHubUser | null; } export function GitHubRepositoriesSuggestionBox({ handleSubmit, - repositories, gitHubAuthUrl, user, }: GitHubRepositoriesSuggestionBoxProps) { const [connectToGitHubModalOpen, setConnectToGitHubModalOpen] = React.useState(false); + const [searchQuery, setSearchQuery] = React.useState(""); + const debouncedSearchQuery = useDebounce(searchQuery, 300); + + const { data: config } = useConfig(); + // TODO: Use `useQueries` to fetch all repositories in parallel + const { data: appRepositories } = useAppRepositories(); + const { data: userRepositories } = useUserRepositories(); + const { data: searchedRepos } = useSearchRepositories( + sanitizeQuery(debouncedSearchQuery), + ); + + const saasPlaceholderRepository = React.useMemo(() => { + if (config?.APP_MODE === "saas" && config?.APP_SLUG) { + return [ + { + id: -1000, + full_name: "Add more repositories...", + }, + ]; + } + + return []; + }, [config]); + + const repositories = + userRepositories?.pages.flatMap((page) => page.data) || + appRepositories?.pages.flatMap((page) => page.data) || + []; const handleConnectToGitHub = () => { if (gitHubAuthUrl) { @@ -40,8 +72,13 @@ export function GitHubRepositoriesSuggestionBox({ content={ isLoggedIn ? ( ) : ( - {children} - + {children} ); } diff --git a/frontend/src/hooks/query/use-search-repositories.ts b/frontend/src/hooks/query/use-search-repositories.ts new file mode 100644 index 000000000000..4cd343c0a55a --- /dev/null +++ b/frontend/src/hooks/query/use-search-repositories.ts @@ -0,0 +1,12 @@ +import { useQuery } from "@tanstack/react-query"; +import { searchPublicRepositories } from "#/api/github"; + +export function useSearchRepositories(query: string) { + return useQuery({ + queryKey: ["repositories", query], + queryFn: () => searchPublicRepositories(query, 3), + enabled: !!query, + select: (data) => data.map((repo) => ({ ...repo, is_public: true })), + initialData: [], + }); +} diff --git a/frontend/src/hooks/use-click-outside-element.ts b/frontend/src/hooks/use-click-outside-element.ts index a6b1f6d6d387..753e7c2d85d2 100644 --- a/frontend/src/hooks/use-click-outside-element.ts +++ b/frontend/src/hooks/use-click-outside-element.ts @@ -17,6 +17,7 @@ export const useClickOutsideElement = ( }; document.addEventListener("click", handleClickOutside); + return () => document.removeEventListener("click", handleClickOutside); }, []); diff --git a/frontend/src/hooks/use-debounce.ts b/frontend/src/hooks/use-debounce.ts new file mode 100644 index 000000000000..d39df3f066fc --- /dev/null +++ b/frontend/src/hooks/use-debounce.ts @@ -0,0 +1,12 @@ +import { useEffect, useState } from "react"; + +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return debouncedValue; +} diff --git a/frontend/src/routes/_oh._index/route.tsx b/frontend/src/routes/_oh._index/route.tsx index 263e7f77b52a..cd2478403803 100644 --- a/frontend/src/routes/_oh._index/route.tsx +++ b/frontend/src/routes/_oh._index/route.tsx @@ -3,9 +3,6 @@ import { useDispatch } from "react-redux"; import posthog from "posthog-js"; import { setImportedProjectZip } from "#/state/initial-query-slice"; import { convertZipToBase64 } from "#/utils/convert-zip-to-base64"; -import { useUserRepositories } from "#/hooks/query/use-user-repositories"; -import { useAppRepositories } from "#/hooks/query/use-app-repositories"; - import { useGitHubUser } from "#/hooks/query/use-github-user"; import { useGitHubAuthUrl } from "#/hooks/use-github-auth-url"; import { useConfig } from "#/hooks/query/use-config"; @@ -22,8 +19,6 @@ function Home() { const { data: config } = useConfig(); const { data: user } = useGitHubUser(); - const { data: appRepositories } = useAppRepositories(); - const { data: userRepositories } = useUserRepositories(); const gitHubAuthUrl = useGitHubAuthUrl({ gitHubToken, @@ -47,11 +42,6 @@ function Home() { formRef.current?.requestSubmit()} - repositories={ - userRepositories?.pages.flatMap((page) => page.data) || - appRepositories?.pages.flatMap((page) => page.data) || - [] - } gitHubAuthUrl={gitHubAuthUrl} user={user || null} /> diff --git a/frontend/src/types/github.d.ts b/frontend/src/types/github.d.ts index 7f18464f4ffd..cb432961eb46 100644 --- a/frontend/src/types/github.d.ts +++ b/frontend/src/types/github.d.ts @@ -16,6 +16,7 @@ interface GitHubUser { interface GitHubRepository { id: number; full_name: string; + stargazers_count?: number; } interface GitHubAppRepository { diff --git a/frontend/src/utils/sanitize-query.ts b/frontend/src/utils/sanitize-query.ts new file mode 100644 index 000000000000..3ec68ec8e874 --- /dev/null +++ b/frontend/src/utils/sanitize-query.ts @@ -0,0 +1,6 @@ +export const sanitizeQuery = (query: string) => + query + .replace(/https?:\/\//, "") + .replace(/github.com\//, "") + .replace(/\.git$/, "") + .toLowerCase(); diff --git a/frontend/test-utils.tsx b/frontend/test-utils.tsx index a2819e8ccf5f..8eeb5e453fb6 100644 --- a/frontend/test-utils.tsx +++ b/frontend/test-utils.tsx @@ -68,7 +68,13 @@ export function renderWithProviders( - + {children}