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:
Rohit Malhotra 2025-08-06 13:03:46 -04:00 committed by GitHub
parent 9c83a5623f
commit 3f327a940f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 3629 additions and 1347 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View 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}
/>
);
}

View 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}
/>
);
}

View 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>
)}
</>
);
}

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -67,9 +67,9 @@ prepareApp().then(() =>
<QueryClientProvider client={queryClient}>
<HydratedRouter />
<PosthogInit />
<div id="modal-portal-exit" />
</QueryClientProvider>
</Provider>
<div id="modal-portal-exit" />
</StrictMode>,
);
}),

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

View 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,
};
}

View 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,
};
};

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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