- Alembic 0003_corporate: 10 new tables (tasks, planned_blocks, manual_entries, project_budgets, tags, task_tags, azure_integrations, azure_work_items, ai_reports, audit_log) + session.task_id FK - New routers: calendar, tasks, manual_entries, budgets, tags, devops, exports, reports - Services: crypto (Fernet PAT encryption), audit log, Mailgun email, APScheduler (ADO sync every 15 min, daily AI report at 20:00, weekly Sunday 21:00) - Azure DevOps two-way sync: pull assigned work items, push CompletedWork on task complete - AI reports: Anthropic API summaries or plain-stats fallback, sent via Mailgun - structlog JSON/console logging, LoggingMiddleware, updated main.py lifespan - pyproject.toml (ruff/mypy/pytest config), CI workflow, pre-commit hooks - Schemas: CalendarBlock, Task*, ManualEntry*, Budget*, Tag*, AzureIntegration*, AiReport*, SyncReport Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
110 lines
3.5 KiB
Python
110 lines
3.5 KiB
Python
"""Azure DevOps integration endpoints."""
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from src.auth import CurrentUser
|
|
from src.database import get_db
|
|
from src.models import AzureIntegration, AzureWorkItem
|
|
from src.schemas import AzureIntegrationIn, AzureIntegrationOut, AzureWorkItemOut, SyncReport
|
|
from src.services.crypto import encrypt
|
|
|
|
router = APIRouter(prefix="/api/devops", tags=["devops"])
|
|
|
|
_CLOSED_STATES = {"Closed", "Done", "Removed"}
|
|
|
|
|
|
@router.get("/integration", response_model=AzureIntegrationOut)
|
|
async def get_integration(
|
|
user: CurrentUser,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
result = await db.execute(
|
|
select(AzureIntegration).where(AzureIntegration.user_id == user.id)
|
|
)
|
|
integ = result.scalar_one_or_none()
|
|
if not integ:
|
|
raise HTTPException(status_code=404, detail="No integration configured")
|
|
return AzureIntegrationOut.model_validate(integ)
|
|
|
|
|
|
@router.put("/integration", response_model=AzureIntegrationOut)
|
|
async def upsert_integration(
|
|
body: AzureIntegrationIn,
|
|
user: CurrentUser,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
result = await db.execute(
|
|
select(AzureIntegration).where(AzureIntegration.user_id == user.id)
|
|
)
|
|
integ = result.scalar_one_or_none()
|
|
|
|
pat_encrypted = encrypt(body.pat)
|
|
pat_hint = body.pat[-4:] if len(body.pat) >= 4 else body.pat
|
|
|
|
if integ is None:
|
|
integ = AzureIntegration(
|
|
user_id=user.id,
|
|
organization=body.organization,
|
|
project=body.project,
|
|
pat_encrypted=pat_encrypted,
|
|
pat_hint=pat_hint,
|
|
)
|
|
db.add(integ)
|
|
else:
|
|
integ.organization = body.organization
|
|
integ.project = body.project
|
|
integ.pat_encrypted = pat_encrypted
|
|
integ.pat_hint = pat_hint
|
|
|
|
await db.commit()
|
|
await db.refresh(integ)
|
|
return AzureIntegrationOut.model_validate(integ)
|
|
|
|
|
|
@router.delete("/integration", status_code=204)
|
|
async def delete_integration(
|
|
user: CurrentUser,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
result = await db.execute(
|
|
select(AzureIntegration).where(AzureIntegration.user_id == user.id)
|
|
)
|
|
integ = result.scalar_one_or_none()
|
|
if not integ:
|
|
raise HTTPException(status_code=404, detail="No integration configured")
|
|
await db.delete(integ)
|
|
await db.commit()
|
|
|
|
|
|
@router.post("/sync", response_model=SyncReport)
|
|
async def trigger_sync(
|
|
user: CurrentUser,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
from src.services.azure_devops.sync import sync_user_work_items
|
|
|
|
count = await sync_user_work_items(user, db)
|
|
|
|
# Check for error from integration record
|
|
result = await db.execute(
|
|
select(AzureIntegration).where(AzureIntegration.user_id == user.id)
|
|
)
|
|
integ = result.scalar_one_or_none()
|
|
error = integ.last_sync_error if integ else ""
|
|
|
|
return SyncReport(synced=count, error=error)
|
|
|
|
|
|
@router.get("/work-items", response_model=list[AzureWorkItemOut])
|
|
async def list_work_items(
|
|
user: CurrentUser,
|
|
state: str = "open",
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
query = select(AzureWorkItem).where(AzureWorkItem.user_id == user.id)
|
|
if state == "open":
|
|
query = query.where(AzureWorkItem.state.notin_(list(_CLOSED_STATES)))
|
|
query = query.order_by(AzureWorkItem.synced_at.desc())
|
|
result = await db.execute(query)
|
|
return [AzureWorkItemOut.model_validate(wi) for wi in result.scalars()]
|