feat(FN-011): complete Step 4 — repository connection model and connection manager

Fusion-Task-Id: FN-011
Fusion-Task-Lineage: 4a9aca6f-9d91-43aa-8d2a-d59657c1541a
This commit is contained in:
Fusion
2026-05-14 07:47:37 +02:00
parent edfbce9635
commit cffeb11410
5 changed files with 141 additions and 0 deletions
+3
View File
@@ -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",
+93
View File
@@ -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,
)
+2
View File
@@ -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",
+4
View File
@@ -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"
)
@@ -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"
)