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:
@@ -1,6 +1,8 @@
|
|||||||
"""Config profile API endpoints."""
|
"""Config profile API endpoints."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -21,6 +23,7 @@ from src.services.config_profile_resolver import (
|
|||||||
resolve_profile,
|
resolve_profile,
|
||||||
resolved_profile_to_dict,
|
resolved_profile_to_dict,
|
||||||
)
|
)
|
||||||
|
from src.utils.git_url_parser import parse_git_url
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -835,3 +838,162 @@ async def resolve_default_profile(
|
|||||||
# Fall back to first created compatible profile
|
# Fall back to first created compatible profile
|
||||||
first = profiles[0]
|
first = profiles[0]
|
||||||
return {"profile_id": str(first.id), "profile_name": first.name}
|
return {"profile_id": str(first.id), "profile_name": first.name}
|
||||||
|
|
||||||
|
|
||||||
|
class ValidateGitUrlRequest(BaseModel):
|
||||||
|
url: str = Field(description="Git remote URL to validate")
|
||||||
|
ssh_key_id: str | None = Field(default=None, description="Optional SSH key ID for private repos")
|
||||||
|
|
||||||
|
|
||||||
|
class ValidateGitUrlResponse(BaseModel):
|
||||||
|
valid: bool
|
||||||
|
suggested_url: str | None = None
|
||||||
|
branches: list[str] | None = None
|
||||||
|
default_branch: str | None = None
|
||||||
|
error: str | None = None
|
||||||
|
error_code: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/validate-git-url", response_model=ValidateGitUrlResponse)
|
||||||
|
async def validate_git_url(
|
||||||
|
data: ValidateGitUrlRequest,
|
||||||
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> ValidateGitUrlResponse:
|
||||||
|
"""Validate a git remote URL and list available branches.
|
||||||
|
|
||||||
|
Parses the URL, suggests corrections for browser URLs, and runs
|
||||||
|
git ls-remote to verify reachability and enumerate branches.
|
||||||
|
"""
|
||||||
|
parse_result = parse_git_url(data.url)
|
||||||
|
original_url = data.url.strip()
|
||||||
|
url_to_check = parse_result.get("base_url") or original_url
|
||||||
|
|
||||||
|
if not url_to_check:
|
||||||
|
return ValidateGitUrlResponse(
|
||||||
|
valid=False,
|
||||||
|
error=parse_result.get("message", "Invalid URL"),
|
||||||
|
error_code=parse_result.get("error_code", "INVALID_URL"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# If the URL needed parsing, return suggestion without checking remote
|
||||||
|
if parse_result.get("needs_parsing") and url_to_check != original_url:
|
||||||
|
return ValidateGitUrlResponse(
|
||||||
|
valid=False,
|
||||||
|
suggested_url=url_to_check,
|
||||||
|
error=parse_result.get("message"),
|
||||||
|
error_code=parse_result.get("error_code", "URL_NEEDS_PARSING"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Optional SSH key for private repos
|
||||||
|
env = None
|
||||||
|
key_path = None
|
||||||
|
if data.ssh_key_id:
|
||||||
|
from src.models.ssh_key import SSHKey
|
||||||
|
from src.services.ssh_keys import _get_fernet
|
||||||
|
|
||||||
|
try:
|
||||||
|
ssh_key_uuid = uuid.UUID(data.ssh_key_id)
|
||||||
|
except ValueError:
|
||||||
|
return ValidateGitUrlResponse(
|
||||||
|
valid=False,
|
||||||
|
error="Invalid SSH key ID format",
|
||||||
|
error_code="INVALID_SSH_KEY",
|
||||||
|
)
|
||||||
|
|
||||||
|
ssh_key = await session.get(SSHKey, ssh_key_uuid)
|
||||||
|
if ssh_key is None or ssh_key.user_id != current_user_id:
|
||||||
|
return ValidateGitUrlResponse(
|
||||||
|
valid=False,
|
||||||
|
error="SSH key not found or not authorized",
|
||||||
|
error_code="SSH_KEY_NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
fernet = _get_fernet()
|
||||||
|
private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
|
||||||
|
fd, key_path = tempfile.mkstemp(prefix="ssh_key_")
|
||||||
|
try:
|
||||||
|
os.write(fd, private_key.encode())
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
os.chmod(key_path, 0o600)
|
||||||
|
env = {
|
||||||
|
"GIT_SSH_COMMAND": f"ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "ls-remote", "--heads", url_to_check],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
env={**os.environ, **env} if env else None,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
if key_path and os.path.exists(key_path):
|
||||||
|
os.unlink(key_path)
|
||||||
|
return ValidateGitUrlResponse(
|
||||||
|
valid=False,
|
||||||
|
error="Remote repository check timed out",
|
||||||
|
error_code="TIMEOUT",
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
if key_path and os.path.exists(key_path):
|
||||||
|
os.unlink(key_path)
|
||||||
|
return ValidateGitUrlResponse(
|
||||||
|
valid=False,
|
||||||
|
error="git command not found on server",
|
||||||
|
error_code="GIT_NOT_FOUND",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if key_path and os.path.exists(key_path):
|
||||||
|
os.unlink(key_path)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
stderr = result.stderr.strip()
|
||||||
|
if "could not resolve" in stderr.lower() or "unable to access" in stderr.lower():
|
||||||
|
error_msg = "Could not reach repository. Check the URL and network access."
|
||||||
|
error_code = "UNREACHABLE"
|
||||||
|
elif "authentication" in stderr.lower() or "permission denied" in stderr.lower():
|
||||||
|
error_msg = "Authentication failed. Provide an SSH key for private repositories."
|
||||||
|
error_code = "AUTH_FAILED"
|
||||||
|
else:
|
||||||
|
error_msg = f"Repository not accessible: {stderr[:200]}"
|
||||||
|
error_code = "REMOTE_ERROR"
|
||||||
|
return ValidateGitUrlResponse(
|
||||||
|
valid=False,
|
||||||
|
error=error_msg,
|
||||||
|
error_code=error_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse branches from ls-remote output
|
||||||
|
branches: list[str] = []
|
||||||
|
default_branch = "main"
|
||||||
|
for line in result.stdout.strip().split("\n"):
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) == 2:
|
||||||
|
ref = parts[1]
|
||||||
|
# refs/heads/branch-name
|
||||||
|
if ref.startswith("refs/heads/"):
|
||||||
|
branch_name = ref[len("refs/heads/"):]
|
||||||
|
branches.append(branch_name)
|
||||||
|
if branch_name in ("main", "master"):
|
||||||
|
default_branch = branch_name
|
||||||
|
|
||||||
|
if not branches:
|
||||||
|
return ValidateGitUrlResponse(
|
||||||
|
valid=False,
|
||||||
|
error="No branches found in remote repository",
|
||||||
|
error_code="NO_BRANCHES",
|
||||||
|
)
|
||||||
|
|
||||||
|
return ValidateGitUrlResponse(
|
||||||
|
valid=True,
|
||||||
|
suggested_url=url_to_check if url_to_check != original_url else None,
|
||||||
|
branches=branches,
|
||||||
|
default_branch=default_branch,
|
||||||
|
)
|
||||||
|
|||||||
@@ -494,7 +494,10 @@ class TestApplyResolvedProfile:
|
|||||||
"/app": ResolvedMount(
|
"/app": ResolvedMount(
|
||||||
target="/app",
|
target="/app",
|
||||||
mode="rw",
|
mode="rw",
|
||||||
files={"config.json": '{"key": "value"}', "nested/file.txt": "hello"},
|
files={
|
||||||
|
"config.json": '{"key": "value"}',
|
||||||
|
"nested/file.txt": "hello",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -531,9 +534,7 @@ class TestApplyResolvedProfile:
|
|||||||
resolved = ResolvedProfile(
|
resolved = ResolvedProfile(
|
||||||
profile_id=uuid.uuid4(),
|
profile_id=uuid.uuid4(),
|
||||||
profile_name="test",
|
profile_name="test",
|
||||||
mounts={
|
mounts={"/app": ResolvedMount(target="/app", mode="rw", files={})},
|
||||||
"/app": ResolvedMount(target="/app", mode="rw", files={})
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
env, files, volumes, hints = apply_resolved_profile(str(tmp_path), resolved)
|
env, files, volumes, hints = apply_resolved_profile(str(tmp_path), resolved)
|
||||||
assert volumes == []
|
assert volumes == []
|
||||||
|
|||||||
@@ -167,3 +167,23 @@ export const resolveDefaultProfile = async (
|
|||||||
});
|
});
|
||||||
return response.data;
|
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 { useState, useEffect } from "react";
|
||||||
import { Icon } from "./icon";
|
import { Icon } from "./icon";
|
||||||
|
import { validateGitUrl } from "../api/config_profiles";
|
||||||
import type { GitMount, GitMountMapping } from "../api/config_profiles";
|
import type { GitMount, GitMountMapping } from "../api/config_profiles";
|
||||||
|
|
||||||
interface GitMountEditorProps {
|
interface GitMountEditorProps {
|
||||||
@@ -204,6 +205,13 @@ interface GitMountFormProps {
|
|||||||
onCancel: () => void;
|
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 GitMountForm = ({ mount, onSave, onCancel }: GitMountFormProps) => {
|
||||||
const [remoteUrl, setRemoteUrl] = useState(mount.remote_url);
|
const [remoteUrl, setRemoteUrl] = useState(mount.remote_url);
|
||||||
const [branch, setBranch] = useState(mount.branch || "");
|
const [branch, setBranch] = useState(mount.branch || "");
|
||||||
@@ -213,6 +221,63 @@ const GitMountForm = ({ mount, onSave, onCancel }: GitMountFormProps) => {
|
|||||||
: [{ source_path: ".", target_path: "" }],
|
: [{ source_path: ".", target_path: "" }],
|
||||||
);
|
);
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
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 validate = (): boolean => {
|
||||||
const newErrors: Record<string, string> = {};
|
const newErrors: Record<string, string> = {};
|
||||||
@@ -286,46 +351,106 @@ const GitMountForm = ({ mount, onSave, onCancel }: GitMountFormProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
<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 }}>
|
<div style={{ flex: 2 }}>
|
||||||
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
|
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
|
||||||
Repository URL
|
Repository URL
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
type="text"
|
<input
|
||||||
value={remoteUrl}
|
type="text"
|
||||||
onChange={(e) => {
|
value={remoteUrl}
|
||||||
setRemoteUrl(e.target.value);
|
onChange={(e) => {
|
||||||
if (errors.remote_url) {
|
setRemoteUrl(e.target.value);
|
||||||
setErrors((prev) => {
|
setValidation({ status: "idle" });
|
||||||
const next = { ...prev };
|
if (errors.remote_url) {
|
||||||
delete next.remote_url;
|
setErrors((prev) => {
|
||||||
return next;
|
const next = { ...prev };
|
||||||
});
|
delete next.remote_url;
|
||||||
}
|
return next;
|
||||||
}}
|
});
|
||||||
placeholder="https://github.com/user/repo.git"
|
}
|
||||||
className={`form-input ${errors.remote_url ? "error" : ""}`}
|
}}
|
||||||
/>
|
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 && (
|
{errors.remote_url && (
|
||||||
<span className="error-text">{errors.remote_url}</span>
|
<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>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
|
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
|
||||||
Branch (optional)
|
Branch
|
||||||
</label>
|
</label>
|
||||||
<input
|
{validation.status === "valid" ? (
|
||||||
type="text"
|
<select
|
||||||
value={branch}
|
value={branch}
|
||||||
onChange={(e) => setBranch(e.target.value)}
|
onChange={(e) => setBranch(e.target.value)}
|
||||||
placeholder="main"
|
className="form-input"
|
||||||
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>
|
||||||
|
|
||||||
<div>
|
<div style={{ opacity: isUrlValidated ? 1 : 0.5, pointerEvents: isUrlValidated ? "auto" : "none" }}>
|
||||||
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
|
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
|
||||||
Mappings
|
Mappings
|
||||||
</label>
|
</label>
|
||||||
@@ -334,6 +459,11 @@ const GitMountForm = ({ mount, onSave, onCancel }: GitMountFormProps) => {
|
|||||||
style={{ margin: "0 0 0.5rem 0", fontSize: "0.8125rem" }}
|
style={{ margin: "0 0 0.5rem 0", fontSize: "0.8125rem" }}
|
||||||
>
|
>
|
||||||
Source paths within the repo and where to mount them in the container.
|
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>
|
</p>
|
||||||
<div
|
<div
|
||||||
style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}
|
style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
name: git-mount-url-validation
|
||||||
|
status: completed
|
||||||
|
type: feat
|
||||||
|
priority: high
|
||||||
Reference in New Issue
Block a user