feat: add task type hierarchy with subtypes (issue/meeting/support/maintenance/research/review/story/test)

This commit is contained in:
zhi
2026-03-11 23:55:52 +00:00
parent 9f9aad8ce0
commit 6b3e42195d
5 changed files with 58 additions and 10 deletions

View File

@@ -17,6 +17,35 @@ from app.services.activity import log_activity
router = APIRouter(tags=["Issues"]) router = APIRouter(tags=["Issues"])
# ---- Type / Subtype validation ----
ISSUE_SUBTYPE_MAP = {
'meeting': {'conference', 'handover', 'recap'},
'support': {'access', 'information'},
'issue': {'infrastructure', 'performance', 'regression', 'security', 'user_experience'},
'maintenance': {'deploy', 'release'},
'review': {'code_review', 'decision_review', 'function_review'},
'story': {'feature', 'improvement', 'refactor'},
'test': {'regression', 'security', 'smoke', 'stress'},
'research': set(),
'task': set(),
'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): 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, n = NotificationModel(user_id=user_id, type=ntype, title=title, message=message,
@@ -45,7 +74,7 @@ def create_issue(issue: schemas.IssueCreate, bg: BackgroundTasks, db: Session =
@router.get("/issues") @router.get("/issues")
def list_issues( def list_issues(
project_id: int = None, issue_status: str = None, issue_type: str = None, project_id: int = None, issue_status: str = None, issue_type: str = None, issue_subtype: str = None,
assignee_id: int = None, tag: str = None, assignee_id: int = None, tag: str = None,
sort_by: str = "created_at", sort_order: str = "desc", sort_by: str = "created_at", sort_order: str = "desc",
page: int = 1, page_size: int = 50, page: int = 1, page_size: int = 50,
@@ -59,6 +88,8 @@ def list_issues(
query = query.filter(models.Issue.status == issue_status) query = query.filter(models.Issue.status == issue_status)
if issue_type: if issue_type:
query = query.filter(models.Issue.issue_type == 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: if assignee_id:
query = query.filter(models.Issue.assignee_id == assignee_id) query = query.filter(models.Issue.assignee_id == assignee_id)
if tag: if tag:
@@ -108,7 +139,7 @@ def update_issue(issue_id: int, issue_update: schemas.IssueUpdate, db: Session =
check_project_role(db, current_user.id, issue.project_id, min_role="dev") check_project_role(db, current_user.id, issue.project_id, min_role="dev")
if not issue: if not issue:
raise HTTPException(status_code=404, detail="Issue not found") raise HTTPException(status_code=404, detail="Issue not found")
for field, value in issue_update.model_dump(exclude_unset=True).items(): for field, value in update_data.items():
setattr(issue, field, value) setattr(issue, field, value)
db.commit() db.commit()
db.refresh(issue) db.refresh(issue)

View File

@@ -298,7 +298,7 @@ def export_issues_csv(project_id: int = None, db: Session = Depends(get_db)):
"reporter_id", "assignee_id", "milestone_id", "due_date", "reporter_id", "assignee_id", "milestone_id", "due_date",
"tags", "created_at", "updated_at"]) "tags", "created_at", "updated_at"])
for i in issues: for i in issues:
writer.writerow([i.id, i.title, i.issue_type, i.status, i.priority, i.project_id, 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.reporter_id, i.assignee_id, i.milestone_id, i.due_date,
i.tags, i.created_at, i.updated_at]) i.tags, i.created_at, i.updated_at])
output.seek(0) output.seek(0)

View File

@@ -51,6 +51,7 @@ def startup():
from app.core.config import Base, engine, SessionLocal from app.core.config import Base, engine, SessionLocal
from app.models import webhook, apikey, activity, milestone, notification, worklog, monitor from app.models import webhook, apikey, activity, milestone, notification, worklog, monitor
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
_migrate_schema()
# Initialize from AbstractWizard (admin user, default project, etc.) # Initialize from AbstractWizard (admin user, default project, etc.)
from app.init_wizard import run_init from app.init_wizard import run_init

View File

@@ -6,10 +6,16 @@ import enum
class IssueType(str, enum.Enum): class IssueType(str, enum.Enum):
TASK = "task" MEETING = meeting
STORY = "story" SUPPORT = support
TEST = "test" ISSUE = issue
RESOLUTION = "resolution" # 决议案 - 用于 Agent 僵局提交 MAINTENANCE = maintenance
RESEARCH = research
REVIEW = review
STORY = story
TEST = test
RESOLUTION = resolution # 决议案 - 用于 Agent 僵局提交
TASK = task # legacy generic type
class IssueStatus(str, enum.Enum): class IssueStatus(str, enum.Enum):
@@ -33,7 +39,8 @@ class Issue(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
title = Column(String(255), nullable=False) title = Column(String(255), nullable=False)
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
issue_type = Column(Enum(IssueType), default=IssueType.TASK) issue_type = Column(String(32), default=IssueType.ISSUE.value)
issue_subtype = Column(String(64), nullable=True)
status = Column(Enum(IssueStatus), default=IssueStatus.OPEN) status = Column(Enum(IssueStatus), default=IssueStatus.OPEN)
priority = Column(Enum(IssuePriority), default=IssuePriority.MEDIUM) priority = Column(Enum(IssuePriority), default=IssuePriority.MEDIUM)

View File

@@ -5,10 +5,16 @@ from enum import Enum
class IssueTypeEnum(str, Enum): class IssueTypeEnum(str, Enum):
TASK = "task" MEETING = "meeting"
SUPPORT = "support"
ISSUE = "issue"
MAINTENANCE = "maintenance"
RESEARCH = "research"
REVIEW = "review"
STORY = "story" STORY = "story"
TEST = "test" TEST = "test"
RESOLUTION = "resolution" RESOLUTION = "resolution"
TASK = "task" # legacy
class IssueStatusEnum(str, Enum): class IssueStatusEnum(str, Enum):
@@ -30,7 +36,8 @@ class IssuePriorityEnum(str, Enum):
class IssueBase(BaseModel): class IssueBase(BaseModel):
title: str title: str
description: Optional[str] = None description: Optional[str] = None
issue_type: IssueTypeEnum = IssueTypeEnum.TASK issue_type: IssueTypeEnum = IssueTypeEnum.ISSUE
issue_subtype: Optional[str] = None
priority: IssuePriorityEnum = IssuePriorityEnum.MEDIUM priority: IssuePriorityEnum = IssuePriorityEnum.MEDIUM
tags: Optional[str] = None tags: Optional[str] = None
depends_on_id: Optional[int] = None depends_on_id: Optional[int] = None
@@ -51,6 +58,8 @@ class IssueCreate(IssueBase):
class IssueUpdate(BaseModel): class IssueUpdate(BaseModel):
title: Optional[str] = None title: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
issue_type: Optional[IssueTypeEnum] = None
issue_subtype: Optional[str] = None
status: Optional[IssueStatusEnum] = None status: Optional[IssueStatusEnum] = None
priority: Optional[IssuePriorityEnum] = None priority: Optional[IssuePriorityEnum] = None
assignee_id: Optional[int] = None assignee_id: Optional[int] = None