generated from coulomb/repo-seed
feat(state-hub): add Extension Points and Technical Debt tracking
New entity types (DB tables, API routers, Pydantic schemas, Alembic migration a3f1c2d4e5b6): - extension_points: ep_id, domain, title, ep_type, status, priority, location, description, topic_id, workstream_id - technical_debt: td_id, domain, title, debt_type, severity, status, location, description, topic_id, workstream_id MCP server: 6 new tools — register_extension_point, list_extension_points, update_ep_status, register_technical_debt, list_technical_debt, update_td_status (each write emits a progress_event) Dashboard: two new pages (extensions.md, techdept.md) with KPI sidebar, charts, urgent-items section, and filterable card lists. Both added to nav in observablehq.config.js. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from api.database import engine
|
||||
from api.routers import decisions, progress, state, tasks, topics, workstreams, workstream_dependencies
|
||||
from api.routers import decisions, extension_points, progress, state, tasks, technical_debt, topics, workstreams, workstream_dependencies
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -32,6 +32,8 @@ app.include_router(workstreams.router)
|
||||
app.include_router(workstream_dependencies.router)
|
||||
app.include_router(tasks.router)
|
||||
app.include_router(decisions.router)
|
||||
app.include_router(extension_points.router)
|
||||
app.include_router(technical_debt.router)
|
||||
app.include_router(progress.router)
|
||||
app.include_router(state.router)
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ from api.models.workstream_dependency import WorkstreamDependency
|
||||
from api.models.task import Task, TaskStatus, TaskPriority
|
||||
from api.models.decision import Decision, DecisionType, DecisionStatus
|
||||
from api.models.progress_event import ProgressEvent
|
||||
from api.models.extension_point import ExtensionPoint, EPStatus
|
||||
from api.models.technical_debt import TechnicalDebt, TDStatus
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
@@ -14,4 +16,6 @@ __all__ = [
|
||||
"Task", "TaskStatus", "TaskPriority",
|
||||
"Decision", "DecisionType", "DecisionStatus",
|
||||
"ProgressEvent",
|
||||
"ExtensionPoint", "EPStatus",
|
||||
"TechnicalDebt", "TDStatus",
|
||||
]
|
||||
|
||||
47
api/models/extension_point.py
Normal file
47
api/models/extension_point.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import enum
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Enum, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class EPStatus(str, enum.Enum):
|
||||
open = "open"
|
||||
in_progress = "in_progress"
|
||||
addressed = "addressed"
|
||||
deferred = "deferred"
|
||||
wont_fix = "wont_fix"
|
||||
|
||||
|
||||
class ExtensionPoint(Base, TimestampMixin):
|
||||
__tablename__ = "extension_points"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
ep_id: Mapped[str | None] = mapped_column(
|
||||
String(30), nullable=True, unique=True, index=True
|
||||
) # human-readable ref, e.g. EP-CUST-001
|
||||
domain: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
location: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
ep_type: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, default="other"
|
||||
) # api | schema | mcp | dashboard | architecture | integration | other
|
||||
status: Mapped[EPStatus] = mapped_column(
|
||||
Enum(EPStatus, name="epstatus"), nullable=False, default=EPStatus.open
|
||||
)
|
||||
priority: Mapped[str] = mapped_column(String(20), nullable=False, default="medium")
|
||||
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821
|
||||
workstream: Mapped["Workstream"] = relationship("Workstream", lazy="selectin") # noqa: F821
|
||||
47
api/models/technical_debt.py
Normal file
47
api/models/technical_debt.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import enum
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Enum, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class TDStatus(str, enum.Enum):
|
||||
open = "open"
|
||||
in_progress = "in_progress"
|
||||
resolved = "resolved"
|
||||
deferred = "deferred"
|
||||
wont_fix = "wont_fix"
|
||||
|
||||
|
||||
class TechnicalDebt(Base, TimestampMixin):
|
||||
__tablename__ = "technical_debt"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
td_id: Mapped[str | None] = mapped_column(
|
||||
String(30), nullable=True, unique=True, index=True
|
||||
) # human-readable ref, e.g. TD-CUST-001
|
||||
domain: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
location: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
debt_type: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, default="other"
|
||||
) # design | implementation | test | docs | dependencies | performance | security | other
|
||||
severity: Mapped[str] = mapped_column(String(20), nullable=False, default="medium")
|
||||
status: Mapped[TDStatus] = mapped_column(
|
||||
Enum(TDStatus, name="tdstatus"), nullable=False, default=TDStatus.open
|
||||
)
|
||||
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821
|
||||
workstream: Mapped["Workstream"] = relationship("Workstream", lazy="selectin") # noqa: F821
|
||||
83
api/routers/extension_points.py
Normal file
83
api/routers/extension_points.py
Normal file
@@ -0,0 +1,83 @@
|
||||
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
|
||||
from api.models.extension_point import EPStatus, ExtensionPoint
|
||||
from api.schemas.extension_point import EPCreate, EPRead, EPUpdate
|
||||
|
||||
router = APIRouter(prefix="/extension-points", tags=["extension-points"])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[EPRead])
|
||||
async def list_eps(
|
||||
domain: str | None = None,
|
||||
status: EPStatus | None = None,
|
||||
ep_type: str | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[ExtensionPoint]:
|
||||
q = select(ExtensionPoint)
|
||||
if domain:
|
||||
q = q.where(ExtensionPoint.domain == domain)
|
||||
if status:
|
||||
q = q.where(ExtensionPoint.status == status)
|
||||
if ep_type:
|
||||
q = q.where(ExtensionPoint.ep_type == ep_type)
|
||||
q = q.order_by(ExtensionPoint.created_at)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/", response_model=EPRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_ep(
|
||||
body: EPCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ExtensionPoint:
|
||||
ep = ExtensionPoint(**body.model_dump())
|
||||
session.add(ep)
|
||||
await session.commit()
|
||||
await session.refresh(ep)
|
||||
return ep
|
||||
|
||||
|
||||
@router.get("/{ep_id}", response_model=EPRead)
|
||||
async def get_ep(
|
||||
ep_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ExtensionPoint:
|
||||
ep = await session.get(ExtensionPoint, ep_id)
|
||||
if ep is None:
|
||||
raise HTTPException(status_code=404, detail="Extension point not found")
|
||||
return ep
|
||||
|
||||
|
||||
@router.patch("/{ep_id}", response_model=EPRead)
|
||||
async def update_ep(
|
||||
ep_id: uuid.UUID,
|
||||
body: EPUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ExtensionPoint:
|
||||
ep = await session.get(ExtensionPoint, ep_id)
|
||||
if ep is None:
|
||||
raise HTTPException(status_code=404, detail="Extension point not found")
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
setattr(ep, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(ep)
|
||||
return ep
|
||||
|
||||
|
||||
@router.delete("/{ep_id}", response_model=EPRead)
|
||||
async def defer_ep(
|
||||
ep_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ExtensionPoint:
|
||||
ep = await session.get(ExtensionPoint, ep_id)
|
||||
if ep is None:
|
||||
raise HTTPException(status_code=404, detail="Extension point not found")
|
||||
ep.status = EPStatus.deferred
|
||||
await session.commit()
|
||||
await session.refresh(ep)
|
||||
return ep
|
||||
86
api/routers/technical_debt.py
Normal file
86
api/routers/technical_debt.py
Normal file
@@ -0,0 +1,86 @@
|
||||
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
|
||||
from api.models.technical_debt import TDStatus, TechnicalDebt
|
||||
from api.schemas.technical_debt import TDCreate, TDRead, TDUpdate
|
||||
|
||||
router = APIRouter(prefix="/technical-debt", tags=["technical-debt"])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[TDRead])
|
||||
async def list_td(
|
||||
domain: str | None = None,
|
||||
status: TDStatus | None = None,
|
||||
debt_type: str | None = None,
|
||||
severity: str | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[TechnicalDebt]:
|
||||
q = select(TechnicalDebt)
|
||||
if domain:
|
||||
q = q.where(TechnicalDebt.domain == domain)
|
||||
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:
|
||||
td = TechnicalDebt(**body.model_dump())
|
||||
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
|
||||
@@ -4,6 +4,8 @@ from api.schemas.task import TaskCreate, TaskUpdate, TaskRead
|
||||
from api.schemas.decision import DecisionCreate, DecisionUpdate, DecisionRead
|
||||
from api.schemas.progress_event import ProgressEventCreate, ProgressEventRead
|
||||
from api.schemas.state import StateSummary, Totals, TopicTotals, WorkstreamTotals, TaskTotals, DecisionTotals
|
||||
from api.schemas.extension_point import EPCreate, EPUpdate, EPRead
|
||||
from api.schemas.technical_debt import TDCreate, TDUpdate, TDRead
|
||||
|
||||
__all__ = [
|
||||
"TopicCreate", "TopicUpdate", "TopicRead", "TopicWithWorkstreams",
|
||||
@@ -12,4 +14,6 @@ __all__ = [
|
||||
"DecisionCreate", "DecisionUpdate", "DecisionRead",
|
||||
"ProgressEventCreate", "ProgressEventRead",
|
||||
"StateSummary", "Totals", "TopicTotals", "WorkstreamTotals", "TaskTotals", "DecisionTotals",
|
||||
"EPCreate", "EPUpdate", "EPRead",
|
||||
"TDCreate", "TDUpdate", "TDRead",
|
||||
]
|
||||
|
||||
53
api/schemas/extension_point.py
Normal file
53
api/schemas/extension_point.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from api.models.extension_point import EPStatus
|
||||
|
||||
VALID_DOMAINS = {
|
||||
"custodian", "railiance", "markitect",
|
||||
"coulomb_social", "personhood", "foerster_capabilities",
|
||||
}
|
||||
VALID_PRIORITIES = {"low", "medium", "high", "critical"}
|
||||
|
||||
|
||||
class EPCreate(BaseModel):
|
||||
ep_id: str | None = None
|
||||
domain: str
|
||||
title: str
|
||||
description: str | None = None
|
||||
location: str | None = None
|
||||
ep_type: str = "other"
|
||||
status: EPStatus = EPStatus.open
|
||||
priority: str = "medium"
|
||||
topic_id: uuid.UUID | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class EPUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
location: str | None = None
|
||||
ep_type: str | None = None
|
||||
status: EPStatus | None = None
|
||||
priority: str | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class EPRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
ep_id: str | None = None
|
||||
domain: str
|
||||
title: str
|
||||
description: str | None = None
|
||||
location: str | None = None
|
||||
ep_type: str
|
||||
status: EPStatus
|
||||
priority: str
|
||||
topic_id: uuid.UUID | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
49
api/schemas/technical_debt.py
Normal file
49
api/schemas/technical_debt.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from api.models.technical_debt import TDStatus
|
||||
|
||||
VALID_SEVERITIES = {"low", "medium", "high", "critical"}
|
||||
|
||||
|
||||
class TDCreate(BaseModel):
|
||||
td_id: str | None = None
|
||||
domain: str
|
||||
title: str
|
||||
description: str | None = None
|
||||
location: str | None = None
|
||||
debt_type: str = "other"
|
||||
severity: str = "medium"
|
||||
status: TDStatus = TDStatus.open
|
||||
topic_id: uuid.UUID | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class TDUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
location: str | None = None
|
||||
debt_type: str | None = None
|
||||
severity: str | None = None
|
||||
status: TDStatus | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class TDRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
td_id: str | None = None
|
||||
domain: str
|
||||
title: str
|
||||
description: str | None = None
|
||||
location: str | None = None
|
||||
debt_type: str
|
||||
severity: str
|
||||
status: TDStatus
|
||||
topic_id: uuid.UUID | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
Reference in New Issue
Block a user