feat(P3.1): milestone action endpoints — freeze/start/close + auto-complete hook

- New milestone_actions router with POST freeze/start/close endpoints
- freeze: validates exactly 1 release maintenance task exists
- start: validates all milestone/task dependencies completed, records started_at
- close: allows from open/freeze/undergoing with reason
- try_auto_complete_milestone helper: auto-completes milestone when sole release task finishes
- Wired auto-complete into task transition and update endpoints
- Added freeze enforcement: no new feature story tasks after freeze
- Added started_at to milestone serializer
- All actions write activity logs
This commit is contained in:
zhi
2026-03-17 04:03:05 +00:00
parent 75ccbcb362
commit 7d8c448cb8
4 changed files with 336 additions and 2 deletions

View File

@@ -0,0 +1,313 @@
"""Milestone status-machine action endpoints (P3.1).
Provides freeze / start / close actions on milestones.
completed is triggered automatically when the sole release maintenance task finishes.
"""
import json
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.orm import Session
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, check_permission
from app.models import models
from app.models.milestone import Milestone, MilestoneStatus
from app.models.task import Task, TaskStatus
from app.services.activity import log_activity
router = APIRouter(
prefix="/projects/{project_id}/milestones/{milestone_id}/actions",
tags=["Milestone Actions"],
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_milestone_or_404(db: Session, project_id: int, milestone_id: int) -> Milestone:
ms = (
db.query(Milestone)
.filter(Milestone.id == milestone_id, Milestone.project_id == project_id)
.first()
)
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
return ms
def _ms_status_value(ms: Milestone) -> str:
"""Return status as plain string regardless of enum wrapper."""
return ms.status.value if hasattr(ms.status, "value") else ms.status
# ---------------------------------------------------------------------------
# Request bodies
# ---------------------------------------------------------------------------
class CloseBody(BaseModel):
reason: Optional[str] = None
# ---------------------------------------------------------------------------
# POST /freeze
# ---------------------------------------------------------------------------
@router.post("/freeze", status_code=200)
def freeze_milestone(
project_id: int,
milestone_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
"""Freeze a milestone (open → freeze).
Pre-conditions:
- Milestone must be in ``open`` status.
- Milestone must have **exactly one** maintenance task with subtype ``release``.
- Caller must have ``freeze milestone`` permission.
"""
check_project_role(db, current_user.id, project_id, min_role="mgr")
check_permission(db, current_user.id, project_id, "freeze milestone")
ms = _get_milestone_or_404(db, project_id, milestone_id)
if _ms_status_value(ms) != "open":
raise HTTPException(
status_code=400,
detail=f"Cannot freeze: milestone is '{_ms_status_value(ms)}', expected 'open'",
)
# Check: exactly one maintenance/release task
release_tasks = (
db.query(Task)
.filter(
Task.milestone_id == milestone_id,
Task.task_type == "maintenance",
Task.task_subtype == "release",
)
.all()
)
if len(release_tasks) == 0:
raise HTTPException(
status_code=400,
detail="Cannot freeze: milestone has no maintenance/release task. Create one first.",
)
if len(release_tasks) > 1:
raise HTTPException(
status_code=400,
detail=f"Cannot freeze: milestone has {len(release_tasks)} maintenance/release tasks, expected exactly 1.",
)
ms.status = MilestoneStatus.FREEZE
db.commit()
db.refresh(ms)
log_activity(
db,
action="freeze",
entity_type="milestone",
entity_id=ms.id,
user_id=current_user.id,
details={"from": "open", "to": "freeze"},
)
return {"detail": "Milestone frozen", "status": "freeze"}
# ---------------------------------------------------------------------------
# POST /start
# ---------------------------------------------------------------------------
@router.post("/start", status_code=200)
def start_milestone(
project_id: int,
milestone_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
"""Start a milestone (freeze → undergoing).
Pre-conditions:
- Milestone must be in ``freeze`` status.
- All milestone dependencies must be completed.
- Caller must have ``start milestone`` permission.
"""
check_project_role(db, current_user.id, project_id, min_role="mgr")
check_permission(db, current_user.id, project_id, "start milestone")
ms = _get_milestone_or_404(db, project_id, milestone_id)
if _ms_status_value(ms) != "freeze":
raise HTTPException(
status_code=400,
detail=f"Cannot start: milestone is '{_ms_status_value(ms)}', expected 'freeze'",
)
# Dependency check — milestone dependencies
dep_ms_ids = []
if ms.depend_on_milestones:
try:
dep_ms_ids = json.loads(ms.depend_on_milestones)
except (json.JSONDecodeError, TypeError):
dep_ms_ids = []
if dep_ms_ids:
dep_milestones = (
db.query(Milestone)
.filter(Milestone.id.in_(dep_ms_ids))
.all()
)
incomplete = [
m.id
for m in dep_milestones
if _ms_status_value(m) != "completed"
]
if incomplete:
raise HTTPException(
status_code=400,
detail=f"Cannot start: dependent milestones not completed: {incomplete}",
)
# Dependency check — task dependencies
dep_task_ids = []
if ms.depend_on_tasks:
try:
dep_task_ids = json.loads(ms.depend_on_tasks)
except (json.JSONDecodeError, TypeError):
dep_task_ids = []
if dep_task_ids:
dep_tasks = db.query(Task).filter(Task.id.in_(dep_task_ids)).all()
incomplete_tasks = [
t.id
for t in dep_tasks
if (t.status.value if hasattr(t.status, "value") else t.status) != "completed"
]
if incomplete_tasks:
raise HTTPException(
status_code=400,
detail=f"Cannot start: dependent tasks not completed: {incomplete_tasks}",
)
ms.status = MilestoneStatus.UNDERGOING
ms.started_at = datetime.now(timezone.utc)
db.commit()
db.refresh(ms)
log_activity(
db,
action="start",
entity_type="milestone",
entity_id=ms.id,
user_id=current_user.id,
details={"from": "freeze", "to": "undergoing", "started_at": ms.started_at.isoformat()},
)
return {"detail": "Milestone started", "status": "undergoing", "started_at": ms.started_at.isoformat()}
# ---------------------------------------------------------------------------
# POST /close
# ---------------------------------------------------------------------------
@router.post("/close", status_code=200)
def close_milestone(
project_id: int,
milestone_id: int,
body: CloseBody = CloseBody(),
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
"""Close (abandon) a milestone (open/freeze/undergoing → closed).
Pre-conditions:
- Milestone must be in ``open``, ``freeze``, or ``undergoing`` status.
- Caller must have ``close milestone`` permission.
"""
check_project_role(db, current_user.id, project_id, min_role="mgr")
check_permission(db, current_user.id, project_id, "close milestone")
ms = _get_milestone_or_404(db, project_id, milestone_id)
current = _ms_status_value(ms)
allowed_from = {"open", "freeze", "undergoing"}
if current not in allowed_from:
raise HTTPException(
status_code=400,
detail=f"Cannot close: milestone is '{current}', must be one of {sorted(allowed_from)}",
)
ms.status = MilestoneStatus.CLOSED
db.commit()
db.refresh(ms)
log_activity(
db,
action="close",
entity_type="milestone",
entity_id=ms.id,
user_id=current_user.id,
details={"from": current, "to": "closed", "reason": body.reason},
)
return {"detail": "Milestone closed", "status": "closed"}
# ---------------------------------------------------------------------------
# Auto-complete helper (called from task completion logic)
# ---------------------------------------------------------------------------
def try_auto_complete_milestone(db: Session, task: Task, user_id: int | None = None):
"""Check if a just-completed task is the sole release/maintenance task
of its milestone, and if so auto-complete the milestone.
This function is designed to be called from the task status transition
logic whenever a task reaches ``completed``.
"""
if task.task_type != "maintenance" or task.task_subtype != "release":
return # not a release task — nothing to do
milestone = (
db.query(Milestone)
.filter(Milestone.id == task.milestone_id)
.first()
)
if not milestone:
return
if _ms_status_value(milestone) != "undergoing":
return # only auto-complete from undergoing
# Verify this is the *only* release task under the milestone
release_count = (
db.query(Task)
.filter(
Task.milestone_id == milestone.id,
Task.task_type == "maintenance",
Task.task_subtype == "release",
)
.count()
)
if release_count != 1:
return # ambiguous — don't auto-complete
milestone.status = MilestoneStatus.COMPLETED
db.commit()
log_activity(
db,
action="auto_complete",
entity_type="milestone",
entity_id=milestone.id,
user_id=user_id,
details={
"trigger": f"release task #{task.id} completed",
"from": "undergoing",
"to": "completed",
},
)

View File

@@ -31,6 +31,7 @@ def _serialize_milestone(milestone):
"depend_on_tasks": json.loads(milestone.depend_on_tasks) if milestone.depend_on_tasks else [],
"project_id": milestone.project_id,
"created_by_id": milestone.created_by_id,
"started_at": milestone.started_at,
"created_at": milestone.created_at,
"updated_at": milestone.updated_at,
}
@@ -111,8 +112,14 @@ def create_milestone_task(project_id: int, milestone_id: int, task_data: schemas
if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
if milestone.status and hasattr(milestone.status, 'value') and milestone.status.value == "undergoing":
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is undergoing")
ms_status = milestone.status.value if hasattr(milestone.status, 'value') else milestone.status
if ms_status in ("undergoing", "completed", "closed"):
raise HTTPException(status_code=400, detail=f"Cannot add items to a milestone that is '{ms_status}'")
# P3.6 / §5: freeze prevents adding new feature story tasks
task_type = task_data.model_dump(exclude_unset=True).get("task_type", "")
task_subtype = task_data.model_dump(exclude_unset=True).get("task_subtype", "")
if ms_status == "freeze" and task_type == "story" and task_subtype == "feature":
raise HTTPException(status_code=400, detail="Cannot add feature story tasks after milestone is frozen")
# Generate task_code
milestone_code = milestone.milestone_code or f"m{milestone.id}"

View File

@@ -176,6 +176,12 @@ def update_task(task_id: int, task_update: schemas.TaskUpdate, db: Session = Dep
setattr(task, field, value)
db.commit()
db.refresh(task)
# P3.5: auto-complete milestone when release task reaches completed via update
if "status" in update_data and update_data["status"] == "completed":
from app.api.routers.milestone_actions import try_auto_complete_milestone
try_auto_complete_milestone(db, task, user_id=current_user.id)
return task
@@ -209,6 +215,12 @@ def transition_task(task_id: int, new_status: str, bg: BackgroundTasks, db: Sess
task.status = new_status
db.commit()
db.refresh(task)
# P3.5: auto-complete milestone when its sole release task is completed
if new_status == "completed":
from app.api.routers.milestone_actions import try_auto_complete_milestone
try_auto_complete_milestone(db, task, user_id=None)
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},

View File

@@ -38,6 +38,7 @@ from app.api.routers.monitor import router as monitor_router
from app.api.routers.milestones import router as milestones_router
from app.api.routers.roles import router as roles_router
from app.api.routers.proposes import router as proposes_router
from app.api.routers.milestone_actions import router as milestone_actions_router
app.include_router(auth_router)
app.include_router(tasks_router)
@@ -50,6 +51,7 @@ app.include_router(monitor_router)
app.include_router(milestones_router)
app.include_router(roles_router)
app.include_router(proposes_router)
app.include_router(milestone_actions_router)
# Auto schema migration for lightweight deployments