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
|
||||
- **Decision**: Use explicit eager loading in API layer instead of SQLModel relationships
|
||||
- **Pattern**: Fetch connection separately in API endpoints and attach to repository object
|
||||
- **Rationale**: Simpler than dealing with SQLModel relationship type annotations and lazy loading complexity
|
||||
### Issue 5: Backend Connection/Repository Validation API
|
||||
- Created `/backend/app/schemas/forgejo_validation.py` with validation response schemas
|
||||
- Added POST `/api/v1/forgejo/connections/{connection_id}/validate` endpoint
|
||||
- Added POST `/api/v1/forgejo/repositories/{repository_id}/validate` endpoint
|
||||
- Validation tests authentication with Forgejo API
|
||||
- Errors are safe for display (no tokens exposed)
|
||||
|
||||
### 2. Token Security
|
||||
- **Pattern**: Store `token_last_eight` in DB, full token only in memory (client session)
|
||||
- **API Response**: Only return `has_token` and `token_last_eight`, never the actual token
|
||||
- **Rationale**: Follows security best practices for sensitive credentials
|
||||
### Issue 6: Cached Issue Database Model
|
||||
- Created `/backend/app/models/forgejo_issues.py` with ForgejoIssue model
|
||||
- Created `/backend/app/schemas/forgejo_issues.py` with read/create/list schemas
|
||||
- Created migration `/backend/migrations/versions/a1b2c3d4e5f7_add_forgejo_issues.py`
|
||||
- Model includes JSON fields for labels and assignees using `sa_column=Column(JSON)`
|
||||
- Unique constraint on (repository_id, forgejo_issue_number)
|
||||
|
||||
### 3. Base URL Normalization
|
||||
- **Pattern**: Strip trailing slash and `/api/v1` path from Forgejo base URL
|
||||
- **API**: Input validation in schemas and service factory
|
||||
- **Rationale**: Forgejo API typically uses base URL without `/api/v1` suffix
|
||||
### Issue 7: Issue Sync Service and Manual Sync API
|
||||
- Created `/backend/app/services/forgejo_issue_sync.py` with IssueSyncService
|
||||
- Service fetches issues via ForgejoAPIClient with pagination
|
||||
- Handles upsert of issues into database
|
||||
- Excludes pull requests from sync (type=issues filter)
|
||||
- Updates repository.last_sync_at on success
|
||||
- Updates repository.last_sync_error on failure
|
||||
- Admin-only endpoint at POST `/api/v1/forgejo/repositories/{repository_id}/sync`
|
||||
|
||||
### 4. Migration Management
|
||||
- **Pattern**: Manual migration creation instead of autogenerate
|
||||
- **Rationale**: Autogenerate failed due to environment setup issues; manual migration is more reliable
|
||||
### Issue 8: Human Issue List and Read APIs
|
||||
- Created `/backend/app/api/forgejo_issues.py` with issue endpoints
|
||||
- GET `/api/v1/forgejo/issues` - paginated list with filters:
|
||||
- repository_id, state, label, assignee, search text
|
||||
- GET `/api/v1/forgejo/issues/{issue_id}` - single issue read
|
||||
- Cross-organization access returns 404
|
||||
- Pull requests excluded from responses (sync service filters them)
|
||||
|
||||
## Code Patterns Established
|
||||
### Errors Encountered
|
||||
- Import path truncated in forgejo_connections.py - fixed by proper import ordering
|
||||
- Schema file had duplicate content - rewritten cleanly
|
||||
- API file corruption during incremental edits - rewritten completely for forgejo_issues.py
|
||||
- JSON field type error: `TypeError: issubclass() arg 1 must be a class` - caused by using `sa_column=JSON` instead of `sa_column=Column(JSON)`. Fixed by following existing pattern in other models (approvals.py, agents.py, board_onboarding.py)
|
||||
|
||||
### ForgejoAPIClient
|
||||
- Async context manager pattern: `async with ForgejoAPIClient(...) as client:`
|
||||
- Token-based auth: `Authorization: token <token>` header
|
||||
- User-Agent: `Pipeline/ForgejoClient/1.0` for identification
|
||||
- Short timeouts: connect=5s, read=30s
|
||||
|
||||
### CRUD API Pattern
|
||||
- All endpoints enforce org-scoped admin access
|
||||
- Duplicate checks before create/update
|
||||
- Connection validation ensures connection belongs to caller's org
|
||||
- Safe response schema excludes sensitive data (token)
|
||||
|
||||
### Model Pattern
|
||||
- All models inherit from `QueryModel`
|
||||
- Timestamps use `app.core.time.utcnow()`
|
||||
- UUID primary keys with `default_factory=uuid4`
|
||||
### Notes
|
||||
- All endpoints follow existing patterns in forgejo_connections.py and forgejo_repositories.py
|
||||
- Auth context via require_org_admin decorator ensures organization isolation
|
||||
- SQLModel relationships cannot be set directly - use fetch + assign pattern
|
||||
- JSON fields use `sa_column=Column(JSON)` for SQLAlchemy JSON type
|
||||
- Issue sync excludes pull requests with `if issue_data.get("pull_request") is not None: continue`
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID
|
||||
|
||||
|
|
@ -20,6 +21,8 @@ from app.schemas.forgejo_connections import (
|
|||
ForgejoConnectionRead,
|
||||
ForgejoConnectionUpdate,
|
||||
)
|
||||
from app.schemas.forgejo_validation import ForgejoConnectionValidationResponse
|
||||
from app.services.forgejo_client import get_forgejo_client
|
||||
from app.services.organizations import OrganizationContext
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -174,3 +177,65 @@ async def delete_connection(
|
|||
await session.delete(connection)
|
||||
await session.commit()
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{connection_id}/validate",
|
||||
response_model=ForgejoConnectionValidationResponse,
|
||||
summary="Validate Forgejo Connection",
|
||||
description="Test if a Forgejo connection can authenticate and access the API.",
|
||||
)
|
||||
async def validate_connection(
|
||||
connection_id: UUID,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
||||
) -> ForgejoConnectionValidationResponse:
|
||||
"""Validate a Forgejo connection by testing authenticated API access."""
|
||||
connection = await crud.get_by_id(session, ForgejoConnection, connection_id)
|
||||
if connection is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
if connection.organization_id != ctx.organization.id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
if not connection.base_url:
|
||||
from app.core.time import utcnow
|
||||
from app.schemas.forgejo_validation import ValidationStatus
|
||||
return ForgejoConnectionValidationResponse(
|
||||
connection_id=str(connection.id),
|
||||
status=ValidationStatus(ok=False, status="error", error_message="No base_url configured"),
|
||||
response_time_ms=0.0,
|
||||
validated_at=utcnow(),
|
||||
)
|
||||
|
||||
from app.core.time import utcnow
|
||||
from app.schemas.forgejo_validation import ValidationStatus
|
||||
import time
|
||||
|
||||
start_time = time.time()
|
||||
try:
|
||||
from app.services.forgejo_client import get_forgejo_client
|
||||
async with get_forgejo_client(connection) as client:
|
||||
# Use /api/v1/user endpoint to validate authentication
|
||||
await client.get_user()
|
||||
response_time_ms = (time.time() - start_time) * 1000
|
||||
return ForgejoConnectionValidationResponse(
|
||||
connection_id=str(connection.id),
|
||||
status=ValidationStatus(ok=True, status="ok"),
|
||||
response_time_ms=response_time_ms,
|
||||
validated_at=utcnow(),
|
||||
)
|
||||
except HTTPException as e:
|
||||
response_time_ms = (time.time() - start_time) * 1000
|
||||
return ForgejoConnectionValidationResponse(
|
||||
connection_id=str(connection.id),
|
||||
status=ValidationStatus(ok=False, status="error", error_message=str(e.detail)),
|
||||
response_time_ms=response_time_ms,
|
||||
validated_at=utcnow(),
|
||||
)
|
||||
except Exception as e:
|
||||
response_time_ms = (time.time() - start_time) * 1000
|
||||
return ForgejoConnectionValidationResponse(
|
||||
connection_id=str(connection.id),
|
||||
status=ValidationStatus(ok=False, status="error", error_message=str(e)),
|
||||
response_time_ms=response_time_ms,
|
||||
validated_at=utcnow(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID
|
||||
|
||||
|
|
@ -20,6 +21,8 @@ from app.schemas.forgejo_repositories import (
|
|||
ForgejoRepositoryRead,
|
||||
ForgejoRepositoryUpdate,
|
||||
)
|
||||
from app.schemas.forgejo_validation import ForgejoRepositoryValidationResponse, ValidationStatus
|
||||
from app.services.forgejo_client import get_forgejo_client
|
||||
from app.services.organizations import OrganizationContext
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -229,6 +232,105 @@ async def delete_repository(
|
|||
return OkResponse()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{repository_id}/validate",
|
||||
response_model=ForgejoRepositoryValidationResponse,
|
||||
summary="Validate Forgejo Repository",
|
||||
description="Test if a Forgejo repository exists and can be accessed with the connection token.",
|
||||
)
|
||||
async def validate_repository(
|
||||
repository_id: UUID,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
||||
) -> ForgejoRepositoryValidationResponse:
|
||||
"""Validate a Forgejo repository by testing API access."""
|
||||
repository = await crud.get_by_id(session, ForgejoRepository, repository_id)
|
||||
if repository is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
if repository.organization_id != ctx.organization.id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# Load connection
|
||||
connection = await crud.get_by_id(session, ForgejoConnection, repository.connection_id)
|
||||
if connection is None or connection.organization_id != ctx.organization.id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
from app.core.time import utcnow
|
||||
import time
|
||||
|
||||
start_time = time.time()
|
||||
repo_exists = None
|
||||
try:
|
||||
async with get_forgejo_client(connection) as client:
|
||||
# Test if repository exists and is accessible
|
||||
await client.get_repository(owner=repository.owner, repo=repository.repo)
|
||||
repo_exists = True
|
||||
response_time_ms = (time.time() - start_time) * 1000
|
||||
return ForgejoRepositoryValidationResponse(
|
||||
repository_id=str(repository.id),
|
||||
status=ValidationStatus(ok=True, status="ok"),
|
||||
response_time_ms=response_time_ms,
|
||||
validated_at=utcnow(),
|
||||
repo_exists=repo_exists,
|
||||
)
|
||||
except HTTPException as e:
|
||||
response_time_ms = (time.time() - start_time) * 1000
|
||||
return ForgejoRepositoryValidationResponse(
|
||||
repository_id=str(repository.id),
|
||||
status=ValidationStatus(ok=False, status="error", error_message=str(e.detail)),
|
||||
response_time_ms=response_time_ms,
|
||||
validated_at=utcnow(),
|
||||
repo_exists=False,
|
||||
)
|
||||
except Exception as e:
|
||||
response_time_ms = (time.time() - start_time) * 1000
|
||||
return ForgejoRepositoryValidationResponse(
|
||||
repository_id=str(repository.id),
|
||||
status=ValidationStatus(ok=False, status="error", error_message=str(e)),
|
||||
response_time_ms=response_time_ms,
|
||||
validated_at=utcnow(),
|
||||
repo_exists=False,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{repository_id}/sync",
|
||||
summary="Sync Issues from Repository",
|
||||
description="Sync issues from a Forgejo repository. Admin-only endpoint.",
|
||||
)
|
||||
async def sync_repository_issues(
|
||||
repository_id: UUID,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
||||
) -> dict[str, int]:
|
||||
"""Sync issues from a Forgejo repository."""
|
||||
repository = await crud.get_by_id(session, ForgejoRepository, repository_id)
|
||||
if repository is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
if repository.organization_id != ctx.organization.id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
from app.services.forgejo_issue_sync import IssueSyncService
|
||||
from app.core.time import utcnow
|
||||
|
||||
try:
|
||||
sync_service = IssueSyncService(session=session, organization_id=ctx.organization.id)
|
||||
result = await sync_service.sync_repository_issues(repository_id=repository_id)
|
||||
return result
|
||||
except ValueError as e:
|
||||
# Update error on repository
|
||||
repository.last_sync_error = str(e)
|
||||
repository.updated_at = utcnow()
|
||||
await crud.save(session, repository)
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(e))
|
||||
except Exception as e:
|
||||
# Update error on repository
|
||||
repository.last_sync_error = str(e)
|
||||
repository.updated_at = utcnow()
|
||||
await crud.save(session, repository)
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
||||
|
||||
|
||||
def _mask_repository(repository: ForgejoRepository, connection: ForgejoConnection | None = None) -> dict[str, object]:
|
||||
"""Return repository dict with safe connection metadata."""
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from app.api.board_onboarding import router as board_onboarding_router
|
|||
from app.api.board_webhooks import router as board_webhooks_router
|
||||
from app.api.boards import router as boards_router
|
||||
from app.api.forgejo_connections import router as forgejo_connections_router
|
||||
from app.api.forgejo_issues import router as forgejo_issues_router
|
||||
from app.api.forgejo_repositories import router as forgejo_repositories_router
|
||||
from app.api.gateway import router as gateway_router
|
||||
from app.api.gateways import router as gateways_router
|
||||
|
|
@ -76,6 +77,10 @@ OPENAPI_TAGS = [
|
|||
"name": "forgejo-connections",
|
||||
"description": "Forgejo connection configuration and management endpoints.",
|
||||
},
|
||||
{
|
||||
"name": "forgejo-issues",
|
||||
"description": "Forgejo issue caching and management endpoints.",
|
||||
},
|
||||
{
|
||||
"name": "forgejo-repositories",
|
||||
"description": "Forgejo repository tracking and sync management endpoints.",
|
||||
|
|
@ -552,6 +557,7 @@ api_v1.include_router(agent_router)
|
|||
api_v1.include_router(agents_router)
|
||||
api_v1.include_router(activity_router)
|
||||
api_v1.include_router(forgejo_connections_router)
|
||||
api_v1.include_router(forgejo_issues_router)
|
||||
api_v1.include_router(forgejo_repositories_router)
|
||||
api_v1.include_router(gateway_router)
|
||||
api_v1.include_router(gateways_router)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
return self._client
|
||||
|
||||
async def get_user(self) -> dict[str, object]:
|
||||
"""Get authenticated user info — useful for connection validation."""
|
||||
client = await self._get_client()
|
||||
response = await client.get("/api/v1/user")
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def list_issues(
|
||||
self,
|
||||
owner: str,
|
||||
|
|
@ -100,7 +107,7 @@ class ForgejoAPIClient:
|
|||
"per_page": limit,
|
||||
"type": "issues", # Exclude pull requests
|
||||
}
|
||||
response = await client.get(f"/repos/{owner}/{repo}/issues", params=params)
|
||||
response = await client.get(f"/api/v1/repos/{owner}/{repo}/issues", params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
|
@ -124,7 +131,7 @@ class ForgejoAPIClient:
|
|||
client = await self._get_client()
|
||||
payload = {"state": "closed"}
|
||||
response = await client.patch(
|
||||
f"/repos/{owner}/{repo}/issues/{issue_number}",
|
||||
f"/api/v1/repos/{owner}/{repo}/issues/{issue_number}",
|
||||
json=payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
|
@ -146,12 +153,12 @@ class ForgejoAPIClient:
|
|||
Repository data as dict
|
||||
"""
|
||||
client = await self._get_client()
|
||||
response = await client.get(f"/repos/{owner}/{repo}")
|
||||
response = await client.get(f"/api/v1/repos/{owner}/{repo}")
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
async def get_forgejo_client(
|
||||
def get_forgejo_client(
|
||||
connection: object,
|
||||
) -> ForgejoAPIClient:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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 = "4c1f5e2a7b9d"
|
||||
down_revision = "c9d7e9b6a4f2"
|
||||
down_revision = "658dca8f4a11"
|
||||
branch_labels = 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
|
||||
Revises: f1b2c3d4e5a6
|
||||
Revises: a9b1c2d3e4f7
|
||||
Create Date: 2026-05-19 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
|
@ -61,7 +61,37 @@ def upgrade() -> None:
|
|||
op.create_index("ix_forgejo_repos_org_id", "forgejo_repositories", ["organization_id"])
|
||||
op.create_index("ix_forgejo_repos_conn_id", "forgejo_repositories", ["connection_id"])
|
||||
|
||||
if not inspector.has_table("forgejo_issues"):
|
||||
op.create_table(
|
||||
"forgejo_issues",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("organization_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("repository_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("forgejo_issue_number", sa.Integer(), nullable=False),
|
||||
sa.Column("title", sa.String(), nullable=False),
|
||||
sa.Column("body_preview", sa.String(1000), nullable=True),
|
||||
sa.Column("state", sa.String(), nullable=False, server_default="open"),
|
||||
sa.Column("is_pull_request", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
sa.Column("labels", sa.JSON(), nullable=True),
|
||||
sa.Column("assignees", sa.JSON(), nullable=True),
|
||||
sa.Column("author", sa.String(), nullable=False),
|
||||
sa.Column("html_url", sa.String(), nullable=False),
|
||||
sa.Column("forgejo_created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("forgejo_updated_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("forgejo_closed_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("last_synced_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["organization_id"], ["organizations.id"]),
|
||||
sa.ForeignKeyConstraint(["repository_id"], ["forgejo_repositories.id"]),
|
||||
)
|
||||
op.create_index("ix_forgejo_issues_org_id", "forgejo_issues", ["organization_id"])
|
||||
op.create_index("ix_forgejo_issues_repo_id", "forgejo_issues", ["repository_id"])
|
||||
op.create_index("ix_forgejo_issues_repo_number", "forgejo_issues", ["repository_id", "forgejo_issue_number"], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("forgejo_issues")
|
||||
op.drop_table("forgejo_repositories")
|
||||
op.drop_table("forgejo_connections")
|
||||
|
|
@ -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