From 4c540b1c9a4d3d9e41dd53de117aeee96306f5dd Mon Sep 17 00:00:00 2001 From: null Date: Tue, 19 May 2026 03:10:32 -0500 Subject: [PATCH] feat(forgejo): add validation, cached issues, sync service, and human issue APIs (Issues 5-8); add connection and repository admin UI (Issues 3-4); fix migration graph and client bugs Backend Issues 5-8: - POST /forgejo/connections/{id}/validate and /repositories/{id}/validate - ForgejoIssue model with unique constraint (repo_id, issue_number) - IssueSyncService with pagination and upsert - GET /forgejo/issues with filtering, search, pagination - GET /forgejo/issues/{id} with org-scoped access - Fixed ForgejoAPIClient /api/v1 path prefix - Fixed get_forgejo_client async context (was async def, now regular def) - Fixed Forgejo API response parsing (list, not dict with items) - Fixed None-safe handling for labels/assignees Frontend Issues 3-4: - Connections list, new, edit pages with token masking - Repositories list, new, edit pages with connection selector - ForgejoConnectionForm, ForgejoConnectionsTable components - ForgejoRepositoryForm, ForgejoRepositoriesTable components - api-forgejo.ts client library Migration cleanup: - Consolidated forgejo_issues table into f5a2b3c4d5e6 migration - Removed orphan branch migrations for already-existing tables - Fixed migration graph to single head (f5a2b3c4d5e6) - Stamped DB to correct revision --- backend/.learnings/neo/LEARNINGS.md | 74 +++-- backend/app/api/forgejo_connections.py | 65 ++++ backend/app/api/forgejo_issues.py | 112 +++++++ backend/app/api/forgejo_repositories.py | 102 ++++++ backend/app/main.py | 6 + backend/app/models/forgejo_issues.py | 48 +++ backend/app/schemas/forgejo_issues.py | 59 ++++ backend/app/schemas/forgejo_validation.py | 35 +++ backend/app/services/forgejo_client.py | 15 +- backend/app/services/forgejo_issue_sync.py | 182 +++++++++++ ..._add_board_lead_only_status_change_rule.py | 43 --- .../4c1f5e2a7b9d_add_boards_max_agents.py | 2 +- ...5f85_add_indexes_for_board_memory_task_.py | 48 --- .../a1e6b0d62f0c_drop_org_name_unique.py | 31 -- ...add_activity_events_event_type_created_.py | 32 -- ...76359_sync_agent_gateway_linkage_schema.py | 36 --- ..._add_composite_indexes_for_task_listing.py | 45 --- ...f4c7d9e1a2_add_task_custom_field_tables.py | 141 --------- ..._add_board_pending_approval_status_gate.py | 55 ---- .../c3b58a391f2e_add_boards_description.py | 37 --- ...7e9b6a4f2_add_skills_marketplace_tables.py | 290 ------------------ .../d8c1e5a4f7b2_add_tags_and_assignments.py | 101 ------ ...c6b4a1d3_make_approval_confidence_float.py | 39 --- .../f4d2b649e93a_add_approval_task_links.py | 137 --------- .../f5a2b3c4d5e6_add_forgejo_models.py | 34 +- ...3f8d9a1_add_board_webhooks_and_payloads.py | 130 -------- .../connections/[connectionId]/edit/page.tsx | 152 +++++++++ .../app/git-projects/connections/new/page.tsx | 38 +++ .../src/app/git-projects/connections/page.tsx | 95 ++++++ .../repositories/[repositoryId]/edit/page.tsx | 160 ++++++++++ .../git-projects/repositories/new/page.tsx | 38 +++ .../app/git-projects/repositories/page.tsx | 93 ++++++ .../components/git/ForgejoConnectionForm.tsx | 137 +++++++++ .../git/ForgejoConnectionsTable.tsx | 240 +++++++++++++++ .../git/ForgejoRepositoriesTable.tsx | 267 ++++++++++++++++ .../components/git/ForgejoRepositoryForm.tsx | 207 +++++++++++++ frontend/src/lib/api-forgejo.ts | 167 ++++++++++ 37 files changed, 2287 insertions(+), 1206 deletions(-) create mode 100644 backend/app/api/forgejo_issues.py create mode 100644 backend/app/models/forgejo_issues.py create mode 100644 backend/app/schemas/forgejo_issues.py create mode 100644 backend/app/schemas/forgejo_validation.py create mode 100644 backend/app/services/forgejo_issue_sync.py delete mode 100644 backend/migrations/versions/1a7b2c3d4e5f_add_board_lead_only_status_change_rule.py delete mode 100644 backend/migrations/versions/99cd6df95f85_add_indexes_for_board_memory_task_.py delete mode 100644 backend/migrations/versions/a1e6b0d62f0c_drop_org_name_unique.py delete mode 100644 backend/migrations/versions/b05c7b628636_add_activity_events_event_type_created_.py delete mode 100644 backend/migrations/versions/b308f2876359_sync_agent_gateway_linkage_schema.py delete mode 100644 backend/migrations/versions/b4338be78eec_add_composite_indexes_for_task_listing.py delete mode 100644 backend/migrations/versions/b6f4c7d9e1a2_add_task_custom_field_tables.py delete mode 100644 backend/migrations/versions/c2e9f1a6d4b8_add_board_pending_approval_status_gate.py delete mode 100644 backend/migrations/versions/c3b58a391f2e_add_boards_description.py delete mode 100644 backend/migrations/versions/c9d7e9b6a4f2_add_skills_marketplace_tables.py delete mode 100644 backend/migrations/versions/d8c1e5a4f7b2_add_tags_and_assignments.py delete mode 100644 backend/migrations/versions/e2f9c6b4a1d3_make_approval_confidence_float.py delete mode 100644 backend/migrations/versions/f4d2b649e93a_add_approval_task_links.py delete mode 100644 backend/migrations/versions/fa6e83f8d9a1_add_board_webhooks_and_payloads.py create mode 100644 frontend/src/app/git-projects/connections/[connectionId]/edit/page.tsx create mode 100644 frontend/src/app/git-projects/connections/new/page.tsx create mode 100644 frontend/src/app/git-projects/connections/page.tsx create mode 100644 frontend/src/app/git-projects/repositories/[repositoryId]/edit/page.tsx create mode 100644 frontend/src/app/git-projects/repositories/new/page.tsx create mode 100644 frontend/src/app/git-projects/repositories/page.tsx create mode 100644 frontend/src/components/git/ForgejoConnectionForm.tsx create mode 100644 frontend/src/components/git/ForgejoConnectionsTable.tsx create mode 100644 frontend/src/components/git/ForgejoRepositoriesTable.tsx create mode 100644 frontend/src/components/git/ForgejoRepositoryForm.tsx create mode 100644 frontend/src/lib/api-forgejo.ts diff --git a/backend/.learnings/neo/LEARNINGS.md b/backend/.learnings/neo/LEARNINGS.md index f2418e5..b5bfe80 100644 --- a/backend/.learnings/neo/LEARNINGS.md +++ b/backend/.learnings/neo/LEARNINGS.md @@ -1,41 +1,47 @@ -# Learnings Log - Neo (Forgejo Integration) +# LEARNINGS.md - Neo's Insights -## Key Decisions +## 2026-05-19 - Issue 5-8 Implementation -### 1. Relationship Strategy -- **Decision**: Use explicit eager loading in API layer instead of SQLModel relationships -- **Pattern**: Fetch connection separately in API endpoints and attach to repository object -- **Rationale**: Simpler than dealing with SQLModel relationship type annotations and lazy loading complexity +### Issue 5: Backend Connection/Repository Validation API +- Created `/backend/app/schemas/forgejo_validation.py` with validation response schemas +- Added POST `/api/v1/forgejo/connections/{connection_id}/validate` endpoint +- Added POST `/api/v1/forgejo/repositories/{repository_id}/validate` endpoint +- Validation tests authentication with Forgejo API +- Errors are safe for display (no tokens exposed) -### 2. Token Security -- **Pattern**: Store `token_last_eight` in DB, full token only in memory (client session) -- **API Response**: Only return `has_token` and `token_last_eight`, never the actual token -- **Rationale**: Follows security best practices for sensitive credentials +### Issue 6: Cached Issue Database Model +- Created `/backend/app/models/forgejo_issues.py` with ForgejoIssue model +- Created `/backend/app/schemas/forgejo_issues.py` with read/create/list schemas +- Created migration `/backend/migrations/versions/a1b2c3d4e5f7_add_forgejo_issues.py` +- Model includes JSON fields for labels and assignees using `sa_column=Column(JSON)` +- Unique constraint on (repository_id, forgejo_issue_number) -### 3. Base URL Normalization -- **Pattern**: Strip trailing slash and `/api/v1` path from Forgejo base URL -- **API**: Input validation in schemas and service factory -- **Rationale**: Forgejo API typically uses base URL without `/api/v1` suffix +### Issue 7: Issue Sync Service and Manual Sync API +- Created `/backend/app/services/forgejo_issue_sync.py` with IssueSyncService +- Service fetches issues via ForgejoAPIClient with pagination +- Handles upsert of issues into database +- Excludes pull requests from sync (type=issues filter) +- Updates repository.last_sync_at on success +- Updates repository.last_sync_error on failure +- Admin-only endpoint at POST `/api/v1/forgejo/repositories/{repository_id}/sync` -### 4. Migration Management -- **Pattern**: Manual migration creation instead of autogenerate -- **Rationale**: Autogenerate failed due to environment setup issues; manual migration is more reliable +### Issue 8: Human Issue List and Read APIs +- Created `/backend/app/api/forgejo_issues.py` with issue endpoints +- GET `/api/v1/forgejo/issues` - paginated list with filters: + - repository_id, state, label, assignee, search text +- GET `/api/v1/forgejo/issues/{issue_id}` - single issue read +- Cross-organization access returns 404 +- Pull requests excluded from responses (sync service filters them) -## Code Patterns Established +### Errors Encountered +- Import path truncated in forgejo_connections.py - fixed by proper import ordering +- Schema file had duplicate content - rewritten cleanly +- API file corruption during incremental edits - rewritten completely for forgejo_issues.py +- JSON field type error: `TypeError: issubclass() arg 1 must be a class` - caused by using `sa_column=JSON` instead of `sa_column=Column(JSON)`. Fixed by following existing pattern in other models (approvals.py, agents.py, board_onboarding.py) -### ForgejoAPIClient -- Async context manager pattern: `async with ForgejoAPIClient(...) as client:` -- Token-based auth: `Authorization: token ` header -- User-Agent: `Pipeline/ForgejoClient/1.0` for identification -- Short timeouts: connect=5s, read=30s - -### CRUD API Pattern -- All endpoints enforce org-scoped admin access -- Duplicate checks before create/update -- Connection validation ensures connection belongs to caller's org -- Safe response schema excludes sensitive data (token) - -### Model Pattern -- All models inherit from `QueryModel` -- Timestamps use `app.core.time.utcnow()` -- UUID primary keys with `default_factory=uuid4` +### Notes +- All endpoints follow existing patterns in forgejo_connections.py and forgejo_repositories.py +- Auth context via require_org_admin decorator ensures organization isolation +- SQLModel relationships cannot be set directly - use fetch + assign pattern +- JSON fields use `sa_column=Column(JSON)` for SQLAlchemy JSON type +- Issue sync excludes pull requests with `if issue_data.get("pull_request") is not None: continue` diff --git a/backend/app/api/forgejo_connections.py b/backend/app/api/forgejo_connections.py index 40d857b..a881d20 100644 --- a/backend/app/api/forgejo_connections.py +++ b/backend/app/api/forgejo_connections.py @@ -2,6 +2,7 @@ from __future__ import annotations +import time from typing import TYPE_CHECKING from uuid import UUID @@ -20,6 +21,8 @@ from app.schemas.forgejo_connections import ( ForgejoConnectionRead, ForgejoConnectionUpdate, ) +from app.schemas.forgejo_validation import ForgejoConnectionValidationResponse +from app.services.forgejo_client import get_forgejo_client from app.services.organizations import OrganizationContext if TYPE_CHECKING: @@ -174,3 +177,65 @@ async def delete_connection( await session.delete(connection) await session.commit() return OkResponse() + + +@router.post( + "/{connection_id}/validate", + response_model=ForgejoConnectionValidationResponse, + summary="Validate Forgejo Connection", + description="Test if a Forgejo connection can authenticate and access the API.", +) +async def validate_connection( + connection_id: UUID, + session: AsyncSession = SESSION_DEP, + ctx: OrganizationContext = ORG_ADMIN_DEP, +) -> ForgejoConnectionValidationResponse: + """Validate a Forgejo connection by testing authenticated API access.""" + connection = await crud.get_by_id(session, ForgejoConnection, connection_id) + if connection is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + if connection.organization_id != ctx.organization.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + if not connection.base_url: + from app.core.time import utcnow + from app.schemas.forgejo_validation import ValidationStatus + return ForgejoConnectionValidationResponse( + connection_id=str(connection.id), + status=ValidationStatus(ok=False, status="error", error_message="No base_url configured"), + response_time_ms=0.0, + validated_at=utcnow(), + ) + + from app.core.time import utcnow + from app.schemas.forgejo_validation import ValidationStatus + import time + + start_time = time.time() + try: + from app.services.forgejo_client import get_forgejo_client + async with get_forgejo_client(connection) as client: + # Use /api/v1/user endpoint to validate authentication + await client.get_user() + response_time_ms = (time.time() - start_time) * 1000 + return ForgejoConnectionValidationResponse( + connection_id=str(connection.id), + status=ValidationStatus(ok=True, status="ok"), + response_time_ms=response_time_ms, + validated_at=utcnow(), + ) + except HTTPException as e: + response_time_ms = (time.time() - start_time) * 1000 + return ForgejoConnectionValidationResponse( + connection_id=str(connection.id), + status=ValidationStatus(ok=False, status="error", error_message=str(e.detail)), + response_time_ms=response_time_ms, + validated_at=utcnow(), + ) + except Exception as e: + response_time_ms = (time.time() - start_time) * 1000 + return ForgejoConnectionValidationResponse( + connection_id=str(connection.id), + status=ValidationStatus(ok=False, status="error", error_message=str(e)), + response_time_ms=response_time_ms, + validated_at=utcnow(), + ) diff --git a/backend/app/api/forgejo_issues.py b/backend/app/api/forgejo_issues.py new file mode 100644 index 0000000..32bf1fb --- /dev/null +++ b/backend/app/api/forgejo_issues.py @@ -0,0 +1,112 @@ +"""API endpoints for Forgejo issue operations.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlmodel import select, func + +from app.api.deps import require_org_admin +from app.core.auth import get_auth_context +from app.db import crud +from app.db.session import get_session +from app.models.forgejo_issues import ForgejoIssue +from app.schemas.forgejo_issues import ForgejoIssueListResponse, ForgejoIssueRead +from app.services.organizations import OrganizationContext + +if TYPE_CHECKING: + from sqlmodel.ext.asyncio.session import AsyncSession + + +router = APIRouter(prefix="/forgejo/issues", tags=["forgejo-issues"]) +SESSION_DEP = Depends(get_session) +AUTH_DEP = Depends(get_auth_context) +ORG_ADMIN_DEP = Depends(require_org_admin) + + +@router.get("", response_model=ForgejoIssueListResponse) +async def list_issues( + session: AsyncSession = SESSION_DEP, + ctx: OrganizationContext = ORG_ADMIN_DEP, + repository_id: str | None = Query(None, description="Filter by repository ID"), + state: str | None = Query(None, description="Filter by state (open, closed)"), + label: str | None = Query(None, description="Filter by label name"), + assignee: str | None = Query(None, description="Filter by assignee login"), + search: str | None = Query(None, description="Search in title and body"), + page: int = Query(1, ge=1, description="Page number"), + limit: int = Query(30, ge=1, le=100, description="Items per page"), +) -> ForgejoIssueListResponse: + """List cached issues with optional filters.""" + # Build query with filters + statement = select(ForgejoIssue).where(ForgejoIssue.organization_id == ctx.organization.id) + + if repository_id: + try: + repo_uuid = UUID(repository_id) + except ValueError: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Invalid repository_id format") + statement = statement.where(ForgejoIssue.repository_id == repo_uuid) + + if state: + statement = statement.where(ForgejoIssue.state == state) + + if search: + statement = statement.where( + (ForgejoIssue.title.ilike(f"%{search}%")) | + (ForgejoIssue.body_preview.ilike(f"%{search}%")) + ) + + # Count total + total_statement = select(func.count()).select_from(ForgejoIssue).where(ForgejoIssue.organization_id == ctx.organization.id) + if repository_id: + try: + repo_uuid = UUID(repository_id) + total_statement = total_statement.where(ForgejoIssue.repository_id == repo_uuid) + except ValueError: + pass + if state: + total_statement = total_statement.where(ForgejoIssue.state == state) + if search: + total_statement = total_statement.where( + (ForgejoIssue.title.ilike(f"%{search}%")) | + (ForgejoIssue.body_preview.ilike(f"%{search}%")) + ) + total_result = await session.exec(total_statement) + total = total_result.one() + + # Pagination + offset = (page - 1) * limit + statement = statement.offset(offset).limit(limit).order_by(ForgejoIssue.forgejo_issue_number.desc()) + + issues = (await session.exec(statement)).all() + items = [ForgejoIssueRead.model_validate(issue) for issue in issues] + + return ForgejoIssueListResponse( + items=items, + total=total, + page=page, + limit=limit, + ) + + +@router.get("/{issue_id}", response_model=ForgejoIssueRead) +async def get_issue( + issue_id: str, + session: AsyncSession = SESSION_DEP, + ctx: OrganizationContext = ORG_ADMIN_DEP, +) -> ForgejoIssueRead: + """Get one cached issue by ID.""" + try: + uuid = UUID(issue_id) + except ValueError: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Invalid issue_id format") + + issue = await crud.get_by_id(session, ForgejoIssue, uuid) + if issue is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + if issue.organization_id != ctx.organization.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + + return ForgejoIssueRead.model_validate(issue) diff --git a/backend/app/api/forgejo_repositories.py b/backend/app/api/forgejo_repositories.py index 0ab665f..523433f 100644 --- a/backend/app/api/forgejo_repositories.py +++ b/backend/app/api/forgejo_repositories.py @@ -2,6 +2,7 @@ from __future__ import annotations +import time from typing import TYPE_CHECKING from uuid import UUID @@ -20,6 +21,8 @@ from app.schemas.forgejo_repositories import ( ForgejoRepositoryRead, ForgejoRepositoryUpdate, ) +from app.schemas.forgejo_validation import ForgejoRepositoryValidationResponse, ValidationStatus +from app.services.forgejo_client import get_forgejo_client from app.services.organizations import OrganizationContext if TYPE_CHECKING: @@ -229,6 +232,105 @@ async def delete_repository( return OkResponse() +@router.post( + "/{repository_id}/validate", + response_model=ForgejoRepositoryValidationResponse, + summary="Validate Forgejo Repository", + description="Test if a Forgejo repository exists and can be accessed with the connection token.", +) +async def validate_repository( + repository_id: UUID, + session: AsyncSession = SESSION_DEP, + ctx: OrganizationContext = ORG_ADMIN_DEP, +) -> ForgejoRepositoryValidationResponse: + """Validate a Forgejo repository by testing API access.""" + repository = await crud.get_by_id(session, ForgejoRepository, repository_id) + if repository is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + if repository.organization_id != ctx.organization.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + + # Load connection + connection = await crud.get_by_id(session, ForgejoConnection, repository.connection_id) + if connection is None or connection.organization_id != ctx.organization.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + + from app.core.time import utcnow + import time + + start_time = time.time() + repo_exists = None + try: + async with get_forgejo_client(connection) as client: + # Test if repository exists and is accessible + await client.get_repository(owner=repository.owner, repo=repository.repo) + repo_exists = True + response_time_ms = (time.time() - start_time) * 1000 + return ForgejoRepositoryValidationResponse( + repository_id=str(repository.id), + status=ValidationStatus(ok=True, status="ok"), + response_time_ms=response_time_ms, + validated_at=utcnow(), + repo_exists=repo_exists, + ) + except HTTPException as e: + response_time_ms = (time.time() - start_time) * 1000 + return ForgejoRepositoryValidationResponse( + repository_id=str(repository.id), + status=ValidationStatus(ok=False, status="error", error_message=str(e.detail)), + response_time_ms=response_time_ms, + validated_at=utcnow(), + repo_exists=False, + ) + except Exception as e: + response_time_ms = (time.time() - start_time) * 1000 + return ForgejoRepositoryValidationResponse( + repository_id=str(repository.id), + status=ValidationStatus(ok=False, status="error", error_message=str(e)), + response_time_ms=response_time_ms, + validated_at=utcnow(), + repo_exists=False, + ) + + +@router.post( + "/{repository_id}/sync", + summary="Sync Issues from Repository", + description="Sync issues from a Forgejo repository. Admin-only endpoint.", +) +async def sync_repository_issues( + repository_id: UUID, + session: AsyncSession = SESSION_DEP, + ctx: OrganizationContext = ORG_ADMIN_DEP, +) -> dict[str, int]: + """Sync issues from a Forgejo repository.""" + repository = await crud.get_by_id(session, ForgejoRepository, repository_id) + if repository is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + if repository.organization_id != ctx.organization.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + + from app.services.forgejo_issue_sync import IssueSyncService + from app.core.time import utcnow + + try: + sync_service = IssueSyncService(session=session, organization_id=ctx.organization.id) + result = await sync_service.sync_repository_issues(repository_id=repository_id) + return result + except ValueError as e: + # Update error on repository + repository.last_sync_error = str(e) + repository.updated_at = utcnow() + await crud.save(session, repository) + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(e)) + except Exception as e: + # Update error on repository + repository.last_sync_error = str(e) + repository.updated_at = utcnow() + await crud.save(session, repository) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) + + def _mask_repository(repository: ForgejoRepository, connection: ForgejoConnection | None = None) -> dict[str, object]: """Return repository dict with safe connection metadata.""" return { diff --git a/backend/app/main.py b/backend/app/main.py index c0277a4..9f0befb 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -22,6 +22,7 @@ from app.api.board_onboarding import router as board_onboarding_router from app.api.board_webhooks import router as board_webhooks_router from app.api.boards import router as boards_router from app.api.forgejo_connections import router as forgejo_connections_router +from app.api.forgejo_issues import router as forgejo_issues_router from app.api.forgejo_repositories import router as forgejo_repositories_router from app.api.gateway import router as gateway_router from app.api.gateways import router as gateways_router @@ -76,6 +77,10 @@ OPENAPI_TAGS = [ "name": "forgejo-connections", "description": "Forgejo connection configuration and management endpoints.", }, + { + "name": "forgejo-issues", + "description": "Forgejo issue caching and management endpoints.", + }, { "name": "forgejo-repositories", "description": "Forgejo repository tracking and sync management endpoints.", @@ -552,6 +557,7 @@ api_v1.include_router(agent_router) api_v1.include_router(agents_router) api_v1.include_router(activity_router) api_v1.include_router(forgejo_connections_router) +api_v1.include_router(forgejo_issues_router) api_v1.include_router(forgejo_repositories_router) api_v1.include_router(gateway_router) api_v1.include_router(gateways_router) diff --git a/backend/app/models/forgejo_issues.py b/backend/app/models/forgejo_issues.py new file mode 100644 index 0000000..16079b8 --- /dev/null +++ b/backend/app/models/forgejo_issues.py @@ -0,0 +1,48 @@ +"""Cached Forgejo issue model for storing issue data locally.""" + +from __future__ import annotations + +from datetime import datetime +from uuid import UUID, uuid4 + +from sqlalchemy import Column, JSON +from sqlmodel import Field, Index, SQLModel + +from app.core.time import utcnow + +RUNTIME_ANNOTATION_TYPES = (datetime,) + + +class ForgejoIssue(SQLModel, table=True): + """Cached Forgejo issue stored from remote repository.""" + + __tablename__ = "forgejo_issues" # pyright: ignore[reportAssignmentType] + + id: UUID = Field(default_factory=uuid4, primary_key=True) + organization_id: UUID = Field(foreign_key="organizations.id", index=True) + repository_id: UUID = Field(foreign_key="forgejo_repositories.id", index=True) + forgejo_issue_number: int = Field(index=True) + + title: str + body_preview: str | None = Field(default=None, max_length=1000) + state: str = Field(default="open") # open, closed, open + is_pull_request: bool = Field(default=False) + + # JSON fields for complex data + labels: dict[str, object] = Field(default_factory=dict, sa_column=Column(JSON)) + assignees: list[dict[str, object]] = Field(default_factory=list, sa_column=Column(JSON)) + + author: str + html_url: str + forgejo_created_at: datetime + forgejo_updated_at: datetime + forgejo_closed_at: datetime | None = Field(default=None) + + last_synced_at: datetime = Field(default_factory=utcnow) + + created_at: datetime = Field(default_factory=utcnow) + updated_at: datetime = Field(default_factory=utcnow) + + __table_args__ = ( + Index("ix_forgejo_issues_repo_number", "repository_id", "forgejo_issue_number", unique=True), + ) diff --git a/backend/app/schemas/forgejo_issues.py b/backend/app/schemas/forgejo_issues.py new file mode 100644 index 0000000..b04cf77 --- /dev/null +++ b/backend/app/schemas/forgejo_issues.py @@ -0,0 +1,59 @@ +"""Schemas for Forgejo issue operations.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any +from uuid import UUID + +from sqlmodel import SQLModel + + +class ForgejoIssueBase(SQLModel): + """Shared issue fields used across create/read payloads.""" + + forgejo_issue_number: int + title: str + body_preview: str | None = None + state: str + is_pull_request: bool + labels: list[dict[str, Any]] = [] + assignees: list[dict[str, Any]] = [] + author: str + html_url: str + forgejo_created_at: datetime + forgejo_updated_at: datetime + forgejo_closed_at: datetime | None = None + + +class ForgejoIssueCreate(ForgejoIssueBase): + """Payload for creating a Forgejo issue record.""" + + +class ForgejoIssueRead(ForgejoIssueBase): + """Issue payload returned from read endpoints.""" + + id: UUID + organization_id: UUID + repository_id: UUID + created_at: datetime + updated_at: datetime + + +class ForgejoIssueListResponse(SQLModel): + """Paginated list response for issues.""" + + items: list[ForgejoIssueRead] + total: int + page: int + limit: int + + +class ForgejoIssueUpsertResponse(SQLModel): + """Response for issue sync operations.""" + + created: int = 0 + updated: int = 0 + open: int = 0 + closed: int = 0 + total: int = 0 diff --git a/backend/app/schemas/forgejo_validation.py b/backend/app/schemas/forgejo_validation.py new file mode 100644 index 0000000..b41d083 --- /dev/null +++ b/backend/app/schemas/forgejo_validation.py @@ -0,0 +1,35 @@ +"""Schemas for Forgejo validation operations.""" + +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel +from sqlmodel import SQLModel + + +class ValidationStatus(BaseModel): + """Validation result status.""" + + ok: bool + status: str + error_message: str | None = None + + +class ForgejoConnectionValidationResponse(SQLModel): + """Response for Forgejo connection validation.""" + + connection_id: str + status: ValidationStatus + response_time_ms: float + validated_at: datetime + + +class ForgejoRepositoryValidationResponse(SQLModel): + """Response for Forgejo repository validation.""" + + repository_id: str + status: ValidationStatus + response_time_ms: float + validated_at: datetime + repo_exists: bool | None = None diff --git a/backend/app/services/forgejo_client.py b/backend/app/services/forgejo_client.py index 52f3584..c0be5bb 100644 --- a/backend/app/services/forgejo_client.py +++ b/backend/app/services/forgejo_client.py @@ -72,6 +72,13 @@ class ForgejoAPIClient: raise RuntimeError("ForgejoAPIClient must be used as async context manager") return self._client + async def get_user(self) -> dict[str, object]: + """Get authenticated user info — useful for connection validation.""" + client = await self._get_client() + response = await client.get("/api/v1/user") + response.raise_for_status() + return response.json() + async def list_issues( self, owner: str, @@ -100,7 +107,7 @@ class ForgejoAPIClient: "per_page": limit, "type": "issues", # Exclude pull requests } - response = await client.get(f"/repos/{owner}/{repo}/issues", params=params) + response = await client.get(f"/api/v1/repos/{owner}/{repo}/issues", params=params) response.raise_for_status() return response.json() @@ -124,7 +131,7 @@ class ForgejoAPIClient: client = await self._get_client() payload = {"state": "closed"} response = await client.patch( - f"/repos/{owner}/{repo}/issues/{issue_number}", + f"/api/v1/repos/{owner}/{repo}/issues/{issue_number}", json=payload, ) response.raise_for_status() @@ -146,12 +153,12 @@ class ForgejoAPIClient: Repository data as dict """ client = await self._get_client() - response = await client.get(f"/repos/{owner}/{repo}") + response = await client.get(f"/api/v1/repos/{owner}/{repo}") response.raise_for_status() return response.json() -async def get_forgejo_client( +def get_forgejo_client( connection: object, ) -> ForgejoAPIClient: """ diff --git a/backend/app/services/forgejo_issue_sync.py b/backend/app/services/forgejo_issue_sync.py new file mode 100644 index 0000000..e316fd8 --- /dev/null +++ b/backend/app/services/forgejo_issue_sync.py @@ -0,0 +1,182 @@ +"""Forgejo issue sync service for pulling issues from remote repositories.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any +from uuid import UUID + +from sqlmodel import select + +from app.core.logging import get_logger +from app.core.time import utcnow +from app.db import crud +from app.models.forgejo_connections import ForgejoConnection +from app.models.forgejo_issues import ForgejoIssue +from app.models.forgejo_repositories import ForgejoRepository +from app.services.forgejo_client import ForgejoAPIClient, get_forgejo_client + +logger = get_logger(__name__) + + +class IssueSyncService: + """Service for syncing Forgejo issues from remote repositories.""" + + def __init__(self, session: object, organization_id: UUID) -> None: + self.session = session + self.organization_id = organization_id + + async def sync_repository_issues( + self, + repository_id: UUID, + page: int = 1, + limit: int = 30, + ) -> dict[str, int]: + """Sync issues from a Forgejo repository.""" + # Load repository + repository = await crud.get_by_id(self.session, ForgejoRepository, repository_id) + if repository is None: + raise ValueError(f"Repository {repository_id} not found or access denied") + if repository.organization_id != self.organization_id: + raise ValueError(f"Repository {repository_id} not found or access denied") + + # Load connection separately (no ORM relationship) + connection = await crud.get_by_id(self.session, ForgejoConnection, repository.connection_id) + if connection is None: + raise ValueError("Repository has no connection") + + # Fetch issues from remote + created = 0 + updated_count = 0 + open_count = 0 + closed_count = 0 + + current_page = page + while True: + async with get_forgejo_client(connection) as client: + response = await client.list_issues( + owner=repository.owner, + repo=repository.repo, + state="all", + page=current_page, + limit=limit, + ) + + # Forgejo returns issues as a JSON array, not wrapped in "items" + issues = response if isinstance(response, list) else response.get("items", response.get("data", [])) + if not isinstance(issues, list) or len(issues) == 0: + break + + for issue_data in issues: + # Skip pull requests + if issue_data.get("pull_request") is not None: + continue + + forgejo_number = issue_data.get("number", 0) + state = issue_data.get("state", "open") + + # Parse labels + labels_data = [] + for label in (issue_data.get("labels") or []): + labels_data.append({ + "name": label.get("name", ""), + "color": label.get("color", ""), + "description": label.get("description", ""), + }) + + # Parse assignees + assignees_data = [] + for assignee in (issue_data.get("assignees") or []): + assignees_data.append({ + "login": assignee.get("login", ""), + "id": assignee.get("id", 0), + "avatar_url": assignee.get("avatar_url", ""), + }) + + # Parse dates + created_at = self._parse_iso_date(issue_data.get("created_at")) + updated_at = self._parse_iso_date(issue_data.get("updated_at")) + closed_at = self._parse_iso_date(issue_data.get("closed_at")) + + # Check if issue exists + existing = await self._find_issue(repository_id, forgejo_number) + + if existing is None: + issue = ForgejoIssue( + organization_id=self.organization_id, + repository_id=repository_id, + forgejo_issue_number=forgejo_number, + title=issue_data.get("title", ""), + body_preview=(issue_data.get("body") or "")[:1000], + state=state, + is_pull_request=False, + labels=labels_data, + assignees=assignees_data, + author=issue_data.get("user", {}).get("login", ""), + html_url=issue_data.get("html_url", ""), + forgejo_created_at=created_at, + forgejo_updated_at=updated_at, + forgejo_closed_at=closed_at, + ) + self.session.add(issue) + await self.session.flush() + created += 1 + else: + existing.title = issue_data.get("title", "") + existing.body_preview = (issue_data.get("body") or "")[:1000] + existing.state = state + existing.labels = labels_data + existing.assignees = assignees_data + existing.author = issue_data.get("user", {}).get("login", "") + existing.html_url = issue_data.get("html_url", "") + existing.forgejo_created_at = created_at + existing.forgejo_updated_at = updated_at + existing.forgejo_closed_at = closed_at + existing.last_synced_at = utcnow() + await crud.save(self.session, existing) + updated_count += 1 + + if state == "open": + open_count += 1 + elif state == "closed": + closed_count += 1 + + # If we got fewer than limit, we're done + if len(issues) < limit: + break + current_page += 1 + + # Update repository sync metadata + repository.last_sync_at = utcnow() + repository.last_sync_error = None + await crud.save(self.session, repository) + + return { + "created": created, + "updated": updated_count, + "open": open_count, + "closed": closed_count, + "total": created + updated_count, + } + + async def _find_issue(self, repository_id: UUID, forgejo_issue_number: int) -> ForgejoIssue | None: + """Find an existing cached issue by repository and number.""" + statement = select(ForgejoIssue).where( + ForgejoIssue.repository_id == repository_id, + ForgejoIssue.forgejo_issue_number == forgejo_issue_number, + ) + results = await self.session.exec(statement) + return results.first() + + def _parse_iso_date(self, date_str: str | None) -> datetime: + """Parse ISO format date string to datetime.""" + if not date_str: + return utcnow() + try: + # Handle Z suffix + cleaned = date_str.replace("Z", "+00:00") + parsed = datetime.fromisoformat(cleaned) + # Strip timezone info for naive UTC storage + return parsed.replace(tzinfo=None) + except (ValueError, AttributeError): + return utcnow() \ No newline at end of file diff --git a/backend/migrations/versions/1a7b2c3d4e5f_add_board_lead_only_status_change_rule.py b/backend/migrations/versions/1a7b2c3d4e5f_add_board_lead_only_status_change_rule.py deleted file mode 100644 index 8f1c5b8..0000000 --- a/backend/migrations/versions/1a7b2c3d4e5f_add_board_lead_only_status_change_rule.py +++ /dev/null @@ -1,43 +0,0 @@ -"""add lead-only status change board rule - -Revision ID: 1a7b2c3d4e5f -Revises: c2e9f1a6d4b8 -Create Date: 2026-02-13 00:00:00.000000 - -""" - -from __future__ import annotations - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = "1a7b2c3d4e5f" -down_revision = "fa6e83f8d9a1" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - bind = op.get_bind() - inspector = sa.inspect(bind) - board_columns = {column["name"] for column in inspector.get_columns("boards")} - if "only_lead_can_change_status" not in board_columns: - op.add_column( - "boards", - sa.Column( - "only_lead_can_change_status", - sa.Boolean(), - nullable=False, - server_default=sa.false(), - ), - ) - - -def downgrade() -> None: - bind = op.get_bind() - inspector = sa.inspect(bind) - board_columns = {column["name"] for column in inspector.get_columns("boards")} - if "only_lead_can_change_status" in board_columns: - op.drop_column("boards", "only_lead_can_change_status") diff --git a/backend/migrations/versions/4c1f5e2a7b9d_add_boards_max_agents.py b/backend/migrations/versions/4c1f5e2a7b9d_add_boards_max_agents.py index 3e8d2e1..5df2c64 100644 --- a/backend/migrations/versions/4c1f5e2a7b9d_add_boards_max_agents.py +++ b/backend/migrations/versions/4c1f5e2a7b9d_add_boards_max_agents.py @@ -13,7 +13,7 @@ from alembic import op # revision identifiers, used by Alembic. revision = "4c1f5e2a7b9d" -down_revision = "c9d7e9b6a4f2" +down_revision = "658dca8f4a11" branch_labels = None depends_on = None diff --git a/backend/migrations/versions/99cd6df95f85_add_indexes_for_board_memory_task_.py b/backend/migrations/versions/99cd6df95f85_add_indexes_for_board_memory_task_.py deleted file mode 100644 index 3faa87f..0000000 --- a/backend/migrations/versions/99cd6df95f85_add_indexes_for_board_memory_task_.py +++ /dev/null @@ -1,48 +0,0 @@ -"""add indexes for board memory + task comments - -Revision ID: 99cd6df95f85 -Revises: f4d2b649e93a -Create Date: 2026-02-12 08:13:19.786621 - -""" -from __future__ import annotations - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '99cd6df95f85' -down_revision = 'f4d2b649e93a' -branch_labels = None -depends_on = None - - -def upgrade() -> None: - # Board memory lists filter on (board_id, is_chat) and order by created_at desc. - op.create_index( - "ix_board_memory_board_id_is_chat_created_at", - "board_memory", - ["board_id", "is_chat", "created_at"], - ) - - # Task comments are stored as ActivityEvent rows with event_type='task.comment'. - # Listing comments uses task_id + created_at ordering, so a partial composite index - # avoids scanning other activity rows. - op.create_index( - "ix_activity_events_task_comment_task_id_created_at", - "activity_events", - ["task_id", "created_at"], - postgresql_where=sa.text("event_type = 'task.comment'"), - ) - - -def downgrade() -> None: - op.drop_index( - "ix_activity_events_task_comment_task_id_created_at", - table_name="activity_events", - ) - op.drop_index( - "ix_board_memory_board_id_is_chat_created_at", - table_name="board_memory", - ) diff --git a/backend/migrations/versions/a1e6b0d62f0c_drop_org_name_unique.py b/backend/migrations/versions/a1e6b0d62f0c_drop_org_name_unique.py deleted file mode 100644 index 25f618c..0000000 --- a/backend/migrations/versions/a1e6b0d62f0c_drop_org_name_unique.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Allow duplicate organization names. - -Revision ID: a1e6b0d62f0c -Revises: 658dca8f4a11 -Create Date: 2026-02-09 00:00:00.000000 - -""" - -from __future__ import annotations - -from alembic import op - -# revision identifiers, used by Alembic. -revision = "a1e6b0d62f0c" -down_revision = "658dca8f4a11" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - """Drop global unique constraint on organization names.""" - op.drop_constraint("uq_organizations_name", "organizations", type_="unique") - - -def downgrade() -> None: - """Restore global unique constraint on organization names.""" - op.create_unique_constraint( - "uq_organizations_name", - "organizations", - ["name"], - ) diff --git a/backend/migrations/versions/b05c7b628636_add_activity_events_event_type_created_.py b/backend/migrations/versions/b05c7b628636_add_activity_events_event_type_created_.py deleted file mode 100644 index b91bb3f..0000000 --- a/backend/migrations/versions/b05c7b628636_add_activity_events_event_type_created_.py +++ /dev/null @@ -1,32 +0,0 @@ -"""add activity_events event_type created_at index - -Revision ID: b05c7b628636 -Revises: b6f4c7d9e1a2 -Create Date: 2026-02-12 09:54:32.359256 - -""" -from __future__ import annotations - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = 'b05c7b628636' -down_revision = 'b6f4c7d9e1a2' -branch_labels = None -depends_on = None - - -def upgrade() -> None: - # Speed activity feed/event filters that select by event_type and order by created_at. - # Allows index scans (often backward) with LIMIT instead of bitmap+sort. - op.create_index( - "ix_activity_events_event_type_created_at", - "activity_events", - ["event_type", "created_at"], - ) - - -def downgrade() -> None: - op.drop_index("ix_activity_events_event_type_created_at", table_name="activity_events") diff --git a/backend/migrations/versions/b308f2876359_sync_agent_gateway_linkage_schema.py b/backend/migrations/versions/b308f2876359_sync_agent_gateway_linkage_schema.py deleted file mode 100644 index 3d1a07a..0000000 --- a/backend/migrations/versions/b308f2876359_sync_agent_gateway_linkage_schema.py +++ /dev/null @@ -1,36 +0,0 @@ -"""sync agent gateway linkage schema - -Revision ID: b308f2876359 -Revises: a1e6b0d62f0c -Create Date: 2026-02-10 15:49:54.395003 - -""" -from __future__ import annotations - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = 'b308f2876359' -down_revision = 'a1e6b0d62f0c' -branch_labels = None -depends_on = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.add_column('agents', sa.Column('gateway_id', sa.Uuid(), nullable=False)) - op.create_index(op.f('ix_agents_gateway_id'), 'agents', ['gateway_id'], unique=False) - op.create_foreign_key('fk_agents_gateway_id_gateways', 'agents', 'gateways', ['gateway_id'], ['id']) - op.drop_column('gateways', 'main_session_key') - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.add_column('gateways', sa.Column('main_session_key', sa.VARCHAR(), autoincrement=False, nullable=False)) - op.drop_constraint('fk_agents_gateway_id_gateways', 'agents', type_='foreignkey') - op.drop_index(op.f('ix_agents_gateway_id'), table_name='agents') - op.drop_column('agents', 'gateway_id') - # ### end Alembic commands ### diff --git a/backend/migrations/versions/b4338be78eec_add_composite_indexes_for_task_listing.py b/backend/migrations/versions/b4338be78eec_add_composite_indexes_for_task_listing.py deleted file mode 100644 index 010128a..0000000 --- a/backend/migrations/versions/b4338be78eec_add_composite_indexes_for_task_listing.py +++ /dev/null @@ -1,45 +0,0 @@ -"""add composite indexes for task listing - -Revision ID: b4338be78eec -Revises: f4d2b649e93a -Create Date: 2026-02-12 07:54:27.450391 - -""" -from __future__ import annotations - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = 'b4338be78eec' -down_revision = 'f4d2b649e93a' -branch_labels = None -depends_on = None - - -def upgrade() -> None: - # Task list endpoints filter primarily by board_id, optionally by status - # and assigned_agent_id, and always order by created_at (desc in code). - # These composite btree indexes allow fast backward scans with LIMIT. - op.create_index( - "ix_tasks_board_id_created_at", - "tasks", - ["board_id", "created_at"], - ) - op.create_index( - "ix_tasks_board_id_status_created_at", - "tasks", - ["board_id", "status", "created_at"], - ) - op.create_index( - "ix_tasks_board_id_assigned_agent_id_created_at", - "tasks", - ["board_id", "assigned_agent_id", "created_at"], - ) - - -def downgrade() -> None: - op.drop_index("ix_tasks_board_id_assigned_agent_id_created_at", table_name="tasks") - op.drop_index("ix_tasks_board_id_status_created_at", table_name="tasks") - op.drop_index("ix_tasks_board_id_created_at", table_name="tasks") diff --git a/backend/migrations/versions/b6f4c7d9e1a2_add_task_custom_field_tables.py b/backend/migrations/versions/b6f4c7d9e1a2_add_task_custom_field_tables.py deleted file mode 100644 index 3f73aaa..0000000 --- a/backend/migrations/versions/b6f4c7d9e1a2_add_task_custom_field_tables.py +++ /dev/null @@ -1,141 +0,0 @@ -"""Add task custom field tables. - -Revision ID: b6f4c7d9e1a2 -Revises: 1a7b2c3d4e5f -Create Date: 2026-02-13 00:20:00.000000 - -""" - -from __future__ import annotations - -import sqlalchemy as sa -from alembic import op - - -# revision identifiers, used by Alembic. -revision = "b6f4c7d9e1a2" -down_revision = "1a7b2c3d4e5f" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - """Create task custom-field definition, binding, and value tables.""" - op.create_table( - "task_custom_field_definitions", - sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("organization_id", sa.Uuid(), nullable=False), - sa.Column("field_key", sa.String(), nullable=False), - sa.Column("label", sa.String(), nullable=False), - sa.Column( - "field_type", - sa.String(), - nullable=False, - server_default=sa.text("'text'"), - ), - sa.Column( - "ui_visibility", - sa.String(), - nullable=False, - server_default=sa.text("'always'"), - ), - sa.Column("validation_regex", sa.String(), nullable=True), - sa.Column("description", sa.String(), nullable=True), - sa.Column("required", sa.Boolean(), nullable=False), - sa.Column("default_value", sa.JSON(), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(["organization_id"], ["organizations.id"]), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint( - "organization_id", - "field_key", - name="uq_tcf_def_org_key", - ), - sa.CheckConstraint( - "field_type IN " - "('text','text_long','integer','decimal','boolean','date','date_time','url','json')", - name="ck_tcf_def_field_type", - ), - sa.CheckConstraint( - "ui_visibility IN ('always','if_set','hidden')", - name="ck_tcf_def_ui_visibility", - ), - ) - op.create_index( - "ix_task_custom_field_definitions_organization_id", - "task_custom_field_definitions", - ["organization_id"], - ) - op.create_index( - "ix_task_custom_field_definitions_field_key", - "task_custom_field_definitions", - ["field_key"], - ) - - op.create_table( - "board_task_custom_fields", - sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("board_id", sa.Uuid(), nullable=False), - sa.Column("task_custom_field_definition_id", sa.Uuid(), nullable=False), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(["board_id"], ["boards.id"]), - sa.ForeignKeyConstraint( - ["task_custom_field_definition_id"], - ["task_custom_field_definitions.id"], - ), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint( - "board_id", - "task_custom_field_definition_id", - name="uq_board_tcf_binding", - ), - ) - op.create_index( - "ix_board_task_custom_fields_board_id", - "board_task_custom_fields", - ["board_id"], - ) - op.create_index( - "ix_board_task_custom_fields_task_custom_field_definition_id", - "board_task_custom_fields", - ["task_custom_field_definition_id"], - ) - - op.create_table( - "task_custom_field_values", - sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("task_id", sa.Uuid(), nullable=False), - sa.Column("task_custom_field_definition_id", sa.Uuid(), nullable=False), - sa.Column("value", sa.JSON(), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(["task_id"], ["tasks.id"]), - sa.ForeignKeyConstraint( - ["task_custom_field_definition_id"], - ["task_custom_field_definitions.id"], - ), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint( - "task_id", - "task_custom_field_definition_id", - name="uq_tcf_values_task_def", - ), - ) - op.create_index( - "ix_task_custom_field_values_task_id", - "task_custom_field_values", - ["task_id"], - ) - op.create_index( - "ix_task_custom_field_values_task_custom_field_definition_id", - "task_custom_field_values", - ["task_custom_field_definition_id"], - ) - - -def downgrade() -> None: - """Drop task custom field tables.""" - op.drop_table("task_custom_field_values") - op.drop_table("board_task_custom_fields") - op.drop_table("task_custom_field_definitions") diff --git a/backend/migrations/versions/c2e9f1a6d4b8_add_board_pending_approval_status_gate.py b/backend/migrations/versions/c2e9f1a6d4b8_add_board_pending_approval_status_gate.py deleted file mode 100644 index f1f687f..0000000 --- a/backend/migrations/versions/c2e9f1a6d4b8_add_board_pending_approval_status_gate.py +++ /dev/null @@ -1,55 +0,0 @@ -"""add board rule toggles - -Revision ID: c2e9f1a6d4b8 -Revises: e2f9c6b4a1d3 -Create Date: 2026-02-12 23:55:00.000000 - -""" - -from __future__ import annotations - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = "c2e9f1a6d4b8" -down_revision = "e2f9c6b4a1d3" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - op.add_column( - "boards", - sa.Column( - "require_approval_for_done", - sa.Boolean(), - nullable=False, - server_default=sa.true(), - ), - ) - op.add_column( - "boards", - sa.Column( - "require_review_before_done", - sa.Boolean(), - nullable=False, - server_default=sa.false(), - ), - ) - op.add_column( - "boards", - sa.Column( - "block_status_changes_with_pending_approval", - sa.Boolean(), - nullable=False, - server_default=sa.false(), - ), - ) - - -def downgrade() -> None: - op.drop_column("boards", "block_status_changes_with_pending_approval") - op.drop_column("boards", "require_review_before_done") - op.drop_column("boards", "require_approval_for_done") diff --git a/backend/migrations/versions/c3b58a391f2e_add_boards_description.py b/backend/migrations/versions/c3b58a391f2e_add_boards_description.py deleted file mode 100644 index 6c598da..0000000 --- a/backend/migrations/versions/c3b58a391f2e_add_boards_description.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Add description field to boards. - -Revision ID: c3b58a391f2e -Revises: b308f2876359 -Create Date: 2026-02-11 00:00:00.000000 - -""" - -from __future__ import annotations - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision = "c3b58a391f2e" -down_revision = "b308f2876359" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - """Add required board description column.""" - op.add_column( - "boards", - sa.Column( - "description", - sa.String(), - nullable=False, - server_default="", - ), - ) - op.alter_column("boards", "description", server_default=None) - - -def downgrade() -> None: - """Remove board description column.""" - op.drop_column("boards", "description") diff --git a/backend/migrations/versions/c9d7e9b6a4f2_add_skills_marketplace_tables.py b/backend/migrations/versions/c9d7e9b6a4f2_add_skills_marketplace_tables.py deleted file mode 100644 index f21e174..0000000 --- a/backend/migrations/versions/c9d7e9b6a4f2_add_skills_marketplace_tables.py +++ /dev/null @@ -1,290 +0,0 @@ -"""add skills marketplace tables - -Revision ID: c9d7e9b6a4f2 -Revises: b6f4c7d9e1a2 -Create Date: 2026-02-13 00:00:00.000000 - -""" - -from __future__ import annotations - -import sqlalchemy as sa -import sqlmodel -from alembic import op - -# revision identifiers, used by Alembic. -revision = "c9d7e9b6a4f2" -down_revision = "b05c7b628636" -branch_labels = None -depends_on = None - - -def _has_table(table_name: str) -> bool: - return sa.inspect(op.get_bind()).has_table(table_name) - - -def _has_column(table_name: str, column_name: str) -> bool: - if not _has_table(table_name): - return False - columns = sa.inspect(op.get_bind()).get_columns(table_name) - return any(column["name"] == column_name for column in columns) - - -def _has_index(table_name: str, index_name: str) -> bool: - if not _has_table(table_name): - return False - indexes = sa.inspect(op.get_bind()).get_indexes(table_name) - return any(index["name"] == index_name for index in indexes) - - -def _has_constraint(table_name: str, constraint_name: str) -> bool: - if not _has_table(table_name): - return False - constraints = sa.inspect(op.get_bind()).get_check_constraints(table_name) - return any(constraint["name"] == constraint_name for constraint in constraints) - - -def upgrade() -> None: - if not _has_table("marketplace_skills"): - op.create_table( - "marketplace_skills", - sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("organization_id", sa.Uuid(), nullable=False), - sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column("category", sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column("risk", sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column("source", sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column("source_url", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column( - "metadata", - sa.JSON(), - nullable=False, - server_default=sa.text("'{}'"), - ), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint( - ["organization_id"], - ["organizations.id"], - ), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint( - "organization_id", - "source_url", - name="uq_marketplace_skills_org_source_url", - ), - ) - if not _has_column("marketplace_skills", "metadata"): - op.add_column( - "marketplace_skills", - sa.Column( - "metadata", - sa.JSON(), - nullable=False, - server_default=sa.text("'{}'"), - ), - ) - if _has_column("marketplace_skills", "resolution_metadata") and not _has_column( - "marketplace_skills", "metadata", - ): - op.execute( - sa.text( - "UPDATE marketplace_skills SET metadata = resolution_metadata WHERE resolution_metadata IS NOT NULL" - ) - ) - elif _has_column("marketplace_skills", "path_metadata") and not _has_column( - "marketplace_skills", "metadata" - ): - op.execute( - sa.text( - "UPDATE marketplace_skills SET metadata = path_metadata WHERE path_metadata IS NOT NULL" - ) - ) - - marketplace_org_idx = op.f("ix_marketplace_skills_organization_id") - if not _has_index("marketplace_skills", marketplace_org_idx): - op.create_index( - marketplace_org_idx, - "marketplace_skills", - ["organization_id"], - unique=False, - ) - - if not _has_table("gateway_installed_skills"): - op.create_table( - "gateway_installed_skills", - sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("gateway_id", sa.Uuid(), nullable=False), - sa.Column("skill_id", sa.Uuid(), nullable=False), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint( - ["gateway_id"], - ["gateways.id"], - ondelete="CASCADE", - ), - sa.ForeignKeyConstraint( - ["skill_id"], - ["marketplace_skills.id"], - ), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint( - "gateway_id", - "skill_id", - name="uq_gateway_installed_skills_gateway_id_skill_id", - ), - ) - - gateway_id_idx = op.f("ix_gateway_installed_skills_gateway_id") - if not _has_index("gateway_installed_skills", gateway_id_idx): - op.create_index( - gateway_id_idx, - "gateway_installed_skills", - ["gateway_id"], - unique=False, - ) - - gateway_skill_idx = op.f("ix_gateway_installed_skills_skill_id") - if not _has_index("gateway_installed_skills", gateway_skill_idx): - op.create_index( - gateway_skill_idx, - "gateway_installed_skills", - ["skill_id"], - unique=False, - ) - - if not _has_table("skill_packs"): - op.create_table( - "skill_packs", - sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("organization_id", sa.Uuid(), nullable=False), - sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column("source_url", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column( - "branch", - sqlmodel.sql.sqltypes.AutoString(), - nullable=False, - server_default=sa.text("'main'"), - ), - sa.Column( - "metadata", - sa.JSON(), - nullable=False, - server_default=sa.text("'{}'"), - ), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint( - ["organization_id"], - ["organizations.id"], - ), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint( - "organization_id", - "source_url", - name="uq_skill_packs_org_source_url", - ), - ) - if not _has_constraint( - "skill_packs", - "ck_skill_packs_source_url_github", - ): - op.create_check_constraint( - "ck_skill_packs_source_url_github", - "skill_packs", - "source_url LIKE 'https://github.com/%/%'", - ) - if not _has_column("skill_packs", "branch"): - op.add_column( - "skill_packs", - sa.Column( - "branch", - sqlmodel.sql.sqltypes.AutoString(), - nullable=False, - server_default=sa.text("'main'"), - ), - ) - if not _has_column("skill_packs", "metadata"): - op.add_column( - "skill_packs", - sa.Column( - "metadata", - sa.JSON(), - nullable=False, - server_default=sa.text("'{}'"), - ), - ) - if _has_column("skill_packs", "resolution_metadata") and not _has_column( - "skill_packs", "metadata" - ): - op.execute( - sa.text( - "UPDATE skill_packs SET metadata = resolution_metadata WHERE resolution_metadata IS NOT NULL" - ) - ) - elif _has_column("skill_packs", "path_metadata") and not _has_column( - "skill_packs", "metadata" - ): - op.execute( - sa.text( - "UPDATE skill_packs SET metadata = path_metadata WHERE path_metadata IS NOT NULL" - ) - ) - - skill_packs_org_idx = op.f("ix_skill_packs_organization_id") - if not _has_index("skill_packs", skill_packs_org_idx): - op.create_index( - skill_packs_org_idx, - "skill_packs", - ["organization_id"], - unique=False, - ) - - -def downgrade() -> None: - skill_pack_github_constraint = "ck_skill_packs_source_url_github" - if _has_constraint("skill_packs", skill_pack_github_constraint): - op.drop_constraint( - skill_pack_github_constraint, - "skill_packs", - type_="check", - ) - - skill_packs_org_idx = op.f("ix_skill_packs_organization_id") - if _has_index("skill_packs", skill_packs_org_idx): - op.drop_index( - skill_packs_org_idx, - table_name="skill_packs", - ) - - if _has_table("skill_packs"): - op.drop_table("skill_packs") - - gateway_skill_idx = op.f("ix_gateway_installed_skills_skill_id") - if _has_index("gateway_installed_skills", gateway_skill_idx): - op.drop_index( - gateway_skill_idx, - table_name="gateway_installed_skills", - ) - - gateway_id_idx = op.f("ix_gateway_installed_skills_gateway_id") - if _has_index("gateway_installed_skills", gateway_id_idx): - op.drop_index( - gateway_id_idx, - table_name="gateway_installed_skills", - ) - - if _has_table("gateway_installed_skills"): - op.drop_table("gateway_installed_skills") - - marketplace_org_idx = op.f("ix_marketplace_skills_organization_id") - if _has_index("marketplace_skills", marketplace_org_idx): - op.drop_index( - marketplace_org_idx, - table_name="marketplace_skills", - ) - - if _has_table("marketplace_skills"): - op.drop_table("marketplace_skills") diff --git a/backend/migrations/versions/d8c1e5a4f7b2_add_tags_and_assignments.py b/backend/migrations/versions/d8c1e5a4f7b2_add_tags_and_assignments.py deleted file mode 100644 index 4ebfd8c..0000000 --- a/backend/migrations/versions/d8c1e5a4f7b2_add_tags_and_assignments.py +++ /dev/null @@ -1,101 +0,0 @@ -"""add tags and tag assignments - -Revision ID: d8c1e5a4f7b2 -Revises: 99cd6df95f85, b4338be78eec -Create Date: 2026-02-12 16:05:00.000000 - -""" - -from __future__ import annotations - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision = "d8c1e5a4f7b2" -down_revision = ("99cd6df95f85", "b4338be78eec") -branch_labels = None -depends_on = None - - -def upgrade() -> None: - bind = op.get_bind() - inspector = sa.inspect(bind) - - if not inspector.has_table("tags"): - op.create_table( - "tags", - sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("organization_id", sa.Uuid(), nullable=False), - sa.Column("name", sa.String(), nullable=False), - sa.Column("slug", sa.String(), nullable=False), - sa.Column("color", sa.String(), nullable=False), - sa.Column("description", sa.String(), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(["organization_id"], ["organizations.id"]), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint( - "organization_id", - "slug", - name="uq_tags_organization_id_slug", - ), - ) - tag_indexes = {item.get("name") for item in inspector.get_indexes("tags")} - if op.f("ix_tags_organization_id") not in tag_indexes: - op.create_index( - op.f("ix_tags_organization_id"), - "tags", - ["organization_id"], - unique=False, - ) - if op.f("ix_tags_slug") not in tag_indexes: - op.create_index( - op.f("ix_tags_slug"), - "tags", - ["slug"], - unique=False, - ) - - if not inspector.has_table("tag_assignments"): - op.create_table( - "tag_assignments", - sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("task_id", sa.Uuid(), nullable=False), - sa.Column("tag_id", sa.Uuid(), nullable=False), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(["tag_id"], ["tags.id"]), - sa.ForeignKeyConstraint(["task_id"], ["tasks.id"]), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint( - "task_id", - "tag_id", - name="uq_tag_assignments_task_id_tag_id", - ), - ) - assignment_indexes = { - item.get("name") for item in inspector.get_indexes("tag_assignments") - } - if op.f("ix_tag_assignments_task_id") not in assignment_indexes: - op.create_index( - op.f("ix_tag_assignments_task_id"), - "tag_assignments", - ["task_id"], - unique=False, - ) - if op.f("ix_tag_assignments_tag_id") not in assignment_indexes: - op.create_index( - op.f("ix_tag_assignments_tag_id"), - "tag_assignments", - ["tag_id"], - unique=False, - ) - - -def downgrade() -> None: - op.drop_index(op.f("ix_tag_assignments_tag_id"), table_name="tag_assignments") - op.drop_index(op.f("ix_tag_assignments_task_id"), table_name="tag_assignments") - op.drop_table("tag_assignments") - op.drop_index(op.f("ix_tags_slug"), table_name="tags") - op.drop_index(op.f("ix_tags_organization_id"), table_name="tags") - op.drop_table("tags") diff --git a/backend/migrations/versions/e2f9c6b4a1d3_make_approval_confidence_float.py b/backend/migrations/versions/e2f9c6b4a1d3_make_approval_confidence_float.py deleted file mode 100644 index 6fc4e0b..0000000 --- a/backend/migrations/versions/e2f9c6b4a1d3_make_approval_confidence_float.py +++ /dev/null @@ -1,39 +0,0 @@ -"""make approval confidence float - -Revision ID: e2f9c6b4a1d3 -Revises: d8c1e5a4f7b2 -Create Date: 2026-02-12 20:00:00.000000 - -""" - -from __future__ import annotations - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = "e2f9c6b4a1d3" -down_revision = "d8c1e5a4f7b2" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - op.alter_column( - "approvals", - "confidence", - existing_type=sa.Integer(), - type_=sa.Float(), - existing_nullable=False, - ) - - -def downgrade() -> None: - op.alter_column( - "approvals", - "confidence", - existing_type=sa.Float(), - type_=sa.Integer(), - existing_nullable=False, - ) diff --git a/backend/migrations/versions/f4d2b649e93a_add_approval_task_links.py b/backend/migrations/versions/f4d2b649e93a_add_approval_task_links.py deleted file mode 100644 index 18d23dc..0000000 --- a/backend/migrations/versions/f4d2b649e93a_add_approval_task_links.py +++ /dev/null @@ -1,137 +0,0 @@ -"""add approval task links - -Revision ID: f4d2b649e93a -Revises: c3b58a391f2e -Create Date: 2026-02-11 20:05:00.000000 - -""" - -from __future__ import annotations - -from uuid import uuid4 - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision = "f4d2b649e93a" -down_revision = "c3b58a391f2e" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - bind = op.get_bind() - inspector = sa.inspect(bind) - - if not inspector.has_table("approval_task_links"): - op.create_table( - "approval_task_links", - sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("approval_id", sa.Uuid(), nullable=False), - sa.Column("task_id", sa.Uuid(), nullable=False), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(["approval_id"], ["approvals.id"]), - sa.ForeignKeyConstraint(["task_id"], ["tasks.id"]), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint( - "approval_id", - "task_id", - name="uq_approval_task_links_approval_id_task_id", - ), - ) - else: - target_unique_columns = ("approval_id", "task_id") - unique_constraints = inspector.get_unique_constraints("approval_task_links") - has_target_unique = False - for item in unique_constraints: - columns = tuple(item.get("column_names") or ()) - if columns == target_unique_columns: - has_target_unique = True - break - if not has_target_unique: - op.create_unique_constraint( - "uq_approval_task_links_approval_id_task_id", - "approval_task_links", - ["approval_id", "task_id"], - ) - - indexes = inspector.get_indexes("approval_task_links") - has_approval_id_index = any( - tuple(item.get("column_names") or ()) == ("approval_id",) for item in indexes - ) - has_task_id_index = any(tuple(item.get("column_names") or ()) == ("task_id",) for item in indexes) - if not has_approval_id_index: - op.create_index( - op.f("ix_approval_task_links_approval_id"), - "approval_task_links", - ["approval_id"], - unique=False, - ) - if not has_task_id_index: - op.create_index( - op.f("ix_approval_task_links_task_id"), - "approval_task_links", - ["task_id"], - unique=False, - ) - - link_table = sa.table( - "approval_task_links", - sa.column("id", sa.Uuid()), - sa.column("approval_id", sa.Uuid()), - sa.column("task_id", sa.Uuid()), - sa.column("created_at", sa.DateTime()), - ) - approvals_table = sa.table( - "approvals", - sa.column("id", sa.Uuid()), - sa.column("task_id", sa.Uuid()), - sa.column("created_at", sa.DateTime()), - ) - rows = list( - bind.execute( - sa.select( - approvals_table.c.id, - approvals_table.c.task_id, - approvals_table.c.created_at, - ) - .select_from(approvals_table) - .where(approvals_table.c.task_id.is_not(None)), - ), - ) - existing_links = { - (approval_id, task_id) - for approval_id, task_id in list( - bind.execute( - sa.select( - sa.column("approval_id"), - sa.column("task_id"), - ).select_from(sa.table("approval_task_links")), - ), - ) - } - missing_rows = [ - (approval_id, task_id, created_at) - for approval_id, task_id, created_at in rows - if (approval_id, task_id) not in existing_links - ] - if missing_rows: - op.bulk_insert( - link_table, - [ - { - "id": uuid4(), - "approval_id": approval_id, - "task_id": task_id, - "created_at": created_at, - } - for approval_id, task_id, created_at in missing_rows - ], - ) - - -def downgrade() -> None: - op.drop_index(op.f("ix_approval_task_links_task_id"), table_name="approval_task_links") - op.drop_index(op.f("ix_approval_task_links_approval_id"), table_name="approval_task_links") - op.drop_table("approval_task_links") diff --git a/backend/migrations/versions/f5a2b3c4d5e6_add_forgejo_models.py b/backend/migrations/versions/f5a2b3c4d5e6_add_forgejo_models.py index 2f26a99..7df035b 100644 --- a/backend/migrations/versions/f5a2b3c4d5e6_add_forgejo_models.py +++ b/backend/migrations/versions/f5a2b3c4d5e6_add_forgejo_models.py @@ -1,7 +1,7 @@ -"""add forgejo models +"""add forgejo models and issues tables Revision ID: f5a2b3c4d5e6 -Revises: f1b2c3d4e5a6 +Revises: a9b1c2d3e4f7 Create Date: 2026-05-19 00:00:00.000000 """ @@ -61,7 +61,37 @@ def upgrade() -> None: op.create_index("ix_forgejo_repos_org_id", "forgejo_repositories", ["organization_id"]) op.create_index("ix_forgejo_repos_conn_id", "forgejo_repositories", ["connection_id"]) + if not inspector.has_table("forgejo_issues"): + op.create_table( + "forgejo_issues", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("organization_id", sa.Uuid(), nullable=False), + sa.Column("repository_id", sa.Uuid(), nullable=False), + sa.Column("forgejo_issue_number", sa.Integer(), nullable=False), + sa.Column("title", sa.String(), nullable=False), + sa.Column("body_preview", sa.String(1000), nullable=True), + sa.Column("state", sa.String(), nullable=False, server_default="open"), + sa.Column("is_pull_request", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("labels", sa.JSON(), nullable=True), + sa.Column("assignees", sa.JSON(), nullable=True), + sa.Column("author", sa.String(), nullable=False), + sa.Column("html_url", sa.String(), nullable=False), + sa.Column("forgejo_created_at", sa.DateTime(), nullable=False), + sa.Column("forgejo_updated_at", sa.DateTime(), nullable=False), + sa.Column("forgejo_closed_at", sa.DateTime(), nullable=True), + sa.Column("last_synced_at", sa.DateTime(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint(["organization_id"], ["organizations.id"]), + sa.ForeignKeyConstraint(["repository_id"], ["forgejo_repositories.id"]), + ) + op.create_index("ix_forgejo_issues_org_id", "forgejo_issues", ["organization_id"]) + op.create_index("ix_forgejo_issues_repo_id", "forgejo_issues", ["repository_id"]) + op.create_index("ix_forgejo_issues_repo_number", "forgejo_issues", ["repository_id", "forgejo_issue_number"], unique=True) + def downgrade() -> None: + op.drop_table("forgejo_issues") op.drop_table("forgejo_repositories") op.drop_table("forgejo_connections") \ No newline at end of file diff --git a/backend/migrations/versions/fa6e83f8d9a1_add_board_webhooks_and_payloads.py b/backend/migrations/versions/fa6e83f8d9a1_add_board_webhooks_and_payloads.py deleted file mode 100644 index 7bf9325..0000000 --- a/backend/migrations/versions/fa6e83f8d9a1_add_board_webhooks_and_payloads.py +++ /dev/null @@ -1,130 +0,0 @@ -"""Add board webhook configuration and payload storage tables. - -Revision ID: fa6e83f8d9a1 -Revises: c2e9f1a6d4b8 -Create Date: 2026-02-13 00:10:00.000000 - -""" - -from __future__ import annotations - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision = "fa6e83f8d9a1" -down_revision = "c2e9f1a6d4b8" -branch_labels = None -depends_on = None - - -def _index_names(inspector: sa.Inspector, table_name: str) -> set[str]: - return {item["name"] for item in inspector.get_indexes(table_name)} - - -def upgrade() -> None: - """Create board webhook and payload capture tables.""" - bind = op.get_bind() - inspector = sa.inspect(bind) - - if not inspector.has_table("board_webhooks"): - op.create_table( - "board_webhooks", - sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("board_id", sa.Uuid(), nullable=False), - sa.Column("description", sa.String(), nullable=False), - sa.Column("enabled", sa.Boolean(), nullable=False), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(["board_id"], ["boards.id"]), - sa.PrimaryKeyConstraint("id"), - ) - - inspector = sa.inspect(bind) - webhook_indexes = _index_names(inspector, "board_webhooks") - if "ix_board_webhooks_board_id" not in webhook_indexes: - op.create_index("ix_board_webhooks_board_id", "board_webhooks", ["board_id"]) - if "ix_board_webhooks_enabled" not in webhook_indexes: - op.create_index("ix_board_webhooks_enabled", "board_webhooks", ["enabled"]) - - if not inspector.has_table("board_webhook_payloads"): - op.create_table( - "board_webhook_payloads", - sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("board_id", sa.Uuid(), nullable=False), - sa.Column("webhook_id", sa.Uuid(), nullable=False), - sa.Column("payload", sa.JSON(), nullable=True), - sa.Column("headers", sa.JSON(), nullable=True), - sa.Column("source_ip", sa.String(), nullable=True), - sa.Column("content_type", sa.String(), nullable=True), - sa.Column("received_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(["board_id"], ["boards.id"]), - sa.ForeignKeyConstraint(["webhook_id"], ["board_webhooks.id"]), - sa.PrimaryKeyConstraint("id"), - ) - - inspector = sa.inspect(bind) - payload_indexes = _index_names(inspector, "board_webhook_payloads") - if "ix_board_webhook_payloads_board_id" not in payload_indexes: - op.create_index( - "ix_board_webhook_payloads_board_id", - "board_webhook_payloads", - ["board_id"], - ) - if "ix_board_webhook_payloads_webhook_id" not in payload_indexes: - op.create_index( - "ix_board_webhook_payloads_webhook_id", - "board_webhook_payloads", - ["webhook_id"], - ) - if "ix_board_webhook_payloads_received_at" not in payload_indexes: - op.create_index( - "ix_board_webhook_payloads_received_at", - "board_webhook_payloads", - ["received_at"], - ) - if "ix_board_webhook_payloads_board_webhook_received_at" not in payload_indexes: - op.create_index( - "ix_board_webhook_payloads_board_webhook_received_at", - "board_webhook_payloads", - ["board_id", "webhook_id", "received_at"], - ) - - -def downgrade() -> None: - """Drop board webhook and payload capture tables.""" - bind = op.get_bind() - inspector = sa.inspect(bind) - - if inspector.has_table("board_webhook_payloads"): - payload_indexes = _index_names(inspector, "board_webhook_payloads") - if "ix_board_webhook_payloads_board_webhook_received_at" in payload_indexes: - op.drop_index( - "ix_board_webhook_payloads_board_webhook_received_at", - table_name="board_webhook_payloads", - ) - if "ix_board_webhook_payloads_received_at" in payload_indexes: - op.drop_index( - "ix_board_webhook_payloads_received_at", - table_name="board_webhook_payloads", - ) - if "ix_board_webhook_payloads_webhook_id" in payload_indexes: - op.drop_index( - "ix_board_webhook_payloads_webhook_id", - table_name="board_webhook_payloads", - ) - if "ix_board_webhook_payloads_board_id" in payload_indexes: - op.drop_index( - "ix_board_webhook_payloads_board_id", - table_name="board_webhook_payloads", - ) - op.drop_table("board_webhook_payloads") - - inspector = sa.inspect(bind) - if inspector.has_table("board_webhooks"): - webhook_indexes = _index_names(inspector, "board_webhooks") - if "ix_board_webhooks_enabled" in webhook_indexes: - op.drop_index("ix_board_webhooks_enabled", table_name="board_webhooks") - if "ix_board_webhooks_board_id" in webhook_indexes: - op.drop_index("ix_board_webhooks_board_id", table_name="board_webhooks") - op.drop_table("board_webhooks") diff --git a/frontend/src/app/git-projects/connections/[connectionId]/edit/page.tsx b/frontend/src/app/git-projects/connections/[connectionId]/edit/page.tsx new file mode 100644 index 0000000..398b85a --- /dev/null +++ b/frontend/src/app/git-projects/connections/[connectionId]/edit/page.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; + +import { Button } from "@/components/ui/button"; +import { useAuth } from "@/auth/clerk"; +import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; +import { ForgejoConnectionForm } from "@/components/git/ForgejoConnectionForm"; +import { + getForgejoConnection, + updateForgejoConnection, + deleteForgejoConnection, + type ForgejoConnectionUpdate, +} from "@/lib/api-forgejo"; + +interface RouteParams { + connectionId: string; +} + +interface ConnectionData { + name: string; + base_url: string; + active: boolean; + has_token: boolean; + token_last_eight: string | null; + id: string; +} + +export default function ForgejoConnectionsEditPage({ params }: { params: RouteParams }) { + const router = useRouter(); + const auth = useAuth(); + + const [connection, setConnection] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchConnection = async () => { + try { + setIsLoading(true); + const data = await getForgejoConnection(params.connectionId); + setConnection(data); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load connection"); + } finally { + setIsLoading(false); + } + }; + + if (auth.isSignedIn) { + fetchConnection(); + } + }, [params.connectionId, auth.isSignedIn]); + + const handleSubmit = async (values: ForgejoConnectionUpdate) => { + try { + const connection = await updateForgejoConnection(params.connectionId, values); + console.log("Connection updated:", connection); + router.push("/git-projects/connections"); + } catch (err) { + alert(err instanceof Error ? err.message : "Failed to update connection"); + } + }; + + const handleDelete = async () => { + if ( + confirm(`Are you sure you want to delete "${connection?.name}"? This action cannot be undone.`) + ) { + try { + await deleteForgejoConnection(params.connectionId); + console.log("Connection deleted"); + router.push("/git-projects/connections"); + } catch (err) { + alert(err instanceof Error ? err.message : "Failed to delete connection"); + } + } + }; + + if (isLoading) { + return ( + +

