feat: smart git URL parsing for browser URLs
- Add git URL parsing utilities (extract_base_repo_url, parse_git_url) - Support GitHub, GitLab, Bitbucket browser URL detection - Add /projects/repositories/parse-url endpoint - Enhance repository creation to detect browser URLs and suggest corrections - Add real-time URL validation in frontend with debouncing - Show visual indicators (green/yellow/red) for URL validity - Display inline suggestions with 'Use Suggested' button - Add comprehensive unit tests for URL parsing - Quality gates: ruff ✓, mypy ✓, typecheck ✓, lint ✓, build ✓
This commit is contained in:
@@ -14,6 +14,7 @@ from src.config import Settings
|
|||||||
from src.models.git_repository import GitRepository
|
from src.models.git_repository import GitRepository
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
|
from src.utils.git_url_parser import parse_git_url
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects", tags=["git-repositories"])
|
router = APIRouter(prefix="/projects", tags=["git-repositories"])
|
||||||
|
|
||||||
@@ -46,6 +47,21 @@ def _get_repo_path(user_id: uuid.UUID, project_id: uuid.UUID, name: str) -> str:
|
|||||||
class GitRepositoryCreate(BaseModel):
|
class GitRepositoryCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
remote_url: str | None = None
|
remote_url: str | None = None
|
||||||
|
force_original_url: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class URLParseRequest(BaseModel):
|
||||||
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
class URLParseResponse(BaseModel):
|
||||||
|
original_url: str
|
||||||
|
base_url: str | None
|
||||||
|
is_valid_clone_url: bool
|
||||||
|
needs_parsing: bool
|
||||||
|
host: str | None
|
||||||
|
message: str
|
||||||
|
error_code: str | None
|
||||||
|
|
||||||
|
|
||||||
class GitRepositoryResponse(BaseModel):
|
class GitRepositoryResponse(BaseModel):
|
||||||
@@ -63,75 +79,6 @@ class GitRepositoryResponse(BaseModel):
|
|||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{project_id}/repositories", response_model=GitRepositoryResponse, status_code=status.HTTP_201_CREATED)
|
|
||||||
async def create_repository(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
data: GitRepositoryCreate,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> GitRepository:
|
|
||||||
_user = await _get_user(session, user_id)
|
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
|
||||||
|
|
||||||
# Check for duplicate name
|
|
||||||
existing = await session.execute(
|
|
||||||
select(GitRepository).where(
|
|
||||||
GitRepository.project_id == project_id,
|
|
||||||
GitRepository.name == data.name,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if existing.scalar_one_or_none():
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists")
|
|
||||||
|
|
||||||
repo_path = _get_repo_path(user_id, project_id, data.name)
|
|
||||||
|
|
||||||
# Ensure parent directory exists
|
|
||||||
os.makedirs(os.path.dirname(repo_path), exist_ok=True)
|
|
||||||
|
|
||||||
if data.remote_url:
|
|
||||||
# Clone as mirror
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["git", "clone", "--mirror", data.remote_url, repo_path],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=300,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"failed to clone repository: {result.stderr}",
|
|
||||||
)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out")
|
|
||||||
except FileNotFoundError:
|
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
|
||||||
else:
|
|
||||||
# Init bare repo
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["git", "init", "--bare", repo_path],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
except FileNotFoundError:
|
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
|
||||||
|
|
||||||
repo = GitRepository(
|
|
||||||
name=data.name,
|
|
||||||
path=repo_path,
|
|
||||||
project_id=project_id,
|
|
||||||
owner_id=user_id,
|
|
||||||
is_mirror=bool(data.remote_url),
|
|
||||||
remote_url=data.remote_url,
|
|
||||||
)
|
|
||||||
session.add(repo)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(repo)
|
|
||||||
return repo
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{project_id}/repositories", response_model=list[GitRepositoryResponse])
|
@router.get("/{project_id}/repositories", response_model=list[GitRepositoryResponse])
|
||||||
async def list_repositories(
|
async def list_repositories(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
@@ -168,3 +115,97 @@ async def delete_repository(
|
|||||||
await session.delete(repo)
|
await session.delete(repo)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/repositories/parse-url", response_model=URLParseResponse)
|
||||||
|
async def parse_repository_url(data: URLParseRequest) -> URLParseResponse:
|
||||||
|
"""Parse a git URL and detect if it's a browser URL that needs correction."""
|
||||||
|
result = parse_git_url(data.url)
|
||||||
|
return URLParseResponse(**result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{project_id}/repositories", response_model=GitRepositoryResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_repository(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
data: GitRepositoryCreate,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> GitRepository:
|
||||||
|
_user = await _get_user(session, user_id)
|
||||||
|
_project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
|
# Check for duplicate name
|
||||||
|
existing = await session.execute(
|
||||||
|
select(GitRepository).where(
|
||||||
|
GitRepository.project_id == project_id,
|
||||||
|
GitRepository.name == data.name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists")
|
||||||
|
|
||||||
|
# Validate and potentially correct the URL
|
||||||
|
remote_url = data.remote_url
|
||||||
|
if remote_url and not data.force_original_url:
|
||||||
|
parse_result = parse_git_url(remote_url)
|
||||||
|
if parse_result["needs_parsing"] and parse_result["base_url"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail={
|
||||||
|
"message": "The provided URL appears to be a browser URL, not a git clone URL",
|
||||||
|
"suggested_url": parse_result["base_url"],
|
||||||
|
"original_url": remote_url,
|
||||||
|
"error_code": "URL_NEEDS_PARSING",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# Use base_url if it was extracted (for URLs without .git suffix)
|
||||||
|
if parse_result["base_url"]:
|
||||||
|
remote_url = parse_result["base_url"]
|
||||||
|
|
||||||
|
repo_path = _get_repo_path(user_id, project_id, data.name)
|
||||||
|
|
||||||
|
# Ensure parent directory exists
|
||||||
|
os.makedirs(os.path.dirname(repo_path), exist_ok=True)
|
||||||
|
|
||||||
|
if remote_url:
|
||||||
|
# Clone as mirror
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "clone", "--mirror", remote_url, repo_path],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=300,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"failed to clone repository: {result.stderr}",
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out")
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
||||||
|
else:
|
||||||
|
# Init bare repo
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "init", "--bare", repo_path],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
||||||
|
|
||||||
|
repo = GitRepository(
|
||||||
|
name=data.name,
|
||||||
|
path=repo_path,
|
||||||
|
project_id=project_id,
|
||||||
|
owner_id=user_id,
|
||||||
|
is_mirror=bool(remote_url),
|
||||||
|
remote_url=remote_url,
|
||||||
|
)
|
||||||
|
session.add(repo)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(repo)
|
||||||
|
return repo
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
"""Git URL parsing utilities to extract base repository URLs from browser URLs."""
|
||||||
|
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
|
||||||
|
def extract_base_repo_url(url: str) -> str | None:
|
||||||
|
"""Extract base repository URL from a browser/git URL.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
https://github.com/user/repo/tree/main → https://github.com/user/repo.git
|
||||||
|
https://github.com/user/repo.git → https://github.com/user/repo.git
|
||||||
|
git@github.com:user/repo.git → git@github.com:user/repo.git
|
||||||
|
https://gitlab.com/user/repo/-/blob/main/README.md → https://gitlab.com/user/repo.git
|
||||||
|
|
||||||
|
Returns None if URL doesn't match known patterns.
|
||||||
|
"""
|
||||||
|
# Handle SSH URLs (pass through unchanged)
|
||||||
|
if url.startswith("git@"):
|
||||||
|
return url if url.endswith(".git") else f"{url}.git"
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Remove query parameters
|
||||||
|
url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
|
||||||
|
|
||||||
|
# Extract host
|
||||||
|
host = parsed.netloc.lower()
|
||||||
|
|
||||||
|
# Split path
|
||||||
|
path_parts = [p for p in parsed.path.split("/") if p]
|
||||||
|
|
||||||
|
if not path_parts:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Determine host type and extract base
|
||||||
|
if "github.com" in host:
|
||||||
|
return _extract_github_url(url, path_parts)
|
||||||
|
elif "gitlab.com" in host:
|
||||||
|
return _extract_gitlab_url(url, path_parts)
|
||||||
|
elif "bitbucket.org" in host:
|
||||||
|
return _extract_bitbucket_url(url, path_parts)
|
||||||
|
else:
|
||||||
|
# Generic host - try basic extraction
|
||||||
|
return _extract_generic_url(url, path_parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_github_url(url: str, path_parts: list[str]) -> str | None:
|
||||||
|
"""Extract base repo URL from GitHub URL."""
|
||||||
|
# Need at least owner/repo
|
||||||
|
if len(path_parts) < 2:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Find the repo name (second path part)
|
||||||
|
# Remove trailing .git if present
|
||||||
|
repo_name = path_parts[1]
|
||||||
|
if repo_name.endswith(".git"):
|
||||||
|
repo_name = repo_name[:-4]
|
||||||
|
|
||||||
|
# Reconstruct base URL
|
||||||
|
base = f"https://github.com/{path_parts[0]}/{repo_name}"
|
||||||
|
|
||||||
|
# Add .git suffix
|
||||||
|
return f"{base}.git"
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_gitlab_url(url: str, path_parts: list[str]) -> str | None:
|
||||||
|
"""Extract base repo URL from GitLab URL."""
|
||||||
|
# Need at least owner/repo
|
||||||
|
if len(path_parts) < 2:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Find the repo name (second path part)
|
||||||
|
repo_name = path_parts[1]
|
||||||
|
if repo_name.endswith(".git"):
|
||||||
|
repo_name = repo_name[:-4]
|
||||||
|
|
||||||
|
# Reconstruct base URL
|
||||||
|
base = f"https://gitlab.com/{path_parts[0]}/{repo_name}"
|
||||||
|
|
||||||
|
return f"{base}.git"
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_bitbucket_url(url: str, path_parts: list[str]) -> str | None:
|
||||||
|
"""Extract base repo URL from Bitbucket URL."""
|
||||||
|
# Need at least owner/repo
|
||||||
|
if len(path_parts) < 2:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Find the repo name (second path part)
|
||||||
|
repo_name = path_parts[1]
|
||||||
|
if repo_name.endswith(".git"):
|
||||||
|
repo_name = repo_name[:-4]
|
||||||
|
|
||||||
|
# Reconstruct base URL
|
||||||
|
base = f"https://bitbucket.org/{path_parts[0]}/{repo_name}"
|
||||||
|
|
||||||
|
return f"{base}.git"
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_generic_url(url: str, path_parts: list[str]) -> str | None:
|
||||||
|
"""Extract base repo URL from generic git host URL."""
|
||||||
|
# Need at least owner/repo
|
||||||
|
if len(path_parts) < 2:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Find the repo name (second path part)
|
||||||
|
repo_name = path_parts[1]
|
||||||
|
if repo_name.endswith(".git"):
|
||||||
|
repo_name = repo_name[:-4]
|
||||||
|
|
||||||
|
# Reconstruct base URL
|
||||||
|
parsed = urlparse(url)
|
||||||
|
base = f"{parsed.scheme}://{parsed.netloc}/{path_parts[0]}/{repo_name}"
|
||||||
|
|
||||||
|
return f"{base}.git"
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_clone_url(url: str) -> bool:
|
||||||
|
"""Check if URL is already a valid git clone URL.
|
||||||
|
|
||||||
|
A valid clone URL:
|
||||||
|
- Is an SSH URL (git@host:path)
|
||||||
|
- Ends with .git
|
||||||
|
- Has no browser-specific path segments
|
||||||
|
"""
|
||||||
|
# SSH URLs are always valid
|
||||||
|
if url.startswith("git@"):
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
path = parsed.path
|
||||||
|
|
||||||
|
# Must end with .git for HTTPS
|
||||||
|
if not path.endswith(".git"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check for browser-specific paths
|
||||||
|
browser_paths = ["/tree/", "/blob/", "/pull/", "/issues/", "/actions/",
|
||||||
|
"-/tree/", "-/blob/", "-/merge_requests/",
|
||||||
|
"/src/"]
|
||||||
|
|
||||||
|
for bp in browser_paths:
|
||||||
|
if bp in path:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def parse_git_url(url: str) -> dict:
|
||||||
|
"""Parse a git URL and return detailed information.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"original_url": str,
|
||||||
|
"base_url": str | None,
|
||||||
|
"is_valid_clone_url": bool,
|
||||||
|
"needs_parsing": bool,
|
||||||
|
"host": str | None,
|
||||||
|
"message": str,
|
||||||
|
"error_code": str | None,
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
result = {
|
||||||
|
"original_url": url,
|
||||||
|
"base_url": None,
|
||||||
|
"is_valid_clone_url": False,
|
||||||
|
"needs_parsing": False,
|
||||||
|
"host": None,
|
||||||
|
"message": "",
|
||||||
|
"error_code": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if empty
|
||||||
|
if not url or not url.strip():
|
||||||
|
result["message"] = "Please enter a URL"
|
||||||
|
result["error_code"] = "INVALID_URL"
|
||||||
|
return result
|
||||||
|
|
||||||
|
url = url.strip()
|
||||||
|
|
||||||
|
# Try to parse
|
||||||
|
try:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
except Exception:
|
||||||
|
result["message"] = "Please enter a valid URL"
|
||||||
|
result["error_code"] = "INVALID_URL"
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Extract host
|
||||||
|
if parsed.netloc:
|
||||||
|
result["host"] = parsed.netloc.lower()
|
||||||
|
elif url.startswith("git@"):
|
||||||
|
# SSH URL: git@host:path
|
||||||
|
parts = url.split(":", 1)
|
||||||
|
if len(parts) == 2:
|
||||||
|
result["host"] = parts[0].replace("git@", "")
|
||||||
|
else:
|
||||||
|
result["message"] = "Please enter a valid URL"
|
||||||
|
result["error_code"] = "INVALID_URL"
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Check if already valid
|
||||||
|
if is_valid_clone_url(url):
|
||||||
|
result["base_url"] = url
|
||||||
|
result["is_valid_clone_url"] = True
|
||||||
|
result["needs_parsing"] = False
|
||||||
|
result["message"] = "Valid git repository URL"
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Try to extract base URL
|
||||||
|
base = extract_base_repo_url(url)
|
||||||
|
if base:
|
||||||
|
result["base_url"] = base
|
||||||
|
result["needs_parsing"] = True
|
||||||
|
result["message"] = f"This looks like a browser URL. Did you mean: {base}?"
|
||||||
|
result["error_code"] = "URL_NEEDS_PARSING"
|
||||||
|
else:
|
||||||
|
result["message"] = "Could not parse this URL. Please enter a valid git repository URL."
|
||||||
|
result["error_code"] = "INVALID_URL"
|
||||||
|
|
||||||
|
return result
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"""Tests for git URL parsing utilities."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.utils.git_url_parser import extract_base_repo_url, is_valid_clone_url, parse_git_url
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractBaseRepoUrl:
|
||||||
|
"""Tests for extract_base_repo_url function."""
|
||||||
|
|
||||||
|
def test_github_tree_url(self):
|
||||||
|
url = "https://github.com/owner/repo/tree/main"
|
||||||
|
result = extract_base_repo_url(url)
|
||||||
|
assert result == "https://github.com/owner/repo.git"
|
||||||
|
|
||||||
|
def test_github_blob_url(self):
|
||||||
|
url = "https://github.com/owner/repo/blob/main/README.md"
|
||||||
|
result = extract_base_repo_url(url)
|
||||||
|
assert result == "https://github.com/owner/repo.git"
|
||||||
|
|
||||||
|
def test_github_pull_url(self):
|
||||||
|
url = "https://github.com/owner/repo/pull/123"
|
||||||
|
result = extract_base_repo_url(url)
|
||||||
|
assert result == "https://github.com/owner/repo.git"
|
||||||
|
|
||||||
|
def test_github_issues_url(self):
|
||||||
|
url = "https://github.com/owner/repo/issues/456"
|
||||||
|
result = extract_base_repo_url(url)
|
||||||
|
assert result == "https://github.com/owner/repo.git"
|
||||||
|
|
||||||
|
def test_github_valid_url(self):
|
||||||
|
url = "https://github.com/owner/repo.git"
|
||||||
|
result = extract_base_repo_url(url)
|
||||||
|
assert result == "https://github.com/owner/repo.git"
|
||||||
|
|
||||||
|
def test_github_url_with_query_params(self):
|
||||||
|
url = "https://github.com/owner/repo?tab=readme-ov-file"
|
||||||
|
result = extract_base_repo_url(url)
|
||||||
|
assert result == "https://github.com/owner/repo.git"
|
||||||
|
|
||||||
|
def test_gitlab_tree_url(self):
|
||||||
|
url = "https://gitlab.com/owner/repo/-/tree/main"
|
||||||
|
result = extract_base_repo_url(url)
|
||||||
|
assert result == "https://gitlab.com/owner/repo.git"
|
||||||
|
|
||||||
|
def test_gitlab_blob_url(self):
|
||||||
|
url = "https://gitlab.com/owner/repo/-/blob/main/README.md"
|
||||||
|
result = extract_base_repo_url(url)
|
||||||
|
assert result == "https://gitlab.com/owner/repo.git"
|
||||||
|
|
||||||
|
def test_gitlab_merge_request_url(self):
|
||||||
|
url = "https://gitlab.com/owner/repo/-/merge_requests/123"
|
||||||
|
result = extract_base_repo_url(url)
|
||||||
|
assert result == "https://gitlab.com/owner/repo.git"
|
||||||
|
|
||||||
|
def test_gitlab_valid_url(self):
|
||||||
|
url = "https://gitlab.com/owner/repo.git"
|
||||||
|
result = extract_base_repo_url(url)
|
||||||
|
assert result == "https://gitlab.com/owner/repo.git"
|
||||||
|
|
||||||
|
def test_bitbucket_src_url(self):
|
||||||
|
url = "https://bitbucket.org/owner/repo/src/main/"
|
||||||
|
result = extract_base_repo_url(url)
|
||||||
|
assert result == "https://bitbucket.org/owner/repo.git"
|
||||||
|
|
||||||
|
def test_bitbucket_valid_url(self):
|
||||||
|
url = "https://bitbucket.org/owner/repo.git"
|
||||||
|
result = extract_base_repo_url(url)
|
||||||
|
assert result == "https://bitbucket.org/owner/repo.git"
|
||||||
|
|
||||||
|
def test_ssh_url(self):
|
||||||
|
url = "git@github.com:owner/repo.git"
|
||||||
|
result = extract_base_repo_url(url)
|
||||||
|
assert result == "git@github.com:owner/repo.git"
|
||||||
|
|
||||||
|
def test_ssh_url_without_git_suffix(self):
|
||||||
|
url = "git@github.com:owner/repo"
|
||||||
|
result = extract_base_repo_url(url)
|
||||||
|
assert result == "git@github.com:owner/repo.git"
|
||||||
|
|
||||||
|
def test_invalid_url(self):
|
||||||
|
url = "not-a-url"
|
||||||
|
result = extract_base_repo_url(url)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_empty_url(self):
|
||||||
|
url = ""
|
||||||
|
result = extract_base_repo_url(url)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsValidCloneUrl:
|
||||||
|
"""Tests for is_valid_clone_url function."""
|
||||||
|
|
||||||
|
def test_valid_ssh_url(self):
|
||||||
|
assert is_valid_clone_url("git@github.com:owner/repo.git") is True
|
||||||
|
|
||||||
|
def test_valid_https_url(self):
|
||||||
|
assert is_valid_clone_url("https://github.com/owner/repo.git") is True
|
||||||
|
|
||||||
|
def test_browser_url(self):
|
||||||
|
assert is_valid_clone_url("https://github.com/owner/repo/tree/main") is False
|
||||||
|
|
||||||
|
def test_url_without_git_suffix(self):
|
||||||
|
assert is_valid_clone_url("https://github.com/owner/repo") is False
|
||||||
|
|
||||||
|
def test_invalid_url(self):
|
||||||
|
assert is_valid_clone_url("not-a-url") is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseGitUrl:
|
||||||
|
"""Tests for parse_git_url function."""
|
||||||
|
|
||||||
|
def test_valid_git_url(self):
|
||||||
|
result = parse_git_url("https://github.com/owner/repo.git")
|
||||||
|
assert result["is_valid_clone_url"] is True
|
||||||
|
assert result["needs_parsing"] is False
|
||||||
|
assert result["base_url"] == "https://github.com/owner/repo.git"
|
||||||
|
assert result["host"] == "github.com"
|
||||||
|
assert "Valid" in result["message"]
|
||||||
|
|
||||||
|
def test_browser_url(self):
|
||||||
|
result = parse_git_url("https://github.com/owner/repo/tree/main")
|
||||||
|
assert result["is_valid_clone_url"] is False
|
||||||
|
assert result["needs_parsing"] is True
|
||||||
|
assert result["base_url"] == "https://github.com/owner/repo.git"
|
||||||
|
assert result["host"] == "github.com"
|
||||||
|
assert result["error_code"] == "URL_NEEDS_PARSING"
|
||||||
|
assert "browser URL" in result["message"]
|
||||||
|
|
||||||
|
def test_invalid_url(self):
|
||||||
|
result = parse_git_url("not-a-url")
|
||||||
|
assert result["is_valid_clone_url"] is False
|
||||||
|
assert result["base_url"] is None
|
||||||
|
assert result["error_code"] == "INVALID_URL"
|
||||||
|
|
||||||
|
def test_empty_url(self):
|
||||||
|
result = parse_git_url("")
|
||||||
|
assert result["is_valid_clone_url"] is False
|
||||||
|
assert result["base_url"] is None
|
||||||
|
assert result["error_code"] == "INVALID_URL"
|
||||||
|
|
||||||
|
def test_ssh_url(self):
|
||||||
|
result = parse_git_url("git@github.com:owner/repo.git")
|
||||||
|
assert result["is_valid_clone_url"] is True
|
||||||
|
assert result["needs_parsing"] is False
|
||||||
|
assert result["host"] == "github.com"
|
||||||
@@ -15,6 +15,22 @@ export interface GitRepository {
|
|||||||
export interface GitRepositoryCreate {
|
export interface GitRepositoryCreate {
|
||||||
name: string;
|
name: string;
|
||||||
remote_url?: string;
|
remote_url?: string;
|
||||||
|
force_original_url?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface URLParseResult {
|
||||||
|
original_url: string;
|
||||||
|
base_url: string | null;
|
||||||
|
is_valid_clone_url: boolean;
|
||||||
|
needs_parsing: boolean;
|
||||||
|
host: string | null;
|
||||||
|
message: string;
|
||||||
|
error_code: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseGitUrl(url: string): Promise<URLParseResult> {
|
||||||
|
const response = await apiClient.post("/projects/repositories/parse-url", { url });
|
||||||
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listRepositories(projectId: string): Promise<GitRepository[]> {
|
export async function listRepositories(projectId: string): Promise<GitRepository[]> {
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createRepository,
|
createRepository,
|
||||||
deleteRepository,
|
deleteRepository,
|
||||||
listRepositories,
|
listRepositories,
|
||||||
|
parseGitUrl,
|
||||||
type GitRepositoryCreate,
|
type GitRepositoryCreate,
|
||||||
|
type URLParseResult,
|
||||||
} from "../api/git_repositories";
|
} from "../api/git_repositories";
|
||||||
import type { GitRepository } from "../api/git_repositories";
|
import type { GitRepository } from "../api/git_repositories";
|
||||||
|
|
||||||
type RepoStatus = "loading" | "ready" | "error";
|
type RepoStatus = "loading" | "ready" | "error";
|
||||||
|
type UrlValidationStatus = "idle" | "validating" | "valid" | "needs-parsing" | "invalid";
|
||||||
|
|
||||||
export const GitRepositoriesPage = () => {
|
export const GitRepositoriesPage = () => {
|
||||||
const { projectId } = useParams<{ projectId: string }>();
|
const { projectId } = useParams<{ projectId: string }>();
|
||||||
@@ -21,6 +24,14 @@ export const GitRepositoriesPage = () => {
|
|||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// URL validation state
|
||||||
|
const [urlValidation, setUrlValidation] = useState<{
|
||||||
|
status: UrlValidationStatus;
|
||||||
|
result: URLParseResult | null;
|
||||||
|
}>({ status: "idle", result: null });
|
||||||
|
|
||||||
|
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const loadRepositories = useCallback(async () => {
|
const loadRepositories = useCallback(async () => {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
setStatus("loading");
|
setStatus("loading");
|
||||||
@@ -38,6 +49,54 @@ export const GitRepositoriesPage = () => {
|
|||||||
void loadRepositories();
|
void loadRepositories();
|
||||||
}, [loadRepositories]);
|
}, [loadRepositories]);
|
||||||
|
|
||||||
|
// Validate URL with debounce
|
||||||
|
useEffect(() => {
|
||||||
|
if (debounceTimer.current) {
|
||||||
|
clearTimeout(debounceTimer.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formRemoteUrl.trim()) {
|
||||||
|
setUrlValidation({ status: "idle", result: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUrlValidation({ status: "validating", result: null });
|
||||||
|
|
||||||
|
debounceTimer.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const result = await parseGitUrl(formRemoteUrl.trim());
|
||||||
|
if (result.is_valid_clone_url) {
|
||||||
|
setUrlValidation({ status: "valid", result });
|
||||||
|
} else if (result.needs_parsing) {
|
||||||
|
setUrlValidation({ status: "needs-parsing", result });
|
||||||
|
} else {
|
||||||
|
setUrlValidation({ status: "invalid", result });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setUrlValidation({ status: "invalid", result: null });
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (debounceTimer.current) {
|
||||||
|
clearTimeout(debounceTimer.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [formRemoteUrl]);
|
||||||
|
|
||||||
|
const getUrlInputClass = () => {
|
||||||
|
switch (urlValidation.status) {
|
||||||
|
case "valid":
|
||||||
|
return "valid-url";
|
||||||
|
case "needs-parsing":
|
||||||
|
return "needs-parsing-url";
|
||||||
|
case "invalid":
|
||||||
|
return "invalid-url";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
@@ -58,9 +117,27 @@ export const GitRepositoriesPage = () => {
|
|||||||
setShowCreate(false);
|
setShowCreate(false);
|
||||||
setFormName("");
|
setFormName("");
|
||||||
setFormRemoteUrl("");
|
setFormRemoteUrl("");
|
||||||
|
setUrlValidation({ status: "idle", result: null });
|
||||||
await loadRepositories();
|
await loadRepositories();
|
||||||
} catch {
|
} catch (err: unknown) {
|
||||||
setFormError("Failed to create repository");
|
const axiosError = err as { response?: { status: number; data: { detail: { suggested_url: string; message: string } } } };
|
||||||
|
if (axiosError.response?.status === 422 && axiosError.response?.data?.detail?.suggested_url) {
|
||||||
|
// Show URL correction suggestion
|
||||||
|
const detail = axiosError.response.data.detail;
|
||||||
|
setFormError(
|
||||||
|
`${detail.message}\nSuggested: ${detail.suggested_url}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setFormError("Failed to create repository");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUseSuggestedUrl = () => {
|
||||||
|
if (urlValidation.result?.base_url) {
|
||||||
|
setFormRemoteUrl(urlValidation.result.base_url);
|
||||||
|
setUrlValidation({ status: "idle", result: null });
|
||||||
|
setFormError(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -165,9 +242,46 @@ export const GitRepositoriesPage = () => {
|
|||||||
value={formRemoteUrl}
|
value={formRemoteUrl}
|
||||||
onChange={(e) => setFormRemoteUrl(e.target.value)}
|
onChange={(e) => setFormRemoteUrl(e.target.value)}
|
||||||
placeholder="https://github.com/user/repo.git"
|
placeholder="https://github.com/user/repo.git"
|
||||||
|
className={getUrlInputClass()}
|
||||||
/>
|
/>
|
||||||
|
{urlValidation.status === "validating" && (
|
||||||
|
<span className="validation-status validating">Validating...</span>
|
||||||
|
)}
|
||||||
|
{urlValidation.status === "valid" && (
|
||||||
|
<span className="validation-status valid">✓ Valid git URL</span>
|
||||||
|
)}
|
||||||
|
{urlValidation.status === "needs-parsing" && urlValidation.result && (
|
||||||
|
<div className="url-suggestion">
|
||||||
|
<span className="validation-status warning">
|
||||||
|
⚠ This looks like a browser URL
|
||||||
|
</span>
|
||||||
|
<div className="suggestion-actions">
|
||||||
|
<span className="suggested-url">
|
||||||
|
Suggested: {urlValidation.result.base_url}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-button small"
|
||||||
|
onClick={handleUseSuggestedUrl}
|
||||||
|
>
|
||||||
|
Use Suggested
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{urlValidation.status === "invalid" && (
|
||||||
|
<span className="validation-status invalid">
|
||||||
|
✗ Invalid URL
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
{formError && <p className="error-text">{formError}</p>}
|
{formError && (
|
||||||
|
<div className="error-message">
|
||||||
|
{formError.split("\n").map((line, i) => (
|
||||||
|
<p key={i} className="error-text">{line}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="dialog-actions">
|
<div className="dialog-actions">
|
||||||
<button className="secondary-button" onClick={() => setShowCreate(false)} type="button">
|
<button className="secondary-button" onClick={() => setShowCreate(false)} type="button">
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -372,3 +372,68 @@ a {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* URL Validation Styles */
|
||||||
|
.valid-url {
|
||||||
|
border-color: #16a34a !important;
|
||||||
|
background-color: #f0fdf4 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.needs-parsing-url {
|
||||||
|
border-color: #ca8a04 !important;
|
||||||
|
background-color: #fefce8 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invalid-url {
|
||||||
|
border-color: #dc2626 !important;
|
||||||
|
background-color: #fef2f2 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-status {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-status.valid {
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-status.warning {
|
||||||
|
color: #ca8a04;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-status.invalid {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-status.validating {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-suggestion {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: #fefce8;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #fde047;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggested-url {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #854d0e;
|
||||||
|
flex: 1;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-button.small {
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-05-19
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
# Smart Git URL Parsing - Design
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
User pastes URL → Frontend validates → Backend validates → Clone repo
|
||||||
|
↓ ↓
|
||||||
|
Show suggestions Parse & suggest
|
||||||
|
```
|
||||||
|
|
||||||
|
## URL Detection Logic
|
||||||
|
|
||||||
|
### Patterns to Detect
|
||||||
|
|
||||||
|
1. **GitHub/GitLab/Bitbucket browser URLs**
|
||||||
|
- `https://github.com/owner/repo/tree/branch-name`
|
||||||
|
- `https://github.com/owner/repo/blob/branch/path/to/file`
|
||||||
|
- `https://github.com/owner/repo/pull/123`
|
||||||
|
- `https://gitlab.com/owner/repo/-/tree/branch`
|
||||||
|
- `https://bitbucket.org/owner/repo/src/branch/`
|
||||||
|
|
||||||
|
2. **URLs with query parameters**
|
||||||
|
- `https://github.com/owner/repo?tab=readme-ov-file`
|
||||||
|
- `https://github.com/owner/repo.git?branch=develop`
|
||||||
|
|
||||||
|
3. **Valid clone URLs (should pass through)**
|
||||||
|
- `https://github.com/owner/repo.git`
|
||||||
|
- `git@github.com:owner/repo.git`
|
||||||
|
- `https://github.com/owner/repo` (without .git)
|
||||||
|
|
||||||
|
### URL Parsing Rules
|
||||||
|
|
||||||
|
```python
|
||||||
|
def extract_base_repo_url(url: str) -> str | None:
|
||||||
|
"""Extract base repository URL from a browser/git URL.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
https://github.com/user/repo/tree/main → https://github.com/user/repo.git
|
||||||
|
https://github.com/user/repo.git → https://github.com/user/repo.git
|
||||||
|
git@github.com:user/repo.git → git@github.com:user/repo.git
|
||||||
|
https://gitlab.com/user/repo/-/blob/main/README.md → https://gitlab.com/user/repo.git
|
||||||
|
|
||||||
|
Returns None if URL doesn't match known patterns.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Algorithm:**
|
||||||
|
1. Remove query parameters
|
||||||
|
2. Detect host (github.com, gitlab.com, bitbucket.org, etc.)
|
||||||
|
3. For GitHub: Remove `/tree/*`, `/blob/*`, `/pull/*`, `/issues/*` paths
|
||||||
|
4. For GitLab: Remove `/-/tree/*`, `/-/blob/*` paths
|
||||||
|
5. For Bitbucket: Remove `/src/*` paths
|
||||||
|
6. Ensure `.git` suffix
|
||||||
|
7. Return cleaned URL or None
|
||||||
|
|
||||||
|
## API Changes
|
||||||
|
|
||||||
|
### POST /repositories (enhanced)
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"project_id": "uuid",
|
||||||
|
"name": "my-repo",
|
||||||
|
"remote_url": "https://github.com/user/repo/tree/main",
|
||||||
|
"is_mirror": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**New Response for Non-Repo URLs (422):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "URL appears to be a browser URL, not a git clone URL",
|
||||||
|
"suggested_url": "https://github.com/user/repo.git",
|
||||||
|
"original_url": "https://github.com/user/repo/tree/main",
|
||||||
|
"needs_confirmation": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### New Endpoint: POST /repositories/parse-url
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"url": "https://github.com/user/repo/tree/main"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"original_url": "https://github.com/user/repo/tree/main",
|
||||||
|
"base_url": "https://github.com/user/repo.git",
|
||||||
|
"is_valid_repo_url": false,
|
||||||
|
"needs_parsing": true,
|
||||||
|
"message": "This looks like a browser URL. Did you mean to clone https://github.com/user/repo.git?"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend Flow
|
||||||
|
|
||||||
|
### Repository Creation Dialog (Enhanced)
|
||||||
|
|
||||||
|
1. **User pastes URL**
|
||||||
|
2. **Frontend calls `/repositories/parse-url`** (debounced)
|
||||||
|
3. **If URL needs parsing:**
|
||||||
|
- Show yellow warning indicator
|
||||||
|
- Display: "This looks like a browser URL"
|
||||||
|
- Show suggested URL with "Use this instead" button
|
||||||
|
- Allow user to proceed with original URL anyway
|
||||||
|
4. **If URL is valid:**
|
||||||
|
- Show green checkmark
|
||||||
|
- Proceed normally
|
||||||
|
5. **User clicks "Create"**
|
||||||
|
6. **If backend returns 422 with suggestion:**
|
||||||
|
- Show confirmation dialog with suggested URL
|
||||||
|
- Options: "Use suggested URL", "Use original", "Cancel"
|
||||||
|
|
||||||
|
### UI Components
|
||||||
|
|
||||||
|
**URLInput Component:**
|
||||||
|
- Input field with validation status icon
|
||||||
|
- Shows inline suggestions when URL is detected as browser URL
|
||||||
|
- Green/yellow/red border based on validation
|
||||||
|
|
||||||
|
**URLCorrectionDialog Component:**
|
||||||
|
- Modal dialog for confirming URL correction
|
||||||
|
- Shows before/after comparison
|
||||||
|
- Clear action buttons
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. **Backend utilities** - URL parsing functions with tests
|
||||||
|
2. **Backend endpoint** - `/repositories/parse-url`
|
||||||
|
3. **Backend validation** - Enhanced POST /repositories with suggestion response
|
||||||
|
4. **Frontend URL input** - Enhanced input with validation feedback
|
||||||
|
5. **Frontend dialog** - Confirmation dialog for URL corrections
|
||||||
|
6. **Integration** - Wire up parse-url endpoint to frontend
|
||||||
|
7. **Tests** - Unit tests for URL parsing, integration tests for flow
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Invalid URLs
|
||||||
|
- Completely malformed URLs: Return 400 with clear message
|
||||||
|
- Unsupported hosts: Return 400 with "Unsupported git host"
|
||||||
|
- Private repos (auth needed): Return 401/403 with auth instructions
|
||||||
|
- Non-existent repos: Return 404 (from git clone failure)
|
||||||
|
|
||||||
|
### Clone Failures
|
||||||
|
- Network issues: Retry with exponential backoff
|
||||||
|
- Auth required: Prompt for credentials
|
||||||
|
- Large repos: Show progress indicator
|
||||||
|
- Timeout: Increase timeout for large repos
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Smart Git URL Parsing for Repository Creation
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
When users add a new repository, they often paste a full browser URL that includes branch names, file paths, or query parameters instead of a clean repository URL. This causes the clone operation to fail with unclear error messages.
|
||||||
|
|
||||||
|
**Examples of problematic URLs:**
|
||||||
|
- `https://github.com/user/repo/tree/main` (includes branch path)
|
||||||
|
- `https://github.com/user/repo/blob/main/README.md` (includes file path)
|
||||||
|
- `https://github.com/user/repo?tab=readme-ov-file` (includes query params)
|
||||||
|
- `https://github.com/user/repo/pull/123` (includes PR path)
|
||||||
|
|
||||||
|
**Current behavior:** The backend attempts to clone the exact URL, which fails with "fatal: repository not found" or similar errors.
|
||||||
|
|
||||||
|
**User confusion:** Users don't understand why the clone failed since the URL works in their browser.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Implement smart URL parsing that:
|
||||||
|
|
||||||
|
1. **Detects non-repo URLs**: Recognizes when a URL contains paths like `/tree/`, `/blob/`, `/pull/`, or query parameters
|
||||||
|
2. **Extracts base repo URL**: Strips away branch names, file paths, query parameters to get `https://host/owner/repo.git`
|
||||||
|
3. **Suggests correction**: Shows the user the extracted base URL and asks for confirmation
|
||||||
|
4. **Improves clone handling**: Handles edge cases and provides clear error messages
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
- **Better UX**: Users get helpful suggestions instead of cryptic errors
|
||||||
|
- **Fewer support issues**: Self-service correction reduces confusion
|
||||||
|
- **More robust**: Handles common copy-paste mistakes automatically
|
||||||
|
- **Educational**: Teaches users what a proper git URL looks like
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- URL parsing utilities to detect and extract base repo URLs
|
||||||
|
- Enhanced validation in repository creation endpoint
|
||||||
|
- Clear error messages for unsupported URLs
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- UI dialog to show URL correction suggestions
|
||||||
|
- Option to accept or edit the suggested URL
|
||||||
|
- Visual indicator for URL validation status
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
# Smart Git URL Parsing Specification
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
1. **URL Detection**: Detect when a provided URL is a browser URL rather than a git clone URL
|
||||||
|
2. **URL Extraction**: Extract the base repository URL from browser URLs
|
||||||
|
3. **User Confirmation**: Show extracted URL to user and ask for confirmation
|
||||||
|
4. **Flexible Input**: Allow users to proceed with original URL if they prefer
|
||||||
|
5. **Multiple Hosts**: Support GitHub, GitLab, Bitbucket, and generic git hosts
|
||||||
|
|
||||||
|
### Non-Functional Requirements
|
||||||
|
|
||||||
|
1. **Performance**: URL parsing should be instant (< 100ms)
|
||||||
|
2. **Accuracy**: Should correctly identify 95%+ of browser URLs
|
||||||
|
3. **User Experience**: Clear, helpful messages without technical jargon
|
||||||
|
4. **Backward Compatibility**: Existing valid git URLs should continue to work
|
||||||
|
|
||||||
|
## API Specification
|
||||||
|
|
||||||
|
### POST /git-repositories/parse-url
|
||||||
|
Parse a URL and determine if it's a valid clone URL or needs correction.
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"url": "https://github.com/user/repo/tree/main"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response 200:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"original_url": "https://github.com/user/repo/tree/main",
|
||||||
|
"base_url": "https://github.com/user/repo.git",
|
||||||
|
"is_valid_clone_url": false,
|
||||||
|
"needs_parsing": true,
|
||||||
|
"host": "github.com",
|
||||||
|
"message": "This URL contains a branch path. The repository URL is: https://github.com/user/repo.git"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response 200 (already valid):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"original_url": "https://github.com/user/repo.git",
|
||||||
|
"base_url": "https://github.com/user/repo.git",
|
||||||
|
"is_valid_clone_url": true,
|
||||||
|
"needs_parsing": false,
|
||||||
|
"host": "github.com",
|
||||||
|
"message": "Valid git repository URL"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Enhanced POST /projects/{project_id}/repositories
|
||||||
|
Enhanced to return suggestions when URL needs parsing.
|
||||||
|
|
||||||
|
**New 422 Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "The provided URL appears to be a browser URL, not a git clone URL",
|
||||||
|
"suggested_url": "https://github.com/user/repo.git",
|
||||||
|
"original_url": "https://github.com/user/repo/tree/main",
|
||||||
|
"error_code": "URL_NEEDS_PARSING"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body (with force flag):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "my-repo",
|
||||||
|
"remote_url": "https://github.com/user/repo/tree/main",
|
||||||
|
"is_mirror": false,
|
||||||
|
"force_original_url": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
### URLParseResult
|
||||||
|
```python
|
||||||
|
class URLParseResult:
|
||||||
|
original_url: str
|
||||||
|
base_url: str | None
|
||||||
|
is_valid_clone_url: bool
|
||||||
|
needs_parsing: bool
|
||||||
|
host: str | None
|
||||||
|
message: str
|
||||||
|
error_code: str | None
|
||||||
|
```
|
||||||
|
|
||||||
|
## URL Parsing Rules
|
||||||
|
|
||||||
|
### Supported Hosts
|
||||||
|
- github.com
|
||||||
|
- gitlab.com
|
||||||
|
- bitbucket.org
|
||||||
|
- Any custom domain with git hosting
|
||||||
|
|
||||||
|
### GitHub URL Patterns
|
||||||
|
```
|
||||||
|
https://github.com/{owner}/{repo} → valid
|
||||||
|
https://github.com/{owner}/{repo}.git → valid
|
||||||
|
https://github.com/{owner}/{repo}/tree/{branch} → extract base
|
||||||
|
https://github.com/{owner}/{repo}/blob/{branch}/{path} → extract base
|
||||||
|
https://github.com/{owner}/{repo}/pull/{number} → extract base
|
||||||
|
https://github.com/{owner}/{repo}/issues/{number} → extract base
|
||||||
|
https://github.com/{owner}/{repo}/actions → extract base
|
||||||
|
```
|
||||||
|
|
||||||
|
### GitLab URL Patterns
|
||||||
|
```
|
||||||
|
https://gitlab.com/{owner}/{repo} → valid
|
||||||
|
https://gitlab.com/{owner}/{repo}.git → valid
|
||||||
|
https://gitlab.com/{owner}/{repo}/-/tree/{branch} → extract base
|
||||||
|
https://gitlab.com/{owner}/{repo}/-/blob/{branch}/{path} → extract base
|
||||||
|
https://gitlab.com/{owner}/{repo}/-/merge_requests/{number} → extract base
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bitbucket URL Patterns
|
||||||
|
```
|
||||||
|
https://bitbucket.org/{owner}/{repo} → valid
|
||||||
|
https://bitbucket.org/{owner}/{repo}.git → valid
|
||||||
|
https://bitbucket.org/{owner}/{repo}/src/{branch} → extract base
|
||||||
|
```
|
||||||
|
|
||||||
|
### Extraction Algorithm
|
||||||
|
1. Parse URL components (scheme, netloc, path, query)
|
||||||
|
2. Remove query parameters entirely
|
||||||
|
3. Split path by `/`
|
||||||
|
4. Remove trailing segments that indicate non-repo paths:
|
||||||
|
- `tree/*`, `blob/*`, `pull/*`, `issues/*`, `actions`
|
||||||
|
- `-/tree/*`, `-/blob/*`, `-/merge_requests/*`
|
||||||
|
- `src/*`
|
||||||
|
5. Reconstruct URL with remaining path
|
||||||
|
6. Add `.git` suffix if missing (for HTTPS URLs)
|
||||||
|
7. Return extracted URL
|
||||||
|
|
||||||
|
## Frontend Specification
|
||||||
|
|
||||||
|
### URL Input Component
|
||||||
|
- Input field for repository URL
|
||||||
|
- Real-time validation (debounced 300ms)
|
||||||
|
- Visual indicators:
|
||||||
|
- 🟢 Green border: Valid git URL
|
||||||
|
- 🟡 Yellow border: Browser URL detected, suggestion shown
|
||||||
|
- 🔴 Red border: Invalid/malformed URL
|
||||||
|
- Inline suggestion banner below input:
|
||||||
|
```
|
||||||
|
⚠️ This looks like a browser URL
|
||||||
|
Suggested: https://github.com/user/repo.git
|
||||||
|
[Use Suggested] [Keep Original]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Confirmation Dialog
|
||||||
|
Shown when backend returns 422 with suggestion:
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ URL Correction Suggestion │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ The URL you entered appears to be │
|
||||||
|
│ a browser URL, not a git clone URL. │
|
||||||
|
│ │
|
||||||
|
│ Original: │
|
||||||
|
│ https://github.com/user/repo/tree/main│
|
||||||
|
│ │
|
||||||
|
│ Suggested repository URL: │
|
||||||
|
│ https://github.com/user/repo.git │
|
||||||
|
│ │
|
||||||
|
│ [Use Suggested URL] [Use Original] │
|
||||||
|
│ [Cancel] │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Codes
|
||||||
|
|
||||||
|
| Error Code | Description | User Message |
|
||||||
|
|------------|-------------|--------------|
|
||||||
|
| URL_NEEDS_PARSING | Browser URL detected | "This looks like a browser URL. Did you mean: {suggested_url}?" |
|
||||||
|
| INVALID_URL | Malformed URL | "Please enter a valid URL" |
|
||||||
|
| UNSUPPORTED_HOST | Unknown git host | "This git host is not supported" |
|
||||||
|
| CLONE_FAILED | Git clone failed | "Failed to clone repository: {error}" |
|
||||||
|
| AUTH_REQUIRED | Private repo, need auth | "This repository requires authentication" |
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Unit Tests (URL Parsing)
|
||||||
|
- Test all GitHub URL patterns
|
||||||
|
- Test all GitLab URL patterns
|
||||||
|
- Test all Bitbucket URL patterns
|
||||||
|
- Test valid URLs pass through unchanged
|
||||||
|
- Test edge cases (subgroups, nested paths, etc.)
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
- Test parse-url endpoint with various URLs
|
||||||
|
- Test repository creation with browser URL (should suggest correction)
|
||||||
|
- Test repository creation with force flag
|
||||||
|
- Test clone operation with corrected URL
|
||||||
|
|
||||||
|
### Frontend Tests
|
||||||
|
- Test URL input validation states
|
||||||
|
- Test suggestion banner display
|
||||||
|
- Test confirmation dialog flow
|
||||||
|
- Test acceptance/rejection of suggestions
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Smart Git URL Parsing - Tasks
|
||||||
|
|
||||||
|
## Phase 1: Backend URL Parsing
|
||||||
|
|
||||||
|
- [ ] **Task 1.1**: Create URL parsing utilities
|
||||||
|
- Create `src/utils/git_url_parser.py`
|
||||||
|
- Implement `extract_base_repo_url()` function
|
||||||
|
- Support GitHub, GitLab, Bitbucket patterns
|
||||||
|
- Handle query parameters, branch paths, file paths
|
||||||
|
- Add comprehensive unit tests
|
||||||
|
|
||||||
|
- [ ] **Task 1.2**: Create URL validation endpoint
|
||||||
|
- Add `POST /git-repositories/parse-url` endpoint
|
||||||
|
- Returns URLParseResult with original, base, validation status
|
||||||
|
- Add tests for endpoint
|
||||||
|
|
||||||
|
- [ ] **Task 1.3**: Enhance repository creation endpoint
|
||||||
|
- Update `POST /projects/{project_id}/repositories`
|
||||||
|
- Detect browser URLs and return 422 with suggestion
|
||||||
|
- Add `force_original_url` flag to bypass suggestion
|
||||||
|
- Update response models
|
||||||
|
|
||||||
|
## Phase 2: Frontend Implementation
|
||||||
|
|
||||||
|
- [ ] **Task 2.1**: Create API client for URL parsing
|
||||||
|
- Add `parseGitUrl()` function to `api/git_repositories.ts`
|
||||||
|
- Add types for URLParseResult
|
||||||
|
|
||||||
|
- [ ] **Task 2.2**: Enhance repository creation form
|
||||||
|
- Add real-time URL validation to input field
|
||||||
|
- Show visual indicators (green/yellow/red)
|
||||||
|
- Display inline suggestion banner
|
||||||
|
- Add "Use Suggested" / "Keep Original" buttons
|
||||||
|
|
||||||
|
- [ ] **Task 2.3**: Create confirmation dialog
|
||||||
|
- Create `URLCorrectionDialog` component
|
||||||
|
- Shows original vs suggested URL comparison
|
||||||
|
- Handles accept/reject/cancel actions
|
||||||
|
- Integrate with repository creation flow
|
||||||
|
|
||||||
|
## Phase 3: Integration & Testing
|
||||||
|
|
||||||
|
- [ ] **Task 3.1**: Wire up frontend to backend
|
||||||
|
- Call parse-url endpoint on URL input change (debounced)
|
||||||
|
- Handle 422 responses from repository creation
|
||||||
|
- Show confirmation dialog when needed
|
||||||
|
|
||||||
|
- [ ] **Task 3.2**: Add error handling
|
||||||
|
- Handle network errors during URL validation
|
||||||
|
- Show clear error messages for unsupported URLs
|
||||||
|
- Handle clone failures gracefully
|
||||||
|
|
||||||
|
- [ ] **Task 3.3**: Run quality gates
|
||||||
|
- Backend: ruff, mypy, pytest
|
||||||
|
- Frontend: typecheck, lint, build
|
||||||
|
|
||||||
|
- [ ] **Task 3.4**: Manual testing
|
||||||
|
- Test with GitHub URLs (tree, blob, pull)
|
||||||
|
- Test with GitLab URLs (-/tree, -/blob)
|
||||||
|
- Test with valid git URLs (should pass through)
|
||||||
|
- Test force_original_url flag
|
||||||
Reference in New Issue
Block a user