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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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(<RepositoriesSettingsTab />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,10 @@ interface RepositoryCreateDialogProps {
|
||||
export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCreated }: RepositoryCreateDialogProps) => {
|
||||
const [createMode, setCreateMode] = useState<CreateMode>("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<string | null>(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
|
||||
<div className="dialog">
|
||||
<h3>{title}</h3>
|
||||
<p className="muted">
|
||||
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.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="stack">
|
||||
<div className="form-field">
|
||||
@@ -168,46 +192,85 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
|
||||
placeholder="repository-name"
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Remote URL {createMode === "clone" ? "(required)" : "(optional)"}
|
||||
<input
|
||||
type="text"
|
||||
value={formRemoteUrl}
|
||||
onChange={(event) => setFormRemoteUrl(event.target.value)}
|
||||
placeholder="https://github.com/user/repo.git"
|
||||
className={getUrlInputClass()}
|
||||
/>
|
||||
{urlValidation.status === "validating" && (
|
||||
<span className="validation-status validating">Validating...</span>
|
||||
)}
|
||||
{urlValidation.status === "valid" && (
|
||||
<span className="validation-status valid">
|
||||
<Icon name="success" size="sm" /> Valid git URL
|
||||
</span>
|
||||
)}
|
||||
{urlValidation.status === "needs-parsing" && urlValidation.result && (
|
||||
<div className="url-suggestion">
|
||||
<span className="validation-status warning">
|
||||
<Icon name="warning" size="sm" /> This looks like a browser URL
|
||||
{createMode === "clone" && !useAdvancedUrl && (
|
||||
<>
|
||||
<label className="form-field">
|
||||
Owner
|
||||
<input
|
||||
type="text"
|
||||
value={owner}
|
||||
onChange={(event) => setOwner(event.target.value)}
|
||||
placeholder="owner"
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Repository
|
||||
<input
|
||||
type="text"
|
||||
value={repoName}
|
||||
onChange={(event) => setRepoName(event.target.value)}
|
||||
placeholder="repo-name"
|
||||
/>
|
||||
</label>
|
||||
<p className="muted">SSH target: git@git.commumedia.org:{owner || "owner"}/{repoName || "repo"}.git</p>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button small"
|
||||
onClick={() => setUseAdvancedUrl(true)}
|
||||
>
|
||||
Use full URL instead
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{createMode === "clone" && useAdvancedUrl && (
|
||||
<label className="form-field">
|
||||
Remote URL
|
||||
<input
|
||||
type="text"
|
||||
value={advancedUrl}
|
||||
onChange={(event) => setAdvancedUrl(event.target.value)}
|
||||
placeholder="https://github.com/user/repo.git"
|
||||
className={getUrlInputClass()}
|
||||
/>
|
||||
{urlValidation.status === "validating" && (
|
||||
<span className="validation-status validating">Validating...</span>
|
||||
)}
|
||||
{urlValidation.status === "valid" && (
|
||||
<span className="validation-status valid">
|
||||
<Icon name="success" size="sm" /> Valid git URL
|
||||
</span>
|
||||
<div className="suggestion-actions">
|
||||
<span className="suggested-url">Suggested: {urlValidation.result.base_url}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button small"
|
||||
onClick={handleUseSuggestedUrl}
|
||||
>
|
||||
Use Suggested
|
||||
</button>
|
||||
)}
|
||||
{urlValidation.status === "needs-parsing" && urlValidation.result && (
|
||||
<div className="url-suggestion">
|
||||
<span className="validation-status warning">
|
||||
<Icon name="warning" size="sm" /> This looks like a browser URL
|
||||
</span>
|
||||
<div className="suggestion-actions">
|
||||
<span className="suggested-url">Suggested: {urlValidation.result.base_url}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button small"
|
||||
onClick={handleUseSuggestedUrl}
|
||||
>
|
||||
Use Suggested
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{urlValidation.status === "invalid" && (
|
||||
<span className="validation-status invalid">
|
||||
<Icon name="error" size="sm" /> Invalid URL
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
{urlValidation.status === "invalid" && (
|
||||
<span className="validation-status invalid">
|
||||
<Icon name="error" size="sm" /> Invalid URL
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button small"
|
||||
onClick={() => setUseAdvancedUrl(false)}
|
||||
>
|
||||
Use owner/repo instead
|
||||
</button>
|
||||
</label>
|
||||
)}
|
||||
{formError && (
|
||||
<div className="error-message">
|
||||
<p className="error-text">{formError}</p>
|
||||
|
||||
@@ -12,7 +12,7 @@ Git repositories are managed within projects. You can create bare repositories f
|
||||
2. Click the **"New Repository"** button
|
||||
3. Fill in the form:
|
||||
- **Name**: Repository name (required)
|
||||
- **Remote URL**: For cloning (optional)
|
||||
- **Owner** and **Repository**: For SSH cloning from `git.commumedia.org`
|
||||
- **Mirror Clone**: Toggle for mirror clones
|
||||
4. Click **"Create Repository"**
|
||||
|
||||
@@ -25,12 +25,12 @@ Creates a new bare git repository. Use this for:
|
||||
|
||||
#### Clone from Remote
|
||||
|
||||
Enter a git URL to clone from:
|
||||
- `https://github.com/user/repo.git`
|
||||
- `git@github.com:user/repo.git`
|
||||
- `https://gitlab.com/user/repo.git`
|
||||
Enter the repository owner and name to clone from `git.commumedia.org` over SSH:
|
||||
- `owner`: `alice`
|
||||
- `repository`: `demo`
|
||||
- Resulting SSH URL: `git@git.commumedia.org:alice/demo.git`
|
||||
|
||||
**Smart URL Parsing:** If you paste a browser URL (like `https://github.com/user/repo/tree/main`), the system will automatically suggest the correct git URL.
|
||||
**Advanced fallback:** If needed, you can still paste a full git URL and the system will suggest the correct clone URL.
|
||||
|
||||
#### Mirror Clone
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-22
|
||||
@@ -0,0 +1,45 @@
|
||||
## Context
|
||||
|
||||
The current repository creation flow already supports cloning remote repositories via `remote_url` and can normalize pasted browser URLs. However, the UI asks for a full URL, which is awkward for the fixed provider `git.commumedia.org`. The requested behavior is to enter `owner` and `repo`, check whether the repository exists, and clone only if it does.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Accept SSH-only `owner` and `repo` inputs for cloning from `git.commumedia.org`
|
||||
- Verify repository existence before clone
|
||||
- Preserve full URL paste as a fallback path
|
||||
- Preserve blank repository creation
|
||||
- Reuse the existing repository create endpoint and shared dialog
|
||||
|
||||
**Non-Goals:**
|
||||
- Supporting multiple git providers
|
||||
- Adding a remote repository discovery API
|
||||
- Supporting HTTPS clone flow for the new structured path
|
||||
- Changing repository storage or clone behavior beyond preflight validation
|
||||
|
||||
## Decisions
|
||||
|
||||
**1. Provider assumption**
|
||||
- Hardcode `git.commumedia.org` for the structured clone path
|
||||
- Build SSH URLs as `git@git.commumedia.org:{owner}/{repo}.git`
|
||||
|
||||
**2. Existence check**
|
||||
- Use `git ls-remote` on the constructed SSH URL before cloning
|
||||
- If the command fails, surface a repository-not-found/inaccessible error and do not clone
|
||||
|
||||
**3. UI structure**
|
||||
- Keep the shared repository creation dialog as the single entry point
|
||||
- In clone mode, collect `owner` and `repo` instead of asking for a full URL
|
||||
- Keep an advanced paste-URL fallback for existing behavior and browser URL parsing
|
||||
- Keep blank repository creation available in the same dialog
|
||||
|
||||
**4. Backend behavior**
|
||||
- Reuse `POST /projects/{project_id}/repositories`
|
||||
- Add preflight logic before the existing `git clone --mirror`
|
||||
- Leave the database schema unchanged
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**[Risk] SSH auth may still fail even if the repo exists** → Mitigation: preflight error should be explicit and user-facing.
|
||||
**[Risk] Command availability** → Mitigation: reuse the same `git` dependency already required for cloning.
|
||||
**[Risk] UI complexity** → Mitigation: keep the dialog shared and minimal, with fallback URL paste.
|
||||
@@ -0,0 +1,25 @@
|
||||
## Why
|
||||
|
||||
Repository creation already supports cloning from a remote URL, but the current UI only accepts a full URL. For the common fixed-provider case (`git.commumedia.org`), users should be able to enter `owner` and `repo` and have the app verify the repository exists before cloning. If the repository does not exist, the app should surface a clear error. Existing blank repository creation must remain available.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Change the shared repository create dialog to support an SSH-only clone form with `owner` and `repo`
|
||||
- Build the clone target as `git@git.commumedia.org:{owner}/{repo}.git`
|
||||
- Preflight clone targets with `git ls-remote` before cloning
|
||||
- Return a clear error when the repository is missing or inaccessible
|
||||
- Keep the current full URL paste flow as an advanced fallback
|
||||
- Keep blank repository creation as a fallback option
|
||||
|
||||
## Capabilities
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `git-repo`: Repository creation UX and clone validation reuse the existing create endpoint and clone path
|
||||
|
||||
## Impact
|
||||
|
||||
- Frontend: `repository-create-dialog.tsx`, `git-repositories.tsx`, `repositories-settings-tab.tsx`
|
||||
- Backend: `git_repositories.py` create endpoint clone preflight
|
||||
- Docs: repository creation guidance must reflect SSH-only owner/repo input
|
||||
- Tests: add coverage for SSH repo existence checks and fallback URL behavior
|
||||
@@ -0,0 +1,22 @@
|
||||
## 1. Backend - SSH Existence Check
|
||||
|
||||
- [ ] 1.1 Add `git ls-remote` preflight to repository creation in `git_repositories.py`
|
||||
- [ ] 1.2 Build SSH clone URL from `owner` and `repo` for `git.commumedia.org`
|
||||
- [ ] 1.3 Return a clear error when the repository is missing or inaccessible
|
||||
|
||||
## 2. Frontend - Structured Clone Form
|
||||
|
||||
- [ ] 2.1 Update `RepositoryCreateDialog` clone mode to accept `owner` and `repo`
|
||||
- [ ] 2.2 Keep advanced full-URL paste flow and blank repository fallback
|
||||
- [ ] 2.3 Reuse the shared dialog from repository settings and repositories page
|
||||
|
||||
## 3. Validation and Docs
|
||||
|
||||
- [ ] 3.1 Update repository docs to explain SSH-only owner/repo input
|
||||
- [ ] 3.2 Add tests for success, missing repo, and URL fallback behavior
|
||||
|
||||
## 4. Quality Gates
|
||||
|
||||
- [ ] 4.1 Run backend and frontend targeted tests
|
||||
- [ ] 4.2 Run frontend typecheck and lint where applicable
|
||||
- [ ] 4.3 Commit and push changes
|
||||
Reference in New Issue
Block a user