diff --git a/src/tracker/api/labels.py b/src/tracker/api/labels.py new file mode 100644 index 0000000..82133b8 --- /dev/null +++ b/src/tracker/api/labels.py @@ -0,0 +1,121 @@ +"""Labels API — CRUD for project-scoped labels.""" + +import uuid + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..database import get_db +from ..models import Label, TaskLabel, Member +from .auth import get_current_member + +router = APIRouter(prefix="/api/v1", tags=["labels"]) + + +class LabelCreate(BaseModel): + name: str + color: str = "#6366f1" + + +class LabelUpdate(BaseModel): + name: str | None = None + color: str | None = None + + +class LabelOut(BaseModel): + id: str + project_id: str + name: str + color: str + + +@router.get("/projects/{project_id}/labels", response_model=list[LabelOut]) +async def list_labels(project_id: str, db: AsyncSession = Depends(get_db)): + result = await db.execute( + select(Label).where(Label.project_id == uuid.UUID(project_id)).order_by(Label.name) + ) + return [LabelOut(id=str(l.id), project_id=str(l.project_id), name=l.name, color=l.color) for l in result.scalars()] + + +@router.post("/projects/{project_id}/labels", response_model=LabelOut) +async def create_label( + project_id: str, + req: LabelCreate, + current_member: Member = Depends(get_current_member), + db: AsyncSession = Depends(get_db), +): + label = Label(project_id=uuid.UUID(project_id), name=req.name, color=req.color) + db.add(label) + await db.commit() + await db.refresh(label) + return LabelOut(id=str(label.id), project_id=str(label.project_id), name=label.name, color=label.color) + + +@router.patch("/labels/{label_id}", response_model=LabelOut) +async def update_label( + label_id: str, + req: LabelUpdate, + current_member: Member = Depends(get_current_member), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(Label).where(Label.id == uuid.UUID(label_id))) + label = result.scalar_one_or_none() + if not label: + raise HTTPException(404, "Label not found") + if req.name is not None: + label.name = req.name + if req.color is not None: + label.color = req.color + await db.commit() + await db.refresh(label) + return LabelOut(id=str(label.id), project_id=str(label.project_id), name=label.name, color=label.color) + + +@router.delete("/labels/{label_id}") +async def delete_label( + label_id: str, + current_member: Member = Depends(get_current_member), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(Label).where(Label.id == uuid.UUID(label_id))) + label = result.scalar_one_or_none() + if not label: + raise HTTPException(404, "Label not found") + # Remove task associations + await db.execute(select(TaskLabel).where(TaskLabel.label_id == label.id)) + await db.delete(label) + await db.commit() + return {"ok": True} + + +# Task-label associations +@router.post("/tasks/{task_id}/labels/{label_id}") +async def add_task_label( + task_id: str, + label_id: str, + current_member: Member = Depends(get_current_member), + db: AsyncSession = Depends(get_db), +): + tl = TaskLabel(task_id=uuid.UUID(task_id), label_id=uuid.UUID(label_id)) + db.add(tl) + await db.commit() + return {"ok": True} + + +@router.delete("/tasks/{task_id}/labels/{label_id}") +async def remove_task_label( + task_id: str, + label_id: str, + current_member: Member = Depends(get_current_member), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(TaskLabel).where(TaskLabel.task_id == uuid.UUID(task_id), TaskLabel.label_id == uuid.UUID(label_id)) + ) + tl = result.scalar_one_or_none() + if tl: + await db.delete(tl) + await db.commit() + return {"ok": True} diff --git a/src/tracker/api/tasks.py b/src/tracker/api/tasks.py index 28fe3b1..1b45f30 100644 --- a/src/tracker/api/tasks.py +++ b/src/tracker/api/tasks.py @@ -12,7 +12,7 @@ from sqlalchemy.orm import selectinload, joinedload from ..database import get_db from ..enums import ( - AuthorType, ChatKind, TaskActionType, TaskLinkType, TaskPriority, TaskStatus, TaskType, WSEventType, + AuthorType, ChatKind, MemberType, TaskActionType, TaskLinkType, TaskPriority, TaskStatus, TaskType, WSEventType, ) from ..models import Task, Step, Project, Member, Message, Chat, TaskAction, TaskLink from .auth import get_current_member @@ -282,6 +282,26 @@ async def create_task( db.add(task) await db.flush() + # Auto-assign by capabilities if no assignee set + if not assignee_id and req.labels: + from ..models import AgentConfig, ProjectMember + agents_q = await db.execute( + select(Member, AgentConfig) + .join(AgentConfig, AgentConfig.member_id == Member.id) + .join(ProjectMember, ProjectMember.member_id == Member.id) + .where( + ProjectMember.project_id == project.id, + Member.type == MemberType.AGENT, + Member.is_active == True, + ) + ) + for agent, config in agents_q: + caps = config.capabilities or [] + if any(label in caps for label in req.labels): + task.assignee_id = agent.id + task.watcher_ids = [agent.id] + break + await _record_action(db, task, current_member, TaskActionType.CREATED) await db.commit() diff --git a/src/tracker/app.py b/src/tracker/app.py index 8c316ef..12153b8 100644 --- a/src/tracker/app.py +++ b/src/tracker/app.py @@ -169,7 +169,7 @@ app.add_middleware( ) # Routers -from .api import auth, members, projects, tasks, messages, steps, attachments, project_files # noqa: E402 +from .api import auth, members, projects, tasks, messages, steps, attachments, project_files, labels # noqa: E402 from .ws.handler import router as ws_router # noqa: E402 app.include_router(auth.router, prefix="/api/v1") @@ -180,6 +180,7 @@ app.include_router(messages.router, prefix="/api/v1") app.include_router(steps.router, prefix="/api/v1") app.include_router(attachments.router, prefix="/api/v1") app.include_router(project_files.router, prefix="/api/v1") +app.include_router(labels.router) app.include_router(ws_router) diff --git a/src/tracker/enums.py b/src/tracker/enums.py index 245ea7e..b38890c 100644 --- a/src/tracker/enums.py +++ b/src/tracker/enums.py @@ -40,6 +40,7 @@ class ChatKind(StrEnum): class ListenMode(StrEnum): ALL = "all" MENTIONS = "mentions" + ASSIGNED = "assigned" # assigned tasks + mentions NONE = "none" diff --git a/src/tracker/models/__init__.py b/src/tracker/models/__init__.py index 8fb957c..9e1ba32 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, TaskLink +from .task import Label, Step, Task, TaskAction, TaskLabel, TaskLink from .chat import Attachment, Chat, Message from .project_file import ProjectFile diff --git a/src/tracker/models/task.py b/src/tracker/models/task.py index 325ae74..0441533 100644 --- a/src/tracker/models/task.py +++ b/src/tracker/models/task.py @@ -53,6 +53,25 @@ class Step(Base): task: Mapped["Task"] = relationship(back_populates="steps") +class Label(Base): + """Project-scoped labels (like Jira).""" + __tablename__ = "labels" + + project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False) + name: Mapped[str] = mapped_column(String(100), nullable=False) + color: Mapped[str] = mapped_column(String(7), default="#6366f1") # hex color + + project: Mapped["Project"] = relationship() + + +class TaskLabel(Base): + """Many-to-many: tasks ↔ labels.""" + __tablename__ = "task_labels" + + task_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), nullable=False) + label_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("labels.id", ondelete="CASCADE"), nullable=False) + + class TaskLink(Base): """Jira-style task dependencies/links.""" __tablename__ = "task_links" diff --git a/src/tracker/ws/handler.py b/src/tracker/ws/handler.py index b4ceed2..e259ca7 100644 --- a/src/tracker/ws/handler.py +++ b/src/tracker/ws/handler.py @@ -241,7 +241,20 @@ async def _authenticate(ws: WebSocket, token: str, on_behalf_of: str | None = No "online": online_list, } - # For agents: include full config from DB + # For agents: include assigned tasks + config + if effective_member.type == MemberType.AGENT: + from ..models import Task as TaskModel + assigned_q = await db.execute( + select(TaskModel).where( + TaskModel.assignee_id == effective_member.id, + TaskModel.status.in_(["todo", "in_progress", "in_review"]), + ) + ) + auth_data["assigned_tasks"] = [ + {"id": str(t.id), "title": t.title, "status": t.status, "project_id": str(t.project_id)} + for t in assigned_q.scalars() + ] + if effective_member.agent_config: ac = effective_member.agent_config auth_data["agent_config"] = { diff --git a/src/tracker/ws/manager.py b/src/tracker/ws/manager.py index 2092ded..dc0ccc0 100644 --- a/src/tracker/ws/manager.py +++ b/src/tracker/ws/manager.py @@ -144,7 +144,7 @@ class ConnectionManager: continue # Regular messages: chat_listen filter - if client.chat_listen == ListenMode.MENTIONS: + if client.chat_listen in (ListenMode.MENTIONS, ListenMode.ASSIGNED): if client.member_id not in mention_ids and client.member_slug not in mention_slugs: continue await self.send_to_session(session_id, payload) @@ -171,7 +171,7 @@ class ConnectionManager: if client.task_listen == ListenMode.ALL: await self.send_to_session(session_id, payload) continue - # For "mentions" mode: check if agent is assignee/reviewer/watcher + # For "mentions"/"assigned" mode: check if agent is assignee/reviewer/watcher if client.member_id and ( client.member_id in (assignee_id, reviewer_id) or client.member_id in watcher_ids