cc-dashboard/src/routers/devops.py
Vadym Samoilenko 9d9e8e82d4 feat: corporate planning hub backend — tasks, calendar, ADO, AI reports
- 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>
2026-05-06 18:44:26 +01:00

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()]