import uuid import socket from pathlib import Path from typing import Any 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.managed_repo import ManagedRepo from api.models.workstream import Workstream from api.schemas.workstream import ( WorkstreamCreate, WorkstreamRead, WorkstreamStatus, WorkstreamUpdate, ) router = APIRouter(prefix="/workstreams", tags=["workstreams"]) def _repo_path(repo: ManagedRepo) -> Path | None: hostname = socket.gethostname() candidates = [] host_paths = repo.host_paths or {} if host_paths.get(hostname): candidates.append(host_paths[hostname]) if repo.local_path: candidates.append(repo.local_path) for raw in candidates: path = Path(raw).expanduser() if path.is_dir(): return path return None def _frontmatter(path: Path) -> dict[str, Any]: try: text = path.read_text(encoding="utf-8") except OSError: return {} if not text.startswith("---\n"): return {} end = text.find("\n---", 4) if end == -1: return {} data: dict[str, Any] = {} for raw_line in text[4:end].splitlines(): line = raw_line.strip() if not line or line.startswith("#") or ":" not in line: continue key, value = line.split(":", 1) value = value.strip() if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: value = value[1:-1] data[key.strip()] = value return data @router.get("/", response_model=list[WorkstreamRead]) async def list_workstreams( topic_id: uuid.UUID | None = None, repo_id: uuid.UUID | None = None, repo_goal_id: uuid.UUID | None = None, status: WorkstreamStatus | None = None, owner: str | None = None, slug: str | None = None, session: AsyncSession = Depends(get_session), ) -> list[Workstream]: q = select(Workstream) if topic_id: q = q.where(Workstream.topic_id == topic_id) if repo_id: q = q.where(Workstream.repo_id == repo_id) if repo_goal_id: q = q.where(Workstream.repo_goal_id == repo_goal_id) if status: q = q.where(Workstream.status == status) if owner: q = q.where(Workstream.owner == owner) if slug: q = q.where(Workstream.slug == slug) q = q.order_by( Workstream.planning_priority.asc().nullslast(), Workstream.planning_order.asc().nullslast(), Workstream.updated_at.desc(), ) result = await session.execute(q) return list(result.scalars().all()) @router.get("/workplan-index") async def workplan_index(session: AsyncSession = Depends(get_session)) -> dict[str, Any]: """Map file-backed workstream ids to their local workplan filenames.""" result = await session.execute( select(ManagedRepo).where(ManagedRepo.status == "active").order_by(ManagedRepo.slug) ) index: dict[str, Any] = {} for repo in result.scalars().all(): root = _repo_path(repo) if root is None: continue for directory, archived in ( (root / "workplans", False), (root / "workplans" / "archived", True), ): if not directory.is_dir(): continue for path in sorted(directory.glob("*.md")): data = _frontmatter(path) workstream_id = data.get("state_hub_workstream_id") if not workstream_id: continue index[str(workstream_id)] = { "filename": path.name, "relative_path": str(path.relative_to(root)), "repo_slug": repo.slug, "archived": archived, } return {"workstreams": index} @router.post("/", response_model=WorkstreamRead, status_code=status.HTTP_201_CREATED) async def create_workstream( body: WorkstreamCreate, session: AsyncSession = Depends(get_session), ) -> Workstream: ws = Workstream(**body.model_dump()) session.add(ws) await session.commit() await session.refresh(ws) return ws @router.get("/{workstream_id}", response_model=WorkstreamRead) async def get_workstream( workstream_id: uuid.UUID, session: AsyncSession = Depends(get_session), ) -> Workstream: ws = await session.get(Workstream, workstream_id) if ws is None: raise HTTPException(status_code=404, detail="Workstream not found") return ws @router.patch("/{workstream_id}", response_model=WorkstreamRead) async def update_workstream( workstream_id: uuid.UUID, body: WorkstreamUpdate, session: AsyncSession = Depends(get_session), ) -> Workstream: ws = await session.get(Workstream, workstream_id) if ws is None: raise HTTPException(status_code=404, detail="Workstream not found") for field, value in body.model_dump(exclude_unset=True).items(): setattr(ws, field, value) await session.commit() await session.refresh(ws) return ws @router.delete("/{workstream_id}", response_model=WorkstreamRead) async def archive_workstream( workstream_id: uuid.UUID, session: AsyncSession = Depends(get_session), ) -> Workstream: ws = await session.get(Workstream, workstream_id) if ws is None: raise HTTPException(status_code=404, detail="Workstream not found") ws.status = "archived" await session.commit() await session.refresh(ws) return ws