From a65987a724f9bbf3aae45c8bdf560b9af482bce6 Mon Sep 17 00:00:00 2001 From: markov Date: Fri, 27 Feb 2026 23:32:27 +0100 Subject: [PATCH] Task dependencies (Jira-style links) - TaskLink model: source_id, target_id, link_type (blocks|depends_on|relates_to) - TaskLinkType enum in enums.py - API: POST/GET/DELETE /tasks/{id}/links - GET returns both outgoing and incoming links with reverse types (blocked_by, required_by) --- src/tracker/api/tasks.py | 132 ++++++++++++++++++++++++++++++++- src/tracker/enums.py | 7 ++ src/tracker/models/__init__.py | 3 +- src/tracker/models/task.py | 12 +++ 4 files changed, 151 insertions(+), 3 deletions(-) diff --git a/src/tracker/api/tasks.py b/src/tracker/api/tasks.py index df5074d..28fe3b1 100644 --- a/src/tracker/api/tasks.py +++ b/src/tracker/api/tasks.py @@ -12,9 +12,9 @@ from sqlalchemy.orm import selectinload, joinedload from ..database import get_db from ..enums import ( - AuthorType, ChatKind, TaskActionType, TaskPriority, TaskStatus, TaskType, WSEventType, + AuthorType, ChatKind, TaskActionType, TaskLinkType, TaskPriority, TaskStatus, TaskType, WSEventType, ) -from ..models import Task, Step, Project, Member, Message, Chat, TaskAction +from ..models import Task, Step, Project, Member, Message, Chat, TaskAction, TaskLink from .auth import get_current_member from .schemas import TaskOut, MessageOut, MemberBrief from .converters import task_out, message_out, member_brief @@ -575,3 +575,131 @@ async def unwatch_task( "watcher_ids", str(current_member.id), None) await db.commit() return {"ok": True, "watcher_ids": [str(w) for w in (task.watcher_ids or [])]} + + +# --- Task Links (Dependencies) --- + +class TaskLinkCreate(BaseModel): + target_id: str + link_type: TaskLinkType + + +class TaskLinkOut(BaseModel): + id: str + source_id: str + target_id: str + link_type: str + target_key: str | None = None + target_title: str | None = None + source_key: str | None = None + source_title: str | None = None + + +@router.post("/tasks/{task_id}/links", response_model=TaskLinkOut) +async def create_task_link( + task_id: str, + req: TaskLinkCreate, + current_member: Member = Depends(get_current_member), + db: AsyncSession = Depends(get_db), +): + source = await _get_task(task_id, db) + target = await _get_task(req.target_id, db) + + if source.id == target.id: + raise HTTPException(400, "Cannot link task to itself") + + # Check for duplicate + existing = await db.execute( + select(TaskLink).where( + TaskLink.source_id == source.id, + TaskLink.target_id == target.id, + ) + ) + if existing.scalar_one_or_none(): + raise HTTPException(409, "Link already exists") + + link = TaskLink( + source_id=source.id, + target_id=target.id, + link_type=req.link_type, + ) + db.add(link) + await db.commit() + await db.refresh(link) + + return TaskLinkOut( + id=str(link.id), + source_id=str(link.source_id), + target_id=str(link.target_id), + link_type=link.link_type, + target_key=f"{target.project.slug.upper()[:2]}-{target.number}" if hasattr(target, 'project') else None, + target_title=target.title, + ) + + +@router.get("/tasks/{task_id}/links", response_model=list[TaskLinkOut]) +async def list_task_links( + task_id: str, + db: AsyncSession = Depends(get_db), +): + task = await _get_task(task_id, db) + + # Links where this task is source + outgoing = await db.execute( + select(TaskLink).where(TaskLink.source_id == task.id) + .options(joinedload(TaskLink.target)) + ) + # Links where this task is target + incoming = await db.execute( + select(TaskLink).where(TaskLink.target_id == task.id) + .options(joinedload(TaskLink.source)) + ) + + result = [] + for link in outgoing.scalars(): + t = link.target + result.append(TaskLinkOut( + id=str(link.id), + source_id=str(link.source_id), + target_id=str(link.target_id), + link_type=link.link_type, + target_title=t.title if t else None, + )) + for link in incoming.scalars(): + # Reverse: if source blocks this task, show as "blocked by" + s = link.source + reverse_type = link.link_type + if link.link_type == TaskLinkType.BLOCKS: + reverse_type = "blocked_by" + elif link.link_type == TaskLinkType.DEPENDS_ON: + reverse_type = "required_by" + result.append(TaskLinkOut( + id=str(link.id), + source_id=str(link.source_id), + target_id=str(link.target_id), + link_type=reverse_type, + source_title=s.title if s else None, + )) + + return result + + +@router.delete("/tasks/{task_id}/links/{link_id}") +async def delete_task_link( + task_id: str, + link_id: str, + current_member: Member = Depends(get_current_member), + db: AsyncSession = Depends(get_db), +): + link = await db.execute( + select(TaskLink).where(TaskLink.id == uuid.UUID(link_id)) + ) + link_obj = link.scalar_one_or_none() + if not link_obj: + raise HTTPException(404, "Link not found") + if str(link_obj.source_id) != task_id and str(link_obj.target_id) != task_id: + raise HTTPException(400, "Link does not belong to this task") + + await db.delete(link_obj) + await db.commit() + return {"ok": True} diff --git a/src/tracker/enums.py b/src/tracker/enums.py index 12dce7a..245ea7e 100644 --- a/src/tracker/enums.py +++ b/src/tracker/enums.py @@ -3,6 +3,13 @@ from enum import StrEnum +class TaskLinkType(StrEnum): + """Jira-style task link types. source → target relationship.""" + BLOCKS = "blocks" # source blocks target (target is blocked by source) + DEPENDS_ON = "depends_on" # source depends on target (source is blocked by target) + RELATES_TO = "relates_to" # bidirectional, informational + + class MemberType(StrEnum): HUMAN = "human" AGENT = "agent" diff --git a/src/tracker/models/__init__.py b/src/tracker/models/__init__.py index 9c53287..8fb957c 100644 --- a/src/tracker/models/__init__.py +++ b/src/tracker/models/__init__.py @@ -3,7 +3,7 @@ from .base import Base from .member import AgentConfig, Member from .project import Project, ProjectMember -from .task import Step, Task, TaskAction +from .task import Step, Task, TaskAction, TaskLink from .chat import Attachment, Chat, Message from .project_file import ProjectFile @@ -15,6 +15,7 @@ __all__ = [ "ProjectMember", "Task", "TaskAction", + "TaskLink", "Step", "Chat", "Message", diff --git a/src/tracker/models/task.py b/src/tracker/models/task.py index ae29253..325ae74 100644 --- a/src/tracker/models/task.py +++ b/src/tracker/models/task.py @@ -53,6 +53,18 @@ class Step(Base): task: Mapped["Task"] = relationship(back_populates="steps") +class TaskLink(Base): + """Jira-style task dependencies/links.""" + __tablename__ = "task_links" + + source_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), nullable=False) + target_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), nullable=False) + link_type: Mapped[str] = mapped_column(String(20), nullable=False) # blocks | depends_on | relates_to + + source: Mapped["Task"] = relationship(foreign_keys=[source_id]) + target: Mapped["Task"] = relationship(foreign_keys=[target_id]) + + class TaskAction(Base): __tablename__ = "task_actions"