feat: git mount URL validation with branch detection
- Add POST /config-profiles/validate-git-url endpoint:
- Parses URL using existing parse_git_url utility
- Suggests corrected URL for browser URLs
- Runs git ls-remote --heads to verify reachability
- Lists available branches from remote
- Supports SSH key for private repos
- Returns structured response: valid, suggested_url, branches,
default_branch, error, error_code
- Update frontend GitMountEditor:
- Add Check button next to URL field with loading state
- Show validation result: valid (green), suggestion (yellow),
invalid (red)
- Suggestion includes Use this button to apply corrected URL
- Branch field becomes dropdown when URL is validated,
populated with remote branches
- Mappings section disabled until URL is validated
- Shows hint: Validate the URL first
- Quality gates: pytest (218 passed, 6 pre-existing),
tsc --noEmit (clean)
This commit is contained in:
@@ -167,3 +167,23 @@ export const resolveDefaultProfile = async (
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export interface ValidateGitUrlResponse {
|
||||
valid: boolean;
|
||||
suggested_url?: string;
|
||||
branches?: string[];
|
||||
default_branch?: string;
|
||||
error?: string;
|
||||
error_code?: string;
|
||||
}
|
||||
|
||||
export const validateGitUrl = async (
|
||||
url: string,
|
||||
sshKeyId?: string,
|
||||
): Promise<ValidateGitUrlResponse> => {
|
||||
const response = await apiClient.post<ValidateGitUrlResponse>(
|
||||
"/config-profiles/validate-git-url",
|
||||
{ url, ssh_key_id: sshKeyId },
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Icon } from "./icon";
|
||||
import { validateGitUrl } from "../api/config_profiles";
|
||||
import type { GitMount, GitMountMapping } from "../api/config_profiles";
|
||||
|
||||
interface GitMountEditorProps {
|
||||
@@ -204,6 +205,13 @@ interface GitMountFormProps {
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
type ValidationState =
|
||||
| { status: "idle" }
|
||||
| { status: "loading" }
|
||||
| { status: "valid"; branches: string[]; defaultBranch: string }
|
||||
| { status: "suggestion"; suggestedUrl: string; message: string }
|
||||
| { status: "invalid"; message: string };
|
||||
|
||||
const GitMountForm = ({ mount, onSave, onCancel }: GitMountFormProps) => {
|
||||
const [remoteUrl, setRemoteUrl] = useState(mount.remote_url);
|
||||
const [branch, setBranch] = useState(mount.branch || "");
|
||||
@@ -213,6 +221,63 @@ const GitMountForm = ({ mount, onSave, onCancel }: GitMountFormProps) => {
|
||||
: [{ source_path: ".", target_path: "" }],
|
||||
);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [validation, setValidation] = useState<ValidationState>({ status: "idle" });
|
||||
|
||||
const isUrlValidated =
|
||||
validation.status === "valid" ||
|
||||
(validation.status === "idle" && mount.remote_url.length > 0);
|
||||
|
||||
const handleCheckUrl = async () => {
|
||||
if (!remoteUrl.trim()) {
|
||||
setErrors((prev) => ({ ...prev, remote_url: "Git URL is required" }));
|
||||
return;
|
||||
}
|
||||
setValidation({ status: "loading" });
|
||||
setErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next.remote_url;
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
const result = await validateGitUrl(remoteUrl.trim());
|
||||
if (result.valid && result.branches) {
|
||||
setValidation({
|
||||
status: "valid",
|
||||
branches: result.branches,
|
||||
defaultBranch: result.default_branch || "main",
|
||||
});
|
||||
if (!branch) {
|
||||
setBranch(result.default_branch || "main");
|
||||
}
|
||||
if (result.suggested_url && result.suggested_url !== remoteUrl.trim()) {
|
||||
setRemoteUrl(result.suggested_url);
|
||||
}
|
||||
} else if (result.suggested_url) {
|
||||
setValidation({
|
||||
status: "suggestion",
|
||||
suggestedUrl: result.suggested_url,
|
||||
message: result.error || "URL needs correction",
|
||||
});
|
||||
} else {
|
||||
setValidation({
|
||||
status: "invalid",
|
||||
message: result.error || "Invalid repository URL",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setValidation({
|
||||
status: "invalid",
|
||||
message: "Failed to validate URL. Please try again.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const applySuggestion = () => {
|
||||
if (validation.status === "suggestion") {
|
||||
setRemoteUrl(validation.suggestedUrl);
|
||||
setValidation({ status: "idle" });
|
||||
}
|
||||
};
|
||||
|
||||
const validate = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
@@ -286,46 +351,106 @@ const GitMountForm = ({ mount, onSave, onCancel }: GitMountFormProps) => {
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||
<div className="form-row" style={{ gap: "0.5rem" }}>
|
||||
<div className="form-row" style={{ gap: "0.5rem", alignItems: "flex-start" }}>
|
||||
<div style={{ flex: 2 }}>
|
||||
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
|
||||
Repository URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={remoteUrl}
|
||||
onChange={(e) => {
|
||||
setRemoteUrl(e.target.value);
|
||||
if (errors.remote_url) {
|
||||
setErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next.remote_url;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}}
|
||||
placeholder="https://github.com/user/repo.git"
|
||||
className={`form-input ${errors.remote_url ? "error" : ""}`}
|
||||
/>
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
<input
|
||||
type="text"
|
||||
value={remoteUrl}
|
||||
onChange={(e) => {
|
||||
setRemoteUrl(e.target.value);
|
||||
setValidation({ status: "idle" });
|
||||
if (errors.remote_url) {
|
||||
setErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next.remote_url;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}}
|
||||
placeholder="https://github.com/user/repo.git"
|
||||
className={`form-input ${errors.remote_url ? "error" : ""}`}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button small"
|
||||
onClick={handleCheckUrl}
|
||||
disabled={validation.status === "loading"}
|
||||
>
|
||||
{validation.status === "loading" ? (
|
||||
<Icon name="loading" size="sm" />
|
||||
) : (
|
||||
"Check"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.remote_url && (
|
||||
<span className="error-text">{errors.remote_url}</span>
|
||||
)}
|
||||
{validation.status === "valid" && (
|
||||
<span className="validation-status valid">
|
||||
Repository is accessible ({(validation as Extract<ValidationState, { status: "valid" }>).branches.length} branches)
|
||||
</span>
|
||||
)}
|
||||
{validation.status === "suggestion" && (
|
||||
<div className="url-suggestion">
|
||||
<span>{validation.message}</span>
|
||||
<div className="suggestion-actions">
|
||||
<code className="suggested-url">
|
||||
{validation.suggestedUrl}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button small"
|
||||
onClick={applySuggestion}
|
||||
>
|
||||
Use this
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{validation.status === "invalid" && (
|
||||
<span className="validation-status invalid">
|
||||
{validation.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
|
||||
Branch (optional)
|
||||
Branch
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
placeholder="main"
|
||||
className="form-input"
|
||||
/>
|
||||
{validation.status === "valid" ? (
|
||||
<select
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
className="form-input"
|
||||
>
|
||||
{(validation as Extract<ValidationState, { status: "valid" }>).branches.map(
|
||||
(b) => (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
placeholder="main"
|
||||
className="form-input"
|
||||
disabled={!isUrlValidated}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ opacity: isUrlValidated ? 1 : 0.5, pointerEvents: isUrlValidated ? "auto" : "none" }}>
|
||||
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
|
||||
Mappings
|
||||
</label>
|
||||
@@ -334,6 +459,11 @@ const GitMountForm = ({ mount, onSave, onCancel }: GitMountFormProps) => {
|
||||
style={{ margin: "0 0 0.5rem 0", fontSize: "0.8125rem" }}
|
||||
>
|
||||
Source paths within the repo and where to mount them in the container.
|
||||
{!isUrlValidated && (
|
||||
<span style={{ color: "var(--warning)" }}>
|
||||
{" "}Validate the URL first.
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<div
|
||||
style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}
|
||||
|
||||
Reference in New Issue
Block a user