diff --git a/apps/api/src/api/git_repositories.py b/apps/api/src/api/git_repositories.py index 4d04789..b00a20d 100644 --- a/apps/api/src/api/git_repositories.py +++ b/apps/api/src/api/git_repositories.py @@ -89,6 +89,32 @@ def _get_repo_path(user_id: uuid.UUID, project_id: uuid.UUID, name: str) -> str: return os.path.join(base, str(user_id), str(project_id), f"{name}.git") +def _build_provider_clone_url(owner: str, repo: str) -> str: + """Build the SSH clone URL for the fixed git provider.""" + return f"git@git.commumedia.org:{owner}/{repo}.git" + + +def _preflight_remote_repository(remote_url: str) -> None: + """Verify a remote repository is reachable before cloning.""" + try: + result = subprocess.run( + ["git", "ls-remote", remote_url], + capture_output=True, + text=True, + timeout=60, + ) + except subprocess.TimeoutExpired: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="remote repository check timed out") + except FileNotFoundError: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found") + + if result.returncode != 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="repository not found or inaccessible", + ) + + class GitRepositoryCreate(BaseModel): name: str remote_url: str | None = None @@ -267,6 +293,9 @@ async def create_repository( if parse_result["base_url"]: remote_url = parse_result["base_url"] + if remote_url: + _preflight_remote_repository(remote_url) + repo_path = _get_repo_path(user_id, project_id, data.name) # Ensure parent directory exists diff --git a/apps/api/tests/unit/test_git_repository_clone_preflight.py b/apps/api/tests/unit/test_git_repository_clone_preflight.py new file mode 100644 index 0000000..5381879 --- /dev/null +++ b/apps/api/tests/unit/test_git_repository_clone_preflight.py @@ -0,0 +1,28 @@ +from unittest.mock import Mock, patch + +import pytest +from fastapi import HTTPException + +from src.api.git_repositories import _build_provider_clone_url, _preflight_remote_repository + + +def test_build_provider_clone_url_uses_fixed_host() -> None: + assert _build_provider_clone_url("alice", "demo") == "git@git.commumedia.org:alice/demo.git" + + +def test_preflight_remote_repository_allows_accessible_repo() -> None: + completed = Mock(returncode=0) + with patch("src.api.git_repositories.subprocess.run", return_value=completed) as run_mock: + _preflight_remote_repository("git@git.commumedia.org:alice/demo.git") + + run_mock.assert_called_once() + + +def test_preflight_remote_repository_rejects_missing_repo() -> None: + completed = Mock(returncode=128) + with patch("src.api.git_repositories.subprocess.run", return_value=completed): + with pytest.raises(HTTPException) as exc_info: + _preflight_remote_repository("git@git.commumedia.org:alice/missing.git") + + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == "repository not found or inaccessible" diff --git a/apps/web/src/components/repositories-settings-tab.test.tsx b/apps/web/src/components/repositories-settings-tab.test.tsx index eccecc6..54bbf56 100644 --- a/apps/web/src/components/repositories-settings-tab.test.tsx +++ b/apps/web/src/components/repositories-settings-tab.test.tsx @@ -46,8 +46,39 @@ describe("RepositoriesSettingsTab", () => { fireEvent.change(screen.getByPlaceholderText(/repository-name/i), { target: { value: "New Repo" }, }); - fireEvent.click(screen.getByLabelText(/clone existing repository/i)); - fireEvent.change(screen.getByPlaceholderText(/github.com\/user\/repo.git/i), { + fireEvent.change(screen.getByPlaceholderText(/owner/i), { + target: { value: "alice" }, + }); + fireEvent.change(screen.getByPlaceholderText(/repo-name/i), { + target: { value: "demo" }, + }); + fireEvent.click(screen.getByRole("button", { name: /clone repository/i })); + + await waitFor(() => { + expect(createMock).toHaveBeenCalledWith("proj-1", { + name: "New Repo", + remote_url: "git@git.commumedia.org:alice/demo.git", + }); + }); + expect(listMock).toHaveBeenCalledTimes(2); + }); + + it("uses advanced url fallback when requested", async () => { + const listMock = vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(mockRepositories); + const createMock = vi.spyOn(gitRepositoriesApi, "createRepository").mockResolvedValue(mockRepositories[0]); + + 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.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" }, }); fireEvent.click(screen.getByRole("button", { name: /clone repository/i })); @@ -73,9 +104,11 @@ describe("RepositoriesSettingsTab", () => { fireEvent.change(screen.getByPlaceholderText(/repository-name/i), { target: { value: "New Repo" }, }); - fireEvent.click(screen.getByLabelText(/clone existing repository/i)); + fireEvent.change(screen.getByPlaceholderText(/owner/i), { + target: { value: "" }, + }); fireEvent.click(screen.getByRole("button", { name: /clone repository/i })); - expect(screen.getByText(/remote url is required/i)).toBeInTheDocument(); + expect(screen.getByText(/owner and repository name are required/i)).toBeInTheDocument(); }); }); diff --git a/apps/web/src/components/repository-create-dialog.tsx b/apps/web/src/components/repository-create-dialog.tsx index 5c58521..dda7269 100644 --- a/apps/web/src/components/repository-create-dialog.tsx +++ b/apps/web/src/components/repository-create-dialog.tsx @@ -17,7 +17,10 @@ interface RepositoryCreateDialogProps { export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCreated }: RepositoryCreateDialogProps) => { const [createMode, setCreateMode] = useState("clone"); const [formName, setFormName] = useState(""); - const [formRemoteUrl, setFormRemoteUrl] = useState(""); + const [owner, setOwner] = useState(""); + const [repoName, setRepoName] = useState(""); + const [advancedUrl, setAdvancedUrl] = useState(""); + const [useAdvancedUrl, setUseAdvancedUrl] = useState(false); const [formError, setFormError] = useState(null); const [urlValidation, setUrlValidation] = useState<{ status: UrlValidationStatus; @@ -34,12 +37,16 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea useEffect(() => { if (!open) return; + if (!useAdvancedUrl) { + setUrlValidation({ status: "idle", result: null }); + return; + } if (debounceTimer.current) { clearTimeout(debounceTimer.current); } - if (!formRemoteUrl.trim()) { + if (!advancedUrl.trim()) { setUrlValidation({ status: "idle", result: null }); return; } @@ -48,7 +55,7 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea debounceTimer.current = setTimeout(async () => { try { - const result = await parseGitUrl(formRemoteUrl.trim()); + const result = await parseGitUrl(advancedUrl.trim()); if (result.is_valid_clone_url) { setUrlValidation({ status: "valid", result }); } else if (result.needs_parsing) { @@ -66,12 +73,15 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea clearTimeout(debounceTimer.current); } }; - }, [formRemoteUrl, open]); + }, [advancedUrl, open, useAdvancedUrl]); const resetForm = () => { setCreateMode("clone"); setFormName(""); - setFormRemoteUrl(""); + setOwner(""); + setRepoName(""); + setAdvancedUrl(""); + setUseAdvancedUrl(false); setFormError(null); setUrlValidation({ status: "idle", result: null }); }; @@ -90,27 +100,41 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea return; } - if (createMode === "clone" && !formRemoteUrl.trim()) { - setFormError("Remote URL is required to clone an existing repository"); - return; - } - try { const input: GitRepositoryCreate = { name: formName.trim(), - remote_url: formRemoteUrl.trim() || undefined, + remote_url: undefined, }; + + if (createMode === "clone") { + if (useAdvancedUrl) { + if (!advancedUrl.trim()) { + setFormError("Remote URL is required for advanced cloning"); + return; + } + input.remote_url = advancedUrl.trim(); + } else { + if (!owner.trim() || !repoName.trim()) { + setFormError("Owner and repository name are required"); + return; + } + input.remote_url = `git@git.commumedia.org:${owner.trim()}/${repoName.trim()}.git`; + } + } + await createRepository(projectId, input); handleClose(); await onCreated(); - } catch { - setFormError("Failed to create repository"); + } catch (error: unknown) { + const response = error as { response?: { data?: { detail?: string } } }; + const detail = response.response?.data?.detail; + setFormError(typeof detail === "string" ? detail : "Failed to create repository"); } }; const handleUseSuggestedUrl = () => { if (urlValidation.result?.base_url) { - setFormRemoteUrl(urlValidation.result.base_url); + setAdvancedUrl(urlValidation.result.base_url); setUrlValidation({ status: "idle", result: null }); setFormError(null); } @@ -136,7 +160,7 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea

{title}

- Clone an existing repository from a git server, or create a blank bare repo here. + Clone an existing repository from git.commumedia.org, or create a blank bare repo here.

@@ -168,46 +192,85 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea placeholder="repository-name" /> -