mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
Implement branch pagination for repository selection and improve UI async dropdown behaviour (#10588)
Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
This commit is contained in:
parent
5b35203253
commit
edc95141f7
@ -54,12 +54,14 @@ const MOCK_RESPOSITORIES: GitRepository[] = [
|
||||
full_name: "rbren/polaris",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
main_branch: "main",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
full_name: "All-Hands-AI/OpenHands",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
main_branch: "main",
|
||||
},
|
||||
];
|
||||
|
||||
@ -99,16 +101,15 @@ describe("RepoConnector", () => {
|
||||
|
||||
// First select the provider
|
||||
const providerDropdown = await waitFor(() =>
|
||||
screen.getByText("Select Provider"),
|
||||
screen.getByTestId("git-provider-dropdown"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("Github"));
|
||||
await userEvent.click(screen.getByText("GitHub"));
|
||||
|
||||
// Then interact with the repository dropdown
|
||||
const repoDropdown = await waitFor(() =>
|
||||
screen.getByTestId("repo-dropdown"),
|
||||
const repoInput = await waitFor(() =>
|
||||
screen.getByTestId("git-repo-dropdown"),
|
||||
);
|
||||
const repoInput = within(repoDropdown).getByRole("combobox");
|
||||
await userEvent.click(repoInput);
|
||||
|
||||
// Wait for the options to be loaded and displayed
|
||||
@ -134,23 +135,23 @@ describe("RepoConnector", () => {
|
||||
expect(launchButton).toBeDisabled();
|
||||
|
||||
// Mock the repository branches API call
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({ branches: [
|
||||
{ name: "main", commit_sha: "123", protected: false },
|
||||
{ name: "develop", commit_sha: "456", protected: false },
|
||||
]);
|
||||
], has_next_page: false, current_page: 1, per_page: 30, total_count: 2 });
|
||||
|
||||
// First select the provider
|
||||
const providerDropdown = await waitFor(() =>
|
||||
screen.getByText("Select Provider"),
|
||||
screen.getByTestId("git-provider-dropdown"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("Github"));
|
||||
await userEvent.click(screen.getByText("GitHub"));
|
||||
|
||||
// Then select the repository
|
||||
const repoDropdown = await waitFor(() =>
|
||||
screen.getByTestId("repo-dropdown"),
|
||||
const repoInput = await waitFor(() =>
|
||||
screen.getByTestId("git-repo-dropdown"),
|
||||
);
|
||||
const repoInput = within(repoDropdown).getByRole("combobox");
|
||||
|
||||
await userEvent.click(repoInput);
|
||||
|
||||
// Wait for the options to be loaded and displayed
|
||||
@ -161,7 +162,8 @@ describe("RepoConnector", () => {
|
||||
|
||||
// Wait for the branch to be auto-selected
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("main")).toBeInTheDocument();
|
||||
const branchInput = screen.getByTestId("git-branch-dropdown-input");
|
||||
expect(branchInput).toHaveValue("main");
|
||||
});
|
||||
|
||||
expect(launchButton).toBeEnabled();
|
||||
@ -224,6 +226,19 @@ describe("RepoConnector", () => {
|
||||
|
||||
it("should create a conversation and redirect with the selected repo when pressing the launch button", async () => {
|
||||
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
|
||||
createConversationSpy.mockResolvedValue({
|
||||
conversation_id: "mock-conversation-id",
|
||||
title: "Test Conversation",
|
||||
selected_repository: "user/repo1",
|
||||
selected_branch: "main",
|
||||
git_provider: "github",
|
||||
last_updated_at: "2023-01-01T00:00:00Z",
|
||||
created_at: "2023-01-01T00:00:00Z",
|
||||
status: "STARTING",
|
||||
runtime_status: null,
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
});
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
@ -244,23 +259,23 @@ describe("RepoConnector", () => {
|
||||
expect(createConversationSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Mock the repository branches API call
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({ branches: [
|
||||
{ name: "main", commit_sha: "123", protected: false },
|
||||
{ name: "develop", commit_sha: "456", protected: false },
|
||||
]);
|
||||
], has_next_page: false, current_page: 1, per_page: 30, total_count: 2 });
|
||||
|
||||
// First select the provider
|
||||
const providerDropdown = await waitFor(() =>
|
||||
screen.getByText("Select Provider"),
|
||||
screen.getByTestId("git-provider-dropdown"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("Github"));
|
||||
await userEvent.click(screen.getByText("GitHub"));
|
||||
|
||||
// Then select the repository
|
||||
const repoDropdown = await waitFor(() =>
|
||||
within(repoConnector).getByTestId("repo-dropdown"),
|
||||
const repoInput = await waitFor(() =>
|
||||
within(repoConnector).getByTestId("git-repo-dropdown"),
|
||||
);
|
||||
const repoInput = within(repoDropdown).getByRole("combobox");
|
||||
|
||||
await userEvent.click(repoInput);
|
||||
|
||||
// Wait for the options to be loaded and displayed
|
||||
@ -271,7 +286,8 @@ describe("RepoConnector", () => {
|
||||
|
||||
// Wait for the branch to be auto-selected
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("main")).toBeInTheDocument();
|
||||
const branchInput = screen.getByTestId("git-branch-dropdown-input");
|
||||
expect(branchInput).toHaveValue("main");
|
||||
});
|
||||
|
||||
await userEvent.click(launchButton);
|
||||
@ -288,6 +304,8 @@ describe("RepoConnector", () => {
|
||||
});
|
||||
|
||||
it("should change the launch button text to 'Loading...' when creating a conversation", async () => {
|
||||
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
|
||||
createConversationSpy.mockImplementation(() => new Promise(() => {})); // Never resolves to keep loading state
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
@ -298,10 +316,10 @@ describe("RepoConnector", () => {
|
||||
});
|
||||
|
||||
// Mock the repository branches API call
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({ branches: [
|
||||
{ name: "main", commit_sha: "123", protected: false },
|
||||
{ name: "develop", commit_sha: "456", protected: false },
|
||||
]);
|
||||
], has_next_page: false, current_page: 1, per_page: 30, total_count: 2 });
|
||||
|
||||
renderRepoConnector();
|
||||
|
||||
@ -309,16 +327,16 @@ describe("RepoConnector", () => {
|
||||
|
||||
// First select the provider
|
||||
const providerDropdown = await waitFor(() =>
|
||||
screen.getByText("Select Provider"),
|
||||
screen.getByTestId("git-provider-dropdown"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("Github"));
|
||||
await userEvent.click(screen.getByText("GitHub"));
|
||||
|
||||
// Then select the repository
|
||||
const repoDropdown = await waitFor(() =>
|
||||
screen.getByTestId("repo-dropdown"),
|
||||
const repoInput = await waitFor(() =>
|
||||
screen.getByTestId("git-repo-dropdown"),
|
||||
);
|
||||
const repoInput = within(repoDropdown).getByRole("combobox");
|
||||
|
||||
await userEvent.click(repoInput);
|
||||
|
||||
// Wait for the options to be loaded and displayed
|
||||
@ -329,7 +347,8 @@ describe("RepoConnector", () => {
|
||||
|
||||
// Wait for the branch to be auto-selected
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("main")).toBeInTheDocument();
|
||||
const branchInput = screen.getByTestId("git-branch-dropdown-input");
|
||||
expect(branchInput).toHaveValue("main");
|
||||
});
|
||||
|
||||
await userEvent.click(launchButton);
|
||||
@ -358,7 +377,7 @@ describe("RepoConnector", () => {
|
||||
const goToSettingsButton = await screen.findByTestId(
|
||||
"navigate-to-settings-button",
|
||||
);
|
||||
const dropdown = screen.queryByTestId("repo-dropdown");
|
||||
const dropdown = screen.queryByTestId("git-repo-dropdown");
|
||||
const launchButton = screen.queryByTestId("repo-launch-button");
|
||||
const providerLinks = screen.queryAllByText(/add git(hub|lab) repos/i);
|
||||
|
||||
|
||||
@ -151,7 +151,7 @@ describe("RepositorySelectionForm", () => {
|
||||
});
|
||||
|
||||
renderForm();
|
||||
expect(await screen.findByTestId("repo-dropdown")).toBeInTheDocument();
|
||||
expect(await screen.findByTestId("git-repo-dropdown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error message when repository fetch fails", async () => {
|
||||
@ -168,10 +168,10 @@ describe("RepositorySelectionForm", () => {
|
||||
renderForm();
|
||||
|
||||
expect(
|
||||
await screen.findByTestId("repo-dropdown-error"),
|
||||
await screen.findByTestId("dropdown-error"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("HOME$FAILED_TO_LOAD_REPOSITORIES"),
|
||||
screen.getByText("Failed to load data"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@ -231,11 +231,7 @@ describe("RepositorySelectionForm", () => {
|
||||
|
||||
renderForm();
|
||||
|
||||
const dropdown = await screen.findByTestId("repo-dropdown");
|
||||
const input = dropdown.querySelector(
|
||||
'input[type="text"]',
|
||||
) as HTMLInputElement;
|
||||
expect(input).toBeInTheDocument();
|
||||
const input = await screen.findByTestId("git-repo-dropdown");
|
||||
|
||||
await userEvent.type(input, "https://github.com/kubernetes/kubernetes");
|
||||
expect(searchGitReposSpy).toHaveBeenLastCalledWith(
|
||||
@ -270,11 +266,7 @@ describe("RepositorySelectionForm", () => {
|
||||
|
||||
renderForm();
|
||||
|
||||
const dropdown = await screen.findByTestId("repo-dropdown");
|
||||
const input = dropdown.querySelector(
|
||||
'input[type="text"]',
|
||||
) as HTMLInputElement;
|
||||
expect(input).toBeInTheDocument();
|
||||
const input = await screen.findByTestId("git-repo-dropdown");
|
||||
|
||||
await userEvent.type(input, "https://github.com/kubernetes/kubernetes");
|
||||
expect(searchGitReposSpy).toHaveBeenLastCalledWith(
|
||||
|
||||
@ -1272,9 +1272,11 @@ describe("MicroagentManagement", () => {
|
||||
// Add microagent integration tests
|
||||
describe("Add microagent functionality", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue(({ branches: [
|
||||
{ name: "main", commit_sha: "abc123", protected: false },
|
||||
]);
|
||||
], has_next_page: false, current_page: 1, per_page: 30, total_count: [
|
||||
{ name: "main", commit_sha: "abc123", protected: false },
|
||||
].length }) );
|
||||
});
|
||||
|
||||
it("should render add microagent button", async () => {
|
||||
@ -1960,9 +1962,11 @@ describe("MicroagentManagement", () => {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue(({ branches: [
|
||||
{ name: "main", commit_sha: "abc123", protected: false },
|
||||
]);
|
||||
], has_next_page: false, current_page: 1, per_page: 30, total_count: [
|
||||
{ name: "main", commit_sha: "abc123", protected: false },
|
||||
].length }) );
|
||||
});
|
||||
|
||||
it("should render update microagent modal when updateMicroagentModalVisible is true", async () => {
|
||||
@ -2522,10 +2526,16 @@ describe("MicroagentManagement", () => {
|
||||
// Mock branch API
|
||||
const branchesSpy = vi
|
||||
.spyOn(OpenHands, "getRepositoryBranches")
|
||||
.mockResolvedValue([
|
||||
{ name: "main", commit_sha: "abc123", protected: false },
|
||||
{ name: "develop", commit_sha: "def456", protected: false },
|
||||
]);
|
||||
.mockResolvedValue({
|
||||
branches: [
|
||||
{ name: "main", commit_sha: "abc123", protected: false },
|
||||
{ name: "develop", commit_sha: "def456", protected: false },
|
||||
],
|
||||
has_next_page: false,
|
||||
current_page: 1,
|
||||
per_page: 30,
|
||||
total_count: 2,
|
||||
});
|
||||
|
||||
// Mock other APIs
|
||||
const getRepositoryMicroagentsSpy = vi.spyOn(
|
||||
|
||||
@ -37,34 +37,27 @@ const selectRepository = async (repoName: string) => {
|
||||
|
||||
// First select the provider
|
||||
const providerDropdown = await waitFor(() =>
|
||||
screen.getByText("Select Provider"),
|
||||
screen.getByTestId("git-provider-dropdown"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("Github"));
|
||||
await userEvent.click(screen.getByText("GitHub"));
|
||||
|
||||
// Then select the repository
|
||||
const dropdown = within(repoConnector).getByTestId("repo-dropdown");
|
||||
const repoInput = within(dropdown).getByRole("combobox");
|
||||
const repoInput = within(repoConnector).getByTestId("git-repo-dropdown");
|
||||
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 dropdownMenu = screen.getByTestId("git-repo-dropdown-menu");
|
||||
expect(within(dropdownMenu).getByText(repoName)).toBeInTheDocument();
|
||||
});
|
||||
const options = screen.getAllByText(repoName);
|
||||
const dropdownOption = options.find(
|
||||
(el) => el.getAttribute("role") === "option",
|
||||
);
|
||||
await userEvent.click(dropdownOption!);
|
||||
const dropdownMenu = screen.getByTestId("git-repo-dropdown-menu");
|
||||
await userEvent.click(within(dropdownMenu).getByText(repoName));
|
||||
|
||||
// Wait for the branch to be auto-selected
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("main")).toBeInTheDocument();
|
||||
const branchInput = screen.getByTestId("git-branch-dropdown-input");
|
||||
expect(branchInput).toHaveValue("main");
|
||||
});
|
||||
};
|
||||
|
||||
@ -85,12 +78,14 @@ const MOCK_RESPOSITORIES: GitRepository[] = [
|
||||
full_name: "octocat/hello-world",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
main_branch: "main",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
full_name: "octocat/earth",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
main_branch: "main",
|
||||
},
|
||||
];
|
||||
|
||||
@ -140,10 +135,10 @@ describe("HomeScreen", () => {
|
||||
await screen.findAllByTestId("task-launch-button");
|
||||
|
||||
// Mock the repository branches API call
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({ branches: [
|
||||
{ name: "main", commit_sha: "123", protected: false },
|
||||
{ name: "develop", commit_sha: "456", protected: false },
|
||||
]);
|
||||
], has_next_page: false, current_page: 1, per_page: 30, total_count: 2 });
|
||||
|
||||
// Select a repository to enable the repo launch button
|
||||
await selectRepository("octocat/hello-world");
|
||||
|
||||
1939
frontend/package-lock.json
generated
1939
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -27,6 +27,7 @@
|
||||
"axios": "^1.11.0",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"downshift": "^9.0.10",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"framer-motion": "^12.23.12",
|
||||
"i18next": "^25.4.2",
|
||||
@ -46,7 +47,6 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.8.2",
|
||||
"react-select": "^5.10.2",
|
||||
"react-syntax-highlighter": "^15.6.6",
|
||||
"react-textarea-autosize": "^8.5.9",
|
||||
"remark-breaks": "^4.0.0",
|
||||
|
||||
@ -21,7 +21,12 @@ import {
|
||||
} from "./open-hands.types";
|
||||
import { openHands } from "./open-hands-axios";
|
||||
import { ApiSettings, PostApiSettings, Provider } from "#/types/settings";
|
||||
import { GitUser, GitRepository, Branch } from "#/types/git";
|
||||
import {
|
||||
GitUser,
|
||||
GitRepository,
|
||||
PaginatedBranchesResponse,
|
||||
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";
|
||||
@ -567,14 +572,38 @@ class OpenHands {
|
||||
};
|
||||
}
|
||||
|
||||
static async getRepositoryBranches(repository: string): Promise<Branch[]> {
|
||||
const { data } = await openHands.get<Branch[]>(
|
||||
`/api/user/repository/branches?repository=${encodeURIComponent(repository)}`,
|
||||
static async getRepositoryBranches(
|
||||
repository: string,
|
||||
page: number = 1,
|
||||
perPage: number = 30,
|
||||
): Promise<PaginatedBranchesResponse> {
|
||||
const { data } = await openHands.get<PaginatedBranchesResponse>(
|
||||
`/api/user/repository/branches?repository=${encodeURIComponent(repository)}&page=${page}&per_page=${perPage}`,
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
static async searchRepositoryBranches(
|
||||
repository: string,
|
||||
query: string,
|
||||
perPage: number = 30,
|
||||
selectedProvider?: Provider,
|
||||
): Promise<Branch[]> {
|
||||
const { data } = await openHands.get<Branch[]>(
|
||||
`/api/user/search/branches`,
|
||||
{
|
||||
params: {
|
||||
repository,
|
||||
query,
|
||||
per_page: perPage,
|
||||
selected_provider: selectedProvider,
|
||||
},
|
||||
},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the available microagents associated with a conversation
|
||||
* @param conversationId The ID of the conversation
|
||||
|
||||
@ -1,69 +0,0 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,65 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import { StylesConfig } from "react-select";
|
||||
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;
|
||||
classNamePrefix?: string;
|
||||
styles?: StylesConfig<SelectOption, false>;
|
||||
}
|
||||
|
||||
export function GitProviderDropdown({
|
||||
providers,
|
||||
value,
|
||||
placeholder = "Select Provider",
|
||||
className,
|
||||
errorMessage,
|
||||
disabled = false,
|
||||
isLoading = false,
|
||||
onChange,
|
||||
classNamePrefix,
|
||||
styles,
|
||||
}: 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}
|
||||
classNamePrefix={classNamePrefix}
|
||||
styles={styles}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,208 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Provider } from "../../types/settings";
|
||||
import { useGitRepositories } from "../../hooks/query/use-git-repositories";
|
||||
import { useSearchRepositories } from "../../hooks/query/use-search-repositories";
|
||||
import { useDebounce } from "../../hooks/use-debounce";
|
||||
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;
|
||||
}
|
||||
|
||||
export function GitRepositoryDropdown({
|
||||
provider,
|
||||
value,
|
||||
placeholder = "Search repositories...",
|
||||
className,
|
||||
errorMessage,
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: GitRepositoryDropdownProps) {
|
||||
const { t } = useTranslation();
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const debouncedSearchInput = useDebounce(searchInput, 300);
|
||||
|
||||
// Process search input to handle URLs
|
||||
const processedSearchInput = useMemo(() => {
|
||||
if (debouncedSearchInput.startsWith("https://")) {
|
||||
const match = debouncedSearchInput.match(
|
||||
/https:\/\/[^/]+\/([^/]+\/[^/]+)/,
|
||||
);
|
||||
return match ? match[1] : debouncedSearchInput;
|
||||
}
|
||||
return debouncedSearchInput;
|
||||
}, [debouncedSearchInput]);
|
||||
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
isError,
|
||||
} = useGitRepositories({
|
||||
provider,
|
||||
enabled: !disabled,
|
||||
});
|
||||
|
||||
// Search query for processed input (handles URLs)
|
||||
const { data: searchData, isLoading: isSearchLoading } =
|
||||
useSearchRepositories(processedSearchInput, provider);
|
||||
|
||||
const allOptions: AsyncSelectOption[] = useMemo(
|
||||
() =>
|
||||
data?.pages
|
||||
? data.pages.flatMap((page) =>
|
||||
page.data.map((repo) => ({
|
||||
value: repo.id,
|
||||
label: repo.full_name,
|
||||
})),
|
||||
)
|
||||
: [],
|
||||
[data],
|
||||
);
|
||||
|
||||
const searchOptions: AsyncSelectOption[] = useMemo(
|
||||
() =>
|
||||
searchData
|
||||
? searchData.map((repo) => ({
|
||||
value: repo.id,
|
||||
label: repo.full_name,
|
||||
}))
|
||||
: [],
|
||||
[searchData],
|
||||
);
|
||||
|
||||
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 results
|
||||
const searchOption = searchOptions.find((opt) => opt.value === value);
|
||||
if (searchOption) return searchOption;
|
||||
|
||||
return null;
|
||||
}, [allOptions, searchOptions, value]);
|
||||
|
||||
const loadOptions = useCallback(
|
||||
async (inputValue: string): Promise<AsyncSelectOption[]> => {
|
||||
// Update search input to trigger debounced search
|
||||
setSearchInput(inputValue);
|
||||
|
||||
// If empty input, show all loaded options
|
||||
if (!inputValue.trim()) {
|
||||
return allOptions;
|
||||
}
|
||||
|
||||
// For very short inputs, do local filtering
|
||||
if (inputValue.length < 2) {
|
||||
return allOptions.filter((option) =>
|
||||
option.label.toLowerCase().includes(inputValue.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
// Handle URL inputs by performing direct search
|
||||
if (inputValue.startsWith("https://")) {
|
||||
const match = inputValue.match(/https:\/\/[^/]+\/([^/]+\/[^/]+)/);
|
||||
if (match) {
|
||||
const repoName = match[1];
|
||||
try {
|
||||
// Perform direct search for URL-based inputs
|
||||
const repositories = await OpenHands.searchGitRepositories(
|
||||
repoName,
|
||||
3,
|
||||
provider,
|
||||
);
|
||||
return repositories.map((repo) => ({
|
||||
value: repo.full_name,
|
||||
label: repo.full_name,
|
||||
data: repo,
|
||||
}));
|
||||
} catch (error) {
|
||||
// Fall back to local filtering if search fails
|
||||
return allOptions.filter((option) =>
|
||||
option.label.toLowerCase().includes(repoName.toLowerCase()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For regular text inputs, use hook-based search results if available
|
||||
if (searchOptions.length > 0 && processedSearchInput === inputValue) {
|
||||
return searchOptions;
|
||||
}
|
||||
|
||||
// Fallback to local filtering while search is loading
|
||||
return allOptions.filter((option) =>
|
||||
option.label.toLowerCase().includes(inputValue.toLowerCase()),
|
||||
);
|
||||
},
|
||||
[allOptions, searchOptions, processedSearchInput, provider],
|
||||
);
|
||||
|
||||
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 = searchData?.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 || isFetchingNextPage || isSearchLoading}
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,79 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,62 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import Select, { StylesConfig } 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;
|
||||
classNamePrefix?: string;
|
||||
styles?: StylesConfig<SelectOption, false>;
|
||||
}
|
||||
|
||||
export function ReactSelectDropdown({
|
||||
options,
|
||||
placeholder = "Select option...",
|
||||
value,
|
||||
defaultValue,
|
||||
className,
|
||||
errorMessage,
|
||||
disabled = false,
|
||||
isClearable = false,
|
||||
isSearchable = true,
|
||||
isLoading = false,
|
||||
onChange,
|
||||
classNamePrefix,
|
||||
styles,
|
||||
}: 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={styles || customStyles}
|
||||
className="w-full"
|
||||
classNamePrefix={classNamePrefix}
|
||||
/>
|
||||
{errorMessage && (
|
||||
<p className="text-red-500 text-sm mt-1">{errorMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,115 +0,0 @@
|
||||
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
|
||||
}),
|
||||
});
|
||||
|
||||
export const getGitProviderMicroagentManagementCustomStyles = <
|
||||
T extends SelectOptionBase,
|
||||
>(): StylesConfig<T, false> => ({
|
||||
...getCustomStyles<T>(),
|
||||
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: "none",
|
||||
opacity: state.isDisabled ? 0.6 : 1,
|
||||
cursor: state.isDisabled ? "not-allowed" : "pointer",
|
||||
"&:hover": {
|
||||
borderColor: "#717888",
|
||||
},
|
||||
"& .git-provider-dropdown__value-container": {
|
||||
padding: "2px 0",
|
||||
},
|
||||
}),
|
||||
});
|
||||
@ -0,0 +1,86 @@
|
||||
import React from "react";
|
||||
import {
|
||||
UseComboboxGetMenuPropsOptions,
|
||||
UseComboboxGetItemPropsOptions,
|
||||
} from "downshift";
|
||||
import { Branch } from "#/types/git";
|
||||
import { DropdownItem } from "../shared/dropdown-item";
|
||||
import { GenericDropdownMenu, EmptyState } from "../shared";
|
||||
|
||||
export interface BranchDropdownMenuProps {
|
||||
isOpen: boolean;
|
||||
filteredBranches: Branch[];
|
||||
inputValue: string;
|
||||
highlightedIndex: number;
|
||||
selectedItem: Branch | null;
|
||||
getMenuProps: <Options>(
|
||||
options?: UseComboboxGetMenuPropsOptions & Options,
|
||||
) => any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
getItemProps: <Options>(
|
||||
options: UseComboboxGetItemPropsOptions<Branch> & Options,
|
||||
) => any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
onScroll: (event: React.UIEvent<HTMLUListElement>) => void;
|
||||
menuRef: React.RefObject<HTMLUListElement | null>;
|
||||
}
|
||||
|
||||
export function BranchDropdownMenu({
|
||||
isOpen,
|
||||
filteredBranches,
|
||||
inputValue,
|
||||
highlightedIndex,
|
||||
selectedItem,
|
||||
getMenuProps,
|
||||
getItemProps,
|
||||
onScroll,
|
||||
menuRef,
|
||||
}: BranchDropdownMenuProps) {
|
||||
const renderItem = (
|
||||
branch: Branch,
|
||||
index: number,
|
||||
currentHighlightedIndex: number,
|
||||
currentSelectedItem: Branch | null,
|
||||
currentGetItemProps: <Options>(
|
||||
options: UseComboboxGetItemPropsOptions<Branch> & Options,
|
||||
) => any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
) => (
|
||||
<DropdownItem
|
||||
key={branch.name}
|
||||
item={branch}
|
||||
index={index}
|
||||
isHighlighted={currentHighlightedIndex === index}
|
||||
isSelected={currentSelectedItem?.name === branch.name}
|
||||
getItemProps={currentGetItemProps}
|
||||
getDisplayText={(branchItem) => branchItem.name}
|
||||
getItemKey={(branchItem) => branchItem.name}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderEmptyState = (currentInputValue: string) => (
|
||||
<li className="px-3 py-2">
|
||||
<EmptyState
|
||||
inputValue={currentInputValue}
|
||||
searchMessage="No branches found"
|
||||
emptyMessage="No branches available"
|
||||
testId="git-branch-dropdown-empty"
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-testid="git-branch-dropdown-menu">
|
||||
<GenericDropdownMenu
|
||||
isOpen={isOpen}
|
||||
filteredItems={filteredBranches}
|
||||
inputValue={inputValue}
|
||||
highlightedIndex={highlightedIndex}
|
||||
selectedItem={selectedItem}
|
||||
getMenuProps={getMenuProps}
|
||||
getItemProps={getItemProps}
|
||||
onScroll={onScroll}
|
||||
menuRef={menuRef}
|
||||
renderItem={renderItem}
|
||||
renderEmptyState={renderEmptyState}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,236 @@
|
||||
import React, {
|
||||
useState,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useRef,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import { useCombobox } from "downshift";
|
||||
import { Branch } from "#/types/git";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { useDebounce } from "#/hooks/use-debounce";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { useBranchData } from "#/hooks/query/use-branch-data";
|
||||
import { LoadingSpinner } from "../shared/loading-spinner";
|
||||
import { ClearButton } from "../shared/clear-button";
|
||||
import { ToggleButton } from "../shared/toggle-button";
|
||||
import { ErrorMessage } from "../shared/error-message";
|
||||
import { BranchDropdownMenu } from "./branch-dropdown-menu";
|
||||
|
||||
export interface GitBranchDropdownProps {
|
||||
repository: string | null;
|
||||
provider: Provider;
|
||||
selectedBranch: Branch | null;
|
||||
onBranchSelect: (branch: Branch | null) => void;
|
||||
defaultBranch?: string | null;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function GitBranchDropdown({
|
||||
repository,
|
||||
provider,
|
||||
selectedBranch,
|
||||
onBranchSelect,
|
||||
defaultBranch,
|
||||
placeholder = "Select branch...",
|
||||
disabled = false,
|
||||
className,
|
||||
}: GitBranchDropdownProps) {
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [userManuallyCleared, setUserManuallyCleared] = useState(false);
|
||||
const debouncedInputValue = useDebounce(inputValue, 300);
|
||||
const menuRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
// Process search input (debounced and filtered)
|
||||
const processedSearchInput = useMemo(
|
||||
() =>
|
||||
debouncedInputValue.trim().length > 0 ? debouncedInputValue.trim() : "",
|
||||
[debouncedInputValue],
|
||||
);
|
||||
|
||||
// Use the new branch data hook with default branch prioritization
|
||||
const {
|
||||
branches: filteredBranches,
|
||||
isLoading,
|
||||
isError,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isSearchLoading,
|
||||
} = useBranchData(
|
||||
repository,
|
||||
provider,
|
||||
defaultBranch || null,
|
||||
processedSearchInput,
|
||||
inputValue,
|
||||
selectedBranch,
|
||||
);
|
||||
|
||||
const error = isError ? new Error("Failed to load branches") : null;
|
||||
|
||||
// Handle clear
|
||||
const handleClear = useCallback(() => {
|
||||
setInputValue("");
|
||||
onBranchSelect(null);
|
||||
setUserManuallyCleared(true); // Mark that user manually cleared the branch
|
||||
}, [onBranchSelect]);
|
||||
|
||||
// Handle branch selection
|
||||
const handleBranchSelect = useCallback(
|
||||
(branch: Branch | null) => {
|
||||
onBranchSelect(branch);
|
||||
setInputValue("");
|
||||
},
|
||||
[onBranchSelect],
|
||||
);
|
||||
|
||||
// Handle input value change
|
||||
const handleInputValueChange = useCallback(
|
||||
({ inputValue: newInputValue }: { inputValue?: string }) => {
|
||||
if (newInputValue !== undefined) {
|
||||
setInputValue(newInputValue);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Handle menu scroll for infinite loading
|
||||
const handleMenuScroll = useCallback(
|
||||
(event: React.UIEvent<HTMLUListElement>) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
|
||||
if (
|
||||
scrollHeight - scrollTop <= clientHeight * 1.5 &&
|
||||
hasNextPage &&
|
||||
!isFetchingNextPage
|
||||
) {
|
||||
fetchNextPage();
|
||||
}
|
||||
},
|
||||
[hasNextPage, isFetchingNextPage, fetchNextPage],
|
||||
);
|
||||
|
||||
// Downshift configuration
|
||||
const {
|
||||
isOpen,
|
||||
selectedItem,
|
||||
highlightedIndex,
|
||||
getInputProps,
|
||||
getItemProps,
|
||||
getMenuProps,
|
||||
getToggleButtonProps,
|
||||
} = useCombobox({
|
||||
items: filteredBranches,
|
||||
selectedItem: selectedBranch,
|
||||
itemToString: (item) => item?.name || "",
|
||||
onSelectedItemChange: ({ selectedItem: newSelectedItem }) => {
|
||||
handleBranchSelect(newSelectedItem || null);
|
||||
},
|
||||
onInputValueChange: handleInputValueChange,
|
||||
inputValue,
|
||||
});
|
||||
|
||||
// Reset branch selection when repository changes
|
||||
useEffect(() => {
|
||||
if (repository) {
|
||||
onBranchSelect(null);
|
||||
setUserManuallyCleared(false); // Reset the manual clear flag when repository changes
|
||||
}
|
||||
}, [repository, onBranchSelect]);
|
||||
|
||||
// Auto-select default branch when branches are loaded and no branch is selected
|
||||
// But only if the user hasn't manually cleared the branch
|
||||
useEffect(() => {
|
||||
if (
|
||||
repository &&
|
||||
defaultBranch &&
|
||||
!selectedBranch &&
|
||||
!userManuallyCleared && // Don't auto-select if user manually cleared
|
||||
filteredBranches.length > 0 &&
|
||||
!isLoading
|
||||
) {
|
||||
const defaultBranchObj = filteredBranches.find(
|
||||
(branch) => branch.name === defaultBranch,
|
||||
);
|
||||
|
||||
if (defaultBranchObj) {
|
||||
onBranchSelect(defaultBranchObj);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
repository,
|
||||
defaultBranch,
|
||||
selectedBranch,
|
||||
userManuallyCleared,
|
||||
filteredBranches,
|
||||
onBranchSelect,
|
||||
isLoading,
|
||||
]);
|
||||
|
||||
// Reset input when repository changes
|
||||
useEffect(() => {
|
||||
setInputValue("");
|
||||
}, [repository]);
|
||||
|
||||
// Initialize input value when selectedBranch changes (but not when user is typing)
|
||||
useEffect(() => {
|
||||
if (selectedBranch && !isOpen && inputValue !== selectedBranch.name) {
|
||||
setInputValue(selectedBranch.name);
|
||||
} else if (!selectedBranch && !isOpen && inputValue) {
|
||||
setInputValue("");
|
||||
}
|
||||
}, [selectedBranch, isOpen, inputValue]);
|
||||
|
||||
const isLoadingState = isLoading || isSearchLoading || isFetchingNextPage;
|
||||
|
||||
return (
|
||||
<div className={cn("relative", className)}>
|
||||
<div className="relative">
|
||||
<input
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...getInputProps({
|
||||
disabled: disabled || !repository,
|
||||
placeholder,
|
||||
className: cn(
|
||||
"w-full px-3 py-2 border border-[#717888] rounded-sm shadow-sm min-h-[2.5rem]",
|
||||
"bg-[#454545] text-[#ECEDEE] placeholder:text-[#B7BDC2] placeholder:italic",
|
||||
"focus:outline-none focus:ring-1 focus:ring-[#717888] focus:border-[#717888]",
|
||||
"disabled:bg-[#363636] disabled:cursor-not-allowed disabled:opacity-60",
|
||||
"pr-10", // Space for toggle button
|
||||
),
|
||||
})}
|
||||
data-testid="git-branch-dropdown-input"
|
||||
/>
|
||||
|
||||
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 flex items-center gap-1">
|
||||
{selectedBranch && (
|
||||
<ClearButton disabled={disabled} onClear={handleClear} />
|
||||
)}
|
||||
|
||||
<ToggleButton
|
||||
isOpen={isOpen}
|
||||
disabled={disabled || !repository}
|
||||
getToggleButtonProps={getToggleButtonProps}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoadingState && <LoadingSpinner hasSelection={!!selectedBranch} />}
|
||||
</div>
|
||||
|
||||
<BranchDropdownMenu
|
||||
isOpen={isOpen}
|
||||
filteredBranches={filteredBranches}
|
||||
inputValue={inputValue}
|
||||
highlightedIndex={highlightedIndex}
|
||||
selectedItem={selectedItem}
|
||||
getMenuProps={getMenuProps}
|
||||
getItemProps={getItemProps}
|
||||
onScroll={handleMenuScroll}
|
||||
menuRef={menuRef}
|
||||
/>
|
||||
|
||||
<ErrorMessage isError={!!error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
export { GitBranchDropdown } from "./git-branch-dropdown";
|
||||
export { BranchDropdownMenu } from "./branch-dropdown-menu";
|
||||
export type { GitBranchDropdownProps } from "./git-branch-dropdown";
|
||||
@ -0,0 +1,193 @@
|
||||
import React, { useState, useMemo, useEffect } from "react";
|
||||
import { useCombobox } from "downshift";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { DropdownItem } from "../shared/dropdown-item";
|
||||
import { GenericDropdownMenu } from "../shared/generic-dropdown-menu";
|
||||
import { ToggleButton } from "../shared/toggle-button";
|
||||
import { LoadingSpinner } from "../shared/loading-spinner";
|
||||
import { ErrorMessage } from "../shared/error-message";
|
||||
import { EmptyState } from "../shared/empty-state";
|
||||
|
||||
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 [inputValue, setInputValue] = useState("");
|
||||
const [localSelectedItem, setLocalSelectedItem] = useState<Provider | null>(
|
||||
value || null,
|
||||
);
|
||||
|
||||
// Format provider names for display
|
||||
const formatProviderName = (provider: Provider): string => {
|
||||
switch (provider) {
|
||||
case "github":
|
||||
return "GitHub";
|
||||
case "gitlab":
|
||||
return "GitLab";
|
||||
case "bitbucket":
|
||||
return "Bitbucket";
|
||||
case "enterprise_sso":
|
||||
return "Enterprise SSO";
|
||||
default:
|
||||
// Fallback for any future provider types
|
||||
return (
|
||||
(provider as string).charAt(0).toUpperCase() +
|
||||
(provider as string).slice(1)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter providers based on input value
|
||||
const filteredProviders = useMemo(() => {
|
||||
// If we have a selected provider and the input matches it exactly, show all providers
|
||||
if (
|
||||
localSelectedItem &&
|
||||
inputValue === formatProviderName(localSelectedItem)
|
||||
) {
|
||||
return providers;
|
||||
}
|
||||
|
||||
// If no input value, show all providers
|
||||
if (!inputValue || !inputValue.trim()) {
|
||||
return providers;
|
||||
}
|
||||
|
||||
// Filter providers based on input
|
||||
return providers.filter((provider) =>
|
||||
formatProviderName(provider)
|
||||
.toLowerCase()
|
||||
.includes(inputValue.toLowerCase()),
|
||||
);
|
||||
}, [providers, inputValue, localSelectedItem]);
|
||||
|
||||
const {
|
||||
isOpen,
|
||||
getToggleButtonProps,
|
||||
getMenuProps,
|
||||
getInputProps,
|
||||
highlightedIndex,
|
||||
getItemProps,
|
||||
selectedItem,
|
||||
} = useCombobox({
|
||||
items: filteredProviders,
|
||||
itemToString: (item) => (item ? formatProviderName(item) : ""),
|
||||
selectedItem: localSelectedItem,
|
||||
onSelectedItemChange: ({ selectedItem: newSelectedItem }) => {
|
||||
setLocalSelectedItem(newSelectedItem || null);
|
||||
onChange?.(newSelectedItem || null);
|
||||
},
|
||||
onInputValueChange: ({ inputValue: newInputValue }) => {
|
||||
setInputValue(newInputValue || "");
|
||||
},
|
||||
inputValue,
|
||||
});
|
||||
|
||||
// Sync with external value prop
|
||||
useEffect(() => {
|
||||
if (value !== localSelectedItem) {
|
||||
setLocalSelectedItem(value || null);
|
||||
}
|
||||
}, [value, localSelectedItem]);
|
||||
|
||||
// Update input value when selection changes (but not when user is typing)
|
||||
useEffect(() => {
|
||||
if (selectedItem && !isOpen) {
|
||||
setInputValue(formatProviderName(selectedItem));
|
||||
} else if (!selectedItem) {
|
||||
setInputValue("");
|
||||
}
|
||||
}, [selectedItem, isOpen]);
|
||||
|
||||
const renderItem = (
|
||||
item: Provider,
|
||||
index: number,
|
||||
currentHighlightedIndex: number,
|
||||
currentSelectedItem: Provider | null,
|
||||
currentGetItemProps: any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
) => (
|
||||
<DropdownItem
|
||||
key={item}
|
||||
item={item}
|
||||
index={index}
|
||||
isHighlighted={index === currentHighlightedIndex}
|
||||
isSelected={item === currentSelectedItem}
|
||||
getItemProps={currentGetItemProps}
|
||||
getDisplayText={formatProviderName}
|
||||
getItemKey={(provider) => provider}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderEmptyState = (currentInputValue: string) => (
|
||||
<EmptyState
|
||||
inputValue={currentInputValue}
|
||||
searchMessage="No providers found"
|
||||
emptyMessage="No providers available"
|
||||
testId="git-provider-dropdown-empty"
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("relative", className)}>
|
||||
<div className="relative">
|
||||
<input
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...getInputProps({
|
||||
disabled,
|
||||
placeholder,
|
||||
readOnly: true, // Make it non-searchable like the original
|
||||
className: cn(
|
||||
"w-full px-3 py-2 border border-[#717888] rounded-sm shadow-sm min-h-[2.5rem]",
|
||||
"bg-[#454545] text-[#ECEDEE] placeholder:text-[#B7BDC2] placeholder:italic",
|
||||
"focus:outline-none focus:ring-1 focus:ring-[#717888] focus:border-[#717888]",
|
||||
"disabled:bg-[#363636] disabled:cursor-not-allowed disabled:opacity-60",
|
||||
"pr-10 cursor-pointer", // Space for toggle button and pointer cursor
|
||||
),
|
||||
})}
|
||||
data-testid="git-provider-dropdown"
|
||||
/>
|
||||
|
||||
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 flex items-center gap-1">
|
||||
<ToggleButton
|
||||
isOpen={isOpen}
|
||||
disabled={disabled}
|
||||
getToggleButtonProps={getToggleButtonProps}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading && <LoadingSpinner hasSelection={!!selectedItem} />}
|
||||
</div>
|
||||
|
||||
<GenericDropdownMenu
|
||||
isOpen={isOpen}
|
||||
filteredItems={filteredProviders}
|
||||
inputValue={inputValue}
|
||||
highlightedIndex={highlightedIndex}
|
||||
selectedItem={selectedItem}
|
||||
getMenuProps={getMenuProps}
|
||||
getItemProps={getItemProps}
|
||||
renderItem={renderItem}
|
||||
renderEmptyState={renderEmptyState}
|
||||
/>
|
||||
|
||||
<ErrorMessage isError={!!errorMessage} message={errorMessage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
export { GitProviderDropdown } from "./git-provider-dropdown";
|
||||
export type { GitProviderDropdownProps } from "./git-provider-dropdown";
|
||||
@ -0,0 +1,79 @@
|
||||
import React from "react";
|
||||
import {
|
||||
UseComboboxGetMenuPropsOptions,
|
||||
UseComboboxGetItemPropsOptions,
|
||||
} from "downshift";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { DropdownItem } from "../shared/dropdown-item";
|
||||
import { GenericDropdownMenu, EmptyState } from "../shared";
|
||||
|
||||
interface DropdownMenuProps {
|
||||
isOpen: boolean;
|
||||
filteredRepositories: GitRepository[];
|
||||
inputValue: string;
|
||||
highlightedIndex: number;
|
||||
selectedItem: GitRepository | null;
|
||||
getMenuProps: <Options>(
|
||||
options?: UseComboboxGetMenuPropsOptions & Options,
|
||||
) => any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
getItemProps: <Options>(
|
||||
options: UseComboboxGetItemPropsOptions<GitRepository> & Options,
|
||||
) => any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
onScroll: (event: React.UIEvent<HTMLUListElement>) => void;
|
||||
menuRef: React.RefObject<HTMLUListElement | null>;
|
||||
}
|
||||
|
||||
export function DropdownMenu({
|
||||
isOpen,
|
||||
filteredRepositories,
|
||||
inputValue,
|
||||
highlightedIndex,
|
||||
selectedItem,
|
||||
getMenuProps,
|
||||
getItemProps,
|
||||
onScroll,
|
||||
menuRef,
|
||||
}: DropdownMenuProps) {
|
||||
const renderItem = (
|
||||
repository: GitRepository,
|
||||
index: number,
|
||||
currentHighlightedIndex: number,
|
||||
currentSelectedItem: GitRepository | null,
|
||||
currentGetItemProps: <Options>(
|
||||
options: UseComboboxGetItemPropsOptions<GitRepository> & Options,
|
||||
) => any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
) => (
|
||||
<DropdownItem
|
||||
key={repository.id}
|
||||
item={repository}
|
||||
index={index}
|
||||
isHighlighted={currentHighlightedIndex === index}
|
||||
isSelected={currentSelectedItem?.id === repository.id}
|
||||
getItemProps={currentGetItemProps}
|
||||
getDisplayText={(repo) => repo.full_name}
|
||||
getItemKey={(repo) => repo.id.toString()}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderEmptyState = (currentInputValue: string) => (
|
||||
<EmptyState inputValue={currentInputValue} />
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-testid="git-repo-dropdown-menu">
|
||||
<GenericDropdownMenu
|
||||
isOpen={isOpen}
|
||||
filteredItems={filteredRepositories}
|
||||
inputValue={inputValue}
|
||||
highlightedIndex={highlightedIndex}
|
||||
selectedItem={selectedItem}
|
||||
getMenuProps={getMenuProps}
|
||||
getItemProps={getItemProps}
|
||||
onScroll={onScroll}
|
||||
menuRef={menuRef}
|
||||
renderItem={renderItem}
|
||||
renderEmptyState={renderEmptyState}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,243 @@
|
||||
import React, {
|
||||
useState,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useRef,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import { useCombobox } from "downshift";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { useDebounce } from "#/hooks/use-debounce";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { LoadingSpinner } from "../shared/loading-spinner";
|
||||
import { ClearButton } from "../shared/clear-button";
|
||||
import { ToggleButton } from "../shared/toggle-button";
|
||||
import { ErrorMessage } from "../shared/error-message";
|
||||
import { useUrlSearch } from "./use-url-search";
|
||||
import { useRepositoryData } from "./use-repository-data";
|
||||
import { DropdownMenu } from "./dropdown-menu";
|
||||
|
||||
export interface GitRepoDropdownProps {
|
||||
provider: Provider;
|
||||
value?: string | null;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
onChange?: (repository?: GitRepository) => void;
|
||||
}
|
||||
|
||||
export function GitRepoDropdown({
|
||||
provider,
|
||||
value,
|
||||
placeholder = "Search repositories...",
|
||||
className,
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: GitRepoDropdownProps) {
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [localSelectedItem, setLocalSelectedItem] =
|
||||
useState<GitRepository | null>(null);
|
||||
const debouncedInputValue = useDebounce(inputValue, 300);
|
||||
const menuRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
// Process search input to handle URLs
|
||||
const processedSearchInput = useMemo(() => {
|
||||
if (debouncedInputValue.startsWith("https://")) {
|
||||
const match = debouncedInputValue.match(
|
||||
/https:\/\/[^/]+\/([^/]+\/[^/]+)/,
|
||||
);
|
||||
return match ? match[1] : debouncedInputValue;
|
||||
}
|
||||
return debouncedInputValue;
|
||||
}, [debouncedInputValue]);
|
||||
|
||||
// URL search functionality
|
||||
const { urlSearchResults, isUrlSearchLoading } = useUrlSearch(
|
||||
inputValue,
|
||||
provider,
|
||||
);
|
||||
|
||||
// Repository data management
|
||||
const {
|
||||
repositories,
|
||||
selectedRepository,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
isError,
|
||||
isSearchLoading,
|
||||
} = useRepositoryData(
|
||||
provider,
|
||||
disabled,
|
||||
processedSearchInput,
|
||||
urlSearchResults,
|
||||
inputValue,
|
||||
value,
|
||||
);
|
||||
|
||||
// Filter repositories based on input value
|
||||
const filteredRepositories = useMemo(() => {
|
||||
// If we have URL search results, show them directly (no filtering needed)
|
||||
if (urlSearchResults.length > 0) {
|
||||
return repositories;
|
||||
}
|
||||
|
||||
// If we have a selected repository and the input matches it exactly, show all repositories
|
||||
if (selectedRepository && inputValue === selectedRepository.full_name) {
|
||||
return repositories;
|
||||
}
|
||||
|
||||
// If no input value, show all repositories
|
||||
if (!inputValue || !inputValue.trim()) {
|
||||
return repositories;
|
||||
}
|
||||
|
||||
// For URL inputs, use the processed search input for filtering
|
||||
const filterText = inputValue.startsWith("https://")
|
||||
? processedSearchInput
|
||||
: inputValue;
|
||||
|
||||
return repositories.filter((repo) =>
|
||||
repo.full_name.toLowerCase().includes(filterText.toLowerCase()),
|
||||
);
|
||||
}, [
|
||||
repositories,
|
||||
inputValue,
|
||||
selectedRepository,
|
||||
urlSearchResults,
|
||||
processedSearchInput,
|
||||
]);
|
||||
|
||||
// Handle selection
|
||||
const handleSelectionChange = useCallback(
|
||||
(selectedItem: GitRepository | null) => {
|
||||
setLocalSelectedItem(selectedItem);
|
||||
onChange?.(selectedItem || undefined);
|
||||
// Update input value to show selected item
|
||||
if (selectedItem) {
|
||||
setInputValue(selectedItem.full_name);
|
||||
}
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
// Handle clear selection
|
||||
const handleClear = useCallback(() => {
|
||||
setLocalSelectedItem(null);
|
||||
handleSelectionChange(null);
|
||||
setInputValue("");
|
||||
}, [handleSelectionChange]);
|
||||
|
||||
// Handle input value change
|
||||
const handleInputValueChange = useCallback(
|
||||
({ inputValue: newInputValue }: { inputValue?: string }) => {
|
||||
setInputValue(newInputValue || "");
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Handle scroll to bottom for pagination
|
||||
const handleMenuScroll = useCallback(
|
||||
(event: React.UIEvent<HTMLUListElement>) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
|
||||
const isNearBottom = scrollTop + clientHeight >= scrollHeight - 10;
|
||||
|
||||
if (isNearBottom && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
},
|
||||
[hasNextPage, isFetchingNextPage, fetchNextPage],
|
||||
);
|
||||
|
||||
const {
|
||||
isOpen,
|
||||
getToggleButtonProps,
|
||||
getMenuProps,
|
||||
getInputProps,
|
||||
highlightedIndex,
|
||||
getItemProps,
|
||||
selectedItem,
|
||||
} = useCombobox({
|
||||
items: filteredRepositories,
|
||||
itemToString: (item) => item?.full_name || "",
|
||||
selectedItem: localSelectedItem,
|
||||
onSelectedItemChange: ({ selectedItem: newSelectedItem }) => {
|
||||
handleSelectionChange(newSelectedItem);
|
||||
},
|
||||
onInputValueChange: handleInputValueChange,
|
||||
inputValue,
|
||||
});
|
||||
|
||||
// Sync localSelectedItem with external value prop
|
||||
useEffect(() => {
|
||||
if (selectedRepository) {
|
||||
setLocalSelectedItem(selectedRepository);
|
||||
} else if (value === null) {
|
||||
setLocalSelectedItem(null);
|
||||
}
|
||||
}, [selectedRepository, value]);
|
||||
|
||||
// Initialize input value when selectedRepository changes (but not when user is typing)
|
||||
useEffect(() => {
|
||||
if (selectedRepository && !isOpen) {
|
||||
setInputValue(selectedRepository.full_name);
|
||||
}
|
||||
}, [selectedRepository, isOpen]);
|
||||
|
||||
const isLoadingState =
|
||||
isLoading || isSearchLoading || isFetchingNextPage || isUrlSearchLoading;
|
||||
|
||||
return (
|
||||
<div className={cn("relative", className)}>
|
||||
<div className="relative">
|
||||
<input
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...getInputProps({
|
||||
disabled,
|
||||
placeholder,
|
||||
className: cn(
|
||||
"w-full px-3 py-2 border border-[#717888] rounded-sm shadow-sm min-h-[2.5rem]",
|
||||
"bg-[#454545] text-[#ECEDEE] placeholder:text-[#B7BDC2] placeholder:italic",
|
||||
"focus:outline-none focus:ring-1 focus:ring-[#717888] focus:border-[#717888]",
|
||||
"disabled:bg-[#363636] disabled:cursor-not-allowed disabled:opacity-60",
|
||||
"pr-10", // Space for toggle button
|
||||
),
|
||||
})}
|
||||
data-testid="git-repo-dropdown"
|
||||
/>
|
||||
|
||||
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 flex items-center gap-1">
|
||||
{selectedRepository && (
|
||||
<ClearButton disabled={disabled} onClear={handleClear} />
|
||||
)}
|
||||
|
||||
<ToggleButton
|
||||
isOpen={isOpen}
|
||||
disabled={disabled}
|
||||
getToggleButtonProps={getToggleButtonProps}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoadingState && (
|
||||
<LoadingSpinner hasSelection={!!selectedRepository} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DropdownMenu
|
||||
isOpen={isOpen}
|
||||
filteredRepositories={filteredRepositories}
|
||||
inputValue={inputValue}
|
||||
highlightedIndex={highlightedIndex}
|
||||
selectedItem={selectedItem}
|
||||
getMenuProps={getMenuProps}
|
||||
getItemProps={getItemProps}
|
||||
onScroll={handleMenuScroll}
|
||||
menuRef={menuRef}
|
||||
/>
|
||||
|
||||
<ErrorMessage isError={isError} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
// Main component
|
||||
export { GitRepoDropdown } from "./git-repo-dropdown";
|
||||
export type { GitRepoDropdownProps } from "./git-repo-dropdown";
|
||||
|
||||
// Repository-specific UI Components
|
||||
export { DropdownMenu } from "./dropdown-menu";
|
||||
|
||||
// Repository-specific Custom Hooks
|
||||
export { useUrlSearch } from "./use-url-search";
|
||||
export { useRepositoryData } from "./use-repository-data";
|
||||
@ -0,0 +1,89 @@
|
||||
import { useMemo } from "react";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { useGitRepositories } from "#/hooks/query/use-git-repositories";
|
||||
import { useSearchRepositories } from "#/hooks/query/use-search-repositories";
|
||||
|
||||
export function useRepositoryData(
|
||||
provider: Provider,
|
||||
disabled: boolean,
|
||||
processedSearchInput: string,
|
||||
urlSearchResults: GitRepository[],
|
||||
inputValue: string,
|
||||
value?: string | null,
|
||||
) {
|
||||
// Fetch user repositories with pagination
|
||||
const {
|
||||
data: repoData,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
isError,
|
||||
} = useGitRepositories({
|
||||
provider,
|
||||
enabled: !disabled,
|
||||
});
|
||||
|
||||
// Search repositories when user types
|
||||
const { data: searchData, isLoading: isSearchLoading } =
|
||||
useSearchRepositories(processedSearchInput, provider);
|
||||
|
||||
// Combine all repositories from paginated data
|
||||
const allRepositories = useMemo(
|
||||
() => repoData?.pages?.flatMap((page) => page.data) || [],
|
||||
[repoData],
|
||||
);
|
||||
|
||||
// Find selected repository from all possible sources
|
||||
const selectedRepository = useMemo(() => {
|
||||
if (!value) return null;
|
||||
|
||||
// Search in all possible repository sources
|
||||
const allPossibleRepos = [
|
||||
...allRepositories,
|
||||
...urlSearchResults,
|
||||
...(searchData || []),
|
||||
];
|
||||
|
||||
return allPossibleRepos.find((repo) => repo.id === value) || null;
|
||||
}, [allRepositories, urlSearchResults, searchData, value]);
|
||||
|
||||
// Get repositories to display (URL search, regular search, or all repos)
|
||||
const repositories = useMemo(() => {
|
||||
// Prioritize URL search results when available
|
||||
if (urlSearchResults.length > 0) {
|
||||
return urlSearchResults;
|
||||
}
|
||||
|
||||
// Don't use search results if input exactly matches selected repository
|
||||
const shouldUseSearch =
|
||||
processedSearchInput &&
|
||||
searchData &&
|
||||
!(selectedRepository && inputValue === selectedRepository.full_name);
|
||||
|
||||
if (shouldUseSearch) {
|
||||
return searchData;
|
||||
}
|
||||
return allRepositories;
|
||||
}, [
|
||||
urlSearchResults,
|
||||
processedSearchInput,
|
||||
searchData,
|
||||
allRepositories,
|
||||
selectedRepository,
|
||||
inputValue,
|
||||
]);
|
||||
|
||||
return {
|
||||
repositories,
|
||||
allRepositories,
|
||||
selectedRepository,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
isError,
|
||||
isSearchLoading,
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
export function useUrlSearch(inputValue: string, provider: Provider) {
|
||||
const [urlSearchResults, setUrlSearchResults] = useState<GitRepository[]>([]);
|
||||
const [isUrlSearchLoading, setIsUrlSearchLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleUrlSearch = async () => {
|
||||
if (inputValue.startsWith("https://")) {
|
||||
const match = inputValue.match(/https:\/\/[^/]+\/([^/]+\/[^/]+)/);
|
||||
if (match) {
|
||||
const repoName = match[1];
|
||||
|
||||
setIsUrlSearchLoading(true);
|
||||
try {
|
||||
const repositories = await OpenHands.searchGitRepositories(
|
||||
repoName,
|
||||
3,
|
||||
provider,
|
||||
);
|
||||
|
||||
setUrlSearchResults(repositories);
|
||||
} catch (error) {
|
||||
setUrlSearchResults([]);
|
||||
} finally {
|
||||
setIsUrlSearchLoading(false);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setUrlSearchResults([]);
|
||||
}
|
||||
};
|
||||
|
||||
handleUrlSearch();
|
||||
}, [inputValue, provider]);
|
||||
|
||||
return { urlSearchResults, isUrlSearchLoading };
|
||||
}
|
||||
@ -2,15 +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 { useRepositoryBranches } from "#/hooks/query/use-repository-branches";
|
||||
// Removed useRepositoryBranches import - GitBranchDropdown manages its own data
|
||||
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
|
||||
import { Branch, GitRepository } from "#/types/git";
|
||||
import { BrandButton } from "../settings/brand-button";
|
||||
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";
|
||||
import { GitProviderDropdown } from "./git-provider-dropdown";
|
||||
import { GitBranchDropdown } from "./git-branch-dropdown";
|
||||
import { GitRepoDropdown } from "./git-repo-dropdown";
|
||||
|
||||
interface RepositorySelectionFormProps {
|
||||
onRepoSelection: (repo: GitRepository | null) => void;
|
||||
@ -28,8 +28,6 @@ export function RepositorySelectionForm({
|
||||
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,
|
||||
@ -50,8 +48,7 @@ export function RepositorySelectionForm({
|
||||
const isCreatingConversation =
|
||||
isPending || isSuccess || isCreatingConversationElsewhere;
|
||||
|
||||
// Check if repository has no branches (empty array after loading completes)
|
||||
const hasNoBranches = !isLoadingBranches && branches && branches.length === 0;
|
||||
// Branch selection is now handled by GitBranchDropdown component
|
||||
|
||||
const handleProviderSelection = (provider: Provider | null) => {
|
||||
setSelectedProvider(provider);
|
||||
@ -60,14 +57,9 @@ export function RepositorySelectionForm({
|
||||
onRepoSelection(null); // Reset parent component's selected repo
|
||||
};
|
||||
|
||||
const handleBranchSelection = (branchName: string | null) => {
|
||||
const selectedBranchObj = branches?.find(
|
||||
(branch) => branch.name === branchName,
|
||||
);
|
||||
if (selectedBranchObj) {
|
||||
setSelectedBranch(selectedBranchObj);
|
||||
}
|
||||
};
|
||||
const handleBranchSelection = React.useCallback((branch: Branch | null) => {
|
||||
setSelectedBranch(branch);
|
||||
}, []);
|
||||
|
||||
// Render the provider dropdown
|
||||
const renderProviderSelector = () => {
|
||||
@ -87,19 +79,6 @@ export function RepositorySelectionForm({
|
||||
);
|
||||
};
|
||||
|
||||
// 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 found, select it, otherwise select the first branch
|
||||
setSelectedBranch(defaultBranch || branches[0]);
|
||||
}
|
||||
}, [branches]);
|
||||
|
||||
// Render the repository selector using our new component
|
||||
const renderRepositorySelector = () => {
|
||||
const handleRepoSelection = (repository?: GitRepository) => {
|
||||
@ -107,13 +86,14 @@ export function RepositorySelectionForm({
|
||||
onRepoSelection(repository);
|
||||
setSelectedRepository(repository);
|
||||
} else {
|
||||
onRepoSelection(null); // Notify parent component that repo was cleared
|
||||
setSelectedRepository(null);
|
||||
setSelectedBranch(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GitRepositoryDropdown
|
||||
<GitRepoDropdown
|
||||
provider={selectedProvider || providers[0]}
|
||||
value={selectedRepository?.id || null}
|
||||
placeholder="Search repositories..."
|
||||
@ -125,16 +105,21 @@ export function RepositorySelectionForm({
|
||||
};
|
||||
|
||||
// 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}
|
||||
/>
|
||||
);
|
||||
const renderBranchSelector = () => {
|
||||
const defaultBranch = selectedRepository?.main_branch || null;
|
||||
return (
|
||||
<GitBranchDropdown
|
||||
repository={selectedRepository?.full_name || null}
|
||||
provider={selectedProvider || providers[0]}
|
||||
selectedBranch={selectedBranch}
|
||||
onBranchSelect={handleBranchSelection}
|
||||
defaultBranch={defaultBranch}
|
||||
placeholder="Select branch..."
|
||||
className="max-w-[500px]"
|
||||
disabled={!selectedRepository}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
@ -148,8 +133,7 @@ export function RepositorySelectionForm({
|
||||
type="button"
|
||||
isDisabled={
|
||||
!selectedRepository ||
|
||||
(!selectedBranch && !hasNoBranches) ||
|
||||
isLoadingBranches ||
|
||||
!selectedBranch ||
|
||||
isCreatingConversation ||
|
||||
(providers.length > 1 && !selectedProvider)
|
||||
}
|
||||
@ -159,7 +143,7 @@ export function RepositorySelectionForm({
|
||||
repository: {
|
||||
name: selectedRepository?.full_name || "",
|
||||
gitProvider: selectedRepository?.git_provider || "github",
|
||||
branch: selectedBranch?.name || (hasNoBranches ? "" : "main"),
|
||||
branch: selectedBranch?.name || "main",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@ -0,0 +1,45 @@
|
||||
import React from "react";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface ClearButtonProps {
|
||||
disabled: boolean;
|
||||
onClear: () => void;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export function ClearButton({
|
||||
disabled,
|
||||
onClear,
|
||||
testId = "dropdown-clear",
|
||||
}: ClearButtonProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClear();
|
||||
}}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"p-1 text-[#B7BDC2] hover:text-[#ECEDEE]",
|
||||
"disabled:cursor-not-allowed disabled:opacity-60",
|
||||
)}
|
||||
type="button"
|
||||
aria-label="Clear selection"
|
||||
data-testid={testId}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
import React from "react";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface DropdownItemProps<T> {
|
||||
item: T;
|
||||
index: number;
|
||||
isHighlighted: boolean;
|
||||
isSelected: boolean;
|
||||
getItemProps: <Options>(options: any & Options) => any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
getDisplayText: (item: T) => string;
|
||||
getItemKey: (item: T) => string;
|
||||
}
|
||||
|
||||
export function DropdownItem<T>({
|
||||
item,
|
||||
index,
|
||||
isHighlighted,
|
||||
isSelected,
|
||||
getItemProps,
|
||||
getDisplayText,
|
||||
getItemKey,
|
||||
}: DropdownItemProps<T>) {
|
||||
const itemProps = getItemProps({
|
||||
index,
|
||||
item,
|
||||
className: cn(
|
||||
"px-3 py-2 cursor-pointer text-sm rounded-lg mx-0.5 my-0.5",
|
||||
"text-[#ECEDEE] focus:outline-none",
|
||||
{
|
||||
"bg-[#24272E]": isHighlighted && !isSelected,
|
||||
"bg-[#C9B974] text-black": isSelected,
|
||||
"hover:bg-[#24272E]": !isSelected,
|
||||
"hover:bg-[#C9B974] hover:text-black": isSelected,
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
<li key={getItemKey(item)} {...itemProps}>
|
||||
<span className="font-medium">{getDisplayText(item)}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
24
frontend/src/components/features/home/shared/empty-state.tsx
Normal file
24
frontend/src/components/features/home/shared/empty-state.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
|
||||
interface EmptyStateProps {
|
||||
inputValue: string;
|
||||
searchMessage?: string;
|
||||
emptyMessage?: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
inputValue,
|
||||
searchMessage = "No items found",
|
||||
emptyMessage = "No items available",
|
||||
testId = "dropdown-empty",
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<li
|
||||
className="px-3 py-2 text-[#B7BDC2] text-sm rounded-lg mx-0.5 my-0.5"
|
||||
data-testid={testId}
|
||||
>
|
||||
{inputValue ? searchMessage : emptyMessage}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
import React from "react";
|
||||
|
||||
interface ErrorMessageProps {
|
||||
isError: boolean;
|
||||
message?: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export function ErrorMessage({
|
||||
isError,
|
||||
message = "Failed to load data",
|
||||
testId = "dropdown-error",
|
||||
}: ErrorMessageProps) {
|
||||
if (!isError) return null;
|
||||
|
||||
return (
|
||||
<div className="text-red-500 text-sm mt-1" data-testid={testId}>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,74 @@
|
||||
import React from "react";
|
||||
import {
|
||||
UseComboboxGetMenuPropsOptions,
|
||||
UseComboboxGetItemPropsOptions,
|
||||
} from "downshift";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
export interface GenericDropdownMenuProps<T> {
|
||||
isOpen: boolean;
|
||||
filteredItems: T[];
|
||||
inputValue: string;
|
||||
highlightedIndex: number;
|
||||
selectedItem: T | null;
|
||||
getMenuProps: <Options>(
|
||||
options?: UseComboboxGetMenuPropsOptions & Options,
|
||||
) => any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
getItemProps: <Options>(
|
||||
options: UseComboboxGetItemPropsOptions<T> & Options,
|
||||
) => any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
onScroll?: (event: React.UIEvent<HTMLUListElement>) => void;
|
||||
menuRef?: React.RefObject<HTMLUListElement | null>;
|
||||
renderItem: (
|
||||
item: T,
|
||||
index: number,
|
||||
highlightedIndex: number,
|
||||
selectedItem: T | null,
|
||||
getItemProps: <Options>(
|
||||
options: UseComboboxGetItemPropsOptions<T> & Options,
|
||||
) => any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
) => React.ReactNode;
|
||||
renderEmptyState: (inputValue: string) => React.ReactNode;
|
||||
}
|
||||
|
||||
export function GenericDropdownMenu<T>({
|
||||
isOpen,
|
||||
filteredItems,
|
||||
inputValue,
|
||||
highlightedIndex,
|
||||
selectedItem,
|
||||
getMenuProps,
|
||||
getItemProps,
|
||||
onScroll,
|
||||
menuRef,
|
||||
renderItem,
|
||||
renderEmptyState,
|
||||
}: GenericDropdownMenuProps<T>) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<ul
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...getMenuProps({
|
||||
ref: menuRef,
|
||||
className: cn(
|
||||
"absolute z-10 w-full bg-[#454545] border border-[#717888] rounded-xl shadow-lg max-h-60 overflow-auto",
|
||||
"focus:outline-none p-1 gap-2 flex flex-col",
|
||||
),
|
||||
onScroll,
|
||||
})}
|
||||
>
|
||||
{filteredItems.length === 0
|
||||
? renderEmptyState(inputValue)
|
||||
: filteredItems.map((item, index) =>
|
||||
renderItem(
|
||||
item,
|
||||
index,
|
||||
highlightedIndex,
|
||||
selectedItem,
|
||||
getItemProps,
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
7
frontend/src/components/features/home/shared/index.ts
Normal file
7
frontend/src/components/features/home/shared/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export { GenericDropdownMenu } from "./generic-dropdown-menu";
|
||||
export { EmptyState } from "./empty-state";
|
||||
export { ErrorMessage } from "./error-message";
|
||||
export { LoadingSpinner } from "./loading-spinner";
|
||||
export { ClearButton } from "./clear-button";
|
||||
export { ToggleButton } from "./toggle-button";
|
||||
export type { GenericDropdownMenuProps } from "./generic-dropdown-menu";
|
||||
@ -0,0 +1,26 @@
|
||||
import React from "react";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
hasSelection: boolean;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export function LoadingSpinner({
|
||||
hasSelection,
|
||||
testId = "dropdown-loading",
|
||||
}: LoadingSpinnerProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-1/2 transform -translate-y-1/2",
|
||||
hasSelection ? "right-16" : "right-12",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="animate-spin h-4 w-4 border-2 border-blue-500 border-t-transparent rounded-full"
|
||||
data-testid={testId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
import React from "react";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface ToggleButtonProps {
|
||||
isOpen: boolean;
|
||||
disabled: boolean;
|
||||
getToggleButtonProps: (
|
||||
props?: Record<string, unknown>,
|
||||
) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function ToggleButton({
|
||||
isOpen,
|
||||
disabled,
|
||||
getToggleButtonProps,
|
||||
}: ToggleButtonProps) {
|
||||
return (
|
||||
<button
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...getToggleButtonProps({
|
||||
disabled,
|
||||
className: cn(
|
||||
"p-1 text-[#B7BDC2] hover:text-[#ECEDEE]",
|
||||
"disabled:cursor-not-allowed disabled:opacity-60",
|
||||
),
|
||||
})}
|
||||
type="button"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
<svg
|
||||
className={cn("w-4 h-4 transition-transform", isOpen && "rotate-180")}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@ -5,7 +5,7 @@ import { Spinner } from "@heroui/react";
|
||||
import { MicroagentManagementSidebarHeader } from "./microagent-management-sidebar-header";
|
||||
import { MicroagentManagementSidebarTabs } from "./microagent-management-sidebar-tabs";
|
||||
import { useGitRepositories } from "#/hooks/query/use-git-repositories";
|
||||
import { GitProviderDropdown } from "#/components/common/git-provider-dropdown";
|
||||
import { GitProviderDropdown } from "#/components/features/home/git-provider-dropdown";
|
||||
import {
|
||||
setPersonalRepositories,
|
||||
setOrganizationRepositories,
|
||||
@ -16,7 +16,6 @@ import { Provider } from "#/types/settings";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { sanitizeQuery } from "#/utils/sanitize-query";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { getGitProviderMicroagentManagementCustomStyles } from "#/components/common/react-select-styles";
|
||||
|
||||
interface MicroagentManagementSidebarProps {
|
||||
isSmallerScreen?: boolean;
|
||||
@ -123,8 +122,6 @@ export function MicroagentManagementSidebar({
|
||||
placeholder="Select Provider"
|
||||
onChange={handleProviderChange}
|
||||
className="w-full"
|
||||
classNamePrefix="git-provider-dropdown"
|
||||
styles={getGitProviderMicroagentManagementCustomStyles()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
126
frontend/src/hooks/query/use-branch-data.ts
Normal file
126
frontend/src/hooks/query/use-branch-data.ts
Normal file
@ -0,0 +1,126 @@
|
||||
import { useMemo } from "react";
|
||||
import { useRepositoryBranchesPaginated } from "./use-repository-branches";
|
||||
import { useSearchBranches } from "./use-search-branches";
|
||||
import { Branch } from "#/types/git";
|
||||
import { Provider } from "#/types/settings";
|
||||
|
||||
export function useBranchData(
|
||||
repository: string | null,
|
||||
provider: Provider,
|
||||
defaultBranch: string | null,
|
||||
processedSearchInput: string,
|
||||
inputValue: string,
|
||||
selectedBranch?: Branch | null,
|
||||
) {
|
||||
// Fetch branches with pagination
|
||||
const {
|
||||
data: branchData,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
isError,
|
||||
} = useRepositoryBranchesPaginated(repository);
|
||||
|
||||
// Search branches when user types
|
||||
const { data: searchData, isLoading: isSearchLoading } = useSearchBranches(
|
||||
repository,
|
||||
processedSearchInput,
|
||||
30,
|
||||
provider,
|
||||
);
|
||||
|
||||
// Combine all branches from paginated data
|
||||
const allBranches = useMemo(
|
||||
() => branchData?.pages?.flatMap((page) => page.branches) || [],
|
||||
[branchData],
|
||||
);
|
||||
|
||||
// Check if default branch is in the loaded branches
|
||||
const defaultBranchInLoaded = useMemo(
|
||||
() =>
|
||||
defaultBranch
|
||||
? allBranches.find((branch) => branch.name === defaultBranch)
|
||||
: null,
|
||||
[allBranches, defaultBranch],
|
||||
);
|
||||
|
||||
// Only search for default branch if it's not already in the loaded branches
|
||||
// and we have loaded some branches (to avoid searching immediately on mount)
|
||||
const shouldSearchDefaultBranch =
|
||||
defaultBranch &&
|
||||
!defaultBranchInLoaded &&
|
||||
allBranches.length > 0 &&
|
||||
!processedSearchInput; // Don't search for default branch when user is searching
|
||||
|
||||
const { data: defaultBranchData, isLoading: isDefaultBranchLoading } =
|
||||
useSearchBranches(
|
||||
repository,
|
||||
shouldSearchDefaultBranch ? defaultBranch : "",
|
||||
30,
|
||||
provider,
|
||||
);
|
||||
|
||||
// Get branches to display with default branch prioritized
|
||||
const branches = useMemo(() => {
|
||||
// Don't use search results if input exactly matches selected branch
|
||||
const shouldUseSearch =
|
||||
processedSearchInput &&
|
||||
searchData &&
|
||||
!(selectedBranch && inputValue === selectedBranch.name);
|
||||
|
||||
let branchesToUse = shouldUseSearch ? searchData : allBranches;
|
||||
|
||||
// If we have a default branch, ensure it's at the top of the list
|
||||
if (defaultBranch) {
|
||||
// Use the already computed defaultBranchInLoaded or check in current branches
|
||||
let defaultBranchObj = shouldUseSearch
|
||||
? branchesToUse.find((branch) => branch.name === defaultBranch)
|
||||
: defaultBranchInLoaded;
|
||||
|
||||
// If not found in current branches, check if we have it from the default branch search
|
||||
if (
|
||||
!defaultBranchObj &&
|
||||
defaultBranchData &&
|
||||
defaultBranchData.length > 0
|
||||
) {
|
||||
defaultBranchObj = defaultBranchData.find(
|
||||
(branch) => branch.name === defaultBranch,
|
||||
);
|
||||
|
||||
// Add the default branch to the beginning of the list
|
||||
if (defaultBranchObj) {
|
||||
branchesToUse = [defaultBranchObj, ...branchesToUse];
|
||||
}
|
||||
} else if (defaultBranchObj) {
|
||||
// If found in current branches, move it to the front
|
||||
const otherBranches = branchesToUse.filter(
|
||||
(branch) => branch.name !== defaultBranch,
|
||||
);
|
||||
branchesToUse = [defaultBranchObj, ...otherBranches];
|
||||
}
|
||||
}
|
||||
|
||||
return branchesToUse;
|
||||
}, [
|
||||
processedSearchInput,
|
||||
searchData,
|
||||
allBranches,
|
||||
selectedBranch,
|
||||
inputValue,
|
||||
defaultBranch,
|
||||
defaultBranchInLoaded,
|
||||
defaultBranchData,
|
||||
]);
|
||||
|
||||
return {
|
||||
branches,
|
||||
allBranches,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isLoading: isLoading || isDefaultBranchLoading,
|
||||
isFetchingNextPage,
|
||||
isError,
|
||||
isSearchLoading,
|
||||
};
|
||||
}
|
||||
@ -1,14 +1,46 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useInfiniteQuery } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { Branch } from "#/types/git";
|
||||
import { Branch, PaginatedBranchesResponse } from "#/types/git";
|
||||
|
||||
export const useRepositoryBranches = (repository: string | null) =>
|
||||
useQuery<Branch[]>({
|
||||
queryKey: ["repository", repository, "branches"],
|
||||
queryFn: async () => {
|
||||
if (!repository) return [];
|
||||
return OpenHands.getRepositoryBranches(repository);
|
||||
const response = await OpenHands.getRepositoryBranches(repository);
|
||||
// Ensure we return an array even if the response is malformed
|
||||
return Array.isArray(response.branches) ? response.branches : [];
|
||||
},
|
||||
enabled: !!repository,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
|
||||
export const useRepositoryBranchesPaginated = (
|
||||
repository: string | null,
|
||||
perPage: number = 30,
|
||||
) =>
|
||||
useInfiniteQuery<PaginatedBranchesResponse, Error>({
|
||||
queryKey: ["repository", repository, "branches", "paginated", perPage],
|
||||
queryFn: async ({ pageParam = 1 }) => {
|
||||
if (!repository) {
|
||||
return {
|
||||
branches: [],
|
||||
has_next_page: false,
|
||||
current_page: 1,
|
||||
per_page: perPage,
|
||||
total_count: 0,
|
||||
};
|
||||
}
|
||||
return OpenHands.getRepositoryBranches(
|
||||
repository,
|
||||
pageParam as number,
|
||||
perPage,
|
||||
);
|
||||
},
|
||||
enabled: !!repository,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
getNextPageParam: (lastPage) =>
|
||||
// Use the has_next_page flag from the API response
|
||||
lastPage.has_next_page ? lastPage.current_page + 1 : undefined,
|
||||
initialPageParam: 1,
|
||||
});
|
||||
|
||||
35
frontend/src/hooks/query/use-search-branches.ts
Normal file
35
frontend/src/hooks/query/use-search-branches.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { Branch } from "#/types/git";
|
||||
import { Provider } from "#/types/settings";
|
||||
|
||||
export function useSearchBranches(
|
||||
repository: string | null,
|
||||
query: string,
|
||||
perPage: number = 30,
|
||||
selectedProvider?: Provider,
|
||||
) {
|
||||
return useQuery<Branch[]>({
|
||||
queryKey: [
|
||||
"repository",
|
||||
repository,
|
||||
"branches",
|
||||
"search",
|
||||
query,
|
||||
perPage,
|
||||
selectedProvider,
|
||||
],
|
||||
queryFn: async () => {
|
||||
if (!repository || !query) return [];
|
||||
return OpenHands.searchRepositoryBranches(
|
||||
repository,
|
||||
query,
|
||||
perPage,
|
||||
selectedProvider,
|
||||
);
|
||||
},
|
||||
enabled: !!repository && !!query,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
gcTime: 1000 * 60 * 15,
|
||||
});
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
import { delay, http, HttpResponse } from "msw";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { GitRepository, Branch, PaginatedBranchesResponse } from "#/types/git";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { RepositoryMicroagent } from "#/types/microagent-management";
|
||||
import { MicroagentContentResponse } from "#/api/open-hands.types";
|
||||
|
||||
// Generate a list of mock repositories with realistic data
|
||||
const generateMockRepositories = (
|
||||
@ -19,6 +21,32 @@ const generateMockRepositories = (
|
||||
owner_type: Math.random() > 0.7 ? "organization" : "user", // 30% chance of being organization
|
||||
}));
|
||||
|
||||
// Generate mock branches for a repository
|
||||
const generateMockBranches = (count: number): Branch[] =>
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
name: (() => {
|
||||
if (i === 0) return "main";
|
||||
if (i === 1) return "develop";
|
||||
return `feature/branch-${i}`;
|
||||
})(),
|
||||
commit_sha: `abc123${i.toString().padStart(3, "0")}`,
|
||||
protected: i === 0, // main branch is protected
|
||||
last_push_date: new Date(
|
||||
Date.now() - Math.random() * 7 * 24 * 60 * 60 * 1000,
|
||||
).toISOString(),
|
||||
}));
|
||||
|
||||
// Generate mock microagents for a repository
|
||||
const generateMockMicroagents = (count: number): RepositoryMicroagent[] =>
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
name: `microagent-${i + 1}`,
|
||||
path: `.openhands/microagents/microagent-${i + 1}.md`,
|
||||
created_at: new Date(
|
||||
Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000,
|
||||
).toISOString(),
|
||||
git_provider: "github",
|
||||
}));
|
||||
|
||||
// Mock repositories for each provider
|
||||
const MOCK_REPOSITORIES = {
|
||||
github: generateMockRepositories(120, "github"),
|
||||
@ -26,6 +54,12 @@ const MOCK_REPOSITORIES = {
|
||||
bitbucket: generateMockRepositories(120, "bitbucket"),
|
||||
};
|
||||
|
||||
// Mock branches (same for all repos for simplicity)
|
||||
const MOCK_BRANCHES = generateMockBranches(25);
|
||||
|
||||
// Mock microagents (same for all repos for simplicity)
|
||||
const MOCK_MICROAGENTS = generateMockMicroagents(5);
|
||||
|
||||
export const GIT_REPOSITORY_HANDLERS = [
|
||||
http.get("/api/user/repositories", async ({ request }) => {
|
||||
await delay(500); // Simulate network delay
|
||||
@ -154,4 +188,138 @@ export const GIT_REPOSITORY_HANDLERS = [
|
||||
|
||||
return HttpResponse.json(limitedRepos);
|
||||
}),
|
||||
|
||||
// Repository branches endpoint
|
||||
http.get("/api/user/repository/branches", async ({ request }) => {
|
||||
await delay(300);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const repository = url.searchParams.get("repository");
|
||||
const page = parseInt(url.searchParams.get("page") || "1", 10);
|
||||
const perPage = parseInt(url.searchParams.get("per_page") || "30", 10);
|
||||
|
||||
if (!repository) {
|
||||
return HttpResponse.json("Repository parameter is required", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate pagination
|
||||
const startIndex = (page - 1) * perPage;
|
||||
const endIndex = startIndex + perPage;
|
||||
const paginatedBranches = MOCK_BRANCHES.slice(startIndex, endIndex);
|
||||
const hasNextPage = endIndex < MOCK_BRANCHES.length;
|
||||
|
||||
const response: PaginatedBranchesResponse = {
|
||||
branches: paginatedBranches,
|
||||
has_next_page: hasNextPage,
|
||||
current_page: page,
|
||||
per_page: perPage,
|
||||
total_count: MOCK_BRANCHES.length,
|
||||
};
|
||||
|
||||
return HttpResponse.json(response);
|
||||
}),
|
||||
|
||||
// Search repository branches endpoint
|
||||
http.get("/api/user/search/branches", async ({ request }) => {
|
||||
await delay(200);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const repository = url.searchParams.get("repository");
|
||||
const query = url.searchParams.get("query") || "";
|
||||
const perPage = parseInt(url.searchParams.get("per_page") || "30", 10);
|
||||
|
||||
if (!repository) {
|
||||
return HttpResponse.json("Repository parameter is required", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Filter branches by search query
|
||||
const filteredBranches = MOCK_BRANCHES.filter((branch) =>
|
||||
branch.name.toLowerCase().includes(query.toLowerCase()),
|
||||
);
|
||||
|
||||
// Limit results
|
||||
const limitedBranches = filteredBranches.slice(0, perPage);
|
||||
|
||||
return HttpResponse.json(limitedBranches);
|
||||
}),
|
||||
|
||||
// Repository microagents endpoint
|
||||
http.get(
|
||||
"/api/user/repository/:owner/:repo/microagents",
|
||||
async ({ params }) => {
|
||||
await delay(400);
|
||||
|
||||
const { owner, repo } = params;
|
||||
|
||||
if (!owner || !repo) {
|
||||
return HttpResponse.json("Owner and repo parameters are required", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return HttpResponse.json(MOCK_MICROAGENTS);
|
||||
},
|
||||
),
|
||||
|
||||
// Repository microagent content endpoint
|
||||
http.get(
|
||||
"/api/user/repository/:owner/:repo/microagents/content",
|
||||
async ({ request, params }) => {
|
||||
await delay(300);
|
||||
|
||||
const { owner, repo } = params;
|
||||
const url = new URL(request.url);
|
||||
const filePath = url.searchParams.get("file_path");
|
||||
|
||||
if (!owner || !repo || !filePath) {
|
||||
return HttpResponse.json(
|
||||
"Owner, repo, and file_path parameters are required",
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Find the microagent by path
|
||||
const microagent = MOCK_MICROAGENTS.find((m) => m.path === filePath);
|
||||
|
||||
if (!microagent) {
|
||||
return HttpResponse.json("Microagent not found", { status: 404 });
|
||||
}
|
||||
|
||||
const response: MicroagentContentResponse = {
|
||||
content: `# ${microagent.name}
|
||||
|
||||
A helpful microagent for repository tasks.
|
||||
|
||||
## Instructions
|
||||
|
||||
This microagent helps with specific tasks related to the repository.
|
||||
|
||||
### Usage
|
||||
|
||||
1. Describe your task clearly
|
||||
2. The microagent will analyze the context
|
||||
3. Follow the provided recommendations
|
||||
|
||||
### Capabilities
|
||||
|
||||
- Code analysis
|
||||
- Task automation
|
||||
- Best practice recommendations
|
||||
- Error detection and resolution
|
||||
|
||||
---
|
||||
|
||||
*Generated mock content for ${microagent.name}*`,
|
||||
path: microagent.path,
|
||||
git_provider: "github",
|
||||
triggers: ["code review", "bug fix", "feature development"],
|
||||
};
|
||||
|
||||
return HttpResponse.json(response);
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
9
frontend/src/types/git.d.ts
vendored
9
frontend/src/types/git.d.ts
vendored
@ -22,6 +22,14 @@ interface Branch {
|
||||
last_push_date?: string;
|
||||
}
|
||||
|
||||
interface PaginatedBranchesResponse {
|
||||
branches: Branch[];
|
||||
has_next_page: boolean;
|
||||
current_page: number;
|
||||
per_page: number;
|
||||
total_count?: number;
|
||||
}
|
||||
|
||||
interface GitRepository {
|
||||
id: string;
|
||||
full_name: string;
|
||||
@ -31,6 +39,7 @@ interface GitRepository {
|
||||
link_header?: string;
|
||||
pushed_at?: string;
|
||||
owner_type?: "user" | "organization";
|
||||
main_branch?: string;
|
||||
}
|
||||
|
||||
interface GitHubCommit {
|
||||
|
||||
@ -5,6 +5,7 @@ import "@testing-library/jest-dom/vitest";
|
||||
|
||||
HTMLCanvasElement.prototype.getContext = vi.fn();
|
||||
HTMLElement.prototype.scrollTo = vi.fn();
|
||||
window.scrollTo = vi.fn();
|
||||
|
||||
// Mock the i18n provider
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
|
||||
@ -13,6 +13,7 @@ from openhands.integrations.service_types import (
|
||||
GitService,
|
||||
InstallationsService,
|
||||
OwnerType,
|
||||
PaginatedBranchesResponse,
|
||||
ProviderType,
|
||||
Repository,
|
||||
RequestMethod,
|
||||
@ -551,6 +552,83 @@ class BitBucketService(BaseGitService, GitService, InstallationsService):
|
||||
|
||||
return branches
|
||||
|
||||
async def get_paginated_branches(
|
||||
self, repository: str, page: int = 1, per_page: int = 30
|
||||
) -> PaginatedBranchesResponse:
|
||||
"""Get branches for a repository with pagination."""
|
||||
# Extract owner and repo from the repository string (e.g., "owner/repo")
|
||||
parts = repository.split('/')
|
||||
if len(parts) < 2:
|
||||
raise ValueError(f'Invalid repository name: {repository}')
|
||||
|
||||
owner = parts[-2]
|
||||
repo = parts[-1]
|
||||
|
||||
url = f'{self.BASE_URL}/repositories/{owner}/{repo}/refs/branches'
|
||||
|
||||
params = {
|
||||
'pagelen': per_page,
|
||||
'page': page,
|
||||
'sort': '-target.date', # Sort by most recent commit date, descending
|
||||
}
|
||||
|
||||
response, _ = await self._make_request(url, params)
|
||||
|
||||
branches = []
|
||||
for branch in response.get('values', []):
|
||||
branches.append(
|
||||
Branch(
|
||||
name=branch.get('name', ''),
|
||||
commit_sha=branch.get('target', {}).get('hash', ''),
|
||||
protected=False, # Bitbucket doesn't expose this in the API
|
||||
last_push_date=branch.get('target', {}).get('date', None),
|
||||
)
|
||||
)
|
||||
|
||||
# Bitbucket provides pagination info in the response
|
||||
has_next_page = response.get('next') is not None
|
||||
total_count = response.get('size') # Total number of items
|
||||
|
||||
return PaginatedBranchesResponse(
|
||||
branches=branches,
|
||||
has_next_page=has_next_page,
|
||||
current_page=page,
|
||||
per_page=per_page,
|
||||
total_count=total_count,
|
||||
)
|
||||
|
||||
async def search_branches(
|
||||
self, repository: str, query: str, per_page: int = 30
|
||||
) -> list[Branch]:
|
||||
"""Search branches by name using Bitbucket API with `q` param."""
|
||||
parts = repository.split('/')
|
||||
if len(parts) < 2:
|
||||
raise ValueError(f'Invalid repository name: {repository}')
|
||||
|
||||
owner = parts[-2]
|
||||
repo = parts[-1]
|
||||
|
||||
url = f'{self.BASE_URL}/repositories/{owner}/{repo}/refs/branches'
|
||||
# Bitbucket filtering: name ~ "query"
|
||||
params = {
|
||||
'pagelen': per_page,
|
||||
'q': f'name~"{query}"',
|
||||
'sort': '-target.date',
|
||||
}
|
||||
response, _ = await self._make_request(url, params)
|
||||
|
||||
branches: list[Branch] = []
|
||||
for branch in response.get('values', []):
|
||||
branches.append(
|
||||
Branch(
|
||||
name=branch.get('name', ''),
|
||||
commit_sha=branch.get('target', {}).get('hash', ''),
|
||||
protected=False,
|
||||
last_push_date=branch.get('target', {}).get('date', None),
|
||||
)
|
||||
)
|
||||
return branches
|
||||
|
||||
async def create_pr(
|
||||
self,
|
||||
repo_name: str,
|
||||
|
||||
@ -12,6 +12,7 @@ from openhands.integrations.github.queries import (
|
||||
get_review_threads_graphql_query,
|
||||
get_thread_comments_graphql_query,
|
||||
get_thread_from_comment_graphql_query,
|
||||
search_branches_graphql_query,
|
||||
suggested_task_issue_graphql_query,
|
||||
suggested_task_pr_graphql_query,
|
||||
)
|
||||
@ -22,6 +23,7 @@ from openhands.integrations.service_types import (
|
||||
GitService,
|
||||
InstallationsService,
|
||||
OwnerType,
|
||||
PaginatedBranchesResponse,
|
||||
ProviderType,
|
||||
Repository,
|
||||
RequestMethod,
|
||||
@ -252,6 +254,7 @@ class GitHubService(BaseGitService, GitService, InstallationsService):
|
||||
else OwnerType.USER
|
||||
),
|
||||
link_header=link_header,
|
||||
main_branch=repo.get('default_branch'),
|
||||
)
|
||||
|
||||
async def get_paginated_repos(
|
||||
@ -619,6 +622,109 @@ class GitHubService(BaseGitService, GitService, InstallationsService):
|
||||
|
||||
return all_branches
|
||||
|
||||
async def get_paginated_branches(
|
||||
self, repository: str, page: int = 1, per_page: int = 30
|
||||
) -> PaginatedBranchesResponse:
|
||||
"""Get branches for a repository with pagination"""
|
||||
url = f'{self.BASE_URL}/repos/{repository}/branches'
|
||||
|
||||
params = {'per_page': str(per_page), 'page': str(page)}
|
||||
response, headers = await self._make_request(url, params)
|
||||
|
||||
branches: list[Branch] = []
|
||||
for branch_data in response:
|
||||
# Extract the last commit date if available
|
||||
last_push_date = None
|
||||
if branch_data.get('commit') and branch_data['commit'].get('commit'):
|
||||
commit_info = branch_data['commit']['commit']
|
||||
if commit_info.get('committer') and commit_info['committer'].get(
|
||||
'date'
|
||||
):
|
||||
last_push_date = commit_info['committer']['date']
|
||||
|
||||
branch = Branch(
|
||||
name=branch_data.get('name'),
|
||||
commit_sha=branch_data.get('commit', {}).get('sha', ''),
|
||||
protected=branch_data.get('protected', False),
|
||||
last_push_date=last_push_date,
|
||||
)
|
||||
branches.append(branch)
|
||||
|
||||
# Parse Link header to determine if there's a next page
|
||||
has_next_page = False
|
||||
if 'Link' in headers:
|
||||
link_header = headers['Link']
|
||||
has_next_page = 'rel="next"' in link_header
|
||||
|
||||
return PaginatedBranchesResponse(
|
||||
branches=branches,
|
||||
has_next_page=has_next_page,
|
||||
current_page=page,
|
||||
per_page=per_page,
|
||||
total_count=None, # GitHub doesn't provide total count in branch API
|
||||
)
|
||||
|
||||
async def search_branches(
|
||||
self, repository: str, query: str, per_page: int = 30
|
||||
) -> list[Branch]:
|
||||
"""Search branches by name using GitHub GraphQL with a partial query."""
|
||||
# Require a non-empty query
|
||||
if not query:
|
||||
return []
|
||||
|
||||
# Clamp per_page to GitHub GraphQL limits
|
||||
per_page = min(max(per_page, 1), 100)
|
||||
|
||||
# Extract owner and repo name from the repository string
|
||||
parts = repository.split('/')
|
||||
if len(parts) < 2:
|
||||
return []
|
||||
owner, name = parts[-2], parts[-1]
|
||||
|
||||
variables = {
|
||||
'owner': owner,
|
||||
'name': name,
|
||||
'query': query or '',
|
||||
'perPage': per_page,
|
||||
}
|
||||
|
||||
try:
|
||||
result = await self.execute_graphql_query(
|
||||
search_branches_graphql_query, variables
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to search for branches: {e}')
|
||||
# Fallback to empty result on any GraphQL error
|
||||
return []
|
||||
|
||||
repo = result.get('data', {}).get('repository')
|
||||
if not repo or not repo.get('refs'):
|
||||
return []
|
||||
|
||||
branches: list[Branch] = []
|
||||
for node in repo['refs'].get('nodes', []):
|
||||
bname = node.get('name') or ''
|
||||
target = node.get('target') or {}
|
||||
typename = target.get('__typename')
|
||||
commit_sha = ''
|
||||
last_push_date = None
|
||||
if typename == 'Commit':
|
||||
commit_sha = target.get('oid', '') or ''
|
||||
last_push_date = target.get('committedDate')
|
||||
|
||||
protected = node.get('branchProtectionRule') is not None
|
||||
|
||||
branches.append(
|
||||
Branch(
|
||||
name=bname,
|
||||
commit_sha=commit_sha,
|
||||
protected=protected,
|
||||
last_push_date=last_push_date,
|
||||
)
|
||||
)
|
||||
|
||||
return branches
|
||||
|
||||
async def create_pr(
|
||||
self,
|
||||
repo_name: str,
|
||||
|
||||
@ -122,3 +122,32 @@ query ($threadId: ID!, $page: Int = 50, $after: String) {
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
# Search branches in a repository by partial name using GitHub GraphQL.
|
||||
# This leverages the `refs` connection with:
|
||||
# - refPrefix: "refs/heads/" to restrict to branches
|
||||
# - query: partial branch name provided by the user
|
||||
# - first: pagination size (clamped by caller to GitHub limits)
|
||||
search_branches_graphql_query = """
|
||||
query SearchBranches($owner: String!, $name: String!, $query: String!, $perPage: Int!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
refs(
|
||||
refPrefix: "refs/heads/",
|
||||
query: $query,
|
||||
first: $perPage,
|
||||
orderBy: { field: ALPHABETICAL, direction: ASC }
|
||||
) {
|
||||
nodes {
|
||||
name
|
||||
target {
|
||||
__typename
|
||||
... on Commit {
|
||||
oid
|
||||
committedDate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
@ -12,6 +12,7 @@ from openhands.integrations.service_types import (
|
||||
Comment,
|
||||
GitService,
|
||||
OwnerType,
|
||||
PaginatedBranchesResponse,
|
||||
ProviderType,
|
||||
Repository,
|
||||
RequestMethod,
|
||||
@ -265,6 +266,7 @@ class GitLabService(BaseGitService, GitService):
|
||||
else OwnerType.USER
|
||||
),
|
||||
link_header=link_header,
|
||||
main_branch=repo.get('default_branch'),
|
||||
)
|
||||
|
||||
def _parse_gitlab_url(self, url: str) -> str | None:
|
||||
@ -577,6 +579,68 @@ class GitLabService(BaseGitService, GitService):
|
||||
|
||||
return all_branches
|
||||
|
||||
async def get_paginated_branches(
|
||||
self, repository: str, page: int = 1, per_page: int = 30
|
||||
) -> PaginatedBranchesResponse:
|
||||
"""Get branches for a repository with pagination"""
|
||||
encoded_name = repository.replace('/', '%2F')
|
||||
url = f'{self.BASE_URL}/projects/{encoded_name}/repository/branches'
|
||||
|
||||
params = {'per_page': str(per_page), 'page': str(page)}
|
||||
response, headers = await self._make_request(url, params)
|
||||
|
||||
branches: list[Branch] = []
|
||||
for branch_data in response:
|
||||
branch = Branch(
|
||||
name=branch_data.get('name'),
|
||||
commit_sha=branch_data.get('commit', {}).get('id', ''),
|
||||
protected=branch_data.get('protected', False),
|
||||
last_push_date=branch_data.get('commit', {}).get('committed_date'),
|
||||
)
|
||||
branches.append(branch)
|
||||
|
||||
# Parse pagination headers
|
||||
has_next_page = False
|
||||
total_count = None
|
||||
|
||||
if 'X-Next-Page' in headers and headers['X-Next-Page']:
|
||||
has_next_page = True
|
||||
if 'X-Total' in headers:
|
||||
try:
|
||||
total_count = int(headers['X-Total'])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return PaginatedBranchesResponse(
|
||||
branches=branches,
|
||||
has_next_page=has_next_page,
|
||||
current_page=page,
|
||||
per_page=per_page,
|
||||
total_count=total_count,
|
||||
)
|
||||
|
||||
async def search_branches(
|
||||
self, repository: str, query: str, per_page: int = 30
|
||||
) -> list[Branch]:
|
||||
"""Search branches using GitLab API which supports `search` param."""
|
||||
encoded_name = repository.replace('/', '%2F')
|
||||
url = f'{self.BASE_URL}/projects/{encoded_name}/repository/branches'
|
||||
|
||||
params = {'per_page': str(per_page), 'search': query}
|
||||
response, _ = await self._make_request(url, params)
|
||||
|
||||
branches: list[Branch] = []
|
||||
for branch_data in response:
|
||||
branches.append(
|
||||
Branch(
|
||||
name=branch_data.get('name'),
|
||||
commit_sha=branch_data.get('commit', {}).get('id', ''),
|
||||
protected=branch_data.get('protected', False),
|
||||
last_push_date=branch_data.get('commit', {}).get('committed_date'),
|
||||
)
|
||||
)
|
||||
return branches
|
||||
|
||||
async def create_mr(
|
||||
self,
|
||||
id: int | str,
|
||||
|
||||
@ -26,6 +26,7 @@ from openhands.integrations.service_types import (
|
||||
GitService,
|
||||
InstallationsService,
|
||||
MicroagentParseError,
|
||||
PaginatedBranchesResponse,
|
||||
ProviderType,
|
||||
Repository,
|
||||
ResourceNotFoundError,
|
||||
@ -256,6 +257,33 @@ class ProviderHandler:
|
||||
|
||||
return tasks
|
||||
|
||||
async def search_branches(
|
||||
self,
|
||||
selected_provider: ProviderType | None,
|
||||
repository: str,
|
||||
query: str,
|
||||
per_page: int = 30,
|
||||
) -> list[Branch]:
|
||||
"""Search for branches within a repository using the appropriate provider service."""
|
||||
if selected_provider:
|
||||
service = self._get_service(selected_provider)
|
||||
try:
|
||||
return await service.search_branches(repository, query, per_page)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'Error searching branches from selected provider {selected_provider}: {e}'
|
||||
)
|
||||
return []
|
||||
|
||||
# If provider not specified, determine provider by verifying repository access
|
||||
try:
|
||||
repo_details = await self.verify_repo_provider(repository)
|
||||
service = self._get_service(repo_details.git_provider)
|
||||
return await service.search_branches(repository, query, per_page)
|
||||
except Exception as e:
|
||||
logger.warning(f'Error searching branches for {repository}: {e}')
|
||||
return []
|
||||
|
||||
async def search_repositories(
|
||||
self,
|
||||
selected_provider: ProviderType | None,
|
||||
@ -442,24 +470,27 @@ class ProviderHandler:
|
||||
raise AuthenticationError(f'Unable to access repo {repository}')
|
||||
|
||||
async def get_branches(
|
||||
self, repository: str, specified_provider: ProviderType | None = None
|
||||
) -> list[Branch]:
|
||||
self,
|
||||
repository: str,
|
||||
specified_provider: ProviderType | None = None,
|
||||
page: int = 1,
|
||||
per_page: int = 30,
|
||||
) -> PaginatedBranchesResponse:
|
||||
"""Get branches for a repository
|
||||
|
||||
Args:
|
||||
repository: The repository name
|
||||
specified_provider: Optional provider type to use
|
||||
page: Page number for pagination (default: 1)
|
||||
per_page: Number of branches per page (default: 30)
|
||||
|
||||
Returns:
|
||||
A list of branches for the repository
|
||||
A paginated response with branches for the repository
|
||||
"""
|
||||
all_branches: list[Branch] = []
|
||||
|
||||
if specified_provider:
|
||||
try:
|
||||
service = self._get_service(specified_provider)
|
||||
branches = await service.get_branches(repository)
|
||||
return branches
|
||||
return await service.get_paginated_branches(repository, page, per_page)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'Error fetching branches from {specified_provider}: {e}'
|
||||
@ -468,31 +499,19 @@ class ProviderHandler:
|
||||
for provider in self.provider_tokens:
|
||||
try:
|
||||
service = self._get_service(provider)
|
||||
branches = await service.get_branches(repository)
|
||||
all_branches.extend(branches)
|
||||
# If we found branches, no need to check other providers
|
||||
if all_branches:
|
||||
break
|
||||
return await service.get_paginated_branches(repository, page, per_page)
|
||||
except Exception as e:
|
||||
logger.warning(f'Error fetching branches from {provider}: {e}')
|
||||
|
||||
# Sort branches by last push date (newest first)
|
||||
all_branches.sort(
|
||||
key=lambda b: b.last_push_date if b.last_push_date else '', reverse=True
|
||||
# Return empty response if no provider worked
|
||||
return PaginatedBranchesResponse(
|
||||
branches=[],
|
||||
has_next_page=False,
|
||||
current_page=page,
|
||||
per_page=per_page,
|
||||
total_count=0,
|
||||
)
|
||||
|
||||
# Move main/master branch to the top if it exists
|
||||
main_branches = []
|
||||
other_branches = []
|
||||
|
||||
for branch in all_branches:
|
||||
if branch.name.lower() in ['main', 'master']:
|
||||
main_branches.append(branch)
|
||||
else:
|
||||
other_branches.append(branch)
|
||||
|
||||
return main_branches + other_branches
|
||||
|
||||
async def get_microagents(self, repository: str) -> list[MicroagentResponse]:
|
||||
"""Get microagents from a repository using the appropriate service.
|
||||
|
||||
|
||||
@ -130,6 +130,14 @@ class Branch(BaseModel):
|
||||
last_push_date: str | None = None # ISO 8601 format date string
|
||||
|
||||
|
||||
class PaginatedBranchesResponse(BaseModel):
|
||||
branches: list[Branch]
|
||||
has_next_page: bool
|
||||
current_page: int
|
||||
per_page: int
|
||||
total_count: int | None = None # Some APIs don't provide total count
|
||||
|
||||
|
||||
class Repository(BaseModel):
|
||||
id: str
|
||||
full_name: str
|
||||
@ -511,6 +519,16 @@ class GitService(Protocol):
|
||||
async def get_branches(self, repository: str) -> list[Branch]:
|
||||
"""Get branches for a repository"""
|
||||
|
||||
async def get_paginated_branches(
|
||||
self, repository: str, page: int = 1, per_page: int = 30
|
||||
) -> PaginatedBranchesResponse:
|
||||
"""Get branches for a repository with pagination"""
|
||||
|
||||
async def search_branches(
|
||||
self, repository: str, query: str, per_page: int = 30
|
||||
) -> list[Branch]:
|
||||
"""Search for branches within a repository"""
|
||||
|
||||
async def get_microagents(self, repository: str) -> list[MicroagentResponse]:
|
||||
"""Get microagents from a repository"""
|
||||
...
|
||||
|
||||
@ -13,6 +13,7 @@ from openhands.integrations.provider import (
|
||||
from openhands.integrations.service_types import (
|
||||
AuthenticationError,
|
||||
Branch,
|
||||
PaginatedBranchesResponse,
|
||||
ProviderType,
|
||||
Repository,
|
||||
SuggestedTask,
|
||||
@ -163,6 +164,49 @@ async def search_repositories(
|
||||
raise AuthenticationError('Git provider token required.')
|
||||
|
||||
|
||||
@app.get('/search/branches', response_model=list[Branch])
|
||||
async def search_branches(
|
||||
repository: str,
|
||||
query: str,
|
||||
per_page: int = 30,
|
||||
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[Branch] | JSONResponse:
|
||||
if provider_tokens:
|
||||
client = ProviderHandler(
|
||||
provider_tokens=provider_tokens,
|
||||
external_auth_token=access_token,
|
||||
external_auth_id=user_id,
|
||||
)
|
||||
try:
|
||||
branches: list[Branch] = await client.search_branches(
|
||||
selected_provider, repository, query, per_page
|
||||
)
|
||||
return branches
|
||||
|
||||
except AuthenticationError as e:
|
||||
return JSONResponse(
|
||||
content=str(e),
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
except UnknownException as e:
|
||||
return JSONResponse(
|
||||
content=str(e),
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'Returning 401 Unauthorized - Git provider token required for user_id: {user_id}'
|
||||
)
|
||||
return JSONResponse(
|
||||
content='Git provider token required.',
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
|
||||
@app.get('/suggested-tasks', response_model=list[SuggestedTask])
|
||||
async def get_suggested_tasks(
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
@ -192,28 +236,34 @@ async def get_suggested_tasks(
|
||||
raise AuthenticationError('No providers set.')
|
||||
|
||||
|
||||
@app.get('/repository/branches', response_model=list[Branch])
|
||||
@app.get('/repository/branches', response_model=PaginatedBranchesResponse)
|
||||
async def get_repository_branches(
|
||||
repository: str,
|
||||
page: int = 1,
|
||||
per_page: int = 30,
|
||||
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[Branch] | JSONResponse:
|
||||
) -> PaginatedBranchesResponse | JSONResponse:
|
||||
"""Get branches for a repository.
|
||||
|
||||
Args:
|
||||
repository: The repository name in the format 'owner/repo'
|
||||
page: Page number for pagination (default: 1)
|
||||
per_page: Number of branches per page (default: 30)
|
||||
|
||||
Returns:
|
||||
A list of branches for the repository
|
||||
A paginated response with branches for the repository
|
||||
"""
|
||||
if provider_tokens:
|
||||
client = ProviderHandler(
|
||||
provider_tokens=provider_tokens, external_auth_token=access_token
|
||||
)
|
||||
try:
|
||||
branches: list[Branch] = await client.get_branches(repository)
|
||||
return branches
|
||||
branches_response: PaginatedBranchesResponse = await client.get_branches(
|
||||
repository, page=page, per_page=per_page
|
||||
)
|
||||
return branches_response
|
||||
|
||||
except UnknownException as e:
|
||||
return JSONResponse(
|
||||
|
||||
84
tests/unit/integrations/bitbucket/test_bitbucket_branches.py
Normal file
84
tests/unit/integrations/bitbucket/test_bitbucket_branches.py
Normal file
@ -0,0 +1,84 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.integrations.bitbucket.bitbucket_service import BitBucketService
|
||||
from openhands.integrations.service_types import Branch, PaginatedBranchesResponse
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_paginated_branches_bitbucket_parsing_and_pagination():
|
||||
service = BitBucketService(token=SecretStr('t'))
|
||||
|
||||
mock_response = {
|
||||
'values': [
|
||||
{
|
||||
'name': 'main',
|
||||
'target': {'hash': 'abc', 'date': '2024-01-01T00:00:00Z'},
|
||||
},
|
||||
{
|
||||
'name': 'feature/x',
|
||||
'target': {'hash': 'def', 'date': '2024-01-02T00:00:00Z'},
|
||||
},
|
||||
],
|
||||
'next': 'https://api.bitbucket.org/2.0/repositories/w/r/refs/branches?page=3',
|
||||
'size': 123,
|
||||
}
|
||||
|
||||
with patch.object(service, '_make_request', return_value=(mock_response, {})):
|
||||
res = await service.get_paginated_branches('w/r', page=2, per_page=2)
|
||||
|
||||
assert isinstance(res, PaginatedBranchesResponse)
|
||||
assert res.has_next_page is True
|
||||
assert res.current_page == 2
|
||||
assert res.per_page == 2
|
||||
assert res.total_count == 123
|
||||
assert res.branches == [
|
||||
Branch(
|
||||
name='main',
|
||||
commit_sha='abc',
|
||||
protected=False,
|
||||
last_push_date='2024-01-01T00:00:00Z',
|
||||
),
|
||||
Branch(
|
||||
name='feature/x',
|
||||
commit_sha='def',
|
||||
protected=False,
|
||||
last_push_date='2024-01-02T00:00:00Z',
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_branches_bitbucket_filters_by_name_contains():
|
||||
service = BitBucketService(token=SecretStr('t'))
|
||||
|
||||
mock_response = {
|
||||
'values': [
|
||||
{
|
||||
'name': 'bugfix/issue-1',
|
||||
'target': {'hash': 'hhh', 'date': '2024-01-10T10:00:00Z'},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with patch.object(service, '_make_request', return_value=(mock_response, {})) as m:
|
||||
branches = await service.search_branches('w/r', query='bugfix', per_page=15)
|
||||
|
||||
args, kwargs = m.call_args
|
||||
url = args[0]
|
||||
params = args[1]
|
||||
assert 'refs/branches' in url
|
||||
assert params['pagelen'] == 15
|
||||
assert params['q'] == 'name~"bugfix"'
|
||||
assert params['sort'] == '-target.date'
|
||||
|
||||
assert branches == [
|
||||
Branch(
|
||||
name='bugfix/issue-1',
|
||||
commit_sha='hhh',
|
||||
protected=False,
|
||||
last_push_date='2024-01-10T10:00:00Z',
|
||||
)
|
||||
]
|
||||
168
tests/unit/integrations/github/test_github_branches.py
Normal file
168
tests/unit/integrations/github/test_github_branches.py
Normal file
@ -0,0 +1,168 @@
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.integrations.github.github_service import GitHubService
|
||||
from openhands.integrations.service_types import Branch, PaginatedBranchesResponse
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_paginated_branches_github_basic_next_page():
|
||||
service = GitHubService(token=SecretStr('t'))
|
||||
|
||||
mock_response = [
|
||||
{
|
||||
'name': 'main',
|
||||
'commit': {
|
||||
'sha': 'abc123',
|
||||
'commit': {'committer': {'date': '2024-01-01T12:00:00Z'}},
|
||||
},
|
||||
'protected': True,
|
||||
},
|
||||
{
|
||||
'name': 'feature/foo',
|
||||
'commit': {
|
||||
'sha': 'def456',
|
||||
'commit': {'committer': {'date': '2024-01-02T15:30:00Z'}},
|
||||
},
|
||||
'protected': False,
|
||||
},
|
||||
]
|
||||
headers = {
|
||||
# Include rel="next" to indicate there is another page
|
||||
'Link': '<https://api.github.com/repos/o/r/branches?page=3>; rel="next"'
|
||||
}
|
||||
|
||||
with patch.object(service, '_make_request', return_value=(mock_response, headers)):
|
||||
result = await service.get_paginated_branches('owner/repo', page=2, per_page=2)
|
||||
|
||||
assert isinstance(result, PaginatedBranchesResponse)
|
||||
assert result.current_page == 2
|
||||
assert result.per_page == 2
|
||||
assert result.has_next_page is True
|
||||
assert result.total_count is None # GitHub does not provide total count
|
||||
assert len(result.branches) == 2
|
||||
|
||||
b0, b1 = result.branches
|
||||
assert isinstance(b0, Branch) and isinstance(b1, Branch)
|
||||
assert b0.name == 'main'
|
||||
assert b0.commit_sha == 'abc123'
|
||||
assert b0.protected is True
|
||||
assert b0.last_push_date == '2024-01-01T12:00:00Z'
|
||||
assert b1.name == 'feature/foo'
|
||||
assert b1.commit_sha == 'def456'
|
||||
assert b1.protected is False
|
||||
assert b1.last_push_date == '2024-01-02T15:30:00Z'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_paginated_branches_github_no_next_page():
|
||||
service = GitHubService(token=SecretStr('t'))
|
||||
|
||||
mock_response = [
|
||||
{
|
||||
'name': 'dev',
|
||||
'commit': {
|
||||
'sha': 'zzz999',
|
||||
'commit': {'committer': {'date': '2024-01-03T00:00:00Z'}},
|
||||
},
|
||||
'protected': False,
|
||||
}
|
||||
]
|
||||
headers = {
|
||||
# No rel="next" – should be treated as last page
|
||||
'Link': '<https://api.github.com/repos/o/r/branches?page=2>; rel="prev"'
|
||||
}
|
||||
|
||||
with patch.object(service, '_make_request', return_value=(mock_response, headers)):
|
||||
result = await service.get_paginated_branches('owner/repo', page=1, per_page=1)
|
||||
assert result.has_next_page is False
|
||||
assert len(result.branches) == 1
|
||||
assert result.branches[0].name == 'dev'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_branches_github_success_and_variables():
|
||||
service = GitHubService(token=SecretStr('t'))
|
||||
|
||||
# Prepare a fake GraphQL response structure
|
||||
graphql_result = {
|
||||
'data': {
|
||||
'repository': {
|
||||
'refs': {
|
||||
'nodes': [
|
||||
{
|
||||
'name': 'feature/bar',
|
||||
'target': {
|
||||
'__typename': 'Commit',
|
||||
'oid': 'aaa111',
|
||||
'committedDate': '2024-01-05T10:00:00Z',
|
||||
},
|
||||
'branchProtectionRule': {}, # indicates protected
|
||||
},
|
||||
{
|
||||
'name': 'chore/update',
|
||||
'target': {
|
||||
'__typename': 'Tag',
|
||||
'oid': 'should_be_ignored_for_commit',
|
||||
},
|
||||
'branchProtectionRule': None,
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exec_mock = AsyncMock(return_value=graphql_result)
|
||||
with patch.object(service, 'execute_graphql_query', exec_mock) as mock_exec:
|
||||
branches = await service.search_branches('foo/bar', query='fe', per_page=999)
|
||||
|
||||
# per_page should be clamped to <= 100 when passed to GraphQL variables
|
||||
args, kwargs = mock_exec.call_args
|
||||
_query = args[0]
|
||||
variables = args[1]
|
||||
assert variables['owner'] == 'foo'
|
||||
assert variables['name'] == 'bar'
|
||||
assert variables['query'] == 'fe'
|
||||
assert 1 <= variables['perPage'] <= 100
|
||||
|
||||
assert len(branches) == 2
|
||||
b0, b1 = branches
|
||||
assert b0.name == 'feature/bar'
|
||||
assert b0.commit_sha == 'aaa111'
|
||||
assert b0.protected is True
|
||||
assert b0.last_push_date == '2024-01-05T10:00:00Z'
|
||||
|
||||
# Non-commit target results in empty sha and no date
|
||||
assert b1.name == 'chore/update'
|
||||
assert b1.commit_sha == ''
|
||||
assert b1.last_push_date is None
|
||||
assert b1.protected is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_branches_github_edge_cases():
|
||||
service = GitHubService(token=SecretStr('t'))
|
||||
|
||||
# Empty query should return [] without issuing a GraphQL call
|
||||
branches = await service.search_branches('foo/bar', query='')
|
||||
assert branches == []
|
||||
|
||||
# Invalid repository string should return [] without calling GraphQL
|
||||
exec_mock = AsyncMock()
|
||||
with patch.object(service, 'execute_graphql_query', exec_mock):
|
||||
branches = await service.search_branches('invalidrepo', query='q')
|
||||
assert branches == []
|
||||
exec_mock.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_branches_github_graphql_error_returns_empty():
|
||||
service = GitHubService(token=SecretStr('t'))
|
||||
|
||||
exec_mock = AsyncMock(side_effect=Exception('Boom'))
|
||||
with patch.object(service, 'execute_graphql_query', exec_mock):
|
||||
branches = await service.search_branches('foo/bar', query='q')
|
||||
assert branches == []
|
||||
119
tests/unit/integrations/gitlab/test_gitlab_branches.py
Normal file
119
tests/unit/integrations/gitlab/test_gitlab_branches.py
Normal file
@ -0,0 +1,119 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.integrations.gitlab.gitlab_service import GitLabService
|
||||
from openhands.integrations.service_types import Branch, PaginatedBranchesResponse
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_paginated_branches_gitlab_headers_and_parsing():
|
||||
service = GitLabService(token=SecretStr('t'))
|
||||
|
||||
mock_response = [
|
||||
{
|
||||
'name': 'main',
|
||||
'commit': {'id': 'abc', 'committed_date': '2024-01-01T00:00:00Z'},
|
||||
'protected': True,
|
||||
},
|
||||
{
|
||||
'name': 'dev',
|
||||
'commit': {'id': 'def', 'committed_date': '2024-01-02T00:00:00Z'},
|
||||
'protected': False,
|
||||
},
|
||||
]
|
||||
|
||||
headers = {
|
||||
'X-Next-Page': '3', # indicates has next page
|
||||
'X-Total': '42',
|
||||
}
|
||||
|
||||
with patch.object(service, '_make_request', return_value=(mock_response, headers)):
|
||||
res = await service.get_paginated_branches('group/repo', page=2, per_page=2)
|
||||
|
||||
assert isinstance(res, PaginatedBranchesResponse)
|
||||
assert res.has_next_page is True
|
||||
assert res.current_page == 2
|
||||
assert res.per_page == 2
|
||||
assert res.total_count == 42
|
||||
assert len(res.branches) == 2
|
||||
assert res.branches[0] == Branch(
|
||||
name='main',
|
||||
commit_sha='abc',
|
||||
protected=True,
|
||||
last_push_date='2024-01-01T00:00:00Z',
|
||||
)
|
||||
assert res.branches[1] == Branch(
|
||||
name='dev',
|
||||
commit_sha='def',
|
||||
protected=False,
|
||||
last_push_date='2024-01-02T00:00:00Z',
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_paginated_branches_gitlab_no_next_or_total():
|
||||
service = GitLabService(token=SecretStr('t'))
|
||||
|
||||
mock_response = [
|
||||
{
|
||||
'name': 'fix',
|
||||
'commit': {'id': 'zzz', 'committed_date': '2024-01-03T00:00:00Z'},
|
||||
'protected': False,
|
||||
}
|
||||
]
|
||||
|
||||
headers = {} # No pagination headers; should be has_next_page False
|
||||
|
||||
with patch.object(service, '_make_request', return_value=(mock_response, headers)):
|
||||
res = await service.get_paginated_branches('group/repo', page=1, per_page=1)
|
||||
assert res.has_next_page is False
|
||||
assert res.total_count is None
|
||||
assert len(res.branches) == 1
|
||||
assert res.branches[0].name == 'fix'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_branches_gitlab_uses_search_param():
|
||||
service = GitLabService(token=SecretStr('t'))
|
||||
|
||||
mock_response = [
|
||||
{
|
||||
'name': 'feat/new',
|
||||
'commit': {'id': '111', 'committed_date': '2024-01-04T00:00:00Z'},
|
||||
'protected': False,
|
||||
},
|
||||
{
|
||||
'name': 'feature/xyz',
|
||||
'commit': {'id': '222', 'committed_date': '2024-01-05T00:00:00Z'},
|
||||
'protected': True,
|
||||
},
|
||||
]
|
||||
|
||||
with patch.object(service, '_make_request', return_value=(mock_response, {})) as m:
|
||||
branches = await service.search_branches(
|
||||
'group/repo', query='feat', per_page=50
|
||||
)
|
||||
|
||||
# Verify parameters
|
||||
args, kwargs = m.call_args
|
||||
url = args[0]
|
||||
params = args[1]
|
||||
assert 'repository/branches' in url
|
||||
assert params['per_page'] == '50'
|
||||
assert params['search'] == 'feat'
|
||||
|
||||
assert len(branches) == 2
|
||||
assert branches[0] == Branch(
|
||||
name='feat/new',
|
||||
commit_sha='111',
|
||||
protected=False,
|
||||
last_push_date='2024-01-04T00:00:00Z',
|
||||
)
|
||||
assert branches[1] == Branch(
|
||||
name='feature/xyz',
|
||||
commit_sha='222',
|
||||
protected=True,
|
||||
last_push_date='2024-01-05T00:00:00Z',
|
||||
)
|
||||
Loading…
x
Reference in New Issue
Block a user