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:
Alex Blank
2026-05-29 12:15:30 +02:00
parent 4a0d38384f
commit 090edf7ef6
5 changed files with 347 additions and 30 deletions
+156 -26
View File
@@ -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" }}