feat: add task type hierarchy with subtypes (issue/meeting/support/maintenance/research/review/story/test)
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user