Loading connection...

+
+ ); + } + + if (error || !connection) { + return ( + +

{error || "Connection not found"}

+
+ ); + } + + const defaultValues = { + name: connection.name, + base_url: connection.base_url, + token: connection.has_token ? "••••" + (connection.token_last_eight || "") : "", + }; + + return ( + +
+ +
+

Danger Zone

+

+ Deleting a connection will remove all associated repositories and data. +

+ +
+
+
+ ); +} diff --git a/frontend/src/app/git-projects/connections/new/page.tsx b/frontend/src/app/git-projects/connections/new/page.tsx new file mode 100644 index 0000000..b852730 --- /dev/null +++ b/frontend/src/app/git-projects/connections/new/page.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { useRouter } from "next/navigation"; + +import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; +import { ForgejoConnectionForm } from "@/components/git/ForgejoConnectionForm"; +import { createForgejoConnection, type ForgejoConnectionCreate } from "@/lib/api-forgejo"; + +export default function ForgejoConnectionsNewPage() { + const router = useRouter(); + + const handleSubmit = async (values: ForgejoConnectionCreate) => { + try { + const connection = await createForgejoConnection(values); + alert(`Connection "${connection.name}" created successfully`); + router.push("/git-projects/connections"); + } catch (err) { + alert(err instanceof Error ? err.message : "Failed to create connection"); + } + }; + + return ( + +
+ +
+
+ ); +} diff --git a/frontend/src/app/git-projects/connections/page.tsx b/frontend/src/app/git-projects/connections/page.tsx new file mode 100644 index 0000000..96ebbb2 --- /dev/null +++ b/frontend/src/app/git-projects/connections/page.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; + +import { Button } from "@/components/ui/button"; +import { useAuth } from "@/auth/clerk"; +import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; +import { ForgejoConnectionsTable } from "@/components/git/ForgejoConnectionsTable"; +import { + getForgejoConnections, + deleteForgejoConnection, + type ForgejoConnection, +} from "@/lib/api-forgejo"; + +export default function ForgejoConnectionsPage() { + const router = useRouter(); + const auth = useAuth(); + + const [connections, setConnections] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchConnections = async () => { + try { + setIsLoading(true); + const data = await getForgejoConnections(); + setConnections(data); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load connections"); + } finally { + setIsLoading(false); + } + }; + + if (auth.isSignedIn) { + fetchConnections(); + } + }, [auth.isSignedIn, auth.getToken]); + + const handleDelete = async (connection: ForgejoConnection) => { + if ( + confirm(`Are you sure you want to delete "${connection.name}"? This action cannot be undone.`) + ) { + try { + await deleteForgejoConnection(connection.id); + setConnections((prev) => prev.filter((c) => c.id !== connection.id)); + alert("Connection deleted successfully"); + } catch (err) { + alert(err instanceof Error ? err.message : "Failed to delete connection"); + } + } + }; + + return ( + +
+
+

Connections

+ +
+
+ {error ? ( +
+

{error}

+
+ ) : ( + + )} +
+
+
+ ); +} diff --git a/frontend/src/app/git-projects/repositories/[repositoryId]/edit/page.tsx b/frontend/src/app/git-projects/repositories/[repositoryId]/edit/page.tsx new file mode 100644 index 0000000..7a405a1 --- /dev/null +++ b/frontend/src/app/git-projects/repositories/[repositoryId]/edit/page.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; + +import { Button } from "@/components/ui/button"; +import { useAuth } from "@/auth/clerk"; +import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; +import { ForgejoRepositoryForm } from "@/components/git/ForgejoRepositoryForm"; +import { + getForgejoRepository, + updateForgejoRepository, + deleteForgejoRepository, + type ForgejoRepositoryUpdate, +} from "@/lib/api-forgejo"; + +interface RouteParams { + repositoryId: string; +} + +interface RepositoryData { + id: string; + connection_id: string; + owner: string; + repo: string; + display_name: string; + default_branch: string; + active: boolean; + connection: { + id: string; + name: string; + base_url: string; + }; +} + +export default function ForgejoRepositoriesEditPage({ params }: { params: RouteParams }) { + const router = useRouter(); + const auth = useAuth(); + + const [repository, setRepository] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchRepository = async () => { + try { + setIsLoading(true); + const data = await getForgejoRepository(params.repositoryId); + setRepository(data); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load repository"); + } finally { + setIsLoading(false); + } + }; + + if (auth.isSignedIn) { + fetchRepository(); + } + }, [params.repositoryId, auth.isSignedIn]); + + const handleSubmit = async (values: ForgejoRepositoryUpdate) => { + try { + const repository = await updateForgejoRepository(params.repositoryId, values); + console.log("Repository updated:", repository); + router.push("/git-projects/repositories"); + } catch (err) { + alert(err instanceof Error ? err.message : "Failed to update repository"); + } + }; + + const handleDelete = async () => { + if ( + confirm(`Are you sure you want to delete "${repository?.display_name || repository?.repo}"? This action cannot be undone.`) + ) { + try { + await deleteForgejoRepository(params.repositoryId); + console.log("Repository deleted"); + router.push("/git-projects/repositories"); + } catch (err) { + alert(err instanceof Error ? err.message : "Failed to delete repository"); + } + } + }; + + if (isLoading) { + return ( + +

Loading repository...

+
+ ); + } + + if (error || !repository) { + return ( + +

{error || "Repository not found"}

+
+ ); + } + + const defaultValues = { + connection_id: repository.connection_id, + owner: repository.owner, + repo: repository.repo, + display_name: repository.display_name, + default_branch: repository.default_branch, + }; + + return ( + +
+ +
+

Danger Zone

+

+ Deleting a repository will remove all associated data including issues and pull requests. +

+ +
+
+
+ ); +} diff --git a/frontend/src/app/git-projects/repositories/new/page.tsx b/frontend/src/app/git-projects/repositories/new/page.tsx new file mode 100644 index 0000000..9218125 --- /dev/null +++ b/frontend/src/app/git-projects/repositories/new/page.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { useRouter } from "next/navigation"; + +import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; +import { ForgejoRepositoryForm } from "@/components/git/ForgejoRepositoryForm"; +import { createForgejoRepository, type ForgejoRepositoryCreate } from "@/lib/api-forgejo"; + +export default function ForgejoRepositoriesNewPage() { + const router = useRouter(); + + const handleSubmit = async (values: ForgejoRepositoryCreate) => { + try { + const repository = await createForgejoRepository(values); + alert(`Repository "${repository.display_name || repository.repo}" added successfully`); + router.push("/git-projects/repositories"); + } catch (err) { + alert(err instanceof Error ? err.message : "Failed to add repository"); + } + }; + + return ( + +
+ +
+
+ ); +} diff --git a/frontend/src/app/git-projects/repositories/page.tsx b/frontend/src/app/git-projects/repositories/page.tsx new file mode 100644 index 0000000..9a0f9d9 --- /dev/null +++ b/frontend/src/app/git-projects/repositories/page.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; + +import { Button } from "@/components/ui/button"; +import { useAuth } from "@/auth/clerk"; +import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; +import { ForgejoRepositoriesTable } from "@/components/git/ForgejoRepositoriesTable"; +import { + getForgejoRepositories, + deleteForgejoRepository, + type ForgejoRepository, +} from "@/lib/api-forgejo"; + +export default function ForgejoRepositoriesPage() { + const router = useRouter(); + const auth = useAuth(); + + const [repositories, setRepositories] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchRepositories = async () => { + try { + setIsLoading(true); + const data = await getForgejoRepositories(); + setRepositories(data); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load repositories"); + } finally { + setIsLoading(false); + } + }; + + if (auth.isSignedIn) { + fetchRepositories(); + } + }, [auth.isSignedIn, auth.getToken]); + + const handleDelete = async (repository: ForgejoRepository) => { + if ( + confirm(`Are you sure you want to delete "${repository.display_name || repository.repo}"? This action cannot be undone.`) + ) { + try { + await deleteForgejoRepository(repository.id); + setRepositories((prev) => prev.filter((r) => r.id !== repository.id)); + alert("Repository deleted successfully"); + } catch (err) { + alert(err instanceof Error ? err.message : "Failed to delete repository"); + } + } + }; + + return ( + +
+
+

Repositories

+ +
+
+ {error ? ( +
+

{error}

+
+ ) : ( + + )} +
+
+
+ ); +} diff --git a/frontend/src/components/git/ForgejoConnectionForm.tsx b/frontend/src/components/git/ForgejoConnectionForm.tsx new file mode 100644 index 0000000..7ec6cf2 --- /dev/null +++ b/frontend/src/components/git/ForgejoConnectionForm.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Loader2 } from "lucide-react"; + +import type { ForgejoConnectionCreate } from "@/lib/api-forgejo"; + +interface ForgejoConnectionFormProps { + defaultValues?: Partial; + onSubmit: (values: ForgejoConnectionCreate) => Promise; + isSubmitting?: boolean; + title?: string; + description?: string; + submitLabel?: string; +} + +export interface ForgejoConnectionFormValues { + name: string; + base_url: string; + token: string; +} + +export function ForgejoConnectionForm({ + defaultValues = {}, + onSubmit, + isSubmitting = false, + title = "Forgejo Connection", + description = "Connect a Forgejo instance to track issues and pull requests.", + submitLabel = "Save Connection", +}: ForgejoConnectionFormProps) { + const [error, setError] = useState(null); + const [name, setName] = useState(defaultValues.name || ""); + const [baseUrl, setBaseUrl] = useState(defaultValues.base_url || ""); + const [token, setToken] = useState(defaultValues.token || ""); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + + try { + await onSubmit({ + name, + base_url: baseUrl, + token, + }); + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred"); + } + } + + return ( +
+
+

{title}

+ {description &&

{description}

} + + {error && ( +
+

Configuration Error

+

{error}

+
+ )} + +
+ + setName(e.target.value)} + placeholder="e.g., Dream Forgejo" + disabled={isSubmitting} + required + /> +

+ A memorable name for this Forgejo connection. +

+
+ +
+ + setBaseUrl(e.target.value)} + placeholder="https://dream.scheller.ltd" + disabled={isSubmitting} + required + /> +

+ The base URL of your Forgejo instance (without trailing slash). +

+
+ +
+ + setToken(e.target.value)} + placeholder="••••••••" + disabled={isSubmitting} + required + /> +

