feat(FN-007): implement repository connection API and git operations
- Add GitHubAdapter and GitLabAdapter with URL parsing - Create provider factory in apps/api/app/git/providers/ - Implement clone, fetch, push in LocalGitOperations - Add repository_connections router with CRUD and SSH key endpoints - Create RepositoryConnection schema with validation - Update models and routers __init__.py for new components - Add comprehensive tests for git operations
This commit is contained in:
@@ -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():
|
||||
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user