Labels, auto-assign, subscription modes, init handshake
Some checks failed
Deploy Tracker / deploy (push) Failing after 4s
Some checks failed
Deploy Tracker / deploy (push) Failing after 4s
- Label + TaskLabel models (project-scoped labels with colors) - Labels API: CRUD for labels + task-label associations - TaskLinkType.ASSIGNED subscription mode - Auto-assign: task labels matched against agent capabilities - auth.ok: includes assigned_tasks for agents - ListenMode.ASSIGNED added to enums
This commit is contained in:
parent
a65987a724
commit
93cdd38b96
121
src/tracker/api/labels.py
Normal file
121
src/tracker/api/labels.py
Normal file
@ -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}
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -40,6 +40,7 @@ class ChatKind(StrEnum):
|
||||
class ListenMode(StrEnum):
|
||||
ALL = "all"
|
||||
MENTIONS = "mentions"
|
||||
ASSIGNED = "assigned" # assigned tasks + mentions
|
||||
NONE = "none"
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"] = {
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user