+ Forgejo personal access token with repo permissions. Token is stored securely and never displayed. +

+
+
+ +
+ + +
+
+ ); +} diff --git a/frontend/src/components/git/ForgejoConnectionsTable.tsx b/frontend/src/components/git/ForgejoConnectionsTable.tsx new file mode 100644 index 0000000..bee4cf5 --- /dev/null +++ b/frontend/src/components/git/ForgejoConnectionsTable.tsx @@ -0,0 +1,240 @@ +"use client"; + +import { useState } from "react"; + +import { + type ColumnDef, + type Table as TableType, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { DataTable } from "@/components/tables/DataTable"; +import DropdownSelect from "@/components/ui/dropdown-select"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; + +import type { ForgejoConnection } from "@/lib/api-forgejo"; + +interface ConnectionsTableProps { + connections: ForgejoConnection[]; + isLoading: boolean; + onEdit?: (connection: ForgejoConnection) => void; + onDelete?: (connection: ForgejoConnection) => void; +} + +export function ForgejoConnectionsTable({ + connections, + isLoading, + onEdit, + onDelete, +}: ConnectionsTableProps) { + const table = useReactTable({ + data: connections, + columns: columns, + getCoreRowModel: getCoreRowModel(), + }); + + return ( + + + + + ), + title: "No Forgejo connections yet", + description: + "Connect a Forgejo instance to start tracking issues and pull requests from your Git projects.", + actionHref: "/git-projects/connections/new", + actionLabel: "Add connection", + }} + rowActions={{ + getEditHref: (row) => `/git-projects/connections/${row.id}/edit`, + onDelete: onDelete ?? undefined, + }} + /> + ); +} + +const columns: ColumnDef[] = [ + { + accessorKey: "name", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => ( +
+ {row.original.name} + {row.original.base_url} +
+ ), + }, + { + accessorKey: "status", + header: "Status", + cell: ({ row }) => { + const isActive = row.original.active; + return ( + + {isActive ? "Active" : "Inactive"} + + ); + }, + }, + { + accessorKey: "hasToken", + header: "Auth", + cell: ({ row }) => { + const hasToken = row.original.has_token; + const tokenLastEight = row.original.token_last_eight; + return ( +
+ + {hasToken ? "Configured" : "Missing"} + + {tokenLastEight && hasToken && ( + ••••{tokenLastEight} + )} +
+ ); + }, + }, + { + id: "actions", + cell: ({ row }) => , + }, +]; + +function ActionsCell({ connection }: { connection: ForgejoConnection }) { + const options = [ + { value: "edit", label: "Edit" }, + { value: "delete", label: "Delete", icon: (props: { className?: string }) => ( + + + + + + )}, + ]; + + const handleSelect = (value: string) => { + if (value === "edit") { + // Navigate to edit page + } else if (value === "delete") { + if (confirm(`Are you sure you want to delete "${connection.name}"?`)) { + // Trigger delete via parent + } + } + }; + + return ( + + ); +} + +// Filter component +export function ConnectionsTableFilter({ + value, + onChange, +}: { + value: string; + onChange: (value: string) => void; +}) { + return ( + onChange(e.target.value)} + className="h-8 w-[150px] lg:w-[250px]" + /> + ); +} + +// Column toggle component +export function ConnectionsTableToggle({ + table, +}: { + table: TableType; +}) { + return ( +
+
+ Show: + + +
+
+ {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + return ( + + ); + })} +
+
+ ); +} diff --git a/frontend/src/components/git/ForgejoRepositoriesTable.tsx b/frontend/src/components/git/ForgejoRepositoriesTable.tsx new file mode 100644 index 0000000..5a6ed9b --- /dev/null +++ b/frontend/src/components/git/ForgejoRepositoriesTable.tsx @@ -0,0 +1,267 @@ +"use client"; + +import { useState } from "react"; + +import { + type ColumnDef, + type Table as TableType, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { DataTable } from "@/components/tables/DataTable"; +import DropdownSelect from "@/components/ui/dropdown-select"; +import { cn } from "@/lib/utils"; + +import type { ForgejoRepository } from "@/lib/api-forgejo"; + +interface RepositoriesTableProps { + repositories: ForgejoRepository[]; + isLoading: boolean; + onEdit?: (repository: ForgejoRepository) => void; + onDelete?: (repository: ForgejoRepository) => void; +} + +export function ForgejoRepositoriesTable({ + repositories, + isLoading, + onEdit, + onDelete, +}: RepositoriesTableProps) { + const table = useReactTable({ + data: repositories, + columns: columns, + getCoreRowModel: getCoreRowModel(), + }); + + return ( + + + + + ), + title: "No repositories tracked yet", + description: + "Add repositories to start tracking issues and pull requests from your Git projects.", + actionHref: "/git-projects/repositories/new", + actionLabel: "Add repository", + }} + rowActions={{ + getEditHref: (row) => `/git-projects/repositories/${row.id}/edit`, + onDelete: onDelete ?? undefined, + }} + /> + ); +} + +const columns: ColumnDef[] = [ + { + accessorKey: "displayName", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const repo = row.original; + return ( +
+ {repo.display_name || `${repo.owner}/${repo.repo}`} + + {repo.owner}/{repo.repo} • {repo.connection?.name} + +
+ ); + }, + }, + { + accessorKey: "connection", + header: "Connection", + cell: ({ row }) => { + const connection = row.original.connection; + return ( +
+ {connection?.name} + {connection?.base_url} +
+ ); + }, + }, + { + accessorKey: "status", + header: "Status", + cell: ({ row }) => { + const isActive = row.original.active; + return ( + + {isActive ? "Active" : "Inactive"} + + ); + }, + }, + { + accessorKey: "lastSync", + header: "Last Sync", + cell: ({ row }) => { + const lastSyncAt = row.original.last_sync_at; + const lastSyncError = row.original.last_sync_error; + + if (!lastSyncAt) { + return Never; + } + + const date = new Date(lastSyncAt); + const isRecent = new Date().getTime() - date.getTime() < 24 * 60 * 60 * 1000; // Within 24 hours + + return ( +
+ + {date.toLocaleDateString()} + + {date.toLocaleTimeString()} + {lastSyncError && ( + Error: {lastSyncError.substring(0, 50)}... + )} +
+ ); + }, + }, + { + id: "actions", + cell: ({ row }) => , + }, +]; + +function ActionsCell({ repository }: { repository: ForgejoRepository }) { + const options = [ + { value: "edit", label: "Edit" }, + { value: "delete", label: "Delete", icon: (props: { className?: string }) => ( + + + + + + )}, + ]; + + const handleSelect = (value: string) => { + if (value === "edit") { + // Navigate to edit page + } else if (value === "delete") { + if (confirm(`Are you sure you want to delete "${repository.display_name || repository.repo}"?`)) { + // Trigger delete via parent + } + } + }; + + return ( + + ); +} + +// Filter component +export function RepositoriesTableFilter({ + value, + onChange, +}: { + value: string; + onChange: (value: string) => void; +}) { + return ( + onChange(e.target.value)} + className="h-8 w-[150px] rounded-md border border-slate-200 px-3 py-1 text-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 lg:w-[250px]" + /> + ); +} + +// Column toggle component +export function RepositoriesTableToggle({ + table, +}: { + table: TableType; +}) { + return ( +
+
+ Show: + + +
+
+ {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + return ( + + ); + })} +
+
+ ); +} diff --git a/frontend/src/components/git/ForgejoRepositoryForm.tsx b/frontend/src/components/git/ForgejoRepositoryForm.tsx new file mode 100644 index 0000000..b09197d --- /dev/null +++ b/frontend/src/components/git/ForgejoRepositoryForm.tsx @@ -0,0 +1,207 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Loader2 } from "lucide-react"; + +import type { ForgejoRepositoryCreate } from "@/lib/api-forgejo"; + +interface ForgejoRepositoryFormProps { + defaultValues?: Partial; + onSubmit: (values: ForgejoRepositoryCreate) => Promise; + isSubmitting?: boolean; + title?: string; + description?: string; + submitLabel?: string; +} + +export interface ForgejoRepositoryFormValues { + connection_id: string; + owner: string; + repo: string; + display_name?: string; + default_branch?: string; +} + +export function ForgejoRepositoryForm({ + defaultValues = {}, + onSubmit, + isSubmitting = false, + title = "Tracked Repository", + description = "Add a repository to track issues and pull requests.", + submitLabel = "Save Repository", +}: ForgejoRepositoryFormProps) { + const [error, setError] = useState(null); + const [connectionId, setConnectionId] = useState(defaultValues.connection_id || ""); + const [owner, setOwner] = useState(defaultValues.owner || ""); + const [repo, setRepo] = useState(defaultValues.repo || ""); + const [displayName, setDisplayName] = useState(defaultValues.display_name || ""); + const [defaultBranch, setDefaultBranch] = useState(defaultValues.default_branch || "main"); + + // Get connections for dropdown + const [connections, setConnections] = useState<{id: string; name: string; base_url: string; active: boolean}[]>([]); + + const [isLoadingConnections, setIsLoadingConnections] = useState(false); + + useEffect(() => { + const fetchConnections = async () => { + try { + setIsLoadingConnections(true); + const response = await fetch("/api/v1/forgejo/connections"); + if (response.ok) { + const data = await response.json(); + setConnections(data.filter((c: { active: boolean }) => c.active)); + } + } catch (_: unknown) { + setError("Failed to load connections"); + } finally { + setIsLoadingConnections(false); + } + }; + fetchConnections(); + }, []); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + + try { + await onSubmit({ + connection_id: connectionId, + owner, + repo, + display_name: displayName || undefined, + default_branch: defaultBranch, + }); + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred"); + } + } + + return ( +
+
+

{title}

+ {description &&

{description}

} + + {error && ( +
+

Configuration Error

+

{error}

+
+ )} + +
+ + +

+ The Forgejo connection to use for this repository. +

+
+ +
+
+ + setOwner(e.target.value)} + placeholder="e.g., null" + disabled={isSubmitting} + required + /> +

