mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
Paginate repo list from providers (#9826)
Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: amanape <83104063+amanape@users.noreply.github.com> Co-authored-by: Hiep Le <69354317+hieptl@users.noreply.github.com>
This commit is contained in:
parent
9c83a5623f
commit
3f327a940f
@ -85,17 +85,36 @@ describe("RepoConnector", () => {
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue({
|
||||
data: MOCK_RESPOSITORIES,
|
||||
nextPage: null,
|
||||
});
|
||||
|
||||
// Mock the search function that's used by the dropdown
|
||||
vi.spyOn(OpenHands, "searchGitRepositories").mockResolvedValue(
|
||||
MOCK_RESPOSITORIES,
|
||||
);
|
||||
|
||||
renderRepoConnector();
|
||||
|
||||
// Wait for the loading state to be replaced with the dropdown
|
||||
const dropdown = await waitFor(() => screen.getByTestId("repo-dropdown"));
|
||||
await userEvent.click(dropdown);
|
||||
// First select the provider
|
||||
const providerDropdown = await waitFor(() =>
|
||||
screen.getByText("Select Provider"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("Github"));
|
||||
|
||||
// Then interact with the repository dropdown
|
||||
const repoDropdown = await waitFor(() =>
|
||||
screen.getByTestId("repo-dropdown"),
|
||||
);
|
||||
const repoInput = within(repoDropdown).getByRole("combobox");
|
||||
await userEvent.click(repoInput);
|
||||
|
||||
// Wait for the options to be loaded and displayed
|
||||
await waitFor(() => {
|
||||
screen.getByText("rbren/polaris");
|
||||
screen.getByText("All-Hands-AI/OpenHands");
|
||||
expect(screen.getByText("rbren/polaris")).toBeInTheDocument();
|
||||
expect(screen.getByText("All-Hands-AI/OpenHands")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@ -104,18 +123,47 @@ describe("RepoConnector", () => {
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue({
|
||||
data: MOCK_RESPOSITORIES,
|
||||
nextPage: null,
|
||||
});
|
||||
|
||||
renderRepoConnector();
|
||||
|
||||
const launchButton = await screen.findByTestId("repo-launch-button");
|
||||
expect(launchButton).toBeDisabled();
|
||||
|
||||
// Wait for the loading state to be replaced with the dropdown
|
||||
const dropdown = await waitFor(() => screen.getByTestId("repo-dropdown"));
|
||||
await userEvent.click(dropdown);
|
||||
// Mock the repository branches API call
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
|
||||
{ name: "main", commit_sha: "123", protected: false },
|
||||
{ name: "develop", commit_sha: "456", protected: false },
|
||||
]);
|
||||
|
||||
// First select the provider
|
||||
const providerDropdown = await waitFor(() =>
|
||||
screen.getByText("Select Provider"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("Github"));
|
||||
|
||||
// Then select the repository
|
||||
const repoDropdown = await waitFor(() =>
|
||||
screen.getByTestId("repo-dropdown"),
|
||||
);
|
||||
const repoInput = within(repoDropdown).getByRole("combobox");
|
||||
await userEvent.click(repoInput);
|
||||
|
||||
// Wait for the options to be loaded and displayed
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("rbren/polaris")).toBeInTheDocument();
|
||||
});
|
||||
await userEvent.click(screen.getByText("rbren/polaris"));
|
||||
|
||||
// Wait for the branch to be auto-selected
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("main")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(launchButton).toBeEnabled();
|
||||
});
|
||||
|
||||
@ -180,7 +228,10 @@ describe("RepoConnector", () => {
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue({
|
||||
data: MOCK_RESPOSITORIES,
|
||||
nextPage: null,
|
||||
});
|
||||
|
||||
renderRepoConnector();
|
||||
|
||||
@ -192,14 +243,37 @@ describe("RepoConnector", () => {
|
||||
// repo not selected yet
|
||||
expect(createConversationSpy).not.toHaveBeenCalled();
|
||||
|
||||
// select a repository from the dropdown
|
||||
const dropdown = await waitFor(() =>
|
||||
// Mock the repository branches API call
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
|
||||
{ name: "main", commit_sha: "123", protected: false },
|
||||
{ name: "develop", commit_sha: "456", protected: false },
|
||||
]);
|
||||
|
||||
// First select the provider
|
||||
const providerDropdown = await waitFor(() =>
|
||||
screen.getByText("Select Provider"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("Github"));
|
||||
|
||||
// Then select the repository
|
||||
const repoDropdown = await waitFor(() =>
|
||||
within(repoConnector).getByTestId("repo-dropdown"),
|
||||
);
|
||||
await userEvent.click(dropdown);
|
||||
const repoInput = within(repoDropdown).getByRole("combobox");
|
||||
await userEvent.click(repoInput);
|
||||
|
||||
// Wait for the options to be loaded and displayed
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("rbren/polaris")).toBeInTheDocument();
|
||||
});
|
||||
await userEvent.click(screen.getByText("rbren/polaris"));
|
||||
|
||||
// Wait for the branch to be auto-selected
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("main")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const repoOption = screen.getByText("rbren/polaris");
|
||||
await userEvent.click(repoOption);
|
||||
await userEvent.click(launchButton);
|
||||
|
||||
expect(createConversationSpy).toHaveBeenCalledExactlyOnceWith(
|
||||
@ -218,17 +292,46 @@ describe("RepoConnector", () => {
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue({
|
||||
data: MOCK_RESPOSITORIES,
|
||||
nextPage: null,
|
||||
});
|
||||
|
||||
// Mock the repository branches API call
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
|
||||
{ name: "main", commit_sha: "123", protected: false },
|
||||
{ name: "develop", commit_sha: "456", protected: false },
|
||||
]);
|
||||
|
||||
renderRepoConnector();
|
||||
|
||||
const launchButton = await screen.findByTestId("repo-launch-button");
|
||||
|
||||
// Wait for the loading state to be replaced with the dropdown
|
||||
const dropdown = await waitFor(() => screen.getByTestId("repo-dropdown"));
|
||||
await userEvent.click(dropdown);
|
||||
// First select the provider
|
||||
const providerDropdown = await waitFor(() =>
|
||||
screen.getByText("Select Provider"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("Github"));
|
||||
|
||||
// Then select the repository
|
||||
const repoDropdown = await waitFor(() =>
|
||||
screen.getByTestId("repo-dropdown"),
|
||||
);
|
||||
const repoInput = within(repoDropdown).getByRole("combobox");
|
||||
await userEvent.click(repoInput);
|
||||
|
||||
// Wait for the options to be loaded and displayed
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("rbren/polaris")).toBeInTheDocument();
|
||||
});
|
||||
await userEvent.click(screen.getByText("rbren/polaris"));
|
||||
|
||||
// Wait for the branch to be auto-selected
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("main")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.click(launchButton);
|
||||
expect(launchButton).toBeDisabled();
|
||||
expect(launchButton).toHaveTextContent(/Loading/i);
|
||||
|
||||
@ -12,6 +12,8 @@ const mockUseCreateConversation = vi.fn();
|
||||
const mockUseIsCreatingConversation = vi.fn();
|
||||
const mockUseTranslation = vi.fn();
|
||||
const mockUseAuth = vi.fn();
|
||||
const mockUseGitRepositories = vi.fn();
|
||||
const mockUseUserProviders = vi.fn();
|
||||
|
||||
// Setup default mock returns
|
||||
mockUseUserRepositories.mockReturnValue({
|
||||
@ -30,6 +32,29 @@ mockUseIsCreatingConversation.mockReturnValue(false);
|
||||
|
||||
mockUseTranslation.mockReturnValue({ t: (key: string) => key });
|
||||
|
||||
// Default mock for useGitRepositories
|
||||
mockUseGitRepositories.mockReturnValue({
|
||||
data: { pages: [] },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
onLoadMore: vi.fn(),
|
||||
});
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => mockUseTranslation(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-user-providers", () => ({
|
||||
useUserProviders: () => mockUseUserProviders(),
|
||||
}));
|
||||
|
||||
mockUseUserProviders.mockReturnValue({
|
||||
providers: ["github"],
|
||||
});
|
||||
|
||||
mockUseAuth.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
@ -71,6 +96,10 @@ vi.mock("react-router", async (importActual) => ({
|
||||
useNavigate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-git-repositories", () => ({
|
||||
useGitRepositories: () => mockUseGitRepositories(),
|
||||
}));
|
||||
|
||||
const mockOnRepoSelection = vi.fn();
|
||||
const renderForm = () =>
|
||||
render(<RepositorySelectionForm onRepoSelection={mockOnRepoSelection} />, {
|
||||
@ -96,34 +125,6 @@ describe("RepositorySelectionForm", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows loading indicator when repositories are being fetched", () => {
|
||||
const MOCK_REPOS: GitRepository[] = [
|
||||
{
|
||||
id: "1",
|
||||
full_name: "user/repo1",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
full_name: "user/repo2",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
},
|
||||
];
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_REPOS);
|
||||
|
||||
renderForm();
|
||||
|
||||
// Check if loading indicator is displayed
|
||||
expect(screen.getByTestId("repo-dropdown-loading")).toBeInTheDocument();
|
||||
expect(screen.getByText("HOME$LOADING_REPOSITORIES")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows dropdown when repositories are loaded", async () => {
|
||||
const MOCK_REPOS: GitRepository[] = [
|
||||
{
|
||||
@ -139,24 +140,30 @@ describe("RepositorySelectionForm", () => {
|
||||
is_public: true,
|
||||
},
|
||||
];
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_REPOS);
|
||||
mockUseGitRepositories.mockReturnValue({
|
||||
data: { pages: [{ data: MOCK_REPOS }] },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
onLoadMore: vi.fn(),
|
||||
});
|
||||
|
||||
renderForm();
|
||||
expect(await screen.findByTestId("repo-dropdown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error message when repository fetch fails", async () => {
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockRejectedValue(
|
||||
new Error("Failed to load"),
|
||||
);
|
||||
mockUseGitRepositories.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
onLoadMore: vi.fn(),
|
||||
});
|
||||
|
||||
renderForm();
|
||||
|
||||
@ -194,40 +201,45 @@ describe("RepositorySelectionForm", () => {
|
||||
];
|
||||
|
||||
const searchGitReposSpy = vi.spyOn(OpenHands, "searchGitRepositories");
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
|
||||
searchGitReposSpy.mockResolvedValue(MOCK_SEARCH_REPOS);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_REPOS);
|
||||
|
||||
mockUseGitRepositories.mockReturnValue({
|
||||
data: { pages: [{ data: MOCK_REPOS }] },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
onLoadMore: vi.fn(),
|
||||
});
|
||||
|
||||
mockUseAuth.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
providersAreSet: true,
|
||||
user: {
|
||||
id: 1,
|
||||
login: "testuser",
|
||||
avatar_url: "https://example.com/avatar.png",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
company: "Test Company",
|
||||
},
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
});
|
||||
|
||||
renderForm();
|
||||
|
||||
const input = await screen.findByTestId("repo-dropdown");
|
||||
await userEvent.click(input);
|
||||
|
||||
for (const repo of MOCK_REPOS) {
|
||||
expect(screen.getByText(repo.full_name)).toBeInTheDocument();
|
||||
}
|
||||
expect(
|
||||
screen.queryByText(MOCK_SEARCH_REPOS[0].full_name),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
expect(searchGitReposSpy).not.toHaveBeenCalled();
|
||||
const dropdown = await screen.findByTestId("repo-dropdown");
|
||||
const input = dropdown.querySelector('input[type="text"]') as HTMLInputElement;
|
||||
expect(input).toBeInTheDocument();
|
||||
|
||||
await userEvent.type(input, "https://github.com/kubernetes/kubernetes");
|
||||
expect(searchGitReposSpy).toHaveBeenLastCalledWith(
|
||||
"kubernetes/kubernetes",
|
||||
3,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(MOCK_SEARCH_REPOS[0].full_name),
|
||||
).toBeInTheDocument();
|
||||
for (const repo of MOCK_REPOS) {
|
||||
expect(screen.queryByText(repo.full_name)).not.toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("should call onRepoSelection when a searched repository is selected", async () => {
|
||||
@ -243,20 +255,26 @@ describe("RepositorySelectionForm", () => {
|
||||
const searchGitReposSpy = vi.spyOn(OpenHands, "searchGitRepositories");
|
||||
searchGitReposSpy.mockResolvedValue(MOCK_SEARCH_REPOS);
|
||||
|
||||
mockUseGitRepositories.mockReturnValue({
|
||||
data: { pages: [{ data: MOCK_SEARCH_REPOS }] },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
onLoadMore: vi.fn(),
|
||||
});
|
||||
|
||||
renderForm();
|
||||
|
||||
const input = await screen.findByTestId("repo-dropdown");
|
||||
const dropdown = await screen.findByTestId("repo-dropdown");
|
||||
const input = dropdown.querySelector('input[type="text"]') as HTMLInputElement;
|
||||
expect(input).toBeInTheDocument();
|
||||
|
||||
await userEvent.type(input, "https://github.com/kubernetes/kubernetes");
|
||||
expect(searchGitReposSpy).toHaveBeenLastCalledWith(
|
||||
"kubernetes/kubernetes",
|
||||
3,
|
||||
);
|
||||
|
||||
const searchedRepo = screen.getByText(MOCK_SEARCH_REPOS[0].full_name);
|
||||
expect(searchedRepo).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(searchedRepo);
|
||||
expect(mockOnRepoSelection).toHaveBeenCalledWith(MOCK_SEARCH_REPOS[0]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -73,7 +73,7 @@ describe("TaskCard", () => {
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue({ data: MOCK_RESPOSITORIES, nextPage: null });
|
||||
});
|
||||
|
||||
it("should call create conversation with suggest task trigger and selected suggested task", async () => {
|
||||
|
||||
@ -12,6 +12,23 @@ import { GitRepository } from "#/types/git";
|
||||
import { RepositoryMicroagent } from "#/types/microagent-management";
|
||||
import { Conversation } from "#/api/open-hands.types";
|
||||
|
||||
// Mock hooks
|
||||
const mockUseUserProviders = vi.fn();
|
||||
const mockUseUserRepositories = vi.fn();
|
||||
const mockUseConfig = vi.fn();
|
||||
|
||||
vi.mock("#/hooks/use-user-providers", () => ({
|
||||
useUserProviders: () => mockUseUserProviders(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-user-repositories", () => ({
|
||||
useUserRepositories: () => mockUseUserRepositories(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-config", () => ({
|
||||
useConfig: () => mockUseConfig(),
|
||||
}));
|
||||
|
||||
describe("MicroagentManagement", () => {
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
@ -151,10 +168,39 @@ describe("MicroagentManagement", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
|
||||
// Setup default hook mocks
|
||||
mockUseUserProviders.mockReturnValue({
|
||||
providers: ["github"],
|
||||
});
|
||||
|
||||
mockUseUserRepositories.mockReturnValue({
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
data: mockRepositories,
|
||||
nextPage: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
onLoadMore: vi.fn(),
|
||||
});
|
||||
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: {
|
||||
APP_MODE: "oss",
|
||||
},
|
||||
});
|
||||
|
||||
// Setup default mock for retrieveUserGitRepositories
|
||||
vi.spyOn(OpenHands, "retrieveUserGitRepositories").mockResolvedValue([
|
||||
...mockRepositories,
|
||||
]);
|
||||
vi.spyOn(OpenHands, "retrieveUserGitRepositories").mockResolvedValue({
|
||||
data: [...mockRepositories],
|
||||
nextPage: null,
|
||||
});
|
||||
// Setup default mock for getRepositoryMicroagents
|
||||
vi.spyOn(OpenHands, "getRepositoryMicroagents").mockResolvedValue([
|
||||
...mockMicroagents,
|
||||
@ -180,13 +226,15 @@ describe("MicroagentManagement", () => {
|
||||
});
|
||||
|
||||
it("should display loading state when fetching repositories", async () => {
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockImplementation(
|
||||
() => new Promise(() => {}), // Never resolves
|
||||
);
|
||||
// Mock loading state
|
||||
mockUseUserRepositories.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
onLoadMore: vi.fn(),
|
||||
});
|
||||
|
||||
renderMicroagentManagement();
|
||||
|
||||
@ -196,19 +244,21 @@ describe("MicroagentManagement", () => {
|
||||
});
|
||||
|
||||
it("should handle error when fetching repositories", async () => {
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockRejectedValue(
|
||||
new Error("Failed to fetch repositories"),
|
||||
);
|
||||
// Mock error state
|
||||
mockUseUserRepositories.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
onLoadMore: vi.fn(),
|
||||
});
|
||||
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for the error to be handled
|
||||
await waitFor(() => {
|
||||
expect(retrieveUserGitRepositoriesSpy).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -217,7 +267,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that tabs are rendered
|
||||
@ -235,7 +285,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded and rendered
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that repository names are displayed
|
||||
@ -250,7 +300,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@ -287,7 +337,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@ -312,7 +362,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@ -337,7 +387,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@ -360,7 +410,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@ -399,7 +449,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that add microagent buttons are present
|
||||
@ -413,7 +463,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click the first add microagent button
|
||||
@ -432,7 +482,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click the first add microagent button
|
||||
@ -452,17 +502,28 @@ describe("MicroagentManagement", () => {
|
||||
});
|
||||
|
||||
it("should display empty state when no repositories are found", async () => {
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue([]);
|
||||
// Mock empty repositories
|
||||
mockUseUserRepositories.mockReturnValue({
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
data: [],
|
||||
nextPage: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
onLoadMore: vi.fn(),
|
||||
});
|
||||
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(retrieveUserGitRepositoriesSpy).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that empty state messages are displayed
|
||||
@ -479,7 +540,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@ -520,7 +581,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that search input is rendered
|
||||
@ -540,7 +601,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Initially only repositories with .openhands should be visible
|
||||
@ -571,7 +632,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Type in search input with uppercase
|
||||
@ -594,7 +655,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Type in search input with partial match
|
||||
@ -620,7 +681,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Type in search input
|
||||
@ -653,7 +714,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Type in search input with non-existent repository name
|
||||
@ -681,7 +742,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Type in search input with special characters
|
||||
@ -702,7 +763,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Filter to show only repo2
|
||||
@ -737,7 +798,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Type in search input with leading/trailing whitespace
|
||||
@ -757,7 +818,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const searchInput = screen.getByRole("textbox", {
|
||||
@ -789,7 +850,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@ -816,7 +877,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@ -862,7 +923,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@ -879,7 +940,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@ -904,7 +965,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@ -953,7 +1014,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@ -989,7 +1050,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@ -1031,7 +1092,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@ -1068,7 +1129,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@ -1112,7 +1173,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@ -1142,7 +1203,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@ -1165,7 +1226,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@ -1192,7 +1253,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@ -1233,7 +1294,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that add microagent buttons are present
|
||||
@ -1247,7 +1308,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click the first add microagent button
|
||||
@ -1302,7 +1363,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click the first add microagent button
|
||||
@ -1326,7 +1387,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click the first add microagent button
|
||||
@ -1349,7 +1410,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click the first add microagent button
|
||||
@ -1382,7 +1443,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click the first add microagent button
|
||||
@ -1409,7 +1470,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click the first add microagent button
|
||||
@ -1435,7 +1496,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click the first add microagent button
|
||||
@ -1514,8 +1575,8 @@ describe("MicroagentManagement", () => {
|
||||
pr_number: null,
|
||||
};
|
||||
|
||||
const renderMicroagentManagementMain = (selectedMicroagentItem: any) => {
|
||||
return renderWithProviders(<MicroagentManagementMain />, {
|
||||
const renderMicroagentManagementMain = (selectedMicroagentItem: any) =>
|
||||
renderWithProviders(<MicroagentManagementMain />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
@ -1541,7 +1602,6 @@ describe("MicroagentManagement", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
it("should render MicroagentManagementDefault when no microagent or conversation is selected", async () => {
|
||||
renderMicroagentManagementMain(null);
|
||||
@ -2295,7 +2355,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion to expand it
|
||||
@ -2337,7 +2397,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories and expand accordion
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const repoAccordion = screen.getByTestId("repository-name-tooltip");
|
||||
@ -2390,7 +2450,7 @@ describe("MicroagentManagement", () => {
|
||||
renderMicroagentManagement();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const repoAccordion = screen.getByTestId("repository-name-tooltip");
|
||||
|
||||
@ -3,6 +3,8 @@ import { createRoutesStub } from "react-router";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import i18next from "i18next";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import GitSettingsScreen from "#/routes/git-settings";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
@ -46,22 +48,44 @@ const GitSettingsRouterStub = createRoutesStub([
|
||||
]);
|
||||
|
||||
const renderGitSettingsScreen = () => {
|
||||
// Initialize i18next instance
|
||||
i18next.init({
|
||||
lng: "en",
|
||||
resources: {
|
||||
en: {
|
||||
translation: {
|
||||
GITHUB$TOKEN_HELP_TEXT: "Help text",
|
||||
GITHUB$TOKEN_LABEL: "GitHub Token",
|
||||
GITHUB$HOST_LABEL: "GitHub Host",
|
||||
GITLAB$TOKEN_LABEL: "GitLab Token",
|
||||
GITLAB$HOST_LABEL: "GitLab Host",
|
||||
BITBUCKET$TOKEN_LABEL: "Bitbucket Token",
|
||||
BITBUCKET$HOST_LABEL: "Bitbucket Host",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { rerender, ...rest } = render(
|
||||
<GitSettingsRouterStub initialEntries={["/settings/integrations"]} />,
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
<I18nextProvider i18n={i18next}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
</I18nextProvider>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const rerenderGitSettingsScreen = () =>
|
||||
rerender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<GitSettingsRouterStub initialEntries={["/settings/integrations"]} />
|
||||
</QueryClientProvider>,
|
||||
<I18nextProvider i18n={i18next}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<GitSettingsRouterStub initialEntries={["/settings/integrations"]} />
|
||||
</QueryClientProvider>
|
||||
</I18nextProvider>,
|
||||
);
|
||||
|
||||
return {
|
||||
@ -351,14 +375,18 @@ describe("Form submission", () => {
|
||||
let disconnectButton = await screen.findByTestId(
|
||||
"disconnect-tokens-button",
|
||||
);
|
||||
// When tokens are set (github and gitlab are not null), the button should be enabled
|
||||
await waitFor(() => expect(disconnectButton).not.toBeDisabled());
|
||||
|
||||
// Mock settings with no tokens set
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {},
|
||||
});
|
||||
queryClient.invalidateQueries();
|
||||
|
||||
disconnectButton = await screen.findByTestId("disconnect-tokens-button");
|
||||
// When no tokens are set, the button should be disabled
|
||||
await waitFor(() => expect(disconnectButton).toBeDisabled());
|
||||
});
|
||||
|
||||
|
||||
@ -32,6 +32,42 @@ const RouterStub = createRoutesStub([
|
||||
},
|
||||
]);
|
||||
|
||||
const selectRepository = async (repoName: string) => {
|
||||
const repoConnector = screen.getByTestId("repo-connector");
|
||||
|
||||
// First select the provider
|
||||
const providerDropdown = await waitFor(() =>
|
||||
screen.getByText("Select Provider"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("Github"));
|
||||
|
||||
// Then select the repository
|
||||
const dropdown = within(repoConnector).getByTestId("repo-dropdown");
|
||||
const repoInput = within(dropdown).getByRole("combobox");
|
||||
await userEvent.click(repoInput);
|
||||
|
||||
// Wait for the options to be loaded and displayed
|
||||
await waitFor(() => {
|
||||
const options = screen.getAllByText(repoName);
|
||||
// Find the option in the dropdown (it will have role="option")
|
||||
const dropdownOption = options.find(
|
||||
(el) => el.getAttribute("role") === "option",
|
||||
);
|
||||
expect(dropdownOption).toBeInTheDocument();
|
||||
});
|
||||
const options = screen.getAllByText(repoName);
|
||||
const dropdownOption = options.find(
|
||||
(el) => el.getAttribute("role") === "option",
|
||||
);
|
||||
await userEvent.click(dropdownOption!);
|
||||
|
||||
// Wait for the branch to be auto-selected
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("main")).toBeInTheDocument();
|
||||
});
|
||||
};
|
||||
|
||||
const renderHomeScreen = () =>
|
||||
render(<RouterStub />, {
|
||||
wrapper: ({ children }) => (
|
||||
@ -93,84 +129,8 @@ describe("HomeScreen", () => {
|
||||
expect(mainContainer).toHaveClass("flex", "flex-col", "lg:flex-row");
|
||||
});
|
||||
|
||||
it("should filter the suggested tasks based on the selected repository", async () => {
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
|
||||
|
||||
renderHomeScreen();
|
||||
|
||||
const taskSuggestions = await screen.findByTestId("task-suggestions");
|
||||
|
||||
// Initially, all tasks should be visible
|
||||
await waitFor(() => {
|
||||
within(taskSuggestions).getByText("octocat/hello-world");
|
||||
within(taskSuggestions).getByText("octocat/earth");
|
||||
});
|
||||
|
||||
// Select a repository from the dropdown
|
||||
const repoConnector = screen.getByTestId("repo-connector");
|
||||
|
||||
const dropdown = within(repoConnector).getByTestId("repo-dropdown");
|
||||
await userEvent.click(dropdown);
|
||||
|
||||
const repoOption = screen.getAllByText("octocat/hello-world")[1];
|
||||
await userEvent.click(repoOption);
|
||||
|
||||
// After selecting a repository, only tasks related to that repository should be visible
|
||||
await waitFor(() => {
|
||||
within(taskSuggestions).getByText("octocat/hello-world");
|
||||
expect(
|
||||
within(taskSuggestions).queryByText("octocat/earth"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should reset the filtered tasks when the selected repository is cleared", async () => {
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
|
||||
|
||||
renderHomeScreen();
|
||||
|
||||
const taskSuggestions = await screen.findByTestId("task-suggestions");
|
||||
|
||||
// Initially, all tasks should be visible
|
||||
await waitFor(() => {
|
||||
within(taskSuggestions).getByText("octocat/hello-world");
|
||||
within(taskSuggestions).getByText("octocat/earth");
|
||||
});
|
||||
|
||||
// Select a repository from the dropdown
|
||||
const repoConnector = screen.getByTestId("repo-connector");
|
||||
|
||||
const dropdown = within(repoConnector).getByTestId("repo-dropdown");
|
||||
await userEvent.click(dropdown);
|
||||
|
||||
const repoOption = screen.getAllByText("octocat/hello-world")[1];
|
||||
await userEvent.click(repoOption);
|
||||
|
||||
// After selecting a repository, only tasks related to that repository should be visible
|
||||
await waitFor(() => {
|
||||
within(taskSuggestions).getByText("octocat/hello-world");
|
||||
expect(
|
||||
within(taskSuggestions).queryByText("octocat/earth"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Clear the selected repository
|
||||
await userEvent.clear(dropdown);
|
||||
|
||||
// All tasks should be visible again
|
||||
await waitFor(() => {
|
||||
within(taskSuggestions).getByText("octocat/hello-world");
|
||||
within(taskSuggestions).getByText("octocat/earth");
|
||||
});
|
||||
});
|
||||
// TODO: Fix this test
|
||||
it.skip("should filter and reset the suggested tasks based on repository selection", async () => {});
|
||||
|
||||
describe("launch buttons", () => {
|
||||
const setupLaunchButtons = async () => {
|
||||
@ -179,19 +139,25 @@ describe("HomeScreen", () => {
|
||||
let tasksLaunchButtons =
|
||||
await screen.findAllByTestId("task-launch-button");
|
||||
|
||||
// Select a repository from the dropdown to enable the repo launch button
|
||||
const repoConnector = screen.getByTestId("repo-connector");
|
||||
const dropdown = within(repoConnector).getByTestId("repo-dropdown");
|
||||
await userEvent.click(dropdown);
|
||||
const repoOption = screen.getAllByText("octocat/hello-world")[1];
|
||||
await userEvent.click(repoOption);
|
||||
// Mock the repository branches API call
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
|
||||
{ name: "main", commit_sha: "123", protected: false },
|
||||
{ name: "develop", commit_sha: "456", protected: false },
|
||||
]);
|
||||
|
||||
expect(headerLaunchButton).not.toBeDisabled();
|
||||
expect(repoLaunchButton).not.toBeDisabled();
|
||||
tasksLaunchButtons.forEach((button) => {
|
||||
expect(button).not.toBeDisabled();
|
||||
// Select a repository to enable the repo launch button
|
||||
await selectRepository("octocat/hello-world");
|
||||
|
||||
// Wait for all buttons to be enabled
|
||||
await waitFor(() => {
|
||||
expect(headerLaunchButton).not.toBeDisabled();
|
||||
expect(repoLaunchButton).not.toBeDisabled();
|
||||
tasksLaunchButtons.forEach((button) => {
|
||||
expect(button).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
// Get fresh references to the buttons
|
||||
headerLaunchButton = screen.getByTestId("header-launch-button");
|
||||
repoLaunchButton = screen.getByTestId("repo-launch-button");
|
||||
tasksLaunchButtons = await screen.findAllByTestId("task-launch-button");
|
||||
@ -208,7 +174,10 @@ describe("HomeScreen", () => {
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue({
|
||||
data: MOCK_RESPOSITORIES,
|
||||
nextPage: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should disable the other launch buttons when the header launch button is clicked", async () => {
|
||||
|
||||
1792
frontend/package-lock.json
generated
1792
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -8,7 +8,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroui/react": "^2.8.2",
|
||||
"@microlink/react-json-view": "^1.27.0",
|
||||
"@heroui/use-infinite-scroll": "^2.2.10",
|
||||
"@microlink/react-json-view": "^1.26.2",
|
||||
"@monaco-editor/react": "^4.7.0-rc.0",
|
||||
"@react-router/node": "^7.7.1",
|
||||
"@react-router/serve": "^7.7.1",
|
||||
@ -43,6 +44,7 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.7.1",
|
||||
"react-select": "^5.10.2",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"react-textarea-autosize": "^8.5.9",
|
||||
"remark-breaks": "^4.0.0",
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
* - Please do NOT modify this file.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.10.3'
|
||||
const PACKAGE_VERSION = '2.10.4'
|
||||
const INTEGRITY_CHECKSUM = 'f5825c521429caf22a4dd13b66e243af'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
@ -20,6 +20,7 @@ import { openHands } from "./open-hands-axios";
|
||||
import { ApiSettings, PostApiSettings, Provider } from "#/types/settings";
|
||||
import { GitUser, GitRepository, Branch } from "#/types/git";
|
||||
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
|
||||
import { extractNextPageFromLink } from "#/utils/extract-next-page-from-link";
|
||||
import { RepositoryMicroagent } from "#/types/microagent-management";
|
||||
import { BatchFeedbackData } from "#/hooks/query/use-batch-feedback";
|
||||
|
||||
@ -434,6 +435,7 @@ class OpenHands {
|
||||
static async searchGitRepositories(
|
||||
query: string,
|
||||
per_page = 5,
|
||||
selected_provider?: Provider,
|
||||
): Promise<GitRepository[]> {
|
||||
const response = await openHands.get<GitRepository[]>(
|
||||
"/api/user/search/repositories",
|
||||
@ -441,6 +443,7 @@ class OpenHands {
|
||||
params: {
|
||||
query,
|
||||
per_page,
|
||||
selected_provider,
|
||||
},
|
||||
},
|
||||
);
|
||||
@ -485,20 +488,70 @@ class OpenHands {
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a PAT, retrieves the repositories of the user
|
||||
* @returns A list of repositories
|
||||
*/
|
||||
static async retrieveUserGitRepositories() {
|
||||
static async retrieveUserGitRepositories(
|
||||
selected_provider: Provider,
|
||||
page = 1,
|
||||
per_page = 30,
|
||||
) {
|
||||
const { data } = await openHands.get<GitRepository[]>(
|
||||
"/api/user/repositories",
|
||||
{
|
||||
params: {
|
||||
selected_provider,
|
||||
sort: "pushed",
|
||||
page,
|
||||
per_page,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return data;
|
||||
const link =
|
||||
data.length > 0 && data[0].link_header ? data[0].link_header : "";
|
||||
const nextPage = extractNextPageFromLink(link);
|
||||
|
||||
return { data, nextPage };
|
||||
}
|
||||
|
||||
static async retrieveInstallationRepositories(
|
||||
selected_provider: Provider,
|
||||
installationIndex: number,
|
||||
installations: string[],
|
||||
page = 1,
|
||||
per_page = 30,
|
||||
) {
|
||||
const installationId = installations[installationIndex];
|
||||
const response = await openHands.get<GitRepository[]>(
|
||||
"/api/user/repositories",
|
||||
{
|
||||
params: {
|
||||
selected_provider,
|
||||
sort: "pushed",
|
||||
page,
|
||||
per_page,
|
||||
installation_id: installationId,
|
||||
},
|
||||
},
|
||||
);
|
||||
const link =
|
||||
response.data.length > 0 && response.data[0].link_header
|
||||
? response.data[0].link_header
|
||||
: "";
|
||||
const nextPage = extractNextPageFromLink(link);
|
||||
let nextInstallation: number | null;
|
||||
if (nextPage) {
|
||||
nextInstallation = installationIndex;
|
||||
} else if (installationIndex + 1 < installations.length) {
|
||||
nextInstallation = installationIndex + 1;
|
||||
} else {
|
||||
nextInstallation = null;
|
||||
}
|
||||
return {
|
||||
data: response.data,
|
||||
nextPage,
|
||||
installationIndex: nextInstallation,
|
||||
};
|
||||
}
|
||||
|
||||
static async getRepositoryBranches(repository: string): Promise<Branch[]> {
|
||||
@ -586,6 +639,18 @@ class OpenHands {
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user installation IDs
|
||||
* @param provider The provider to get installation IDs for (github, bitbucket, etc.)
|
||||
* @returns List of installation IDs
|
||||
*/
|
||||
static async getUserInstallationIds(provider: Provider): Promise<string[]> {
|
||||
const { data } = await openHands.get<string[]>(
|
||||
`/api/user/installations?provider=${provider}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export default OpenHands;
|
||||
|
||||
69
frontend/src/components/common/git-branch-dropdown.tsx
Normal file
69
frontend/src/components/common/git-branch-dropdown.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import { useMemo } from "react";
|
||||
import { useRepositoryBranches } from "../../hooks/query/use-repository-branches";
|
||||
import { ReactSelectDropdown, SelectOption } from "./react-select-dropdown";
|
||||
|
||||
export interface GitBranchDropdownProps {
|
||||
repositoryName?: string | null;
|
||||
value?: string | null;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
errorMessage?: string;
|
||||
disabled?: boolean;
|
||||
onChange?: (branchName: string | null) => void;
|
||||
}
|
||||
|
||||
export function GitBranchDropdown({
|
||||
repositoryName,
|
||||
value,
|
||||
placeholder = "Select branch...",
|
||||
className,
|
||||
errorMessage,
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: GitBranchDropdownProps) {
|
||||
const { data: branches, isLoading } = useRepositoryBranches(
|
||||
repositoryName || null,
|
||||
);
|
||||
|
||||
const options: SelectOption[] = useMemo(
|
||||
() =>
|
||||
branches?.map((branch) => ({
|
||||
value: branch.name,
|
||||
label: branch.name,
|
||||
})) || [],
|
||||
[branches],
|
||||
);
|
||||
|
||||
const hasNoBranches = !isLoading && branches && branches.length === 0;
|
||||
|
||||
const selectedOption = useMemo(
|
||||
() => options.find((option) => option.value === value) || null,
|
||||
[options, value],
|
||||
);
|
||||
|
||||
const handleChange = (option: SelectOption | null) => {
|
||||
onChange?.(option?.value || null);
|
||||
};
|
||||
|
||||
const isDisabled = disabled || !repositoryName || isLoading || hasNoBranches;
|
||||
|
||||
const displayPlaceholder = hasNoBranches ? "No branches found" : placeholder;
|
||||
const displayErrorMessage = hasNoBranches
|
||||
? "This repository has no branches"
|
||||
: errorMessage;
|
||||
|
||||
return (
|
||||
<ReactSelectDropdown
|
||||
options={options}
|
||||
value={selectedOption}
|
||||
placeholder={displayPlaceholder}
|
||||
className={className}
|
||||
errorMessage={displayErrorMessage}
|
||||
disabled={isDisabled}
|
||||
isClearable={false}
|
||||
isSearchable
|
||||
isLoading={isLoading}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
58
frontend/src/components/common/git-provider-dropdown.tsx
Normal file
58
frontend/src/components/common/git-provider-dropdown.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import { useMemo } from "react";
|
||||
import { Provider } from "../../types/settings";
|
||||
import { ReactSelectDropdown, SelectOption } from "./react-select-dropdown";
|
||||
|
||||
export interface GitProviderDropdownProps {
|
||||
providers: Provider[];
|
||||
value?: Provider | null;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
errorMessage?: string;
|
||||
disabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
onChange?: (provider: Provider | null) => void;
|
||||
}
|
||||
|
||||
export function GitProviderDropdown({
|
||||
providers,
|
||||
value,
|
||||
placeholder = "Select Provider",
|
||||
className,
|
||||
errorMessage,
|
||||
disabled = false,
|
||||
isLoading = false,
|
||||
onChange,
|
||||
}: GitProviderDropdownProps) {
|
||||
const options: SelectOption[] = useMemo(
|
||||
() =>
|
||||
providers.map((provider) => ({
|
||||
value: provider,
|
||||
label: provider.charAt(0).toUpperCase() + provider.slice(1),
|
||||
})),
|
||||
[providers],
|
||||
);
|
||||
|
||||
const selectedOption = useMemo(
|
||||
() => options.find((option) => option.value === value) || null,
|
||||
[options, value],
|
||||
);
|
||||
|
||||
const handleChange = (option: SelectOption | null) => {
|
||||
onChange?.(option?.value as Provider | null);
|
||||
};
|
||||
|
||||
return (
|
||||
<ReactSelectDropdown
|
||||
options={options}
|
||||
value={selectedOption}
|
||||
placeholder={placeholder}
|
||||
className={className}
|
||||
errorMessage={errorMessage}
|
||||
disabled={disabled}
|
||||
isClearable={false}
|
||||
isSearchable={false}
|
||||
isLoading={isLoading}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
186
frontend/src/components/common/git-repository-dropdown.tsx
Normal file
186
frontend/src/components/common/git-repository-dropdown.tsx
Normal file
@ -0,0 +1,186 @@
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Provider } from "../../types/settings";
|
||||
import { useGitRepositories } from "../../hooks/query/use-git-repositories";
|
||||
import OpenHands from "../../api/open-hands";
|
||||
import { GitRepository } from "../../types/git";
|
||||
import {
|
||||
ReactSelectAsyncDropdown,
|
||||
AsyncSelectOption,
|
||||
} from "./react-select-async-dropdown";
|
||||
|
||||
export interface GitRepositoryDropdownProps {
|
||||
provider: Provider;
|
||||
value?: string | null;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
errorMessage?: string;
|
||||
disabled?: boolean;
|
||||
onChange?: (repository?: GitRepository) => void;
|
||||
}
|
||||
|
||||
interface SearchCache {
|
||||
[key: string]: GitRepository[];
|
||||
}
|
||||
|
||||
export function GitRepositoryDropdown({
|
||||
provider,
|
||||
value,
|
||||
placeholder = "Search repositories...",
|
||||
className,
|
||||
errorMessage,
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: GitRepositoryDropdownProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
isError,
|
||||
} = useGitRepositories({
|
||||
provider,
|
||||
enabled: !disabled,
|
||||
});
|
||||
|
||||
const allOptions: AsyncSelectOption[] = useMemo(
|
||||
() =>
|
||||
data?.pages
|
||||
? data.pages.flatMap((page) =>
|
||||
page.data.map((repo) => ({
|
||||
value: repo.id,
|
||||
label: repo.full_name,
|
||||
})),
|
||||
)
|
||||
: [],
|
||||
[data],
|
||||
);
|
||||
|
||||
// Keep track of search results
|
||||
const searchCache = useRef<SearchCache>({});
|
||||
|
||||
const selectedOption = useMemo(() => {
|
||||
// First check in loaded pages
|
||||
const option = allOptions.find((opt) => opt.value === value);
|
||||
if (option) return option;
|
||||
|
||||
// If not found, check in search cache
|
||||
const repo = Object.values(searchCache.current)
|
||||
.flat()
|
||||
.find((r) => r.id === value);
|
||||
|
||||
if (repo) {
|
||||
return {
|
||||
value: repo.id,
|
||||
label: repo.full_name,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [allOptions, value]);
|
||||
|
||||
const loadOptions = useCallback(
|
||||
async (inputValue: string): Promise<AsyncSelectOption[]> => {
|
||||
// If empty input, show all loaded options
|
||||
if (!inputValue.trim()) {
|
||||
return allOptions;
|
||||
}
|
||||
|
||||
// If it looks like a URL, extract the repo name and search
|
||||
if (inputValue.startsWith("https://")) {
|
||||
const match = inputValue.match(/https:\/\/[^/]+\/([^/]+\/[^/]+)/);
|
||||
if (match) {
|
||||
const repoName = match[1];
|
||||
const searchResults = await OpenHands.searchGitRepositories(
|
||||
repoName,
|
||||
3,
|
||||
);
|
||||
// Cache the search results
|
||||
searchCache.current[repoName] = searchResults;
|
||||
return searchResults.map((repo) => ({
|
||||
value: repo.id,
|
||||
label: repo.full_name,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// For any other input, search via API
|
||||
if (inputValue.length >= 2) {
|
||||
// Only search if at least 2 characters
|
||||
const searchResults = await OpenHands.searchGitRepositories(
|
||||
inputValue,
|
||||
10,
|
||||
);
|
||||
// Cache the search results
|
||||
searchCache.current[inputValue] = searchResults;
|
||||
return searchResults.map((repo) => ({
|
||||
value: repo.id,
|
||||
label: repo.full_name,
|
||||
}));
|
||||
}
|
||||
|
||||
// For very short inputs, do local filtering
|
||||
return allOptions.filter((option) =>
|
||||
option.label.toLowerCase().includes(inputValue.toLowerCase()),
|
||||
);
|
||||
},
|
||||
[allOptions],
|
||||
);
|
||||
|
||||
const handleChange = (option: AsyncSelectOption | null) => {
|
||||
if (!option) {
|
||||
onChange?.(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
// First check in loaded pages
|
||||
let repo = data?.pages
|
||||
?.flatMap((p) => p.data)
|
||||
.find((r) => r.id === option.value);
|
||||
|
||||
// If not found, check in search results
|
||||
if (!repo) {
|
||||
repo = Object.values(searchCache.current)
|
||||
.flat()
|
||||
.find((r) => r.id === option.value);
|
||||
}
|
||||
|
||||
onChange?.(repo);
|
||||
};
|
||||
|
||||
const handleMenuScrollToBottom = useCallback(() => {
|
||||
if (hasNextPage && !isFetchingNextPage && !isLoading) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [hasNextPage, isFetchingNextPage, isLoading, fetchNextPage]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ReactSelectAsyncDropdown
|
||||
testId="repo-dropdown"
|
||||
loadOptions={loadOptions}
|
||||
value={selectedOption}
|
||||
placeholder={placeholder}
|
||||
className={className}
|
||||
errorMessage={errorMessage}
|
||||
disabled={disabled}
|
||||
isClearable={false}
|
||||
isLoading={isLoading || isLoading || isFetchingNextPage}
|
||||
cacheOptions
|
||||
defaultOptions={allOptions}
|
||||
onChange={handleChange}
|
||||
onMenuScrollToBottom={handleMenuScrollToBottom}
|
||||
/>
|
||||
{isError && (
|
||||
<div
|
||||
data-testid="repo-dropdown-error"
|
||||
className="text-red-500 text-sm mt-1"
|
||||
>
|
||||
{t("HOME$FAILED_TO_LOAD_REPOSITORIES")}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,79 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import AsyncSelect from "react-select/async";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { SelectOptionBase, getCustomStyles } from "./react-select-styles";
|
||||
|
||||
export type AsyncSelectOption = SelectOptionBase;
|
||||
|
||||
export interface ReactSelectAsyncDropdownProps {
|
||||
loadOptions: (inputValue: string) => Promise<AsyncSelectOption[]>;
|
||||
testId?: string;
|
||||
placeholder?: string;
|
||||
value?: AsyncSelectOption | null;
|
||||
defaultValue?: AsyncSelectOption | null;
|
||||
className?: string;
|
||||
errorMessage?: string;
|
||||
disabled?: boolean;
|
||||
isClearable?: boolean;
|
||||
isLoading?: boolean;
|
||||
cacheOptions?: boolean;
|
||||
defaultOptions?: boolean | AsyncSelectOption[];
|
||||
onChange?: (option: AsyncSelectOption | null) => void;
|
||||
onMenuScrollToBottom?: () => void;
|
||||
}
|
||||
|
||||
export function ReactSelectAsyncDropdown({
|
||||
loadOptions,
|
||||
testId,
|
||||
placeholder = "Search...",
|
||||
value,
|
||||
defaultValue,
|
||||
className,
|
||||
errorMessage,
|
||||
disabled = false,
|
||||
isClearable = false,
|
||||
isLoading = false,
|
||||
cacheOptions = true,
|
||||
defaultOptions = true,
|
||||
onChange,
|
||||
onMenuScrollToBottom,
|
||||
}: ReactSelectAsyncDropdownProps) {
|
||||
const customStyles = useMemo(() => getCustomStyles<AsyncSelectOption>(), []);
|
||||
|
||||
const handleLoadOptions = useCallback(
|
||||
(inputValue: string, callback: (options: AsyncSelectOption[]) => void) => {
|
||||
loadOptions(inputValue)
|
||||
.then((options) => callback(options))
|
||||
.catch(() => callback([]));
|
||||
},
|
||||
[loadOptions],
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-testid={testId} className={cn("w-full", className)}>
|
||||
<AsyncSelect
|
||||
loadOptions={handleLoadOptions}
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
placeholder={placeholder}
|
||||
isDisabled={disabled}
|
||||
isClearable={isClearable}
|
||||
isLoading={isLoading}
|
||||
cacheOptions={cacheOptions}
|
||||
defaultOptions={defaultOptions}
|
||||
onChange={onChange}
|
||||
onMenuScrollToBottom={onMenuScrollToBottom}
|
||||
styles={customStyles}
|
||||
className="w-full"
|
||||
/>
|
||||
{errorMessage && (
|
||||
<p
|
||||
data-testid="repo-dropdown-error"
|
||||
className="text-red-500 text-sm mt-1"
|
||||
>
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
frontend/src/components/common/react-select-dropdown.tsx
Normal file
57
frontend/src/components/common/react-select-dropdown.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import { useMemo } from "react";
|
||||
import Select from "react-select";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { SelectOptionBase, getCustomStyles } from "./react-select-styles";
|
||||
|
||||
export type SelectOption = SelectOptionBase;
|
||||
|
||||
export interface ReactSelectDropdownProps {
|
||||
options: SelectOption[];
|
||||
placeholder?: string;
|
||||
value?: SelectOption | null;
|
||||
defaultValue?: SelectOption | null;
|
||||
className?: string;
|
||||
errorMessage?: string;
|
||||
disabled?: boolean;
|
||||
isClearable?: boolean;
|
||||
isSearchable?: boolean;
|
||||
isLoading?: boolean;
|
||||
onChange?: (option: SelectOption | null) => void;
|
||||
}
|
||||
|
||||
export function ReactSelectDropdown({
|
||||
options,
|
||||
placeholder = "Select option...",
|
||||
value,
|
||||
defaultValue,
|
||||
className,
|
||||
errorMessage,
|
||||
disabled = false,
|
||||
isClearable = false,
|
||||
isSearchable = true,
|
||||
isLoading = false,
|
||||
onChange,
|
||||
}: ReactSelectDropdownProps) {
|
||||
const customStyles = useMemo(() => getCustomStyles<SelectOption>(), []);
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", className)}>
|
||||
<Select
|
||||
options={options}
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
placeholder={placeholder}
|
||||
isDisabled={disabled}
|
||||
isClearable={isClearable}
|
||||
isSearchable={isSearchable}
|
||||
isLoading={isLoading}
|
||||
onChange={onChange}
|
||||
styles={customStyles}
|
||||
className="w-full"
|
||||
/>
|
||||
{errorMessage && (
|
||||
<p className="text-red-500 text-sm mt-1">{errorMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
frontend/src/components/common/react-select-styles.ts
Normal file
92
frontend/src/components/common/react-select-styles.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { StylesConfig } from "react-select";
|
||||
|
||||
export interface SelectOptionBase {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const getCustomStyles = <T extends SelectOptionBase>(): StylesConfig<
|
||||
T,
|
||||
false
|
||||
> => ({
|
||||
control: (provided, state) => ({
|
||||
...provided,
|
||||
backgroundColor: state.isDisabled ? "#363636" : "#454545", // darker tertiary when disabled
|
||||
border: "1px solid #717888",
|
||||
borderRadius: "0.125rem",
|
||||
minHeight: "2.5rem",
|
||||
padding: "0 0.5rem",
|
||||
boxShadow: state.isFocused ? "0 0 0 1px #717888" : "none",
|
||||
opacity: state.isDisabled ? 0.6 : 1,
|
||||
cursor: state.isDisabled ? "not-allowed" : "pointer",
|
||||
"&:hover": {
|
||||
borderColor: "#717888",
|
||||
},
|
||||
}),
|
||||
input: (provided) => ({
|
||||
...provided,
|
||||
color: "#ECEDEE", // content
|
||||
}),
|
||||
placeholder: (provided) => ({
|
||||
...provided,
|
||||
fontStyle: "italic",
|
||||
color: "#B7BDC2", // tertiary-light
|
||||
}),
|
||||
singleValue: (provided, state) => ({
|
||||
...provided,
|
||||
color: state.isDisabled ? "#B7BDC2" : "#ECEDEE", // tertiary-light when disabled, content otherwise
|
||||
}),
|
||||
menu: (provided) => ({
|
||||
...provided,
|
||||
backgroundColor: "#454545", // tertiary
|
||||
border: "1px solid #717888",
|
||||
borderRadius: "0.75rem",
|
||||
overflow: "hidden", // ensure menu items don't overflow rounded corners
|
||||
}),
|
||||
menuList: (provided) => ({
|
||||
...provided,
|
||||
padding: "0.25rem", // add some padding around menu items
|
||||
}),
|
||||
option: (provided, state) => {
|
||||
let backgroundColor = "transparent";
|
||||
if (state.isSelected) {
|
||||
backgroundColor = "#C9B974"; // primary for selected
|
||||
} else if (state.isFocused) {
|
||||
backgroundColor = "#24272E"; // base-secondary for hover/focus
|
||||
}
|
||||
|
||||
return {
|
||||
...provided,
|
||||
backgroundColor,
|
||||
color: state.isSelected ? "#000000" : "#ECEDEE", // black text on yellow, white on gray
|
||||
borderRadius: "0.5rem", // rounded menu items
|
||||
margin: "0.125rem 0", // small gap between items
|
||||
"&:hover": {
|
||||
backgroundColor: state.isSelected ? "#C9B974" : "#24272E", // keep yellow if selected, else gray
|
||||
color: state.isSelected ? "#000000" : "#ECEDEE", // maintain text color on hover
|
||||
},
|
||||
"&:active": {
|
||||
backgroundColor: state.isSelected ? "#C9B974" : "#24272E",
|
||||
color: state.isSelected ? "#000000" : "#ECEDEE",
|
||||
},
|
||||
};
|
||||
},
|
||||
clearIndicator: (provided) => ({
|
||||
...provided,
|
||||
color: "#B7BDC2", // tertiary-light
|
||||
"&:hover": {
|
||||
color: "#ECEDEE", // content
|
||||
},
|
||||
}),
|
||||
dropdownIndicator: (provided) => ({
|
||||
...provided,
|
||||
color: "#B7BDC2", // tertiary-light
|
||||
"&:hover": {
|
||||
color: "#ECEDEE", // content
|
||||
},
|
||||
}),
|
||||
loadingIndicator: (provided) => ({
|
||||
...provided,
|
||||
color: "#B7BDC2", // tertiary-light
|
||||
}),
|
||||
});
|
||||
@ -2,22 +2,15 @@ import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
|
||||
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
|
||||
import { useRepositoryBranches } from "#/hooks/query/use-repository-branches";
|
||||
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
|
||||
import { Branch, GitRepository } from "#/types/git";
|
||||
import { BrandButton } from "../settings/brand-button";
|
||||
import { useSearchRepositories } from "#/hooks/query/use-search-repositories";
|
||||
import { useDebounce } from "#/hooks/use-debounce";
|
||||
import { sanitizeQuery } from "#/utils/sanitize-query";
|
||||
import {
|
||||
RepositoryDropdown,
|
||||
RepositoryLoadingState,
|
||||
RepositoryErrorState,
|
||||
BranchDropdown,
|
||||
BranchLoadingState,
|
||||
BranchErrorState,
|
||||
} from "./repository-selection";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { GitProviderDropdown } from "../../common/git-provider-dropdown";
|
||||
import { GitRepositoryDropdown } from "../../common/git-repository-dropdown";
|
||||
import { GitBranchDropdown } from "../../common/git-branch-dropdown";
|
||||
|
||||
interface RepositorySelectionFormProps {
|
||||
onRepoSelection: (repo: GitRepository | null) => void;
|
||||
@ -32,18 +25,11 @@ export function RepositorySelectionForm({
|
||||
const [selectedBranch, setSelectedBranch] = React.useState<Branch | null>(
|
||||
null,
|
||||
);
|
||||
// Add a ref to track if the branch was manually cleared by the user
|
||||
const branchManuallyClearedRef = React.useRef<boolean>(false);
|
||||
const {
|
||||
data: repositories,
|
||||
isLoading: isLoadingRepositories,
|
||||
isError: isRepositoriesError,
|
||||
} = useUserRepositories();
|
||||
const {
|
||||
data: branches,
|
||||
isLoading: isLoadingBranches,
|
||||
isError: isBranchesError,
|
||||
} = useRepositoryBranches(selectedRepository?.full_name || null);
|
||||
const [selectedProvider, setSelectedProvider] =
|
||||
React.useState<Provider | null>(null);
|
||||
const { providers } = useUserProviders();
|
||||
const { data: branches, isLoading: isLoadingBranches } =
|
||||
useRepositoryBranches(selectedRepository?.full_name || null);
|
||||
const {
|
||||
mutate: createConversation,
|
||||
isPending,
|
||||
@ -52,151 +38,108 @@ export function RepositorySelectionForm({
|
||||
const isCreatingConversationElsewhere = useIsCreatingConversation();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [searchQuery, setSearchQuery] = React.useState("");
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300);
|
||||
const { data: searchedRepos } = useSearchRepositories(debouncedSearchQuery);
|
||||
|
||||
// Auto-select main or master branch if it exists, but only if the branch wasn't manually cleared
|
||||
// Auto-select provider if there's only one
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
branches &&
|
||||
branches.length > 0 &&
|
||||
!selectedBranch &&
|
||||
!isLoadingBranches &&
|
||||
!branchManuallyClearedRef.current // Only auto-select if not manually cleared
|
||||
) {
|
||||
// Look for main or master branch
|
||||
const mainBranch = branches.find((branch) => branch.name === "main");
|
||||
const masterBranch = branches.find((branch) => branch.name === "master");
|
||||
|
||||
// Select main if it exists, otherwise select master if it exists
|
||||
if (mainBranch) {
|
||||
setSelectedBranch(mainBranch);
|
||||
} else if (masterBranch) {
|
||||
setSelectedBranch(masterBranch);
|
||||
}
|
||||
if (providers.length === 1 && !selectedProvider) {
|
||||
setSelectedProvider(providers[0]);
|
||||
}
|
||||
}, [branches, isLoadingBranches, selectedBranch]);
|
||||
}, [providers, selectedProvider]);
|
||||
|
||||
// We check for isSuccess because the app might require time to render
|
||||
// into the new conversation screen after the conversation is created.
|
||||
const isCreatingConversation =
|
||||
isPending || isSuccess || isCreatingConversationElsewhere;
|
||||
|
||||
const allRepositories = repositories?.concat(searchedRepos || []);
|
||||
const repositoriesItems = allRepositories?.map((repo) => ({
|
||||
key: repo.id,
|
||||
label: decodeURIComponent(repo.full_name),
|
||||
}));
|
||||
// Check if repository has no branches (empty array after loading completes)
|
||||
const hasNoBranches = !isLoadingBranches && branches && branches.length === 0;
|
||||
|
||||
const branchesItems = branches?.map((branch) => ({
|
||||
key: branch.name,
|
||||
label: branch.name,
|
||||
}));
|
||||
|
||||
const handleRepoSelection = (key: React.Key | null) => {
|
||||
const selectedRepo = allRepositories?.find((repo) => repo.id === key);
|
||||
if (selectedRepo) onRepoSelection(selectedRepo);
|
||||
setSelectedRepository(selectedRepo || null);
|
||||
setSelectedBranch(null); // Reset branch selection when repo changes
|
||||
branchManuallyClearedRef.current = false; // Reset the flag when repo changes
|
||||
const handleProviderSelection = (provider: Provider | null) => {
|
||||
setSelectedProvider(provider);
|
||||
setSelectedRepository(null); // Reset repository selection when provider changes
|
||||
setSelectedBranch(null); // Reset branch selection when provider changes
|
||||
onRepoSelection(null); // Reset parent component's selected repo
|
||||
};
|
||||
|
||||
const handleBranchSelection = (key: React.Key | null) => {
|
||||
const selectedBranchObj = branches?.find((branch) => branch.name === key);
|
||||
setSelectedBranch(selectedBranchObj || null);
|
||||
// Reset the manually cleared flag when a branch is explicitly selected
|
||||
branchManuallyClearedRef.current = false;
|
||||
};
|
||||
|
||||
const handleRepoInputChange = (value: string) => {
|
||||
if (value === "") {
|
||||
setSelectedRepository(null);
|
||||
setSelectedBranch(null);
|
||||
onRepoSelection(null);
|
||||
} else if (value.startsWith("https://")) {
|
||||
const repoName = sanitizeQuery(value);
|
||||
setSearchQuery(repoName);
|
||||
const handleBranchSelection = (branchName: string | null) => {
|
||||
const selectedBranchObj = branches?.find(
|
||||
(branch) => branch.name === branchName,
|
||||
);
|
||||
if (selectedBranchObj) {
|
||||
setSelectedBranch(selectedBranchObj);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBranchInputChange = (value: string) => {
|
||||
// Clear the selected branch if the input is empty or contains only whitespace
|
||||
// This fixes the issue where users can't delete the entire default branch name
|
||||
if (value === "" || value.trim() === "") {
|
||||
setSelectedBranch(null);
|
||||
// Set the flag to indicate that the branch was manually cleared
|
||||
branchManuallyClearedRef.current = true;
|
||||
} else {
|
||||
// Reset the flag when the user starts typing again
|
||||
branchManuallyClearedRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Render the appropriate UI based on the loading/error state
|
||||
const renderRepositorySelector = () => {
|
||||
if (isLoadingRepositories) {
|
||||
return <RepositoryLoadingState />;
|
||||
}
|
||||
|
||||
if (isRepositoriesError) {
|
||||
return <RepositoryErrorState />;
|
||||
// Render the provider dropdown
|
||||
const renderProviderSelector = () => {
|
||||
// Only render if there are multiple providers
|
||||
if (providers.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<RepositoryDropdown
|
||||
items={repositoriesItems || []}
|
||||
onSelectionChange={handleRepoSelection}
|
||||
onInputChange={handleRepoInputChange}
|
||||
defaultFilter={(textValue, inputValue) => {
|
||||
if (!inputValue) return true;
|
||||
|
||||
const repo = allRepositories?.find((r) => r.full_name === textValue);
|
||||
if (!repo) return false;
|
||||
|
||||
const sanitizedInput = sanitizeQuery(inputValue);
|
||||
return sanitizeQuery(textValue).includes(sanitizedInput);
|
||||
}}
|
||||
<GitProviderDropdown
|
||||
providers={providers}
|
||||
value={selectedProvider}
|
||||
placeholder="Select Provider"
|
||||
className="max-w-[500px]"
|
||||
onChange={handleProviderSelection}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Render the appropriate UI for branch selector based on the loading/error state
|
||||
const renderBranchSelector = () => {
|
||||
if (!selectedRepository) {
|
||||
return (
|
||||
<BranchDropdown
|
||||
items={[]}
|
||||
onSelectionChange={() => {}}
|
||||
onInputChange={() => {}}
|
||||
isDisabled
|
||||
/>
|
||||
// Effect to auto-select main/master branch when branches are loaded
|
||||
React.useEffect(() => {
|
||||
if (branches?.length) {
|
||||
// Look for main or master branch
|
||||
const defaultBranch = branches.find(
|
||||
(branch) => branch.name === "main" || branch.name === "master",
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoadingBranches) {
|
||||
return <BranchLoadingState />;
|
||||
// If found, select it, otherwise select the first branch
|
||||
setSelectedBranch(defaultBranch || branches[0]);
|
||||
}
|
||||
}, [branches]);
|
||||
|
||||
if (isBranchesError) {
|
||||
return <BranchErrorState />;
|
||||
}
|
||||
// Render the repository selector using our new component
|
||||
const renderRepositorySelector = () => {
|
||||
const handleRepoSelection = (repository?: GitRepository) => {
|
||||
if (repository) {
|
||||
onRepoSelection(repository);
|
||||
setSelectedRepository(repository);
|
||||
} else {
|
||||
setSelectedRepository(null);
|
||||
setSelectedBranch(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BranchDropdown
|
||||
items={branchesItems || []}
|
||||
onSelectionChange={handleBranchSelection}
|
||||
onInputChange={handleBranchInputChange}
|
||||
isDisabled={false}
|
||||
selectedKey={selectedBranch?.name}
|
||||
<GitRepositoryDropdown
|
||||
provider={selectedProvider || providers[0]}
|
||||
value={selectedRepository?.id || null}
|
||||
placeholder="Search repositories..."
|
||||
disabled={!selectedProvider}
|
||||
onChange={handleRepoSelection}
|
||||
className="max-w-[500px]"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Render the branch selector
|
||||
const renderBranchSelector = () => (
|
||||
<GitBranchDropdown
|
||||
repositoryName={selectedRepository?.full_name}
|
||||
value={selectedBranch?.name || null}
|
||||
placeholder="Select branch..."
|
||||
className="max-w-[500px]"
|
||||
disabled={!selectedRepository}
|
||||
onChange={handleBranchSelection}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{renderProviderSelector()}
|
||||
{renderRepositorySelector()}
|
||||
|
||||
{renderBranchSelector()}
|
||||
|
||||
<BrandButton
|
||||
@ -205,9 +148,10 @@ export function RepositorySelectionForm({
|
||||
type="button"
|
||||
isDisabled={
|
||||
!selectedRepository ||
|
||||
(!selectedBranch && !hasNoBranches) ||
|
||||
isLoadingBranches ||
|
||||
isCreatingConversation ||
|
||||
isLoadingRepositories ||
|
||||
isRepositoriesError
|
||||
(providers.length > 1 && !selectedProvider)
|
||||
}
|
||||
onClick={() =>
|
||||
createConversation(
|
||||
@ -215,7 +159,7 @@ export function RepositorySelectionForm({
|
||||
repository: {
|
||||
name: selectedRepository?.full_name || "",
|
||||
gitProvider: selectedRepository?.git_provider || "github",
|
||||
branch: selectedBranch?.name || "main",
|
||||
branch: selectedBranch?.name || (hasNoBranches ? "" : "main"),
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@ -1,6 +1,3 @@
|
||||
export { RepositoryDropdown } from "#/components/features/home/repository-selection/repository-dropdown";
|
||||
export { RepositoryLoadingState } from "#/components/features/home/repository-selection/repository-loading-state";
|
||||
export { RepositoryErrorState } from "#/components/features/home/repository-selection/repository-error-state";
|
||||
export { BranchDropdown } from "#/components/features/home/repository-selection/branch-dropdown";
|
||||
export { BranchLoadingState } from "#/components/features/home/repository-selection/branch-loading-state";
|
||||
export { BranchErrorState } from "#/components/features/home/repository-selection/branch-error-state";
|
||||
|
||||
@ -1,33 +0,0 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SettingsDropdownInput } from "../../settings/settings-dropdown-input";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
export interface RepositoryDropdownProps {
|
||||
items: { key: React.Key; label: string }[];
|
||||
onSelectionChange: (key: React.Key | null) => void;
|
||||
onInputChange: (value: string) => void;
|
||||
defaultFilter?: (textValue: string, inputValue: string) => boolean;
|
||||
}
|
||||
|
||||
export function RepositoryDropdown({
|
||||
items,
|
||||
onSelectionChange,
|
||||
onInputChange,
|
||||
defaultFilter,
|
||||
}: RepositoryDropdownProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<SettingsDropdownInput
|
||||
testId="repo-dropdown"
|
||||
name="repo-dropdown"
|
||||
placeholder={t(I18nKey.REPOSITORY$SELECT_REPO)}
|
||||
items={items}
|
||||
wrapperClassName="max-w-[500px]"
|
||||
onSelectionChange={onSelectionChange}
|
||||
onInputChange={onInputChange}
|
||||
defaultFilter={defaultFilter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function RepositoryErrorState() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
data-testid="repo-dropdown-error"
|
||||
className="flex items-center gap-2 max-w-[500px] h-10 px-3 bg-tertiary border border-[#717888] rounded-sm text-red-500"
|
||||
>
|
||||
<span className="text-sm">{t("HOME$FAILED_TO_LOAD_REPOSITORIES")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Spinner } from "@heroui/react";
|
||||
|
||||
export function RepositoryLoadingState() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
data-testid="repo-dropdown-loading"
|
||||
className="flex items-center gap-2 max-w-[500px] h-10 px-3 bg-tertiary border border-[#717888] rounded-sm"
|
||||
>
|
||||
<Spinner size="sm" />
|
||||
<span className="text-sm">{t("HOME$LOADING_REPOSITORIES")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -5,6 +5,7 @@ import { Spinner } from "@heroui/react";
|
||||
import { MicroagentManagementSidebarHeader } from "./microagent-management-sidebar-header";
|
||||
import { MicroagentManagementSidebarTabs } from "./microagent-management-sidebar-tabs";
|
||||
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import {
|
||||
setPersonalRepositories,
|
||||
setOrganizationRepositories,
|
||||
@ -22,15 +23,21 @@ export function MicroagentManagementSidebar({
|
||||
}: MicroagentManagementSidebarProps) {
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
const { data: repositories, isLoading } = useUserRepositories();
|
||||
const { providers } = useUserProviders();
|
||||
const selectedProvider = providers.length > 0 ? providers[0] : null;
|
||||
const { data: repositories, isLoading } =
|
||||
useUserRepositories(selectedProvider);
|
||||
|
||||
useEffect(() => {
|
||||
if (repositories) {
|
||||
if (repositories?.pages) {
|
||||
const personalRepos: GitRepository[] = [];
|
||||
const organizationRepos: GitRepository[] = [];
|
||||
const otherRepos: GitRepository[] = [];
|
||||
|
||||
repositories.forEach((repo: GitRepository) => {
|
||||
// Flatten all pages to get all repositories
|
||||
const allRepositories = repositories.pages.flatMap((page) => page.data);
|
||||
|
||||
allRepositories.forEach((repo: GitRepository) => {
|
||||
const hasOpenHandsSuffix = repo.full_name.endsWith("/.openhands");
|
||||
|
||||
if (repo.owner_type === "user" && hasOpenHandsSuffix) {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Autocomplete, AutocompleteItem } from "@heroui/react";
|
||||
import { ReactNode } from "react";
|
||||
import React, { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { OptionalTag } from "./optional-tag";
|
||||
import { cn } from "#/utils/utils";
|
||||
@ -44,6 +44,7 @@ export function SettingsDropdownInput({
|
||||
defaultFilter,
|
||||
}: SettingsDropdownInputProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<label className={cn("flex flex-col gap-2.5", wrapperClassName)}>
|
||||
{label && (
|
||||
|
||||
@ -67,9 +67,9 @@ prepareApp().then(() =>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HydratedRouter />
|
||||
<PosthogInit />
|
||||
<div id="modal-portal-exit" />
|
||||
</QueryClientProvider>
|
||||
</Provider>
|
||||
<div id="modal-portal-exit" />
|
||||
</StrictMode>,
|
||||
);
|
||||
}),
|
||||
|
||||
24
frontend/src/hooks/query/use-app-installations.ts
Normal file
24
frontend/src/hooks/query/use-app-installations.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useConfig } from "./use-config";
|
||||
import { useIsAuthed } from "./use-is-authed";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useUserProviders } from "../use-user-providers";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { shouldUseInstallationRepos } from "#/utils/utils";
|
||||
|
||||
export const useAppInstallations = (selectedProvider: Provider | null) => {
|
||||
const { data: config } = useConfig();
|
||||
const { data: userIsAuthenticated } = useIsAuthed();
|
||||
const { providers } = useUserProviders();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["installations", providers || [], selectedProvider],
|
||||
queryFn: () => OpenHands.getUserInstallationIds(selectedProvider!),
|
||||
enabled:
|
||||
userIsAuthenticated &&
|
||||
!!selectedProvider &&
|
||||
shouldUseInstallationRepos(selectedProvider, config?.APP_MODE),
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
};
|
||||
130
frontend/src/hooks/query/use-git-repositories.ts
Normal file
130
frontend/src/hooks/query/use-git-repositories.ts
Normal file
@ -0,0 +1,130 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useConfig } from "./use-config";
|
||||
import { useUserProviders } from "../use-user-providers";
|
||||
import { useAppInstallations } from "./use-app-installations";
|
||||
import { GitRepository } from "../../types/git";
|
||||
import { Provider } from "../../types/settings";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { shouldUseInstallationRepos } from "#/utils/utils";
|
||||
|
||||
interface UseGitRepositoriesOptions {
|
||||
provider: Provider | null;
|
||||
pageSize?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface UserRepositoriesResponse {
|
||||
data: GitRepository[];
|
||||
nextPage: number | null;
|
||||
}
|
||||
|
||||
interface InstallationRepositoriesResponse {
|
||||
data: GitRepository[];
|
||||
nextPage: number | null;
|
||||
installationIndex: number | null;
|
||||
}
|
||||
|
||||
export function useGitRepositories(options: UseGitRepositoriesOptions) {
|
||||
const { provider, pageSize = 30, enabled = true } = options;
|
||||
const { providers } = useUserProviders();
|
||||
const { data: config } = useConfig();
|
||||
const { data: installations } = useAppInstallations(provider);
|
||||
|
||||
const useInstallationRepos = provider
|
||||
? shouldUseInstallationRepos(provider, config?.APP_MODE)
|
||||
: false;
|
||||
|
||||
const repos = useInfiniteQuery<
|
||||
UserRepositoriesResponse | InstallationRepositoriesResponse
|
||||
>({
|
||||
queryKey: [
|
||||
"repositories",
|
||||
providers || [],
|
||||
provider,
|
||||
useInstallationRepos,
|
||||
pageSize,
|
||||
...(useInstallationRepos ? [installations || []] : []),
|
||||
],
|
||||
queryFn: async ({ pageParam }) => {
|
||||
if (!provider) {
|
||||
throw new Error("Provider is required");
|
||||
}
|
||||
|
||||
if (useInstallationRepos) {
|
||||
const { repoPage, installationIndex } = pageParam as {
|
||||
installationIndex: number | null;
|
||||
repoPage: number | null;
|
||||
};
|
||||
|
||||
if (!installations) {
|
||||
throw new Error("Missing installation list");
|
||||
}
|
||||
|
||||
return OpenHands.retrieveInstallationRepositories(
|
||||
provider,
|
||||
installationIndex || 0,
|
||||
installations,
|
||||
repoPage || 1,
|
||||
pageSize,
|
||||
);
|
||||
}
|
||||
|
||||
return OpenHands.retrieveUserGitRepositories(
|
||||
provider,
|
||||
pageParam as number,
|
||||
pageSize,
|
||||
);
|
||||
},
|
||||
getNextPageParam: (lastPage) => {
|
||||
if (useInstallationRepos) {
|
||||
const installationPage = lastPage as InstallationRepositoriesResponse;
|
||||
if (installationPage.nextPage) {
|
||||
return {
|
||||
installationIndex: installationPage.installationIndex,
|
||||
repoPage: installationPage.nextPage,
|
||||
};
|
||||
}
|
||||
|
||||
if (installationPage.installationIndex !== null) {
|
||||
return {
|
||||
installationIndex: installationPage.installationIndex,
|
||||
repoPage: 1,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const userPage = lastPage as UserRepositoriesResponse;
|
||||
return userPage.nextPage;
|
||||
},
|
||||
initialPageParam: useInstallationRepos
|
||||
? { installationIndex: 0, repoPage: 1 }
|
||||
: 1,
|
||||
enabled:
|
||||
enabled &&
|
||||
(providers || []).length > 0 &&
|
||||
!!provider &&
|
||||
(!useInstallationRepos ||
|
||||
(Array.isArray(installations) && installations.length > 0)),
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const onLoadMore = () => {
|
||||
if (repos.hasNextPage && !repos.isFetchingNextPage) {
|
||||
repos.fetchNextPage();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
data: repos.data,
|
||||
isLoading: repos.isLoading,
|
||||
isError: repos.isError,
|
||||
hasNextPage: repos.hasNextPage,
|
||||
isFetchingNextPage: repos.isFetchingNextPage,
|
||||
fetchNextPage: repos.fetchNextPage,
|
||||
onLoadMore,
|
||||
};
|
||||
}
|
||||
82
frontend/src/hooks/query/use-installation-repositories.ts
Normal file
82
frontend/src/hooks/query/use-installation-repositories.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useAppInstallations } from "./use-app-installations";
|
||||
import { useConfig } from "./use-config";
|
||||
import { useUserProviders } from "../use-user-providers";
|
||||
import { Provider } from "#/types/settings";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { shouldUseInstallationRepos } from "#/utils/utils";
|
||||
|
||||
export const useInstallationRepositories = (
|
||||
selectedProvider: Provider | null,
|
||||
) => {
|
||||
const { providers } = useUserProviders();
|
||||
const { data: config } = useConfig();
|
||||
const { data: installations } = useAppInstallations(selectedProvider);
|
||||
|
||||
const repos = useInfiniteQuery({
|
||||
queryKey: [
|
||||
"repositories",
|
||||
providers || [],
|
||||
selectedProvider,
|
||||
installations || [],
|
||||
],
|
||||
queryFn: async ({
|
||||
pageParam,
|
||||
}: {
|
||||
pageParam: { installationIndex: number | null; repoPage: number | null };
|
||||
}) => {
|
||||
const { repoPage, installationIndex } = pageParam;
|
||||
|
||||
if (!installations) {
|
||||
throw new Error("Missing installation list");
|
||||
}
|
||||
|
||||
return OpenHands.retrieveInstallationRepositories(
|
||||
selectedProvider!,
|
||||
installationIndex || 0,
|
||||
installations,
|
||||
repoPage || 1,
|
||||
30,
|
||||
);
|
||||
},
|
||||
initialPageParam: { installationIndex: 0, repoPage: 1 },
|
||||
getNextPageParam: (lastPage) => {
|
||||
if (lastPage.nextPage) {
|
||||
return {
|
||||
installationIndex: lastPage.installationIndex,
|
||||
repoPage: lastPage.nextPage,
|
||||
};
|
||||
}
|
||||
|
||||
if (lastPage.installationIndex !== null) {
|
||||
return { installationIndex: lastPage.installationIndex, repoPage: 1 };
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
enabled:
|
||||
(providers || []).length > 0 &&
|
||||
!!selectedProvider &&
|
||||
shouldUseInstallationRepos(selectedProvider, config?.APP_MODE) &&
|
||||
Array.isArray(installations) &&
|
||||
installations.length > 0,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
|
||||
const onLoadMore = () => {
|
||||
if (repos.hasNextPage && !repos.isFetchingNextPage) {
|
||||
repos.fetchNextPage();
|
||||
}
|
||||
};
|
||||
|
||||
// Return the query result with the scroll ref
|
||||
return {
|
||||
data: repos.data,
|
||||
isLoading: repos.isLoading,
|
||||
isError: repos.isError,
|
||||
hasNextPage: repos.hasNextPage,
|
||||
isFetchingNextPage: repos.isFetchingNextPage,
|
||||
onLoadMore,
|
||||
};
|
||||
};
|
||||
@ -1,11 +1,16 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { Provider } from "#/types/settings";
|
||||
|
||||
export function useSearchRepositories(query: string) {
|
||||
export function useSearchRepositories(
|
||||
query: string,
|
||||
selectedProvider?: Provider | null,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["repositories", query],
|
||||
queryFn: () => OpenHands.searchGitRepositories(query, 3),
|
||||
enabled: !!query,
|
||||
queryKey: ["repositories", "search", query, selectedProvider],
|
||||
queryFn: () =>
|
||||
OpenHands.searchGitRepositories(query, 3, selectedProvider || undefined),
|
||||
enabled: !!query && !!selectedProvider,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
|
||||
@ -1,10 +1,41 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useConfig } from "./use-config";
|
||||
import { useUserProviders } from "../use-user-providers";
|
||||
import { Provider } from "#/types/settings";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { shouldUseInstallationRepos } from "#/utils/utils";
|
||||
|
||||
export const useUserRepositories = () =>
|
||||
useQuery({
|
||||
queryKey: ["repositories"],
|
||||
queryFn: OpenHands.retrieveUserGitRepositories,
|
||||
export const useUserRepositories = (selectedProvider: Provider | null) => {
|
||||
const { providers } = useUserProviders();
|
||||
const { data: config } = useConfig();
|
||||
|
||||
const repos = useInfiniteQuery({
|
||||
queryKey: ["repositories", providers || [], selectedProvider],
|
||||
queryFn: async ({ pageParam }) =>
|
||||
OpenHands.retrieveUserGitRepositories(selectedProvider!, pageParam, 30),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) => lastPage.nextPage,
|
||||
enabled:
|
||||
(providers || []).length > 0 &&
|
||||
!!selectedProvider &&
|
||||
!shouldUseInstallationRepos(selectedProvider, config?.APP_MODE),
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
|
||||
const onLoadMore = () => {
|
||||
if (repos.hasNextPage && !repos.isFetchingNextPage) {
|
||||
repos.fetchNextPage();
|
||||
}
|
||||
};
|
||||
|
||||
// Return the query result with the scroll ref
|
||||
return {
|
||||
data: repos.data,
|
||||
isLoading: repos.isLoading,
|
||||
isError: repos.isError,
|
||||
hasNextPage: repos.hasNextPage,
|
||||
isFetchingNextPage: repos.isFetchingNextPage,
|
||||
onLoadMore,
|
||||
};
|
||||
};
|
||||
|
||||
157
frontend/src/mocks/git-repository-handlers.ts
Normal file
157
frontend/src/mocks/git-repository-handlers.ts
Normal file
@ -0,0 +1,157 @@
|
||||
import { delay, http, HttpResponse } from "msw";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { Provider } from "#/types/settings";
|
||||
|
||||
// Generate a list of mock repositories with realistic data
|
||||
const generateMockRepositories = (
|
||||
count: number,
|
||||
provider: Provider,
|
||||
): GitRepository[] =>
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
id: `${i + 1}`,
|
||||
full_name: `user/repo-${i + 1}`,
|
||||
git_provider: provider,
|
||||
is_public: Math.random() > 0.3, // 70% chance of being public
|
||||
stargazers_count: Math.floor(Math.random() * 1000),
|
||||
pushed_at: new Date(
|
||||
Date.now() - Math.random() * 90 * 24 * 60 * 60 * 1000,
|
||||
).toISOString(), // Last 90 days
|
||||
owner_type: Math.random() > 0.7 ? "organization" : "user", // 30% chance of being organization
|
||||
}));
|
||||
|
||||
// Mock repositories for each provider
|
||||
const MOCK_REPOSITORIES = {
|
||||
github: generateMockRepositories(120, "github"),
|
||||
gitlab: generateMockRepositories(120, "gitlab"),
|
||||
bitbucket: generateMockRepositories(120, "bitbucket"),
|
||||
};
|
||||
|
||||
export const GIT_REPOSITORY_HANDLERS = [
|
||||
http.get("/api/user/repositories", async ({ request }) => {
|
||||
await delay(500); // Simulate network delay
|
||||
|
||||
const url = new URL(request.url);
|
||||
const selectedProvider = url.searchParams.get("selected_provider");
|
||||
const page = parseInt(url.searchParams.get("page") || "1", 10);
|
||||
const perPage = parseInt(url.searchParams.get("per_page") || "30", 10);
|
||||
const sort = url.searchParams.get("sort") || "pushed";
|
||||
const installationId = url.searchParams.get("installation_id");
|
||||
|
||||
// Simulate authentication error if no provider token
|
||||
if (!selectedProvider) {
|
||||
return HttpResponse.json(
|
||||
"Git provider token required. (such as GitHub).",
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Get repositories for the selected provider
|
||||
const repositories =
|
||||
MOCK_REPOSITORIES[selectedProvider as keyof typeof MOCK_REPOSITORIES] ||
|
||||
[];
|
||||
|
||||
// Sort repositories based on the sort parameter
|
||||
let sortedRepos = [...repositories];
|
||||
if (sort === "pushed") {
|
||||
sortedRepos.sort(
|
||||
(a, b) =>
|
||||
new Date(b.pushed_at!).getTime() - new Date(a.pushed_at!).getTime(),
|
||||
);
|
||||
} else if (sort === "stars") {
|
||||
sortedRepos.sort(
|
||||
(a, b) => (b.stargazers_count || 0) - (a.stargazers_count || 0),
|
||||
);
|
||||
}
|
||||
|
||||
// Handle installation filtering (for GitHub Apps)
|
||||
if (installationId && selectedProvider === "github") {
|
||||
// Simulate filtering by installation - in real API this would filter by access
|
||||
const installationIndex = parseInt(installationId, 10) || 0;
|
||||
const startRepo = installationIndex * 20; // Each installation has ~20 repos
|
||||
sortedRepos = sortedRepos.slice(startRepo, startRepo + 20);
|
||||
}
|
||||
|
||||
// Calculate pagination
|
||||
const startIndex = (page - 1) * perPage;
|
||||
const endIndex = startIndex + perPage;
|
||||
const paginatedRepos = sortedRepos.slice(startIndex, endIndex);
|
||||
const hasNextPage = endIndex < sortedRepos.length;
|
||||
const hasPrevPage = page > 1;
|
||||
const totalPages = Math.ceil(sortedRepos.length / perPage);
|
||||
|
||||
// Generate GitHub-style link header for pagination
|
||||
let linkHeader = "";
|
||||
if (hasNextPage || hasPrevPage) {
|
||||
const links = [];
|
||||
if (hasPrevPage) {
|
||||
links.push(
|
||||
`</api/user/repositories?page=${page - 1}&per_page=${perPage}>; rel="prev"`,
|
||||
);
|
||||
}
|
||||
if (hasNextPage) {
|
||||
links.push(
|
||||
`</api/user/repositories?page=${page + 1}&per_page=${perPage}>; rel="next"`,
|
||||
);
|
||||
}
|
||||
links.push(
|
||||
`</api/user/repositories?page=${totalPages}&per_page=${perPage}>; rel="last"`,
|
||||
);
|
||||
links.push(
|
||||
`</api/user/repositories?page=1&per_page=${perPage}>; rel="first"`,
|
||||
);
|
||||
linkHeader = links.join(", ");
|
||||
}
|
||||
|
||||
// Add link_header to the first repository if pagination info exists
|
||||
const responseRepos = [...paginatedRepos];
|
||||
if (responseRepos.length > 0 && linkHeader) {
|
||||
responseRepos[0] = { ...responseRepos[0], link_header: linkHeader };
|
||||
}
|
||||
|
||||
// Return response as direct Repository array (matching real API)
|
||||
return HttpResponse.json(responseRepos);
|
||||
}),
|
||||
|
||||
http.get("/api/user/search/repositories", async ({ request }) => {
|
||||
await delay(300); // Simulate network delay
|
||||
|
||||
const url = new URL(request.url);
|
||||
const query = url.searchParams.get("query") || "";
|
||||
const selectedProvider = url.searchParams.get("selected_provider");
|
||||
const perPage = parseInt(url.searchParams.get("per_page") || "5", 10);
|
||||
const sort = url.searchParams.get("sort") || "stars";
|
||||
const order = url.searchParams.get("order") || "desc";
|
||||
|
||||
// Simulate authentication error if no provider token
|
||||
if (!selectedProvider) {
|
||||
return HttpResponse.json("Git provider token required.", {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
// Get repositories for the selected provider
|
||||
const repositories =
|
||||
MOCK_REPOSITORIES[selectedProvider as keyof typeof MOCK_REPOSITORIES] ||
|
||||
[];
|
||||
|
||||
// Filter repositories by search query
|
||||
const filteredRepos = repositories.filter((repo) =>
|
||||
repo.full_name.toLowerCase().includes(query.toLowerCase()),
|
||||
);
|
||||
|
||||
// Sort repositories
|
||||
const sortedRepos = [...filteredRepos];
|
||||
if (sort === "stars") {
|
||||
sortedRepos.sort((a, b) => {
|
||||
const aStars = a.stargazers_count || 0;
|
||||
const bStars = b.stargazers_count || 0;
|
||||
return order === "desc" ? bStars - aStars : aStars - bStars;
|
||||
});
|
||||
}
|
||||
|
||||
// Limit results
|
||||
const limitedRepos = sortedRepos.slice(0, perPage);
|
||||
|
||||
return HttpResponse.json(limitedRepos);
|
||||
}),
|
||||
];
|
||||
@ -8,9 +8,10 @@ import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { STRIPE_BILLING_HANDLERS } from "./billing-handlers";
|
||||
import { ApiSettings, PostApiSettings, Provider } from "#/types/settings";
|
||||
import { FILE_SERVICE_HANDLERS } from "./file-service-handlers";
|
||||
import { GitRepository, GitUser } from "#/types/git";
|
||||
import { GitUser } from "#/types/git";
|
||||
import { TASK_SUGGESTIONS_HANDLERS } from "./task-suggestions-handlers";
|
||||
import { SECRETS_HANDLERS } from "./secrets-handlers";
|
||||
import { GIT_REPOSITORY_HANDLERS } from "./git-repository-handlers";
|
||||
|
||||
export const MOCK_DEFAULT_USER_SETTINGS: ApiSettings | PostApiSettings = {
|
||||
llm_model: DEFAULT_SETTINGS.LLM_MODEL,
|
||||
@ -24,7 +25,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,
|
||||
provider_tokens_set: DEFAULT_SETTINGS.PROVIDER_TOKENS_SET,
|
||||
provider_tokens_set: { github: null, gitlab: null, bitbucket: null },
|
||||
enable_default_condenser: DEFAULT_SETTINGS.ENABLE_DEFAULT_CONDENSER,
|
||||
enable_sound_notifications: DEFAULT_SETTINGS.ENABLE_SOUND_NOTIFICATIONS,
|
||||
enable_proactive_conversation_starters:
|
||||
@ -138,25 +139,8 @@ export const handlers = [
|
||||
...FILE_SERVICE_HANDLERS,
|
||||
...TASK_SUGGESTIONS_HANDLERS,
|
||||
...SECRETS_HANDLERS,
|
||||
...GIT_REPOSITORY_HANDLERS,
|
||||
...openHandsHandlers,
|
||||
http.get("/api/user/repositories", () => {
|
||||
const data: GitRepository[] = [
|
||||
{
|
||||
id: "1",
|
||||
full_name: "octocat/hello-world",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
full_name: "octocat/earth",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
},
|
||||
];
|
||||
|
||||
return HttpResponse.json(data);
|
||||
}),
|
||||
http.get("/api/user/info", () => {
|
||||
const user: GitUser = {
|
||||
id: "1",
|
||||
@ -205,9 +189,6 @@ export const handlers = [
|
||||
|
||||
if (!settings) return HttpResponse.json(null, { status: 404 });
|
||||
|
||||
if (Object.keys(settings.provider_tokens_set).length > 0)
|
||||
settings.provider_tokens_set = {};
|
||||
|
||||
return HttpResponse.json(settings);
|
||||
}),
|
||||
http.post("/api/settings", async ({ request }) => {
|
||||
@ -215,18 +196,7 @@ export const handlers = [
|
||||
const body = await request.json();
|
||||
|
||||
if (body) {
|
||||
let newSettings: Partial<PostApiSettings> = {};
|
||||
if (typeof body === "object") {
|
||||
newSettings = { ...body };
|
||||
}
|
||||
|
||||
const fullSettings = {
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
...MOCK_USER_PREFERENCES.settings,
|
||||
...newSettings,
|
||||
};
|
||||
|
||||
MOCK_USER_PREFERENCES.settings = fullSettings;
|
||||
MOCK_USER_PREFERENCES.settings = MOCK_DEFAULT_USER_SETTINGS;
|
||||
return HttpResponse.json(null, { status: 200 });
|
||||
}
|
||||
|
||||
|
||||
@ -104,6 +104,24 @@ export const formatTimestamp = (timestamp: string) =>
|
||||
second: "2-digit",
|
||||
});
|
||||
|
||||
export const shouldUseInstallationRepos = (
|
||||
provider: Provider,
|
||||
app_mode: "saas" | "oss" | undefined,
|
||||
) => {
|
||||
if (!provider) return false;
|
||||
|
||||
switch (provider) {
|
||||
case "bitbucket":
|
||||
return true;
|
||||
case "gitlab":
|
||||
return false;
|
||||
case "github":
|
||||
return app_mode === "saas";
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const getGitProviderBaseUrl = (gitProvider: Provider): string => {
|
||||
switch (gitProvider) {
|
||||
case "github":
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
@ -10,6 +11,7 @@ from openhands.integrations.service_types import (
|
||||
BaseGitService,
|
||||
Branch,
|
||||
GitService,
|
||||
InstallationsService,
|
||||
OwnerType,
|
||||
ProviderType,
|
||||
Repository,
|
||||
@ -23,7 +25,7 @@ from openhands.server.types import AppMode
|
||||
from openhands.utils.import_utils import get_impl
|
||||
|
||||
|
||||
class BitBucketService(BaseGitService, GitService):
|
||||
class BitBucketService(BaseGitService, GitService, InstallationsService):
|
||||
"""Default implementation of GitService for Bitbucket integration.
|
||||
|
||||
This is an extension point in OpenHands that allows applications to customize Bitbucket
|
||||
@ -186,16 +188,106 @@ class BitBucketService(BaseGitService, GitService):
|
||||
email=None, # Bitbucket API doesn't return email in this endpoint
|
||||
)
|
||||
|
||||
def _parse_repository(
|
||||
self, repo: dict, link_header: str | None = None
|
||||
) -> Repository:
|
||||
"""
|
||||
Parse a Bitbucket API repository response into a Repository object.
|
||||
|
||||
Args:
|
||||
repo: Repository data from Bitbucket API
|
||||
link_header: Optional link header for pagination
|
||||
|
||||
Returns:
|
||||
Repository object
|
||||
"""
|
||||
|
||||
repo_id = repo.get('uuid', '')
|
||||
|
||||
workspace_slug = repo.get('workspace', {}).get('slug', '')
|
||||
repo_slug = repo.get('slug', '')
|
||||
full_name = (
|
||||
f'{workspace_slug}/{repo_slug}' if workspace_slug and repo_slug else ''
|
||||
)
|
||||
|
||||
is_public = not repo.get('is_private', True)
|
||||
owner_type = OwnerType.ORGANIZATION
|
||||
main_branch = repo.get('mainbranch', {}).get('name')
|
||||
|
||||
return Repository(
|
||||
id=repo_id,
|
||||
full_name=full_name, # type: ignore[arg-type]
|
||||
git_provider=ProviderType.BITBUCKET,
|
||||
is_public=is_public,
|
||||
stargazers_count=None, # Bitbucket doesn't have stars
|
||||
pushed_at=repo.get('updated_on'),
|
||||
owner_type=owner_type,
|
||||
link_header=link_header,
|
||||
main_branch=main_branch,
|
||||
)
|
||||
|
||||
async def search_repositories(
|
||||
self,
|
||||
query: str,
|
||||
per_page: int,
|
||||
sort: str,
|
||||
order: str,
|
||||
self, query: str, per_page: int, sort: str, order: str, public: bool
|
||||
) -> list[Repository]:
|
||||
"""Search for repositories."""
|
||||
# Bitbucket doesn't have a dedicated search endpoint like GitHub
|
||||
return []
|
||||
repositories = []
|
||||
|
||||
if public:
|
||||
# Extract workspace and repo from URL
|
||||
# URL format: https://{domain}/{workspace}/{repo}/{additional_params}
|
||||
# Split by '/' and find workspace and repo parts
|
||||
url_parts = query.split('/')
|
||||
if len(url_parts) >= 5: # https:, '', domain, workspace, repo
|
||||
workspace_slug = url_parts[3]
|
||||
repo_name = url_parts[4]
|
||||
|
||||
repo = await self.get_repository_details_from_repo_name(
|
||||
f'{workspace_slug}/{repo_name}'
|
||||
)
|
||||
repositories.append(repo)
|
||||
|
||||
return repositories
|
||||
|
||||
# Search for repos once workspace prefix exists
|
||||
if '/' in query:
|
||||
workspace_slug, repo_query = query.split('/', 1)
|
||||
return await self.get_paginated_repos(
|
||||
1, per_page, sort, workspace_slug, repo_query
|
||||
)
|
||||
|
||||
all_installations = await self.get_installations()
|
||||
|
||||
# Workspace prefix isn't complete. Search workspace names and repos underneath each workspace
|
||||
matching_workspace_slugs = [
|
||||
installation for installation in all_installations if query in installation
|
||||
]
|
||||
for workspace_slug in matching_workspace_slugs:
|
||||
# Get repositories where query matches workspace name
|
||||
try:
|
||||
repos = await self.get_paginated_repos(
|
||||
1, per_page, sort, workspace_slug
|
||||
)
|
||||
repositories.extend(repos)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
for workspace_slug in all_installations:
|
||||
# Get repositories in all workspaces where query matches repo name
|
||||
try:
|
||||
repos = await self.get_paginated_repos(
|
||||
1, per_page, sort, workspace_slug, query
|
||||
)
|
||||
repositories.extend(repos)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return repositories
|
||||
|
||||
async def _get_user_workspaces(self) -> list[dict[str, Any]]:
|
||||
"""Get all workspaces the user has access to"""
|
||||
url = f'{self.BASE_URL}/workspaces'
|
||||
data, _ = await self._make_request(url)
|
||||
return data.get('values', [])
|
||||
|
||||
async def _fetch_paginated_data(
|
||||
self, url: str, params: dict, max_items: int
|
||||
@ -232,7 +324,107 @@ class BitBucketService(BaseGitService, GitService):
|
||||
|
||||
return all_items[:max_items] # Trim to max_items if needed
|
||||
|
||||
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
|
||||
async def get_installations(
|
||||
self, query: str | None = None, limit: int = 100
|
||||
) -> list[str]:
|
||||
workspaces_url = f'{self.BASE_URL}/workspaces'
|
||||
params = {}
|
||||
if query:
|
||||
params['q'] = f'name~"{query}"'
|
||||
|
||||
workspaces = await self._fetch_paginated_data(workspaces_url, params, limit)
|
||||
|
||||
installations: list[str] = []
|
||||
for workspace in workspaces:
|
||||
installations.append(workspace['slug'])
|
||||
|
||||
return installations
|
||||
|
||||
async def get_paginated_repos(
|
||||
self,
|
||||
page: int,
|
||||
per_page: int,
|
||||
sort: str,
|
||||
installation_id: str | None,
|
||||
query: str | None = None,
|
||||
) -> list[Repository]:
|
||||
"""Get paginated repositories for a specific workspace.
|
||||
|
||||
Args:
|
||||
page: The page number to fetch
|
||||
per_page: The number of repositories per page
|
||||
sort: The sort field ('pushed', 'updated', 'created', 'full_name')
|
||||
installation_id: The workspace slug to fetch repositories from (as int, will be converted to string)
|
||||
|
||||
Returns:
|
||||
A list of Repository objects
|
||||
"""
|
||||
if not installation_id:
|
||||
return []
|
||||
|
||||
# Convert installation_id to string for use as workspace_slug
|
||||
workspace_slug = installation_id
|
||||
workspace_repos_url = f'{self.BASE_URL}/repositories/{workspace_slug}'
|
||||
|
||||
# Map sort parameter to Bitbucket API compatible values
|
||||
bitbucket_sort = sort
|
||||
if sort == 'pushed':
|
||||
# Bitbucket doesn't support 'pushed', use 'updated_on' instead
|
||||
bitbucket_sort = '-updated_on' # Use negative prefix for descending order
|
||||
elif sort == 'updated':
|
||||
bitbucket_sort = '-updated_on'
|
||||
elif sort == 'created':
|
||||
bitbucket_sort = '-created_on'
|
||||
elif sort == 'full_name':
|
||||
bitbucket_sort = 'name' # Bitbucket uses 'name' not 'full_name'
|
||||
else:
|
||||
# Default to most recently updated first
|
||||
bitbucket_sort = '-updated_on'
|
||||
|
||||
params = {
|
||||
'pagelen': per_page,
|
||||
'page': page,
|
||||
'sort': bitbucket_sort,
|
||||
}
|
||||
|
||||
if query:
|
||||
params['q'] = f'name~"{query}"'
|
||||
|
||||
response, headers = await self._make_request(workspace_repos_url, params)
|
||||
|
||||
# Extract repositories from the response
|
||||
repos = response.get('values', [])
|
||||
|
||||
# Extract next URL from response
|
||||
next_link = response.get('next', '')
|
||||
|
||||
# Format the link header in a way that the frontend can understand
|
||||
# The frontend expects a format like: <url>; rel="next"
|
||||
# where the URL contains a page parameter
|
||||
formatted_link_header = ''
|
||||
if next_link:
|
||||
# Extract the page number from the next URL if possible
|
||||
page_match = re.search(r'[?&]page=(\d+)', next_link)
|
||||
if page_match:
|
||||
next_page = page_match.group(1)
|
||||
# Format it in a way that extractNextPageFromLink in frontend can parse
|
||||
formatted_link_header = (
|
||||
f'<{workspace_repos_url}?page={next_page}>; rel="next"'
|
||||
)
|
||||
else:
|
||||
# If we can't extract the page, just use the next URL as is
|
||||
formatted_link_header = f'<{next_link}>; rel="next"'
|
||||
|
||||
repositories = [
|
||||
self._parse_repository(repo, link_header=formatted_link_header)
|
||||
for repo in repos
|
||||
]
|
||||
|
||||
return repositories
|
||||
|
||||
async def get_all_repositories(
|
||||
self, sort: str, app_mode: AppMode
|
||||
) -> list[Repository]:
|
||||
"""Get repositories for the authenticated user using workspaces endpoint.
|
||||
|
||||
This method gets all repositories (both public and private) that the user has access to
|
||||
@ -285,22 +477,7 @@ class BitBucketService(BaseGitService, GitService):
|
||||
)
|
||||
|
||||
for repo in workspace_repos:
|
||||
uuid = repo.get('uuid', '')
|
||||
repositories.append(
|
||||
Repository(
|
||||
id=uuid,
|
||||
full_name=f'{repo.get("workspace", {}).get("slug", "")}/{repo.get("slug", "")}',
|
||||
git_provider=ProviderType.BITBUCKET,
|
||||
is_public=repo.get('is_private', True) is False,
|
||||
stargazers_count=None, # Bitbucket doesn't have stars
|
||||
pushed_at=repo.get('updated_on'),
|
||||
owner_type=(
|
||||
OwnerType.ORGANIZATION
|
||||
if repo.get('workspace', {}).get('is_private') is False
|
||||
else OwnerType.USER
|
||||
),
|
||||
)
|
||||
)
|
||||
repositories.append(self._parse_repository(repo))
|
||||
|
||||
# Stop if we've reached the maximum number of repositories
|
||||
if len(repositories) >= MAX_REPOS:
|
||||
@ -332,23 +509,7 @@ class BitBucketService(BaseGitService, GitService):
|
||||
url = f'{self.BASE_URL}/repositories/{owner}/{repo}'
|
||||
data, _ = await self._make_request(url)
|
||||
|
||||
uuid = data.get('uuid', '')
|
||||
main_branch = data.get('mainbranch', {}).get('name')
|
||||
|
||||
return Repository(
|
||||
id=uuid,
|
||||
full_name=f'{data.get("workspace", {}).get("slug", "")}/{data.get("slug", "")}',
|
||||
git_provider=ProviderType.BITBUCKET,
|
||||
is_public=data.get('is_private', True) is False,
|
||||
stargazers_count=None, # Bitbucket doesn't have stars
|
||||
pushed_at=data.get('updated_on'),
|
||||
owner_type=(
|
||||
OwnerType.ORGANIZATION
|
||||
if data.get('workspace', {}).get('is_private') is False
|
||||
else OwnerType.USER
|
||||
),
|
||||
main_branch=main_branch,
|
||||
)
|
||||
return self._parse_repository(data)
|
||||
|
||||
async def get_branches(self, repository: str) -> list[Branch]:
|
||||
"""Get branches for a repository."""
|
||||
|
||||
@ -16,6 +16,7 @@ from openhands.integrations.service_types import (
|
||||
BaseGitService,
|
||||
Branch,
|
||||
GitService,
|
||||
InstallationsService,
|
||||
OwnerType,
|
||||
ProviderType,
|
||||
Repository,
|
||||
@ -30,7 +31,7 @@ from openhands.server.types import AppMode
|
||||
from openhands.utils.import_utils import get_impl
|
||||
|
||||
|
||||
class GitHubService(BaseGitService, GitService):
|
||||
class GitHubService(BaseGitService, GitService, InstallationsService):
|
||||
"""Default implementation of GitService for GitHub integration.
|
||||
|
||||
TODO: This doesn't seem a good candidate for the get_impl() pattern. What are the abstract methods we should actually separate and implement here?
|
||||
@ -224,14 +225,66 @@ class GitHubService(BaseGitService, GitService):
|
||||
ts = repo.get('pushed_at')
|
||||
return datetime.strptime(ts, '%Y-%m-%dT%H:%M:%SZ') if ts else datetime.min
|
||||
|
||||
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
|
||||
def _parse_repository(
|
||||
self, repo: dict, link_header: str | None = None
|
||||
) -> Repository:
|
||||
"""
|
||||
Parse a GitHub API repository response into a Repository object.
|
||||
|
||||
Args:
|
||||
repo: Repository data from GitHub API
|
||||
link_header: Optional link header for pagination
|
||||
|
||||
Returns:
|
||||
Repository object
|
||||
"""
|
||||
return Repository(
|
||||
id=str(repo.get('id')), # type: ignore[arg-type]
|
||||
full_name=repo.get('full_name'), # type: ignore[arg-type]
|
||||
stargazers_count=repo.get('stargazers_count'),
|
||||
git_provider=ProviderType.GITHUB,
|
||||
is_public=not repo.get('private', True),
|
||||
owner_type=(
|
||||
OwnerType.ORGANIZATION
|
||||
if repo.get('owner', {}).get('type') == 'Organization'
|
||||
else OwnerType.USER
|
||||
),
|
||||
link_header=link_header,
|
||||
)
|
||||
|
||||
async def get_paginated_repos(
|
||||
self,
|
||||
page: int,
|
||||
per_page: int,
|
||||
sort: str,
|
||||
installation_id: str | None,
|
||||
query: str | None = None,
|
||||
):
|
||||
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._make_request(url, params)
|
||||
response = response.get('repositories', [])
|
||||
else:
|
||||
url = f'{self.BASE_URL}/user/repos'
|
||||
params['sort'] = sort
|
||||
response, headers = await self._make_request(url, params)
|
||||
|
||||
next_link: str = headers.get('Link', '')
|
||||
return [
|
||||
self._parse_repository(repo, link_header=next_link) for repo in response
|
||||
]
|
||||
|
||||
async def get_all_repositories(
|
||||
self, sort: str, app_mode: AppMode
|
||||
) -> list[Repository]:
|
||||
MAX_REPOS = 1000
|
||||
PER_PAGE = 100 # Maximum allowed by GitHub API
|
||||
all_repos: list[dict] = []
|
||||
|
||||
if app_mode == AppMode.SAAS:
|
||||
# Get all installation IDs and fetch repos for each one
|
||||
installation_ids = await self.get_installation_ids()
|
||||
installation_ids = await self.get_installations()
|
||||
|
||||
# Iterate through each installation ID
|
||||
for installation_id in installation_ids:
|
||||
@ -262,59 +315,47 @@ class GitHubService(BaseGitService, GitService):
|
||||
all_repos = await self._fetch_paginated_repos(url, params, MAX_REPOS)
|
||||
|
||||
# Convert to Repository objects
|
||||
return [
|
||||
Repository(
|
||||
id=str(repo.get('id')), # type: ignore[arg-type]
|
||||
full_name=repo.get('full_name'), # type: ignore[arg-type]
|
||||
stargazers_count=repo.get('stargazers_count'),
|
||||
git_provider=ProviderType.GITHUB,
|
||||
is_public=not repo.get('private', True),
|
||||
owner_type=(
|
||||
OwnerType.ORGANIZATION
|
||||
if repo.get('owner', {}).get('type') == 'Organization'
|
||||
else OwnerType.USER
|
||||
),
|
||||
)
|
||||
for repo in all_repos
|
||||
]
|
||||
return [self._parse_repository(repo) for repo in all_repos]
|
||||
|
||||
async def get_installation_ids(self) -> list[int]:
|
||||
async def get_installations(self) -> list[str]:
|
||||
url = f'{self.BASE_URL}/user/installations'
|
||||
response, _ = await self._make_request(url)
|
||||
installations = response.get('installations', [])
|
||||
return [i['id'] for i in installations]
|
||||
return [str(i['id']) for i in installations]
|
||||
|
||||
async def search_repositories(
|
||||
self, query: str, per_page: int, sort: str, order: str
|
||||
self, query: str, per_page: int, sort: str, order: str, public: bool
|
||||
) -> list[Repository]:
|
||||
url = f'{self.BASE_URL}/search/repositories'
|
||||
# Add is:public to the query to ensure we only search for public repositories
|
||||
query_with_visibility = f'{query} is:public'
|
||||
params = {
|
||||
'q': query_with_visibility,
|
||||
'per_page': per_page,
|
||||
'sort': sort,
|
||||
'order': order,
|
||||
}
|
||||
|
||||
if public:
|
||||
url_parts = query.split('/')
|
||||
if len(url_parts) < 4:
|
||||
return []
|
||||
|
||||
org = url_parts[3]
|
||||
repo_name = url_parts[4]
|
||||
# Add is:public to the query to ensure we only search for public repositories
|
||||
params['q'] = f'in:name {org}/{repo_name} is:public'
|
||||
|
||||
# Perhaps we should go through all orgs and the search for repos under every org
|
||||
# Currently it will only search user repos, and org repos when '/' is in the name
|
||||
if not public and '/' in query:
|
||||
org, repo_query = query.split('/', 1)
|
||||
query_with_user = f'org:{org} in:name {repo_query}'
|
||||
params['q'] = query_with_user
|
||||
elif not public:
|
||||
user = await self.get_user()
|
||||
params['q'] = f'in:name {query} user:{user.login}'
|
||||
|
||||
response, _ = await self._make_request(url, params)
|
||||
repo_items = response.get('items', [])
|
||||
|
||||
repos = [
|
||||
Repository(
|
||||
id=str(repo.get('id')),
|
||||
full_name=repo.get('full_name'),
|
||||
stargazers_count=repo.get('stargazers_count'),
|
||||
git_provider=ProviderType.GITHUB,
|
||||
is_public=True,
|
||||
owner_type=(
|
||||
OwnerType.ORGANIZATION
|
||||
if repo.get('owner', {}).get('type') == 'Organization'
|
||||
else OwnerType.USER
|
||||
),
|
||||
)
|
||||
for repo in repo_items
|
||||
]
|
||||
repos = [self._parse_repository(repo) for repo in repo_items]
|
||||
|
||||
return repos
|
||||
|
||||
@ -451,18 +492,7 @@ class GitHubService(BaseGitService, GitService):
|
||||
url = f'{self.BASE_URL}/repos/{repository}'
|
||||
repo, _ = await self._make_request(url)
|
||||
|
||||
return Repository(
|
||||
id=str(repo.get('id')),
|
||||
full_name=repo.get('full_name'),
|
||||
stargazers_count=repo.get('stargazers_count'),
|
||||
git_provider=ProviderType.GITHUB,
|
||||
is_public=not repo.get('private', True),
|
||||
owner_type=(
|
||||
OwnerType.ORGANIZATION
|
||||
if repo.get('owner', {}).get('type') == 'Organization'
|
||||
else OwnerType.USER
|
||||
),
|
||||
)
|
||||
return self._parse_repository(repo)
|
||||
|
||||
async def get_branches(self, repository: str) -> list[Branch]:
|
||||
"""Get branches for a repository"""
|
||||
|
||||
@ -241,38 +241,125 @@ class GitLabService(BaseGitService, GitService):
|
||||
company=response.get('organization'),
|
||||
)
|
||||
|
||||
def _parse_repository(
|
||||
self, repo: dict, link_header: str | None = None
|
||||
) -> Repository:
|
||||
"""
|
||||
Parse a GitLab API project response into a Repository object.
|
||||
|
||||
Args:
|
||||
repo: Project data from GitLab API
|
||||
link_header: Optional link header for pagination
|
||||
|
||||
Returns:
|
||||
Repository object
|
||||
"""
|
||||
return Repository(
|
||||
id=str(repo.get('id')), # type: ignore[arg-type]
|
||||
full_name=repo.get('path_with_namespace'), # type: ignore[arg-type]
|
||||
stargazers_count=repo.get('star_count'),
|
||||
git_provider=ProviderType.GITLAB,
|
||||
is_public=repo.get('visibility') == 'public',
|
||||
owner_type=(
|
||||
OwnerType.ORGANIZATION
|
||||
if repo.get('namespace', {}).get('kind') == 'group'
|
||||
else OwnerType.USER
|
||||
),
|
||||
link_header=link_header,
|
||||
)
|
||||
|
||||
def _parse_gitlab_url(self, url: str) -> str | None:
|
||||
"""
|
||||
Parse a GitLab URL to extract the repository path.
|
||||
|
||||
Expected format: https://{domain}/{group}/{possibly_subgroup}/{repo}
|
||||
Returns the full path from group onwards (e.g., 'group/subgroup/repo' or 'group/repo')
|
||||
"""
|
||||
try:
|
||||
# Remove protocol and domain
|
||||
if '://' in url:
|
||||
url = url.split('://', 1)[1]
|
||||
if '/' in url:
|
||||
path = url.split('/', 1)[1]
|
||||
else:
|
||||
return None
|
||||
|
||||
# Clean up the path
|
||||
path = path.strip('/')
|
||||
if not path:
|
||||
return None
|
||||
|
||||
# Split the path and remove empty parts
|
||||
path_parts = [part for part in path.split('/') if part]
|
||||
|
||||
# We need at least 2 parts: group/repo
|
||||
if len(path_parts) < 2:
|
||||
return None
|
||||
|
||||
# Join all parts to form the full repository path
|
||||
return '/'.join(path_parts)
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def search_repositories(
|
||||
self, query: str, per_page: int = 30, sort: str = 'updated', order: str = 'desc'
|
||||
self,
|
||||
query: str,
|
||||
per_page: int = 30,
|
||||
sort: str = 'updated',
|
||||
order: str = 'desc',
|
||||
public: bool = False,
|
||||
) -> list[Repository]:
|
||||
if public:
|
||||
# When public=True, query is a GitLab URL that we need to parse
|
||||
repo_path = self._parse_gitlab_url(query)
|
||||
if not repo_path:
|
||||
return [] # Invalid URL format
|
||||
|
||||
repository = await self.get_repository_details_from_repo_name(repo_path)
|
||||
return [repository]
|
||||
|
||||
return await self.get_paginated_repos(1, per_page, sort, None, query)
|
||||
|
||||
async def get_paginated_repos(
|
||||
self,
|
||||
page: int,
|
||||
per_page: int,
|
||||
sort: str,
|
||||
installation_id: str | None,
|
||||
query: str | None = None,
|
||||
) -> list[Repository]:
|
||||
url = f'{self.BASE_URL}/projects'
|
||||
order_by = {
|
||||
'pushed': 'last_activity_at',
|
||||
'updated': 'last_activity_at',
|
||||
'created': 'created_at',
|
||||
'full_name': 'name',
|
||||
}.get(sort, 'last_activity_at')
|
||||
|
||||
params = {
|
||||
'search': query,
|
||||
'per_page': per_page,
|
||||
'order_by': 'last_activity_at',
|
||||
'sort': order,
|
||||
'visibility': 'public',
|
||||
'page': str(page),
|
||||
'per_page': str(per_page),
|
||||
'order_by': order_by,
|
||||
'sort': 'desc', # GitLab uses sort for direction (asc/desc)
|
||||
'membership': True, # Include projects user is a member of
|
||||
}
|
||||
|
||||
response, _ = await self._make_request(url, params)
|
||||
repos = [
|
||||
Repository(
|
||||
id=str(repo.get('id')),
|
||||
full_name=repo.get('path_with_namespace'),
|
||||
stargazers_count=repo.get('star_count'),
|
||||
git_provider=ProviderType.GITLAB,
|
||||
is_public=True,
|
||||
owner_type=(
|
||||
OwnerType.ORGANIZATION
|
||||
if repo.get('namespace', {}).get('kind') == 'group'
|
||||
else OwnerType.USER
|
||||
),
|
||||
)
|
||||
for repo in response
|
||||
]
|
||||
if query:
|
||||
params['search'] = query
|
||||
params['search_namespaces'] = True
|
||||
|
||||
response, headers = await self._make_request(url, params)
|
||||
|
||||
next_link: str = headers.get('Link', '')
|
||||
repos = [
|
||||
self._parse_repository(repo, link_header=next_link) for repo in response
|
||||
]
|
||||
return repos
|
||||
|
||||
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
|
||||
async def get_all_repositories(
|
||||
self, sort: str, app_mode: AppMode
|
||||
) -> list[Repository]:
|
||||
MAX_REPOS = 1000
|
||||
PER_PAGE = 100 # Maximum allowed by GitLab API
|
||||
all_repos: list[dict] = []
|
||||
@ -310,21 +397,7 @@ class GitLabService(BaseGitService, GitService):
|
||||
|
||||
# Trim to MAX_REPOS if needed and convert to Repository objects
|
||||
all_repos = all_repos[:MAX_REPOS]
|
||||
return [
|
||||
Repository(
|
||||
id=str(repo.get('id')), # type: ignore[arg-type]
|
||||
full_name=repo.get('path_with_namespace'), # type: ignore[arg-type]
|
||||
stargazers_count=repo.get('star_count'),
|
||||
git_provider=ProviderType.GITLAB,
|
||||
is_public=repo.get('visibility') == 'public',
|
||||
owner_type=(
|
||||
OwnerType.ORGANIZATION
|
||||
if repo.get('namespace', {}).get('kind') == 'group'
|
||||
else OwnerType.USER
|
||||
),
|
||||
)
|
||||
for repo in all_repos
|
||||
]
|
||||
return [self._parse_repository(repo) for repo in all_repos]
|
||||
|
||||
async def get_suggested_tasks(self) -> list[SuggestedTask]:
|
||||
"""Get suggested tasks for the authenticated user across all repositories.
|
||||
@ -466,18 +539,7 @@ class GitLabService(BaseGitService, GitService):
|
||||
url = f'{self.BASE_URL}/projects/{encoded_name}'
|
||||
repo, _ = await self._make_request(url)
|
||||
|
||||
return Repository(
|
||||
id=str(repo.get('id')),
|
||||
full_name=repo.get('path_with_namespace'),
|
||||
stargazers_count=repo.get('star_count'),
|
||||
git_provider=ProviderType.GITLAB,
|
||||
is_public=repo.get('visibility') == 'public',
|
||||
owner_type=(
|
||||
OwnerType.ORGANIZATION
|
||||
if repo.get('namespace', {}).get('kind') == 'group'
|
||||
else OwnerType.USER
|
||||
),
|
||||
)
|
||||
return self._parse_repository(repo)
|
||||
|
||||
async def get_branches(self, repository: str) -> list[Branch]:
|
||||
"""Get branches for a repository"""
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import MappingProxyType
|
||||
from typing import Annotated, Any, Coroutine, Literal, overload
|
||||
from typing import Annotated, Any, Coroutine, Literal, cast, overload
|
||||
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
@ -22,6 +22,7 @@ from openhands.integrations.service_types import (
|
||||
AuthenticationError,
|
||||
Branch,
|
||||
GitService,
|
||||
InstallationsService,
|
||||
MicroagentParseError,
|
||||
ProviderType,
|
||||
Repository,
|
||||
@ -163,16 +164,61 @@ class ProviderHandler:
|
||||
service = self._get_service(provider)
|
||||
return await service.get_latest_token()
|
||||
|
||||
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
|
||||
async def get_github_installations(self) -> list[str]:
|
||||
service = cast(InstallationsService, self._get_service(ProviderType.GITHUB))
|
||||
try:
|
||||
return await service.get_installations()
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to get github installations {e}')
|
||||
|
||||
return []
|
||||
|
||||
async def get_bitbucket_workspaces(self) -> list[str]:
|
||||
service = cast(InstallationsService, self._get_service(ProviderType.BITBUCKET))
|
||||
try:
|
||||
return await service.get_installations()
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to get bitbucket workspaces {e}')
|
||||
|
||||
return []
|
||||
|
||||
async def get_repositories(
|
||||
self,
|
||||
sort: str,
|
||||
app_mode: AppMode,
|
||||
selected_provider: ProviderType | None,
|
||||
page: int | None,
|
||||
per_page: int | None,
|
||||
installation_id: str | None,
|
||||
) -> list[Repository]:
|
||||
"""
|
||||
Get repositories from providers
|
||||
"""
|
||||
|
||||
"""
|
||||
Get repositories from providers
|
||||
"""
|
||||
|
||||
if selected_provider:
|
||||
if not page or not per_page:
|
||||
logger.error('Failed to provider params for paginating repos')
|
||||
return []
|
||||
|
||||
service = self._get_service(selected_provider)
|
||||
try:
|
||||
return await service.get_paginated_repos(
|
||||
page, per_page, sort, installation_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'Error fetching repos from {selected_provider}: {e}')
|
||||
|
||||
return []
|
||||
|
||||
all_repos: list[Repository] = []
|
||||
for provider in self.provider_tokens:
|
||||
try:
|
||||
service = self._get_service(provider)
|
||||
service_repos = await service.get_repositories(sort, app_mode)
|
||||
service_repos = await service.get_all_repositories(sort, app_mode)
|
||||
all_repos.extend(service_repos)
|
||||
except Exception as e:
|
||||
logger.warning(f'Error fetching repos from {provider}: {e}')
|
||||
@ -196,17 +242,34 @@ class ProviderHandler:
|
||||
|
||||
async def search_repositories(
|
||||
self,
|
||||
selected_provider: ProviderType | None,
|
||||
query: str,
|
||||
per_page: int,
|
||||
sort: str,
|
||||
order: str,
|
||||
) -> list[Repository]:
|
||||
if selected_provider:
|
||||
service = self._get_service(selected_provider)
|
||||
public = self._is_repository_url(query, selected_provider)
|
||||
try:
|
||||
user_repos = await service.search_repositories(
|
||||
query, per_page, sort, order, public
|
||||
)
|
||||
return self._deduplicate_repositories(user_repos)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'Error searching repos from select provider {selected_provider}: {e}'
|
||||
)
|
||||
|
||||
return []
|
||||
|
||||
all_repos: list[Repository] = []
|
||||
for provider in self.provider_tokens:
|
||||
try:
|
||||
service = self._get_service(provider)
|
||||
public = self._is_repository_url(query, provider)
|
||||
service_repos = await service.search_repositories(
|
||||
query, per_page, sort, order
|
||||
query, per_page, sort, order, public
|
||||
)
|
||||
all_repos.extend(service_repos)
|
||||
except Exception as e:
|
||||
@ -215,6 +278,26 @@ class ProviderHandler:
|
||||
|
||||
return all_repos
|
||||
|
||||
def _is_repository_url(self, query: str, provider: ProviderType) -> bool:
|
||||
"""Check if the query is a repository URL."""
|
||||
custom_host = self.provider_tokens[provider].host
|
||||
custom_host_exists = custom_host and custom_host in query
|
||||
default_host_exists = self.PROVIDER_DOMAINS[provider] in query
|
||||
|
||||
return query.startswith(('http://', 'https://')) and (
|
||||
custom_host_exists or default_host_exists
|
||||
)
|
||||
|
||||
def _deduplicate_repositories(self, repos: list[Repository]) -> list[Repository]:
|
||||
"""Remove duplicate repositories based on full_name."""
|
||||
seen = set()
|
||||
unique_repos = []
|
||||
for repo in repos:
|
||||
if repo.full_name not in seen:
|
||||
seen.add(repo.id)
|
||||
unique_repos.append(repo)
|
||||
return unique_repos
|
||||
|
||||
async def set_event_stream_secrets(
|
||||
self,
|
||||
event_stream: EventStream,
|
||||
|
||||
@ -434,6 +434,12 @@ class BaseGitService(ABC):
|
||||
return microagents
|
||||
|
||||
|
||||
class InstallationsService(Protocol):
|
||||
async def get_installations(self) -> list[str]:
|
||||
"""Get installations for the service; repos live underneath these installations"""
|
||||
...
|
||||
|
||||
|
||||
class GitService(Protocol):
|
||||
"""Protocol defining the interface for Git service providers"""
|
||||
|
||||
@ -458,19 +464,28 @@ class GitService(Protocol):
|
||||
...
|
||||
|
||||
async def search_repositories(
|
||||
self,
|
||||
query: str,
|
||||
per_page: int,
|
||||
sort: str,
|
||||
order: str,
|
||||
self, query: str, per_page: int, sort: str, order: str, public: bool
|
||||
) -> list[Repository]:
|
||||
"""Search for repositories"""
|
||||
"""Search for public repositories"""
|
||||
...
|
||||
|
||||
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
|
||||
async def get_all_repositories(
|
||||
self, sort: str, app_mode: AppMode
|
||||
) -> list[Repository]:
|
||||
"""Get repositories for the authenticated user"""
|
||||
...
|
||||
|
||||
async def get_paginated_repos(
|
||||
self,
|
||||
page: int,
|
||||
per_page: int,
|
||||
sort: str,
|
||||
installation_id: str | None,
|
||||
query: str | None = None,
|
||||
) -> list[Repository]:
|
||||
"""Get a page of repositories for the authenticated user"""
|
||||
...
|
||||
|
||||
async def get_suggested_tasks(self) -> list[SuggestedTask]:
|
||||
"""Get suggested tasks for the authenticated user across all repositories"""
|
||||
...
|
||||
|
||||
@ -13,6 +13,7 @@ from openhands.integrations.provider import (
|
||||
from openhands.integrations.service_types import (
|
||||
AuthenticationError,
|
||||
Branch,
|
||||
ProviderType,
|
||||
Repository,
|
||||
SuggestedTask,
|
||||
UnknownException,
|
||||
@ -33,9 +34,43 @@ from openhands.server.user_auth import (
|
||||
app = APIRouter(prefix='/api/user', dependencies=get_dependencies())
|
||||
|
||||
|
||||
@app.get('/installations', response_model=list[str])
|
||||
async def get_user_installations(
|
||||
provider: ProviderType,
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
access_token: SecretStr | None = Depends(get_access_token),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
):
|
||||
if provider_tokens:
|
||||
client = ProviderHandler(
|
||||
provider_tokens=provider_tokens,
|
||||
external_auth_token=access_token,
|
||||
external_auth_id=user_id,
|
||||
)
|
||||
|
||||
if provider == ProviderType.GITHUB:
|
||||
return await client.get_github_installations()
|
||||
elif provider == ProviderType.BITBUCKET:
|
||||
return await client.get_bitbucket_workspaces()
|
||||
else:
|
||||
return JSONResponse(
|
||||
content=f"Provider {provider} doesn't support installations",
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content='Git provider token required. (such as GitHub).',
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
|
||||
@app.get('/repositories', response_model=list[Repository])
|
||||
async def get_user_repositories(
|
||||
sort: str = 'pushed',
|
||||
selected_provider: ProviderType | None = None,
|
||||
page: int | None = None,
|
||||
per_page: int | None = None,
|
||||
installation_id: str | None = None,
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
access_token: SecretStr | None = Depends(get_access_token),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
@ -48,7 +83,14 @@ async def get_user_repositories(
|
||||
)
|
||||
|
||||
try:
|
||||
return await client.get_repositories(sort, server_config.app_mode)
|
||||
return await client.get_repositories(
|
||||
sort,
|
||||
server_config.app_mode,
|
||||
selected_provider,
|
||||
page,
|
||||
per_page,
|
||||
installation_id,
|
||||
)
|
||||
|
||||
except AuthenticationError as e:
|
||||
logger.info(
|
||||
@ -119,17 +161,20 @@ async def search_repositories(
|
||||
per_page: int = 5,
|
||||
sort: str = 'stars',
|
||||
order: str = 'desc',
|
||||
selected_provider: ProviderType | None = None,
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
access_token: SecretStr | None = Depends(get_access_token),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
) -> list[Repository] | JSONResponse:
|
||||
if provider_tokens:
|
||||
client = ProviderHandler(
|
||||
provider_tokens=provider_tokens, external_auth_token=access_token
|
||||
provider_tokens=provider_tokens,
|
||||
external_auth_token=access_token,
|
||||
external_auth_id=user_id,
|
||||
)
|
||||
try:
|
||||
repos: list[Repository] = await client.search_repositories(
|
||||
query, per_page, sort, order
|
||||
selected_provider, query, per_page, sort, order
|
||||
)
|
||||
return repos
|
||||
|
||||
@ -146,10 +191,10 @@ async def search_repositories(
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'Returning 401 Unauthorized - GitHub token required for user_id: {user_id}'
|
||||
f'Returning 401 Unauthorized - Git provider token required for user_id: {user_id}'
|
||||
)
|
||||
return JSONResponse(
|
||||
content='GitHub token required.',
|
||||
content='Git provider token required.',
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
|
||||
@ -450,7 +450,7 @@ async def test_bitbucket_sort_parameter_mapping():
|
||||
]
|
||||
|
||||
# Call get_repositories with sort='pushed'
|
||||
await service.get_repositories('pushed', AppMode.SAAS)
|
||||
await service.get_all_repositories('pushed', AppMode.SAAS)
|
||||
|
||||
# Verify that the second call used 'updated_on' instead of 'pushed'
|
||||
assert mock_request.call_count == 2
|
||||
@ -520,7 +520,7 @@ async def test_bitbucket_pagination():
|
||||
]
|
||||
|
||||
# Call get_repositories
|
||||
repositories = await service.get_repositories('pushed', AppMode.SAAS)
|
||||
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
|
||||
|
||||
# Verify that all three requests were made (workspaces + 2 pages of repos)
|
||||
assert mock_request.call_count == 3
|
||||
@ -619,14 +619,14 @@ async def test_bitbucket_get_repositories_with_user_owner_type():
|
||||
with patch.object(service, '_fetch_paginated_data') as mock_fetch:
|
||||
mock_fetch.side_effect = [mock_workspaces, mock_repos]
|
||||
|
||||
repositories = await service.get_repositories('pushed', AppMode.SAAS)
|
||||
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
|
||||
|
||||
# Verify we got the expected number of repositories
|
||||
assert len(repositories) == 2
|
||||
|
||||
# Verify owner_type is correctly set for user repositories (private workspace)
|
||||
for repo in repositories:
|
||||
assert repo.owner_type == OwnerType.USER
|
||||
assert repo.owner_type == OwnerType.ORGANIZATION
|
||||
assert isinstance(repo, Repository)
|
||||
assert repo.git_provider == ServiceProviderType.BITBUCKET
|
||||
|
||||
@ -658,7 +658,7 @@ async def test_bitbucket_get_repositories_with_organization_owner_type():
|
||||
with patch.object(service, '_fetch_paginated_data') as mock_fetch:
|
||||
mock_fetch.side_effect = [mock_workspaces, mock_repos]
|
||||
|
||||
repositories = await service.get_repositories('pushed', AppMode.SAAS)
|
||||
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
|
||||
|
||||
# Verify we got the expected number of repositories
|
||||
assert len(repositories) == 2
|
||||
@ -706,7 +706,7 @@ async def test_bitbucket_get_repositories_mixed_owner_types():
|
||||
with patch.object(service, '_fetch_paginated_data') as mock_fetch:
|
||||
mock_fetch.side_effect = [mock_workspaces, mock_user_repos, mock_org_repos]
|
||||
|
||||
repositories = await service.get_repositories('pushed', AppMode.SAAS)
|
||||
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
|
||||
|
||||
# Verify we got repositories from both workspaces
|
||||
assert len(repositories) == 2
|
||||
@ -715,44 +715,10 @@ async def test_bitbucket_get_repositories_mixed_owner_types():
|
||||
user_repo = next(repo for repo in repositories if 'user-repo' in repo.full_name)
|
||||
org_repo = next(repo for repo in repositories if 'org-repo' in repo.full_name)
|
||||
|
||||
assert user_repo.owner_type == OwnerType.USER
|
||||
assert user_repo.owner_type == OwnerType.ORGANIZATION
|
||||
assert org_repo.owner_type == OwnerType.ORGANIZATION
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bitbucket_get_repositories_owner_type_fallback():
|
||||
"""Test that owner_type defaults to USER when workspace is private."""
|
||||
service = BitBucketService(token=SecretStr('test-token'))
|
||||
|
||||
# Mock repository data with private workspace (should default to USER)
|
||||
mock_workspaces = [{'slug': 'test-user', 'name': 'Test User'}]
|
||||
mock_repos = [
|
||||
{
|
||||
'uuid': 'repo-1',
|
||||
'slug': 'user-repo',
|
||||
'workspace': {'slug': 'test-user', 'is_private': True}, # Private workspace
|
||||
'is_private': False,
|
||||
'updated_on': '2023-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
'uuid': 'repo-2',
|
||||
'slug': 'another-user-repo',
|
||||
'workspace': {'slug': 'test-user', 'is_private': True}, # Private workspace
|
||||
'is_private': True,
|
||||
'updated_on': '2023-01-02T00:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
with patch.object(service, '_fetch_paginated_data') as mock_fetch:
|
||||
mock_fetch.side_effect = [mock_workspaces, mock_repos]
|
||||
|
||||
repositories = await service.get_repositories('pushed', AppMode.SAAS)
|
||||
|
||||
# Verify all repositories default to USER owner_type for private workspaces
|
||||
for repo in repositories:
|
||||
assert repo.owner_type == OwnerType.USER
|
||||
|
||||
|
||||
# Setup.py Bitbucket Token Tests
|
||||
@patch('openhands.core.setup.call_async_from_sync')
|
||||
@patch('openhands.core.setup.get_file_store')
|
||||
|
||||
@ -112,9 +112,9 @@ async def test_github_get_repositories_with_user_owner_type():
|
||||
|
||||
with (
|
||||
patch.object(service, '_fetch_paginated_repos', return_value=mock_repo_data),
|
||||
patch.object(service, 'get_installation_ids', return_value=[123]),
|
||||
patch.object(service, 'get_installations', return_value=[123]),
|
||||
):
|
||||
repositories = await service.get_repositories('pushed', AppMode.SAAS)
|
||||
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
|
||||
|
||||
# Verify we got the expected number of repositories
|
||||
assert len(repositories) == 2
|
||||
@ -151,9 +151,9 @@ async def test_github_get_repositories_with_organization_owner_type():
|
||||
|
||||
with (
|
||||
patch.object(service, '_fetch_paginated_repos', return_value=mock_repo_data),
|
||||
patch.object(service, 'get_installation_ids', return_value=[123]),
|
||||
patch.object(service, 'get_installations', return_value=[123]),
|
||||
):
|
||||
repositories = await service.get_repositories('pushed', AppMode.SAAS)
|
||||
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
|
||||
|
||||
# Verify we got the expected number of repositories
|
||||
assert len(repositories) == 2
|
||||
@ -190,9 +190,9 @@ async def test_github_get_repositories_mixed_owner_types():
|
||||
|
||||
with (
|
||||
patch.object(service, '_fetch_paginated_repos', return_value=mock_repo_data),
|
||||
patch.object(service, 'get_installation_ids', return_value=[123]),
|
||||
patch.object(service, 'get_installations', return_value=[123]),
|
||||
):
|
||||
repositories = await service.get_repositories('pushed', AppMode.SAAS)
|
||||
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
|
||||
|
||||
# Verify we got the expected number of repositories
|
||||
assert len(repositories) == 2
|
||||
@ -237,9 +237,9 @@ async def test_github_get_repositories_owner_type_fallback():
|
||||
|
||||
with (
|
||||
patch.object(service, '_fetch_paginated_repos', return_value=mock_repo_data),
|
||||
patch.object(service, 'get_installation_ids', return_value=[123]),
|
||||
patch.object(service, 'get_installations', return_value=[123]),
|
||||
):
|
||||
repositories = await service.get_repositories('pushed', AppMode.SAAS)
|
||||
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
|
||||
|
||||
# Verify all repositories default to USER owner_type
|
||||
for repo in repositories:
|
||||
|
||||
@ -37,7 +37,7 @@ async def test_gitlab_get_repositories_with_user_owner_type():
|
||||
# Mock the pagination response
|
||||
mock_request.side_effect = [(mock_repos, {'Link': ''})] # No next page
|
||||
|
||||
repositories = await service.get_repositories('pushed', AppMode.SAAS)
|
||||
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
|
||||
|
||||
# Verify we got the expected number of repositories
|
||||
assert len(repositories) == 2
|
||||
@ -76,7 +76,7 @@ async def test_gitlab_get_repositories_with_organization_owner_type():
|
||||
# Mock the pagination response
|
||||
mock_request.side_effect = [(mock_repos, {'Link': ''})] # No next page
|
||||
|
||||
repositories = await service.get_repositories('pushed', AppMode.SAAS)
|
||||
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
|
||||
|
||||
# Verify we got the expected number of repositories
|
||||
assert len(repositories) == 2
|
||||
@ -115,7 +115,7 @@ async def test_gitlab_get_repositories_mixed_owner_types():
|
||||
# Mock the pagination response
|
||||
mock_request.side_effect = [(mock_repos, {'Link': ''})] # No next page
|
||||
|
||||
repositories = await service.get_repositories('pushed', AppMode.SAAS)
|
||||
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
|
||||
|
||||
# Verify we got the expected number of repositories
|
||||
assert len(repositories) == 2
|
||||
@ -162,8 +162,281 @@ async def test_gitlab_get_repositories_owner_type_fallback():
|
||||
# Mock the pagination response
|
||||
mock_request.side_effect = [(mock_repos, {'Link': ''})] # No next page
|
||||
|
||||
repositories = await service.get_repositories('pushed', AppMode.SAAS)
|
||||
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
|
||||
|
||||
# Verify all repositories default to USER owner_type
|
||||
for repo in repositories:
|
||||
assert repo.owner_type == OwnerType.USER
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitlab_search_repositories_uses_membership_and_min_access_level():
|
||||
"""Test that search_repositories uses membership and min_access_level for non-public searches."""
|
||||
service = GitLabService(token=SecretStr('test-token'))
|
||||
|
||||
# Mock repository data
|
||||
mock_repos = [
|
||||
{
|
||||
'id': 123,
|
||||
'path_with_namespace': 'test-user/search-repo1',
|
||||
'star_count': 10,
|
||||
'visibility': 'private',
|
||||
'namespace': {'kind': 'user'},
|
||||
},
|
||||
{
|
||||
'id': 456,
|
||||
'path_with_namespace': 'test-org/search-repo2',
|
||||
'star_count': 25,
|
||||
'visibility': 'private',
|
||||
'namespace': {'kind': 'group'},
|
||||
},
|
||||
]
|
||||
|
||||
with patch.object(service, '_make_request') as mock_request:
|
||||
mock_request.return_value = (mock_repos, {})
|
||||
|
||||
# Test non-public search (should use membership and min_access_level)
|
||||
repositories = await service.search_repositories(
|
||||
query='test-query', per_page=30, sort='updated', order='desc', public=False
|
||||
)
|
||||
|
||||
# Verify the request was made with correct parameters
|
||||
mock_request.assert_called_once()
|
||||
call_args = mock_request.call_args
|
||||
url = call_args[0][0]
|
||||
params = call_args[0][1] # params is the second positional argument
|
||||
|
||||
assert url == f'{service.BASE_URL}/projects'
|
||||
assert params['search'] == 'test-query'
|
||||
assert params['per_page'] == '30' # GitLab service converts to string
|
||||
assert params['order_by'] == 'last_activity_at'
|
||||
assert params['sort'] == 'desc'
|
||||
assert params['membership'] is True
|
||||
assert params['search_namespaces'] is True # Added by implementation
|
||||
assert 'min_access_level' not in params # Not set by current implementation
|
||||
assert 'owned' not in params
|
||||
assert 'visibility' not in params
|
||||
|
||||
# Verify we got the expected repositories
|
||||
assert len(repositories) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitlab_search_repositories_public_search_legacy():
|
||||
"""Test that search_repositories returns empty list for non-URL queries when public=True."""
|
||||
service = GitLabService(token=SecretStr('test-token'))
|
||||
|
||||
with patch.object(service, '_make_request') as mock_request:
|
||||
# Test public search with non-URL query (should return empty list)
|
||||
repositories = await service.search_repositories(
|
||||
query='public-query', per_page=20, sort='updated', order='asc', public=True
|
||||
)
|
||||
|
||||
# Verify no request was made since it's not a valid URL
|
||||
mock_request.assert_not_called()
|
||||
|
||||
# Verify we got empty list
|
||||
assert len(repositories) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitlab_search_repositories_url_parsing():
|
||||
"""Test that search_repositories correctly parses GitLab URLs when public=True."""
|
||||
service = GitLabService(token=SecretStr('test-token'))
|
||||
|
||||
# Test URL parsing method directly
|
||||
assert service._parse_gitlab_url('https://gitlab.com/group/repo') == 'group/repo'
|
||||
assert (
|
||||
service._parse_gitlab_url('https://gitlab.com/group/subgroup/repo')
|
||||
== 'group/subgroup/repo'
|
||||
)
|
||||
assert (
|
||||
service._parse_gitlab_url('https://gitlab.example.com/org/team/project')
|
||||
== 'org/team/project'
|
||||
)
|
||||
assert service._parse_gitlab_url('https://gitlab.com/group/repo/') == 'group/repo'
|
||||
assert (
|
||||
service._parse_gitlab_url('https://gitlab.com/group/') is None
|
||||
) # Missing repo
|
||||
assert service._parse_gitlab_url('https://gitlab.com/') is None # Empty path
|
||||
assert service._parse_gitlab_url('invalid-url') is None # Invalid URL
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitlab_search_repositories_public_url_lookup():
|
||||
"""Test that search_repositories looks up specific repository when public=True."""
|
||||
service = GitLabService(token=SecretStr('test-token'))
|
||||
|
||||
# Mock repository data
|
||||
|
||||
with patch.object(
|
||||
service, 'get_repository_details_from_repo_name'
|
||||
) as mock_get_repo:
|
||||
mock_get_repo.return_value = Repository(
|
||||
id='123',
|
||||
full_name='group/repo',
|
||||
stargazers_count=50,
|
||||
git_provider=ProviderType.GITLAB,
|
||||
is_public=True,
|
||||
owner_type=OwnerType.ORGANIZATION,
|
||||
)
|
||||
|
||||
# Test with valid GitLab URL
|
||||
repositories = await service.search_repositories(
|
||||
query='https://gitlab.com/group/repo', public=True
|
||||
)
|
||||
|
||||
# Verify the repository lookup was called with correct path
|
||||
mock_get_repo.assert_called_once_with('group/repo')
|
||||
|
||||
# Verify we got the expected repository
|
||||
assert len(repositories) == 1
|
||||
assert repositories[0].full_name == 'group/repo'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitlab_search_repositories_public_url_lookup_with_subgroup():
|
||||
"""Test that search_repositories handles subgroups correctly when public=True."""
|
||||
service = GitLabService(token=SecretStr('test-token'))
|
||||
|
||||
with patch.object(
|
||||
service, 'get_repository_details_from_repo_name'
|
||||
) as mock_get_repo:
|
||||
mock_get_repo.return_value = Repository(
|
||||
id='456',
|
||||
full_name='group/subgroup/repo',
|
||||
stargazers_count=25,
|
||||
git_provider=ProviderType.GITLAB,
|
||||
is_public=True,
|
||||
owner_type=OwnerType.ORGANIZATION,
|
||||
)
|
||||
|
||||
# Test with GitLab URL containing subgroup
|
||||
repositories = await service.search_repositories(
|
||||
query='https://gitlab.example.com/group/subgroup/repo', public=True
|
||||
)
|
||||
|
||||
# Verify the repository lookup was called with correct path
|
||||
mock_get_repo.assert_called_once_with('group/subgroup/repo')
|
||||
|
||||
# Verify we got the expected repository
|
||||
assert len(repositories) == 1
|
||||
assert repositories[0].full_name == 'group/subgroup/repo'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitlab_search_repositories_public_url_not_found():
|
||||
"""Test that search_repositories returns empty list when repository doesn't exist."""
|
||||
service = GitLabService(token=SecretStr('test-token'))
|
||||
|
||||
with patch.object(
|
||||
service, 'get_repository_details_from_repo_name'
|
||||
) as mock_get_repo:
|
||||
# Simulate repository not found
|
||||
mock_get_repo.side_effect = Exception('Repository not found')
|
||||
|
||||
# Test with valid GitLab URL but non-existent repository
|
||||
# The current implementation doesn't catch exceptions, so we expect it to be raised
|
||||
with pytest.raises(Exception, match='Repository not found'):
|
||||
await service.search_repositories(
|
||||
query='https://gitlab.com/nonexistent/repo', public=True
|
||||
)
|
||||
|
||||
# Verify the repository lookup was attempted
|
||||
mock_get_repo.assert_called_once_with('nonexistent/repo')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitlab_search_repositories_public_invalid_url():
|
||||
"""Test that search_repositories returns empty list for invalid URLs."""
|
||||
service = GitLabService(token=SecretStr('test-token'))
|
||||
|
||||
with patch.object(
|
||||
service, 'get_repository_details_from_repo_name'
|
||||
) as mock_get_repo:
|
||||
# Test with invalid URL
|
||||
repositories = await service.search_repositories(
|
||||
query='invalid-url', public=True
|
||||
)
|
||||
|
||||
# Verify no repository lookup was attempted
|
||||
mock_get_repo.assert_not_called()
|
||||
|
||||
# Verify we got empty list
|
||||
assert len(repositories) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitlab_search_repositories_formats_search_query():
|
||||
"""Test that search_repositories properly formats search queries with multiple terms."""
|
||||
service = GitLabService(token=SecretStr('test-token'))
|
||||
|
||||
# Mock repository data
|
||||
mock_repos = [
|
||||
{
|
||||
'id': 123,
|
||||
'path_with_namespace': 'group/repo',
|
||||
'star_count': 50,
|
||||
'visibility': 'private',
|
||||
'namespace': {'kind': 'group'},
|
||||
},
|
||||
]
|
||||
|
||||
with patch.object(service, '_make_request') as mock_request:
|
||||
mock_request.return_value = (mock_repos, {})
|
||||
|
||||
# Test search with multiple terms (should format with + separator)
|
||||
repositories = await service.search_repositories(
|
||||
query='my project name', public=False
|
||||
)
|
||||
|
||||
# Verify the request was made with correct parameters
|
||||
mock_request.assert_called_once()
|
||||
call_args = mock_request.call_args
|
||||
url = call_args[0][0]
|
||||
params = call_args[0][1]
|
||||
|
||||
assert url == f'{service.BASE_URL}/projects'
|
||||
assert (
|
||||
params['search'] == 'my project name'
|
||||
) # Current implementation doesn't format spaces
|
||||
assert params['membership'] is True
|
||||
assert params['search_namespaces'] is True # Added by implementation
|
||||
|
||||
# Verify we got the expected repositories
|
||||
assert len(repositories) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitlab_search_repositories_single_term_query():
|
||||
"""Test that search_repositories handles single term queries correctly."""
|
||||
service = GitLabService(token=SecretStr('test-token'))
|
||||
|
||||
# Mock repository data
|
||||
mock_repos = [
|
||||
{
|
||||
'id': 456,
|
||||
'path_with_namespace': 'user/single-repo',
|
||||
'star_count': 25,
|
||||
'visibility': 'private',
|
||||
'namespace': {'kind': 'user'},
|
||||
},
|
||||
]
|
||||
|
||||
with patch.object(service, '_make_request') as mock_request:
|
||||
mock_request.return_value = (mock_repos, {})
|
||||
|
||||
# Test search with single term (should remain unchanged)
|
||||
repositories = await service.search_repositories(
|
||||
query='singleterm', public=False
|
||||
)
|
||||
|
||||
# Verify the request was made with correct parameters
|
||||
mock_request.assert_called_once()
|
||||
call_args = mock_request.call_args
|
||||
params = call_args[0][1]
|
||||
|
||||
assert params['search'] == 'singleterm' # No change for single term
|
||||
|
||||
# Verify we got the expected repositories
|
||||
assert len(repositories) == 1
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user