refactor: replace issues backend with milestone tasks

This commit is contained in:
zhi
2026-03-16 13:22:14 +00:00
parent dc5d06489d
commit 214a9b109d
20 changed files with 836 additions and 1066 deletions

View File

@@ -5,6 +5,7 @@ from sqlalchemy.orm import Session
from app.core.config import get_db
from app.models import models
from app.models.task import Task
from app.schemas import schemas
from app.api.deps import get_current_user_or_apikey
from app.api.rbac import check_project_role
@@ -13,25 +14,24 @@ from app.models.notification import Notification as NotificationModel
router = APIRouter(tags=["Comments"])
def _notify_if_needed(db, issue_id, user_ids, ntype, title):
def _notify_if_needed(db, task_id, user_ids, ntype, title):
"""Helper to notify multiple users."""
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
if not issue:
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
return
for uid in set(user_ids):
if uid:
n = NotificationModel(user_id=uid, type=ntype, title=title, entity_type="issue", entity_id=issue_id)
n = NotificationModel(user_id=uid, type=ntype, title=title, entity_type="task", entity_id=task_id)
db.add(n)
db.commit()
@router.post("/comments", response_model=schemas.CommentResponse, status_code=status.HTTP_201_CREATED)
def create_comment(comment: schemas.CommentCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
# Get project_id from issue first
issue = db.query(models.Issue).filter(models.Issue.id == comment.issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="Issue not found")
check_project_role(db, current_user.id, issue.project_id, min_role="viewer")
task = db.query(Task).filter(Task.id == comment.task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
check_project_role(db, current_user.id, task.project_id, min_role="viewer")
db_comment = models.Comment(**comment.model_dump())
db.add(db_comment)
@@ -40,19 +40,19 @@ def create_comment(comment: schemas.CommentCreate, db: Session = Depends(get_db)
# Notify reporter and assignee (but not the commenter themselves)
notify_users = []
if issue.reporter_id != current_user.id:
notify_users.append(issue.reporter_id)
if issue.assignee_id and issue.assignee_id != current_user.id:
notify_users.append(issue.assignee_id)
if task.reporter_id != current_user.id:
notify_users.append(task.reporter_id)
if task.assignee_id and task.assignee_id != current_user.id:
notify_users.append(task.assignee_id)
if notify_users:
_notify_if_needed(db, issue.id, notify_users, "comment_added", f"New comment on: {issue.title[:50]}")
_notify_if_needed(db, task.id, notify_users, "comment_added", f"New comment on: {task.title[:50]}")
return db_comment
@router.get("/issues/{issue_id}/comments", response_model=List[schemas.CommentResponse])
def list_comments(issue_id: int, db: Session = Depends(get_db)):
return db.query(models.Comment).filter(models.Comment.issue_id == issue_id).all()
@router.get("/tasks/{task_id}/comments", response_model=List[schemas.CommentResponse])
def list_comments(task_id: int, db: Session = Depends(get_db)):
return db.query(models.Comment).filter(models.Comment.task_id == task_id).all()
@router.patch("/comments/{comment_id}", response_model=schemas.CommentResponse)
@@ -60,10 +60,10 @@ def update_comment(comment_id: int, comment_update: schemas.CommentUpdate, db: S
comment = db.query(models.Comment).filter(models.Comment.id == comment_id).first()
if not comment:
raise HTTPException(status_code=404, detail="Comment not found")
issue = db.query(models.Issue).filter(models.Issue.id == comment.issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="Issue not found")
check_project_role(db, current_user.id, issue.project_id, min_role="viewer")
task = db.query(Task).filter(Task.id == comment.task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
check_project_role(db, current_user.id, task.project_id, min_role="viewer")
for field, value in comment_update.model_dump(exclude_unset=True).items():
setattr(comment, field, value)
db.commit()
@@ -76,11 +76,10 @@ def delete_comment(comment_id: int, db: Session = Depends(get_db), current_user:
comment = db.query(models.Comment).filter(models.Comment.id == comment_id).first()
if not comment:
raise HTTPException(status_code=404, detail="Comment not found")
# Get issue to check project role
issue = db.query(models.Issue).filter(models.Issue.id == comment.issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="Issue not found")
check_project_role(db, current_user.id, issue.project_id, min_role="dev")
task = db.query(Task).filter(Task.id == comment.task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
check_project_role(db, current_user.id, task.project_id, min_role="dev")
db.delete(comment)
db.commit()
return None

View File

@@ -1,340 +0,0 @@
"""Issues router."""
import math
from typing import List
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from sqlalchemy.orm import Session
from pydantic import BaseModel
from app.core.config import get_db
from app.models import models
from app.schemas import schemas
from app.services.webhook import fire_webhooks_sync
from app.models.notification import Notification as NotificationModel
from app.api.deps import get_current_user_or_apikey
from app.api.rbac import check_project_role
from app.services.activity import log_activity
router = APIRouter(tags=["Issues"])
# ---- Type / Subtype validation ----
ISSUE_SUBTYPE_MAP = {
'meeting': {'conference', 'handover', 'recap'},
'support': {'access', 'information'},
'issue': {'infrastructure', 'performance', 'regression', 'security', 'user_experience', 'defect'},
'maintenance': {'deploy', 'release'},
'review': {'code_review', 'decision_review', 'function_review'},
'story': {'feature', 'improvement', 'refactor'},
'test': {'regression', 'security', 'smoke', 'stress'},
'research': set(),
'task': {'defect'},
'resolution': set(),
}
ALLOWED_ISSUE_TYPES = set(ISSUE_SUBTYPE_MAP.keys())
def _validate_issue_type_subtype(issue_type: str | None, issue_subtype: str | None, require_subtype: bool = False):
if issue_type is None:
raise HTTPException(status_code=400, detail='issue_type is required')
if issue_type not in ALLOWED_ISSUE_TYPES:
raise HTTPException(status_code=400, detail=f'Invalid issue_type: {issue_type}')
allowed = ISSUE_SUBTYPE_MAP.get(issue_type, set())
if issue_subtype:
if issue_subtype not in allowed:
raise HTTPException(status_code=400, detail=f'Invalid issue_subtype for {issue_type}: {issue_subtype}')
else:
if require_subtype and allowed:
raise HTTPException(status_code=400, detail=f'issue_subtype required for type: {issue_type}')
def _notify_user(db, user_id, ntype, title, message=None, entity_type=None, entity_id=None):
n = NotificationModel(user_id=user_id, type=ntype, title=title, message=message,
entity_type=entity_type, entity_id=entity_id)
db.add(n)
db.commit()
return n
# ---- CRUD ----
@router.post("/issues", response_model=schemas.IssueResponse, status_code=status.HTTP_201_CREATED)
def create_issue(issue: schemas.IssueCreate, bg: BackgroundTasks, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
check_project_role(db, current_user.id, issue.project_id, min_role="dev")
db_issue = models.Issue(**issue.model_dump())
db.add(db_issue)
db.commit()
db.refresh(db_issue)
event = "resolution.created" if db_issue.issue_type == "resolution" else "issue.created"
bg.add_task(fire_webhooks_sync, event,
{"issue_id": db_issue.id, "title": db_issue.title, "type": db_issue.issue_type, "status": db_issue.status},
db_issue.project_id, db)
log_activity(db, "issue.created", "issue", db_issue.id, current_user.id, {"title": db_issue.title})
return db_issue
@router.get("/issues")
def list_issues(
project_id: int = None, issue_status: str = None, issue_type: str = None, issue_subtype: str = None,
assignee_id: int = None, tag: str = None,
sort_by: str = "created_at", sort_order: str = "desc",
page: int = 1, page_size: int = 50,
db: Session = Depends(get_db)
):
"""List issues with filtering, sorting, and pagination metadata."""
query = db.query(models.Issue)
if project_id:
query = query.filter(models.Issue.project_id == project_id)
if issue_status:
query = query.filter(models.Issue.status == issue_status)
if issue_type:
query = query.filter(models.Issue.issue_type == issue_type)
if issue_subtype:
query = query.filter(models.Issue.issue_subtype == issue_subtype)
if assignee_id:
query = query.filter(models.Issue.assignee_id == assignee_id)
if tag:
query = query.filter(models.Issue.tags.contains(tag))
sort_fields = {
"created_at": models.Issue.created_at, "updated_at": models.Issue.updated_at,
"priority": models.Issue.priority, "title": models.Issue.title,
"due_date": models.Issue.due_date, "status": models.Issue.status,
}
sort_col = sort_fields.get(sort_by, models.Issue.created_at)
query = query.order_by(sort_col.asc() if sort_order == "asc" else sort_col.desc())
total = query.count()
page = max(1, page)
page_size = min(max(1, page_size), 200)
total_pages = math.ceil(total / page_size) if total else 1
items = query.offset((page - 1) * page_size).limit(page_size).all()
return {"items": [schemas.IssueResponse.model_validate(i) for i in items],
"total": total, "page": page, "page_size": page_size, "total_pages": total_pages}
@router.get("/issues/overdue", response_model=List[schemas.IssueResponse])
def list_overdue_issues(project_id: int = None, db: Session = Depends(get_db)):
query = db.query(models.Issue).filter(
models.Issue.due_date != None,
models.Issue.due_date < datetime.utcnow(),
models.Issue.status.notin_(["resolved", "closed"])
)
if project_id:
query = query.filter(models.Issue.project_id == project_id)
return query.order_by(models.Issue.due_date.asc()).all()
@router.get("/issues/{issue_id}", response_model=schemas.IssueResponse)
def get_issue(issue_id: int, db: Session = Depends(get_db)):
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="Issue not found")
return issue
@router.patch("/issues/{issue_id}", response_model=schemas.IssueResponse)
def update_issue(issue_id: int, issue_update: schemas.IssueUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
if issue:
check_project_role(db, current_user.id, issue.project_id, min_role="dev")
if not issue:
raise HTTPException(status_code=404, detail="Issue not found")
for field, value in update_data.items():
setattr(issue, field, value)
db.commit()
db.refresh(issue)
return issue
@router.delete("/issues/{issue_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_issue(issue_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
if issue:
check_project_role(db, current_user.id, issue.project_id, min_role="mgr")
if not issue:
raise HTTPException(status_code=404, detail="Issue not found")
log_activity(db, "issue.deleted", "issue", issue.id, current_user.id, {"title": issue.title})
db.delete(issue)
db.commit()
return None
# ---- Transition ----
@router.post("/issues/{issue_id}/transition", response_model=schemas.IssueResponse)
def transition_issue(issue_id: int, new_status: str, bg: BackgroundTasks, db: Session = Depends(get_db)):
valid_statuses = ["open", "in_progress", "resolved", "closed", "blocked"]
if new_status not in valid_statuses:
raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of: {valid_statuses}")
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="Issue not found")
old_status = issue.status
issue.status = new_status
db.commit()
db.refresh(issue)
event = "issue.closed" if new_status == "closed" else "issue.updated"
bg.add_task(fire_webhooks_sync, event,
{"issue_id": issue.id, "title": issue.title, "old_status": old_status, "new_status": new_status},
issue.project_id, db)
return issue
# ---- Assignment ----
@router.post("/issues/{issue_id}/assign")
def assign_issue(issue_id: int, assignee_id: int, db: Session = Depends(get_db)):
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="Issue not found")
user = db.query(models.User).filter(models.User.id == assignee_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
issue.assignee_id = assignee_id
db.commit()
db.refresh(issue)
_notify_user(db, assignee_id, "issue.assigned",
f"Issue #{issue.id} assigned to you",
f"'{issue.title}' has been assigned to you.", "issue", issue.id)
return {"issue_id": issue.id, "assignee_id": assignee_id, "title": issue.title}
# ---- Relations ----
class IssueRelation(BaseModel):
parent_id: int
child_id: int
@router.post("/issues/link")
def link_issues(rel: IssueRelation, db: Session = Depends(get_db)):
parent = db.query(models.Issue).filter(models.Issue.id == rel.parent_id).first()
child = db.query(models.Issue).filter(models.Issue.id == rel.child_id).first()
if not parent or not child:
raise HTTPException(status_code=404, detail="Issue not found")
if rel.parent_id == rel.child_id:
raise HTTPException(status_code=400, detail="Cannot link issue to itself")
child.depends_on_id = rel.parent_id
db.commit()
return {"parent_id": rel.parent_id, "child_id": rel.child_id, "status": "linked"}
@router.delete("/issues/link")
def unlink_issues(child_id: int, db: Session = Depends(get_db)):
child = db.query(models.Issue).filter(models.Issue.id == child_id).first()
if not child:
raise HTTPException(status_code=404, detail="Issue not found")
child.depends_on_id = None
db.commit()
return {"child_id": child_id, "status": "unlinked"}
@router.get("/issues/{issue_id}/children", response_model=List[schemas.IssueResponse])
def get_children(issue_id: int, db: Session = Depends(get_db)):
return db.query(models.Issue).filter(models.Issue.depends_on_id == issue_id).all()
# ---- Tags ----
@router.post("/issues/{issue_id}/tags")
def add_tag(issue_id: int, tag: str, db: Session = Depends(get_db)):
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="Issue not found")
current = set(issue.tags.split(",")) if issue.tags else set()
current.add(tag.strip())
current.discard("")
issue.tags = ",".join(sorted(current))
db.commit()
return {"issue_id": issue_id, "tags": list(current)}
@router.delete("/issues/{issue_id}/tags")
def remove_tag(issue_id: int, tag: str, db: Session = Depends(get_db)):
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="Issue not found")
current = set(issue.tags.split(",")) if issue.tags else set()
current.discard(tag.strip())
current.discard("")
issue.tags = ",".join(sorted(current)) if current else None
db.commit()
return {"issue_id": issue_id, "tags": list(current)}
@router.get("/tags")
def list_all_tags(project_id: int = None, db: Session = Depends(get_db)):
query = db.query(models.Issue.tags).filter(models.Issue.tags != None)
if project_id:
query = query.filter(models.Issue.project_id == project_id)
all_tags = set()
for (tags,) in query.all():
for t in tags.split(","):
t = t.strip()
if t:
all_tags.add(t)
return {"tags": sorted(all_tags)}
# ---- Batch ----
class BatchTransition(BaseModel):
issue_ids: List[int]
new_status: str
class BatchAssign(BaseModel):
issue_ids: List[int]
assignee_id: int
@router.post("/issues/batch/transition")
def batch_transition(data: BatchTransition, bg: BackgroundTasks, db: Session = Depends(get_db)):
valid_statuses = ["open", "in_progress", "resolved", "closed", "blocked"]
if data.new_status not in valid_statuses:
raise HTTPException(status_code=400, detail="Invalid status")
updated = []
for issue_id in data.issue_ids:
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
if issue:
old_status = issue.status
issue.status = data.new_status
updated.append({"id": issue.id, "title": issue.title, "old": old_status, "new": data.new_status})
db.commit()
for u in updated:
event = "issue.closed" if data.new_status == "closed" else "issue.updated"
bg.add_task(fire_webhooks_sync, event, u, None, db)
return {"updated": len(updated), "issues": updated}
@router.post("/issues/batch/assign")
def batch_assign(data: BatchAssign, db: Session = Depends(get_db)):
user = db.query(models.User).filter(models.User.id == data.assignee_id).first()
if not user:
raise HTTPException(status_code=404, detail="Assignee not found")
updated = []
for issue_id in data.issue_ids:
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
if issue:
issue.assignee_id = data.assignee_id
updated.append(issue_id)
db.commit()
return {"updated": len(updated), "issue_ids": updated, "assignee_id": data.assignee_id}
# ---- Search ----
@router.get("/search/issues")
def search_issues(q: str, project_id: int = None, page: int = 1, page_size: int = 50,
db: Session = Depends(get_db)):
query = db.query(models.Issue).filter(
(models.Issue.title.contains(q)) | (models.Issue.description.contains(q))
)
if project_id:
query = query.filter(models.Issue.project_id == project_id)
total = query.count()
page = max(1, page)
page_size = min(max(1, page_size), 200)
total_pages = math.ceil(total / page_size) if total else 1
items = query.offset((page - 1) * page_size).limit(page_size).all()
return {"items": [schemas.IssueResponse.model_validate(i) for i in items],
"total": total, "page": page, "page_size": page_size, "total_pages": total_pages}

View File

@@ -1,15 +1,18 @@
"""Milestones API router."""
"""Milestones API router (project-scoped)."""
import json
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List, Optional
from typing import List
from app.core.config import get_db
from app.api.deps import get_current_user_or_apikey
from app.api.rbac import check_project_role
from app.models import models
from app.models.milestone import Milestone
from app.models.task import Task, TaskStatus, TaskPriority
from app.models.support import Support
from app.models.meeting import Meeting
from app.schemas import schemas
router = APIRouter(prefix="/projects/{project_id}/milestones", tags=["Milestones"])
@@ -17,7 +20,7 @@ router = APIRouter(prefix="/projects/{project_id}/milestones", tags=["Milestones
def _serialize_milestone(milestone):
"""Serialize milestone with JSON fields."""
result = {
return {
"id": milestone.id,
"title": milestone.title,
"description": milestone.description,
@@ -30,12 +33,10 @@ def _serialize_milestone(milestone):
"created_at": milestone.created_at,
"updated_at": milestone.updated_at,
}
return result
@router.get("", response_model=List[schemas.MilestoneResponse])
def list_milestones(project_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""List all milestones for a project."""
check_project_role(db, current_user.id, project_id, min_role="viewer")
milestones = db.query(Milestone).filter(Milestone.project_id == project_id).all()
return [_serialize_milestone(m) for m in milestones]
@@ -43,10 +44,8 @@ def list_milestones(project_id: int, db: Session = Depends(get_db), current_user
@router.post("", response_model=schemas.MilestoneResponse, status_code=status.HTTP_201_CREATED)
def create_milestone(project_id: int, milestone: schemas.MilestoneCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""Create a new milestone for a project."""
check_project_role(db, current_user.id, project_id, min_role="mgr")
# Generate milestone_code: projCode:{i:05x}
project = db.query(models.Project).filter(models.Project.id == project_id).first()
project_code = project.project_code if project else f"P{project_id}"
max_ms = db.query(Milestone).filter(Milestone.project_id == project_id).order_by(Milestone.id.desc()).first()
@@ -54,9 +53,7 @@ def create_milestone(project_id: int, milestone: schemas.MilestoneCreate, db: Se
milestone_code = f"{project_code}:{next_num:05x}"
data = milestone.model_dump()
# Remove project_id from data if present (it's already in the URL path)
data.pop('project_id', None)
# Handle JSON fields
if data.get("depend_on_milestones"):
data["depend_on_milestones"] = json.dumps(data["depend_on_milestones"])
if data.get("depend_on_tasks"):
@@ -70,7 +67,6 @@ def create_milestone(project_id: int, milestone: schemas.MilestoneCreate, db: Se
@router.get("/{milestone_id}", response_model=schemas.MilestoneResponse)
def get_milestone(project_id: int, milestone_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""Get a milestone by ID."""
check_project_role(db, current_user.id, project_id, min_role="viewer")
milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
if not milestone:
@@ -80,13 +76,11 @@ def get_milestone(project_id: int, milestone_id: int, db: Session = Depends(get_
@router.patch("/{milestone_id}", response_model=schemas.MilestoneResponse)
def update_milestone(project_id: int, milestone_id: int, milestone: schemas.MilestoneUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""Update a milestone."""
check_project_role(db, current_user.id, project_id, min_role="mgr")
db_milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
if not db_milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
data = milestone.model_dump(exclude_unset=True)
# Handle JSON fields
if "depend_on_milestones" in data:
data["depend_on_milestones"] = json.dumps(data["depend_on_milestones"]) if data["depend_on_milestones"] else None
if "depend_on_tasks" in data:
@@ -100,7 +94,6 @@ def update_milestone(project_id: int, milestone_id: int, milestone: schemas.Mile
@router.delete("/{milestone_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_milestone(project_id: int, milestone_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""Delete a milestone."""
check_project_role(db, current_user.id, project_id, min_role="admin")
db_milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
if not db_milestone:
@@ -110,39 +103,8 @@ def delete_milestone(project_id: int, milestone_id: int, db: Session = Depends(g
return None
# Issue type helpers
ISSUE_TYPE_TASK = "task"
ISSUE_TYPE_SUPPORT = "support"
ISSUE_TYPE_MEETING = "meeting"
@router.post("/{milestone_id}/tasks", status_code=status.HTTP_201_CREATED)
def create_milestone_task(project_id: int, milestone_id: int, issue: schemas.IssueCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""Create a task under a milestone."""
check_project_role(db, current_user.id, project_id, min_role="dev")
milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
# Check if milestone is in progressing status - cannot add new story
if milestone.status and hasattr(milestone.status, 'value') and milestone.status.value == "progressing":
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is in_progress")
issue_data = issue.model_dump()
issue_data["issue_type"] = ISSUE_TYPE_TASK
issue_data["milestone_id"] = milestone_id
issue_data["project_id"] = project_id
issue_data["reporter_id"] = current_user.id
db_issue = models.Issue(**issue_data)
db.add(db_issue)
db.commit()
db.refresh(db_issue)
return db_issue
@router.post("/{milestone_id}/supports", status_code=status.HTTP_201_CREATED)
def create_milestone_support(project_id: int, milestone_id: int, issue: schemas.IssueCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""Create a support request under a milestone."""
@router.post("/{milestone_id}/tasks", status_code=status.HTTP_201_CREATED, tags=["Milestones"])
def create_milestone_task(project_id: int, milestone_id: int, task_data: schemas.TaskCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
check_project_role(db, current_user.id, project_id, min_role="dev")
milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
if not milestone:
@@ -151,98 +113,86 @@ def create_milestone_support(project_id: int, milestone_id: int, issue: schemas.
if milestone.status and hasattr(milestone.status, 'value') and milestone.status.value == "progressing":
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is in_progress")
issue_data = issue.model_dump()
issue_data["issue_type"] = ISSUE_TYPE_SUPPORT
issue_data["milestone_id"] = milestone_id
issue_data["project_id"] = project_id
issue_data["reporter_id"] = current_user.id
db_issue = models.Issue(**issue_data)
db.add(db_issue)
# Generate task_code
milestone_code = milestone.milestone_code or f"m{milestone.id}"
max_task = db.query(Task).filter(Task.milestone_id == milestone.id).order_by(Task.id.desc()).first()
next_num = (max_task.id + 1) if max_task else 1
task_code = f"{milestone_code}:T{next_num:05x}"
est_time = None
data = task_data.model_dump(exclude_unset=True)
if data.get("estimated_working_time"):
try:
est_time = datetime.strptime(data["estimated_working_time"], "%H:%M").time()
except:
pass
task = Task(
title=data.get("title"),
description=data.get("description"),
task_type=data.get("task_type", "task"),
task_subtype=data.get("task_subtype"),
status=TaskStatus.OPEN,
priority=TaskPriority.MEDIUM,
project_id=project_id,
milestone_id=milestone_id,
reporter_id=current_user.id,
task_code=task_code,
estimated_effort=data.get("estimated_effort"),
estimated_working_time=est_time,
created_by_id=current_user.id,
)
db.add(task)
db.commit()
db.refresh(db_issue)
return db_issue
@router.post("/{milestone_id}/meetings", status_code=status.HTTP_201_CREATED)
def create_milestone_meeting(project_id: int, milestone_id: int, issue: schemas.IssueCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""Create a meeting under a milestone."""
check_project_role(db, current_user.id, project_id, min_role="dev")
milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
if milestone.status and hasattr(milestone.status, 'value') and milestone.status.value == "progressing":
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is in_progress")
issue_data = issue.model_dump()
issue_data["issue_type"] = ISSUE_TYPE_MEETING
issue_data["milestone_id"] = milestone_id
issue_data["project_id"] = project_id
issue_data["reporter_id"] = current_user.id
db_issue = models.Issue(**issue_data)
db.add(db_issue)
db.commit()
db.refresh(db_issue)
return db_issue
db.refresh(task)
return task
@router.get("/{milestone_id}/items")
def get_milestone_items(project_id: int, milestone_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""Get all items (tasks, supports, meetings) for a milestone."""
check_project_role(db, current_user.id, project_id, min_role="viewer")
milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
issues = db.query(models.Issue).filter(models.Issue.milestone_id == milestone_id).all()
tasks = db.query(Task).filter(Task.milestone_id == milestone_id).all()
supports = db.query(Support).filter(Support.milestone_id == milestone_id).all()
meetings = db.query(Meeting).filter(Meeting.milestone_id == milestone_id).all()
tasks = []
supports = []
meetings = []
for issue in issues:
issue_data = {
"id": issue.id,
"title": issue.title,
"description": issue.description,
"status": issue.status.value if hasattr(issue.status, 'value') else issue.status,
"priority": issue.priority.value if hasattr(issue.priority, 'value') else issue.priority,
"created_at": issue.created_at,
}
if issue.issue_type == ISSUE_TYPE_TASK:
tasks.append(issue_data)
elif issue.issue_type == ISSUE_TYPE_SUPPORT:
supports.append(issue_data)
elif issue.issue_type == ISSUE_TYPE_MEETING:
meetings.append(issue_data)
return {"tasks": tasks, "supports": supports, "meetings": meetings}
return {
"tasks": [{
"id": t.id, "title": t.title, "description": t.description,
"status": t.status.value if hasattr(t.status, 'value') else t.status,
"priority": t.priority.value if hasattr(t.priority, 'value') else t.priority,
"task_code": t.task_code, "created_at": t.created_at,
} for t in tasks],
"supports": [{
"id": s.id, "title": s.title, "description": s.description,
"status": s.status.value, "priority": s.priority.value, "created_at": s.created_at,
} for s in supports],
"meetings": [{
"id": m.id, "title": m.title, "description": m.description,
"status": m.status.value, "priority": m.priority.value, "created_at": m.created_at,
} for m in meetings],
}
@router.get("/{milestone_id}/progress")
def get_milestone_progress(project_id: int, milestone_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""Get progress for a milestone - tasks only."""
check_project_role(db, current_user.id, project_id, min_role="viewer")
milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
# Count tasks only (not meetings or supports)
all_issues = db.query(models.Issue).filter(
models.Issue.milestone_id == milestone_id,
models.Issue.issue_type == ISSUE_TYPE_TASK
).all()
total = len(all_issues)
completed = sum(1 for i in all_issues if i.status and hasattr(i.status, 'value') and i.status.value == "closed")
all_tasks = db.query(Task).filter(Task.milestone_id == milestone_id).all()
total = len(all_tasks)
completed = sum(1 for t in all_tasks if t.status == TaskStatus.CLOSED)
progress_pct = (completed / total * 100) if total > 0 else 0
# Calculate time progress if planned_release_date is set
time_progress = None
if milestone.planned_release_date:
if milestone.planned_release_date and milestone.created_at:
now = datetime.now()
if milestone.created_at and milestone.planned_release_date > milestone.created_at:
if milestone.planned_release_date > milestone.created_at:
total_duration = (milestone.planned_release_date - milestone.created_at).total_seconds()
elapsed = (now - milestone.created_at).total_seconds()
time_progress = min(100, max(0, (elapsed / total_duration * 100)))
@@ -251,6 +201,7 @@ def get_milestone_progress(project_id: int, milestone_id: int, db: Session = Dep
"milestone_id": milestone_id,
"title": milestone.title,
"total": total,
"total_tasks": total,
"completed": completed,
"progress_pct": round(progress_pct, 1),
"time_progress_pct": round(time_progress, 1) if time_progress else None,

View File

@@ -103,22 +103,19 @@ def list_activity(entity_type: str = None, entity_id: int = None, user_id: int =
return query.order_by(ActivityLog.created_at.desc()).limit(limit).all()
# ============ Milestones ============
# ============ Milestones (top-level, non project-scoped) ============
@router.post("/milestones", response_model=schemas.MilestoneResponse, status_code=status.HTTP_201_CREATED, tags=["Milestones"])
def create_milestone(ms: schemas.MilestoneCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
import json
# Generate milestone_code: projCode:{i:05x}
project = db.query(models.Project).filter(models.Project.id == ms.project_id).first()
project_code = project.project_code if project and project.project_code else f"P{ms.project_id}"
# Get max milestone number for this project
max_ms = db.query(MilestoneModel).filter(MilestoneModel.project_id == ms.project_id).order_by(MilestoneModel.id.desc()).first()
next_num = (max_ms.id + 1) if max_ms else 1
milestone_code = f"{project_code}:{next_num:05x}"
data = ms.model_dump()
# Serialize list fields to JSON strings
if data.get("depend_on_milestones"):
data["depend_on_milestones"] = json.dumps(data["depend_on_milestones"])
else:
@@ -176,24 +173,14 @@ def delete_milestone(milestone_id: int, db: Session = Depends(get_db)):
return None
@router.get("/milestones/{milestone_id}/issues", response_model=List[schemas.IssueResponse], tags=["Milestones"])
def list_milestone_issues(milestone_id: int, db: Session = Depends(get_db)):
return db.query(models.Issue).filter(models.Issue.milestone_id == milestone_id).all()
@router.get("/milestones/{milestone_id}/progress", tags=["Milestones"])
def milestone_progress(milestone_id: int, db: Session = Depends(get_db)):
from datetime import datetime
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
# Count tasks only
issues = db.query(models.Issue).filter(
models.Issue.milestone_id == milestone_id,
models.Issue.issue_type == "task"
).all()
total = len(issues)
done = sum(1 for i in issues if i.status in ("resolved", "closed"))
tasks = db.query(Task).filter(Task.milestone_id == milestone_id).all()
total = len(tasks)
done = sum(1 for t in tasks if t.status == TaskStatus.CLOSED)
time_progress = None
if ms.planned_release_date and ms.created_at:
@@ -202,162 +189,18 @@ def milestone_progress(milestone_id: int, db: Session = Depends(get_db)):
elapsed = (now - ms.created_at).total_seconds()
time_progress = min(100, max(0, (elapsed / total_duration * 100)))
return {"milestone_id": milestone_id, "title": ms.title, "total": total,
"completed": done, "progress_pct": round(done / total * 100, 1) if total else 0,
"time_progress_pct": round(time_progress, 1) if time_progress else None,
"planned_release_date": ms.planned_release_date}
@router.get("/milestones/{milestone_id}/items", tags=["Milestones"])
def milestone_items(milestone_id: int, db: Session = Depends(get_db)):
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
issues = db.query(models.Issue).filter(models.Issue.milestone_id == milestone_id).all()
tasks = []
supports = []
meetings = []
for issue in issues:
issue_data = {
"id": issue.id,
"title": issue.title,
"description": issue.description,
"status": issue.status.value if hasattr(issue.status, 'value') else issue.status,
"priority": issue.priority.value if hasattr(issue.priority, 'value') else issue.priority,
"created_at": issue.created_at,
}
if issue.issue_type == "task":
tasks.append(issue_data)
elif issue.issue_type == "support":
supports.append(issue_data)
elif issue.issue_type == "meeting":
meetings.append(issue_data)
return {"tasks": tasks, "supports": supports, "meetings": meetings}
@router.post("/milestones/{milestone_id}/tasks", status_code=status.HTTP_201_CREATED, tags=["Milestones"])
def create_milestone_task(milestone_id: int, issue_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
import json
from datetime import datetime, time
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
# Check if milestone is progressing
if ms.status and hasattr(ms.status, 'value') and ms.status.value == "progressing":
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is in_progress")
# Get project_id from milestone
project_id = ms.project_id
# Generate task_code: i_{project_code}_{id:06x}
project = db.query(models.Project).filter(models.Project.id == project_id).first()
project_code = project.project_code if project else f"P{project_id}"
# Get max id for this project to generate unique code
max_issue = db.query(models.Issue).filter(models.Issue.project_id == project_id).order_by(models.Issue.id.desc()).first()
next_id = (max_issue.id + 1) if max_issue else 1
task_code = f"{milestone_code}:T{next_num:05x}"
# Parse estimated_working_time if provided
est_time = None
if issue_data.get("estimated_working_time"):
try:
est_time = datetime.strptime(issue_data["estimated_working_time"], "%H:%M").time()
except:
pass
issue = models.Issue(
title=issue_data.get("title"),
description=issue_data.get("description"),
issue_type="task",
status=models.IssueStatus.OPEN,
priority=models.IssuePriority.MEDIUM,
project_id=project_id,
milestone_id=milestone_id,
reporter_id=current_user.id,
# Task-specific fields
task_code=task_code,
estimated_effort=issue_data.get("estimated_effort"),
estimated_working_time=est_time,
task_status="open",
created_by_id=current_user.id,
)
db.add(issue)
db.commit()
db.refresh(issue)
# Return with task_code
return {
"id": issue.id,
"title": issue.title,
"description": issue.description,
"task_code": issue.task_code,
"status": issue.status.value if hasattr(issue.status, 'value') else issue.status,
"priority": issue.priority.value if hasattr(issue.priority, 'value') else issue.priority,
"created_at": issue.created_at,
"milestone_id": milestone_id,
"title": ms.title,
"total": total,
"total_tasks": total,
"completed": done,
"progress_pct": round(done / total * 100, 1) if total else 0,
"time_progress_pct": round(time_progress, 1) if time_progress else None,
"planned_release_date": ms.planned_release_date,
}
@router.post("/milestones/{milestone_id}/supports", status_code=status.HTTP_201_CREATED, tags=["Milestones"])
def create_milestone_support(milestone_id: int, issue_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
if ms.status and hasattr(ms.status, 'value') and ms.status.value == "progressing":
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is in_progress")
project_id = ms.project_id
issue = models.Issue(
title=issue_data.get("title"),
description=issue_data.get("description"),
issue_type="support",
status=models.IssueStatus.OPEN,
priority=models.IssuePriority.MEDIUM,
project_id=project_id,
milestone_id=milestone_id,
reporter_id=current_user.id,
)
db.add(issue)
db.commit()
db.refresh(issue)
return issue
@router.post("/milestones/{milestone_id}/meetings", status_code=status.HTTP_201_CREATED, tags=["Milestones"])
def create_milestone_meeting(milestone_id: int, issue_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
if ms.status and hasattr(ms.status, 'value') and ms.status.value == "progressing":
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is in_progress")
project_id = ms.project_id
issue = models.Issue(
title=issue_data.get("title"),
description=issue_data.get("description"),
issue_type="meeting",
status=models.IssueStatus.OPEN,
priority=models.IssuePriority.MEDIUM,
project_id=project_id,
milestone_id=milestone_id,
reporter_id=current_user.id,
)
db.add(issue)
db.commit()
db.refresh(issue)
return issue
# ============ Notifications ============
class NotificationResponse(BaseModel):
@@ -368,10 +211,24 @@ class NotificationResponse(BaseModel):
message: str | None = None
entity_type: str | None = None
entity_id: int | None = None
task_id: int | None = None
is_read: bool
created_at: datetime
class Config:
from_attributes = True
def _serialize_notification(notification: NotificationModel):
return {
"id": notification.id,
"user_id": notification.user_id,
"type": notification.type,
"title": notification.title,
"message": notification.message or notification.title,
"entity_type": notification.entity_type,
"entity_id": notification.entity_id,
"task_id": notification.entity_id if notification.entity_type == "task" else None,
"is_read": notification.is_read,
"created_at": notification.created_at,
}
@router.get("/notifications", response_model=List[NotificationResponse], tags=["Notifications"])
@@ -379,7 +236,8 @@ def list_notifications(unread_only: bool = False, limit: int = 50, db: Session =
query = db.query(NotificationModel).filter(NotificationModel.user_id == current_user.id)
if unread_only:
query = query.filter(NotificationModel.is_read == False)
return query.order_by(NotificationModel.created_at.desc()).limit(limit).all()
notifications = query.order_by(NotificationModel.created_at.desc()).limit(limit).all()
return [_serialize_notification(n) for n in notifications]
@router.get("/notifications/count", tags=["Notifications"])
@@ -414,7 +272,7 @@ def mark_all_read(db: Session = Depends(get_db), current_user: models.User = Dep
# ============ Work Logs ============
class WorkLogCreate(BaseModel):
issue_id: int
task_id: int
user_id: int
hours: float
description: str | None = None
@@ -422,7 +280,7 @@ class WorkLogCreate(BaseModel):
class WorkLogResponse(BaseModel):
id: int
issue_id: int
task_id: int
user_id: int
hours: float
description: str | None = None
@@ -434,9 +292,9 @@ class WorkLogResponse(BaseModel):
@router.post("/worklogs", response_model=WorkLogResponse, status_code=status.HTTP_201_CREATED, tags=["Time Tracking"])
def create_worklog(wl: WorkLogCreate, db: Session = Depends(get_db)):
issue = db.query(models.Issue).filter(models.Issue.id == wl.issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="Issue not found")
task = db.query(Task).filter(Task.id == wl.task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
user = db.query(models.User).filter(models.User.id == wl.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
@@ -449,19 +307,19 @@ def create_worklog(wl: WorkLogCreate, db: Session = Depends(get_db)):
return db_wl
@router.get("/issues/{issue_id}/worklogs", response_model=List[WorkLogResponse], tags=["Time Tracking"])
def list_issue_worklogs(issue_id: int, db: Session = Depends(get_db)):
return db.query(WorkLog).filter(WorkLog.issue_id == issue_id).order_by(WorkLog.logged_date.desc()).all()
@router.get("/tasks/{task_id}/worklogs", response_model=List[WorkLogResponse], tags=["Time Tracking"])
def list_task_worklogs(task_id: int, db: Session = Depends(get_db)):
return db.query(WorkLog).filter(WorkLog.task_id == task_id).order_by(WorkLog.logged_date.desc()).all()
@router.get("/issues/{issue_id}/worklogs/summary", tags=["Time Tracking"])
def issue_worklog_summary(issue_id: int, db: Session = Depends(get_db)):
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="Issue not found")
total = db.query(sqlfunc.sum(WorkLog.hours)).filter(WorkLog.issue_id == issue_id).scalar() or 0
count = db.query(WorkLog).filter(WorkLog.issue_id == issue_id).count()
return {"issue_id": issue_id, "total_hours": round(total, 2), "log_count": count}
@router.get("/tasks/{task_id}/worklogs/summary", tags=["Time Tracking"])
def task_worklog_summary(task_id: int, db: Session = Depends(get_db)):
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
total = db.query(sqlfunc.sum(WorkLog.hours)).filter(WorkLog.task_id == task_id).scalar() or 0
count = db.query(WorkLog).filter(WorkLog.task_id == task_id).count()
return {"task_id": task_id, "total_hours": round(total, 2), "log_count": count}
@router.delete("/worklogs/{worklog_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["Time Tracking"])
@@ -476,47 +334,55 @@ def delete_worklog(worklog_id: int, db: Session = Depends(get_db)):
# ============ Export ============
@router.get("/export/issues", tags=["Export"])
def export_issues_csv(project_id: int = None, db: Session = Depends(get_db)):
query = db.query(models.Issue)
@router.get("/export/tasks", tags=["Export"])
def export_tasks_csv(project_id: int = None, db: Session = Depends(get_db)):
query = db.query(Task)
if project_id:
query = query.filter(models.Issue.project_id == project_id)
issues = query.all()
query = query.filter(Task.project_id == project_id)
tasks = query.all()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["id", "title", "type", "subtype", "status", "priority", "project_id",
"reporter_id", "assignee_id", "milestone_id", "due_date",
"milestone_id", "reporter_id", "assignee_id", "task_code",
"tags", "created_at", "updated_at"])
for i in issues:
writer.writerow([i.id, i.title, i.issue_type, i.issue_subtype or "", i.status, i.priority, i.project_id,
i.reporter_id, i.assignee_id, i.milestone_id, i.due_date,
i.tags, i.created_at, i.updated_at])
for t in tasks:
writer.writerow([t.id, t.title, t.task_type, t.task_subtype or "",
t.status.value if hasattr(t.status, 'value') else t.status,
t.priority.value if hasattr(t.priority, 'value') else t.priority,
t.project_id, t.milestone_id, t.reporter_id, t.assignee_id, t.task_code,
t.tags, t.created_at, t.updated_at])
output.seek(0)
return StreamingResponse(iter([output.getvalue()]), media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=issues.csv"})
headers={"Content-Disposition": "attachment; filename=tasks.csv"})
# ============ Dashboard ============
@router.get("/dashboard/stats", tags=["Dashboard"])
def dashboard_stats(project_id: int = None, db: Session = Depends(get_db)):
query = db.query(models.Issue)
query = db.query(Task)
if project_id:
query = query.filter(models.Issue.project_id == project_id)
query = query.filter(Task.project_id == project_id)
total = query.count()
by_status = {s: query.filter(models.Issue.status == s).count()
for s in ["open", "in_progress", "resolved", "closed", "blocked"]}
by_type = {t: query.filter(models.Issue.issue_type == t).count()
for t in ["task", "story", "test", "resolution"]}
by_priority = {p: query.filter(models.Issue.priority == p).count()
for p in ["low", "medium", "high", "critical"]}
return {"total": total, "by_status": by_status, "by_type": by_type, "by_priority": by_priority}
by_status = {s.value: query.filter(Task.status == s).count() for s in TaskStatus}
by_type = {t: query.filter(Task.task_type == t).count()
for t in ["task", "story", "test", "resolution", "issue", "maintenance", "research", "review"]}
by_priority = {p.value: query.filter(Task.priority == p).count() for p in TaskPriority}
recent = query.order_by(Task.created_at.desc()).limit(10).all()
return {
"total": total,
"total_tasks": total,
"by_status": by_status,
"by_type": by_type,
"by_priority": by_priority,
"recent_tasks": [schemas.TaskResponse.model_validate(t) for t in recent],
}
# ============ Tasks ============
# ============ Milestone-scoped Tasks ============
@router.get("/tasks/{project_code}/{milestone_id}", tags=["Tasks"])
def list_tasks(project_code: str, milestone_id: int, db: Session = Depends(get_db)):
def list_milestone_tasks(project_code: str, milestone_id: int, db: Session = Depends(get_db)):
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
@@ -533,6 +399,8 @@ def list_tasks(project_code: str, milestone_id: int, db: Session = Depends(get_d
"status": t.status.value if hasattr(t.status, "value") else t.status,
"priority": t.priority.value if hasattr(t.priority, "value") else t.priority,
"task_code": t.task_code,
"task_type": t.task_type,
"task_subtype": t.task_subtype,
"estimated_effort": t.estimated_effort,
"estimated_working_time": str(t.estimated_working_time) if t.estimated_working_time else None,
"started_on": t.started_on,
@@ -545,9 +413,7 @@ def list_tasks(project_code: str, milestone_id: int, db: Session = Depends(get_d
@router.post("/tasks/{project_code}/{milestone_id}", status_code=status.HTTP_201_CREATED, tags=["Tasks"])
def create_task(project_code: str, milestone_id: int, task_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
from datetime import datetime
def create_milestone_task(project_code: str, milestone_id: int, task_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
@@ -559,7 +425,6 @@ def create_task(project_code: str, milestone_id: int, task_data: dict, db: Sessi
if ms.status and hasattr(ms.status, "value") and ms.status.value == "progressing":
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is in_progress")
# Generate task_code: milestoneCode:T{i:05x}
milestone_code = ms.milestone_code or f"m{ms.id}"
max_task = db.query(Task).filter(Task.milestone_id == ms.id).order_by(Task.id.desc()).first()
next_num = (max_task.id + 1) if max_task else 1
@@ -577,6 +442,8 @@ def create_task(project_code: str, milestone_id: int, task_data: dict, db: Sessi
description=task_data.get("description"),
status=TaskStatus.OPEN,
priority=TaskPriority.MEDIUM,
task_type=task_data.get("task_type", "task"),
task_subtype=task_data.get("task_subtype"),
project_id=project.id,
milestone_id=milestone_id,
reporter_id=current_user.id,
@@ -600,78 +467,6 @@ def create_task(project_code: str, milestone_id: int, task_data: dict, db: Sessi
}
@router.get("/tasks/{project_code}/{milestone_id}/{task_id}", tags=["Tasks"])
def get_task(project_code: str, milestone_id: int, task_id: int, db: Session = Depends(get_db)):
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
task = db.query(Task).filter(
Task.id == task_id,
Task.project_id == project.id,
Task.milestone_id == milestone_id
).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return {
"id": task.id,
"title": task.title,
"description": task.description,
"status": task.status.value,
"priority": task.priority.value,
"task_code": task.task_code,
"estimated_effort": task.estimated_effort,
"estimated_working_time": str(task.estimated_working_time) if task.estimated_working_time else None,
"started_on": task.started_on,
"finished_on": task.finished_on,
"depend_on": task.depend_on,
"related_tasks": task.related_tasks,
"assignee_id": task.assignee_id,
"created_at": task.created_at,
}
@router.patch("/tasks/{project_code}/{milestone_id}/{task_id}", tags=["Tasks"])
def update_task(project_code: str, milestone_id: int, task_id: int, task_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
from datetime import datetime
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
task = db.query(Task).filter(
Task.id == task_id,
Task.project_id == project.id,
Task.milestone_id == milestone_id
).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
if "title" in task_data:
task.title = task_data["title"]
if "description" in task_data:
task.description = task_data["description"]
if "status" in task_data:
new_status = task_data["status"]
if new_status == "progressing" and not task.started_on:
task.started_on = datetime.now()
if new_status == "closed" and not task.finished_on:
task.finished_on = datetime.now()
task.status = TaskStatus[new_status.upper()] if new_status.upper() in [s.name for s in TaskStatus] else TaskStatus.OPEN
if "priority" in task_data:
task.priority = TaskPriority[task_data["priority"].upper()] if task_data["priority"].upper() in [s.name for s in TaskPriority] else TaskPriority.MEDIUM
if "estimated_effort" in task_data:
task.estimated_effort = task_data["estimated_effort"]
if "assignee_id" in task_data:
task.assignee_id = task_data["assignee_id"]
db.commit()
db.refresh(task)
return task
# ============ Supports ============
@router.get("/supports/{project_code}/{milestone_id}", tags=["Supports"])
@@ -709,7 +504,6 @@ def create_support(project_code: str, milestone_id: int, support_data: dict, db:
if ms.status and hasattr(ms.status, "value") and ms.status.value == "progressing":
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is in_progress")
# Generate support_code: milestoneCode:S{i:05x}
milestone_code = ms.milestone_code or f"m{ms.id}"
max_support = db.query(Support).filter(Support.milestone_id == milestone_id).order_by(Support.id.desc()).first()
next_num = (max_support.id + 1) if max_support else 1
@@ -758,8 +552,6 @@ def list_meetings(project_code: str, milestone_id: int, db: Session = Depends(ge
@router.post("/meetings/{project_code}/{milestone_id}", status_code=status.HTTP_201_CREATED, tags=["Meetings"])
def create_meeting(project_code: str, milestone_id: int, meeting_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
from datetime import datetime
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
@@ -771,7 +563,6 @@ def create_meeting(project_code: str, milestone_id: int, meeting_data: dict, db:
if ms.status and hasattr(ms.status, "value") and ms.status.value == "progressing":
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is in_progress")
# Generate meeting_code: milestoneCode:M{i:05x}
milestone_code = ms.milestone_code or f"m{ms.id}"
max_meeting = db.query(Meeting).filter(Meeting.milestone_id == milestone_id).order_by(Meeting.id.desc()).first()
next_num = (max_meeting.id + 1) if max_meeting else 1

View File

@@ -18,7 +18,7 @@ from app.models.monitor import (
ServerHandshakeNonce,
)
from app.services.monitoring import (
get_issue_stats_cached,
get_task_stats_cached,
get_provider_usage_view,
get_server_states_view,
test_provider_connection,
@@ -66,7 +66,7 @@ def monitor_public_key():
@router.get('/public/overview')
def public_overview(db: Session = Depends(get_db)):
return {
'issues': get_issue_stats_cached(db, ttl_seconds=1800),
'tasks': get_task_stats_cached(db, ttl_seconds=1800),
'providers': get_provider_usage_view(db),
'servers': get_server_states_view(db, offline_after_minutes=7),
'generated_at': datetime.now(timezone.utc).isoformat(),

View File

@@ -231,14 +231,14 @@ def delete_project(
project_code = project.project_code
# Delete milestones and their issues
# Delete milestones and their tasks
from app.models.milestone import Milestone
from app.models.task import Task
milestones = db.query(Milestone).filter(Milestone.project_id == project_id).all()
for ms in milestones:
# Delete issues under milestone
issues = db.query(models.Issue).filter(models.Issue.milestone_id == ms.id).all()
for issue in issues:
db.delete(issue)
tasks = db.query(Task).filter(Task.milestone_id == ms.id).all()
for task in tasks:
db.delete(task)
db.delete(ms)
# Delete project members
@@ -363,13 +363,14 @@ from sqlalchemy import func as sqlfunc
@router.get("/{project_id}/worklogs/summary")
def project_worklog_summary(project_id: int, db: Session = Depends(get_db)):
from app.models.task import Task as TaskModel
results = db.query(
models.User.id, models.User.username,
sqlfunc.sum(WorkLog.hours).label("total_hours"),
sqlfunc.count(WorkLog.id).label("log_count")
).join(WorkLog, WorkLog.user_id == models.User.id)\
.join(models.Issue, WorkLog.issue_id == models.Issue.id)\
.filter(models.Issue.project_id == project_id)\
.join(TaskModel, WorkLog.task_id == TaskModel.id)\
.filter(TaskModel.project_id == project_id)\
.group_by(models.User.id, models.User.username).all()
total = sum(r.total_hours for r in results)
by_user = [{"user_id": r.id, "username": r.username, "hours": round(r.total_hours, 2), "logs": r.log_count} for r in results]

347
app/api/routers/tasks.py Normal file
View File

@@ -0,0 +1,347 @@
"""Tasks router — replaces the old issues router. All CRUD operates on the `tasks` table."""
import math
from typing import List
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from sqlalchemy.orm import Session
from pydantic import BaseModel
from app.core.config import get_db
from app.models import models
from app.models.task import Task, TaskStatus, TaskPriority
from app.models.milestone import Milestone
from app.schemas import schemas
from app.services.webhook import fire_webhooks_sync
from app.models.notification import Notification as NotificationModel
from app.api.deps import get_current_user_or_apikey
from app.api.rbac import check_project_role
from app.services.activity import log_activity
router = APIRouter(tags=["Tasks"])
# ---- Type / Subtype validation ----
TASK_SUBTYPE_MAP = {
'issue': {'infrastructure', 'performance', 'regression', 'security', 'user_experience', 'defect'},
'maintenance': {'deploy', 'release'},
'review': {'code_review', 'decision_review', 'function_review'},
'story': {'feature', 'improvement', 'refactor'},
'test': {'regression', 'security', 'smoke', 'stress'},
'research': set(),
'task': {'defect'},
'resolution': set(),
}
ALLOWED_TASK_TYPES = set(TASK_SUBTYPE_MAP.keys())
def _validate_task_type_subtype(task_type: str | None, task_subtype: str | None):
if task_type is None:
return
if task_type not in ALLOWED_TASK_TYPES:
raise HTTPException(status_code=400, detail=f'Invalid task_type: {task_type}')
allowed = TASK_SUBTYPE_MAP.get(task_type, set())
if task_subtype and task_subtype not in allowed:
raise HTTPException(status_code=400, detail=f'Invalid task_subtype for {task_type}: {task_subtype}')
def _notify_user(db, user_id, ntype, title, message=None, entity_type=None, entity_id=None):
n = NotificationModel(user_id=user_id, type=ntype, title=title, message=message,
entity_type=entity_type, entity_id=entity_id)
db.add(n)
db.commit()
return n
# ---- CRUD ----
@router.post("/tasks", response_model=schemas.TaskResponse, status_code=status.HTTP_201_CREATED)
def create_task(task_in: schemas.TaskCreate, bg: BackgroundTasks, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
_validate_task_type_subtype(task_in.task_type, task_in.task_subtype)
data = task_in.model_dump(exclude_unset=True)
data["reporter_id"] = data.get("reporter_id") or current_user.id
data["created_by_id"] = current_user.id
if not data.get("project_id"):
raise HTTPException(status_code=400, detail="project_id is required")
if not data.get("milestone_id"):
raise HTTPException(status_code=400, detail="milestone_id is required")
check_project_role(db, current_user.id, data["project_id"], min_role="dev")
milestone = db.query(Milestone).filter(
Milestone.id == data["milestone_id"],
Milestone.project_id == data["project_id"],
).first()
if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
est_time = None
if data.get("estimated_working_time"):
try:
est_time = datetime.strptime(data["estimated_working_time"], "%H:%M").time()
except Exception:
pass
data["estimated_working_time"] = est_time
milestone_code = milestone.milestone_code or f"m{milestone.id}"
max_task = db.query(Task).filter(Task.milestone_id == milestone.id).order_by(Task.id.desc()).first()
next_num = (max_task.id + 1) if max_task else 1
data["task_code"] = f"{milestone_code}:T{next_num:05x}"
db_task = Task(**data)
db.add(db_task)
db.commit()
db.refresh(db_task)
event = "resolution.created" if db_task.task_type == "resolution" else "task.created"
bg.add_task(
fire_webhooks_sync,
event,
{"task_id": db_task.id, "title": db_task.title, "type": db_task.task_type, "status": db_task.status.value},
db_task.project_id,
db,
)
log_activity(db, "task.created", "task", db_task.id, current_user.id, {"title": db_task.title})
return db_task
@router.get("/tasks")
def list_tasks(
project_id: int = None, task_status: str = None, task_type: str = None, task_subtype: str = None,
assignee_id: int = None, tag: str = None,
sort_by: str = "created_at", sort_order: str = "desc",
page: int = 1, page_size: int = 50,
db: Session = Depends(get_db)
):
query = db.query(Task)
if project_id:
query = query.filter(Task.project_id == project_id)
if task_status:
query = query.filter(Task.status == task_status)
if task_type:
query = query.filter(Task.task_type == task_type)
if task_subtype:
query = query.filter(Task.task_subtype == task_subtype)
if assignee_id:
query = query.filter(Task.assignee_id == assignee_id)
if tag:
query = query.filter(Task.tags.contains(tag))
sort_fields = {
"created_at": Task.created_at, "updated_at": Task.updated_at,
"priority": Task.priority, "title": Task.title,
}
sort_col = sort_fields.get(sort_by, Task.created_at)
query = query.order_by(sort_col.asc() if sort_order == "asc" else sort_col.desc())
total = query.count()
page = max(1, page)
page_size = min(max(1, page_size), 200)
total_pages = math.ceil(total / page_size) if total else 1
items = query.offset((page - 1) * page_size).limit(page_size).all()
return {
"items": [schemas.TaskResponse.model_validate(i) for i in items],
"total": total,
"total_tasks": total,
"page": page,
"page_size": page_size,
"total_pages": total_pages,
}
@router.get("/tasks/{task_id}", response_model=schemas.TaskResponse)
def get_task(task_id: int, db: Session = Depends(get_db)):
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return task
@router.patch("/tasks/{task_id}", response_model=schemas.TaskResponse)
def update_task(task_id: int, task_update: schemas.TaskUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
check_project_role(db, current_user.id, task.project_id, min_role="dev")
update_data = task_update.model_dump(exclude_unset=True)
if "status" in update_data:
new_status = update_data["status"]
if new_status == "progressing" and not task.started_on:
task.started_on = datetime.utcnow()
if new_status == "closed" and not task.finished_on:
task.finished_on = datetime.utcnow()
for field, value in update_data.items():
setattr(task, field, value)
db.commit()
db.refresh(task)
return task
@router.delete("/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_task(task_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
check_project_role(db, current_user.id, task.project_id, min_role="mgr")
log_activity(db, "task.deleted", "task", task.id, current_user.id, {"title": task.title})
db.delete(task)
db.commit()
return None
# ---- Transition ----
@router.post("/tasks/{task_id}/transition", response_model=schemas.TaskResponse)
def transition_task(task_id: int, new_status: str, bg: BackgroundTasks, db: Session = Depends(get_db)):
valid_statuses = [s.value for s in TaskStatus]
if new_status not in valid_statuses:
raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of: {valid_statuses}")
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
old_status = task.status.value if hasattr(task.status, 'value') else task.status
if new_status == "progressing" and not task.started_on:
task.started_on = datetime.utcnow()
if new_status == "closed" and not task.finished_on:
task.finished_on = datetime.utcnow()
task.status = new_status
db.commit()
db.refresh(task)
event = "task.closed" if new_status == "closed" else "task.updated"
bg.add_task(fire_webhooks_sync, event,
{"task_id": task.id, "title": task.title, "old_status": old_status, "new_status": new_status},
task.project_id, db)
return task
# ---- Assignment ----
@router.post("/tasks/{task_id}/assign")
def assign_task(task_id: int, assignee_id: int, db: Session = Depends(get_db)):
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
user = db.query(models.User).filter(models.User.id == assignee_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
task.assignee_id = assignee_id
db.commit()
db.refresh(task)
_notify_user(db, assignee_id, "task.assigned",
f"Task #{task.id} assigned to you",
f"'{task.title}' has been assigned to you.", "task", task.id)
return {"task_id": task.id, "assignee_id": assignee_id, "title": task.title}
# ---- Tags ----
@router.post("/tasks/{task_id}/tags")
def add_tag(task_id: int, tag: str, db: Session = Depends(get_db)):
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
current = set(task.tags.split(",")) if task.tags else set()
current.add(tag.strip())
current.discard("")
task.tags = ",".join(sorted(current))
db.commit()
return {"task_id": task_id, "tags": list(current)}
@router.delete("/tasks/{task_id}/tags")
def remove_tag(task_id: int, tag: str, db: Session = Depends(get_db)):
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
current = set(task.tags.split(",")) if task.tags else set()
current.discard(tag.strip())
current.discard("")
task.tags = ",".join(sorted(current)) if current else None
db.commit()
return {"task_id": task_id, "tags": list(current)}
@router.get("/tags")
def list_all_tags(project_id: int = None, db: Session = Depends(get_db)):
query = db.query(Task.tags).filter(Task.tags != None)
if project_id:
query = query.filter(Task.project_id == project_id)
all_tags = set()
for (tags,) in query.all():
for t in tags.split(","):
t = t.strip()
if t:
all_tags.add(t)
return {"tags": sorted(all_tags)}
# ---- Batch ----
class BatchTransition(BaseModel):
task_ids: List[int]
new_status: str
class BatchAssign(BaseModel):
task_ids: List[int]
assignee_id: int
@router.post("/tasks/batch/transition")
def batch_transition(data: BatchTransition, bg: BackgroundTasks, db: Session = Depends(get_db)):
valid_statuses = [s.value for s in TaskStatus]
if data.new_status not in valid_statuses:
raise HTTPException(status_code=400, detail="Invalid status")
updated = []
for task_id in data.task_ids:
task = db.query(Task).filter(Task.id == task_id).first()
if task:
old_status = task.status.value if hasattr(task.status, 'value') else task.status
task.status = data.new_status
updated.append({"id": task.id, "title": task.title, "old": old_status, "new": data.new_status})
db.commit()
for u in updated:
event = "task.closed" if data.new_status == "closed" else "task.updated"
bg.add_task(fire_webhooks_sync, event, u, None, db)
return {"updated": len(updated), "tasks": updated}
@router.post("/tasks/batch/assign")
def batch_assign(data: BatchAssign, db: Session = Depends(get_db)):
user = db.query(models.User).filter(models.User.id == data.assignee_id).first()
if not user:
raise HTTPException(status_code=404, detail="Assignee not found")
updated = []
for task_id in data.task_ids:
task = db.query(Task).filter(Task.id == task_id).first()
if task:
task.assignee_id = data.assignee_id
updated.append(task_id)
db.commit()
return {"updated": len(updated), "task_ids": updated, "assignee_id": data.assignee_id}
# ---- Search ----
@router.get("/search/tasks")
def search_tasks(q: str, project_id: int = None, page: int = 1, page_size: int = 50,
db: Session = Depends(get_db)):
query = db.query(Task).filter(
(Task.title.contains(q)) | (Task.description.contains(q))
)
if project_id:
query = query.filter(Task.project_id == project_id)
total = query.count()
page = max(1, page)
page_size = min(max(1, page_size), 200)
total_pages = math.ceil(total / page_size) if total else 1
items = query.offset((page - 1) * page_size).limit(page_size).all()
return {
"items": [schemas.TaskResponse.model_validate(i) for i in items],
"total": total,
"total_tasks": total,
"page": page,
"page_size": page_size,
"total_pages": total_pages,
}

View File

@@ -65,7 +65,7 @@ from datetime import datetime
class WorkLogResponse(BaseModel):
id: int
issue_id: int
task_id: int
user_id: int
hours: float
description: str | None = None

View File

@@ -100,11 +100,11 @@ DEFAULT_PERMISSIONS = [
("project.write", "Edit project", "project"),
("project.delete", "Delete project", "project"),
("project.manage_members", "Manage project members", "project"),
# Issue/Milestone permissions
("issue.create", "Create issues", "issue"),
("issue.read", "View issues", "issue"),
("issue.write", "Edit issues", "issue"),
("issue.delete", "Delete issues", "issue"),
# Task/Milestone permissions
("task.create", "Create tasks", "task"),
("task.read", "View tasks", "task"),
("task.write", "Edit tasks", "task"),
("task.delete", "Delete tasks", "task"),
("milestone.create", "Create milestones", "milestone"),
("milestone.read", "View milestones", "milestone"),
("milestone.write", "Edit milestones", "milestone"),

View File

@@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
app = FastAPI(
title="HarborForge API",
description="Agent/人类协同任务管理平台 API",
version="0.2.0"
version="0.3.0"
)
# CORS
@@ -24,11 +24,11 @@ def health_check():
@app.get("/version", tags=["System"])
def version():
return {"name": "HarborForge", "version": "0.2.0", "description": "Agent/人类协同任务管理平台"}
return {"name": "HarborForge", "version": "0.3.0", "description": "Agent/人类协同任务管理平台"}
# Register routers
from app.api.routers.auth import router as auth_router
from app.api.routers.issues import router as issues_router
from app.api.routers.tasks import router as tasks_router
from app.api.routers.projects import router as projects_router
from app.api.routers.users import router as users_router
from app.api.routers.comments import router as comments_router
@@ -39,7 +39,7 @@ from app.api.routers.milestones import router as milestones_router
from app.api.routers.roles import router as roles_router
app.include_router(auth_router)
app.include_router(issues_router)
app.include_router(tasks_router)
app.include_router(projects_router)
app.include_router(users_router)
app.include_router(comments_router)
@@ -54,33 +54,113 @@ app.include_router(roles_router)
def _migrate_schema():
from sqlalchemy import text
from app.core.config import SessionLocal
def _has_table(db, table_name: str) -> bool:
return db.execute(text("SHOW TABLES LIKE :table_name"), {"table_name": table_name}).fetchone() is not None
def _has_column(db, table_name: str, column_name: str) -> bool:
return db.execute(
text(f"SHOW COLUMNS FROM {table_name} LIKE :column_name"),
{"column_name": column_name},
).fetchone() is not None
def _drop_fk_constraints(db, table_name: str, referenced_table: str):
rows = db.execute(text(
"""
SELECT CONSTRAINT_NAME
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = :table_name
AND REFERENCED_TABLE_NAME = :referenced_table
AND CONSTRAINT_NAME <> 'PRIMARY'
"""
), {"table_name": table_name, "referenced_table": referenced_table}).fetchall()
for (constraint_name,) in rows:
db.execute(text(f"ALTER TABLE {table_name} DROP FOREIGN KEY `{constraint_name}`"))
def _ensure_fk(db, table_name: str, column_name: str, referenced_table: str, referenced_column: str, constraint_name: str):
exists = db.execute(text(
"""
SELECT 1
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = :table_name
AND COLUMN_NAME = :column_name
AND REFERENCED_TABLE_NAME = :referenced_table
AND REFERENCED_COLUMN_NAME = :referenced_column
LIMIT 1
"""
), {
"table_name": table_name,
"column_name": column_name,
"referenced_table": referenced_table,
"referenced_column": referenced_column,
}).fetchone()
if not exists:
db.execute(text(
f"ALTER TABLE {table_name} ADD CONSTRAINT `{constraint_name}` FOREIGN KEY ({column_name}) REFERENCES {referenced_table}({referenced_column})"
))
db = SessionLocal()
try:
# issues.issue_subtype
result = db.execute(text("SHOW COLUMNS FROM issues LIKE 'issue_subtype'")).fetchone()
if not result:
db.execute(text("ALTER TABLE issues ADD COLUMN issue_subtype VARCHAR(64) NULL"))
# issues.issue_type enum -> varchar
result = db.execute(text("SHOW COLUMNS FROM issues WHERE Field='issue_type'")).fetchone()
if result and 'enum' in result[1].lower():
db.execute(text("ALTER TABLE issues MODIFY issue_type VARCHAR(32) DEFAULT 'issue'"))
# projects.project_code
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'project_code'")).fetchone()
if not result:
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'project_code'"))
if not result.fetchone():
db.execute(text("ALTER TABLE projects ADD COLUMN project_code VARCHAR(16) NULL"))
db.execute(text("CREATE UNIQUE INDEX idx_projects_project_code ON projects (project_code)"))
# projects.owner_name
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'owner_name'")).fetchone()
if not result:
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'owner_name'"))
if not result.fetchone():
db.execute(text("ALTER TABLE projects ADD COLUMN owner_name VARCHAR(128) NOT NULL DEFAULT ''"))
# projects.sub_projects / related_projects
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'sub_projects'")).fetchone()
if not result:
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'sub_projects'"))
if not result.fetchone():
db.execute(text("ALTER TABLE projects ADD COLUMN sub_projects VARCHAR(512) NULL"))
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'related_projects'")).fetchone()
if not result:
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'related_projects'"))
if not result.fetchone():
db.execute(text("ALTER TABLE projects ADD COLUMN related_projects VARCHAR(512) NULL"))
# tasks extra fields
result = db.execute(text("SHOW COLUMNS FROM tasks LIKE 'task_type'"))
if not result.fetchone():
db.execute(text("ALTER TABLE tasks ADD COLUMN task_type VARCHAR(32) DEFAULT 'task'"))
result = db.execute(text("SHOW COLUMNS FROM tasks LIKE 'task_subtype'"))
if not result.fetchone():
db.execute(text("ALTER TABLE tasks ADD COLUMN task_subtype VARCHAR(64) NULL"))
result = db.execute(text("SHOW COLUMNS FROM tasks LIKE 'tags'"))
if not result.fetchone():
db.execute(text("ALTER TABLE tasks ADD COLUMN tags VARCHAR(500) NULL"))
result = db.execute(text("SHOW COLUMNS FROM tasks LIKE 'resolution_summary'"))
if not result.fetchone():
db.execute(text("ALTER TABLE tasks ADD COLUMN resolution_summary TEXT NULL"))
db.execute(text("ALTER TABLE tasks ADD COLUMN positions TEXT NULL"))
db.execute(text("ALTER TABLE tasks ADD COLUMN pending_matters TEXT NULL"))
# comments: issue_id -> task_id
if _has_table(db, "comments"):
_drop_fk_constraints(db, "comments", "issues")
if _has_column(db, "comments", "issue_id") and not _has_column(db, "comments", "task_id"):
db.execute(text("ALTER TABLE comments CHANGE COLUMN issue_id task_id INTEGER NOT NULL"))
if _has_column(db, "comments", "task_id"):
_ensure_fk(db, "comments", "task_id", "tasks", "id", "fk_comments_task_id")
# work_logs: issue_id -> task_id
if _has_table(db, "work_logs"):
_drop_fk_constraints(db, "work_logs", "issues")
if _has_column(db, "work_logs", "issue_id") and not _has_column(db, "work_logs", "task_id"):
db.execute(text("ALTER TABLE work_logs CHANGE COLUMN issue_id task_id INTEGER NOT NULL"))
if _has_column(db, "work_logs", "task_id"):
_ensure_fk(db, "work_logs", "task_id", "tasks", "id", "fk_work_logs_task_id")
# Drop issues table if it exists (no longer used anywhere)
if _has_table(db, "issues"):
db.execute(text("DROP TABLE issues"))
db.commit()
except Exception as e:
db.rollback()
print(f"Migration warning: {e}")
finally:
db.close()
@@ -89,7 +169,7 @@ def _migrate_schema():
@app.on_event("startup")
def startup():
from app.core.config import Base, engine, SessionLocal
from app.models import models, webhook, apikey, activity, milestone, notification, worklog, monitor, role_permission
from app.models import models, webhook, apikey, activity, milestone, notification, worklog, monitor, role_permission, task, support, meeting
Base.metadata.create_all(bind=engine)
_migrate_schema()

View File

@@ -7,8 +7,8 @@ class ActivityLog(Base):
__tablename__ = "activity_logs"
id = Column(Integer, primary_key=True, index=True)
action = Column(String(50), nullable=False) # e.g. "issue.created", "comment.added"
entity_type = Column(String(50), nullable=False) # "issue", "project", "comment"
action = Column(String(50), nullable=False) # e.g. "task.created", "comment.added"
entity_type = Column(String(50), nullable=False) # "task", "project", "comment"
entity_id = Column(Integer, nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
details = Column(Text, nullable=True) # JSON string

View File

@@ -6,96 +6,42 @@ from app.models.role_permission import Role
import enum
class IssueType(str, enum.Enum):
MEETING = "meeting"
SUPPORT = "support"
class TaskType(str, enum.Enum):
"""Task type enum — 'issue' is a subtype of task, not the other way around."""
ISSUE = "issue"
MAINTENANCE = "maintenance"
RESEARCH = "research"
REVIEW = "review"
STORY = "story"
TEST = "test"
RESOLUTION = "resolution" # 决议案 - 用于 Agent 僵局提交
TASK = "task" # legacy generic type
RESOLUTION = "resolution"
TASK = "task"
class IssueStatus(str, enum.Enum):
class TaskStatus(str, enum.Enum):
OPEN = "open"
IN_PROGRESS = "in_progress"
RESOLVED = "resolved"
PENDING = "pending"
PROGRESSING = "progressing"
CLOSED = "closed"
BLOCKED = "blocked"
class IssuePriority(str, enum.Enum):
class TaskPriority(str, enum.Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "critical"
class Issue(Base):
__tablename__ = "issues"
id = Column(Integer, primary_key=True, index=True)
title = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
issue_type = Column(String(32), default=IssueType.ISSUE.value)
issue_subtype = Column(String(64), nullable=True)
status = Column(Enum(IssueStatus), default=IssueStatus.OPEN)
priority = Column(Enum(IssuePriority), default=IssuePriority.MEDIUM)
# Relationships
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
reporter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
assignee_id = Column(Integer, ForeignKey("users.id"), nullable=True)
# Resolution specific fields (for RESOLUTION type)
resolution_summary = Column(Text, nullable=True) # 僵局摘要
positions = Column(Text, nullable=True) # 各方立场 (JSON)
pending_matters = Column(Text, nullable=True) # 待决事项
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Tags (comma-separated for simplicity)
tags = Column(String(500), nullable=True)
# Dependencies
depends_on_id = Column(Integer, ForeignKey("issues.id"), nullable=True)
# Due date and milestone
due_date = Column(DateTime(timezone=True), nullable=True)
milestone_id = Column(Integer, ForeignKey("milestones.id"), nullable=True)
# Task-specific fields
task_code = Column(String(64), nullable=True, unique=True, index=True)
depend_on = Column(Text, nullable=True) # JSON list of task codes
estimated_effort = Column(Integer, nullable=True) # 1-10
estimated_working_time = Column(Time(timezone=True), nullable=True)
task_status = Column(String(32), default="open") # open, closed, pending, progressing
started_on = Column(DateTime(timezone=True), nullable=True)
finished_on = Column(DateTime(timezone=True), nullable=True)
related_tasks = Column(Text, nullable=True) # JSON list of task codes
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
project = relationship("Project", back_populates="issues")
reporter = relationship("User", foreign_keys=[reporter_id], back_populates="reported_issues")
assignee = relationship("User", foreign_keys=[assignee_id], back_populates="assigned_issues")
comments = relationship("Comment", back_populates="issue", cascade="all, delete-orphan")
class Comment(Base):
__tablename__ = "comments"
id = Column(Integer, primary_key=True, index=True)
content = Column(Text, nullable=False)
issue_id = Column(Integer, ForeignKey("issues.id"), nullable=False)
task_id = Column(Integer, ForeignKey("tasks.id"), nullable=False)
author_id = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
issue = relationship("Issue", back_populates="comments")
author = relationship("User", back_populates="comments")
@@ -114,7 +60,6 @@ class Project(Base):
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
issues = relationship("Issue", back_populates="project", cascade="all, delete-orphan")
members = relationship("ProjectMember", back_populates="project", cascade="all, delete-orphan")
owner = relationship("User", back_populates="owned_projects")
@@ -125,15 +70,13 @@ class User(Base):
id = Column(Integer, primary_key=True, index=True)
username = Column(String(50), unique=True, nullable=False)
email = Column(String(100), unique=True, nullable=False)
hashed_password = Column(String(255), nullable=True) # Nullable for OAuth users
hashed_password = Column(String(255), nullable=True)
full_name = Column(String(100), nullable=True)
is_active = Column(Boolean, default=True)
is_admin = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
owned_projects = relationship("Project", back_populates="owner")
reported_issues = relationship("Issue", foreign_keys=[Issue.reporter_id], back_populates="reporter")
assigned_issues = relationship("Issue", foreign_keys=[Issue.assignee_id], back_populates="assignee")
comments = relationship("Comment", back_populates="author")
project_memberships = relationship("ProjectMember", back_populates="user")

View File

@@ -9,10 +9,10 @@ class Notification(Base):
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
type = Column(String(50), nullable=False) # issue.assigned, issue.mentioned, comment.added, milestone.due
type = Column(String(50), nullable=False) # task.assigned, task.mentioned, comment.added, milestone.due
title = Column(String(255), nullable=False)
message = Column(Text, nullable=True)
entity_type = Column(String(50), nullable=True) # issue, comment, milestone
entity_type = Column(String(50), nullable=True) # task, comment, milestone
entity_id = Column(Integer, nullable=True)
is_read = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())

View File

@@ -26,18 +26,35 @@ class Task(Base):
priority = Column(Enum(TaskPriority), default=TaskPriority.MEDIUM)
task_code = Column(String(64), nullable=True, unique=True, index=True)
# Task type/subtype (replaces old issue_type/issue_subtype)
task_type = Column(String(32), default="task")
task_subtype = Column(String(64), nullable=True)
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
milestone_id = Column(Integer, ForeignKey("milestones.id"), nullable=False)
reporter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
assignee_id = Column(Integer, ForeignKey("users.id"), nullable=True)
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
# Tags (comma-separated)
tags = Column(String(500), nullable=True)
# Dependencies
depend_on = Column(Text, nullable=True)
related_tasks = Column(Text, nullable=True)
# Effort tracking
estimated_effort = Column(Integer, nullable=True)
estimated_working_time = Column(Time, nullable=True)
started_on = Column(DateTime(timezone=True), nullable=True)
finished_on = Column(DateTime(timezone=True), nullable=True)
related_tasks = Column(Text, nullable=True)
# Resolution specific fields (for task_type="resolution")
resolution_summary = Column(Text, nullable=True)
positions = Column(Text, nullable=True)
pending_matters = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
comments = relationship("Comment", foreign_keys="Comment.task_id", cascade="all, delete-orphan")

View File

@@ -5,10 +5,10 @@ import enum
class WebhookEvent(str, enum.Enum):
ISSUE_CREATED = "issue.created"
ISSUE_UPDATED = "issue.updated"
ISSUE_CLOSED = "issue.closed"
ISSUE_DELETED = "issue.deleted"
TASK_CREATED = "task.created"
TASK_UPDATED = "task.updated"
TASK_CLOSED = "task.closed"
TASK_DELETED = "task.deleted"
COMMENT_CREATED = "comment.created"
RESOLUTION_CREATED = "resolution.created"
MEMBER_ADDED = "member.added"

View File

@@ -7,9 +7,9 @@ class WorkLog(Base):
__tablename__ = "work_logs"
id = Column(Integer, primary_key=True, index=True)
issue_id = Column(Integer, ForeignKey("issues.id"), nullable=False)
task_id = Column(Integer, ForeignKey("tasks.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
hours = Column(Float, nullable=False) # Hours spent
hours = Column(Float, nullable=False)
description = Column(Text, nullable=True)
logged_date = Column(DateTime(timezone=True), nullable=False) # When the work was done
logged_date = Column(DateTime(timezone=True), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())

View File

@@ -4,9 +4,7 @@ from datetime import datetime
from enum import Enum
class IssueTypeEnum(str, Enum):
MEETING = "meeting"
SUPPORT = "support"
class TaskTypeEnum(str, Enum):
ISSUE = "issue"
MAINTENANCE = "maintenance"
RESEARCH = "research"
@@ -14,40 +12,39 @@ class IssueTypeEnum(str, Enum):
STORY = "story"
TEST = "test"
RESOLUTION = "resolution"
TASK = "task" # legacy
TASK = "task"
class IssueStatusEnum(str, Enum):
class TaskStatusEnum(str, Enum):
OPEN = "open"
IN_PROGRESS = "in_progress"
RESOLVED = "resolved"
PENDING = "pending"
PROGRESSING = "progressing"
CLOSED = "closed"
BLOCKED = "blocked"
class IssuePriorityEnum(str, Enum):
class TaskPriorityEnum(str, Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "critical"
# Issue schemas
class IssueBase(BaseModel):
# Task schemas
class TaskBase(BaseModel):
title: str
description: Optional[str] = None
issue_type: IssueTypeEnum = IssueTypeEnum.ISSUE
issue_subtype: Optional[str] = None
priority: IssuePriorityEnum = IssuePriorityEnum.MEDIUM
task_type: TaskTypeEnum = TaskTypeEnum.TASK
task_subtype: Optional[str] = None
priority: TaskPriorityEnum = TaskPriorityEnum.MEDIUM
tags: Optional[str] = None
depends_on_id: Optional[int] = None
due_date: Optional[datetime] = None
estimated_effort: Optional[int] = None
estimated_working_time: Optional[str] = None
class TaskCreate(TaskBase):
project_id: Optional[int] = None
milestone_id: Optional[int] = None
class IssueCreate(IssueBase):
project_id: int
reporter_id: int
reporter_id: Optional[int] = None
assignee_id: Optional[int] = None
# Resolution specific
resolution_summary: Optional[str] = None
@@ -55,37 +52,35 @@ class IssueCreate(IssueBase):
pending_matters: Optional[str] = None
class IssueUpdate(BaseModel):
class TaskUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
issue_type: Optional[IssueTypeEnum] = None
issue_subtype: Optional[str] = None
status: Optional[IssueStatusEnum] = None
priority: Optional[IssuePriorityEnum] = None
task_type: Optional[TaskTypeEnum] = None
task_subtype: Optional[str] = None
status: Optional[TaskStatusEnum] = None
priority: Optional[TaskPriorityEnum] = None
assignee_id: Optional[int] = None
tags: Optional[str] = None
depends_on_id: Optional[int] = None
due_date: Optional[datetime] = None
milestone_id: Optional[int] = None
estimated_effort: Optional[int] = None
# Resolution specific
resolution_summary: Optional[str] = None
positions: Optional[str] = None
pending_matters: Optional[str] = None
class IssueResponse(IssueBase):
class TaskResponse(TaskBase):
id: int
status: IssueStatusEnum
status: TaskStatusEnum
task_code: Optional[str] = None
project_id: int
milestone_id: int
reporter_id: int
assignee_id: Optional[int]
resolution_summary: Optional[str]
positions: Optional[str]
pending_matters: Optional[str]
due_date: Optional[datetime] = None
milestone_id: Optional[int] = None
assignee_id: Optional[int] = None
resolution_summary: Optional[str] = None
positions: Optional[str] = None
pending_matters: Optional[str] = None
created_at: datetime
updated_at: Optional[datetime]
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
@@ -97,7 +92,7 @@ class CommentBase(BaseModel):
class CommentCreate(CommentBase):
issue_id: int
task_id: int
author_id: int
@@ -107,10 +102,10 @@ class CommentUpdate(BaseModel):
class CommentResponse(CommentBase):
id: int
issue_id: int
task_id: int
author_id: int
created_at: datetime
updated_at: Optional[datetime]
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
@@ -147,15 +142,6 @@ class ProjectResponse(BaseModel):
owner_id: int
created_at: datetime
class _ProjectResponse_Inactive(ProjectBase):
id: int
owner_id: int
project_code: str | None = None
created_at: datetime
class Config:
from_attributes = True
# User schemas
class UserBase(BaseModel):

View File

@@ -6,7 +6,7 @@ from datetime import datetime
class WebhookCreate(BaseModel):
url: str
secret: Optional[str] = None
events: str # comma-separated: "issue.created,issue.updated"
events: str # comma-separated: "task.created,task.updated"
project_id: Optional[int] = None
is_active: bool = True

View File

@@ -5,7 +5,7 @@ from typing import Any, Dict, Tuple
import requests
from sqlalchemy.orm import Session
from app.models.models import Issue
from app.models.task import Task, TaskStatus
from app.models.monitor import ProviderAccount, ProviderUsageSnapshot, MonitoredServer, ServerState
_CACHE: Dict[str, Dict[str, Any]] = {}
@@ -70,25 +70,25 @@ def _normalize_usage_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
}
def get_issue_stats_cached(db: Session, ttl_seconds: int = 1800):
key = 'issue_stats_24h'
def get_task_stats_cached(db: Session, ttl_seconds: int = 1800):
key = 'task_stats_24h'
now = _now()
hit = _CACHE.get(key)
if hit and (now - hit['at']).total_seconds() < ttl_seconds:
return hit['data']
since = now - timedelta(hours=24)
total = db.query(Issue).count()
new_24h = db.query(Issue).filter(Issue.created_at >= since).count()
processed_24h = db.query(Issue).filter(
Issue.updated_at != None,
Issue.updated_at >= since,
Issue.status.in_(['resolved', 'closed'])
total = db.query(Task).count()
new_24h = db.query(Task).filter(Task.created_at >= since).count()
processed_24h = db.query(Task).filter(
Task.updated_at != None,
Task.updated_at >= since,
Task.status == TaskStatus.CLOSED,
).count()
data = {
'total_issues': total,
'new_issues_24h': new_24h,
'processed_issues_24h': processed_24h,
'total_tasks': total,
'new_tasks_24h': new_24h,
'processed_tasks_24h': processed_24h,
'computed_at': now.isoformat(),
'cache_ttl_seconds': ttl_seconds,
}

233
cli.py
View File

@@ -5,27 +5,47 @@ import argparse
import json
import os
import sys
import urllib.request
import urllib.error
import urllib.parse
import urllib.request
BASE_URL = os.environ.get("HARBORFORGE_URL", "http://localhost:8000")
TOKEN = os.environ.get("HARBORFORGE_TOKEN", "")
STATUS_ICON = {
"open": "🟢",
"pending": "🟡",
"progressing": "🔵",
"closed": "",
}
TYPE_ICON = {
"resolution": "⚖️",
"task": "📋",
"story": "📖",
"test": "🧪",
"issue": "📌",
"maintenance": "🛠️",
"research": "🔬",
"review": "🧐",
}
def _request(method, path, data=None):
url = f"{BASE_URL}{path}"
headers = {"Content-Type": "application/json"}
if TOKEN:
headers["Authorization"] = f"Bearer {TOKEN}"
body = json.dumps(data).encode() if data else None
body = json.dumps(data).encode() if data is not None else None
req = urllib.request.Request(url, data=body, headers=headers, method=method)
try:
with urllib.request.urlopen(req) as resp:
if resp.status == 204:
return None
return json.loads(resp.read())
raw = resp.read()
return json.loads(raw) if raw else None
except urllib.error.HTTPError as e:
print(f"Error {e.code}: {e.read().decode()}", file=sys.stderr)
sys.exit(1)
@@ -45,36 +65,39 @@ def cmd_login(args):
sys.exit(1)
def cmd_issues(args):
def cmd_tasks(args):
params = []
if args.project:
params.append(f"project_id={args.project}")
if args.type:
params.append(f"issue_type={args.type}")
params.append(f"task_type={args.type}")
if args.status:
params.append(f"issue_status={args.status}")
params.append(f"task_status={args.status}")
qs = f"?{'&'.join(params)}" if params else ""
issues = _request("GET", f"/issues{qs}")
for i in issues:
status_icon = {"open": "🟢", "in_progress": "🔵", "resolved": "", "closed": "", "blocked": "🔴"}.get(i["status"], "")
type_icon = {"resolution": "⚖️", "task": "📋", "story": "📖", "test": "🧪"}.get(i["issue_type"], "📌")
print(f" {status_icon} {type_icon} #{i['id']} [{i['priority']}] {i['title']}")
result = _request("GET", f"/tasks{qs}")
items = result.get("items", result if isinstance(result, list) else [])
for task in items:
status_icon = STATUS_ICON.get(task["status"], "")
type_icon = TYPE_ICON.get(task.get("task_type"), "📌")
print(f" {status_icon} {type_icon} #{task['id']} [{task['priority']}] {task['title']}")
def cmd_issue_create(args):
def cmd_task_create(args):
data = {
"title": args.title,
"project_id": args.project,
"milestone_id": args.milestone,
"reporter_id": args.reporter,
"issue_type": args.type,
"task_type": args.type,
"priority": args.priority or "medium",
}
if args.description:
data["description"] = args.description
if args.assignee:
data["assignee_id"] = args.assignee
if args.subtype:
data["task_subtype"] = args.subtype
# Resolution specific
if args.type == "resolution":
if args.summary:
data["resolution_summary"] = args.summary
@@ -83,21 +106,21 @@ def cmd_issue_create(args):
if args.pending:
data["pending_matters"] = args.pending
result = _request("POST", "/issues", data)
print(f"Created issue #{result['id']}: {result['title']}")
result = _request("POST", "/tasks", data)
print(f"Created task #{result['id']}: {result['title']}")
def cmd_projects(args):
projects = _request("GET", "/projects")
for p in projects:
print(f" #{p['id']} {p['name']} - {p.get('description', '')}")
for project in projects:
print(f" #{project['id']} {project['name']} - {project.get('description', '')}")
def cmd_users(args):
users = _request("GET", "/users")
for u in users:
role = "👑" if u["is_admin"] else "👤"
print(f" {role} #{u['id']} {u['username']} ({u.get('full_name', '')})")
for user in users:
role = "👑" if user["is_admin"] else "👤"
print(f" {role} #{user['id']} {user['username']} ({user.get('full_name', '')})")
def cmd_version(args):
@@ -110,41 +133,38 @@ def cmd_health(args):
print(f"Status: {result['status']}")
def cmd_search(args):
params = [f"q={args.query}"]
params = [f"q={urllib.parse.quote(args.query)}"]
if args.project:
params.append(f"project_id={args.project}")
qs = "&".join(params)
issues = _request("GET", f"/search/issues?{qs}")
if not issues:
result = _request("GET", f"/search/tasks?{'&'.join(params)}")
items = result.get("items", result if isinstance(result, list) else [])
if not items:
print(" No results found.")
return
for i in issues:
status_icon = {"open": "\U0001f7e2", "in_progress": "\U0001f535", "resolved": "\u2705", "closed": "\u26ab", "blocked": "\U0001f534"}.get(i["status"], "\u2753")
type_icon = {"resolution": "\u2696\ufe0f", "task": "\U0001f4cb", "story": "\U0001f4d6", "test": "\U0001f9ea"}.get(i["issue_type"], "\U0001f4cc")
print(f" {status_icon} {type_icon} #{i['id']} [{i['priority']}] {i['title']}")
for task in items:
status_icon = STATUS_ICON.get(task["status"], "")
type_icon = TYPE_ICON.get(task.get("task_type"), "📌")
print(f" {status_icon} {type_icon} #{task['id']} [{task['priority']}] {task['title']}")
def cmd_transition(args):
result = _request("POST", f"/issues/{args.issue_id}/transition?new_status={args.status}")
print(f"Issue #{result['id']} transitioned to: {result['status']}")
result = _request("POST", f"/tasks/{args.task_id}/transition?new_status={args.status}")
print(f"Task #{result['id']} transitioned to: {result['status']}")
def cmd_stats(args):
params = f"?project_id={args.project}" if args.project else ""
stats = _request("GET", f"/dashboard/stats{params}")
print(f"Total: {stats['total']}")
print(f"Total: {stats['total_tasks']}")
print("By status:")
for s, c in stats["by_status"].items():
if c > 0:
print(f" {s}: {c}")
for status_name, count in stats["by_status"].items():
if count > 0:
print(f" {status_name}: {count}")
print("By type:")
for t, c in stats["by_type"].items():
if c > 0:
print(f" {t}: {c}")
for task_type, count in stats["by_type"].items():
if count > 0:
print(f" {task_type}: {count}")
def cmd_milestones(args):
@@ -153,10 +173,10 @@ def cmd_milestones(args):
if not milestones:
print(" No milestones found.")
return
for m in milestones:
status_icon = "🟢" if m["status"] == "open" else ""
due = f" (due: {m['due_date'][:10]})" if m.get("due_date") else ""
print(f" {status_icon} #{m['id']} {m['title']}{due}")
for milestone in milestones:
status_icon = STATUS_ICON.get(milestone["status"], "")
due = f" (due: {milestone['due_date'][:10]})" if milestone.get("due_date") else ""
print(f" {status_icon} #{milestone['id']} {milestone['title']}{due}")
def cmd_milestone_progress(args):
@@ -165,140 +185,114 @@ def cmd_milestone_progress(args):
filled = int(bar_len * result["progress_pct"] / 100)
bar = "" * filled + "" * (bar_len - filled)
print(f" {result['title']}")
print(f" [{bar}] {result['progress_pct']}% ({result['completed']}/{result['total_issues']})")
print(f" [{bar}] {result['progress_pct']}% ({result['completed']}/{result['total_tasks']})")
def cmd_notifications(args):
params = [f"user_id={args.user}"]
params = []
if args.unread:
params.append("unread_only=true")
qs = "&".join(params)
notifs = _request("GET", f"/notifications?{qs}")
if not notifs:
qs = f"?{'&'.join(params)}" if params else ""
notifications = _request("GET", f"/notifications{qs}")
if not notifications:
print(" No notifications.")
return
for n in notifs:
icon = "🔴" if not n["is_read"] else ""
print(f" {icon} [{n['type']}] {n['title']}")
for notification in notifications:
icon = "🔴" if not notification["is_read"] else ""
print(f" {icon} [{notification['type']}] {notification.get('message') or notification['title']}")
def cmd_overdue(args):
params = f"?project_id={args.project}" if args.project else ""
issues = _request("GET", f"/issues/overdue{params}")
if not issues:
print(" No overdue issues! 🎉")
return
for i in issues:
due = i.get("due_date", "?")[:10] if i.get("due_date") else "?"
print(f" ⏰ #{i['id']} [{i['priority']}] {i['title']} (due: {due})")
print("Overdue tasks are not supported by the current milestone-based task schema.")
def cmd_log_time(args):
from datetime import datetime
data = {
'issue_id': args.issue_id,
'user_id': args.user_id,
'hours': args.hours,
'logged_date': datetime.utcnow().isoformat(),
"task_id": args.task_id,
"user_id": args.user_id,
"hours": args.hours,
"logged_date": datetime.utcnow().isoformat(),
}
if args.desc:
data['description'] = args.desc
r = api('POST', '/worklogs', json=data)
print(f'Logged {r["hours"]}h on issue #{r["issue_id"]} (log #{r["id"]})')
data["description"] = args.desc
result = _request("POST", "/worklogs", data)
print(f"Logged {result['hours']}h on task #{result['task_id']} (log #{result['id']})")
def cmd_worklogs(args):
logs = api('GET', f'/issues/{args.issue_id}/worklogs')
for l in logs:
desc = f' - {l["description"]}' if l.get('description') else ''
print(f' [{l["id"]}] {l["hours"]}h by user#{l["user_id"]} on {l["logged_date"]}{desc}')
summary = api('GET', f'/issues/{args.issue_id}/worklogs/summary')
print(f' Total: {summary["total_hours"]}h ({summary["log_count"]} logs)')
logs = _request("GET", f"/tasks/{args.task_id}/worklogs")
for log in logs:
desc = f" - {log['description']}" if log.get("description") else ""
print(f" [{log['id']}] {log['hours']}h by user#{log['user_id']} on {log['logged_date']}{desc}")
summary = _request("GET", f"/tasks/{args.task_id}/worklogs/summary")
print(f" Total: {summary['total_hours']}h ({summary['log_count']} logs)")
def main():
parser = argparse.ArgumentParser(description="HarborForge CLI")
sub = parser.add_subparsers(dest="command")
# login
p_login = sub.add_parser("login", help="Login and get token")
p_login.add_argument("username")
p_login.add_argument("password")
# issues
p_issues = sub.add_parser("issues", help="List issues")
p_issues.add_argument("--project", "-p", type=int)
p_issues.add_argument("--type", "-t", choices=["task", "story", "test", "resolution"])
p_issues.add_argument("--status", "-s")
p_tasks = sub.add_parser("tasks", aliases=["issues"], help="List tasks")
p_tasks.add_argument("--project", "-p", type=int)
p_tasks.add_argument("--type", "-t", choices=["task", "story", "test", "resolution", "issue", "maintenance", "research", "review"])
p_tasks.add_argument("--status", "-s", choices=["open", "pending", "progressing", "closed"])
# issue create
p_create = sub.add_parser("create-issue", help="Create an issue")
p_create = sub.add_parser("create-task", aliases=["create-issue"], help="Create a task")
p_create.add_argument("title")
p_create.add_argument("--project", "-p", type=int, required=True)
p_create.add_argument("--milestone", "-m", type=int, required=True)
p_create.add_argument("--reporter", "-r", type=int, required=True)
p_create.add_argument("--type", "-t", default="task", choices=["task", "story", "test", "resolution"])
p_create.add_argument("--type", "-t", default="task", choices=["task", "story", "test", "resolution", "issue", "maintenance", "research", "review"])
p_create.add_argument("--subtype")
p_create.add_argument("--priority", choices=["low", "medium", "high", "critical"])
p_create.add_argument("--description", "-d")
p_create.add_argument("--assignee", "-a", type=int)
# Resolution fields
p_create.add_argument("--summary")
p_create.add_argument("--positions")
p_create.add_argument("--pending")
# projects
sub.add_parser("projects", help="List projects")
# users
sub.add_parser("users", help="List users")
# version
sub.add_parser("version", help="Show version")
# health
sub.add_parser("health", help="Health check")
# search
p_search = sub.add_parser("search", help="Search issues")
p_search = sub.add_parser("search", help="Search tasks")
p_search.add_argument("query")
p_search.add_argument("--project", "-p", type=int)
# transition
p_trans = sub.add_parser("transition", help="Transition issue status")
p_trans.add_argument("issue_id", type=int)
p_trans.add_argument("status", choices=["open", "in_progress", "resolved", "closed", "blocked"])
p_trans = sub.add_parser("transition", help="Transition task status")
p_trans.add_argument("task_id", type=int)
p_trans.add_argument("status", choices=["open", "pending", "progressing", "closed"])
# stats
p_stats = sub.add_parser("stats", help="Dashboard stats")
p_stats.add_argument("--project", "-p", type=int)
# milestones
p_ms = sub.add_parser("milestones", help="List milestones")
p_ms.add_argument("--project", "-p", type=int)
# milestone progress
p_msp = sub.add_parser("milestone-progress", help="Show milestone progress")
p_msp.add_argument("milestone_id", type=int)
# notifications
p_notif = sub.add_parser("notifications", help="List notifications")
p_notif.add_argument("--user", "-u", type=int, required=True)
p_notif = sub.add_parser("notifications", help="List notifications for current token user")
p_notif.add_argument("--unread", action="store_true")
# overdue
p_overdue = sub.add_parser("overdue", help="List overdue issues")
p_overdue = sub.add_parser("overdue", help="Explain overdue-task support status")
p_overdue.add_argument("--project", "-p", type=int)
p_logtime = sub.add_parser('log-time', help='Log time on an issue')
p_logtime.add_argument('issue_id', type=int)
p_logtime.add_argument('user_id', type=int)
p_logtime.add_argument('hours', type=float)
p_logtime.add_argument('--desc', '-d', type=str)
p_logtime = sub.add_parser("log-time", help="Log time on a task")
p_logtime.add_argument("task_id", type=int)
p_logtime.add_argument("user_id", type=int)
p_logtime.add_argument("hours", type=float)
p_logtime.add_argument("--desc", "-d", type=str)
p_worklogs = sub.add_parser('worklogs', help='List work logs for an issue')
p_worklogs.add_argument('issue_id', type=int)
p_worklogs = sub.add_parser("worklogs", help="List work logs for a task")
p_worklogs.add_argument("task_id", type=int)
args = parser.parse_args()
if not args.command:
@@ -307,8 +301,10 @@ def main():
cmds = {
"login": cmd_login,
"issues": cmd_issues,
"create-issue": cmd_issue_create,
"tasks": cmd_tasks,
"issues": cmd_tasks,
"create-task": cmd_task_create,
"create-issue": cmd_task_create,
"projects": cmd_projects,
"users": cmd_users,
"version": cmd_version,
@@ -327,5 +323,4 @@ def main():
if __name__ == "__main__":
import urllib.parse
main()