From cc1507a33e0312ce59fffba3541779410295544e Mon Sep 17 00:00:00 2001 From: marxlaml Date: Fri, 22 May 2026 18:34:02 +0200 Subject: [PATCH 1/7] fix(web): add repository creation to project settings - Add repository creation dialog to the project settings repositories tab - Reuse shared repository API for list/create/delete operations - Add coverage for the create flow in the settings tab Quality gates: tsc --noEmit, vitest run src/components/repositories-settings-tab.test.tsx --- .../repositories-settings-tab.test.tsx | 59 ++++++++ .../components/repositories-settings-tab.tsx | 126 +++++++++++++++--- 2 files changed, 163 insertions(+), 22 deletions(-) create mode 100644 apps/web/src/components/repositories-settings-tab.test.tsx diff --git a/apps/web/src/components/repositories-settings-tab.test.tsx b/apps/web/src/components/repositories-settings-tab.test.tsx new file mode 100644 index 0000000..c223e4b --- /dev/null +++ b/apps/web/src/components/repositories-settings-tab.test.tsx @@ -0,0 +1,59 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { RepositoriesSettingsTab } from "./repositories-settings-tab"; +import * as gitRepositoriesApi from "../api/git_repositories"; + +const mockRepositories = [ + { + id: "repo-1", + name: "Main Repo", + path: "/repos/main", + project_id: "proj-1", + owner_id: "user-1", + is_mirror: false, + remote_url: null, + last_push: null, + created_at: null, + }, +]; + +vi.mock("react-router-dom", async () => { + const actual = await vi.importActual("react-router-dom"); + return { + ...actual, + useParams: () => ({ projectId: "proj-1" }), + }; +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe("RepositoriesSettingsTab", () => { + it("opens create dialog and submits a repository", 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: /create/i })); + + await waitFor(() => { + expect(createMock).toHaveBeenCalledWith("proj-1", { + name: "New Repo", + remote_url: undefined, + }); + }); + expect(listMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/src/components/repositories-settings-tab.tsx b/apps/web/src/components/repositories-settings-tab.tsx index 3b928de..7ff9a62 100644 --- a/apps/web/src/components/repositories-settings-tab.tsx +++ b/apps/web/src/components/repositories-settings-tab.tsx @@ -1,37 +1,77 @@ -import React, { useState, useEffect } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import { useParams } from "react-router-dom"; -import { apiClient } from "../api/client"; -import { GitRepository } from "../api/git_repositories"; + +import { + createRepository, + deleteRepository, + listRepositories, + type GitRepository, + type GitRepositoryCreate, +} from "../api/git_repositories"; +import { Icon } from "./icon"; export const RepositoriesSettingsTab: React.FC = () => { const { projectId } = useParams<{ projectId: string }>(); const [repositories, setRepositories] = useState([]); const [loading, setLoading] = useState(true); - const [error, setError] = useState(">"); + const [showCreate, setShowCreate] = useState(false); + const [formName, setFormName] = useState(""); + const [formRemoteUrl, setFormRemoteUrl] = useState(""); + const [error, setError] = useState(""); - useEffect(() => { - const fetchRepositories = async () => { - try { - const response = await apiClient.get( - `/projects/${projectId}/repositories` - ); - setRepositories(response.data); - } catch (err) { - setError("Failed to load repositories"); - } finally { - setLoading(false); - } - }; + const loadRepositories = useCallback(async () => { + if (!projectId) { + setLoading(false); + return; + } - fetchRepositories(); + setLoading(true); + try { + const data = await listRepositories(projectId); + setRepositories(data); + } catch { + setError("Failed to load repositories"); + } finally { + setLoading(false); + } }, [projectId]); + useEffect(() => { + void loadRepositories(); + }, [loadRepositories]); + + const handleCreate = async (event: React.FormEvent) => { + event.preventDefault(); + setError(""); + + if (!projectId) return; + if (!formName.trim()) { + setError("Repository name is required"); + return; + } + + try { + const input: GitRepositoryCreate = { + name: formName.trim(), + remote_url: formRemoteUrl.trim() || undefined, + }; + await createRepository(projectId, input); + setShowCreate(false); + setFormName(""); + setFormRemoteUrl(""); + await loadRepositories(); + } catch { + setError("Failed to create repository"); + } + }; + const handleDelete = async (repoId: string) => { + if (!projectId) return; if (!window.confirm("Are you sure you want to delete this repository?")) return; try { - await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`); - setRepositories(repositories.filter((r) => r.id !== repoId)); - } catch (err) { + await deleteRepository(projectId, repoId); + setRepositories((current) => current.filter((r) => r.id !== repoId)); + } catch { setError("Failed to delete repository"); } }; @@ -40,7 +80,13 @@ export const RepositoriesSettingsTab: React.FC = () => { return (
-

Repositories

+
+

Repositories

+ +
{error &&
{error}
}
@@ -66,6 +112,42 @@ export const RepositoriesSettingsTab: React.FC = () => { )) )}
+ + {showCreate && ( +
+
+

Add Repository

+
+ + +
+ + +
+
+
+
+ )}
); }; From ebfe991a15c344f15afb1ab965b146d8d889efd5 Mon Sep 17 00:00:00 2001 From: marxlaml Date: Fri, 22 May 2026 18:38:21 +0200 Subject: [PATCH 2/7] fix(web): support cloning existing repositories - Make the add repository dialog explicitly support cloning from a remote git server - Keep blank repo creation as a fallback option - Add validation and tests for the clone-first flow Quality gates: tsc --noEmit, vitest run src/components/repositories-settings-tab.test.tsx --- .../repositories-settings-tab.test.tsx | 26 ++++++++++-- .../components/repositories-settings-tab.tsx | 41 +++++++++++++++++-- 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/repositories-settings-tab.test.tsx b/apps/web/src/components/repositories-settings-tab.test.tsx index c223e4b..69e5304 100644 --- a/apps/web/src/components/repositories-settings-tab.test.tsx +++ b/apps/web/src/components/repositories-settings-tab.test.tsx @@ -32,7 +32,7 @@ afterEach(() => { }); describe("RepositoriesSettingsTab", () => { - it("opens create dialog and submits a repository", async () => { + it("opens create dialog and clones an existing repository", async () => { const listMock = vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(mockRepositories); const createMock = vi.spyOn(gitRepositoriesApi, "createRepository").mockResolvedValue(mockRepositories[0]); @@ -46,14 +46,34 @@ describe("RepositoriesSettingsTab", () => { fireEvent.change(screen.getByPlaceholderText(/repository-name/i), { target: { value: "New Repo" }, }); - fireEvent.click(screen.getByRole("button", { name: /create/i })); + fireEvent.change(screen.getByPlaceholderText(/github.com\/user\/repo.git/i), { + target: { value: "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: undefined, + remote_url: "https://github.com/user/repo.git", }); }); expect(listMock).toHaveBeenCalledTimes(2); }); + + it("shows validation when cloning without a remote url", async () => { + vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(mockRepositories); + 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: /clone repository/i })); + + expect(screen.getByText(/remote url is required/i)).toBeInTheDocument(); + }); }); diff --git a/apps/web/src/components/repositories-settings-tab.tsx b/apps/web/src/components/repositories-settings-tab.tsx index 7ff9a62..c7545ee 100644 --- a/apps/web/src/components/repositories-settings-tab.tsx +++ b/apps/web/src/components/repositories-settings-tab.tsx @@ -15,6 +15,7 @@ export const RepositoriesSettingsTab: React.FC = () => { const [repositories, setRepositories] = useState([]); const [loading, setLoading] = useState(true); const [showCreate, setShowCreate] = useState(false); + const [createMode, setCreateMode] = useState<"clone" | "blank">("clone"); const [formName, setFormName] = useState(""); const [formRemoteUrl, setFormRemoteUrl] = useState(""); const [error, setError] = useState(""); @@ -49,6 +50,10 @@ export const RepositoriesSettingsTab: React.FC = () => { setError("Repository name is required"); return; } + if (createMode === "clone" && !formRemoteUrl.trim()) { + setError("Remote URL is required to clone an existing repository"); + return; + } try { const input: GitRepositoryCreate = { @@ -57,6 +62,7 @@ export const RepositoriesSettingsTab: React.FC = () => { }; await createRepository(projectId, input); setShowCreate(false); + setCreateMode("clone"); setFormName(""); setFormRemoteUrl(""); await loadRepositories(); @@ -82,7 +88,11 @@ export const RepositoriesSettingsTab: React.FC = () => {

Repositories

- @@ -117,9 +127,32 @@ export const RepositoriesSettingsTab: React.FC = () => {

Add Repository

+

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

+
+ + +
From 5af4de0d7edd6bcae593b5ecc28fefa7c8c91682 Mon Sep 17 00:00:00 2001 From: marxlaml Date: Fri, 22 May 2026 18:43:03 +0200 Subject: [PATCH 3/7] refactor(web): reuse repository create dialog - Extract the repository create dialog into a shared component - Reuse the same clone/validation flow in project settings and repository management pages - Keep the shared UI covered with focused tests Quality gates: tsc --noEmit, vitest run src/components/repositories-settings-tab.test.tsx --- .../repositories-settings-tab.test.tsx | 2 + .../components/repositories-settings-tab.tsx | 105 +------- .../components/repository-create-dialog.tsx | 230 ++++++++++++++++++ apps/web/src/pages/git-repositories.tsx | 193 +-------------- 4 files changed, 250 insertions(+), 280 deletions(-) create mode 100644 apps/web/src/components/repository-create-dialog.tsx diff --git a/apps/web/src/components/repositories-settings-tab.test.tsx b/apps/web/src/components/repositories-settings-tab.test.tsx index 69e5304..eccecc6 100644 --- a/apps/web/src/components/repositories-settings-tab.test.tsx +++ b/apps/web/src/components/repositories-settings-tab.test.tsx @@ -46,6 +46,7 @@ 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), { target: { value: "https://github.com/user/repo.git" }, }); @@ -72,6 +73,7 @@ describe("RepositoriesSettingsTab", () => { fireEvent.change(screen.getByPlaceholderText(/repository-name/i), { target: { value: "New Repo" }, }); + fireEvent.click(screen.getByLabelText(/clone existing repository/i)); fireEvent.click(screen.getByRole("button", { name: /clone repository/i })); expect(screen.getByText(/remote url is required/i)).toBeInTheDocument(); diff --git a/apps/web/src/components/repositories-settings-tab.tsx b/apps/web/src/components/repositories-settings-tab.tsx index c7545ee..af3b315 100644 --- a/apps/web/src/components/repositories-settings-tab.tsx +++ b/apps/web/src/components/repositories-settings-tab.tsx @@ -1,13 +1,8 @@ import React, { useCallback, useEffect, useState } from "react"; import { useParams } from "react-router-dom"; -import { - createRepository, - deleteRepository, - listRepositories, - type GitRepository, - type GitRepositoryCreate, -} from "../api/git_repositories"; +import { deleteRepository, listRepositories, type GitRepository } from "../api/git_repositories"; +import { RepositoryCreateDialog } from "./repository-create-dialog"; import { Icon } from "./icon"; export const RepositoriesSettingsTab: React.FC = () => { @@ -15,9 +10,6 @@ export const RepositoriesSettingsTab: React.FC = () => { const [repositories, setRepositories] = useState([]); const [loading, setLoading] = useState(true); const [showCreate, setShowCreate] = useState(false); - const [createMode, setCreateMode] = useState<"clone" | "blank">("clone"); - const [formName, setFormName] = useState(""); - const [formRemoteUrl, setFormRemoteUrl] = useState(""); const [error, setError] = useState(""); const loadRepositories = useCallback(async () => { @@ -41,36 +33,6 @@ export const RepositoriesSettingsTab: React.FC = () => { void loadRepositories(); }, [loadRepositories]); - const handleCreate = async (event: React.FormEvent) => { - event.preventDefault(); - setError(""); - - if (!projectId) return; - if (!formName.trim()) { - setError("Repository name is required"); - return; - } - if (createMode === "clone" && !formRemoteUrl.trim()) { - setError("Remote URL is required to clone an existing repository"); - return; - } - - try { - const input: GitRepositoryCreate = { - name: formName.trim(), - remote_url: formRemoteUrl.trim() || undefined, - }; - await createRepository(projectId, input); - setShowCreate(false); - setCreateMode("clone"); - setFormName(""); - setFormRemoteUrl(""); - await loadRepositories(); - } catch { - setError("Failed to create repository"); - } - }; - const handleDelete = async (repoId: string) => { if (!projectId) return; if (!window.confirm("Are you sure you want to delete this repository?")) return; @@ -124,62 +86,13 @@ export const RepositoriesSettingsTab: React.FC = () => {
{showCreate && ( -
-
-

Add Repository

-

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

-
-
- - -
- - -
- - -
-
-
-
+ setShowCreate(false)} + onCreated={loadRepositories} + /> )}
); diff --git a/apps/web/src/components/repository-create-dialog.tsx b/apps/web/src/components/repository-create-dialog.tsx new file mode 100644 index 0000000..5c58521 --- /dev/null +++ b/apps/web/src/components/repository-create-dialog.tsx @@ -0,0 +1,230 @@ +import { useEffect, useRef, useState } from "react"; + +import { createRepository, parseGitUrl, type GitRepositoryCreate, type URLParseResult } from "../api/git_repositories"; +import { Icon } from "./icon"; + +type CreateMode = "clone" | "blank"; +type UrlValidationStatus = "idle" | "validating" | "valid" | "needs-parsing" | "invalid"; + +interface RepositoryCreateDialogProps { + projectId: string; + open: boolean; + title: string; + onClose: () => void; + onCreated: () => Promise | void; +} + +export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCreated }: RepositoryCreateDialogProps) => { + const [createMode, setCreateMode] = useState("clone"); + const [formName, setFormName] = useState(""); + const [formRemoteUrl, setFormRemoteUrl] = useState(""); + const [formError, setFormError] = useState(null); + const [urlValidation, setUrlValidation] = useState<{ + status: UrlValidationStatus; + result: URLParseResult | null; + }>({ status: "idle", result: null }); + const debounceTimer = useRef | null>(null); + + useEffect(() => { + if (!open && debounceTimer.current) { + clearTimeout(debounceTimer.current); + debounceTimer.current = null; + } + }, [open]); + + useEffect(() => { + if (!open) return; + + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + } + + if (!formRemoteUrl.trim()) { + setUrlValidation({ status: "idle", result: null }); + return; + } + + setUrlValidation({ status: "validating", result: null }); + + debounceTimer.current = setTimeout(async () => { + try { + const result = await parseGitUrl(formRemoteUrl.trim()); + if (result.is_valid_clone_url) { + setUrlValidation({ status: "valid", result }); + } else if (result.needs_parsing) { + setUrlValidation({ status: "needs-parsing", result }); + } else { + setUrlValidation({ status: "invalid", result }); + } + } catch { + setUrlValidation({ status: "invalid", result: null }); + } + }, 300); + + return () => { + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + } + }; + }, [formRemoteUrl, open]); + + const resetForm = () => { + setCreateMode("clone"); + setFormName(""); + setFormRemoteUrl(""); + setFormError(null); + setUrlValidation({ status: "idle", result: null }); + }; + + const handleClose = () => { + resetForm(); + onClose(); + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setFormError(null); + + if (!formName.trim()) { + setFormError("Repository name is required"); + 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, + }; + await createRepository(projectId, input); + handleClose(); + await onCreated(); + } catch { + setFormError("Failed to create repository"); + } + }; + + const handleUseSuggestedUrl = () => { + if (urlValidation.result?.base_url) { + setFormRemoteUrl(urlValidation.result.base_url); + setUrlValidation({ status: "idle", result: null }); + setFormError(null); + } + }; + + const getUrlInputClass = () => { + switch (urlValidation.status) { + case "valid": + return "valid-url"; + case "needs-parsing": + return "needs-parsing-url"; + case "invalid": + return "invalid-url"; + default: + return ""; + } + }; + + if (!open) return null; + + return ( +
+
+

{title}

+

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

+
+
+ + +
+ + + {formError && ( +
+

{formError}

+
+ )} +
+ + +
+
+
+
+ ); +}; diff --git a/apps/web/src/pages/git-repositories.tsx b/apps/web/src/pages/git-repositories.tsx index 45ddc80..1ad2c4e 100644 --- a/apps/web/src/pages/git-repositories.tsx +++ b/apps/web/src/pages/git-repositories.tsx @@ -1,19 +1,15 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { - createRepository, deleteRepository, listRepositories, - parseGitUrl, - type GitRepositoryCreate, - type URLParseResult, } from "../api/git_repositories"; import type { GitRepository } from "../api/git_repositories"; import { Icon } from "../components/icon"; +import { RepositoryCreateDialog } from "../components/repository-create-dialog"; type RepoStatus = "loading" | "ready" | "error"; -type UrlValidationStatus = "idle" | "validating" | "valid" | "needs-parsing" | "invalid"; export const GitRepositoriesPage = () => { const { projectId } = useParams<{ projectId: string }>(); @@ -21,19 +17,8 @@ export const GitRepositoriesPage = () => { const [status, setStatus] = useState("loading"); const [repositories, setRepositories] = useState([]); const [showCreate, setShowCreate] = useState(false); - const [formName, setFormName] = useState(""); - const [formRemoteUrl, setFormRemoteUrl] = useState(""); - const [formError, setFormError] = useState(null); const [deleteConfirmId, setDeleteConfirmId] = useState(null); - // URL validation state - const [urlValidation, setUrlValidation] = useState<{ - status: UrlValidationStatus; - result: URLParseResult | null; - }>({ status: "idle", result: null }); - - const debounceTimer = useRef | null>(null); - const loadRepositories = useCallback(async () => { if (!projectId) return; setStatus("loading"); @@ -51,98 +36,6 @@ export const GitRepositoriesPage = () => { void loadRepositories(); }, [loadRepositories]); - // Validate URL with debounce - useEffect(() => { - if (debounceTimer.current) { - clearTimeout(debounceTimer.current); - } - - if (!formRemoteUrl.trim()) { - setUrlValidation({ status: "idle", result: null }); - return; - } - - setUrlValidation({ status: "validating", result: null }); - - debounceTimer.current = setTimeout(async () => { - try { - const result = await parseGitUrl(formRemoteUrl.trim()); - if (result.is_valid_clone_url) { - setUrlValidation({ status: "valid", result }); - } else if (result.needs_parsing) { - setUrlValidation({ status: "needs-parsing", result }); - } else { - setUrlValidation({ status: "invalid", result }); - } - } catch { - setUrlValidation({ status: "invalid", result: null }); - } - }, 300); - - return () => { - if (debounceTimer.current) { - clearTimeout(debounceTimer.current); - } - }; - }, [formRemoteUrl]); - - const getUrlInputClass = () => { - switch (urlValidation.status) { - case "valid": - return "valid-url"; - case "needs-parsing": - return "needs-parsing-url"; - case "invalid": - return "invalid-url"; - default: - return ""; - } - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setFormError(null); - - if (!formName.trim()) { - setFormError("Repository name is required"); - return; - } - - if (!projectId) return; - - try { - const input: GitRepositoryCreate = { - name: formName.trim(), - remote_url: formRemoteUrl.trim() || undefined, - }; - await createRepository(projectId, input); - setShowCreate(false); - setFormName(""); - setFormRemoteUrl(""); - setUrlValidation({ status: "idle", result: null }); - await loadRepositories(); - } catch (err: unknown) { - const axiosError = err as { response?: { status: number; data: { detail: { suggested_url: string; message: string } } } }; - if (axiosError.response?.status === 422 && axiosError.response?.data?.detail?.suggested_url) { - // Show URL correction suggestion - const detail = axiosError.response.data.detail; - setFormError( - `${detail.message}\nSuggested: ${detail.suggested_url}` - ); - } else { - setFormError("Failed to create repository"); - } - } - }; - - const handleUseSuggestedUrl = () => { - if (urlValidation.result?.base_url) { - setFormRemoteUrl(urlValidation.result.base_url); - setUrlValidation({ status: "idle", result: null }); - setFormError(null); - } - }; - const handleDelete = async (repoId: string) => { if (!projectId) return; try { @@ -233,81 +126,13 @@ export const GitRepositoriesPage = () => { )} {showCreate && ( -
-
-

Create Repository

-
- - - {formError && ( -
- {formError.split("\n").map((line, i) => ( -

{line}

- ))} -
- )} -
- - -
-
-
-
+ setShowCreate(false)} + onCreated={loadRepositories} + /> )} ); From 2525c584718df33f0a08502c838149b20276ad3e Mon Sep 17 00:00:00 2001 From: marxlaml Date: Fri, 22 May 2026 19:07:04 +0200 Subject: [PATCH 4/7] feat(web): support ssh owner repo clone flow - Add SSH-only owner/repo clone path for git.commumedia.org - Preflight remote repository existence with git ls-remote before cloning - Keep advanced URL paste fallback and blank repository creation - Add focused backend and frontend coverage plus docs updates Quality gates: python -m py_compile, vitest run src/components/repositories-settings-tab.test.tsx, npm run typecheck --- apps/api/src/api/git_repositories.py | 29 +++ .../test_git_repository_clone_preflight.py | 28 +++ .../repositories-settings-tab.test.tsx | 41 ++++- .../components/repository-create-dialog.tsx | 169 ++++++++++++------ docs/features/repositories.md | 12 +- .../git-repo-ssh-clone-check/.openspec.yaml | 2 + .../git-repo-ssh-clone-check/design.md | 45 +++++ .../git-repo-ssh-clone-check/proposal.md | 25 +++ .../changes/git-repo-ssh-clone-check/tasks.md | 22 +++ 9 files changed, 310 insertions(+), 63 deletions(-) create mode 100644 apps/api/tests/unit/test_git_repository_clone_preflight.py create mode 100644 openspec/changes/git-repo-ssh-clone-check/.openspec.yaml create mode 100644 openspec/changes/git-repo-ssh-clone-check/design.md create mode 100644 openspec/changes/git-repo-ssh-clone-check/proposal.md create mode 100644 openspec/changes/git-repo-ssh-clone-check/tasks.md 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" /> -