remove Python stopgap; rewire to pql 1.0
test / unit + widget + golden + a11y (push) Failing after 38s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / unit + widget + golden + a11y (push) Failing after 38s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
pql 1.0 ships full feature parity for decisions and tickets. Sunset condition from D-040 met; deletion per R-011. Removed: tools/scripts/plan entrypoint, tools/scripts/planning/ Python modules, tools/scripts/README.md, .gitignore pycache entries. Rewired: make decisions-validate now calls pql decisions validate. CLAUDE.md decision discipline guardrail points at pql. Extension stub comments updated. decisions/README.md examples use pql. D-040 amended as superseded. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+1
-7
@@ -52,15 +52,9 @@ coverage.*
|
||||
# app/test/goldens/goldens/{ci,linux}/).
|
||||
app/test/goldens/failures/
|
||||
|
||||
# -- pql per-repo state (clide dogfoods against itself; pql's index lands
|
||||
# here when running queries locally). `pql.db` is also where the
|
||||
# `tools/scripts/plan` stopgap writes decisions + tickets (D-040).
|
||||
# -- pql per-repo state (index + planning DB, rebuilt from markdown) ----
|
||||
/.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
|
||||
|
||||
@@ -18,6 +18,13 @@ heading, and (b) bumping `project.yaml` `version:` in the same commit.
|
||||
|
||||
### Added
|
||||
|
||||
- pql skill installed via `pql init --with-skill=yes`
|
||||
(`.claude/skills/pql/SKILL.md`). Covers vault queries and the
|
||||
planning surface (decisions + tickets).
|
||||
|
||||
- `Bash(pql)` and `Bash(pql *)` permissions in
|
||||
`.claude/settings.json`.
|
||||
|
||||
- Git subsystem in the daemon (`lib/src/git/`). Status parser
|
||||
(`git status --porcelain`), unified-diff parser, and operations
|
||||
(stage, unstage, stage-hunk, discard, commit, stash, log, pull,
|
||||
@@ -270,6 +277,21 @@ heading, and (b) bumping `project.yaml` `version:` in the same commit.
|
||||
updated to reference `decisions/architecture.md` instead of the
|
||||
deleted `docs/ADRs/`.
|
||||
|
||||
- `make decisions-validate` rewired from `tools/scripts/plan` to
|
||||
`pql decisions validate`.
|
||||
|
||||
- Decision discipline guardrail in CLAUDE.md now points at
|
||||
`pql decisions claim` instead of the Python stopgap.
|
||||
|
||||
### Removed
|
||||
|
||||
- `tools/scripts/plan` — Python stopgap planning scripts, superseded
|
||||
by `pql` 1.0 native `decisions` and `ticket` subcommands. Sunset
|
||||
condition from
|
||||
[`D-040`](decisions/process.md#d-040-python-stopgap-under-toolsscriptsplan)
|
||||
met; deletion per
|
||||
[`R-011`](decisions/rejected.md#r-011-permanent-stopgap).
|
||||
|
||||
### Removed
|
||||
|
||||
- `docs/ADRs/` directory — content lifted into `decisions/` as D/R
|
||||
|
||||
@@ -28,7 +28,7 @@ These are load-bearing. Violating any means the design is wrong, not the rule.
|
||||
- **pql: wrap, don't duplicate, and treat it as a clide subsystem when present.** Pql logic only lives in `lib/src/pql/` (pure shell-outs). Clide owns pql's `ignore_files:` config key; it never touches pql's `.pql/` index/cache data. See [`D-003`](decisions/architecture.md#d-003-pql-as-supporter-tool-clide-wraps-never-duplicates).
|
||||
- **Repo-is-the-workspace.** The git repo root is the workspace — no parallel "vault" concept. Clide dogfoods against its own repo.
|
||||
- **Ignore discipline.** Single knob: `ignore_files:` in `.pql/config.yaml`, ordered layering. Default `[.gitignore]`; clide writes `[.gitignore, .clideignore]` when `.clideignore` exists. See [`D-004`](decisions/architecture.md#d-004-ignore-file-strategy).
|
||||
- **Decision discipline.** All architectural choices live in `decisions/<domain>.md` as `D-NNN` records. Open questions live in `decisions/questions-<domain>.md` as `Q-NNN`. Rejected alternatives live in `decisions/rejected.md` as `R-NNN`. Before an architectural change, read the relevant domain file; before disagreeing with a guardrail, propose an amendment to the underlying `D-NNN` rather than a one-off. Claim new IDs via `tools/scripts/plan decisions claim D <domain> "title"` (until pql ships planning subcommands — see [`Q-021`](decisions/questions-architecture.md#q-021-pql-absorbs-planning-vs-keeps-separate)). See [`decisions/README.md`](decisions/README.md).
|
||||
- **Decision discipline.** All architectural choices live in `decisions/<domain>.md` as `D-NNN` records. Open questions live in `decisions/questions-<domain>.md` as `Q-NNN`. Rejected alternatives live in `decisions/rejected.md` as `R-NNN`. Before an architectural change, read the relevant domain file; before disagreeing with a guardrail, propose an amendment to the underlying `D-NNN` rather than a one-off. Claim new IDs via `pql decisions claim D <domain> "title"`. See [`decisions/README.md`](decisions/README.md).
|
||||
|
||||
## Tier ordering (don't skip ahead)
|
||||
|
||||
|
||||
@@ -174,7 +174,7 @@ security: ## Dart advisory review + ptyc source review (manual — no floating d
|
||||
|
||||
.PHONY: decisions-validate
|
||||
decisions-validate: ## Parser dry-run over decisions/*.md (cheap pre-push gate).
|
||||
tools/scripts/plan decisions validate
|
||||
pql decisions validate
|
||||
|
||||
.PHONY: push-check
|
||||
push-check: decisions-validate test-core test test-a11y ## Pre-push gate: decisions + core + fast unit + widget + golden + a11y (<90s).
|
||||
|
||||
@@ -2,9 +2,7 @@ import 'package:clide_app/extension/extension.dart';
|
||||
|
||||
/// Tier-reserved stub. Will surface a sidebar tab (filter by domain /
|
||||
/// status, backlinks from current file) + commands (`decisions.open`,
|
||||
/// `decisions.claim`, `decisions.amend`). Data source: `pql decisions
|
||||
/// …` once `Q-021` resolves; `tools/scripts/plan decisions …` until
|
||||
/// then.
|
||||
/// `decisions.claim`, `decisions.amend`). Data source: `pql decisions …`.
|
||||
class DecisionsExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.decisions';
|
||||
|
||||
@@ -2,9 +2,8 @@ import 'package:clide_app/extension/extension.dart';
|
||||
|
||||
/// Tier-reserved stub. Will surface a sidebar tab (filtered list) + a
|
||||
/// workspace tab (kanban board) + commands (`tickets.open`,
|
||||
/// `tickets.new`, `tickets.move`, `tickets.block`). Data source: `pql
|
||||
/// ticket …` once `Q-021` resolves; `tools/scripts/plan ticket …` until
|
||||
/// then. Ticket persistence strategy open at `Q-022`.
|
||||
/// `tickets.new`, `tickets.move`, `tickets.block`). Data source:
|
||||
/// `pql ticket …`. Ticket persistence strategy open at `Q-022`.
|
||||
class TicketsExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.tickets';
|
||||
|
||||
+9
-12
@@ -82,26 +82,23 @@ same semantics, no race on concurrent sessions.
|
||||
|
||||
## Querying
|
||||
|
||||
Stopgap today: `tools/scripts/plan decisions …` reads and writes
|
||||
`.pql/pql.db` (gitignored; markdown is source of truth). Replaced
|
||||
by `pql decisions …` when pql ships parity — see
|
||||
[D-040](process.md#d-040-python-stopgap-under-toolsscriptsplan) and
|
||||
[R-011](rejected.md#r-011-permanent-stopgap).
|
||||
`pql decisions …` reads `decisions/*.md` and writes `.pql/pql.db`
|
||||
(gitignored; markdown is the source of truth).
|
||||
|
||||
Common queries:
|
||||
|
||||
```bash
|
||||
tools/scripts/plan decisions list --type confirmed --domain architecture
|
||||
tools/scripts/plan decisions show D-005 --with-refs
|
||||
tools/scripts/plan decisions coverage # D-records without tickets
|
||||
tools/scripts/plan decisions validate # pre-push parser gate
|
||||
pql decisions list --type confirmed --domain architecture
|
||||
pql decisions show D-005 --with-refs
|
||||
pql decisions coverage # D-records without tickets
|
||||
pql decisions validate # pre-push parser gate
|
||||
pql ticket board # kanban view of tickets
|
||||
```
|
||||
|
||||
## Adding a decision
|
||||
|
||||
1. Edit the appropriate domain file.
|
||||
2. Follow the record shape above.
|
||||
3. Run `tools/scripts/plan decisions validate` (also runs in
|
||||
`make push-check`).
|
||||
3. Run `pql decisions validate` (also runs in `make push-check`).
|
||||
4. Commit. The SQLite index rebuilds from markdown on any
|
||||
`tools/scripts/plan decisions sync`.
|
||||
`pql decisions sync`.
|
||||
|
||||
@@ -46,12 +46,13 @@ Q&D record system itself, kanban, commit conventions, changelog.
|
||||
- **Cost:** Planning features don't ship until pql catches up. Mitigated by [D-040](#d-040-python-stopgap-under-toolsscriptsplan). Gated by [Q-021](questions-process.md#q-021-pql-absorbs-planning-vs-keeps-separate).
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
|
||||
### D-040: Python stopgap under `tools/scripts/plan`
|
||||
### D-040: [SUPERSEDED] Python stopgap under `tools/scripts/plan`
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** A time-limited Python port of settled-reach's `decisions_sync.py` + `ticket` + `decision` scripts lives at `tools/scripts/plan` with support modules under `tools/scripts/planning/`. Writes to `.pql/pql.db` (gitignored). Ticket IDs are `T-NNN` (TEXT PK, reshape from settled-reach's integers). Same schema, same markdown, same verb shape as the eventual `pql` subcommands.
|
||||
- **Sunset:** Delete the stopgap when pql ships `pql decisions sync | validate | list | show | claim | coverage` + `pql ticket new | list | show | status | assign | block | board` with feature parity, and reads the same `.pql/pql.db` file the stopgap wrote. Removal commit shape: [R-011](rejected.md#r-011-permanent-stopgap).
|
||||
- **Rationale:** Planning tooling must work day one. Pql's Go implementation won't land for at least a cycle or two. Without a stopgap, the convention lives on paper; with one, tickets + decisions are queryable from today. Same schema means migration is call-site find-replace (`tools/scripts/plan ` → `pql `), no data migration.
|
||||
- **Cost:** Python dep on contributors' machines (already present on most Linux dists). One time-limited tool to maintain. See [R-010](rejected.md#r-010-python-script-stopgap-at-toolingdb) for why `tools/scripts/plan` and not `tooling/db/`.
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
- **Amendment (2026-04-22):** Sunset condition met. pql 1.0.0 ships full feature parity. Stopgap deleted per [R-011](rejected.md#r-011-permanent-stopgap).
|
||||
|
||||
---
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,62 +0,0 @@
|
||||
#!/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())
|
||||
@@ -1,5 +0,0 @@
|
||||
"""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.
|
||||
"""
|
||||
@@ -1,104 +0,0 @@
|
||||
"""`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
|
||||
@@ -1,178 +0,0 @@
|
||||
"""`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
|
||||
@@ -1,34 +0,0 @@
|
||||
"""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
|
||||
@@ -1,77 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,202 +0,0 @@
|
||||
"""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}"
|
||||
@@ -1,129 +0,0 @@
|
||||
"""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
|
||||
"""
|
||||
)
|
||||
)
|
||||
@@ -1,249 +0,0 @@
|
||||
"""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
|
||||
@@ -1,93 +0,0 @@
|
||||
"""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