feat(db): add sprint sweep subcommand for health checks
Structured JSON output with tickets grouped by status, per-team summary counts, and bookkeeping issue detection (unassigned in_progress, stale backlog, assigned but done). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+102
-4
@@ -7,6 +7,7 @@ Direct DB access only for sprint lifecycle mutations.
|
||||
|
||||
Usage:
|
||||
sprint status [--sprint N] [--team T] Sprint progress and ticket overview
|
||||
sprint sweep [--sprint N] Health check: grouped tickets, issues, team summary (JSON)
|
||||
sprint start [--sprint N] Activate a planned sprint
|
||||
sprint stop [--sprint N] Complete an active sprint
|
||||
sprint start-work [--sprint N] [--team T] Full context dump for starting work
|
||||
@@ -503,6 +504,101 @@ def cmd_start_work(args):
|
||||
print(REMINDER)
|
||||
|
||||
|
||||
def cmd_sweep(args):
|
||||
"""Health check: grouped tickets, bookkeeping issues, team summary (JSON)."""
|
||||
flags, _ = parse_flags(args, ["sprint"])
|
||||
sprint = detect_sprint(flags, prefer_status="active")
|
||||
|
||||
tickets = get_tickets_for_sprint(sprint["id"])
|
||||
|
||||
# Build dependency map for blocked detection
|
||||
blocked_by_map = {} # ticket_id -> [blocker_id, ...]
|
||||
for t in tickets:
|
||||
if t["status"] == "done":
|
||||
continue
|
||||
deps = get_ticket_deps(t["id"])
|
||||
open_blockers = [b["id"] for b in deps.get("blocked_by", []) if b["status"] != "done"]
|
||||
if open_blockers:
|
||||
blocked_by_map[t["id"]] = open_blockers
|
||||
|
||||
# Group by status
|
||||
by_status = {"done": [], "review": [], "in_progress": [], "blocked": [], "backlog": []}
|
||||
for t in tickets:
|
||||
entry = {
|
||||
"id": t["id"],
|
||||
"title": t["title"],
|
||||
"team": t.get("team") or "unassigned",
|
||||
"assigned_to": t.get("assigned_to"),
|
||||
"priority": t["priority"],
|
||||
}
|
||||
if t["status"] == "done":
|
||||
by_status["done"].append(entry)
|
||||
elif t["id"] in blocked_by_map:
|
||||
entry["blocked_by"] = blocked_by_map[t["id"]]
|
||||
by_status["blocked"].append(entry)
|
||||
elif t["status"] == "review":
|
||||
by_status["review"].append(entry)
|
||||
elif t["status"] == "in_progress":
|
||||
by_status["in_progress"].append(entry)
|
||||
else:
|
||||
by_status["backlog"].append(entry)
|
||||
|
||||
# Per-team summary
|
||||
by_team = {}
|
||||
for status, items in by_status.items():
|
||||
for item in items:
|
||||
team = item["team"]
|
||||
if team not in by_team:
|
||||
by_team[team] = {"backlog": 0, "in_progress": 0, "review": 0, "blocked": 0, "done": 0, "total": 0}
|
||||
by_team[team][status] = by_team[team].get(status, 0) + 1
|
||||
by_team[team]["total"] += 1
|
||||
|
||||
# Bookkeeping issues
|
||||
issues = []
|
||||
for t in tickets:
|
||||
if t["status"] in ("in_progress", "review") and not t.get("assigned_to"):
|
||||
issues.append({
|
||||
"type": "unassigned_in_progress",
|
||||
"ticket_id": t["id"],
|
||||
"detail": f"#{t['id']} is {t['status']} but has no agent assigned",
|
||||
"fix": f"db/connectors/ticket assign {t['id']} <agent>",
|
||||
})
|
||||
if t["status"] == "backlog" and sprint["status"] == "active" and t["id"] not in blocked_by_map:
|
||||
issues.append({
|
||||
"type": "stale_backlog",
|
||||
"ticket_id": t["id"],
|
||||
"detail": f"#{t['id']} still in backlog (unblocked, never started)",
|
||||
"fix": f"db/connectors/ticket status {t['id']} in_progress",
|
||||
})
|
||||
if t["status"] == "done" and t.get("assigned_to"):
|
||||
issues.append({
|
||||
"type": "assigned_but_done",
|
||||
"ticket_id": t["id"],
|
||||
"detail": f"#{t['id']} is done but still assigned to {t['assigned_to']}",
|
||||
"fix": f"db/connectors/ticket unassign {t['id']}",
|
||||
})
|
||||
|
||||
# Progress
|
||||
total = len(tickets)
|
||||
done = len(by_status["done"])
|
||||
pct = int(done / total * 100) if total > 0 else 0
|
||||
|
||||
result = {
|
||||
"ok": True,
|
||||
"sprint": {
|
||||
"id": sprint["id"],
|
||||
"name": sprint.get("name", f"Sprint {sprint['id']}"),
|
||||
"status": sprint["status"],
|
||||
"goal": sprint.get("goal", ""),
|
||||
},
|
||||
"progress": {"total": total, "done": done, "pct": pct},
|
||||
"by_status": by_status,
|
||||
"by_team": by_team,
|
||||
"issues": issues,
|
||||
}
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
|
||||
def cmd_prepare(args):
|
||||
flags, _ = parse_flags(args, ["sprint", "team"])
|
||||
sprint = detect_sprint_for_prepare(flags)
|
||||
@@ -606,16 +702,17 @@ HELP = """sprint \u2014 sprint lifecycle and context for agents
|
||||
|
||||
Usage:
|
||||
sprint status [--sprint N] [--team T] Sprint progress and ticket overview
|
||||
sprint sweep [--sprint N] Health check: grouped tickets, issues, team summary (JSON)
|
||||
sprint start [--sprint N] Activate a planned sprint
|
||||
sprint stop [--sprint N] Complete an active sprint
|
||||
sprint start-work [--sprint N] [--team T] Full context dump for starting work
|
||||
sprint prepare [--sprint N] [--team T] Prepare next sprint (candidates + gaps)
|
||||
|
||||
Sprint auto-detection:
|
||||
status/start-work prefer the active sprint
|
||||
start prefer the planning sprint
|
||||
stop prefer the active sprint
|
||||
prepare target next sprint (max id + 1)
|
||||
status/start-work/sweep prefer the active sprint
|
||||
start prefer the planning sprint
|
||||
stop prefer the active sprint
|
||||
prepare target next sprint (max id + 1)
|
||||
|
||||
Team auto-detection:
|
||||
If --team is omitted, uses the current git branch name (unless on main).
|
||||
@@ -632,6 +729,7 @@ def main():
|
||||
|
||||
commands = {
|
||||
"status": cmd_status,
|
||||
"sweep": cmd_sweep,
|
||||
"start": cmd_start,
|
||||
"stop": cmd_stop,
|
||||
"start-work": cmd_start_work,
|
||||
|
||||
Reference in New Issue
Block a user