[Feat]: Gitlab p2 - let user add PAT via FE (#7125)

This commit is contained in:
Rohit Malhotra
2025-04-01 11:23:58 -04:00
committed by GitHub
parent 7488d1d0cb
commit 89bfbfad59
42 changed files with 702 additions and 288 deletions

View File

@@ -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(<ActionSuggestions onSuggestionsClick={() => {}} />);
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(<ActionSuggestions onSuggestionsClick={() => {}} />);
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(<ActionSuggestions onSuggestionsClick={() => {}} />);
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(<ActionSuggestions onSuggestionsClick={() => {}} />);
const component = render(
<ActionSuggestions onSuggestionsClick={() => {}} />,
);
// 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);

View File

@@ -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(
<GitHubRepositorySelector
<GitRepositorySelector
onInputChange={onInputChangeMock}
onSelect={onSelectMock}
publicRepositories={[]}
@@ -19,7 +20,7 @@ describe("GitHubRepositorySelector", () => {
);
expect(
screen.getByPlaceholderText("LANDING$SELECT_REPO"),
screen.getByPlaceholderText("LANDING$SELECT_GIT_REPO"),
).toBeInTheDocument();
});
@@ -37,7 +38,7 @@ describe("GitHubRepositorySelector", () => {
});
renderWithProviders(
<GitHubRepositorySelector
<GitRepositorySelector
onInputChange={onInputChangeMock}
onSelect={onSelectMock}
publicRepositories={[]}
@@ -53,11 +54,13 @@ describe("GitHubRepositorySelector", () => {
{
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(
<GitHubRepositorySelector
<GitRepositorySelector
onInputChange={onInputChangeMock}
onSelect={onSelectMock}
publicRepositories={[]}

View File

@@ -10,12 +10,18 @@ import * as AdvancedSettingsUtlls from "#/utils/has-advanced-settings-set";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import * as ConsentHandlers from "#/utils/handle-capture-consent";
import AccountSettings from "#/routes/account-settings";
import { Provider } from "#/types/settings";
const toggleAdvancedSettings = async (user: UserEvent) => {
const advancedSwitch = await screen.findByTestId("advanced-settings-switch");
await user.click(advancedSwitch);
};
const mock_provider_tokens_are_set: Record<Provider, boolean> = {
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 '<hidden>' 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();
});
});

View File

@@ -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<GitHubRepository[]>(
"/api/github/repositories",
const response = await openHands.get<GitRepository[]>(
"/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<GitHubRepository[]>(
"/api/github/repositories",
export const retrieveUserGitRepositories = async (page = 1, per_page = 30) => {
const response = await openHands.get<GitRepository[]>(
"/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

View File

@@ -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<GitHubUser> {
const response = await openHands.get<GitHubUser>("/api/github/user");
const response = await openHands.get<GitHubUser>("/api/user/info");
const { data } = response;
@@ -323,16 +324,16 @@ class OpenHands {
}
static async getGitHubUserInstallationIds(): Promise<number[]> {
const response = await openHands.get<number[]>("/api/github/installations");
const response = await openHands.get<number[]>("/api/user/installations");
return response.data;
}
static async searchGitHubRepositories(
query: string,
per_page = 5,
): Promise<GitHubRepository[]> {
const response = await openHands.get<GitHubRepository[]>(
"/api/github/search/repositories",
): Promise<GitRepository[]> {
const response = await openHands.get<GitRepository[]>(
"/api/user/search/repositories",
{
params: {
query,

View File

@@ -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 (
<div className="flex flex-col gap-2 mb-2">
{githubTokenIsSet && selectedRepository && (
{providersAreSet && selectedRepository && (
<div className="flex flex-row gap-2 justify-center w-full">
{!hasPullRequest ? (
<>
<SuggestionItem
suggestion={{
label: "Push to Branch",
value:
"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.",
value: terms.pushToBranch,
}}
onClick={(value) => {
posthog.capture("push_to_branch_button_clicked");
@@ -38,9 +57,8 @@ export function ActionSuggestions({
/>
<SuggestionItem
suggestion={{
label: "Push & Create PR",
value:
"Please push the changes to GitHub and open a pull request. Please create a meaningful branch name that describes the changes.",
label: `Push & Create ${terms.prShort}`,
value: terms.createPR,
}}
onClick={(value) => {
posthog.capture("create_pr_button_clicked");
@@ -52,9 +70,8 @@ export function ActionSuggestions({
) : (
<SuggestionItem
suggestion={{
label: "Push changes to PR",
value:
"Please push the latest changes to the existing pull request.",
label: `Push changes to ${terms.prShort}`,
value: terms.pushToPR,
}}
onClick={(value) => {
posthog.capture("push_to_pr_button_clicked");

View File

@@ -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<string | null>(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<Provider, GitRepository[]>
>(
(acc, repo) => {
if (!acc[repo.git_provider]) {
acc[repo.git_provider] = [];
}
acc[repo.git_provider].push(repo);
return acc;
},
{} as Record<Provider, GitRepository[]>,
);
const groupedPublicRepos = publicRepositories.reduce<
Record<Provider, GitRepository[]>
>(
(acc, repo) => {
if (!acc[repo.git_provider]) {
acc[repo.git_provider] = [];
}
acc[repo.git_provider].push(repo);
return acc;
},
{} as Record<Provider, GitRepository[]>,
);
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 ? (
<div className="flex items-center justify-center py-2">
<Spinner size="sm" className="mr-2" />
<span>{t(I18nKey.GITHUB$LOADING_REPOSITORIES)}</span>
</div>
) : (
t(I18nKey.GITHUB$NO_RESULTS)
);
return (
<Autocomplete
data-testid="github-repo-selector"
name="repo"
aria-label="GitHub Repository"
placeholder={t(I18nKey.LANDING$SELECT_REPO)}
aria-label="Git Repository"
placeholder={t(I18nKey.LANDING$SELECT_GIT_REPO)}
isVirtualized={false}
selectedKey={selectedKey}
inputProps={{
@@ -67,6 +106,7 @@ export function GitHubRepositorySelector({
inputWrapper:
"text-sm w-full rounded-[4px] px-3 py-[10px] bg-[#525252] text-[#A3A3A3]",
},
endContent: isLoading ? <Spinner size="sm" /> : 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({
</a>
</AutocompleteItem> // eslint-disable-next-line @typescript-eslint/no-explicit-any
) as any)}
{userRepositories.length > 0 && (
<AutocompleteSection showDivider title={t(I18nKey.GITHUB$YOUR_REPOS)}>
{userRepositories.map((repo) => (
<AutocompleteItem
data-testid="github-repo-item"
key={repo.id}
className="data-[selected=true]:bg-default-100"
textValue={repo.full_name}
>
{repo.full_name}
</AutocompleteItem>
))}
</AutocompleteSection>
{Object.entries(groupedUserRepos).map(([provider, repos]) =>
repos.length > 0 ? (
<AutocompleteSection
key={`user-${provider}`}
showDivider
title={`${t(I18nKey.GITHUB$YOUR_REPOS)} - ${provider}`}
>
{repos.map((repo) => (
<AutocompleteItem
data-testid="github-repo-item"
key={repo.id}
className="data-[selected=true]:bg-default-100"
textValue={repo.full_name}
>
{repo.full_name}
</AutocompleteItem>
))}
</AutocompleteSection>
) : null,
)}
{publicRepositories.length > 0 && (
<AutocompleteSection showDivider title={t(I18nKey.GITHUB$PUBLIC_REPOS)}>
{publicRepositories.map((repo) => (
<AutocompleteItem
data-testid="github-repo-item"
key={repo.id}
className="data-[selected=true]:bg-default-100"
textValue={repo.full_name}
>
{repo.full_name}
<span className="ml-1 text-gray-400">
({repo.stargazers_count || 0})
</span>
</AutocompleteItem>
))}
</AutocompleteSection>
{Object.entries(groupedPublicRepos).map(([provider, repos]) =>
repos.length > 0 ? (
<AutocompleteSection
key={`public-${provider}`}
showDivider
title={`${t(I18nKey.GITHUB$PUBLIC_REPOS)} - ${provider}`}
>
{repos.map((repo) => (
<AutocompleteItem
data-testid="github-repo-item"
key={repo.id}
className="data-[selected=true]:bg-default-100"
textValue={repo.full_name}
>
{repo.full_name}
<span className="ml-1 text-gray-400">
({repo.stargazers_count || 0})
</span>
</AutocompleteItem>
))}
</AutocompleteSection>
) : null,
)}
</Autocomplete>
);

View File

@@ -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<string>("");
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 ? (
<GitHubRepositorySelector
<GitRepositorySelector
onInputChange={setSearchQuery}
onSelect={handleSubmit}
publicRepositories={searchedRepos || []}
userRepositories={repositories}
isLoading={isLoading}
/>
) : (
<BrandButton

View File

@@ -1,27 +1,37 @@
import React from "react";
import { Provider } from "#/types/settings";
interface AuthContextType {
githubTokenIsSet: boolean;
setGitHubTokenIsSet: (value: boolean) => 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<AuthContextType | undefined>(undefined);
function AuthProvider({ children, initialGithubTokenIsSet }: AuthContextProps) {
const [githubTokenIsSet, setGitHubTokenIsSet] = React.useState(
!!initialGithubTokenIsSet,
function AuthProvider({
children,
initialProviderTokens = [],
}: AuthContextProps) {
const [providerTokensSet, setProviderTokensSet] = React.useState<Provider[]>(
initialProviderTokens,
);
const [providersAreSet, setProvidersAreSet] = React.useState<boolean>(false);
const value = React.useMemo(
() => ({
githubTokenIsSet,
setGitHubTokenIsSet,
providerTokensSet,
setProviderTokensSet,
providersAreSet,
setProvidersAreSet,
}),
[githubTokenIsSet, setGitHubTokenIsSet],
[providerTokensSet],
);
return <AuthContext value={value}>{children}</AuthContext>;

View File

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

View File

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

View File

@@ -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);

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<keyof typeof providers>
).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

View File

@@ -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
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -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() {
</div>
<div className="flex gap-4 w-full flex-col md:flex-row mt-8">
<GitHubRepositoriesSuggestionBox
<GitRepositoriesSuggestionBox
handleSubmit={() => formRef.current?.requestSubmit()}
gitHubAuthUrl={gitHubAuthUrl}
user={user || null}

View File

@@ -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 && (
<WaitlistModal
ghTokenIsSet={githubTokenIsSet}
ghTokenIsSet={providersAreSet}
githubAuthUrl={gitHubAuthUrl}
/>
)}

View File

@@ -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() {
<section className="flex flex-col gap-6">
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
GitHub Settings
Git Provider Settings
</h2>
{isSaas && hasAppSlug && (
<Link
@@ -422,17 +428,58 @@ function AccountSettings() {
</b>
.
</p>
<SettingsInput
testId="gitlab-token-input"
name="gitlab-token-input"
label="GitLab Token"
type="password"
className="w-[680px]"
startContent={
isGitLabTokenSet && (
<KeyStatusIcon isSet={!!isGitLabTokenSet} />
)
}
placeholder={isGitHubTokenSet ? "<hidden>" : ""}
/>
<p data-testId="gitlab-token-help-anchor" className="text-xs">
{" "}
Generate a token on{" "}
<b>
{" "}
<a
href="https://gitlab.com/-/user_settings/personal_access_tokens?name=openhands-app&scopes=api,read_user,read_repository,write_repository"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
>
GitLab
</a>{" "}
</b>
or see the{" "}
<b>
<a
href="https://docs.gitlab.com/user/profile/personal_access_tokens/"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
>
documentation
</a>
</b>
.
</p>
<BrandButton
type="button"
variant="secondary"
onClick={handleLogout}
isDisabled={!providersAreSet}
>
Disconnect Tokens
</BrandButton>
</>
)}
<BrandButton
type="button"
variant="secondary"
onClick={handleLogout}
isDisabled={!isGitHubTokenSet}
>
Disconnect from GitHub
</BrandButton>
</section>
<section className="flex flex-col gap-6">

View File

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

View File

@@ -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<string | null>) {
setSelectedRepository(state, action: PayloadAction<GitRepository | null>) {
state.selectedRepository = action.payload;
},
clearSelectedRepository(state) {

View File

@@ -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;
}

View File

@@ -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<Provider, boolean>;
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, string>;
provider_tokens_set: Record<Provider, boolean>;
};
export type PostSettings = Settings & {

View File

@@ -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<Settings>): boolean =>
!!settings.LLM_BASE_URL ||
settings.AGENT !== DEFAULT_SETTINGS.AGENT ||
settings.REMOTE_RUNTIME_RESOURCE_FACTOR !==

View File

@@ -65,7 +65,7 @@ export function renderWithProviders(
function Wrapper({ children }: PropsWithChildren) {
return (
<Provider store={store}>
<AuthProvider initialGithubTokenIsSet>
<AuthProvider initialProviderTokens={[]}>
<QueryClientProvider
client={
new QueryClient({

View File

@@ -0,0 +1,35 @@
---
name: gitlab
type: knowledge
version: 1.0.0
agent: CodeActAgent
triggers:
- gitlab
- git
---
You have access to an environment variable, `GITLAB_TOKEN`, which allows you to interact with
the GitLab API.
You can use `curl` with the `GITLAB_TOKEN` to interact with GitLab's API.
ALWAYS use the GitLab API for operations instead of a web browser.
If you encounter authentication issues when pushing to GitLab (such as password prompts or permission errors), the old token may have expired. In such case, update the remote URL to include the current token: `git remote set-url origin https://${GITLAB_TOKEN}@gitlab.com/username/repo.git`
Here are some instructions for pushing, but ONLY do this if the user asks you to:
* NEVER push directly to the `main` or `master` branch
* Git config (username and email) is pre-set. Do not modify.
* You may already be on a branch starting with `openhands-workspace`. Create a new branch with a better name before pushing.
* Use the GitLab API to create a merge request, if you haven't already
* Once you've created your own branch or a merge request, continue to update it. Do NOT create a new one unless you are explicitly asked to. Update the PR title and description as necessary, but don't change the branch name.
* Use the main branch as the base branch, unless the user requests otherwise
* After opening or updating a merge request, send the user a short message with a link to the merge request.
* Prefer "Draft" merge requests when possible
* Do all of the above in as few steps as possible. E.g. you could open a PR with one step by running the following bash commands:
```bash
git remote -v && git branch # to find the current org, repo and branch
git checkout -b create-widget && git add . && git commit -m "Create widget" && git push -u origin create-widget
curl -X POST "https://gitlab.com/api/v4/projects/$PROJECT_ID/merge_requests" \
-H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
-d '{"source_branch": "create-widget", "target_branch": "openhands-workspace", "title": "Create widget"}'
```

View File

@@ -23,6 +23,7 @@ from openhands.runtime import get_runtime_cls
from openhands.runtime.base import Runtime
from openhands.security import SecurityAnalyzer, options
from openhands.storage import get_file_store
from openhands.utils.async_utils import GENERAL_TIMEOUT, call_async_from_sync
def create_runtime(
@@ -117,10 +118,8 @@ def initialize_repository_for_runtime(
repo_directory = None
if selected_repository and provider_tokens:
logger.debug(f'Selected repository {selected_repository}.')
repo_directory = runtime.clone_repo(
provider_tokens,
selected_repository,
None,
repo_directory = call_async_from_sync(
runtime.clone_repo, GENERAL_TIMEOUT, github_token, selected_repository, None
)
# Run setup script if it exists
runtime.maybe_run_setup_script()

View File

@@ -9,6 +9,7 @@ from openhands.core.logger import openhands_logger as logger
from openhands.integrations.service_types import (
AuthenticationError,
GitService,
ProviderType,
Repository,
SuggestedTask,
TaskType,
@@ -99,29 +100,46 @@ class GitHubService(GitService):
)
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]:
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',

View File

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

View File

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

View File

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

View File

@@ -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 = (

View File

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

View File

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

View File

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

View File

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