diff --git a/apps/api/src/api/config_profiles.py b/apps/api/src/api/config_profiles.py index 4fe2c5e..e047c73 100644 --- a/apps/api/src/api/config_profiles.py +++ b/apps/api/src/api/config_profiles.py @@ -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, + ) diff --git a/apps/api/tests/unit/test_config_profile_resolver.py b/apps/api/tests/unit/test_config_profile_resolver.py index 1c6c11e..8c5a0d8 100644 --- a/apps/api/tests/unit/test_config_profile_resolver.py +++ b/apps/api/tests/unit/test_config_profile_resolver.py @@ -494,7 +494,10 @@ class TestApplyResolvedProfile: "/app": ResolvedMount( target="/app", 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( profile_id=uuid.uuid4(), profile_name="test", - mounts={ - "/app": ResolvedMount(target="/app", mode="rw", files={}) - }, + mounts={"/app": ResolvedMount(target="/app", mode="rw", files={})}, ) env, files, volumes, hints = apply_resolved_profile(str(tmp_path), resolved) assert volumes == [] diff --git a/apps/web/src/api/config_profiles.ts b/apps/web/src/api/config_profiles.ts index 048d70c..c8959b2 100644 --- a/apps/web/src/api/config_profiles.ts +++ b/apps/web/src/api/config_profiles.ts @@ -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 => { + const response = await apiClient.post( + "/config-profiles/validate-git-url", + { url, ssh_key_id: sshKeyId }, + ); + return response.data; +}; diff --git a/apps/web/src/components/git-mount-editor.tsx b/apps/web/src/components/git-mount-editor.tsx index 573b5db..85d67ff 100644 --- a/apps/web/src/components/git-mount-editor.tsx +++ b/apps/web/src/components/git-mount-editor.tsx @@ -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>({}); + const [validation, setValidation] = useState({ 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 = {}; @@ -286,46 +351,106 @@ const GitMountForm = ({ mount, onSave, onCancel }: GitMountFormProps) => { return (
-
+
- { - 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" : ""}`} - /> +
+ { + 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 }} + /> + +
{errors.remote_url && ( {errors.remote_url} )} + {validation.status === "valid" && ( + + Repository is accessible ({(validation as Extract).branches.length} branches) + + )} + {validation.status === "suggestion" && ( +
+ {validation.message} +
+ + {validation.suggestedUrl} + + +
+
+ )} + {validation.status === "invalid" && ( + + {validation.message} + + )}
- setBranch(e.target.value)} - placeholder="main" - className="form-input" - /> + {validation.status === "valid" ? ( + + ) : ( + setBranch(e.target.value)} + placeholder="main" + className="form-input" + disabled={!isUrlValidated} + /> + )}
-
+
@@ -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 && ( + + {" "}Validate the URL first. + + )}