diff --git a/backend/app/api/forgejo_connections.py b/backend/app/api/forgejo_connections.py index 5a0bbd8..11cdb71 100644 --- a/backend/app/api/forgejo_connections.py +++ b/backend/app/api/forgejo_connections.py @@ -21,6 +21,7 @@ from app.schemas.forgejo_connections import ( ForgejoConnectionUpdate, ) from app.schemas.forgejo_validation import ForgejoConnectionValidationResponse +from app.services.forgejo_cleanup import delete_connection_with_repositories from app.services.organizations import OrganizationContext if TYPE_CHECKING: @@ -169,7 +170,7 @@ async def delete_connection( if connection.organization_id != ctx.organization.id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) - await session.delete(connection) + await delete_connection_with_repositories(session, connection) await session.commit() return OkResponse() diff --git a/backend/app/api/forgejo_repositories.py b/backend/app/api/forgejo_repositories.py index e321ff7..cb55f3f 100644 --- a/backend/app/api/forgejo_repositories.py +++ b/backend/app/api/forgejo_repositories.py @@ -21,6 +21,7 @@ from app.schemas.forgejo_repositories import ( ) from app.schemas.forgejo_validation import ForgejoRepositoryValidationResponse, ValidationStatus from app.services.forgejo_client import get_forgejo_client +from app.services.forgejo_cleanup import delete_repository_with_dependents from app.services.organizations import OrganizationContext if TYPE_CHECKING: @@ -222,7 +223,7 @@ async def delete_repository( if repository.organization_id != ctx.organization.id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) - await session.delete(repository) + await delete_repository_with_dependents(session, repository) await session.commit() return OkResponse() diff --git a/backend/app/services/forgejo_cleanup.py b/backend/app/services/forgejo_cleanup.py new file mode 100644 index 0000000..3700133 --- /dev/null +++ b/backend/app/services/forgejo_cleanup.py @@ -0,0 +1,55 @@ +"""Explicit cleanup helpers for Forgejo connection/repository deletes.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from sqlmodel import select + +from app.models.board_repository_links import BoardRepositoryLink +from app.models.forgejo_connections import ForgejoConnection +from app.models.forgejo_issues import ForgejoIssue +from app.models.forgejo_repositories import ForgejoRepository + +if TYPE_CHECKING: + from sqlmodel.ext.asyncio.session import AsyncSession + + +async def delete_repository_with_dependents( + session: AsyncSession, + repository: ForgejoRepository, +) -> None: + """Delete a tracked repository and rows that reference it.""" + link_statement = select(BoardRepositoryLink).where( + BoardRepositoryLink.repository_id == repository.id, + BoardRepositoryLink.organization_id == repository.organization_id, + ) + links = (await session.exec(link_statement)).all() + for link in links: + await session.delete(link) + + issue_statement = select(ForgejoIssue).where( + ForgejoIssue.repository_id == repository.id, + ForgejoIssue.organization_id == repository.organization_id, + ) + issues = (await session.exec(issue_statement)).all() + for issue in issues: + await session.delete(issue) + + await session.delete(repository) + + +async def delete_connection_with_repositories( + session: AsyncSession, + connection: ForgejoConnection, +) -> None: + """Delete a Forgejo connection and all tracked repositories under it.""" + repository_statement = select(ForgejoRepository).where( + ForgejoRepository.connection_id == connection.id, + ForgejoRepository.organization_id == connection.organization_id, + ) + repositories = (await session.exec(repository_statement)).all() + for repository in repositories: + await delete_repository_with_dependents(session, repository) + + await session.delete(connection) diff --git a/backend/tests/test_forgejo_connections_api.py b/backend/tests/test_forgejo_connections_api.py new file mode 100644 index 0000000..af74945 --- /dev/null +++ b/backend/tests/test_forgejo_connections_api.py @@ -0,0 +1,159 @@ +# ruff: noqa: INP001 +"""Integration tests for Forgejo connection CRUD behavior.""" + +from __future__ import annotations + +from datetime import datetime +from uuid import uuid4 + +import pytest +from fastapi import APIRouter, FastAPI +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine +from sqlmodel import SQLModel, col, select +from sqlmodel.ext.asyncio.session import AsyncSession + +from app import models as _models +from app.api.deps import require_org_member +from app.api.forgejo_connections import router as forgejo_connections_router +from app.db.session import get_session +from app.models.board_repository_links import BoardRepositoryLink +from app.models.boards import Board +from app.models.forgejo_connections import ForgejoConnection +from app.models.forgejo_issues import ForgejoIssue +from app.models.forgejo_repositories import ForgejoRepository +from app.models.organization_members import OrganizationMember +from app.models.organizations import Organization +from app.services.organizations import OrganizationContext + + +async def _make_engine() -> AsyncEngine: + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + return engine + + +def _build_test_app( + session_maker: async_sessionmaker[AsyncSession], + ctx: OrganizationContext, +) -> FastAPI: + app = FastAPI() + api_v1 = APIRouter(prefix="/api/v1") + api_v1.include_router(forgejo_connections_router) + app.include_router(api_v1) + + async def _override_get_session() -> AsyncSession: + async with session_maker() as session: + yield session + + async def _override_require_org_member() -> OrganizationContext: + return ctx + + app.dependency_overrides[get_session] = _override_get_session + app.dependency_overrides[require_org_member] = _override_require_org_member + return app + + +@pytest.mark.asyncio +async def test_delete_connection_removes_repositories_and_dependents() -> None: + engine = await _make_engine() + session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + organization = Organization(id=uuid4(), name="Pipeline") + member = OrganizationMember( + id=uuid4(), + organization_id=organization.id, + user_id=uuid4(), + role="owner", + ) + app = _build_test_app( + session_maker, + OrganizationContext(organization=organization, member=member), + ) + + connection = ForgejoConnection( + id=uuid4(), + organization_id=organization.id, + name="Dream Forgejo", + base_url="https://forgejo.example.local", + ) + repository = ForgejoRepository( + id=uuid4(), + organization_id=organization.id, + connection_id=connection.id, + owner="openclaw", + repo="pipeline", + display_name="Pipeline", + ) + board = Board( + id=uuid4(), + organization_id=organization.id, + name="Pipeline Board", + slug="pipeline-board", + ) + issue = ForgejoIssue( + id=uuid4(), + organization_id=organization.id, + repository_id=repository.id, + forgejo_issue_number=42, + title="Sync issue", + body_preview="Cached issue body", + state="open", + is_pull_request=False, + labels=[], + assignees=[], + author="kaspa", + html_url="https://forgejo.example.local/openclaw/pipeline/issues/42", + forgejo_created_at=datetime(2026, 5, 19, 12, 0, 0), + forgejo_updated_at=datetime(2026, 5, 19, 12, 5, 0), + ) + link = BoardRepositoryLink( + id=uuid4(), + organization_id=organization.id, + board_id=board.id, + repository_id=repository.id, + ) + + try: + async with session_maker() as session: + session.add(organization) + session.add(connection) + session.add(repository) + session.add(board) + session.add(issue) + session.add(link) + await session.commit() + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://testserver", + ) as client: + response = await client.delete(f"/api/v1/forgejo/connections/{connection.id}") + + assert response.status_code == 200 + assert response.json() == {"ok": True} + + async with session_maker() as session: + assert ( + await session.exec( + select(ForgejoConnection).where(col(ForgejoConnection.id) == connection.id) + ) + ).first() is None + assert ( + await session.exec( + select(ForgejoRepository).where(col(ForgejoRepository.id) == repository.id) + ) + ).first() is None + assert ( + await session.exec(select(ForgejoIssue).where(col(ForgejoIssue.id) == issue.id)) + ).first() is None + assert ( + await session.exec( + select(BoardRepositoryLink).where(col(BoardRepositoryLink.id) == link.id) + ) + ).first() is None + assert ( + await session.exec(select(Board).where(col(Board.id) == board.id)) + ).first() is not None + finally: + await engine.dispose() diff --git a/frontend/src/app/git-projects/connections/[connectionId]/edit/page.tsx b/frontend/src/app/git-projects/connections/[connectionId]/edit/page.tsx index 4d8759c..e23e234 100644 --- a/frontend/src/app/git-projects/connections/[connectionId]/edit/page.tsx +++ b/frontend/src/app/git-projects/connections/[connectionId]/edit/page.tsx @@ -164,7 +164,7 @@ export default function ForgejoConnectionsEditPage({ open={deleteOpen} onOpenChange={setDeleteOpen} title="Delete Git Project connection" - description={`Delete "${connection.name}" from Pipeline? Repositories that use this connection will stop syncing.`} + description={`Delete "${connection.name}" from Pipeline? Tracked repositories and cached issues that use this connection will be removed.`} onConfirm={handleDelete} isConfirming={isDeleting} errorMessage={deleteError} diff --git a/frontend/src/app/git-projects/connections/page.tsx b/frontend/src/app/git-projects/connections/page.tsx index b9a01d6..181aaa2 100644 --- a/frontend/src/app/git-projects/connections/page.tsx +++ b/frontend/src/app/git-projects/connections/page.tsx @@ -167,7 +167,7 @@ export default function ForgejoConnectionsPage() { title="Delete Git Project connection" description={ deleteTarget - ? `Delete "${deleteTarget.name}" from Pipeline? Repositories that use this connection will stop syncing.` + ? `Delete "${deleteTarget.name}" from Pipeline? Tracked repositories and cached issues that use this connection will be removed.` : "" } onConfirm={confirmDelete} diff --git a/frontend/src/app/settings/git-projects/page.tsx b/frontend/src/app/settings/git-projects/page.tsx index a7dc285..f050de4 100644 --- a/frontend/src/app/settings/git-projects/page.tsx +++ b/frontend/src/app/settings/git-projects/page.tsx @@ -332,6 +332,9 @@ export default function GitProjectSettingsPage() { setConnections((prev) => prev.filter((c) => c.id !== deleteTarget.item.id), ); + setRepositories((prev) => + prev.filter((r) => r.connection_id !== deleteTarget.item.id), + ); setNotice({ tone: "success", message: `Deleted "${deleteTarget.item.name}".`, @@ -670,7 +673,7 @@ export default function GitProjectSettingsPage() { } description={ deleteTarget?.type === "connection" - ? `Delete "${deleteTarget.item.name}" from Pipeline? Repositories that use this connection will stop syncing.` + ? `Delete "${deleteTarget.item.name}" from Pipeline? Tracked repositories and cached issues that use this connection will be removed.` : deleteTarget?.type === "repository" ? `Delete "${repositoryName(deleteTarget.item)}" from Pipeline? Synced issue records for this repository will be removed.` : ""