diff --git a/apps/api/app/git/__init__.py b/apps/api/app/git/__init__.py index 6bed921..9fb2f09 100644 --- a/apps/api/app/git/__init__.py +++ b/apps/api/app/git/__init__.py @@ -1,5 +1,6 @@ """Git provider abstraction, credentials, SSH keys, and operations.""" +from app.git.connection import ConnectionManager, RepositoryConnectionData from app.git.credentials import AccessTokenCredential, CredentialStorage, GitCredential from app.git.provider import GitProvider from app.git.ssh_key import SshKeyLifecycle, SshKeyPair @@ -7,12 +8,14 @@ from app.git.types import ConnectionStatus, CredentialKind, ProviderKind, SshKey __all__ = [ "AccessTokenCredential", + "ConnectionManager", "ConnectionStatus", "CredentialKind", "CredentialStorage", "GitCredential", "GitProvider", "ProviderKind", + "RepositoryConnectionData", "SshKeyLifecycle", "SshKeyPair", "SshKeyStatus", diff --git a/apps/api/app/git/connection.py b/apps/api/app/git/connection.py new file mode 100644 index 0000000..3e5c4c5 --- /dev/null +++ b/apps/api/app/git/connection.py @@ -0,0 +1,93 @@ +"""Repository connection orchestration.""" + +import uuid + +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.git.credentials import CredentialStorage, GitCredential +from app.git.provider import GitProvider +from app.git.types import ConnectionStatus, ProviderKind +from app.models.repository_connection import RepositoryConnection + + +class RepositoryConnectionData(BaseModel): + """Domain-level read model for a repository connection.""" + + id: uuid.UUID + project_id: uuid.UUID + repository_id: uuid.UUID | None + provider_kind: ProviderKind + credential_id: uuid.UUID | None + connection_status: ConnectionStatus + default_branch: str | None + + +class ConnectionManager: + """Orchestrates creating, validating, and retrieving repository connections.""" + + def __init__(self, provider: GitProvider, storage: CredentialStorage) -> None: + self.provider = provider + self.storage = storage + + async def connect( + self, + session: AsyncSession, + project_id: uuid.UUID, + git_url: str, + credential: GitCredential, + ) -> RepositoryConnectionData: + """Store *credential*, create a connection row, and validate with the provider.""" + credential_id = self.storage.create(credential) + + row = RepositoryConnection( + project_id=project_id, + provider_kind=str(self.provider.get_kind()), + credential_id=credential_id, + connection_status=str(ConnectionStatus.pending), + ) + session.add(row) + await session.flush() + + status = self.provider.validate_connection(git_url, str(credential_id)) + if status == ConnectionStatus.connected: + row.connection_status = str(ConnectionStatus.connected) + else: + row.connection_status = str(ConnectionStatus.error) + await session.flush() + raise RuntimeError("Connection validation failed") + + await session.refresh(row) + return _map_row(row) + + async def disconnect( + self, session: AsyncSession, connection_id: uuid.UUID + ) -> None: + """Mark the connection as disconnected.""" + row = await session.get(RepositoryConnection, connection_id) + if row is None: + return + row.connection_status = str(ConnectionStatus.disconnected) + await session.flush() + + async def get_connection( + self, session: AsyncSession, connection_id: uuid.UUID + ) -> RepositoryConnectionData | None: + """Fetch a connection by ID and map it to the Pydantic read model.""" + row = await session.get(RepositoryConnection, connection_id) + if row is None: + return None + return _map_row(row) + + +def _map_row(row: RepositoryConnection) -> RepositoryConnectionData: + return RepositoryConnectionData( + id=row.id, + project_id=row.project_id, + repository_id=row.repository_id, + provider_kind=ProviderKind(row.provider_kind), + credential_id=row.credential_id, + connection_status=ConnectionStatus(row.connection_status), + default_branch=row.default_branch, + ) diff --git a/apps/api/app/models/__init__.py b/apps/api/app/models/__init__.py index 18d1964..6374e13 100644 --- a/apps/api/app/models/__init__.py +++ b/apps/api/app/models/__init__.py @@ -3,6 +3,7 @@ from app.models.base import Base from app.models.config import Config from app.models.project import Project from app.models.repository import Repository +from app.models.repository_connection import RepositoryConnection from app.models.secret import Secret from app.models.tool_definition import ToolDefinition from app.models.tool_instance import ToolInstance @@ -15,6 +16,7 @@ __all__ = [ "Config", "Project", "Repository", + "RepositoryConnection", "Secret", "ToolDefinition", "ToolInstance", diff --git a/apps/api/app/models/repository.py b/apps/api/app/models/repository.py index 7aa6775..5ae25fd 100644 --- a/apps/api/app/models/repository.py +++ b/apps/api/app/models/repository.py @@ -8,6 +8,7 @@ from app.models.base import Base, TimestampMixin, UUIDMixin if TYPE_CHECKING: from app.models.project import Project + from app.models.repository_connection import RepositoryConnection class Repository(Base, UUIDMixin, TimestampMixin): @@ -28,3 +29,6 @@ class Repository(Base, UUIDMixin, TimestampMixin): project: Mapped["Project"] = relationship( back_populates="repositories" ) + connections: Mapped[list["RepositoryConnection"]] = relationship( + back_populates="repository" + ) diff --git a/apps/api/app/models/repository_connection.py b/apps/api/app/models/repository_connection.py new file mode 100644 index 0000000..28f741c --- /dev/null +++ b/apps/api/app/models/repository_connection.py @@ -0,0 +1,39 @@ +"""RepositoryConnection links a project to a Git repository via a provider.""" + +import uuid +from typing import TYPE_CHECKING + +from sqlalchemy import ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base, TimestampMixin, UUIDMixin + +if TYPE_CHECKING: + from app.models.repository import Repository + + +class RepositoryConnection(Base, UUIDMixin, TimestampMixin): + __tablename__ = "repository_connection" + + project_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("project.id"), index=True + ) + repository_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("repository.id"), nullable=True, index=True + ) + provider_kind: Mapped[str] = mapped_column( + String(50), default="generic" + ) + credential_id: Mapped[uuid.UUID | None] = mapped_column( + index=True, nullable=True + ) + connection_status: Mapped[str] = mapped_column( + String(50), default="pending" + ) + default_branch: Mapped[str | None] = mapped_column( + String(100), nullable=True + ) + + repository: Mapped["Repository"] = relationship( + back_populates="connections" + )