From 2fb8e95d532a9a318ada3b0d4dab57462b6fa2e5 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 24 Feb 2026 02:35:33 +0100 Subject: [PATCH] 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 --- db/connectors/sprint | 106 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 102 insertions(+), 4 deletions(-) diff --git a/db/connectors/sprint b/db/connectors/sprint index babeb7794..8e60602fe 100755 --- a/db/connectors/sprint +++ b/db/connectors/sprint @@ -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']} ", + }) + 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,