Task dependencies (Jira-style links)
Some checks failed
Deploy Tracker / deploy (push) Failing after 3s

- 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)
This commit is contained in:
markov 2026-02-27 23:32:27 +01:00
parent 0aab543826
commit a65987a724
4 changed files with 151 additions and 3 deletions

View File

@ -12,9 +12,9 @@ from sqlalchemy.orm import selectinload, joinedload
from ..database import get_db from ..database import get_db
from ..enums import ( 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 .auth import get_current_member
from .schemas import TaskOut, MessageOut, MemberBrief from .schemas import TaskOut, MessageOut, MemberBrief
from .converters import task_out, message_out, member_brief from .converters import task_out, message_out, member_brief
@ -575,3 +575,131 @@ async def unwatch_task(
"watcher_ids", str(current_member.id), None) "watcher_ids", str(current_member.id), None)
await db.commit() await db.commit()
return {"ok": True, "watcher_ids": [str(w) for w in (task.watcher_ids or [])]} 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}

View File

@ -3,6 +3,13 @@
from enum import StrEnum 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): class MemberType(StrEnum):
HUMAN = "human" HUMAN = "human"
AGENT = "agent" AGENT = "agent"

View File

@ -3,7 +3,7 @@
from .base import Base from .base import Base
from .member import AgentConfig, Member from .member import AgentConfig, Member
from .project import Project, ProjectMember 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 .chat import Attachment, Chat, Message
from .project_file import ProjectFile from .project_file import ProjectFile
@ -15,6 +15,7 @@ __all__ = [
"ProjectMember", "ProjectMember",
"Task", "Task",
"TaskAction", "TaskAction",
"TaskLink",
"Step", "Step",
"Chat", "Chat",
"Message", "Message",

View File

@ -53,6 +53,18 @@ class Step(Base):
task: Mapped["Task"] = relationship(back_populates="steps") 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): class TaskAction(Base):
__tablename__ = "task_actions" __tablename__ = "task_actions"