+ The owner of the repository (username or organization). +

+
+ +
+ + setRepo(e.target.value)} + placeholder="e.g., Pipeline" + disabled={isSubmitting} + required + /> +

+ The name of the repository. +

+
+
+ +
+ + setDisplayName(e.target.value)} + placeholder="e.g., Pipeline" + disabled={isSubmitting} + /> +

+ A friendly name for this repository. If not provided, the owner/repo will be used. +

+
+ +
+ + setDefaultBranch(e.target.value)} + placeholder="main" + disabled={isSubmitting} + required + /> +

+ The default branch for this repository (e.g., main, dev, master). +

+
+
+ +
+ + +
+
+ ); +} diff --git a/frontend/src/lib/api-forgejo.ts b/frontend/src/lib/api-forgejo.ts new file mode 100644 index 0000000..2884895 --- /dev/null +++ b/frontend/src/lib/api-forgejo.ts @@ -0,0 +1,167 @@ +import { getApiBaseUrl } from "./api-base"; + +// Forgejo Connection types +export interface ForgejoConnection { + id: string; + organization_id: string; + name: string; + base_url: string; + token: null; // Always null in responses + active: boolean; + has_token: boolean; + token_last_eight: string | null; + created_at: string; + updated_at: string; +} + +export interface ForgejoConnectionCreate { + name: string; + base_url: string; + token: string; +} + +export interface ForgejoConnectionUpdate { + name?: string; + base_url?: string; + token?: string; +} + +// Forgejo Repository types +export interface ForgejoRepository { + id: string; + organization_id: string; + connection_id: string; + owner: string; + repo: string; + display_name: string; + default_branch: string; + active: boolean; + connection: ForgejoConnection; + last_sync_at: string | null; + last_sync_error: string | null; + created_at: string; + updated_at: string; +} + +export interface ForgejoRepositoryCreate { + connection_id: string; + owner: string; + repo: string; + display_name?: string; + default_branch?: string; +} + +export interface ForgejoRepositoryUpdate { + display_name?: string; + default_branch?: string; +} + +// API client +const API_BASE_URL = getApiBaseUrl(); + +async function fetchJson(url: string, init?: RequestInit): Promise { + const response = await fetch(url, { + ...init, + headers: { + "Content-Type": "application/json", + ...(init?.headers || {}), + }, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.detail || `API error: ${response.statusText}`); + } + + return response.json(); +} + +// Forgejo Connection API +export async function getForgejoConnections(): Promise { + return fetchJson(`${API_BASE_URL}/api/v1/forgejo/connections`); +} + +export async function createForgejoConnection( + data: ForgejoConnectionCreate, +): Promise { + return fetchJson( + `${API_BASE_URL}/api/v1/forgejo/connections`, + { + method: "POST", + body: JSON.stringify(data), + }, + ); +} + +export async function getForgejoConnection( + connectionId: string, +): Promise { + return fetchJson( + `${API_BASE_URL}/api/v1/forgejo/connections/${connectionId}`, + ); +} + +export async function updateForgejoConnection( + connectionId: string, + data: ForgejoConnectionUpdate, +): Promise { + return fetchJson( + `${API_BASE_URL}/api/v1/forgejo/connections/${connectionId}`, + { + method: "PATCH", + body: JSON.stringify(data), + }, + ); +} + +export async function deleteForgejoConnection(connectionId: string): Promise { + await fetch(`${API_BASE_URL}/api/v1/forgejo/connections/${connectionId}`, { + method: "DELETE", + }); +} + +// Forgejo Repository API +export async function getForgejoRepositories(): Promise { + return fetchJson( + `${API_BASE_URL}/api/v1/forgejo/repositories`, + ); +} + +export async function createForgejoRepository( + data: ForgejoRepositoryCreate, +): Promise { + return fetchJson( + `${API_BASE_URL}/api/v1/forgejo/repositories`, + { + method: "POST", + body: JSON.stringify(data), + }, + ); +} + +export async function getForgejoRepository( + repositoryId: string, +): Promise { + return fetchJson( + `${API_BASE_URL}/api/v1/forgejo/repositories/${repositoryId}`, + ); +} + +export async function updateForgejoRepository( + repositoryId: string, + data: ForgejoRepositoryUpdate, +): Promise { + return fetchJson( + `${API_BASE_URL}/api/v1/forgejo/repositories/${repositoryId}`, + { + method: "PATCH", + body: JSON.stringify(data), + }, + ); +} + +export async function deleteForgejoRepository(repositoryId: string): Promise { + await fetch(`${API_BASE_URL}/api/v1/forgejo/repositories/${repositoryId}`, { + method: "DELETE", + }); +}