diff --git a/apps/web/src/components/features/project/repositories-settings-tab.test.tsx b/apps/web/src/components/features/project/repositories-settings-tab.test.tsx index 0701780..55ad7ec 100644 --- a/apps/web/src/components/features/project/repositories-settings-tab.test.tsx +++ b/apps/web/src/components/features/project/repositories-settings-tab.test.tsx @@ -20,6 +20,7 @@ const mockRepositories = [ owner_id: "user-1", is_mirror: false, remote_url: null, + ssh_key_id: null, last_push: null, created_at: null, }, @@ -42,7 +43,59 @@ afterEach(() => { }); describe("RepositoriesSettingsTab", () => { - it("opens create dialog and clones an existing repository", async () => { + it("opens create dialog and clones using full URL by default", async () => { + const listMock = vi + .spyOn(gitRepositoriesApi, "listRepositories") + .mockResolvedValue(mockRepositories); + const createMock = vi + .spyOn(gitRepositoriesApi, "createRepository") + .mockResolvedValue(mockRepositories[0]); + const parseMock = vi + .spyOn(gitRepositoriesApi, "parseGitUrl") + .mockResolvedValue({ + original_url: "https://github.com/user/repo.git", + base_url: "https://github.com/user/repo.git", + is_valid_clone_url: true, + needs_parsing: false, + host: "github.com", + message: "Valid git repository URL", + error_code: null, + }); + vi.spyOn(sshKeysApi, "listSSHKeys").mockResolvedValue([]); + + render(); + + await waitFor(() => { + expect(screen.getByText("Main Repo")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: /add repository/i })); + fireEvent.change(screen.getByPlaceholderText(/repository-name/i), { + target: { value: "New Repo" }, + }); + fireEvent.change( + screen.getByPlaceholderText(/https:\/\/github.com\/user\/repo.git/i), + { + target: { value: "https://github.com/user/repo.git" }, + }, + ); + + await waitFor(() => { + expect(parseMock).toHaveBeenCalledWith("https://github.com/user/repo.git"); + }); + + fireEvent.click(screen.getByRole("button", { name: /clone repository/i })); + + await waitFor(() => { + expect(createMock).toHaveBeenCalledWith("proj-1", { + name: "New Repo", + remote_url: "https://github.com/user/repo.git", + }); + }); + expect(listMock).toHaveBeenCalledTimes(2); + }); + + it("switches to owner/repo mode and requires SSH key for generated SSH URL", async () => { const listMock = vi .spyOn(gitRepositoriesApi, "listRepositories") .mockResolvedValue(mockRepositories); @@ -68,6 +121,9 @@ describe("RepositoriesSettingsTab", () => { fireEvent.change(screen.getByPlaceholderText(/repository-name/i), { target: { value: "New Repo" }, }); + fireEvent.click( + screen.getByRole("button", { name: /use owner\/repo instead/i }), + ); fireEvent.change(screen.getByPlaceholderText(/owner/i), { target: { value: "alice" }, }); @@ -92,14 +148,33 @@ describe("RepositoriesSettingsTab", () => { expect(listMock).toHaveBeenCalledTimes(2); }); - it("uses advanced url fallback when requested", async () => { + it("accepts SSH URL in full URL mode when SSH key is selected", async () => { const listMock = vi .spyOn(gitRepositoriesApi, "listRepositories") .mockResolvedValue(mockRepositories); const createMock = vi .spyOn(gitRepositoriesApi, "createRepository") .mockResolvedValue(mockRepositories[0]); - vi.spyOn(sshKeysApi, "listSSHKeys").mockResolvedValue([]); + // SSH URLs are short-circuited client-side; parseGitUrl should NOT be called + const parseMock = vi + .spyOn(gitRepositoriesApi, "parseGitUrl") + .mockResolvedValue({ + original_url: "", + base_url: null, + is_valid_clone_url: false, + needs_parsing: false, + host: null, + message: "", + error_code: null, + }); + vi.spyOn(sshKeysApi, "listSSHKeys").mockResolvedValue([ + { + id: "key-1", + name: "My Key", + public_key: "ssh-ed25519 AAA...", + created_at: "2024-01-01", + }, + ]); render(); @@ -111,26 +186,80 @@ describe("RepositoriesSettingsTab", () => { fireEvent.change(screen.getByPlaceholderText(/repository-name/i), { target: { value: "New Repo" }, }); - fireEvent.click( - screen.getByRole("button", { name: /use full url instead/i }), - ); fireEvent.change( screen.getByPlaceholderText(/https:\/\/github.com\/user\/repo.git/i), { - target: { value: "https://github.com/user/repo.git" }, + target: { value: "git@github.com:user/repo.git" }, }, ); + + // Client-side short-circuit shows valid without calling backend + await waitFor(() => { + expect(screen.getByText(/valid git url/i)).toBeInTheDocument(); + }); + expect(parseMock).not.toHaveBeenCalled(); + + const sshSelect = screen.getByRole("combobox", { name: /ssh key/i }); + await waitFor(() => + expect(sshSelect.querySelector('option[value="key-1"]')).toBeTruthy(), + ); + (sshSelect as HTMLSelectElement).value = "key-1"; + fireEvent.change(sshSelect); + fireEvent.click(screen.getByRole("button", { name: /clone repository/i })); await waitFor(() => { expect(createMock).toHaveBeenCalledWith("proj-1", { name: "New Repo", - remote_url: "https://github.com/user/repo.git", + remote_url: "git@github.com:user/repo.git", + ssh_key_id: "key-1", }); }); expect(listMock).toHaveBeenCalledTimes(2); }); + it("rejects SSH URL in full URL mode without an SSH key", async () => { + vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue( + mockRepositories, + ); + const createMock = vi + .spyOn(gitRepositoriesApi, "createRepository") + .mockResolvedValue(mockRepositories[0]); + vi.spyOn(sshKeysApi, "listSSHKeys").mockResolvedValue([ + { + id: "key-1", + name: "My Key", + public_key: "ssh-ed25519 AAA...", + created_at: "2024-01-01", + }, + ]); + + render(); + + await waitFor(() => { + expect(screen.getByText("Main Repo")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: /add repository/i })); + fireEvent.change(screen.getByPlaceholderText(/repository-name/i), { + target: { value: "New Repo" }, + }); + fireEvent.change( + screen.getByPlaceholderText(/https:\/\/github.com\/user\/repo.git/i), + { + target: { value: "git@github.com:user/repo.git" }, + }, + ); + + // Do NOT select an SSH key + fireEvent.click(screen.getByRole("button", { name: /clone repository/i })); + + expect( + screen.getByText(/an ssh key is required for ssh urls/i), + ).toBeInTheDocument(); + expect(createMock).not.toHaveBeenCalled(); + }); + it("shows validation when cloning without a remote url", async () => { vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue( mockRepositories, @@ -145,13 +274,16 @@ describe("RepositoriesSettingsTab", () => { fireEvent.change(screen.getByPlaceholderText(/repository-name/i), { target: { value: "New Repo" }, }); - fireEvent.change(screen.getByPlaceholderText(/owner/i), { - target: { value: "" }, - }); + fireEvent.change( + screen.getByPlaceholderText(/https:\/\/github.com\/user\/repo.git/i), + { + target: { value: "" }, + }, + ); fireEvent.click(screen.getByRole("button", { name: /clone repository/i })); expect( - screen.getByText(/owner and repository name are required/i), + screen.getByText(/remote url is required for advanced cloning/i), ).toBeInTheDocument(); }); }); diff --git a/apps/web/src/components/features/project/repository-create-dialog.tsx b/apps/web/src/components/features/project/repository-create-dialog.tsx index cb5b80a..fa3e6f5 100644 --- a/apps/web/src/components/features/project/repository-create-dialog.tsx +++ b/apps/web/src/components/features/project/repository-create-dialog.tsx @@ -37,7 +37,7 @@ export const RepositoryCreateDialog = ({ const [owner, setOwner] = useState(""); const [repoName, setRepoName] = useState(""); const [advancedUrl, setAdvancedUrl] = useState(""); - const [useAdvancedUrl, setUseAdvancedUrl] = useState(false); + const [useAdvancedUrl, setUseAdvancedUrl] = useState(true); const [formError, setFormError] = useState(null); const [urlValidation, setUrlValidation] = useState<{ status: UrlValidationStatus; @@ -47,6 +47,11 @@ export const RepositoryCreateDialog = ({ const [selectedSshKey, setSelectedSshKey] = useState(""); const debounceTimer = useRef | null>(null); + const isSshUrl = (url: string): boolean => { + const u = url.trim().toLowerCase(); + return u.startsWith("git@") || u.startsWith("ssh://"); + }; + useEffect(() => { if (!open && debounceTimer.current) { clearTimeout(debounceTimer.current); @@ -86,8 +91,26 @@ export const RepositoryCreateDialog = ({ setUrlValidation({ status: "validating", result: null }); debounceTimer.current = setTimeout(async () => { + const trimmed = advancedUrl.trim(); + // Short-circuit SSH URLs — the backend parseGitUrl may not always + // recognise them, but they are valid clone URLs by definition. + if (isSshUrl(trimmed)) { + setUrlValidation({ + status: "valid", + result: { + original_url: trimmed, + base_url: trimmed, + is_valid_clone_url: true, + needs_parsing: false, + host: null, + message: "Valid SSH git URL", + error_code: null, + }, + }); + return; + } try { - const result = await parseGitUrl(advancedUrl.trim()); + const result = await parseGitUrl(trimmed); if (result.is_valid_clone_url) { setUrlValidation({ status: "valid", result }); } else if (result.needs_parsing) { @@ -124,11 +147,6 @@ export const RepositoryCreateDialog = ({ onClose(); }; - const isSshUrl = (url: string): boolean => { - const u = url.trim().toLowerCase(); - return u.startsWith("git@") || u.startsWith("ssh://"); - }; - const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); setFormError(null);