diff --git a/db/commonwealth.db b/db/commonwealth.db index 569c76f2a..6b440a6bc 100644 Binary files a/db/commonwealth.db and b/db/commonwealth.db differ diff --git a/db/connectors/ticket b/db/connectors/ticket new file mode 100755 index 000000000..eab87097d --- /dev/null +++ b/db/connectors/ticket @@ -0,0 +1,353 @@ +#!/usr/bin/env python3 +""" +Ticket CLI — ergonomic interface to the project ticketing database. + +Usage: + ticket list [--status S] [--priority P] [--epic N] [--sprint N] [--assigned A] + ticket show + ticket done [ ...] + ticket status + ticket assign + ticket unassign + ticket sprint [--active] + ticket sprint assign + ticket deps + ticket search + ticket create [--parent N] [--priority P] [--decision D] + ticket epics [--status S] + ticket children <id> + ticket count [--status S] + +All output is JSON on stdout. +""" + +import json +import os +import sqlite3 +import sys +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +CONFIG_PATH = SCRIPT_DIR / "config.json" + + +def load_config(): + with open(CONFIG_PATH, "r") as f: + cfg = json.load(f) + db_path = (SCRIPT_DIR / cfg["sqlite_db"]).resolve() + cfg["sqlite_db_resolved"] = str(db_path) + return cfg + + +def get_connection(cfg): + conn = sqlite3.connect(cfg["sqlite_db_resolved"]) + conn.execute("PRAGMA journal_mode=WAL;") + conn.execute("PRAGMA foreign_keys=ON;") + conn.row_factory = sqlite3.Row + return conn + + +def query(conn, sql, params=()): + cursor = conn.execute(sql, params) + columns = [desc[0] for desc in cursor.description] if cursor.description else [] + return [dict(zip(columns, row)) for row in cursor.fetchall()] + + +def execute(conn, sql, params=()): + cursor = conn.execute(sql, params) + conn.commit() + return cursor.rowcount + + +def out(data): + print(json.dumps(data, indent=2)) + + +def parse_flags(args, known_flags): + """Parse --flag value pairs from args, return (flags_dict, positional_args).""" + flags = {} + positional = [] + i = 0 + while i < len(args): + if args[i].startswith("--") and args[i][2:] in known_flags: + key = args[i][2:] + if i + 1 < len(args): + flags[key] = args[i + 1] + i += 2 + else: + positional.append(args[i]) + i += 1 + else: + positional.append(args[i]) + i += 1 + return flags, positional + + +# --------------------------------------------------------------------------- +# Commands +# --------------------------------------------------------------------------- + +def cmd_list(conn, args): + flags, _ = parse_flags(args, ["status", "priority", "epic", "sprint", "assigned"]) + conditions = [] + params = [] + if "status" in flags: + conditions.append("t.status = ?") + params.append(flags["status"]) + if "priority" in flags: + conditions.append("t.priority = ?") + params.append(flags["priority"]) + if "epic" in flags: + conditions.append("t.parent_id = ?") + params.append(int(flags["epic"])) + if "sprint" in flags: + conditions.append("t.sprint_id = ?") + params.append(int(flags["sprint"])) + if "assigned" in flags: + conditions.append("t.assigned_to = ?") + params.append(flags["assigned"]) + where = " AND ".join(conditions) if conditions else "1=1" + sql = f"""SELECT t.id, t.type, t.title, t.status, t.priority, t.assigned_to, + t.parent_id, t.sprint_id + FROM tickets t WHERE {where} + ORDER BY + CASE t.priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 + WHEN 'medium' THEN 2 ELSE 3 END, t.id""" + rows = query(conn, sql, tuple(params)) + out({"ok": True, "count": len(rows), "rows": rows}) + + +def cmd_show(conn, ticket_id): + rows = query(conn, """SELECT t.*, p.title as parent_title + FROM tickets t LEFT JOIN tickets p ON t.parent_id = p.id + WHERE t.id = ?""", (ticket_id,)) + if not rows: + out({"ok": False, "error": f"Ticket #{ticket_id} not found"}) + return + ticket = rows[0] + # Get children + children = query(conn, "SELECT id, title, status, priority FROM tickets WHERE parent_id = ? ORDER BY id", (ticket_id,)) + # Get dependencies (what blocks this) + blockers = query(conn, """SELECT t.id, t.title, t.status FROM ticket_deps d + JOIN tickets t ON d.blocker_id = t.id + WHERE d.blocked_id = ?""", (ticket_id,)) + # Get dependents (what this blocks) + blocks = query(conn, """SELECT t.id, t.title, t.status FROM ticket_deps d + JOIN tickets t ON d.blocked_id = t.id + WHERE d.blocker_id = ?""", (ticket_id,)) + ticket["children"] = children + ticket["blocked_by"] = blockers + ticket["blocks"] = blocks + out({"ok": True, "ticket": ticket}) + + +def cmd_done(conn, ids): + updated = 0 + for tid in ids: + updated += execute(conn, "UPDATE tickets SET status='done', updated_at=datetime('now') WHERE id=?", (int(tid),)) + out({"ok": True, "updated": updated, "ids": [int(i) for i in ids]}) + + +def cmd_status(conn, ticket_id, new_status): + valid = ('backlog', 'ready', 'in_progress', 'review', 'done', 'cancelled') + if new_status not in valid: + out({"ok": False, "error": f"Invalid status '{new_status}'. Valid: {', '.join(valid)}"}) + return + updated = execute(conn, "UPDATE tickets SET status=?, updated_at=datetime('now') WHERE id=?", (new_status, int(ticket_id))) + out({"ok": True, "updated": updated, "id": int(ticket_id), "status": new_status}) + + +def cmd_assign(conn, ticket_id, agent): + updated = execute(conn, "UPDATE tickets SET assigned_to=?, updated_at=datetime('now') WHERE id=?", (agent, int(ticket_id))) + out({"ok": True, "updated": updated, "id": int(ticket_id), "assigned_to": agent}) + + +def cmd_unassign(conn, ticket_id): + updated = execute(conn, "UPDATE tickets SET assigned_to=NULL, updated_at=datetime('now') WHERE id=?", (int(ticket_id),)) + out({"ok": True, "updated": updated, "id": int(ticket_id), "assigned_to": None}) + + +def cmd_sprint(conn, args): + flags, positional = parse_flags(args, ["active"]) + if positional and positional[0] == "assign" and len(positional) >= 3: + ticket_id, sprint_id = int(positional[1]), int(positional[2]) + updated = execute(conn, "UPDATE tickets SET sprint_id=?, updated_at=datetime('now') WHERE id=?", (sprint_id, ticket_id)) + out({"ok": True, "updated": updated, "id": ticket_id, "sprint_id": sprint_id}) + return + conditions = [] + params = [] + if "active" in flags: + conditions.append("s.status = 'active'") + where = " AND ".join(conditions) if conditions else "1=1" + sprints = query(conn, f"""SELECT s.*, COUNT(t.id) as ticket_count, + SUM(CASE WHEN t.status='done' THEN 1 ELSE 0 END) as done_count + FROM sprints s LEFT JOIN tickets t ON t.sprint_id = s.id + WHERE {where} GROUP BY s.id ORDER BY s.id DESC""", tuple(params)) + out({"ok": True, "count": len(sprints), "sprints": sprints}) + + +def cmd_deps(conn, ticket_id): + blockers = query(conn, """SELECT t.id, t.title, t.status, t.priority FROM ticket_deps d + JOIN tickets t ON d.blocker_id = t.id + WHERE d.blocked_id = ? ORDER BY t.id""", (int(ticket_id),)) + blocks = query(conn, """SELECT t.id, t.title, t.status, t.priority FROM ticket_deps d + JOIN tickets t ON d.blocked_id = t.id + WHERE d.blocker_id = ? ORDER BY t.id""", (int(ticket_id),)) + out({"ok": True, "id": int(ticket_id), "blocked_by": blockers, "blocks": blocks}) + + +def cmd_search(conn, keyword): + rows = query(conn, """SELECT id, type, title, status, priority, assigned_to + FROM tickets WHERE title LIKE ? OR description LIKE ? + ORDER BY id""", (f"%{keyword}%", f"%{keyword}%")) + out({"ok": True, "count": len(rows), "rows": rows}) + + +def cmd_create(conn, args): + flags, positional = parse_flags(args, ["parent", "priority", "decision"]) + if len(positional) < 2: + out({"ok": False, "error": "Usage: ticket create <type> <title> [--parent N] [--priority P] [--decision D]"}) + return + ticket_type = positional[0] + title = " ".join(positional[1:]) + parent_id = int(flags["parent"]) if "parent" in flags else None + priority = flags.get("priority", "medium") + decision_ref = flags.get("decision") + conn.execute( + "INSERT INTO tickets (type, title, parent_id, priority, decision_ref) VALUES (?, ?, ?, ?, ?)", + (ticket_type, title, parent_id, priority, decision_ref)) + conn.commit() + last_id = query(conn, "SELECT last_insert_rowid() as id")[0]["id"] + out({"ok": True, "id": last_id, "title": title}) + + +def cmd_epics(conn, args): + flags, _ = parse_flags(args, ["status"]) + conditions = ["t.type = 'epic'"] + params = [] + if "status" in flags: + conditions.append("t.status = ?") + params.append(flags["status"]) + where = " AND ".join(conditions) + rows = query(conn, f"""SELECT t.id, t.title, t.status, t.priority, t.assigned_to, + COUNT(c.id) as child_count, + SUM(CASE WHEN c.status='done' THEN 1 ELSE 0 END) as done_count + FROM tickets t LEFT JOIN tickets c ON c.parent_id = t.id + WHERE {where} GROUP BY t.id + ORDER BY CASE t.priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 + WHEN 'medium' THEN 2 ELSE 3 END, t.id""", tuple(params)) + out({"ok": True, "count": len(rows), "rows": rows}) + + +def cmd_children(conn, ticket_id): + rows = query(conn, """SELECT id, type, title, status, priority, assigned_to + FROM tickets WHERE parent_id = ? ORDER BY id""", (int(ticket_id),)) + out({"ok": True, "count": len(rows), "parent_id": int(ticket_id), "rows": rows}) + + +def cmd_count(conn, args): + flags, _ = parse_flags(args, ["status"]) + if "status" in flags: + rows = query(conn, "SELECT COUNT(*) as count FROM tickets WHERE status = ?", (flags["status"],)) + else: + rows = query(conn, "SELECT status, COUNT(*) as count FROM tickets GROUP BY status ORDER BY count DESC") + out({"ok": True, "rows": rows}) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +HELP = """ticket — project ticket CLI + +Usage: + ticket list [--status S] [--priority P] [--epic N] [--sprint N] [--assigned A] + ticket show <id> Full ticket detail with deps and children + ticket done <id> [<id> ...] Mark tickets as done + ticket status <id> <new_status> Change ticket status + ticket assign <id> <agent> Assign ticket to agent/branch + ticket unassign <id> Remove assignment + ticket sprint [--active] List sprints + ticket sprint assign <id> <sprint> Assign ticket to sprint + ticket deps <id> Show ticket dependencies + ticket search <keyword> Search tickets by title/description + ticket create <type> <title> [--parent N] [--priority P] [--decision D] + ticket epics [--status S] List epics with child counts + ticket children <id> List children of a ticket + ticket count [--status S] Count tickets by status""" + + +def main(): + if len(sys.argv) < 2 or sys.argv[1] in ("--help", "-h", "help"): + print(HELP) + sys.exit(0) + + try: + cfg = load_config() + except (FileNotFoundError, json.JSONDecodeError) as exc: + out({"ok": False, "error": f"Config error: {exc}"}) + sys.exit(1) + + conn = get_connection(cfg) + cmd = sys.argv[1] + args = sys.argv[2:] + + try: + if cmd == "list": + cmd_list(conn, args) + elif cmd == "show": + if not args: + out({"ok": False, "error": "Usage: ticket show <id>"}) + else: + cmd_show(conn, int(args[0])) + elif cmd == "done": + if not args: + out({"ok": False, "error": "Usage: ticket done <id> [<id> ...]"}) + else: + cmd_done(conn, args) + elif cmd == "status": + if len(args) < 2: + out({"ok": False, "error": "Usage: ticket status <id> <new_status>"}) + else: + cmd_status(conn, args[0], args[1]) + elif cmd == "assign": + if len(args) < 2: + out({"ok": False, "error": "Usage: ticket assign <id> <agent>"}) + else: + cmd_assign(conn, args[0], args[1]) + elif cmd == "unassign": + if not args: + out({"ok": False, "error": "Usage: ticket unassign <id>"}) + else: + cmd_unassign(conn, args[0]) + elif cmd == "sprint": + cmd_sprint(conn, args) + elif cmd == "deps": + if not args: + out({"ok": False, "error": "Usage: ticket deps <id>"}) + else: + cmd_deps(conn, args[0]) + elif cmd == "search": + if not args: + out({"ok": False, "error": "Usage: ticket search <keyword>"}) + else: + cmd_search(conn, " ".join(args)) + elif cmd == "create": + cmd_create(conn, args) + elif cmd == "epics": + cmd_epics(conn, args) + elif cmd == "children": + if not args: + out({"ok": False, "error": "Usage: ticket children <id>"}) + else: + cmd_children(conn, args[0]) + elif cmd == "count": + cmd_count(conn, args) + else: + out({"ok": False, "error": f"Unknown command: {cmd}. Use --help for usage."}) + finally: + conn.close() + + +if __name__ == "__main__": + main()