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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user