add Python stopgap planning tooling under tools/scripts/plan
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>
This commit is contained in:
+6
-1
@@ -48,9 +48,14 @@ coverage.*
|
||||
/tmp/
|
||||
|
||||
# -- pql per-repo state (clide dogfoods against itself; pql's index lands
|
||||
# here when running queries locally). Mirror what `pql init` writes.
|
||||
# here when running queries locally). `pql.db` is also where the
|
||||
# `tools/scripts/plan` stopgap writes decisions + tickets (D-040).
|
||||
/.pql/
|
||||
|
||||
# -- Python stopgap tooling under tools/scripts/plan ------------------
|
||||
tools/scripts/__pycache__/
|
||||
tools/scripts/planning/__pycache__/
|
||||
|
||||
# -- SQLite index files (defensive; should never land at repo root) ----
|
||||
*.sqlite
|
||||
*.sqlite-journal
|
||||
|
||||
@@ -37,6 +37,19 @@ heading, and (b) bumping `project.yaml` `version:` in the same commit.
|
||||
- `DECISIONS.md` one-line pointer at the repo root (matches
|
||||
settled-reach's convention).
|
||||
|
||||
- `tools/scripts/plan` — Python stopgap entrypoint for `decisions`
|
||||
and `ticket` subcommands, writing to `.pql/pql.db` (gitignored).
|
||||
Supports `decisions sync | validate | claim | list | show | coverage`
|
||||
and `ticket new | list | show | status | assign | team | block |
|
||||
unblock | label | search | board`, plus `sqlite-query`. Verb shape
|
||||
and output format mirror the eventual `pql` subcommands so migration
|
||||
when pql ships feature parity is a call-site find-replace
|
||||
(`tools/scripts/plan ` → `pql `). Ported from settled-reach with
|
||||
the Scrum layer stripped; ticket IDs are `T-NNN` (TEXT PKs) and
|
||||
there's no `sprints` table. Time-limited per
|
||||
[`D-040`](decisions/process.md#d-040-python-stopgap-under-toolsscriptsplan)
|
||||
/ [`R-011`](decisions/rejected.md#r-011-permanent-stopgap).
|
||||
|
||||
### Removed
|
||||
|
||||
- `docs/ADRs/` directory — content lifted into `decisions/` as D/R
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# STOPGAP — Planning tooling
|
||||
|
||||
This directory is a **time-limited stopgap**. The Python scripts here
|
||||
port settled-reach's `decisions_sync.py` + `ticket` + `decision`
|
||||
scripts, Scrum-stripped, and write to `.pql/pql.db` (gitignored).
|
||||
|
||||
## Sunset clause
|
||||
|
||||
Per [`D-040`](../../decisions/process.md#d-040-python-stopgap-under-toolsscriptsplan)
|
||||
and [`R-011`](../../decisions/rejected.md#r-011-permanent-stopgap),
|
||||
this stopgap deletes when pql ships:
|
||||
|
||||
- `pql decisions sync | validate | list | show | claim | coverage`
|
||||
- `pql ticket new | list | show | status | assign | block | board`
|
||||
|
||||
with feature parity on the same `.pql/pql.db` file this stopgap wrote.
|
||||
|
||||
Migration is a call-site find-replace:
|
||||
|
||||
```
|
||||
tools/scripts/plan decisions sync → pql decisions sync
|
||||
tools/scripts/plan ticket new task "…" → pql ticket new task "…"
|
||||
tools/scripts/plan ticket board → pql ticket board
|
||||
```
|
||||
|
||||
See [`Q-021`](../../decisions/questions-architecture.md#q-021-pql-absorbs-planning-vs-keeps-separate)
|
||||
for the open gate on whether pql absorbs planning long-term.
|
||||
|
||||
## Entrypoint
|
||||
|
||||
`plan` is a Python executable. Support modules live under `planning/`.
|
||||
|
||||
```
|
||||
plan decisions sync | validate | claim D|Q|R <domain> [title] | list | show <id> | coverage
|
||||
plan ticket new <type> "title" | list | show <id> | status <id> <new> | assign <id> <agent>
|
||||
plan ticket block <id> --by <other> | unblock <id> --from <other>
|
||||
plan ticket team <id> <team> | label <id> add|rm <label> | board | search "query"
|
||||
plan sqlite-query "SELECT …"
|
||||
```
|
||||
|
||||
Needs Python 3.9+ (f-strings + `pathlib`). No third-party deps.
|
||||
|
||||
## Where things live
|
||||
|
||||
- DB: `.pql/pql.db` (gitignored, per-dev)
|
||||
- Records (source of truth): `decisions/*.md`
|
||||
- Tickets: today SQLite-only; see
|
||||
[`Q-022`](../../decisions/questions-architecture.md#q-022-ticket-persistence-strategy)
|
||||
for the markdown-mirror question.
|
||||
Executable
+62
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
"""plan — clide planning stopgap.
|
||||
|
||||
Writes to .pql/pql.db. Replaced by `pql` subcommands when pql ships
|
||||
feature parity. See tools/scripts/README.md for the sunset clause.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Make "from planning import ..." work when invoked as a script.
|
||||
HERE = Path(__file__).resolve().parent
|
||||
if str(HERE) not in sys.path:
|
||||
sys.path.insert(0, str(HERE))
|
||||
|
||||
from planning import cmd_decisions, cmd_ticket, db # noqa: E402
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
ap = argparse.ArgumentParser(
|
||||
prog="plan",
|
||||
description="clide planning stopgap — decisions + tickets",
|
||||
)
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
cmd_decisions.add_subparsers(sub)
|
||||
cmd_ticket.add_subparsers(sub)
|
||||
|
||||
sq = sub.add_parser("sqlite-query", help="run a raw SQL query")
|
||||
sq.add_argument("sql")
|
||||
|
||||
return ap
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
ap = _build_parser()
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
if args.cmd == "decisions":
|
||||
return cmd_decisions.dispatch(args)
|
||||
if args.cmd == "ticket":
|
||||
return cmd_ticket.dispatch(args)
|
||||
if args.cmd == "sqlite-query":
|
||||
conn = db.connect()
|
||||
try:
|
||||
cur = conn.execute(args.sql)
|
||||
if cur.description:
|
||||
cols = [d[0] for d in cur.description]
|
||||
print("\t".join(cols))
|
||||
for row in cur.fetchall():
|
||||
print("\t".join(str(v) for v in row))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
return 0
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Planning stopgap — decisions + tickets written to .pql/pql.db.
|
||||
|
||||
Time-limited per D-040; replaced by pql subcommands when feature parity
|
||||
lands. See ../README.md for the sunset clause.
|
||||
"""
|
||||
@@ -0,0 +1,104 @@
|
||||
"""`plan decisions …` subcommands."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from . import db, parser, repo_decisions
|
||||
from .format import emit, rows_to_dicts
|
||||
|
||||
|
||||
def add_subparsers(parent: argparse._SubParsersAction) -> None:
|
||||
p = parent.add_parser("decisions", help="decisions subcommands")
|
||||
sub = p.add_subparsers(dest="subcmd", required=True)
|
||||
|
||||
sub.add_parser("sync", help="parse decisions/*.md → upsert into sqlite")
|
||||
sub.add_parser("validate", help="dry-run parser; exit non-zero on errors")
|
||||
|
||||
claim = sub.add_parser("claim", help="print next available D/Q/R id")
|
||||
claim.add_argument("prefix", choices=["D", "Q", "R"])
|
||||
claim.add_argument("domain", nargs="?")
|
||||
claim.add_argument("title", nargs="*")
|
||||
|
||||
ls = sub.add_parser("list", help="list decisions")
|
||||
ls.add_argument("--type", choices=["confirmed", "question", "rejected"])
|
||||
ls.add_argument("--domain")
|
||||
ls.add_argument("--status")
|
||||
ls.add_argument("--output", choices=["table", "json"])
|
||||
|
||||
show = sub.add_parser("show", help="show a decision")
|
||||
show.add_argument("id")
|
||||
show.add_argument("--with-refs", action="store_true")
|
||||
show.add_argument("--with-tickets", action="store_true")
|
||||
show.add_argument("--output", choices=["table", "json"])
|
||||
|
||||
cov = sub.add_parser("coverage", help="D-records without implementing tickets")
|
||||
cov.add_argument("--output", choices=["table", "json"])
|
||||
|
||||
|
||||
def dispatch(args: argparse.Namespace) -> int:
|
||||
root = db.repo_root()
|
||||
|
||||
if args.subcmd == "sync":
|
||||
conn = db.connect(root)
|
||||
try:
|
||||
result = repo_decisions.sync(conn, root)
|
||||
finally:
|
||||
conn.close()
|
||||
emit(result, mode="json" if not sys.stdout.isatty() else "table")
|
||||
return 0
|
||||
|
||||
if args.subcmd == "validate":
|
||||
ok, errors = parser.validate(root)
|
||||
if ok:
|
||||
print("decisions validate: ok")
|
||||
return 0
|
||||
for err in errors:
|
||||
print(f"error: {err}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if args.subcmd == "claim":
|
||||
new_id = parser.next_id(root, args.prefix)
|
||||
if args.domain is None:
|
||||
print(new_id)
|
||||
return 0
|
||||
title = " ".join(args.title) if args.title else "(unclaimed)"
|
||||
print(f"{new_id} {args.domain} {title}")
|
||||
print(
|
||||
"(claim is advisory in the stopgap — write the record to "
|
||||
f"decisions/{args.domain}.md manually, then `plan decisions sync`)"
|
||||
)
|
||||
return 0
|
||||
|
||||
conn = db.connect(root)
|
||||
try:
|
||||
if args.subcmd == "list":
|
||||
rows = repo_decisions.list_decisions(
|
||||
conn, type_=args.type, domain=args.domain, status=args.status
|
||||
)
|
||||
emit(rows_to_dicts(rows), mode=args.output)
|
||||
return 0
|
||||
|
||||
if args.subcmd == "show":
|
||||
row = repo_decisions.get(conn, args.id)
|
||||
if row is None:
|
||||
print(f"error: {args.id} not found", file=sys.stderr)
|
||||
return 3
|
||||
data: dict = dict(row)
|
||||
if args.with_refs:
|
||||
data["refs"] = rows_to_dicts(repo_decisions.refs_of(conn, args.id))
|
||||
if args.with_tickets:
|
||||
data["tickets"] = rows_to_dicts(
|
||||
repo_decisions.tickets_for(conn, args.id)
|
||||
)
|
||||
emit(data, mode=args.output)
|
||||
return 0
|
||||
|
||||
if args.subcmd == "coverage":
|
||||
rows = repo_decisions.coverage(conn)
|
||||
emit(rows_to_dicts(rows), mode=args.output)
|
||||
return 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return 1
|
||||
@@ -0,0 +1,178 @@
|
||||
"""`plan ticket …` subcommands."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from . import db, repo_tickets
|
||||
from .format import emit, emit_board, rows_to_dicts
|
||||
|
||||
|
||||
def add_subparsers(parent: argparse._SubParsersAction) -> None:
|
||||
p = parent.add_parser("ticket", help="ticket subcommands")
|
||||
sub = p.add_subparsers(dest="subcmd", required=True)
|
||||
|
||||
new = sub.add_parser("new", help="create a ticket")
|
||||
new.add_argument("type", choices=["initiative", "epic", "story", "task", "bug"])
|
||||
new.add_argument("title")
|
||||
new.add_argument("--parent")
|
||||
new.add_argument("--priority", choices=["critical", "high", "medium", "low"], default="medium")
|
||||
new.add_argument("--decision", dest="decision_ref")
|
||||
new.add_argument("--team")
|
||||
new.add_argument("--description")
|
||||
|
||||
ls = sub.add_parser("list", help="list tickets")
|
||||
ls.add_argument("--status")
|
||||
ls.add_argument("--team")
|
||||
ls.add_argument("--assigned")
|
||||
ls.add_argument("--decision")
|
||||
ls.add_argument("--label")
|
||||
ls.add_argument("--output", choices=["table", "json"])
|
||||
|
||||
show = sub.add_parser("show", help="show a ticket")
|
||||
show.add_argument("id")
|
||||
show.add_argument("--with-decision", action="store_true")
|
||||
show.add_argument("--with-blockers", action="store_true")
|
||||
show.add_argument("--with-children", action="store_true")
|
||||
show.add_argument("--output", choices=["table", "json"])
|
||||
|
||||
st = sub.add_parser("status", help="change status")
|
||||
st.add_argument("id")
|
||||
st.add_argument("new_status", choices=[
|
||||
"backlog", "ready", "in_progress", "review", "done", "cancelled",
|
||||
])
|
||||
|
||||
asg = sub.add_parser("assign", help="assign to agent")
|
||||
asg.add_argument("id")
|
||||
asg.add_argument("agent")
|
||||
|
||||
tm = sub.add_parser("team", help="set team")
|
||||
tm.add_argument("id")
|
||||
tm.add_argument("team")
|
||||
|
||||
blk = sub.add_parser("block", help="mark <id> as blocked by <other>")
|
||||
blk.add_argument("id")
|
||||
blk.add_argument("--by", required=True, dest="by")
|
||||
|
||||
ublk = sub.add_parser("unblock", help="remove a blocker")
|
||||
ublk.add_argument("id")
|
||||
ublk.add_argument("--from", required=True, dest="from_")
|
||||
|
||||
lbl = sub.add_parser("label", help="add/rm labels")
|
||||
lbl.add_argument("id")
|
||||
lbl.add_argument("op", choices=["add", "rm"])
|
||||
lbl.add_argument("label")
|
||||
|
||||
srch = sub.add_parser("search", help="search title + description")
|
||||
srch.add_argument("query")
|
||||
srch.add_argument("--output", choices=["table", "json"])
|
||||
|
||||
brd = sub.add_parser("board", help="kanban columns")
|
||||
brd.add_argument("--team")
|
||||
brd.add_argument("--output", choices=["table", "json"])
|
||||
|
||||
|
||||
def dispatch(args: argparse.Namespace) -> int:
|
||||
conn = db.connect()
|
||||
try:
|
||||
if args.subcmd == "new":
|
||||
tid = repo_tickets.create(
|
||||
conn,
|
||||
type_=args.type,
|
||||
title=args.title,
|
||||
description=args.description,
|
||||
parent_id=args.parent,
|
||||
priority=args.priority,
|
||||
decision_ref=args.decision_ref,
|
||||
team=args.team,
|
||||
)
|
||||
print(tid)
|
||||
return 0
|
||||
|
||||
if args.subcmd == "list":
|
||||
rows = repo_tickets.list_(
|
||||
conn,
|
||||
status=args.status,
|
||||
team=args.team,
|
||||
assigned=args.assigned,
|
||||
decision=args.decision,
|
||||
label=args.label,
|
||||
)
|
||||
emit(rows_to_dicts(rows), mode=args.output)
|
||||
return 0
|
||||
|
||||
if args.subcmd == "show":
|
||||
row = repo_tickets.get(conn, args.id)
|
||||
if row is None:
|
||||
print(f"error: {args.id} not found", file=sys.stderr)
|
||||
return 3
|
||||
data: dict = dict(row)
|
||||
if args.with_decision and row["decision_ref"]:
|
||||
dec = conn.execute(
|
||||
"SELECT * FROM decisions WHERE id = ?", (row["decision_ref"],)
|
||||
).fetchone()
|
||||
data["decision"] = dict(dec) if dec else None
|
||||
if args.with_blockers:
|
||||
data["blocked_by"] = rows_to_dicts(repo_tickets.blockers(conn, args.id))
|
||||
if args.with_children:
|
||||
data["children"] = rows_to_dicts(repo_tickets.children(conn, args.id))
|
||||
emit(data, mode=args.output)
|
||||
return 0
|
||||
|
||||
if args.subcmd == "status":
|
||||
n = repo_tickets.set_status(conn, args.id, args.new_status)
|
||||
if n == 0:
|
||||
print(f"error: {args.id} not found", file=sys.stderr)
|
||||
return 3
|
||||
print(f"{args.id} → {args.new_status}")
|
||||
return 0
|
||||
|
||||
if args.subcmd == "assign":
|
||||
n = repo_tickets.set_field(conn, args.id, "assigned_to", args.agent)
|
||||
if n == 0:
|
||||
print(f"error: {args.id} not found", file=sys.stderr)
|
||||
return 3
|
||||
print(f"{args.id} assigned to {args.agent}")
|
||||
return 0
|
||||
|
||||
if args.subcmd == "team":
|
||||
n = repo_tickets.set_field(conn, args.id, "team", args.team)
|
||||
if n == 0:
|
||||
print(f"error: {args.id} not found", file=sys.stderr)
|
||||
return 3
|
||||
print(f"{args.id} team = {args.team}")
|
||||
return 0
|
||||
|
||||
if args.subcmd == "block":
|
||||
repo_tickets.block(conn, args.id, args.by)
|
||||
print(f"{args.id} blocked by {args.by}")
|
||||
return 0
|
||||
|
||||
if args.subcmd == "unblock":
|
||||
n = repo_tickets.unblock(conn, args.id, args.from_)
|
||||
if n == 0:
|
||||
print(f"(no such blocker)")
|
||||
else:
|
||||
print(f"{args.id} unblocked from {args.from_}")
|
||||
return 0
|
||||
|
||||
if args.subcmd == "label":
|
||||
if args.op == "add":
|
||||
repo_tickets.label_add(conn, args.id, args.label)
|
||||
else:
|
||||
repo_tickets.label_rm(conn, args.id, args.label)
|
||||
print(f"{args.id} labels {args.op} {args.label}")
|
||||
return 0
|
||||
|
||||
if args.subcmd == "search":
|
||||
rows = repo_tickets.search(conn, args.query)
|
||||
emit(rows_to_dicts(rows), mode=args.output)
|
||||
return 0
|
||||
|
||||
if args.subcmd == "board":
|
||||
columns = repo_tickets.board(conn, team=args.team)
|
||||
emit_board(columns, mode=args.output)
|
||||
return 0
|
||||
finally:
|
||||
conn.close()
|
||||
return 1
|
||||
@@ -0,0 +1,34 @@
|
||||
"""SQLite wrapper — opens `.pql/pql.db` at the repo root, applies schema.
|
||||
|
||||
Discovery: walk up from CWD looking for a .git/ sibling; the repo root
|
||||
hosts .pql/pql.db. If .pql/ doesn't exist, it's created on first open.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from .schema import SCHEMA_SQL
|
||||
|
||||
|
||||
def repo_root(start: Path | None = None) -> Path:
|
||||
path = (start or Path.cwd()).resolve()
|
||||
for parent in (path, *path.parents):
|
||||
if (parent / ".git").exists():
|
||||
return parent
|
||||
raise RuntimeError(f"not inside a git repository (starting from {path})")
|
||||
|
||||
|
||||
def db_path(root: Path | None = None) -> Path:
|
||||
root = root or repo_root()
|
||||
return root / ".pql" / "pql.db"
|
||||
|
||||
|
||||
def connect(root: Path | None = None) -> sqlite3.Connection:
|
||||
path = db_path(root)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
conn.executescript(SCHEMA_SQL)
|
||||
return conn
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Output formatting — table (default for TTY), json (for scripting)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
from typing import Iterable, Mapping, Sequence
|
||||
|
||||
|
||||
def _is_tty() -> bool:
|
||||
return sys.stdout.isatty()
|
||||
|
||||
|
||||
def rows_to_dicts(rows: Iterable[sqlite3.Row]) -> list[dict]:
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def emit(data, *, mode: str | None = None) -> None:
|
||||
"""Emit `data` as either JSON or a human table.
|
||||
|
||||
`mode` is "json" | "table" | None (auto: table on TTY, json otherwise).
|
||||
"""
|
||||
if mode is None:
|
||||
mode = "table" if _is_tty() else "json"
|
||||
if mode == "json":
|
||||
print(json.dumps(data, indent=2, default=str))
|
||||
return
|
||||
# table
|
||||
if isinstance(data, dict) and "rows" in data:
|
||||
_print_table(data["rows"])
|
||||
return
|
||||
if isinstance(data, list):
|
||||
_print_table(data)
|
||||
return
|
||||
if isinstance(data, dict):
|
||||
for k, v in data.items():
|
||||
print(f"{k}: {v}")
|
||||
return
|
||||
print(data)
|
||||
|
||||
|
||||
def _print_table(rows: Sequence[Mapping]) -> None:
|
||||
if not rows:
|
||||
print("(no rows)")
|
||||
return
|
||||
cols = list(rows[0].keys())
|
||||
widths = {c: len(c) for c in cols}
|
||||
for r in rows:
|
||||
for c in cols:
|
||||
widths[c] = max(widths[c], len(str(r.get(c, ""))))
|
||||
header = " ".join(c.ljust(widths[c]) for c in cols)
|
||||
print(header)
|
||||
print(" ".join("-" * widths[c] for c in cols))
|
||||
for r in rows:
|
||||
print(" ".join(str(r.get(c, "")).ljust(widths[c]) for c in cols))
|
||||
|
||||
|
||||
def emit_board(columns: dict[str, list[sqlite3.Row]], *, mode: str | None = None) -> None:
|
||||
if mode is None:
|
||||
mode = "table" if _is_tty() else "json"
|
||||
if mode == "json":
|
||||
print(json.dumps(
|
||||
{status: rows_to_dicts(rows) for status, rows in columns.items()},
|
||||
indent=2,
|
||||
default=str,
|
||||
))
|
||||
return
|
||||
for status, rows in columns.items():
|
||||
print(f"== {status.upper()} ({len(rows)}) ==")
|
||||
if not rows:
|
||||
print(" (empty)")
|
||||
continue
|
||||
for row in rows:
|
||||
prio = row["priority"] or "medium"
|
||||
who = row["assigned_to"] or "-"
|
||||
print(f" {row['id']} [{prio:8}] @{who:10} {row['title']}")
|
||||
print()
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Markdown parser for decisions/*.md.
|
||||
|
||||
Yields `Record` dicts with id, type, domain, title, status, date,
|
||||
file_path, and a list of (target_id, ref_type, note) cross-refs.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
HEADING_RE = re.compile(r"^###\s+((?:D|Q|R)-\d+):\s+(.+)$")
|
||||
DATE_RE = re.compile(r"^\s*-\s+\*\*(?:Date|Rejected):\*\*\s+(\d{4}-\d{2}-\d{2})")
|
||||
STATUS_RE = re.compile(r"^\s*-\s+\*\*Status:\*\*\s+(.+)")
|
||||
SUPERSEDES_RE = re.compile(r"^\s*-\s+\*\*Supersedes:\*\*", re.IGNORECASE)
|
||||
SUPERSEDED_BY_RE = re.compile(r"^\s*-\s+\*\*Superseded\s+by:\*\*", re.IGNORECASE)
|
||||
RESOLVES_RE = re.compile(r"^\s*-\s+\*\*Resolves:\*\*", re.IGNORECASE)
|
||||
DEPENDS_RE = re.compile(r"^\s*-\s+\*\*Depends\s+on:\*\*", re.IGNORECASE)
|
||||
AMENDS_RE = re.compile(r"^\s*\*\*Amendment\s*\(", re.IGNORECASE)
|
||||
CROSS_REF_RE = re.compile(r"^\s*-\s+\*\*Cross-reference:\*\*", re.IGNORECASE)
|
||||
REF_ID_RE = re.compile(r"(?:D|Q|R|T)-\d+")
|
||||
|
||||
TYPE_FROM_PREFIX = {"D": "confirmed", "Q": "question", "R": "rejected"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Record:
|
||||
id: str
|
||||
type: str
|
||||
domain: str
|
||||
title: str
|
||||
status: str
|
||||
date: str | None
|
||||
file_path: str
|
||||
refs: list[tuple[str, str, str]] = field(default_factory=list)
|
||||
|
||||
|
||||
def _infer_status(rec_type: str, title: str, body: list[str]) -> str:
|
||||
if rec_type == "rejected":
|
||||
return "active"
|
||||
for line in body:
|
||||
if SUPERSEDED_BY_RE.match(line):
|
||||
return "superseded"
|
||||
if rec_type == "question":
|
||||
for line in body:
|
||||
m = STATUS_RE.match(line)
|
||||
if not m:
|
||||
continue
|
||||
s = m.group(1).strip().lower()
|
||||
if "partial" in s or "remaining" in s:
|
||||
return "open"
|
||||
if s.startswith("resolved"):
|
||||
return "resolved"
|
||||
return "open"
|
||||
return "open"
|
||||
return "active"
|
||||
|
||||
|
||||
def _extract_date(body: list[str]) -> str | None:
|
||||
for line in body:
|
||||
m = DATE_RE.match(line)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def _extract_refs(rec_id: str, body: list[str]) -> list[tuple[str, str, str]]:
|
||||
refs: list[tuple[str, str, str]] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for line in body:
|
||||
if SUPERSEDES_RE.match(line):
|
||||
ref_type = "supersedes"
|
||||
elif SUPERSEDED_BY_RE.match(line):
|
||||
ref_type = "references"
|
||||
elif RESOLVES_RE.match(line):
|
||||
ref_type = "resolves"
|
||||
elif DEPENDS_RE.match(line):
|
||||
ref_type = "depends_on"
|
||||
elif AMENDS_RE.match(line):
|
||||
ref_type = "amends"
|
||||
else:
|
||||
ref_type = "references"
|
||||
for target in REF_ID_RE.findall(line):
|
||||
if target == rec_id or target.startswith("T-"):
|
||||
# T-NNN refs are ticket→decision, not decision↔decision
|
||||
continue
|
||||
key = (target, ref_type)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
note = line.strip().lstrip("- ").rstrip()
|
||||
if len(note) > 200:
|
||||
note = note[:197] + "..."
|
||||
refs.append((target, ref_type, note))
|
||||
return refs
|
||||
|
||||
|
||||
def parse_file(path: Path, repo_root: Path) -> list[Record]:
|
||||
domain = path.stem
|
||||
if domain.startswith("questions-"):
|
||||
domain = domain[len("questions-"):]
|
||||
rel_path = str(path.relative_to(repo_root))
|
||||
text = path.read_text(encoding="utf-8")
|
||||
|
||||
records: list[Record] = []
|
||||
cur_id: str | None = None
|
||||
cur_title: str | None = None
|
||||
cur_body: list[str] = []
|
||||
|
||||
def flush() -> None:
|
||||
nonlocal cur_id, cur_title, cur_body
|
||||
if cur_id is None:
|
||||
return
|
||||
rec_type = TYPE_FROM_PREFIX[cur_id[0]]
|
||||
records.append(
|
||||
Record(
|
||||
id=cur_id,
|
||||
type=rec_type,
|
||||
domain=domain,
|
||||
title=(cur_title or "").strip(),
|
||||
status=_infer_status(rec_type, cur_title or "", cur_body),
|
||||
date=_extract_date(cur_body),
|
||||
file_path=rel_path,
|
||||
refs=_extract_refs(cur_id, cur_body),
|
||||
)
|
||||
)
|
||||
cur_id = None
|
||||
cur_title = None
|
||||
cur_body = []
|
||||
|
||||
for line in text.split("\n"):
|
||||
m = HEADING_RE.match(line)
|
||||
if m:
|
||||
flush()
|
||||
cur_id = m.group(1)
|
||||
cur_title = m.group(2)
|
||||
cur_body = []
|
||||
elif cur_id is not None:
|
||||
if line.strip() == "---":
|
||||
flush()
|
||||
else:
|
||||
cur_body.append(line)
|
||||
flush()
|
||||
return records
|
||||
|
||||
|
||||
def parse_all(repo_root: Path) -> tuple[list[Record], list[str]]:
|
||||
decisions_dir = repo_root / "decisions"
|
||||
if not decisions_dir.is_dir():
|
||||
return [], [f"decisions/ not found at {decisions_dir}"]
|
||||
records: list[Record] = []
|
||||
warnings: list[str] = []
|
||||
for path in sorted(decisions_dir.glob("*.md")):
|
||||
if path.name.lower() == "readme.md":
|
||||
continue
|
||||
try:
|
||||
records.extend(parse_file(path, repo_root))
|
||||
except Exception as exc: # noqa: BLE001 - we want a useful warning
|
||||
warnings.append(f"error parsing {path.name}: {exc}")
|
||||
return records, warnings
|
||||
|
||||
|
||||
def validate(repo_root: Path) -> tuple[bool, list[str]]:
|
||||
"""Return (ok, errors). Non-zero errors fail push-check."""
|
||||
records, warnings = parse_all(repo_root)
|
||||
errors: list[str] = list(warnings)
|
||||
|
||||
ids_seen: dict[str, str] = {}
|
||||
for rec in records:
|
||||
if rec.id in ids_seen and ids_seen[rec.id] != rec.file_path:
|
||||
errors.append(
|
||||
f"duplicate id {rec.id}: {ids_seen[rec.id]} and {rec.file_path}"
|
||||
)
|
||||
ids_seen[rec.id] = rec.file_path
|
||||
if not rec.title:
|
||||
errors.append(f"{rec.id}: empty title")
|
||||
|
||||
known = set(ids_seen)
|
||||
for rec in records:
|
||||
for target, ref_type, _ in rec.refs:
|
||||
if target not in known:
|
||||
errors.append(
|
||||
f"{rec.id} → {target} ({ref_type}): target not found"
|
||||
)
|
||||
|
||||
return (len(errors) == 0), errors
|
||||
|
||||
|
||||
def next_id(repo_root: Path, prefix: str) -> str:
|
||||
prefix = prefix.upper()
|
||||
if prefix not in TYPE_FROM_PREFIX:
|
||||
raise ValueError(f"invalid prefix {prefix!r} — must be D, Q, or R")
|
||||
records, _ = parse_all(repo_root)
|
||||
highest = 0
|
||||
for rec in records:
|
||||
if rec.id.startswith(f"{prefix}-"):
|
||||
try:
|
||||
highest = max(highest, int(rec.id.split("-", 1)[1]))
|
||||
except ValueError:
|
||||
continue
|
||||
return f"{prefix}-{highest + 1:03d}"
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Decisions repository — upsert records, query joined views."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from .parser import Record, parse_all
|
||||
|
||||
|
||||
def sync(conn: sqlite3.Connection, repo_root: Path) -> dict:
|
||||
records, warnings = parse_all(repo_root)
|
||||
known_ids = {r.id for r in records}
|
||||
conn.execute("DELETE FROM decision_refs")
|
||||
upserted = 0
|
||||
for r in records:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO decisions (id, type, domain, title, status, date, file_path, synced_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
type = excluded.type,
|
||||
domain = excluded.domain,
|
||||
title = excluded.title,
|
||||
status = excluded.status,
|
||||
date = excluded.date,
|
||||
file_path = excluded.file_path,
|
||||
synced_at = datetime('now')
|
||||
""",
|
||||
(r.id, r.type, r.domain, r.title, r.status, r.date, r.file_path),
|
||||
)
|
||||
upserted += 1
|
||||
|
||||
refs_created = 0
|
||||
broken = 0
|
||||
for r in records:
|
||||
for target, ref_type, note in r.refs:
|
||||
if target not in known_ids:
|
||||
broken += 1
|
||||
warnings.append(f"broken ref: {r.id} → {target} ({ref_type})")
|
||||
continue
|
||||
try:
|
||||
conn.execute(
|
||||
"""INSERT OR IGNORE INTO decision_refs
|
||||
(source_id, target_id, ref_type, note)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(r.id, target, ref_type, note),
|
||||
)
|
||||
refs_created += 1
|
||||
except sqlite3.IntegrityError:
|
||||
pass
|
||||
conn.commit()
|
||||
return {
|
||||
"synced": upserted,
|
||||
"refs": refs_created,
|
||||
"broken": broken,
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
|
||||
def list_decisions(
|
||||
conn: sqlite3.Connection,
|
||||
type_: str | None = None,
|
||||
domain: str | None = None,
|
||||
status: str | None = None,
|
||||
) -> list[sqlite3.Row]:
|
||||
conditions: list[str] = []
|
||||
params: list[str] = []
|
||||
if type_:
|
||||
conditions.append("type = ?")
|
||||
params.append(type_)
|
||||
if domain:
|
||||
conditions.append("domain = ?")
|
||||
params.append(domain)
|
||||
if status:
|
||||
conditions.append("status = ?")
|
||||
params.append(status)
|
||||
where = " AND ".join(conditions) if conditions else "1=1"
|
||||
return list(
|
||||
conn.execute(
|
||||
f"SELECT id, type, domain, title, status, date, file_path "
|
||||
f"FROM decisions WHERE {where} ORDER BY id",
|
||||
params,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def get(conn: sqlite3.Connection, rid: str) -> sqlite3.Row | None:
|
||||
return conn.execute(
|
||||
"SELECT * FROM decisions WHERE id = ?", (rid,)
|
||||
).fetchone()
|
||||
|
||||
|
||||
def refs_of(conn: sqlite3.Connection, rid: str) -> list[sqlite3.Row]:
|
||||
return list(
|
||||
conn.execute(
|
||||
"""
|
||||
SELECT source_id, target_id, ref_type, note
|
||||
FROM decision_refs
|
||||
WHERE source_id = ? OR target_id = ?
|
||||
ORDER BY ref_type, source_id, target_id
|
||||
""",
|
||||
(rid, rid),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def tickets_for(conn: sqlite3.Connection, rid: str) -> list[sqlite3.Row]:
|
||||
return list(
|
||||
conn.execute(
|
||||
"SELECT id, type, title, status, priority FROM tickets "
|
||||
"WHERE decision_ref = ? ORDER BY id",
|
||||
(rid,),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def coverage(conn: sqlite3.Connection) -> list[sqlite3.Row]:
|
||||
"""D-records without an implementing ticket."""
|
||||
return list(
|
||||
conn.execute(
|
||||
"""
|
||||
SELECT d.id, d.domain, d.title
|
||||
FROM decisions d
|
||||
LEFT JOIN tickets t ON t.decision_ref = d.id
|
||||
WHERE d.type = 'confirmed' AND t.id IS NULL
|
||||
ORDER BY d.id
|
||||
"""
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,249 @@
|
||||
"""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
|
||||
@@ -0,0 +1,93 @@
|
||||
"""SQL schema for .pql/pql.db — adapted from settled-reach, Scrum stripped.
|
||||
|
||||
Differences from settled-reach:
|
||||
- Tickets use TEXT PK (`T-NNN`), not INTEGER AUTOINCREMENT.
|
||||
- No `sprints` table; no `sprint_id` column.
|
||||
- `ticket_deps.{blocker,blocked}_id` are TEXT (referencing `tickets.id`).
|
||||
"""
|
||||
|
||||
SCHEMA_SQL = """
|
||||
PRAGMA journal_mode=WAL;
|
||||
PRAGMA foreign_keys=ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
|
||||
domain TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK(status IN ('active','superseded','resolved','open')),
|
||||
date TEXT,
|
||||
file_path TEXT NOT NULL,
|
||||
synced_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decision_refs (
|
||||
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
ref_type TEXT NOT NULL
|
||||
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
|
||||
note TEXT,
|
||||
PRIMARY KEY (source_id, target_id, ref_type)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
|
||||
parent_id TEXT REFERENCES tickets(id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'backlog'
|
||||
CHECK(status IN ('backlog','ready','in_progress','review','done','cancelled')),
|
||||
priority TEXT DEFAULT 'medium'
|
||||
CHECK(priority IN ('critical','high','medium','low')),
|
||||
assigned_to TEXT,
|
||||
team TEXT,
|
||||
decision_ref TEXT REFERENCES decisions(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_deps (
|
||||
blocker_id TEXT NOT NULL REFERENCES tickets(id),
|
||||
blocked_id TEXT NOT NULL REFERENCES tickets(id),
|
||||
PRIMARY KEY (blocker_id, blocked_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_history (
|
||||
ticket_id TEXT NOT NULL REFERENCES tickets(id),
|
||||
field TEXT NOT NULL,
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
changed_by TEXT,
|
||||
changed_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_labels (
|
||||
ticket_id TEXT NOT NULL REFERENCES tickets(id),
|
||||
label TEXT NOT NULL,
|
||||
PRIMARY KEY (ticket_id, label)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
|
||||
"""
|
||||
|
||||
|
||||
TICKET_STATUSES = (
|
||||
"backlog",
|
||||
"ready",
|
||||
"in_progress",
|
||||
"review",
|
||||
"done",
|
||||
"cancelled",
|
||||
)
|
||||
|
||||
TICKET_TYPES = ("initiative", "epic", "story", "task", "bug")
|
||||
|
||||
PRIORITIES = ("critical", "high", "medium", "low")
|
||||
Reference in New Issue
Block a user