diff --git a/frontend/__tests__/components/chat/action-suggestions.test.tsx b/frontend/__tests__/components/chat/action-suggestions.test.tsx index ccc2eb1e81..186a0e8ce4 100644 --- a/frontend/__tests__/components/chat/action-suggestions.test.tsx +++ b/frontend/__tests__/components/chat/action-suggestions.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import { ActionSuggestions } from "#/components/features/chat/action-suggestions"; import { useAuth } from "#/context/auth-context"; @@ -21,21 +21,34 @@ vi.mock("#/context/auth-context", () => ({ describe("ActionSuggestions", () => { // Setup mocks for each test - vi.clearAllMocks(); + beforeEach(() => { + vi.clearAllMocks(); - (useAuth as any).mockReturnValue({ - githubTokenIsSet: true, - }); + (useAuth as any).mockReturnValue({ + providersAreSet: true, + }); - (useSelector as any).mockReturnValue({ - selectedRepository: "test-repo", + (useSelector as any).mockReturnValue({ + selectedRepository: "test-repo", + }); }); it("should render both GitHub buttons when GitHub token is set and repository is selected", () => { render( {}} />); - const pushButton = screen.getByRole("button", { name: "Push to Branch" }); - const prButton = screen.getByRole("button", { name: "Push & Create PR" }); + // Find all buttons with data-testid="suggestion" + const buttons = screen.getAllByTestId("suggestion"); + + // Check if we have at least 2 buttons + expect(buttons.length).toBeGreaterThanOrEqual(2); + + // Check if the buttons contain the expected text + const pushButton = buttons.find((button) => + button.textContent?.includes("Push to Branch"), + ); + const prButton = buttons.find((button) => + button.textContent?.includes("Push & Create PR"), + ); expect(pushButton).toBeInTheDocument(); expect(prButton).toBeInTheDocument(); @@ -43,13 +56,12 @@ describe("ActionSuggestions", () => { it("should not render buttons when GitHub token is not set", () => { (useAuth as any).mockReturnValue({ - githubTokenIsSet: false, + providersAreSet: false, }); render( {}} />); - expect(screen.queryByRole("button", { name: "Push to Branch" })).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Push & Create PR" })).not.toBeInTheDocument(); + expect(screen.queryByTestId("suggestion")).not.toBeInTheDocument(); }); it("should not render buttons when no repository is selected", () => { @@ -59,17 +71,20 @@ describe("ActionSuggestions", () => { render( {}} />); - expect(screen.queryByRole("button", { name: "Push to Branch" })).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Push & Create PR" })).not.toBeInTheDocument(); + expect(screen.queryByTestId("suggestion")).not.toBeInTheDocument(); }); it("should have different prompts for 'Push to Branch' and 'Push & Create PR' buttons", () => { // This test verifies that the prompts are different in the component - const component = render( {}} />); + const component = render( + {}} />, + ); // Get the component instance to access the internal values - const pushBranchPrompt = "Please push the changes to a remote branch on GitHub, but do NOT create a pull request. Please use the exact SAME branch name as the one you are currently on."; - const createPRPrompt = "Please push the changes to GitHub and open a pull request. Please create a meaningful branch name that describes the changes."; + const pushBranchPrompt = + "Please push the changes to a remote branch on GitHub, but do NOT create a pull request. Please use the exact SAME branch name as the one you are currently on."; + const createPRPrompt = + "Please push the changes to GitHub and open a pull request. Please create a meaningful branch name that describes the changes."; // Verify the prompts are different expect(pushBranchPrompt).not.toEqual(createPRPrompt); diff --git a/frontend/__tests__/components/features/github/github-repo-selector.test.tsx b/frontend/__tests__/components/features/github/github-repo-selector.test.tsx index 8f4fe1d30b..a30cd19904 100644 --- a/frontend/__tests__/components/features/github/github-repo-selector.test.tsx +++ b/frontend/__tests__/components/features/github/github-repo-selector.test.tsx @@ -1,16 +1,17 @@ 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 { GitRepositorySelector } from "#/components/features/github/github-repo-selector"; import OpenHands from "#/api/open-hands"; +import { Provider } from "#/types/settings"; -describe("GitHubRepositorySelector", () => { +describe("GitRepositorySelector", () => { const onInputChangeMock = vi.fn(); const onSelectMock = vi.fn(); it("should render the search input", () => { renderWithProviders( - { ); expect( - screen.getByPlaceholderText("LANDING$SELECT_REPO"), + screen.getByPlaceholderText("LANDING$SELECT_GIT_REPO"), ).toBeInTheDocument(); }); @@ -37,7 +38,7 @@ describe("GitHubRepositorySelector", () => { }); renderWithProviders( - { { id: 1, full_name: "test/repo1", + git_provider: "github" as Provider, stargazers_count: 100, }, { id: 2, full_name: "test/repo2", + git_provider: "github" as Provider, stargazers_count: 200, }, ]; @@ -69,7 +72,7 @@ describe("GitHubRepositorySelector", () => { searchPublicRepositoriesSpy.mockResolvedValue(mockSearchedRepos); renderWithProviders( - { const advancedSwitch = await screen.findByTestId("advanced-settings-switch"); await user.click(advancedSwitch); }; +const mock_provider_tokens_are_set: Record = { + github: true, + gitlab: false, +}; + describe("Settings Screen", () => { const getSettingsSpy = vi.spyOn(OpenHands, "getSettings"); const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings"); @@ -59,7 +65,7 @@ describe("Settings Screen", () => { await waitFor(() => { screen.getByText("LLM Settings"); - screen.getByText("GitHub Settings"); + screen.getByText("Git Provider Settings"); screen.getByText("Additional Settings"); screen.getByText("Reset to defaults"); screen.getByText("Save Changes"); @@ -94,7 +100,6 @@ describe("Settings Screen", () => { it.skip("should render an indicator if the GitHub token is not set", async () => { getSettingsSpy.mockResolvedValue({ ...MOCK_DEFAULT_USER_SETTINGS, - github_token_is_set: false, }); renderSettingsScreen(); @@ -115,7 +120,7 @@ describe("Settings Screen", () => { it("should set '' placeholder if the GitHub token is set", async () => { getSettingsSpy.mockResolvedValue({ ...MOCK_DEFAULT_USER_SETTINGS, - github_token_is_set: true, + provider_tokens_set: mock_provider_tokens_are_set, }); renderSettingsScreen(); @@ -129,7 +134,7 @@ describe("Settings Screen", () => { it("should render an indicator if the GitHub token is set", async () => { getSettingsSpy.mockResolvedValue({ ...MOCK_DEFAULT_USER_SETTINGS, - github_token_is_set: true, + provider_tokens_set: mock_provider_tokens_are_set, }); renderSettingsScreen(); @@ -145,27 +150,26 @@ describe("Settings Screen", () => { } }); - it("should render a disabled 'Disconnect from GitHub' button if the GitHub token is not set", async () => { + it("should render a disabled 'Disconnect Tokens' button if the GitHub token is not set", async () => { getSettingsSpy.mockResolvedValue({ ...MOCK_DEFAULT_USER_SETTINGS, - github_token_is_set: false, }); renderSettingsScreen(); - const button = await screen.findByText("Disconnect from GitHub"); + const button = await screen.findByText("Disconnect Tokens"); expect(button).toBeInTheDocument(); expect(button).toBeDisabled(); }); - it("should render an enabled 'Disconnect from GitHub' button if the GitHub token is set", async () => { + it("should render an enabled 'Disconnect Tokens' button if any Git tokens are set", async () => { getSettingsSpy.mockResolvedValue({ ...MOCK_DEFAULT_USER_SETTINGS, - github_token_is_set: true, + provider_tokens_set: mock_provider_tokens_are_set, }); renderSettingsScreen(); - const button = await screen.findByText("Disconnect from GitHub"); + const button = await screen.findByText("Disconnect Tokens"); expect(button).toBeInTheDocument(); expect(button).toBeEnabled(); @@ -174,17 +178,17 @@ describe("Settings Screen", () => { expect(input).toBeInTheDocument(); }); - it("should logout the user when the 'Disconnect from GitHub' button is clicked", async () => { + it("should logout the user when the 'Disconnect Tokens' button is clicked", async () => { const user = userEvent.setup(); getSettingsSpy.mockResolvedValue({ ...MOCK_DEFAULT_USER_SETTINGS, - github_token_is_set: true, + provider_tokens_set: mock_provider_tokens_are_set, }); renderSettingsScreen(); - const button = await screen.findByText("Disconnect from GitHub"); + const button = await screen.findByText("Disconnect Tokens"); await user.click(button); expect(handleLogoutMock).toHaveBeenCalled(); @@ -249,7 +253,6 @@ describe("Settings Screen", () => { const user = userEvent.setup(); getSettingsSpy.mockResolvedValue({ ...MOCK_DEFAULT_USER_SETTINGS, - github_token_is_set: false, llm_model: "anthropic/claude-3-5-sonnet-20241022", }); saveSettingsSpy.mockRejectedValueOnce(new Error("Invalid GitHub token")); @@ -707,7 +710,6 @@ describe("Settings Screen", () => { getSettingsSpy.mockResolvedValue({ ...MOCK_DEFAULT_USER_SETTINGS, language: "no", - github_token_is_set: true, user_consents_to_analytics: true, llm_base_url: "https://test.com", llm_model: "anthropic/claude-3-5-sonnet-20241022", @@ -719,7 +721,7 @@ describe("Settings Screen", () => { await waitFor(() => { expect(screen.getByTestId("language-input")).toHaveValue("Norsk"); - expect(screen.getByText("Disconnect from GitHub")).toBeInTheDocument(); + expect(screen.getByText("Disconnect Tokens")).toBeInTheDocument(); expect(screen.getByTestId("enable-analytics-switch")).toBeChecked(); expect(screen.getByTestId("advanced-settings-switch")).toBeChecked(); expect(screen.getByTestId("base-url-input")).toHaveValue( @@ -760,7 +762,6 @@ describe("Settings Screen", () => { expect(saveSettingsSpy).toHaveBeenCalledWith( expect.objectContaining({ llm_api_key: "", // empty because it's not set previously - provider_tokens: undefined, language: "no", }), ); @@ -797,7 +798,6 @@ describe("Settings Screen", () => { expect(saveSettingsSpy).toHaveBeenCalledWith( expect.objectContaining({ - provider_tokens: undefined, llm_api_key: "", // empty because it's not set previously llm_model: "openai/gpt-4o", }), @@ -846,11 +846,17 @@ describe("Settings Screen", () => { // Wait for the mutation to complete and the modal to be removed await waitFor(() => { expect(screen.queryByTestId("reset-modal")).not.toBeInTheDocument(); - expect(screen.queryByTestId("llm-custom-model-input")).not.toBeInTheDocument(); + expect( + screen.queryByTestId("llm-custom-model-input"), + ).not.toBeInTheDocument(); expect(screen.queryByTestId("base-url-input")).not.toBeInTheDocument(); expect(screen.queryByTestId("agent-input")).not.toBeInTheDocument(); - expect(screen.queryByTestId("security-analyzer-input")).not.toBeInTheDocument(); - expect(screen.queryByTestId("enable-confirmation-mode-switch")).not.toBeInTheDocument(); + expect( + screen.queryByTestId("security-analyzer-input"), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("enable-confirmation-mode-switch"), + ).not.toBeInTheDocument(); }); }); diff --git a/frontend/src/api/github.ts b/frontend/src/api/github.ts index 4b83229f3e..5df1d90aca 100644 --- a/frontend/src/api/github.ts +++ b/frontend/src/api/github.ts @@ -1,3 +1,4 @@ +import { GitRepository } from "#/types/github"; import { extractNextPageFromLink } from "#/utils/extract-next-page-from-link"; import { openHands } from "./open-hands-axios"; @@ -14,8 +15,8 @@ export const retrieveGitHubAppRepositories = async ( per_page = 30, ) => { const installationId = installations[installationIndex]; - const response = await openHands.get( - "/api/github/repositories", + const response = await openHands.get( + "/api/user/repositories", { params: { sort: "pushed", @@ -53,12 +54,9 @@ export const retrieveGitHubAppRepositories = async ( * Given a PAT, retrieves the repositories of the user * @returns A list of repositories */ -export const retrieveGitHubUserRepositories = async ( - page = 1, - per_page = 30, -) => { - const response = await openHands.get( - "/api/github/repositories", +export const retrieveUserGitRepositories = async (page = 1, per_page = 30) => { + const response = await openHands.get( + "/api/user/repositories", { params: { sort: "pushed", @@ -68,6 +66,7 @@ export const retrieveGitHubUserRepositories = async ( }, ); + // Check if any provider has more results const link = response.data.length > 0 && response.data[0].link_header ? response.data[0].link_header diff --git a/frontend/src/api/open-hands.ts b/frontend/src/api/open-hands.ts index c56736e8c9..643812d1e9 100644 --- a/frontend/src/api/open-hands.ts +++ b/frontend/src/api/open-hands.ts @@ -14,6 +14,7 @@ import { } from "./open-hands.types"; import { openHands } from "./open-hands-axios"; import { ApiSettings, PostApiSettings } from "#/types/settings"; +import { GitHubUser, GitRepository } from "#/types/github"; class OpenHands { /** @@ -306,7 +307,7 @@ class OpenHands { } static async getGitHubUser(): Promise { - const response = await openHands.get("/api/github/user"); + const response = await openHands.get("/api/user/info"); const { data } = response; @@ -323,16 +324,16 @@ class OpenHands { } static async getGitHubUserInstallationIds(): Promise { - const response = await openHands.get("/api/github/installations"); + const response = await openHands.get("/api/user/installations"); return response.data; } static async searchGitHubRepositories( query: string, per_page = 5, - ): Promise { - const response = await openHands.get( - "/api/github/search/repositories", + ): Promise { + const response = await openHands.get( + "/api/user/search/repositories", { params: { query, diff --git a/frontend/src/components/features/chat/action-suggestions.tsx b/frontend/src/components/features/chat/action-suggestions.tsx index fb298320eb..2bec92af0e 100644 --- a/frontend/src/components/features/chat/action-suggestions.tsx +++ b/frontend/src/components/features/chat/action-suggestions.tsx @@ -12,24 +12,43 @@ interface ActionSuggestionsProps { export function ActionSuggestions({ onSuggestionsClick, }: ActionSuggestionsProps) { - const { githubTokenIsSet } = useAuth(); + const { providersAreSet } = useAuth(); const { selectedRepository } = useSelector( (state: RootState) => state.initialQuery, ); const [hasPullRequest, setHasPullRequest] = React.useState(false); + const isGitLab = + selectedRepository !== null && + selectedRepository.git_provider && + selectedRepository.git_provider.toLowerCase() === "gitlab"; + + const pr = isGitLab ? "merge request" : "pull request"; + const prShort = isGitLab ? "MR" : "PR"; + + const terms = { + pr, + prShort, + pushToBranch: `Please push the changes to a remote branch on ${ + isGitLab ? "GitLab" : "GitHub" + }, but do NOT create a ${pr}. Please use the exact SAME branch name as the one you are currently on.`, + createPR: `Please push the changes to ${ + isGitLab ? "GitLab" : "GitHub" + } and open a ${pr}. Please create a meaningful branch name that describes the changes.`, + pushToPR: `Please push the latest changes to the existing ${pr}.`, + }; + return (
- {githubTokenIsSet && selectedRepository && ( + {providersAreSet && selectedRepository && (
{!hasPullRequest ? ( <> { posthog.capture("push_to_branch_button_clicked"); @@ -38,9 +57,8 @@ export function ActionSuggestions({ /> { posthog.capture("create_pr_button_clicked"); @@ -52,9 +70,8 @@ export function ActionSuggestions({ ) : ( { posthog.capture("push_to_pr_button_clicked"); diff --git a/frontend/src/components/features/github/github-repo-selector.tsx b/frontend/src/components/features/github/github-repo-selector.tsx index f3893e1507..dd74b3badb 100644 --- a/frontend/src/components/features/github/github-repo-selector.tsx +++ b/frontend/src/components/features/github/github-repo-selector.tsx @@ -4,6 +4,7 @@ import { Autocomplete, AutocompleteItem, AutocompleteSection, + Spinner, } from "@heroui/react"; import { useDispatch } from "react-redux"; import posthog from "posthog-js"; @@ -11,37 +12,68 @@ import { I18nKey } from "#/i18n/declaration"; import { setSelectedRepository } from "#/state/initial-query-slice"; import { useConfig } from "#/hooks/query/use-config"; import { sanitizeQuery } from "#/utils/sanitize-query"; +import { GitRepository } from "#/types/github"; +import { Provider, ProviderOptions } from "#/types/settings"; -interface GitHubRepositorySelectorProps { +interface GitRepositorySelectorProps { onInputChange: (value: string) => void; onSelect: () => void; - userRepositories: GitHubRepository[]; - publicRepositories: GitHubRepository[]; + userRepositories: GitRepository[]; + publicRepositories: GitRepository[]; + isLoading?: boolean; } -export function GitHubRepositorySelector({ +export function GitRepositorySelector({ onInputChange, onSelect, userRepositories, publicRepositories, -}: GitHubRepositorySelectorProps) { + isLoading = false, +}: GitRepositorySelectorProps) { const { t } = useTranslation(); const { data: config } = useConfig(); const [selectedKey, setSelectedKey] = React.useState(null); - const allRepositories: GitHubRepository[] = [ + const allRepositories: GitRepository[] = [ ...publicRepositories.filter( (repo) => !userRepositories.find((r) => r.id === repo.id), ), ...userRepositories, ]; + // Group repositories by provider + const groupedUserRepos = userRepositories.reduce< + Record + >( + (acc, repo) => { + if (!acc[repo.git_provider]) { + acc[repo.git_provider] = []; + } + acc[repo.git_provider].push(repo); + return acc; + }, + {} as Record, + ); + + const groupedPublicRepos = publicRepositories.reduce< + Record + >( + (acc, repo) => { + if (!acc[repo.git_provider]) { + acc[repo.git_provider] = []; + } + acc[repo.git_provider].push(repo); + return acc; + }, + {} as Record, + ); + const dispatch = useDispatch(); const handleRepoSelection = (id: string | null) => { const repo = allRepositories.find((r) => r.id.toString() === id); if (repo) { - dispatch(setSelectedRepository(repo.full_name)); + dispatch(setSelectedRepository(repo)); posthog.capture("repository_selected"); onSelect(); setSelectedKey(id); @@ -52,14 +84,21 @@ export function GitHubRepositorySelector({ dispatch(setSelectedRepository(null)); }; - const emptyContent = t(I18nKey.GITHUB$NO_RESULTS); + const emptyContent = isLoading ? ( +
+ + {t(I18nKey.GITHUB$LOADING_REPOSITORIES)} +
+ ) : ( + t(I18nKey.GITHUB$NO_RESULTS) + ); return ( : undefined, }} onSelectionChange={(id) => handleRepoSelection(id?.toString() ?? null)} onInputChange={onInputChange} @@ -74,10 +114,32 @@ export function GitHubRepositorySelector({ listboxProps={{ emptyContent, }} - defaultFilter={(textValue, inputValue) => - !inputValue || - sanitizeQuery(textValue).includes(sanitizeQuery(inputValue)) - } + defaultFilter={(textValue, inputValue) => { + if (!inputValue) return true; + + const sanitizedInput = sanitizeQuery(inputValue); + + const repo = allRepositories.find((r) => r.full_name === textValue); + if (!repo) return false; + + const provider = repo.git_provider?.toLowerCase() as Provider; + const providerKeys = Object.keys(ProviderOptions) as Provider[]; + + // If input is exactly "git", show repos from any git-based provider + if (sanitizedInput === "git") { + return providerKeys.includes(provider); + } + + // Provider based typeahead + for (const p of providerKeys) { + if (p.startsWith(sanitizedInput)) { + return provider === p; + } + } + + // Default case: check if the repository name matches the input + return sanitizeQuery(textValue).includes(sanitizedInput); + }} > {config?.APP_MODE === "saas" && config?.APP_SLUG && @@ -93,36 +155,48 @@ export function GitHubRepositorySelector({ // eslint-disable-next-line @typescript-eslint/no-explicit-any ) as any)} - {userRepositories.length > 0 && ( - - {userRepositories.map((repo) => ( - - {repo.full_name} - - ))} - + {Object.entries(groupedUserRepos).map(([provider, repos]) => + repos.length > 0 ? ( + + {repos.map((repo) => ( + + {repo.full_name} + + ))} + + ) : null, )} - {publicRepositories.length > 0 && ( - - {publicRepositories.map((repo) => ( - - {repo.full_name} - - ({repo.stargazers_count || 0}⭐) - - - ))} - + {Object.entries(groupedPublicRepos).map(([provider, repos]) => + repos.length > 0 ? ( + + {repos.map((repo) => ( + + {repo.full_name} + + ({repo.stargazers_count || 0}⭐) + + + ))} + + ) : null, )} ); 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 d68767e5f4..4bf2ec2d42 100644 --- a/frontend/src/components/features/github/github-repositories-suggestion-box.tsx +++ b/frontend/src/components/features/github/github-repositories-suggestion-box.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router"; import { I18nKey } from "#/i18n/declaration"; import { SuggestionBox } from "#/components/features/suggestions/suggestion-box"; -import { GitHubRepositorySelector } from "./github-repo-selector"; +import { GitRepositorySelector } from "./github-repo-selector"; import { useAppRepositories } from "#/hooks/query/use-app-repositories"; import { useSearchRepositories } from "#/hooks/query/use-search-repositories"; import { useUserRepositories } from "#/hooks/query/use-user-repositories"; @@ -11,29 +11,35 @@ import { sanitizeQuery } from "#/utils/sanitize-query"; import { useDebounce } from "#/hooks/use-debounce"; import { BrandButton } from "../settings/brand-button"; import GitHubLogo from "#/assets/branding/github-logo.svg?react"; +import { GitHubErrorReponse, GitHubUser } from "#/types/github"; -interface GitHubRepositoriesSuggestionBoxProps { +interface GitRepositoriesSuggestionBoxProps { handleSubmit: () => void; gitHubAuthUrl: string | null; user: GitHubErrorReponse | GitHubUser | null; } -export function GitHubRepositoriesSuggestionBox({ +export function GitRepositoriesSuggestionBox({ handleSubmit, gitHubAuthUrl, user, -}: GitHubRepositoriesSuggestionBoxProps) { +}: GitRepositoriesSuggestionBoxProps) { const { t } = useTranslation(); const navigate = useNavigate(); const [searchQuery, setSearchQuery] = React.useState(""); + const debouncedSearchQuery = useDebounce(searchQuery, 300); // TODO: Use `useQueries` to fetch all repositories in parallel - const { data: appRepositories } = useAppRepositories(); - const { data: userRepositories } = useUserRepositories(); - const { data: searchedRepos } = useSearchRepositories( - sanitizeQuery(debouncedSearchQuery), - ); + const { data: appRepositories, isLoading: isAppReposLoading } = + useAppRepositories(); + const { data: userRepositories, isLoading: isUserReposLoading } = + useUserRepositories(); + const { data: searchedRepos, isLoading: isSearchReposLoading } = + useSearchRepositories(sanitizeQuery(debouncedSearchQuery)); + + const isLoading = + isAppReposLoading || isUserReposLoading || isSearchReposLoading; const repositories = userRepositories?.pages.flatMap((page) => page.data) || @@ -55,11 +61,12 @@ export function GitHubRepositoriesSuggestionBox({ title={t(I18nKey.LANDING$OPEN_REPO)} content={ isLoggedIn ? ( - ) : ( void; + providerTokensSet: Provider[]; + setProviderTokensSet: (tokens: Provider[]) => void; + providersAreSet: boolean; + setProvidersAreSet: (status: boolean) => void; } interface AuthContextProps extends React.PropsWithChildren { - initialGithubTokenIsSet?: boolean; + initialProviderTokens?: Provider[]; } const AuthContext = React.createContext(undefined); -function AuthProvider({ children, initialGithubTokenIsSet }: AuthContextProps) { - const [githubTokenIsSet, setGitHubTokenIsSet] = React.useState( - !!initialGithubTokenIsSet, +function AuthProvider({ + children, + initialProviderTokens = [], +}: AuthContextProps) { + const [providerTokensSet, setProviderTokensSet] = React.useState( + initialProviderTokens, ); + const [providersAreSet, setProvidersAreSet] = React.useState(false); + const value = React.useMemo( () => ({ - githubTokenIsSet, - setGitHubTokenIsSet, + providerTokensSet, + setProviderTokensSet, + providersAreSet, + setProvidersAreSet, }), - [githubTokenIsSet, setGitHubTokenIsSet], + [providerTokensSet], ); return {children}; diff --git a/frontend/src/hooks/mutation/use-create-conversation.ts b/frontend/src/hooks/mutation/use-create-conversation.ts index 52fe804950..971e369af1 100644 --- a/frontend/src/hooks/mutation/use-create-conversation.ts +++ b/frontend/src/hooks/mutation/use-create-conversation.ts @@ -29,7 +29,7 @@ export const useCreateConversation = () => { if (variables.q) dispatch(setInitialPrompt(variables.q)); return OpenHands.createConversation( - selectedRepository || undefined, + selectedRepository?.full_name || undefined, variables.q, files, replayJson || undefined, diff --git a/frontend/src/hooks/mutation/use-logout.ts b/frontend/src/hooks/mutation/use-logout.ts index d10bd307e2..0e08f83277 100644 --- a/frontend/src/hooks/mutation/use-logout.ts +++ b/frontend/src/hooks/mutation/use-logout.ts @@ -4,7 +4,7 @@ import { useAuth } from "#/context/auth-context"; import { useConfig } from "../query/use-config"; export const useLogout = () => { - const { setGitHubTokenIsSet } = useAuth(); + const { setProviderTokensSet, setProvidersAreSet } = useAuth(); const queryClient = useQueryClient(); const { data: config } = useConfig(); @@ -20,7 +20,8 @@ export const useLogout = () => { queryClient.removeQueries({ queryKey: ["settings"] }); // Update token state - this will trigger a settings refetch since it's part of the query key - setGitHubTokenIsSet(false); + setProviderTokensSet([]); + setProvidersAreSet(false); }, }); }; diff --git a/frontend/src/hooks/mutation/use-save-settings.ts b/frontend/src/hooks/mutation/use-save-settings.ts index 53297b3530..5d5eada7d7 100644 --- a/frontend/src/hooks/mutation/use-save-settings.ts +++ b/frontend/src/hooks/mutation/use-save-settings.ts @@ -25,10 +25,10 @@ const saveSettingsMutationFn = async ( ? "" : settings.LLM_API_KEY?.trim() || undefined, remote_runtime_resource_factor: settings.REMOTE_RUNTIME_RESOURCE_FACTOR, - provider_tokens: settings.provider_tokens, enable_default_condenser: settings.ENABLE_DEFAULT_CONDENSER, enable_sound_notifications: settings.ENABLE_SOUND_NOTIFICATIONS, user_consents_to_analytics: settings.user_consents_to_analytics, + provider_tokens: settings.provider_tokens, }; await OpenHands.saveSettings(apiSettings); diff --git a/frontend/src/hooks/query/use-app-installations.ts b/frontend/src/hooks/query/use-app-installations.ts index 356c0147c7..cb764dafe1 100644 --- a/frontend/src/hooks/query/use-app-installations.ts +++ b/frontend/src/hooks/query/use-app-installations.ts @@ -5,13 +5,13 @@ import { useAuth } from "#/context/auth-context"; export const useAppInstallations = () => { const { data: config } = useConfig(); - const { githubTokenIsSet } = useAuth(); + const { providersAreSet } = useAuth(); return useQuery({ - queryKey: ["installations", githubTokenIsSet, config?.GITHUB_CLIENT_ID], + queryKey: ["installations", providersAreSet, config?.GITHUB_CLIENT_ID], queryFn: OpenHands.getGitHubUserInstallationIds, enabled: - githubTokenIsSet && + providersAreSet && !!config?.GITHUB_CLIENT_ID && config?.APP_MODE === "saas", staleTime: 1000 * 60 * 5, // 5 minutes diff --git a/frontend/src/hooks/query/use-app-repositories.ts b/frontend/src/hooks/query/use-app-repositories.ts index a38b7857ae..e7e2669684 100644 --- a/frontend/src/hooks/query/use-app-repositories.ts +++ b/frontend/src/hooks/query/use-app-repositories.ts @@ -6,12 +6,12 @@ import { useConfig } from "./use-config"; import { useAuth } from "#/context/auth-context"; export const useAppRepositories = () => { - const { githubTokenIsSet } = useAuth(); + const { providersAreSet } = useAuth(); const { data: config } = useConfig(); const { data: installations } = useAppInstallations(); const repos = useInfiniteQuery({ - queryKey: ["repositories", githubTokenIsSet, installations], + queryKey: ["repositories", providersAreSet, installations], queryFn: async ({ pageParam, }: { @@ -46,7 +46,7 @@ export const useAppRepositories = () => { return null; }, enabled: - githubTokenIsSet && + providersAreSet && Array.isArray(installations) && installations.length > 0 && config?.APP_MODE === "saas", diff --git a/frontend/src/hooks/query/use-github-user.ts b/frontend/src/hooks/query/use-github-user.ts index 7781e07187..e8fcc359cc 100644 --- a/frontend/src/hooks/query/use-github-user.ts +++ b/frontend/src/hooks/query/use-github-user.ts @@ -7,15 +7,15 @@ import { useAuth } from "#/context/auth-context"; import { useLogout } from "../mutation/use-logout"; export const useGitHubUser = () => { - const { githubTokenIsSet } = useAuth(); + const { providersAreSet, providerTokensSet } = useAuth(); const { mutateAsync: logout } = useLogout(); const { data: config } = useConfig(); const user = useQuery({ - queryKey: ["user", githubTokenIsSet], + queryKey: ["user", providerTokensSet], queryFn: OpenHands.getGitHubUser, - enabled: githubTokenIsSet && !!config?.APP_MODE, + enabled: providersAreSet && !!config?.APP_MODE, retry: false, staleTime: 1000 * 60 * 5, // 5 minutes gcTime: 1000 * 60 * 15, // 15 minutes diff --git a/frontend/src/hooks/query/use-is-authed.ts b/frontend/src/hooks/query/use-is-authed.ts index 4e49d50ec7..170d9dcec8 100644 --- a/frontend/src/hooks/query/use-is-authed.ts +++ b/frontend/src/hooks/query/use-is-authed.ts @@ -5,13 +5,13 @@ import { useConfig } from "./use-config"; import { useAuth } from "#/context/auth-context"; export const useIsAuthed = () => { - const { githubTokenIsSet } = useAuth(); + const { providersAreSet } = useAuth(); const { data: config } = useConfig(); const appMode = React.useMemo(() => config?.APP_MODE, [config]); return useQuery({ - queryKey: ["user", "authenticated", githubTokenIsSet, appMode], + queryKey: ["user", "authenticated", providersAreSet, appMode], queryFn: () => OpenHands.authenticate(appMode!), enabled: !!appMode, staleTime: 1000 * 60 * 5, // 5 minutes diff --git a/frontend/src/hooks/query/use-settings.ts b/frontend/src/hooks/query/use-settings.ts index eee1952f0c..44b42303c0 100644 --- a/frontend/src/hooks/query/use-settings.ts +++ b/frontend/src/hooks/query/use-settings.ts @@ -17,7 +17,7 @@ const getSettingsQueryFn = async () => { SECURITY_ANALYZER: apiSettings.security_analyzer, LLM_API_KEY: apiSettings.llm_api_key, REMOTE_RUNTIME_RESOURCE_FACTOR: apiSettings.remote_runtime_resource_factor, - GITHUB_TOKEN_IS_SET: apiSettings.github_token_is_set, + PROVIDER_TOKENS_SET: apiSettings.provider_tokens_set, ENABLE_DEFAULT_CONDENSER: apiSettings.enable_default_condenser, ENABLE_SOUND_NOTIFICATIONS: apiSettings.enable_sound_notifications, USER_CONSENTS_TO_ANALYTICS: apiSettings.user_consents_to_analytics, @@ -27,10 +27,11 @@ const getSettingsQueryFn = async () => { }; export const useSettings = () => { - const { setGitHubTokenIsSet, githubTokenIsSet } = useAuth(); + const { setProviderTokensSet, providerTokensSet, setProvidersAreSet } = + useAuth(); const query = useQuery({ - queryKey: ["settings", githubTokenIsSet], + queryKey: ["settings", providerTokensSet], queryFn: getSettingsQueryFn, // Only retry if the error is not a 404 because we // would want to show the modal immediately if the @@ -50,8 +51,18 @@ export const useSettings = () => { }, [query.data?.LLM_API_KEY, query.isFetched]); React.useEffect(() => { - if (query.isFetched) setGitHubTokenIsSet(!!query.data?.GITHUB_TOKEN_IS_SET); - }, [query.data?.GITHUB_TOKEN_IS_SET, query.isFetched]); + if (query.data?.PROVIDER_TOKENS_SET) { + const providers = query.data.PROVIDER_TOKENS_SET; + const setProviders = ( + Object.keys(providers) as Array + ).filter((key) => providers[key]); + setProviderTokensSet(setProviders); + const atLeastOneSet = Object.values(query.data.PROVIDER_TOKENS_SET).some( + (value) => value, + ); + setProvidersAreSet(atLeastOneSet); + } + }, [query.data?.PROVIDER_TOKENS_SET, query.isFetched]); // We want to return the defaults if the settings aren't found so the user can still see the // options to make their initial save. We don't set the defaults in `initialData` above because diff --git a/frontend/src/hooks/query/use-user-repositories.ts b/frontend/src/hooks/query/use-user-repositories.ts index cb54878268..40a7b7dd3c 100644 --- a/frontend/src/hooks/query/use-user-repositories.ts +++ b/frontend/src/hooks/query/use-user-repositories.ts @@ -1,20 +1,20 @@ import { useInfiniteQuery } from "@tanstack/react-query"; import React from "react"; -import { retrieveGitHubUserRepositories } from "#/api/github"; +import { retrieveUserGitRepositories } from "#/api/github"; import { useConfig } from "./use-config"; import { useAuth } from "#/context/auth-context"; export const useUserRepositories = () => { - const { githubTokenIsSet } = useAuth(); + const { providerTokensSet, providersAreSet } = useAuth(); const { data: config } = useConfig(); const repos = useInfiniteQuery({ - queryKey: ["repositories", githubTokenIsSet], + queryKey: ["repositories", providerTokensSet], queryFn: async ({ pageParam }) => - retrieveGitHubUserRepositories(pageParam, 100), + retrieveUserGitRepositories(pageParam, 100), initialPageParam: 1, getNextPageParam: (lastPage) => lastPage.nextPage, - enabled: githubTokenIsSet && config?.APP_MODE === "oss", + enabled: providersAreSet && config?.APP_MODE === "oss", staleTime: 1000 * 60 * 5, // 5 minutes gcTime: 1000 * 60 * 15, // 15 minutes }); diff --git a/frontend/src/hooks/use-github-auth-url.ts b/frontend/src/hooks/use-github-auth-url.ts index b058913b61..c4d7ae8d30 100644 --- a/frontend/src/hooks/use-github-auth-url.ts +++ b/frontend/src/hooks/use-github-auth-url.ts @@ -9,15 +9,15 @@ interface UseGitHubAuthUrlConfig { } export const useGitHubAuthUrl = (config: UseGitHubAuthUrlConfig) => { - const { githubTokenIsSet } = useAuth(); + const { providersAreSet } = useAuth(); return React.useMemo(() => { - if (config.appMode === "saas" && !githubTokenIsSet) + if (config.appMode === "saas" && !providersAreSet) return generateGitHubAuthUrl( config.gitHubClientId || "", new URL(window.location.href), ); return null; - }, [githubTokenIsSet, config.appMode, config.gitHubClientId]); + }, [providersAreSet, config.appMode, config.gitHubClientId]); }; diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 3c83f1fa85..6e82f3e98c 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -151,6 +151,7 @@ export enum I18nKey { LANDING$CHANGE_PROMPT = "LANDING$CHANGE_PROMPT", GITHUB$CONNECT = "GITHUB$CONNECT", GITHUB$NO_RESULTS = "GITHUB$NO_RESULTS", + GITHUB$LOADING_REPOSITORIES = "GITHUB$LOADING_REPOSITORIES", GITHUB$ADD_MORE_REPOS = "GITHUB$ADD_MORE_REPOS", GITHUB$YOUR_REPOS = "GITHUB$YOUR_REPOS", GITHUB$PUBLIC_REPOS = "GITHUB$PUBLIC_REPOS", @@ -305,7 +306,7 @@ export enum I18nKey { ACCOUNT_SETTINGS$LOGOUT = "ACCOUNT_SETTINGS$LOGOUT", SETTINGS_FORM$ADVANCED_OPTIONS_LABEL = "SETTINGS_FORM$ADVANCED_OPTIONS_LABEL", CONVERSATION$NO_CONVERSATIONS = "CONVERSATION$NO_CONVERSATIONS", - LANDING$SELECT_REPO = "LANDING$SELECT_REPO", + LANDING$SELECT_GIT_REPO = "LANDING$SELECT_GIT_REPO", BUTTON$SEND = "BUTTON$SEND", STATUS$WAITING_FOR_CLIENT = "STATUS$WAITING_FOR_CLIENT", SUGGESTIONS$WHAT_TO_BUILD = "SUGGESTIONS$WHAT_TO_BUILD", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 03a19b499d..1d8090d09a 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -2239,7 +2239,19 @@ "es": "No se encontraron resultados.", "de": "Keine Ergebnisse gefunden.", "it": "Nessun risultato trovato.", - "pt": "Nenhum resultado encontrado.", + "pt": "Nenhum resultado encontrado." + }, + "GITHUB$LOADING_REPOSITORIES": { + "en": "Loading repositories...", + "ja": "リポジトリを読み込み中...", + "zh-CN": "正在加载仓库...", + "zh-TW": "正在加載倉庫...", + "ko-KR": "저장소 로딩 중...", + "fr": "Chargement des dépôts...", + "es": "Cargando repositorios...", + "de": "Lade Repositories...", + "it": "Caricamento dei repository...", + "pt": "Carregando repositórios...", "ar": "لم يتم العثور على نتائج.", "no": "Ingen resultater funnet.", "tr": "Sonuç bulunamadı" @@ -4554,19 +4566,19 @@ "no": "Ingen samtaler funnet", "tr": "Konuşma yok" }, - "LANDING$SELECT_REPO": { - "en": "Select a GitHub project", - "ja": "GitHubプロジェクトを選択", - "zh-CN": "选择GitHub项目", - "zh-TW": "選擇 GitHub 專案", - "ko-KR": "GitHub 프로젝트 선택", - "fr": "Sélectionner un projet GitHub", - "es": "Seleccionar un proyecto de GitHub", - "de": "Ein GitHub-Projekt auswählen", - "it": "Seleziona un progetto GitHub", - "pt": "Selecionar um projeto do GitHub", - "ar": "اختر مشروع GitHub", - "no": "Velg et GitHub-prosjekt", + "LANDING$SELECT_GIT_REPO": { + "en": "Select a Git project", + "ja": "Gitプロジェクトを選択", + "zh-CN": "选择Git项目", + "zh-TW": "選擇 Git 專案", + "ko-KR": "Git 프로젝트 선택", + "fr": "Sélectionner un projet Git", + "es": "Seleccionar un proyecto de Git", + "de": "Ein Git-Projekt auswählen", + "it": "Seleziona un progetto Git", + "pt": "Selecionar um projeto do Git", + "ar": "اختر مشروع Git", + "no": "Velg et Git-prosjekt", "tr": "Depo seç" }, "BUTTON$SEND": { diff --git a/frontend/src/mocks/handlers.ts b/frontend/src/mocks/handlers.ts index 2571b9ff70..8c553c0715 100644 --- a/frontend/src/mocks/handlers.ts +++ b/frontend/src/mocks/handlers.ts @@ -7,6 +7,7 @@ import { import { DEFAULT_SETTINGS } from "#/services/settings"; import { STRIPE_BILLING_HANDLERS } from "./billing-handlers"; import { ApiSettings, PostApiSettings } from "#/types/settings"; +import { GitHubUser } from "#/types/github"; export const MOCK_DEFAULT_USER_SETTINGS: ApiSettings | PostApiSettings = { llm_model: DEFAULT_SETTINGS.LLM_MODEL, @@ -18,7 +19,7 @@ export const MOCK_DEFAULT_USER_SETTINGS: ApiSettings | PostApiSettings = { security_analyzer: DEFAULT_SETTINGS.SECURITY_ANALYZER, remote_runtime_resource_factor: DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR, - github_token_is_set: DEFAULT_SETTINGS.GITHUB_TOKEN_IS_SET, + provider_tokens_set: DEFAULT_SETTINGS.PROVIDER_TOKENS_SET, enable_default_condenser: DEFAULT_SETTINGS.ENABLE_DEFAULT_CONDENSER, enable_sound_notifications: DEFAULT_SETTINGS.ENABLE_SOUND_NOTIFICATIONS, user_consents_to_analytics: DEFAULT_SETTINGS.USER_CONSENTS_TO_ANALYTICS, @@ -148,13 +149,13 @@ const openHandsHandlers = [ export const handlers = [ ...STRIPE_BILLING_HANDLERS, ...openHandsHandlers, - http.get("/api/github/repositories", () => + http.get("/api/user/repositories", () => HttpResponse.json([ { id: 1, full_name: "octocat/hello-world" }, { id: 2, full_name: "octocat/earth" }, ]), ), - http.get("/api/github/user", () => { + http.get("/api/user/info", () => { const user: GitHubUser = { id: 1, login: "octocat", @@ -190,12 +191,13 @@ export const handlers = [ }), http.get("/api/settings", async () => { await delay(); + const { settings } = MOCK_USER_PREFERENCES; if (!settings) return HttpResponse.json(null, { status: 404 }); - if (Object.keys(settings.provider_tokens).length > 0) - settings.github_token_is_set = true; + if (Object.keys(settings.provider_tokens_set).length > 0) + settings.provider_tokens_set = { github: false, gitlab: false }; return HttpResponse.json(settings); }), diff --git a/frontend/src/routes/_oh._index/route.tsx b/frontend/src/routes/_oh._index/route.tsx index ff33476bc0..2cf3a14b3e 100644 --- a/frontend/src/routes/_oh._index/route.tsx +++ b/frontend/src/routes/_oh._index/route.tsx @@ -5,8 +5,8 @@ import { setReplayJson } from "#/state/initial-query-slice"; import { useGitHubUser } from "#/hooks/query/use-github-user"; import { useGitHubAuthUrl } from "#/hooks/use-github-auth-url"; import { useConfig } from "#/hooks/query/use-config"; +import { GitRepositoriesSuggestionBox } from "#/components/features/github/github-repositories-suggestion-box"; import { ReplaySuggestionBox } from "../../components/features/suggestions/replay-suggestion-box"; -import { GitHubRepositoriesSuggestionBox } from "#/components/features/github/github-repositories-suggestion-box"; import { CodeNotInGitHubLink } from "#/components/features/github/code-not-in-github-link"; import { HeroHeading } from "#/components/shared/hero-heading"; import { TaskForm } from "#/components/shared/task-form"; @@ -37,7 +37,7 @@ function Home() {
- formRef.current?.requestSubmit()} gitHubAuthUrl={gitHubAuthUrl} user={user || null} diff --git a/frontend/src/routes/_oh/route.tsx b/frontend/src/routes/_oh/route.tsx index 798028fdb4..5043ff9537 100644 --- a/frontend/src/routes/_oh/route.tsx +++ b/frontend/src/routes/_oh/route.tsx @@ -58,7 +58,7 @@ export default function MainApp() { const navigate = useNavigate(); const { pathname } = useLocation(); const [searchParams] = useSearchParams(); - const { githubTokenIsSet } = useAuth(); + const { providersAreSet } = useAuth(); const { data: settings } = useSettings(); const { error, isFetching } = useBalance(); const { migrateUserConsent } = useMigrateUserConsent(); @@ -131,7 +131,7 @@ export default function MainApp() { {renderWaitlistModal && ( )} diff --git a/frontend/src/routes/account-settings.tsx b/frontend/src/routes/account-settings.tsx index a97f4cae37..7cc0c50feb 100644 --- a/frontend/src/routes/account-settings.tsx +++ b/frontend/src/routes/account-settings.tsx @@ -25,6 +25,8 @@ import { displayErrorToast, displaySuccessToast, } from "#/utils/custom-toast-handlers"; +import { ProviderOptions } from "#/types/settings"; +import { useAuth } from "#/context/auth-context"; const REMOTE_RUNTIME_OPTIONS = [ { key: 1, label: "1x (2 core, 8G)" }, @@ -46,6 +48,7 @@ function AccountSettings() { } = useAIConfigOptions(); const { mutate: saveSettings } = useSaveSettings(); const { handleLogout } = useAppLogout(); + const { providerTokensSet, providersAreSet } = useAuth(); const isFetching = isFetchingSettings || isFetchingResources; const isSuccess = isSuccessfulSettings && isSuccessfulResources; @@ -62,7 +65,6 @@ function AccountSettings() { isCustomModel(resources.models, settings.LLM_MODEL) || hasAdvancedSettingsSet({ ...settings, - PROVIDER_TOKENS: settings.PROVIDER_TOKENS || {}, }) ); } @@ -71,7 +73,10 @@ function AccountSettings() { }; const hasAppSlug = !!config?.APP_SLUG; - const isGitHubTokenSet = settings?.GITHUB_TOKEN_IS_SET; + const isGitHubTokenSet = + providerTokensSet.includes(ProviderOptions.github) || false; + const isGitLabTokenSet = + providerTokensSet.includes(ProviderOptions.gitlab) || false; const isLLMKeySet = settings?.LLM_API_KEY === "**********"; const isAnalyticsEnabled = settings?.USER_CONSENTS_TO_ANALYTICS; const isAdvancedSettingsSet = determineWhetherToToggleAdvancedSettings(); @@ -121,6 +126,8 @@ function AccountSettings() { ? undefined // don't update if it's already set : ""); // reset if it's first time save to avoid 500 error + const githubToken = formData.get("github-token-input")?.toString(); + const gitlabToken = formData.get("gitlab-token-input")?.toString(); // we don't want the user to be able to modify these settings in SaaS const finalLlmModel = shouldHandleSpecialSaasCase ? undefined @@ -130,15 +137,14 @@ function AccountSettings() { : llmBaseUrl; const finalLlmApiKey = shouldHandleSpecialSaasCase ? undefined : llmApiKey; - const githubToken = formData.get("github-token-input")?.toString(); const newSettings = { - github_token: githubToken, - provider_tokens: githubToken - ? { - github: githubToken, - gitlab: "", - } - : undefined, + provider_tokens: + githubToken || gitlabToken + ? { + github: githubToken || "", + gitlab: gitlabToken || "", + } + : undefined, LANGUAGE: languageValue, user_consents_to_analytics: userConsentsToAnalytics, ENABLE_DEFAULT_CONDENSER: enableMemoryCondenser, @@ -367,7 +373,7 @@ function AccountSettings() {

- GitHub Settings + Git Provider Settings

{isSaas && hasAppSlug && ( .

+ + + ) + } + placeholder={isGitHubTokenSet ? "" : ""} + /> + +

+ {" "} + Generate a token on{" "} + + {" "} + + GitLab + {" "} + + or see the{" "} + + + documentation + + + . +

+ + Disconnect Tokens + )} - - - Disconnect from GitHub -
diff --git a/frontend/src/services/settings.ts b/frontend/src/services/settings.ts index 91b95d2021..34ffd42636 100644 --- a/frontend/src/services/settings.ts +++ b/frontend/src/services/settings.ts @@ -11,7 +11,7 @@ export const DEFAULT_SETTINGS: Settings = { CONFIRMATION_MODE: false, SECURITY_ANALYZER: "", REMOTE_RUNTIME_RESOURCE_FACTOR: 1, - GITHUB_TOKEN_IS_SET: false, + PROVIDER_TOKENS_SET: { github: false, gitlab: false }, ENABLE_DEFAULT_CONDENSER: true, ENABLE_SOUND_NOTIFICATIONS: false, USER_CONSENTS_TO_ANALYTICS: false, diff --git a/frontend/src/state/initial-query-slice.ts b/frontend/src/state/initial-query-slice.ts index 00a628d43a..f393d5fca1 100644 --- a/frontend/src/state/initial-query-slice.ts +++ b/frontend/src/state/initial-query-slice.ts @@ -1,9 +1,12 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { Provider } from "#/types/settings"; +import { GitRepository } from "#/types/github"; type SliceState = { files: string[]; // base64 encoded images initialPrompt: string | null; - selectedRepository: string | null; + selectedRepository: GitRepository | null; + selectedRepositoryProvider: Provider | null; replayJson: string | null; }; @@ -11,6 +14,7 @@ const initialState: SliceState = { files: [], initialPrompt: null, selectedRepository: null, + selectedRepositoryProvider: null, replayJson: null, }; @@ -33,7 +37,7 @@ export const selectedFilesSlice = createSlice({ clearInitialPrompt(state) { state.initialPrompt = null; }, - setSelectedRepository(state, action: PayloadAction) { + setSelectedRepository(state, action: PayloadAction) { state.selectedRepository = action.payload; }, clearSelectedRepository(state) { diff --git a/frontend/src/types/github.d.ts b/frontend/src/types/github.d.ts index 36ccb3ee74..dc8d8863ac 100644 --- a/frontend/src/types/github.d.ts +++ b/frontend/src/types/github.d.ts @@ -1,3 +1,5 @@ +import { Provider } from "#/types/settings"; + interface GitHubErrorReponse { message: string; documentation_url: string; @@ -13,9 +15,10 @@ interface GitHubUser { email: string | null; } -interface GitHubRepository { +interface GitRepository { id: number; full_name: string; + git_provider: Provider; stargazers_count?: number; link_header?: string; } diff --git a/frontend/src/types/settings.ts b/frontend/src/types/settings.ts index 9fcec7d7ef..0649ce7b7a 100644 --- a/frontend/src/types/settings.ts +++ b/frontend/src/types/settings.ts @@ -1,4 +1,9 @@ -export type Provider = "github" | "gitlab"; +export const ProviderOptions = { + github: "github", + gitlab: "gitlab", +} as const; + +export type Provider = keyof typeof ProviderOptions; export type Settings = { LLM_MODEL: string; @@ -9,7 +14,7 @@ export type Settings = { CONFIRMATION_MODE: boolean; SECURITY_ANALYZER: string; REMOTE_RUNTIME_RESOURCE_FACTOR: number | null; - GITHUB_TOKEN_IS_SET: boolean; + PROVIDER_TOKENS_SET: Record; ENABLE_DEFAULT_CONDENSER: boolean; ENABLE_SOUND_NOTIFICATIONS: boolean; USER_CONSENTS_TO_ANALYTICS: boolean | null; @@ -26,11 +31,11 @@ export type ApiSettings = { confirmation_mode: boolean; security_analyzer: string; remote_runtime_resource_factor: number | null; - github_token_is_set: boolean; enable_default_condenser: boolean; enable_sound_notifications: boolean; user_consents_to_analytics: boolean | null; provider_tokens: Record; + provider_tokens_set: Record; }; export type PostSettings = Settings & { diff --git a/frontend/src/utils/has-advanced-settings-set.ts b/frontend/src/utils/has-advanced-settings-set.ts index 374047c36a..ca876fe6e2 100644 --- a/frontend/src/utils/has-advanced-settings-set.ts +++ b/frontend/src/utils/has-advanced-settings-set.ts @@ -1,7 +1,7 @@ import { DEFAULT_SETTINGS } from "#/services/settings"; import { Settings } from "#/types/settings"; -export const hasAdvancedSettingsSet = (settings: Settings): boolean => +export const hasAdvancedSettingsSet = (settings: Partial): boolean => !!settings.LLM_BASE_URL || settings.AGENT !== DEFAULT_SETTINGS.AGENT || settings.REMOTE_RUNTIME_RESOURCE_FACTOR !== diff --git a/frontend/test-utils.tsx b/frontend/test-utils.tsx index d4ed2629df..c6be5cb0b3 100644 --- a/frontend/test-utils.tsx +++ b/frontend/test-utils.tsx @@ -65,7 +65,7 @@ export function renderWithProviders( function Wrapper({ children }: PropsWithChildren) { return ( - + list[Repository]: - params = {'page': str(page), 'per_page': str(per_page)} - if installation_id: - url = f'{self.BASE_URL}/user/installations/{installation_id}/repositories' - response, headers = await self._fetch_data(url, params) - response = response.get('repositories', []) - else: - url = f'{self.BASE_URL}/user/repos' - params['sort'] = sort - response, headers = await self._fetch_data(url, params) + MAX_REPOS = 1000 + PER_PAGE = 100 # Maximum allowed by GitHub API + all_repos: list[dict]= [] + page = 1 - next_link: str = headers.get('Link', '') - repos = [ + while len(all_repos) < MAX_REPOS: + params = {'page': str(page), 'per_page': str(PER_PAGE)} + if installation_id: + url = f'{self.BASE_URL}/user/installations/{installation_id}/repositories' + response, headers = await self._fetch_data(url, params) + response = response.get('repositories', []) + else: + url = f'{self.BASE_URL}/user/repos' + params['sort'] = sort + response, headers = await self._fetch_data(url, params) + + if not response: # No more repositories + break + + all_repos.extend(response) + page += 1 + + # Check if we've reached the last page + link_header = headers.get('Link', '') + if 'rel="next"' not in link_header: + break + + # Trim to MAX_REPOS if needed and convert to Repository objects + all_repos = all_repos[:MAX_REPOS] + return [ Repository( id=repo.get('id'), full_name=repo.get('full_name'), stargazers_count=repo.get('stargazers_count'), - link_header=next_link, + git_provider=ProviderType.GITHUB ) - for repo in response + for repo in all_repos ] - return repos async def get_installation_ids(self) -> list[int]: url = f'{self.BASE_URL}/user/installations' @@ -143,6 +161,7 @@ class GitHubService(GitService): id=repo.get('id'), full_name=repo.get('full_name'), stargazers_count=repo.get('stargazers_count'), + git_provider=ProviderType.GITHUB ) for repo in repos ] @@ -290,6 +309,14 @@ class GitHubService(GitService): except Exception: return [] + async def does_repo_exist(self, repository: str) -> bool: + url = f'{self.BASE_URL}/repos/{repository}' + try: + await self._fetch_data(url) + return True + except Exception: + return False + github_service_cls = os.environ.get( 'OPENHANDS_GITHUB_SERVICE_CLS', diff --git a/openhands/integrations/gitlab/gitlab_service.py b/openhands/integrations/gitlab/gitlab_service.py index 95ff6e52ea..8446c3fc4c 100644 --- a/openhands/integrations/gitlab/gitlab_service.py +++ b/openhands/integrations/gitlab/gitlab_service.py @@ -2,11 +2,13 @@ import os from typing import Any import httpx +from urllib.parse import quote_plus from pydantic import SecretStr from openhands.integrations.service_types import ( AuthenticationError, GitService, + ProviderType, Repository, UnknownException, User, @@ -95,7 +97,7 @@ class GitLabService(GitService): async def search_repositories( self, query: str, per_page: int = 30, sort: str = 'updated', order: str = 'desc' - ): + ) -> list[Repository]: url = f'{self.BASE_URL}/search' params = { 'scope': 'projects', @@ -104,13 +106,82 @@ class GitLabService(GitService): 'order_by': sort, 'sort': order, } - response, headers = await self._fetch_data(url, params) - return response, headers + response, _ = await self._fetch_data(url, params) + repos = [ + Repository( + id=repo.get('id'), + full_name=repo.get('path_with_namespace'), + stargazers_count=repo.get('star_count'), + git_provider=ProviderType.GITLAB + ) + for repo in response + ] + + return repos async def get_repositories( - self, page: int, per_page: int, sort: str, installation_id: int | None + self, sort: str, installation_id: int | None ) -> list[Repository]: - return [] + if installation_id: + return [] # Not implementing installation_token case yet + + MAX_REPOS = 1000 + PER_PAGE = 100 # Maximum allowed by GitLab API + all_repos: list[dict] = [] + page = 1 + + url = f'{self.BASE_URL}/projects' + # Map GitHub's sort values to GitLab's order_by values + order_by = { + 'pushed': 'last_activity_at', + 'updated': 'last_activity_at', + 'created': 'created_at', + 'full_name': 'name' + }.get(sort, 'last_activity_at') + + while len(all_repos) < MAX_REPOS: + params = { + 'page': str(page), + 'per_page': str(PER_PAGE), + 'order_by': order_by, + 'sort': 'desc', # GitLab uses sort for direction (asc/desc) + 'owned': 1, # Use 1 instead of True + 'membership': 1 # Use 1 instead of True + } + response, headers = await self._fetch_data(url, params) + + if not response: # No more repositories + break + + all_repos.extend(response) + page += 1 + + # Check if we've reached the last page + link_header = headers.get('Link', '') + if 'rel="next"' not in link_header: + break + + # Trim to MAX_REPOS if needed and convert to Repository objects + all_repos = all_repos[:MAX_REPOS] + return [ + Repository( + id=repo.get('id'), + full_name=repo.get('path_with_namespace'), + stargazers_count=repo.get('star_count'), + git_provider=ProviderType.GITLAB + ) + for repo in all_repos + ] + + async def does_repo_exist(self, repository: str) -> bool: + encoded_repo = quote_plus(repository) + url = f'{self.BASE_URL}/projects/{encoded_repo}' + try: + await self._fetch_data(url) + return True + except Exception as e: + print(e) + return False gitlab_service_cls = os.environ.get( diff --git a/openhands/integrations/provider.py b/openhands/integrations/provider.py index 74f344f9b7..2843dfc427 100644 --- a/openhands/integrations/provider.py +++ b/openhands/integrations/provider.py @@ -1,6 +1,5 @@ from __future__ import annotations -from enum import Enum from types import MappingProxyType from typing import Annotated, Any, Coroutine, Literal, overload @@ -24,16 +23,12 @@ from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl from openhands.integrations.service_types import ( AuthenticationError, GitService, + ProviderType, Repository, User, ) -class ProviderType(Enum): - GITHUB = 'github' - GITLAB = 'gitlab' - - class ProviderToken(BaseModel): token: SecretStr | None = Field(default=None) user_id: str | None = Field(default=None) @@ -194,21 +189,71 @@ class ProviderHandler: return await service.get_latest_token() async def get_repositories( - self, page: int, per_page: int, sort: str, installation_id: int | None + self, + sort: str, + installation_id: int | None, ) -> list[Repository]: - """Get repositories from all available providers""" - all_repos = [] + """ + Get repositories from a selected providers with pagination support + """ + + all_repos: list[Repository] = [] for provider in self.provider_tokens: try: service = self._get_service(provider) - repos = await service.get_repositories( - page, per_page, sort, installation_id - ) - all_repos.extend(repos) + service_repos = await service.get_repositories(sort, installation_id) + all_repos.extend(service_repos) except Exception: continue + return all_repos + async def search_repositories( + self, + query: str, + per_page: int, + sort: str, + order: str, + ): + all_repos: list[Repository] = [] + for provider in self.provider_tokens: + try: + service = self._get_service(provider) + service_repos = await service.search_repositories( + query, per_page, sort, order + ) + all_repos.extend(service_repos) + except Exception: + continue + + return all_repos + + async def get_remote_repository_url(self, repository: str) -> str | None: + if not repository: + return None + + provider_domains = { + ProviderType.GITHUB: 'github.com', + ProviderType.GITLAB: 'gitlab.com', + } + + for provider in self.provider_tokens: + try: + service = self._get_service(provider) + repo_exists = await service.does_repo_exist(repository) + if repo_exists: + git_token = self.provider_tokens[provider].token + if git_token and provider in provider_domains: + domain = provider_domains[provider] + + if provider == ProviderType.GITLAB: + return f'https://oauth2:{git_token.get_secret_value()}@{domain}/{repository}.git' + + return f'https://{git_token.get_secret_value()}@{domain}/{repository}.git' + except Exception: + continue + return None + async def set_event_stream_secrets( self, event_stream: EventStream, diff --git a/openhands/integrations/service_types.py b/openhands/integrations/service_types.py index 04f43bccc4..b60bc37a7a 100644 --- a/openhands/integrations/service_types.py +++ b/openhands/integrations/service_types.py @@ -4,6 +4,11 @@ from typing import Protocol from pydantic import BaseModel, SecretStr +class ProviderType(Enum): + GITHUB = 'github' + GITLAB = 'gitlab' + + class TaskType(str, Enum): MERGE_CONFLICTS = 'MERGE_CONFLICTS' FAILING_CHECKS = 'FAILING_CHECKS' @@ -31,8 +36,10 @@ class User(BaseModel): class Repository(BaseModel): id: int full_name: str + git_provider: ProviderType stargazers_count: int | None = None link_header: str | None = None + pushed_at: str | None = None # ISO 8601 format date string class AuthenticationError(ValueError): @@ -81,10 +88,11 @@ class GitService(Protocol): async def get_repositories( self, - page: int, - per_page: int, sort: str, installation_id: int | None, ) -> list[Repository]: """Get repositories for the authenticated user""" ... + + async def does_repo_exist(self, repository: str) -> bool: + """Check if a repository exists for the user""" diff --git a/openhands/runtime/base.py b/openhands/runtime/base.py index 3ad16c0948..166ca6289b 100644 --- a/openhands/runtime/base.py +++ b/openhands/runtime/base.py @@ -153,6 +153,7 @@ class Runtime(FileEditRuntimeMixin): ) self.user_id = user_id + self.git_provider_tokens = git_provider_tokens # TODO: remove once done debugging expired github token self.prev_token: SecretStr | None = None @@ -321,28 +322,25 @@ class Runtime(FileEditRuntimeMixin): return self.event_stream.add_event(observation, source) # type: ignore[arg-type] - def clone_repo( + async def clone_repo( self, git_provider_tokens: PROVIDER_TOKEN_TYPE, selected_repository: str, selected_branch: str | None, ) -> str: - if ( - ProviderType.GITHUB not in git_provider_tokens - or not git_provider_tokens[ProviderType.GITHUB].token - or not selected_repository - ): - raise ValueError( - 'github_token and selected_repository must be provided to clone a repository' - ) + provider_handler = ProviderHandler(provider_tokens=git_provider_tokens) + remote_repo_url = await provider_handler.get_remote_repository_url( + selected_repository + ) + + if not remote_repo_url: + raise ValueError('Missing either Git token or valid repository') if self.status_callback: self.status_callback( 'info', 'STATUS$SETTING_UP_WORKSPACE', 'Setting up workspace...' ) - github_token: SecretStr = git_provider_tokens[ProviderType.GITHUB].token - url = f'https://{github_token.get_secret_value()}@github.com/{selected_repository}.git' dir_name = selected_repository.split('/')[-1] # Generate a random branch name to avoid conflicts @@ -352,7 +350,7 @@ class Runtime(FileEditRuntimeMixin): openhands_workspace_branch = f'openhands-workspace-{random_str}' # Clone repository command - clone_command = f'git clone {url} {dir_name}' + clone_command = f'git clone {remote_repo_url} {dir_name}' # Checkout to appropriate branch checkout_command = ( diff --git a/openhands/server/routes/github.py b/openhands/server/routes/github.py index 33676def89..547130dfbe 100644 --- a/openhands/server/routes/github.py +++ b/openhands/server/routes/github.py @@ -17,27 +17,29 @@ from openhands.integrations.service_types import ( ) from openhands.server.auth import get_access_token, get_provider_tokens -app = APIRouter(prefix='/api/github') +app = APIRouter(prefix='/api/user') + + +from pydantic import BaseModel @app.get('/repositories', response_model=list[Repository]) -async def get_github_repositories( - page: int = 1, - per_page: int = 10, +async def get_user_repositories( sort: str = 'pushed', installation_id: int | None = None, provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens), access_token: SecretStr | None = Depends(get_access_token), ): - if provider_tokens and ProviderType.GITHUB in provider_tokens: - token = provider_tokens[ProviderType.GITHUB] - client = GithubServiceImpl( - user_id=token.user_id, external_auth_token=access_token, token=token.token + + if provider_tokens: + client = ProviderHandler( + provider_tokens=provider_tokens, external_auth_token=access_token ) try: + repos: list[Repository] = await client.get_repositories( - page, per_page, sort, installation_id + sort, installation_id ) return repos @@ -54,13 +56,13 @@ async def get_github_repositories( ) return JSONResponse( - content='GitHub token required.', + content='Git provider token required. (such as GitHub).', status_code=status.HTTP_401_UNAUTHORIZED, ) -@app.get('/user', response_model=User) -async def get_github_user( +@app.get('/info', response_model=User) +async def get_user( provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens), access_token: SecretStr | None = Depends(get_access_token), ): @@ -86,7 +88,7 @@ async def get_github_user( ) return JSONResponse( - content='GitHub token required.', + content='Git provider token required. (such as GitHub).', status_code=status.HTTP_401_UNAUTHORIZED, ) @@ -125,7 +127,7 @@ async def get_github_installation_ids( @app.get('/search/repositories', response_model=list[Repository]) -async def search_github_repositories( +async def search_repositories( query: str, per_page: int = 5, sort: str = 'stars', @@ -133,11 +135,10 @@ async def search_github_repositories( provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens), access_token: SecretStr | None = Depends(get_access_token), ): - if provider_tokens and ProviderType.GITHUB in provider_tokens: - token = provider_tokens[ProviderType.GITHUB] - client = GithubServiceImpl( - user_id=token.user_id, external_auth_token=access_token, token=token.token + if provider_tokens: + client = ProviderHandler( + provider_tokens=provider_tokens, external_auth_token=access_token ) try: repos: list[Repository] = await client.search_repositories( diff --git a/openhands/server/routes/settings.py b/openhands/server/routes/settings.py index 73442b079a..47ee7e61ab 100644 --- a/openhands/server/routes/settings.py +++ b/openhands/server/routes/settings.py @@ -10,6 +10,7 @@ from openhands.server.settings import GETSettingsModel, POSTSettingsModel, Setti from openhands.server.shared import SettingsStoreImpl, config, server_config from openhands.server.types import AppMode + app = APIRouter(prefix='/api') @@ -25,10 +26,24 @@ async def load_settings(request: Request) -> GETSettingsModel | JSONResponse: content={'error': 'Settings not found'}, ) - github_token_is_set = bool(user_id) or bool(get_provider_tokens(request)) + provider_tokens_set = {} + + if bool(user_id): + provider_tokens_set[ProviderType.GITHUB.value] = True + + provider_tokens = get_provider_tokens(request) + if provider_tokens: + all_provider_types = [provider.value for provider in ProviderType] + provider_tokens_types = [provider.value for provider in provider_tokens] + for provider_type in all_provider_types: + if provider_type in provider_tokens_types: + provider_tokens_set[provider_type] = True + else: + provider_tokens_set[provider_type] = False + settings_with_token_data = GETSettingsModel( **settings.model_dump(exclude='secrets_store'), - github_token_is_set=github_token_is_set, + provider_tokens_set=provider_tokens_set, ) settings_with_token_data.llm_api_key = settings.llm_api_key diff --git a/openhands/server/session/agent_session.py b/openhands/server/session/agent_session.py index 27daf479fb..6e9cc3416a 100644 --- a/openhands/server/session/agent_session.py +++ b/openhands/server/session/agent_session.py @@ -323,12 +323,9 @@ class AgentSession: return False if selected_repository and git_provider_tokens: - await call_sync_from_async( - self.runtime.clone_repo, - git_provider_tokens, - selected_repository, - selected_branch, - ) + await self.runtime.clone_repo(git_provider_tokens, + selected_repository, + selected_branch) await call_sync_from_async(self.runtime.maybe_run_setup_script) self.logger.debug( diff --git a/openhands/server/settings.py b/openhands/server/settings.py index ac267ada25..398e7a3da9 100644 --- a/openhands/server/settings.py +++ b/openhands/server/settings.py @@ -120,4 +120,4 @@ class GETSettingsModel(Settings): Settings with additional token data for the frontend """ - github_token_is_set: bool | None = None + provider_tokens_set: dict[str, bool] | None = None