mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-01-09 14:57:59 -05:00
[Feat]: Gitlab p2 - let user add PAT via FE (#7125)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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={[]}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
5
frontend/src/types/github.d.ts
vendored
5
frontend/src/types/github.d.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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 !==
|
||||
|
||||
@@ -65,7 +65,7 @@ export function renderWithProviders(
|
||||
function Wrapper({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<AuthProvider initialGithubTokenIsSet>
|
||||
<AuthProvider initialProviderTokens={[]}>
|
||||
<QueryClientProvider
|
||||
client={
|
||||
new QueryClient({
|
||||
|
||||
35
microagents/knowledge/gitlab.md
Normal file
35
microagents/knowledge/gitlab.md
Normal 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"}'
|
||||
```
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user