diff --git a/apps/api/app/git/operations.py b/apps/api/app/git/operations.py index 2b4f145..3620bd3 100644 --- a/apps/api/app/git/operations.py +++ b/apps/api/app/git/operations.py @@ -1,5 +1,3 @@ -"""Local Git subprocess interface.""" - import abc import subprocess from pathlib import Path @@ -7,46 +5,41 @@ from typing import Any class GitOperations(abc.ABC): - """Local Git subprocess interface. - - This abstraction is separate from :class:`~app.git.provider.GitProvider`, - which handles remote provider API operations. - """ - @abc.abstractmethod def clone(self, git_url: str, dest: Path, credential_id: str) -> None: - """Clone *git_url* into *dest* using *credential_id*.""" + pass @abc.abstractmethod def fetch(self, repo_path: Path, credential_id: str) -> None: - """Fetch updates for the repository at *repo_path*.""" + pass @abc.abstractmethod def push(self, repo_path: Path, credential_id: str) -> None: - """Push local commits for the repository at *repo_path*.""" + pass @abc.abstractmethod def get_status(self, repo_path: Path) -> dict[str, Any]: - """Return the working-tree status of the repository at *repo_path*.""" + pass class LocalGitOperations(GitOperations): - """Git operations backed by the local ``git`` CLI.""" - def clone(self, git_url: str, dest: Path, credential_id: str) -> None: - raise NotImplementedError( - "Credential-aware subprocess invocation will be implemented in a follow-up task" - ) + cmd = ["git", "clone", git_url, str(dest)] + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + raise RuntimeError(f"Git clone failed: {result.stderr}") def fetch(self, repo_path: Path, credential_id: str) -> None: - raise NotImplementedError( - "Credential-aware subprocess invocation will be implemented in a follow-up task" - ) + cmd = ["git", "-C", str(repo_path), "fetch", "--all"] + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + raise RuntimeError(f"Git fetch failed: {result.stderr}") def push(self, repo_path: Path, credential_id: str) -> None: - raise NotImplementedError( - "Credential-aware subprocess invocation will be implemented in a follow-up task" - ) + cmd = ["git", "-C", str(repo_path), "push"] + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + raise RuntimeError(f"Git push failed: {result.stderr}") def get_status(self, repo_path: Path) -> dict[str, Any]: if not repo_path.exists() or not (repo_path / ".git").is_dir(): diff --git a/apps/api/app/git/providers/__init__.py b/apps/api/app/git/providers/__init__.py new file mode 100644 index 0000000..3735df2 --- /dev/null +++ b/apps/api/app/git/providers/__init__.py @@ -0,0 +1,17 @@ +from app.git.provider import GitProvider +from app.git.types import ProviderKind + +from .github import GitHubAdapter +from .gitlab import GitLabAdapter + +PROVIDERS: dict[ProviderKind, type[GitProvider]] = { + ProviderKind.github: GitHubAdapter, + ProviderKind.gitlab: GitLabAdapter, +} + + +def get_provider(kind: ProviderKind) -> GitProvider: + provider_class = PROVIDERS.get(kind) + if provider_class is None: + raise ValueError(f"Unsupported provider kind: {kind}") + return provider_class() diff --git a/apps/api/app/git/providers/github.py b/apps/api/app/git/providers/github.py new file mode 100644 index 0000000..f9f5b8a --- /dev/null +++ b/apps/api/app/git/providers/github.py @@ -0,0 +1,40 @@ +from typing import Any + +from app.git.provider import GitProvider +from app.git.types import ConnectionStatus, ProviderKind + + +class GitHubAdapter(GitProvider): + BASE_URL = "https://api.github.com" + + def get_kind(self) -> ProviderKind: + return ProviderKind.github + + def _get_headers(self, token: str) -> dict[str, str]: + return { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + + def _extract_owner_repo(self, git_url: str) -> tuple[str, str]: + clean = git_url.replace("https://github.com/", "") + clean = clean.replace("git@github.com:", "") + clean = clean.replace(".git", "") + parts = clean.split("/") + return parts[0], parts[1] + + def validate_connection(self, git_url: str, credential_id: str) -> ConnectionStatus: + return ConnectionStatus.connected + + def list_repositories(self, credential_id: str) -> list[dict[str, Any]]: + return [] + + def create_deploy_key(self, git_url: str, public_key: str) -> str: + return "" + + def delete_deploy_key(self, git_url: str, deploy_key_id: str) -> None: + return + + def get_default_branch(self, git_url: str, credential_id: str) -> str: + return "main" diff --git a/apps/api/app/git/providers/gitlab.py b/apps/api/app/git/providers/gitlab.py new file mode 100644 index 0000000..07b9caf --- /dev/null +++ b/apps/api/app/git/providers/gitlab.py @@ -0,0 +1,35 @@ +from typing import Any + +from app.git.provider import GitProvider +from app.git.types import ConnectionStatus, ProviderKind + + +class GitLabAdapter(GitProvider): + BASE_URL = "https://gitlab.com/api/v4" + + def get_kind(self) -> ProviderKind: + return ProviderKind.gitlab + + def _get_headers(self, token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + def _extract_project_path(self, git_url: str) -> str: + path = git_url.replace("https://gitlab.com/", "") + path = path.replace("git@gitlab.com:", "") + path = path.replace(".git", "") + return path + + def validate_connection(self, git_url: str, credential_id: str) -> ConnectionStatus: + return ConnectionStatus.connected + + def list_repositories(self, credential_id: str) -> list[dict[str, Any]]: + return [] + + def create_deploy_key(self, git_url: str, public_key: str) -> str: + return "" + + def delete_deploy_key(self, git_url: str, deploy_key_id: str) -> None: + return + + def get_default_branch(self, git_url: str, credential_id: str) -> str: + return "main" diff --git a/apps/api/app/models/__init__.py b/apps/api/app/models/__init__.py index 6374e13..fdca2fa 100644 --- a/apps/api/app/models/__init__.py +++ b/apps/api/app/models/__init__.py @@ -1,6 +1,7 @@ from app.models.access_route import AccessRoute from app.models.base import Base from app.models.config import Config +from app.models.credential import Credential from app.models.project import Project from app.models.repository import Repository from app.models.repository_connection import RepositoryConnection @@ -14,6 +15,7 @@ __all__ = [ "Base", "AccessRoute", "Config", + "Credential", "Project", "Repository", "RepositoryConnection", diff --git a/apps/api/app/routers/__init__.py b/apps/api/app/routers/__init__.py index a940340..9ca0308 100644 --- a/apps/api/app/routers/__init__.py +++ b/apps/api/app/routers/__init__.py @@ -4,6 +4,7 @@ from app.routers.access_routes import router as access_routes_router from app.routers.configs import router as configs_router from app.routers.projects import router as projects_router from app.routers.repositories import router as repositories_router +from app.routers.repository_connections import router as repository_connections_router from app.routers.secrets import router as secrets_router from app.routers.tool_definitions import router as tool_definitions_router from app.routers.tool_instances import router as tool_instances_router @@ -15,6 +16,7 @@ routers: list[APIRouter] = [ configs_router, projects_router, repositories_router, + repository_connections_router, secrets_router, tool_definitions_router, tool_instances_router, diff --git a/apps/api/app/routers/repository_connections.py b/apps/api/app/routers/repository_connections.py new file mode 100644 index 0000000..5eaf4ca --- /dev/null +++ b/apps/api/app/routers/repository_connections.py @@ -0,0 +1,243 @@ +"""Repository connection router.""" + +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.dependencies import get_current_active_user +from app.db import get_db_session +from app.git.credential_storage import DatabaseCredentialStorage +from app.git.credentials import AccessTokenCredential, GitCredential +from app.git.providers import get_provider +from app.git.ssh_key import SshKeyLifecycle +from app.git.types import ConnectionStatus, ProviderKind +from app.models.project import Project +from app.models.repository import Repository +from app.models.repository_connection import RepositoryConnection +from app.models.user import User +from app.schemas.repository_connection import ( + RepositoryConnectionCreate, + RepositoryConnectionRead, + SshKeyResponse, +) + +router = APIRouter(tags=["repository-connections"]) + + +async def _get_project_for_user( + project_id: UUID, user: User, session: AsyncSession +) -> Project: + project = await session.get(Project, project_id) + if not project or project.owner_id != user.id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Project not found" + ) + return project + + +@router.post( + "/projects/{project_id}/repository-connections", + response_model=RepositoryConnectionRead, + status_code=status.HTTP_201_CREATED, +) +async def create_repository_connection( + project_id: UUID, + conn_in: RepositoryConnectionCreate, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> RepositoryConnection: + await _get_project_for_user(project_id, current_user, session) + + repo = await session.get(Repository, conn_in.repository_id) + if not repo or repo.project_id != project_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Repository not found" + ) + + result = await session.execute( + select(RepositoryConnection).where( + RepositoryConnection.project_id == project_id, + RepositoryConnection.repository_id == conn_in.repository_id, + RepositoryConnection.provider_kind == conn_in.provider_kind, + ) + ) + existing = result.scalar_one_or_none() + if existing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Connection already exists for this repository and provider", + ) + + storage = DatabaseCredentialStorage(session) + credential: GitCredential + if conn_in.credential_kind == "access_token": + credential = AccessTokenCredential( + encrypted_payload=conn_in.credential_payload + ) + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Unsupported credential kind: {conn_in.credential_kind}", + ) + + credential_id = await storage.create(credential) + + connection = RepositoryConnection( + project_id=project_id, + repository_id=conn_in.repository_id, + provider_kind=conn_in.provider_kind, + credential_id=credential_id, + connection_status=str(ConnectionStatus.pending), + ) + session.add(connection) + await session.commit() + await session.refresh(connection) + + try: + provider = get_provider(ProviderKind(conn_in.provider_kind)) + provider_status = provider.validate_connection(repo.git_url, str(credential_id)) + connection.connection_status = str(provider_status) + except Exception: + connection.connection_status = str(ConnectionStatus.error) + + await session.commit() + await session.refresh(connection) + return connection + + +@router.get( + "/projects/{project_id}/repository-connections", + response_model=list[RepositoryConnectionRead], +) +async def list_repository_connections( + project_id: UUID, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> list[RepositoryConnection]: + await _get_project_for_user(project_id, current_user, session) + result = await session.execute( + select(RepositoryConnection).where( + RepositoryConnection.project_id == project_id + ) + ) + return list(result.scalars().all()) + + +@router.get( + "/projects/{project_id}/repository-connections/{connection_id}", + response_model=RepositoryConnectionRead, +) +async def get_repository_connection( + project_id: UUID, + connection_id: UUID, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> RepositoryConnection: + await _get_project_for_user(project_id, current_user, session) + connection = await session.get(RepositoryConnection, connection_id) + if not connection or connection.project_id != project_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found" + ) + return connection + + +@router.delete( + "/projects/{project_id}/repository-connections/{connection_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +async def delete_repository_connection( + project_id: UUID, + connection_id: UUID, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> None: + await _get_project_for_user(project_id, current_user, session) + connection = await session.get(RepositoryConnection, connection_id) + if not connection or connection.project_id != project_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found" + ) + + if connection.credential_id: + storage = DatabaseCredentialStorage(session) + await storage.delete(connection.credential_id) + + await session.delete(connection) + await session.commit() + + +@router.post( + "/projects/{project_id}/repository-connections/{connection_id}/ssh-key", + response_model=SshKeyResponse, + status_code=status.HTTP_201_CREATED, +) +async def generate_ssh_key( + project_id: UUID, + connection_id: UUID, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> dict[str, str]: + await _get_project_for_user(project_id, current_user, session) + connection = await session.get(RepositoryConnection, connection_id) + if not connection or connection.project_id != project_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found" + ) + + key_pair = SshKeyLifecycle.generate(connection_id) + + storage = DatabaseCredentialStorage(session) + ssh_credential = GitCredential( + kind="ssh_key", + encrypted_payload=key_pair.encrypted_private_key, + ) + credential_id = await storage.create(ssh_credential) + + connection.credential_id = credential_id + await session.commit() + + return { + "connection_id": str(connection_id), + "public_key": key_pair.public_key, + "credential_id": str(credential_id), + } + + +@router.post( + "/projects/{project_id}/repository-connections/{connection_id}/validate", + response_model=RepositoryConnectionRead, +) +async def validate_connection( + project_id: UUID, + connection_id: UUID, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> RepositoryConnection: + await _get_project_for_user(project_id, current_user, session) + connection = await session.get(RepositoryConnection, connection_id) + if not connection or connection.project_id != project_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found" + ) + + repo = await session.get(Repository, connection.repository_id) + if not repo: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Repository not found" + ) + + try: + provider = get_provider(ProviderKind(connection.provider_kind)) + provider_status = provider.validate_connection( + repo.git_url, str(connection.credential_id) if connection.credential_id else "" + ) + connection.connection_status = str(provider_status) + except Exception: + connection.connection_status = str(ConnectionStatus.error) + + await session.commit() + await session.refresh(connection) + return connection diff --git a/apps/api/app/schemas/repository_connection.py b/apps/api/app/schemas/repository_connection.py new file mode 100644 index 0000000..9c59c04 --- /dev/null +++ b/apps/api/app/schemas/repository_connection.py @@ -0,0 +1,35 @@ +from uuid import UUID + +from app.schemas.base import OrmBase + + +class RepositoryConnectionBase(OrmBase): + project_id: UUID + repository_id: UUID | None = None + provider_kind: str = "generic" + credential_id: UUID | None = None + connection_status: str = "pending" + default_branch: str | None = None + + +class RepositoryConnectionCreate(OrmBase): + repository_id: UUID + provider_kind: str + credential_kind: str + credential_payload: str + + +class RepositoryConnectionRead(OrmBase): + id: UUID + project_id: UUID + repository_id: UUID | None = None + provider_kind: str + credential_id: UUID | None = None + connection_status: str + default_branch: str | None = None + + +class SshKeyResponse(OrmBase): + connection_id: UUID + public_key: str + credential_id: UUID diff --git a/apps/api/tests/test_git_operations.py b/apps/api/tests/test_git_operations.py index d2b20ef..c924426 100644 --- a/apps/api/tests/test_git_operations.py +++ b/apps/api/tests/test_git_operations.py @@ -44,18 +44,17 @@ def temp_repo() -> Any: yield repo_path -def test_clone_raises_not_implemented_error(local_git: LocalGitOperations) -> None: - with pytest.raises(NotImplementedError): +def test_clone_invalid_repo_raises(local_git: LocalGitOperations) -> None: + with pytest.raises(RuntimeError, match="Git clone failed"): local_git.clone("https://example.com/repo.git", Path("/tmp/dest"), "cred-id") -def test_fetch_raises_not_implemented_error(local_git: LocalGitOperations, temp_repo: Path) -> None: - with pytest.raises(NotImplementedError): - local_git.fetch(temp_repo, "cred-id") +def test_fetch_no_remote_succeeds(local_git: LocalGitOperations, temp_repo: Path) -> None: + local_git.fetch(temp_repo, "cred-id") -def test_push_raises_not_implemented_error(local_git: LocalGitOperations, temp_repo: Path) -> None: - with pytest.raises(NotImplementedError): +def test_push_no_remote_raises(local_git: LocalGitOperations, temp_repo: Path) -> None: + with pytest.raises(RuntimeError, match="Git push failed"): local_git.push(temp_repo, "cred-id")