Ports settled-reach's decisions_sync.py + ticket + decision scripts, Scrum-stripped. Writes to .pql/pql.db (gitignored). Verb shape mirrors the eventual `pql` subcommands so migration is a call-site find-replace once pql ships feature parity (D-040, R-011). Ticket IDs are T-NNN (TEXT PKs) — reshape from settled-reach's integer auto-increment so the stopgap's writes are compatible with pql's future reads without a data migration. No `sprints` table; the pyramid is kanban / waterfall (D-035). Verified end-to-end: sync parses 71 records (39 confirmed, 23 open questions, 9 rejected) with 62 cross-refs and zero broken links; `ticket new`, `ticket status`, `ticket board`, and `decisions show --with-refs` all round-trip correctly. Co-Authored-By: Claude <noreply@anthropic.com>
250 lines
6.9 KiB
Python
250 lines
6.9 KiB
Python
"""Tickets repository — CRUD with T-NNN TEXT PKs and history tracking."""
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
|
|
from .schema import PRIORITIES, TICKET_STATUSES, TICKET_TYPES
|
|
|
|
|
|
def next_ticket_id(conn: sqlite3.Connection) -> str:
|
|
row = conn.execute(
|
|
"SELECT id FROM tickets WHERE id LIKE 'T-%' ORDER BY "
|
|
"CAST(SUBSTR(id, 3) AS INTEGER) DESC LIMIT 1"
|
|
).fetchone()
|
|
highest = 0
|
|
if row is not None:
|
|
try:
|
|
highest = int(row["id"].split("-", 1)[1])
|
|
except ValueError:
|
|
highest = 0
|
|
return f"T-{highest + 1:03d}"
|
|
|
|
|
|
def create(
|
|
conn: sqlite3.Connection,
|
|
*,
|
|
type_: str,
|
|
title: str,
|
|
description: str | None = None,
|
|
parent_id: str | None = None,
|
|
priority: str = "medium",
|
|
decision_ref: str | None = None,
|
|
team: str | None = None,
|
|
) -> str:
|
|
if type_ not in TICKET_TYPES:
|
|
raise ValueError(f"invalid type {type_!r}; must be one of {TICKET_TYPES}")
|
|
if priority not in PRIORITIES:
|
|
raise ValueError(f"invalid priority {priority!r}; must be one of {PRIORITIES}")
|
|
tid = next_ticket_id(conn)
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO tickets
|
|
(id, type, parent_id, title, description, priority, decision_ref, team)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(tid, type_, parent_id, title, description, priority, decision_ref, team),
|
|
)
|
|
conn.commit()
|
|
return tid
|
|
|
|
|
|
def list_(
|
|
conn: sqlite3.Connection,
|
|
*,
|
|
status: str | None = None,
|
|
team: str | None = None,
|
|
assigned: str | None = None,
|
|
decision: str | None = None,
|
|
label: str | None = None,
|
|
) -> list[sqlite3.Row]:
|
|
conds: list[str] = []
|
|
params: list[str] = []
|
|
if status:
|
|
conds.append("t.status = ?")
|
|
params.append(status)
|
|
if team:
|
|
conds.append("t.team = ?")
|
|
params.append(team)
|
|
if assigned:
|
|
conds.append("t.assigned_to = ?")
|
|
params.append(assigned)
|
|
if decision:
|
|
conds.append("t.decision_ref = ?")
|
|
params.append(decision)
|
|
join = ""
|
|
if label:
|
|
join = " JOIN ticket_labels l ON l.ticket_id = t.id "
|
|
conds.append("l.label = ?")
|
|
params.append(label)
|
|
where = " AND ".join(conds) if conds else "1=1"
|
|
return list(
|
|
conn.execute(
|
|
f"""
|
|
SELECT t.id, t.type, t.title, t.status, t.priority,
|
|
t.assigned_to, t.team, t.decision_ref
|
|
FROM tickets t{join}
|
|
WHERE {where}
|
|
ORDER BY
|
|
CASE t.priority
|
|
WHEN 'critical' THEN 0
|
|
WHEN 'high' THEN 1
|
|
WHEN 'medium' THEN 2
|
|
ELSE 3
|
|
END,
|
|
t.id
|
|
""",
|
|
params,
|
|
)
|
|
)
|
|
|
|
|
|
def get(conn: sqlite3.Connection, tid: str) -> sqlite3.Row | None:
|
|
return conn.execute("SELECT * FROM tickets WHERE id = ?", (tid,)).fetchone()
|
|
|
|
|
|
def children(conn: sqlite3.Connection, tid: str) -> list[sqlite3.Row]:
|
|
return list(
|
|
conn.execute(
|
|
"SELECT id, type, title, status, priority FROM tickets "
|
|
"WHERE parent_id = ? ORDER BY id",
|
|
(tid,),
|
|
)
|
|
)
|
|
|
|
|
|
def blockers(conn: sqlite3.Connection, tid: str) -> list[sqlite3.Row]:
|
|
return list(
|
|
conn.execute(
|
|
"""
|
|
SELECT t.id, t.title, t.status
|
|
FROM ticket_deps d JOIN tickets t ON d.blocker_id = t.id
|
|
WHERE d.blocked_id = ?
|
|
ORDER BY t.id
|
|
""",
|
|
(tid,),
|
|
)
|
|
)
|
|
|
|
|
|
def set_status(
|
|
conn: sqlite3.Connection, tid: str, new_status: str, *, changed_by: str | None = None
|
|
) -> int:
|
|
if new_status not in TICKET_STATUSES:
|
|
raise ValueError(
|
|
f"invalid status {new_status!r}; must be one of {TICKET_STATUSES}"
|
|
)
|
|
row = get(conn, tid)
|
|
if row is None:
|
|
return 0
|
|
old_status = row["status"]
|
|
n = conn.execute(
|
|
"UPDATE tickets SET status = ?, updated_at = datetime('now') WHERE id = ?",
|
|
(new_status, tid),
|
|
).rowcount
|
|
if n:
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by)
|
|
VALUES (?, 'status', ?, ?, ?)
|
|
""",
|
|
(tid, old_status, new_status, changed_by),
|
|
)
|
|
conn.commit()
|
|
return n
|
|
|
|
|
|
def set_field(
|
|
conn: sqlite3.Connection,
|
|
tid: str,
|
|
field: str,
|
|
value: str | None,
|
|
*,
|
|
changed_by: str | None = None,
|
|
) -> int:
|
|
if field not in {"assigned_to", "team", "priority", "decision_ref"}:
|
|
raise ValueError(f"not a settable field: {field!r}")
|
|
row = get(conn, tid)
|
|
if row is None:
|
|
return 0
|
|
old = row[field]
|
|
n = conn.execute(
|
|
f"UPDATE tickets SET {field} = ?, updated_at = datetime('now') WHERE id = ?",
|
|
(value, tid),
|
|
).rowcount
|
|
if n:
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
""",
|
|
(tid, field, old, value, changed_by),
|
|
)
|
|
conn.commit()
|
|
return n
|
|
|
|
|
|
def block(conn: sqlite3.Connection, blocked: str, blocker: str) -> None:
|
|
conn.execute(
|
|
"INSERT OR IGNORE INTO ticket_deps (blocker_id, blocked_id) VALUES (?, ?)",
|
|
(blocker, blocked),
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
def unblock(conn: sqlite3.Connection, blocked: str, blocker: str) -> int:
|
|
n = conn.execute(
|
|
"DELETE FROM ticket_deps WHERE blocked_id = ? AND blocker_id = ?",
|
|
(blocked, blocker),
|
|
).rowcount
|
|
conn.commit()
|
|
return n
|
|
|
|
|
|
def label_add(conn: sqlite3.Connection, tid: str, label: str) -> None:
|
|
conn.execute(
|
|
"INSERT OR IGNORE INTO ticket_labels (ticket_id, label) VALUES (?, ?)",
|
|
(tid, label),
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
def label_rm(conn: sqlite3.Connection, tid: str, label: str) -> int:
|
|
n = conn.execute(
|
|
"DELETE FROM ticket_labels WHERE ticket_id = ? AND label = ?",
|
|
(tid, label),
|
|
).rowcount
|
|
conn.commit()
|
|
return n
|
|
|
|
|
|
def search(conn: sqlite3.Connection, query: str) -> list[sqlite3.Row]:
|
|
like = f"%{query}%"
|
|
return list(
|
|
conn.execute(
|
|
"""
|
|
SELECT id, type, title, status, priority, assigned_to, team
|
|
FROM tickets
|
|
WHERE title LIKE ? OR COALESCE(description, '') LIKE ?
|
|
ORDER BY id
|
|
""",
|
|
(like, like),
|
|
)
|
|
)
|
|
|
|
|
|
def board(conn: sqlite3.Connection, team: str | None = None) -> dict[str, list[sqlite3.Row]]:
|
|
columns: dict[str, list[sqlite3.Row]] = {s: [] for s in TICKET_STATUSES}
|
|
conds = ["1=1"]
|
|
params: list[str] = []
|
|
if team:
|
|
conds.append("team = ?")
|
|
params.append(team)
|
|
where = " AND ".join(conds)
|
|
for row in conn.execute(
|
|
f"SELECT id, title, status, priority, assigned_to, team FROM tickets "
|
|
f"WHERE {where} ORDER BY id",
|
|
params,
|
|
):
|
|
columns[row["status"]].append(row)
|
|
return columns
|