state-hub/api/routers/technical_debt.py

141 lines
4.8 KiB
Python
Raw Permalink Normal View History

import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
feat(state-hub): implement v0.5 — dynamic domains & multi-repo Replaces the hardcoded 6-domain PostgreSQL ENUM with a first-class `domains` DB table, and adds a `managed_repos` table for multi-repo support per domain. P1 — Domain as a DB entity: - Migration b1c2d3e4f5a6: creates `domains` table, migrates topics.domain ENUM column to domain_id FK, drops the domain ENUM type - Domain ORM model (api/models/domain.py) + Pydantic schemas - Domain API router: GET/POST /domains/, GET/PATCH /domains/{slug}/, rename and archive endpoints with EP/TD cascade on rename - Topic model updated: domain_id FK + @property domain_slug for backwards-compatible JSON serialization (field renamed domain → domain_slug) - TopicCreate/TopicRead updated; seed.py rewritten to use FK lookup P2 — Multi-repo support: - ManagedRepo ORM model (api/models/managed_repo.py) + schemas - Repo API router: GET/POST /repos/, GET/PATCH /repos/{slug}/, archive - Makefile: add-domain, rename-domain, add-repo, list-repos targets - register_project.sh: verify domain via /domains/ API + POST /repos/ P3 — MCP tools & live validation: - 6 new MCP tools: list_domains, create_domain, rename_domain, archive_domain, list_domain_repos, register_repo - EP/TD routers: replace hardcoded VALID_DOMAINS set with per-request DB lookup — returns 422 with list of valid slugs on unknown domain - State summary: adds domains: list[DomainSummary] (slug, name, repo_count, active_workstream_count, ep_count, td_count) - TOOLS.md updated with domain management section P4 — Dashboard: - New domains.md page with KPI row + domain cards + repo lists - domains.json.py + repos.json.py data loaders - Domains page added to observablehq.config.js nav - workstreams.md, extensions.md, techdept.md: domain_slug fix + dynamic domain list loaded from /domains/ API (no longer hardcoded) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 15:20:15 +01:00
from api.models.domain import Domain
from api.models.technical_debt import TDNote, TDStatus, TechnicalDebt
from api.schemas.technical_debt import TDCreate, TDNoteCreate, TDNoteRead, TDRead, TDUpdate
router = APIRouter(prefix="/technical-debt", tags=["technical-debt"])
async def _resolve_domain_id(slug: str, session: AsyncSession) -> uuid.UUID:
"""Resolve a domain slug to its UUID, raising 422 if unknown."""
row = await session.execute(
select(Domain.id).where(Domain.slug == slug, Domain.status == "active")
)
domain_id = row.scalar_one_or_none()
if domain_id is None:
valid = [r[0] for r in (await session.execute(
select(Domain.slug).where(Domain.status == "active")
)).all()]
raise HTTPException(
status_code=422,
detail=f"Unknown domain '{slug}'. Valid domains: {sorted(valid)}",
)
return domain_id
feat(state-hub): implement v0.5 — dynamic domains & multi-repo Replaces the hardcoded 6-domain PostgreSQL ENUM with a first-class `domains` DB table, and adds a `managed_repos` table for multi-repo support per domain. P1 — Domain as a DB entity: - Migration b1c2d3e4f5a6: creates `domains` table, migrates topics.domain ENUM column to domain_id FK, drops the domain ENUM type - Domain ORM model (api/models/domain.py) + Pydantic schemas - Domain API router: GET/POST /domains/, GET/PATCH /domains/{slug}/, rename and archive endpoints with EP/TD cascade on rename - Topic model updated: domain_id FK + @property domain_slug for backwards-compatible JSON serialization (field renamed domain → domain_slug) - TopicCreate/TopicRead updated; seed.py rewritten to use FK lookup P2 — Multi-repo support: - ManagedRepo ORM model (api/models/managed_repo.py) + schemas - Repo API router: GET/POST /repos/, GET/PATCH /repos/{slug}/, archive - Makefile: add-domain, rename-domain, add-repo, list-repos targets - register_project.sh: verify domain via /domains/ API + POST /repos/ P3 — MCP tools & live validation: - 6 new MCP tools: list_domains, create_domain, rename_domain, archive_domain, list_domain_repos, register_repo - EP/TD routers: replace hardcoded VALID_DOMAINS set with per-request DB lookup — returns 422 with list of valid slugs on unknown domain - State summary: adds domains: list[DomainSummary] (slug, name, repo_count, active_workstream_count, ep_count, td_count) - TOOLS.md updated with domain management section P4 — Dashboard: - New domains.md page with KPI row + domain cards + repo lists - domains.json.py + repos.json.py data loaders - Domains page added to observablehq.config.js nav - workstreams.md, extensions.md, techdept.md: domain_slug fix + dynamic domain list loaded from /domains/ API (no longer hardcoded) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 15:20:15 +01:00
@router.get("/", response_model=list[TDRead])
async def list_td(
domain: str | None = None,
status: str | None = None, # str to accept both legacy and workflow values
debt_type: str | None = None,
severity: str | None = None,
session: AsyncSession = Depends(get_session),
) -> list[TechnicalDebt]:
q = select(TechnicalDebt)
if domain:
domain_id = await _resolve_domain_id(domain, session)
q = q.where(TechnicalDebt.domain_id == domain_id)
if status:
q = q.where(TechnicalDebt.status == status)
if debt_type:
q = q.where(TechnicalDebt.debt_type == debt_type)
if severity:
q = q.where(TechnicalDebt.severity == severity)
q = q.order_by(TechnicalDebt.created_at)
result = await session.execute(q)
return list(result.scalars().all())
@router.post("/", response_model=TDRead, status_code=status.HTTP_201_CREATED)
async def create_td(
body: TDCreate,
session: AsyncSession = Depends(get_session),
) -> TechnicalDebt:
domain_id = await _resolve_domain_id(body.domain, session)
data = body.model_dump(exclude={"domain"})
data["domain_id"] = domain_id
td = TechnicalDebt(**data)
session.add(td)
await session.commit()
await session.refresh(td)
return td
@router.get("/{td_id}", response_model=TDRead)
async def get_td(
td_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> TechnicalDebt:
td = await session.get(TechnicalDebt, td_id)
if td is None:
raise HTTPException(status_code=404, detail="Technical debt item not found")
return td
@router.patch("/{td_id}", response_model=TDRead)
async def update_td(
td_id: uuid.UUID,
body: TDUpdate,
session: AsyncSession = Depends(get_session),
) -> TechnicalDebt:
td = await session.get(TechnicalDebt, td_id)
if td is None:
raise HTTPException(status_code=404, detail="Technical debt item not found")
for field, value in body.model_dump(exclude_unset=True).items():
setattr(td, field, value)
await session.commit()
await session.refresh(td)
return td
@router.delete("/{td_id}", response_model=TDRead)
async def defer_td(
td_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> TechnicalDebt:
td = await session.get(TechnicalDebt, td_id)
if td is None:
raise HTTPException(status_code=404, detail="Technical debt item not found")
td.status = TDStatus.deferred
await session.commit()
await session.refresh(td)
return td
# ── Notes ─────────────────────────────────────────────────────────────────────
@router.get("/{td_id}/notes", response_model=list[TDNoteRead])
async def list_notes(
td_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> list[TDNote]:
td = await session.get(TechnicalDebt, td_id)
if td is None:
raise HTTPException(status_code=404, detail="Technical debt item not found")
result = await session.execute(
select(TDNote).where(TDNote.td_id == td_id).order_by(TDNote.created_at)
)
return list(result.scalars().all())
@router.post("/{td_id}/notes", response_model=TDNoteRead, status_code=status.HTTP_201_CREATED)
async def add_note(
td_id: uuid.UUID,
body: TDNoteCreate,
session: AsyncSession = Depends(get_session),
) -> TDNote:
td = await session.get(TechnicalDebt, td_id)
if td is None:
raise HTTPException(status_code=404, detail="Technical debt item not found")
note = TDNote(td_id=td_id, **body.model_dump())
session.add(note)
await session.commit()
await session.refresh(note)
return note