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
This commit is contained in:
parent
83241a304f
commit
4c540b1c9a
|
|
@ -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
|
### Issue 5: Backend Connection/Repository Validation API
|
||||||
- **Decision**: Use explicit eager loading in API layer instead of SQLModel relationships
|
- Created `/backend/app/schemas/forgejo_validation.py` with validation response schemas
|
||||||
- **Pattern**: Fetch connection separately in API endpoints and attach to repository object
|
- Added POST `/api/v1/forgejo/connections/{connection_id}/validate` endpoint
|
||||||
- **Rationale**: Simpler than dealing with SQLModel relationship type annotations and lazy loading complexity
|
- 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
|
### Issue 6: Cached Issue Database Model
|
||||||
- **Pattern**: Store `token_last_eight` in DB, full token only in memory (client session)
|
- Created `/backend/app/models/forgejo_issues.py` with ForgejoIssue model
|
||||||
- **API Response**: Only return `has_token` and `token_last_eight`, never the actual token
|
- Created `/backend/app/schemas/forgejo_issues.py` with read/create/list schemas
|
||||||
- **Rationale**: Follows security best practices for sensitive credentials
|
- 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
|
### Issue 7: Issue Sync Service and Manual Sync API
|
||||||
- **Pattern**: Strip trailing slash and `/api/v1` path from Forgejo base URL
|
- Created `/backend/app/services/forgejo_issue_sync.py` with IssueSyncService
|
||||||
- **API**: Input validation in schemas and service factory
|
- Service fetches issues via ForgejoAPIClient with pagination
|
||||||
- **Rationale**: Forgejo API typically uses base URL without `/api/v1` suffix
|
- 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
|
### Issue 8: Human Issue List and Read APIs
|
||||||
- **Pattern**: Manual migration creation instead of autogenerate
|
- Created `/backend/app/api/forgejo_issues.py` with issue endpoints
|
||||||
- **Rationale**: Autogenerate failed due to environment setup issues; manual migration is more reliable
|
- 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
|
### Notes
|
||||||
- Async context manager pattern: `async with ForgejoAPIClient(...) as client:`
|
- All endpoints follow existing patterns in forgejo_connections.py and forgejo_repositories.py
|
||||||
- Token-based auth: `Authorization: token <token>` header
|
- Auth context via require_org_admin decorator ensures organization isolation
|
||||||
- User-Agent: `Pipeline/ForgejoClient/1.0` for identification
|
- SQLModel relationships cannot be set directly - use fetch + assign pattern
|
||||||
- Short timeouts: connect=5s, read=30s
|
- 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`
|
||||||
### 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`
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
|
@ -20,6 +21,8 @@ from app.schemas.forgejo_connections import (
|
||||||
ForgejoConnectionRead,
|
ForgejoConnectionRead,
|
||||||
ForgejoConnectionUpdate,
|
ForgejoConnectionUpdate,
|
||||||
)
|
)
|
||||||
|
from app.schemas.forgejo_validation import ForgejoConnectionValidationResponse
|
||||||
|
from app.services.forgejo_client import get_forgejo_client
|
||||||
from app.services.organizations import OrganizationContext
|
from app.services.organizations import OrganizationContext
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
|
@ -174,3 +177,65 @@ async def delete_connection(
|
||||||
await session.delete(connection)
|
await session.delete(connection)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return OkResponse()
|
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(),
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
|
@ -20,6 +21,8 @@ from app.schemas.forgejo_repositories import (
|
||||||
ForgejoRepositoryRead,
|
ForgejoRepositoryRead,
|
||||||
ForgejoRepositoryUpdate,
|
ForgejoRepositoryUpdate,
|
||||||
)
|
)
|
||||||
|
from app.schemas.forgejo_validation import ForgejoRepositoryValidationResponse, ValidationStatus
|
||||||
|
from app.services.forgejo_client import get_forgejo_client
|
||||||
from app.services.organizations import OrganizationContext
|
from app.services.organizations import OrganizationContext
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
|
@ -229,6 +232,105 @@ async def delete_repository(
|
||||||
return OkResponse()
|
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]:
|
def _mask_repository(repository: ForgejoRepository, connection: ForgejoConnection | None = None) -> dict[str, object]:
|
||||||
"""Return repository dict with safe connection metadata."""
|
"""Return repository dict with safe connection metadata."""
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -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.board_webhooks import router as board_webhooks_router
|
||||||
from app.api.boards import router as boards_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_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.forgejo_repositories import router as forgejo_repositories_router
|
||||||
from app.api.gateway import router as gateway_router
|
from app.api.gateway import router as gateway_router
|
||||||
from app.api.gateways import router as gateways_router
|
from app.api.gateways import router as gateways_router
|
||||||
|
|
@ -76,6 +77,10 @@ OPENAPI_TAGS = [
|
||||||
"name": "forgejo-connections",
|
"name": "forgejo-connections",
|
||||||
"description": "Forgejo connection configuration and management endpoints.",
|
"description": "Forgejo connection configuration and management endpoints.",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "forgejo-issues",
|
||||||
|
"description": "Forgejo issue caching and management endpoints.",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "forgejo-repositories",
|
"name": "forgejo-repositories",
|
||||||
"description": "Forgejo repository tracking and sync management endpoints.",
|
"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(agents_router)
|
||||||
api_v1.include_router(activity_router)
|
api_v1.include_router(activity_router)
|
||||||
api_v1.include_router(forgejo_connections_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(forgejo_repositories_router)
|
||||||
api_v1.include_router(gateway_router)
|
api_v1.include_router(gateway_router)
|
||||||
api_v1.include_router(gateways_router)
|
api_v1.include_router(gateways_router)
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -72,6 +72,13 @@ class ForgejoAPIClient:
|
||||||
raise RuntimeError("ForgejoAPIClient must be used as async context manager")
|
raise RuntimeError("ForgejoAPIClient must be used as async context manager")
|
||||||
return self._client
|
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(
|
async def list_issues(
|
||||||
self,
|
self,
|
||||||
owner: str,
|
owner: str,
|
||||||
|
|
@ -100,7 +107,7 @@ class ForgejoAPIClient:
|
||||||
"per_page": limit,
|
"per_page": limit,
|
||||||
"type": "issues", # Exclude pull requests
|
"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()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
|
|
@ -124,7 +131,7 @@ class ForgejoAPIClient:
|
||||||
client = await self._get_client()
|
client = await self._get_client()
|
||||||
payload = {"state": "closed"}
|
payload = {"state": "closed"}
|
||||||
response = await client.patch(
|
response = await client.patch(
|
||||||
f"/repos/{owner}/{repo}/issues/{issue_number}",
|
f"/api/v1/repos/{owner}/{repo}/issues/{issue_number}",
|
||||||
json=payload,
|
json=payload,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
@ -146,12 +153,12 @@ class ForgejoAPIClient:
|
||||||
Repository data as dict
|
Repository data as dict
|
||||||
"""
|
"""
|
||||||
client = await self._get_client()
|
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()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
async def get_forgejo_client(
|
def get_forgejo_client(
|
||||||
connection: object,
|
connection: object,
|
||||||
) -> ForgejoAPIClient:
|
) -> ForgejoAPIClient:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -13,7 +13,7 @@ from alembic import op
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision = "4c1f5e2a7b9d"
|
revision = "4c1f5e2a7b9d"
|
||||||
down_revision = "c9d7e9b6a4f2"
|
down_revision = "658dca8f4a11"
|
||||||
branch_labels = None
|
branch_labels = None
|
||||||
depends_on = None
|
depends_on = None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
|
||||||
)
|
|
||||||
|
|
@ -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"],
|
|
||||||
)
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -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 ###
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -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,
|
|
||||||
)
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""add forgejo models
|
"""add forgejo models and issues tables
|
||||||
|
|
||||||
Revision ID: f5a2b3c4d5e6
|
Revision ID: f5a2b3c4d5e6
|
||||||
Revises: f1b2c3d4e5a6
|
Revises: a9b1c2d3e4f7
|
||||||
Create Date: 2026-05-19 00:00:00.000000
|
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_org_id", "forgejo_repositories", ["organization_id"])
|
||||||
op.create_index("ix_forgejo_repos_conn_id", "forgejo_repositories", ["connection_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:
|
def downgrade() -> None:
|
||||||
|
op.drop_table("forgejo_issues")
|
||||||
op.drop_table("forgejo_repositories")
|
op.drop_table("forgejo_repositories")
|
||||||
op.drop_table("forgejo_connections")
|
op.drop_table("forgejo_connections")
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -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<ConnectionData | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<DashboardPageLayout
|
||||||
|
signedOut={{
|
||||||
|
message: "Sign in to edit a Forgejo connection.",
|
||||||
|
forceRedirectUrl: "/git-projects/connections",
|
||||||
|
signUpForceRedirectUrl: "/git-projects/connections",
|
||||||
|
}}
|
||||||
|
title="Loading..."
|
||||||
|
stickyHeader
|
||||||
|
>
|
||||||
|
<p className="text-slate-500">Loading connection...</p>
|
||||||
|
</DashboardPageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !connection) {
|
||||||
|
return (
|
||||||
|
<DashboardPageLayout
|
||||||
|
signedOut={{
|
||||||
|
message: "Sign in to edit a Forgejo connection.",
|
||||||
|
forceRedirectUrl: "/git-projects/connections",
|
||||||
|
signUpForceRedirectUrl: "/git-projects/connections",
|
||||||
|
}}
|
||||||
|
title="Error"
|
||||||
|
stickyHeader
|
||||||
|
>
|
||||||
|
<p className="text-red-600">{error || "Connection not found"}</p>
|
||||||
|
</DashboardPageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultValues = {
|
||||||
|
name: connection.name,
|
||||||
|
base_url: connection.base_url,
|
||||||
|
token: connection.has_token ? "••••" + (connection.token_last_eight || "") : "",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardPageLayout
|
||||||
|
signedOut={{
|
||||||
|
message: "Sign in to edit a Forgejo connection.",
|
||||||
|
forceRedirectUrl: "/git-projects/connections",
|
||||||
|
signUpForceRedirectUrl: "/git-projects/connections",
|
||||||
|
}}
|
||||||
|
title={`Edit Connection: ${connection.name}`}
|
||||||
|
description="Update connection settings and credentials."
|
||||||
|
stickyHeader
|
||||||
|
>
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<ForgejoConnectionForm
|
||||||
|
defaultValues={defaultValues}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
submitLabel="Save Changes"
|
||||||
|
/>
|
||||||
|
<div className="mt-8 border-t pt-6">
|
||||||
|
<h3 className="text-lg font-semibold text-red-900 dark:text-red-100">Danger Zone</h3>
|
||||||
|
<p className="mt-2 text-sm text-slate-500">
|
||||||
|
Deleting a connection will remove all associated repositories and data.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="text-red-600 hover:bg-red-50 hover:text-red-700 dark:text-red-400 dark:hover:bg-red-950 dark:hover:text-red-300"
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
Delete Connection
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DashboardPageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<DashboardPageLayout
|
||||||
|
signedOut={{
|
||||||
|
message: "Sign in to create a Forgejo connection.",
|
||||||
|
forceRedirectUrl: "/git-projects/connections/new",
|
||||||
|
signUpForceRedirectUrl: "/git-projects/connections/new",
|
||||||
|
}}
|
||||||
|
title="New Forgejo Connection"
|
||||||
|
description="Add a new Forgejo instance to track issues and pull requests."
|
||||||
|
stickyHeader
|
||||||
|
>
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<ForgejoConnectionForm onSubmit={handleSubmit} />
|
||||||
|
</div>
|
||||||
|
</DashboardPageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<ForgejoConnection[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<DashboardPageLayout
|
||||||
|
signedOut={{
|
||||||
|
message: "Sign in to manage Forgejo connections.",
|
||||||
|
forceRedirectUrl: "/git-projects/connections",
|
||||||
|
signUpForceRedirectUrl: "/git-projects/connections",
|
||||||
|
}}
|
||||||
|
title="Forgejo Connections"
|
||||||
|
description={`${connections.length} connection${connections.length === 1 ? "" : "s"} configured.`}
|
||||||
|
stickyHeader
|
||||||
|
isAdmin={false}
|
||||||
|
adminOnlyMessage="Admin access required to manage Forgejo connections."
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-medium text-slate-500">Connections</h2>
|
||||||
|
<Button
|
||||||
|
onClick={() => router.push("/git-projects/connections/new")}
|
||||||
|
>
|
||||||
|
Add Connection
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
|
{error ? (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<p className="text-red-600">{error}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ForgejoConnectionsTable
|
||||||
|
connections={connections}
|
||||||
|
isLoading={isLoading}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DashboardPageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<RepositoryData | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<DashboardPageLayout
|
||||||
|
signedOut={{
|
||||||
|
message: "Sign in to edit a tracked repository.",
|
||||||
|
forceRedirectUrl: "/git-projects/repositories",
|
||||||
|
signUpForceRedirectUrl: "/git-projects/repositories",
|
||||||
|
}}
|
||||||
|
title="Loading..."
|
||||||
|
stickyHeader
|
||||||
|
>
|
||||||
|
<p className="text-slate-500">Loading repository...</p>
|
||||||
|
</DashboardPageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !repository) {
|
||||||
|
return (
|
||||||
|
<DashboardPageLayout
|
||||||
|
signedOut={{
|
||||||
|
message: "Sign in to edit a tracked repository.",
|
||||||
|
forceRedirectUrl: "/git-projects/repositories",
|
||||||
|
signUpForceRedirectUrl: "/git-projects/repositories",
|
||||||
|
}}
|
||||||
|
title="Error"
|
||||||
|
stickyHeader
|
||||||
|
>
|
||||||
|
<p className="text-red-600">{error || "Repository not found"}</p>
|
||||||
|
</DashboardPageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultValues = {
|
||||||
|
connection_id: repository.connection_id,
|
||||||
|
owner: repository.owner,
|
||||||
|
repo: repository.repo,
|
||||||
|
display_name: repository.display_name,
|
||||||
|
default_branch: repository.default_branch,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardPageLayout
|
||||||
|
signedOut={{
|
||||||
|
message: "Sign in to edit a tracked repository.",
|
||||||
|
forceRedirectUrl: "/git-projects/repositories",
|
||||||
|
signUpForceRedirectUrl: "/git-projects/repositories",
|
||||||
|
}}
|
||||||
|
title={`Edit Repository: ${repository.display_name || repository.repo}`}
|
||||||
|
description="Update repository settings and tracking options."
|
||||||
|
stickyHeader
|
||||||
|
>
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<ForgejoRepositoryForm
|
||||||
|
defaultValues={defaultValues}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
submitLabel="Save Changes"
|
||||||
|
/>
|
||||||
|
<div className="mt-8 border-t pt-6">
|
||||||
|
<h3 className="text-lg font-semibold text-red-900 dark:text-red-100">Danger Zone</h3>
|
||||||
|
<p className="mt-2 text-sm text-slate-500">
|
||||||
|
Deleting a repository will remove all associated data including issues and pull requests.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="text-red-600 hover:bg-red-50 hover:text-red-700 dark:text-red-400 dark:hover:bg-red-950 dark:hover:text-red-300"
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
Delete Repository
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DashboardPageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<DashboardPageLayout
|
||||||
|
signedOut={{
|
||||||
|
message: "Sign in to add a tracked repository.",
|
||||||
|
forceRedirectUrl: "/git-projects/repositories/new",
|
||||||
|
signUpForceRedirectUrl: "/git-projects/repositories/new",
|
||||||
|
}}
|
||||||
|
title="Add Tracked Repository"
|
||||||
|
description="Add a repository to track issues and pull requests from your Forgejo instance."
|
||||||
|
stickyHeader
|
||||||
|
>
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<ForgejoRepositoryForm onSubmit={handleSubmit} />
|
||||||
|
</div>
|
||||||
|
</DashboardPageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<ForgejoRepository[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<DashboardPageLayout
|
||||||
|
signedOut={{
|
||||||
|
message: "Sign in to manage tracked repositories.",
|
||||||
|
forceRedirectUrl: "/git-projects/repositories",
|
||||||
|
signUpForceRedirectUrl: "/git-projects/repositories",
|
||||||
|
}}
|
||||||
|
title="Tracked Repositories"
|
||||||
|
description={`${repositories.length} repository${repositories.length === 1 ? "" : "s"} being tracked.`}
|
||||||
|
stickyHeader
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-medium text-slate-500">Repositories</h2>
|
||||||
|
<Button
|
||||||
|
onClick={() => router.push("/git-projects/repositories/new")}
|
||||||
|
>
|
||||||
|
Add Repository
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
|
{error ? (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<p className="text-red-600">{error}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ForgejoRepositoriesTable
|
||||||
|
repositories={repositories}
|
||||||
|
isLoading={isLoading}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DashboardPageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<ForgejoConnectionFormValues>;
|
||||||
|
onSubmit: (values: ForgejoConnectionCreate) => Promise<void>;
|
||||||
|
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<string | null>(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 (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold">{title}</h3>
|
||||||
|
{description && <p className="text-sm text-slate-500">{description}</p>}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg bg-red-50 p-4 text-red-700">
|
||||||
|
<p className="font-medium">Configuration Error</p>
|
||||||
|
<p className="text-sm">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="name" className="text-sm font-medium">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="e.g., Dream Forgejo"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
A memorable name for this Forgejo connection.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="base_url" className="text-sm font-medium">
|
||||||
|
Base URL
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="base_url"
|
||||||
|
value={baseUrl}
|
||||||
|
onChange={(e) => setBaseUrl(e.target.value)}
|
||||||
|
placeholder="https://dream.scheller.ltd"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
The base URL of your Forgejo instance (without trailing slash).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="token" className="text-sm font-medium">
|
||||||
|
Token
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="token"
|
||||||
|
type="password"
|
||||||
|
value={token}
|
||||||
|
onChange={(e) => setToken(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Forgejo personal access token with repo permissions. Token is stored securely and never displayed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-4 pt-4 border-t">
|
||||||
|
<Button type="button" variant="outline" onClick={() => window.history.back()} disabled={isSubmitting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
submitLabel
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<DataTable
|
||||||
|
table={table}
|
||||||
|
isLoading={isLoading}
|
||||||
|
emptyState={{
|
||||||
|
icon: (
|
||||||
|
<svg
|
||||||
|
className="h-16 w-16 text-slate-300"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M9 11l3 3L22 4" />
|
||||||
|
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
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<ForgejoConnection>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => column.toggleSorting(false)}
|
||||||
|
className="px-0 hover:bg-transparent hover:text-slate-900"
|
||||||
|
>
|
||||||
|
Name
|
||||||
|
{column.getIsSorted() === "asc" && "↑"}
|
||||||
|
{column.getIsSorted() === "desc" && "↓"}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium text-slate-900">{row.original.name}</span>
|
||||||
|
<span className="text-xs text-slate-500">{row.original.base_url}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "status",
|
||||||
|
header: "Status",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const isActive = row.original.active;
|
||||||
|
return (
|
||||||
|
<Badge variant={isActive ? "default" : "outline"}>
|
||||||
|
{isActive ? "Active" : "Inactive"}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "hasToken",
|
||||||
|
header: "Auth",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const hasToken = row.original.has_token;
|
||||||
|
const tokenLastEight = row.original.token_last_eight;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant={hasToken ? "outline" : "default"}>
|
||||||
|
{hasToken ? "Configured" : "Missing"}
|
||||||
|
</Badge>
|
||||||
|
{tokenLastEight && hasToken && (
|
||||||
|
<span className="text-xs text-slate-500 font-mono">••••{tokenLastEight}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
cell: ({ row }) => <ActionsCell connection={row.original} />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function ActionsCell({ connection }: { connection: ForgejoConnection }) {
|
||||||
|
const options = [
|
||||||
|
{ value: "edit", label: "Edit" },
|
||||||
|
{ value: "delete", label: "Delete", icon: (props: { className?: string }) => (
|
||||||
|
<svg
|
||||||
|
className={cn("h-4 w-4", props.className)}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M3 6h18" />
|
||||||
|
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
||||||
|
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||||
|
</svg>
|
||||||
|
)},
|
||||||
|
];
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<DropdownSelect
|
||||||
|
ariaLabel="Connection actions"
|
||||||
|
options={options}
|
||||||
|
onValueChange={handleSelect}
|
||||||
|
triggerClassName="h-8 w-8 p-0"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter component
|
||||||
|
export function ConnectionsTableFilter({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
placeholder="Filter connections..."
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="h-8 w-[150px] lg:w-[250px]"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Column toggle component
|
||||||
|
export function ConnectionsTableToggle({
|
||||||
|
table,
|
||||||
|
}: {
|
||||||
|
table: TableType<ForgejoConnection>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-slate-500">Show:</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => table.toggleAllColumnsVisible()}
|
||||||
|
className="h-8 px-2 py-1 text-xs"
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => table.toggleAllColumnsVisible(false)}
|
||||||
|
className="h-8 px-2 py-1 text-xs"
|
||||||
|
>
|
||||||
|
None
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{table
|
||||||
|
.getAllColumns()
|
||||||
|
.filter((column) => column.getCanHide())
|
||||||
|
.map((column) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={column.id}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => column.toggleVisibility(!column.getIsVisible())}
|
||||||
|
className={cn(
|
||||||
|
"h-8 px-2 py-1 text-xs",
|
||||||
|
column.getIsVisible() ? "bg-slate-100 text-slate-900" : "text-slate-500",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{column.id}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<DataTable
|
||||||
|
table={table}
|
||||||
|
isLoading={isLoading}
|
||||||
|
emptyState={{
|
||||||
|
icon: (
|
||||||
|
<svg
|
||||||
|
className="h-16 w-16 text-slate-300"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M9 11l3 3L22 4" />
|
||||||
|
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
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<ForgejoRepository>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "displayName",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => column.toggleSorting(false)}
|
||||||
|
className="px-0 hover:bg-transparent hover:text-slate-900"
|
||||||
|
>
|
||||||
|
Repository
|
||||||
|
{column.getIsSorted() === "asc" && "↑"}
|
||||||
|
{column.getIsSorted() === "desc" && "↓"}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const repo = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium text-slate-900">{repo.display_name || `${repo.owner}/${repo.repo}`}</span>
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
{repo.owner}/{repo.repo} • {repo.connection?.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "connection",
|
||||||
|
header: "Connection",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const connection = row.original.connection;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm text-slate-700">{connection?.name}</span>
|
||||||
|
<span className="text-xs text-slate-500">{connection?.base_url}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "status",
|
||||||
|
header: "Status",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const isActive = row.original.active;
|
||||||
|
return (
|
||||||
|
<Badge variant={isActive ? "default" : "outline"}>
|
||||||
|
{isActive ? "Active" : "Inactive"}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "lastSync",
|
||||||
|
header: "Last Sync",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const lastSyncAt = row.original.last_sync_at;
|
||||||
|
const lastSyncError = row.original.last_sync_error;
|
||||||
|
|
||||||
|
if (!lastSyncAt) {
|
||||||
|
return <span className="text-sm text-slate-400">Never</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(lastSyncAt);
|
||||||
|
const isRecent = new Date().getTime() - date.getTime() < 24 * 60 * 60 * 1000; // Within 24 hours
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className={`text-sm ${isRecent ? "text-green-600" : "text-slate-700"}`}>
|
||||||
|
{date.toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500">{date.toLocaleTimeString()}</span>
|
||||||
|
{lastSyncError && (
|
||||||
|
<span className="text-xs text-red-500">Error: {lastSyncError.substring(0, 50)}...</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
cell: ({ row }) => <ActionsCell repository={row.original} />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function ActionsCell({ repository }: { repository: ForgejoRepository }) {
|
||||||
|
const options = [
|
||||||
|
{ value: "edit", label: "Edit" },
|
||||||
|
{ value: "delete", label: "Delete", icon: (props: { className?: string }) => (
|
||||||
|
<svg
|
||||||
|
className={cn("h-4 w-4", props.className)}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M3 6h18" />
|
||||||
|
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
||||||
|
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||||
|
</svg>
|
||||||
|
)},
|
||||||
|
];
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<DropdownSelect
|
||||||
|
ariaLabel="Repository actions"
|
||||||
|
options={options}
|
||||||
|
onValueChange={handleSelect}
|
||||||
|
triggerClassName="h-8 w-8 p-0"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter component
|
||||||
|
export function RepositoriesTableFilter({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Filter repositories..."
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={(e) => 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<ForgejoRepository>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-slate-500">Show:</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => table.toggleAllColumnsVisible()}
|
||||||
|
className="h-8 px-2 py-1 text-xs"
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => table.toggleAllColumnsVisible(false)}
|
||||||
|
className="h-8 px-2 py-1 text-xs"
|
||||||
|
>
|
||||||
|
None
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{table
|
||||||
|
.getAllColumns()
|
||||||
|
.filter((column) => column.getCanHide())
|
||||||
|
.map((column) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={column.id}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => column.toggleVisibility(!column.getIsVisible())}
|
||||||
|
className={cn(
|
||||||
|
"h-8 px-2 py-1 text-xs",
|
||||||
|
column.getIsVisible() ? "bg-slate-100 text-slate-900" : "text-slate-500",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{column.id}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<ForgejoRepositoryFormValues>;
|
||||||
|
onSubmit: (values: ForgejoRepositoryCreate) => Promise<void>;
|
||||||
|
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<string | null>(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 (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold">{title}</h3>
|
||||||
|
{description && <p className="text-sm text-slate-500">{description}</p>}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg bg-red-50 p-4 text-red-700">
|
||||||
|
<p className="font-medium">Configuration Error</p>
|
||||||
|
<p className="text-sm">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="connection_id" className="text-sm font-medium">
|
||||||
|
Connection
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="connection_id"
|
||||||
|
value={connectionId}
|
||||||
|
onChange={(e) => setConnectionId(e.target.value)}
|
||||||
|
disabled={isSubmitting || isLoadingConnections}
|
||||||
|
className="flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Select a connection</option>
|
||||||
|
{connections.map((conn) => (
|
||||||
|
<option key={conn.id} value={conn.id}>
|
||||||
|
{conn.name} - {conn.base_url}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
The Forgejo connection to use for this repository.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="owner" className="text-sm font-medium">
|
||||||
|
Owner
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="owner"
|
||||||
|
value={owner}
|
||||||
|
onChange={(e) => setOwner(e.target.value)}
|
||||||
|
placeholder="e.g., null"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
The owner of the repository (username or organization).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="repo" className="text-sm font-medium">
|
||||||
|
Repository
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="repo"
|
||||||
|
value={repo}
|
||||||
|
onChange={(e) => setRepo(e.target.value)}
|
||||||
|
placeholder="e.g., Pipeline"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
The name of the repository.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="display_name" className="text-sm font-medium">
|
||||||
|
Display Name (Optional)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="display_name"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
placeholder="e.g., Pipeline"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
A friendly name for this repository. If not provided, the owner/repo will be used.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="default_branch" className="text-sm font-medium">
|
||||||
|
Default Branch
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="default_branch"
|
||||||
|
value={defaultBranch}
|
||||||
|
onChange={(e) => setDefaultBranch(e.target.value)}
|
||||||
|
placeholder="main"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
The default branch for this repository (e.g., main, dev, master).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-4 pt-4 border-t">
|
||||||
|
<Button type="button" variant="outline" onClick={() => window.history.back()} disabled={isSubmitting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
submitLabel
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
|
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<ForgejoConnection[]> {
|
||||||
|
return fetchJson<ForgejoConnection[]>(`${API_BASE_URL}/api/v1/forgejo/connections`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createForgejoConnection(
|
||||||
|
data: ForgejoConnectionCreate,
|
||||||
|
): Promise<ForgejoConnection> {
|
||||||
|
return fetchJson<ForgejoConnection>(
|
||||||
|
`${API_BASE_URL}/api/v1/forgejo/connections`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getForgejoConnection(
|
||||||
|
connectionId: string,
|
||||||
|
): Promise<ForgejoConnection> {
|
||||||
|
return fetchJson<ForgejoConnection>(
|
||||||
|
`${API_BASE_URL}/api/v1/forgejo/connections/${connectionId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateForgejoConnection(
|
||||||
|
connectionId: string,
|
||||||
|
data: ForgejoConnectionUpdate,
|
||||||
|
): Promise<ForgejoConnection> {
|
||||||
|
return fetchJson<ForgejoConnection>(
|
||||||
|
`${API_BASE_URL}/api/v1/forgejo/connections/${connectionId}`,
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteForgejoConnection(connectionId: string): Promise<void> {
|
||||||
|
await fetch(`${API_BASE_URL}/api/v1/forgejo/connections/${connectionId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forgejo Repository API
|
||||||
|
export async function getForgejoRepositories(): Promise<ForgejoRepository[]> {
|
||||||
|
return fetchJson<ForgejoRepository[]>(
|
||||||
|
`${API_BASE_URL}/api/v1/forgejo/repositories`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createForgejoRepository(
|
||||||
|
data: ForgejoRepositoryCreate,
|
||||||
|
): Promise<ForgejoRepository> {
|
||||||
|
return fetchJson<ForgejoRepository>(
|
||||||
|
`${API_BASE_URL}/api/v1/forgejo/repositories`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getForgejoRepository(
|
||||||
|
repositoryId: string,
|
||||||
|
): Promise<ForgejoRepository> {
|
||||||
|
return fetchJson<ForgejoRepository>(
|
||||||
|
`${API_BASE_URL}/api/v1/forgejo/repositories/${repositoryId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateForgejoRepository(
|
||||||
|
repositoryId: string,
|
||||||
|
data: ForgejoRepositoryUpdate,
|
||||||
|
): Promise<ForgejoRepository> {
|
||||||
|
return fetchJson<ForgejoRepository>(
|
||||||
|
`${API_BASE_URL}/api/v1/forgejo/repositories/${repositoryId}`,
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteForgejoRepository(repositoryId: string): Promise<void> {
|
||||||
|
await fetch(`${API_BASE_URL}/api/v1/forgejo/repositories/${repositoryId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue