feat(frontend): enhance GitHub repo picker with search and sorting (#5783)

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Graham Neubig <neubig@gmail.com>
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
This commit is contained in:
Robert Brennan 2025-01-03 10:44:32 -05:00 committed by GitHub
parent f14f75b064
commit 3b26678a77
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 221 additions and 58 deletions

View File

@ -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(
<GitHubRepositorySelector
onInputChange={onInputChangeMock}
onSelect={onSelectMock}
repositories={[]}
/>,
);
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(
<GitHubRepositorySelector
onInputChange={onInputChangeMock}
onSelect={onSelectMock}
repositories={[]}
/>,
);
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(
<GitHubRepositorySelector
onInputChange={onInputChangeMock}
onSelect={onSelectMock}
repositories={[]}
/>,
);
expect(screen.getByTestId("github-repo-selector")).toBeInTheDocument();
});
});

View File

@ -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"

View File

@ -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"

View File

@ -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<GitHubRepository[]> => {
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<GitHubCommit | null> => {

View File

@ -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<string | null>(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 ? (
<a
href={`https://github.com/apps/${config.APP_SLUG}/installations/new`}
target="_blank"
rel="noreferrer noopener"
className="underline"
>
Add more repositories...
</a>
) : (
"No results found."
);
const emptyContent = "No results found.";
return (
<Autocomplete
@ -67,6 +56,7 @@ export function GitHubRepositorySelector({
aria-label="GitHub Repository"
placeholder="Select a GitHub project"
selectedKey={selectedKey}
items={repositories}
inputProps={{
classNames: {
inputWrapper:
@ -74,20 +64,29 @@ export function GitHubRepositorySelector({
},
}}
onSelectionChange={(id) => handleRepoSelection(id?.toString() ?? null)}
clearButtonProps={{ onClick: handleClearSelection }}
onInputChange={onInputChange}
clearButtonProps={{ onPress: handleClearSelection }}
listboxProps={{
emptyContent,
}}
>
{finalRepositories.map((repo) => (
{(item) => (
<AutocompleteItem
data-testid="github-repo-item"
key={repo.id}
value={repo.id}
key={item.id}
value={item.id}
textValue={item.full_name}
>
{repo.full_name}
<div className="flex items-center justify-between">
{item.full_name}
{item.is_public && !!item.stargazers_count && (
<span className="text-xs text-gray-400">
({item.stargazers_count})
</span>
)}
</div>
</AutocompleteItem>
))}
)}
</Autocomplete>
);
}

View File

@ -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<string>("");
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 ? (
<GitHubRepositorySelector
onInputChange={setSearchQuery}
onSelect={handleSubmit}
repositories={repositories}
repositories={[
...saasPlaceholderRepository,
...searchedRepos,
...repositories,
]}
/>
) : (
<ModalButton

View File

@ -25,9 +25,7 @@ export function SettingsUpToDateProvider({
);
return (
<SettingsUpToDateContext.Provider value={value}>
{children}
</SettingsUpToDateContext.Provider>
<SettingsUpToDateContext value={value}>{children}</SettingsUpToDateContext>
);
}

View File

@ -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: [],
});
}

View File

@ -17,6 +17,7 @@ export const useClickOutsideElement = <T extends HTMLElement>(
};
document.addEventListener("click", handleClickOutside);
return () => document.removeEventListener("click", handleClickOutside);
}, []);

View File

@ -0,0 +1,12 @@
import { useEffect, useState } from "react";
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}

View File

@ -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() {
<div className="flex gap-4 w-full flex-col md:flex-row">
<GitHubRepositoriesSuggestionBox
handleSubmit={() => formRef.current?.requestSubmit()}
repositories={
userRepositories?.pages.flatMap((page) => page.data) ||
appRepositories?.pages.flatMap((page) => page.data) ||
[]
}
gitHubAuthUrl={gitHubAuthUrl}
user={user || null}
/>

View File

@ -16,6 +16,7 @@ interface GitHubUser {
interface GitHubRepository {
id: number;
full_name: string;
stargazers_count?: number;
}
interface GitHubAppRepository {

View File

@ -0,0 +1,6 @@
export const sanitizeQuery = (query: string) =>
query
.replace(/https?:\/\//, "")
.replace(/github.com\//, "")
.replace(/\.git$/, "")
.toLowerCase();

View File

@ -68,7 +68,13 @@ export function renderWithProviders(
<Provider store={store}>
<AuthProvider>
<SettingsUpToDateProvider>
<QueryClientProvider client={new QueryClient()}>
<QueryClientProvider
client={
new QueryClient({
defaultOptions: { queries: { retry: false } },
})
}
>
<ConversationProvider>
<I18nextProvider i18n={i18n}>{children}</I18nextProvider>
</ConversationProvider>