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."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
@@ -21,6 +23,7 @@ from src.services.config_profile_resolver import (
|
||||
resolve_profile,
|
||||
resolved_profile_to_dict,
|
||||
)
|
||||
from src.utils.git_url_parser import parse_git_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -835,3 +838,162 @@ async def resolve_default_profile(
|
||||
# Fall back to first created compatible profile
|
||||
first = profiles[0]
|
||||
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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user