dctracker 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dct/__init__.py +6 -0
- dct/cli.py +1659 -0
- dct/config.py +150 -0
- dct/core/__init__.py +0 -0
- dct/core/analytics.py +382 -0
- dct/core/changelog.py +112 -0
- dct/core/checkpoints.py +205 -0
- dct/core/decisions.py +238 -0
- dct/core/engine.py +32 -0
- dct/core/handoff.py +151 -0
- dct/core/ids.py +25 -0
- dct/core/items.py +382 -0
- dct/core/plans/__init__.py +6 -0
- dct/core/plans/classify.py +94 -0
- dct/core/plans/crud.py +680 -0
- dct/core/plans/ingest.py +408 -0
- dct/core/plans/parser.py +312 -0
- dct/core/projects.py +298 -0
- dct/core/sprint.py +315 -0
- dct/core/sprints.py +601 -0
- dct/core/version.py +116 -0
- dct/db.py +558 -0
- dct/export.py +107 -0
- dct/hooks/check-changelog-on-stop.sh +49 -0
- dct/hooks/check-changelog.sh +143 -0
- dct/hooks/dct-plan-autoscan.sh +54 -0
- dct/hooks/dct-task-mirror.sh +62 -0
- dct/server.py +1812 -0
- dct/setup.py +258 -0
- dct/skills/clog/SKILL.md +73 -0
- dct/skills/commit/SKILL.md +153 -0
- dct/skills/dct-import/SKILL.md +329 -0
- dct/skills/dct-init/SKILL.md +289 -0
- dct/skills/handoff/SKILL.md +136 -0
- dct/skills/pickup/SKILL.md +115 -0
- dct/skills/plan-resolve-uncertain/SKILL.md +82 -0
- dct/skills/plan-status/SKILL.md +79 -0
- dct/skills/release/SKILL.md +281 -0
- dct/skills/track/SKILL.md +121 -0
- dct/skills/track-list/SKILL.md +134 -0
- dct/skills/track-resolve/SKILL.md +53 -0
- dct/skills/track-update/SKILL.md +109 -0
- dct/web/__init__.py +1 -0
- dct/web/app.py +83 -0
- dct/web/blueprints/__init__.py +5 -0
- dct/web/blueprints/analytics.py +34 -0
- dct/web/blueprints/changelog.py +40 -0
- dct/web/blueprints/decisions.py +23 -0
- dct/web/blueprints/events.py +69 -0
- dct/web/blueprints/handoffs.py +26 -0
- dct/web/blueprints/home.py +15 -0
- dct/web/blueprints/items.py +128 -0
- dct/web/blueprints/plans.py +53 -0
- dct/web/blueprints/projects.py +23 -0
- dct/web/blueprints/sprints.py +71 -0
- dct/web/changes.py +77 -0
- dct/web/serializers.py +109 -0
- dct/web/static/app.css +609 -0
- dct/web/static/app.js +62 -0
- dct/web/static/vendor/SOURCES.txt +10 -0
- dct/web/static/vendor/htmx.min.js +1 -0
- dct/web/static/vendor/uPlot.iife.min.js +2 -0
- dct/web/static/vendor/uPlot.min.css +1 -0
- dct/web/templates/analytics.html +63 -0
- dct/web/templates/base.html +106 -0
- dct/web/templates/changelog/list.html +23 -0
- dct/web/templates/decisions/list.html +22 -0
- dct/web/templates/handoffs/list.html +45 -0
- dct/web/templates/home.html +20 -0
- dct/web/templates/item_detail.html +91 -0
- dct/web/templates/items_list.html +44 -0
- dct/web/templates/partials/_bars.html +15 -0
- dct/web/templates/partials/_checkpoint_steps.html +22 -0
- dct/web/templates/partials/_items_table.html +23 -0
- dct/web/templates/partials/_plan_section.html +24 -0
- dct/web/templates/partials/_project_card.html +24 -0
- dct/web/templates/plans/detail.html +16 -0
- dct/web/templates/plans/list.html +15 -0
- dct/web/templates/project_home.html +51 -0
- dct/web/templates/sprints/detail.html +37 -0
- dct/web/templates/sprints/list.html +35 -0
- dctracker-1.0.0.dist-info/METADATA +357 -0
- dctracker-1.0.0.dist-info/RECORD +87 -0
- dctracker-1.0.0.dist-info/WHEEL +5 -0
- dctracker-1.0.0.dist-info/entry_points.txt +2 -0
- dctracker-1.0.0.dist-info/licenses/LICENSE +21 -0
- dctracker-1.0.0.dist-info/top_level.txt +1 -0
dct/core/version.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Version bump + autodetect logic — supports X.Y and X.Y.Z formats."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import tomllib
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def parse_version(version: str) -> tuple[int, ...]:
|
|
9
|
+
parts = version.strip().split(".")
|
|
10
|
+
return tuple(int(p) for p in parts)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def format_version(parts: tuple[int, ...]) -> str:
|
|
14
|
+
return ".".join(str(p) for p in parts)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def bump(current: str, bump_type: str) -> str:
|
|
18
|
+
"""Bump version string by type (patch/minor/major).
|
|
19
|
+
|
|
20
|
+
Supports both X.Y (two-part) and X.Y.Z (three-part) formats.
|
|
21
|
+
Two-part versions get a patch level on patch bump: 0.18 → 0.18.1
|
|
22
|
+
"""
|
|
23
|
+
if bump_type not in ("patch", "minor", "major"):
|
|
24
|
+
raise ValueError(f"Invalid bump type: '{bump_type}'. Valid: patch, minor, major")
|
|
25
|
+
|
|
26
|
+
parts = list(parse_version(current))
|
|
27
|
+
|
|
28
|
+
if bump_type == "major":
|
|
29
|
+
return format_version((parts[0] + 1, 0, 0) if len(parts) >= 3 else (parts[0] + 1, 0))
|
|
30
|
+
elif bump_type == "minor":
|
|
31
|
+
return format_version((parts[0], parts[1] + 1, 0) if len(parts) >= 3 else (parts[0], parts[1] + 1))
|
|
32
|
+
else: # patch
|
|
33
|
+
if len(parts) >= 3:
|
|
34
|
+
return format_version((parts[0], parts[1], parts[2] + 1))
|
|
35
|
+
else:
|
|
36
|
+
return format_version((parts[0], parts[1], 1))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def detect_version(path: str | Path) -> tuple[str | None, str | None]:
|
|
40
|
+
"""Best-effort version detection from common project files.
|
|
41
|
+
|
|
42
|
+
Tries in order and returns the first hit:
|
|
43
|
+
1. <path>/VERSION — raw single line, stripped
|
|
44
|
+
2. <path>/pyproject.toml — [project].version (PEP 621)
|
|
45
|
+
3. <path>/package.json — .version (npm/yarn)
|
|
46
|
+
4. <path>/Cargo.toml — [package].version (rust)
|
|
47
|
+
5. <path>/src-tauri/tauri.conf.json — .version (Tauri v2) or
|
|
48
|
+
.package.version (Tauri v1)
|
|
49
|
+
|
|
50
|
+
Returns (version, source_file) or (None, None) if no pattern matches.
|
|
51
|
+
Never raises — malformed files are silently skipped so `project add`
|
|
52
|
+
proceeds even when one of these files is corrupt.
|
|
53
|
+
"""
|
|
54
|
+
base = Path(path)
|
|
55
|
+
if not base.is_dir():
|
|
56
|
+
return None, None
|
|
57
|
+
|
|
58
|
+
# 1. VERSION (raw text)
|
|
59
|
+
vf = base / "VERSION"
|
|
60
|
+
if vf.is_file():
|
|
61
|
+
try:
|
|
62
|
+
v = vf.read_text(encoding="utf-8").strip()
|
|
63
|
+
if v:
|
|
64
|
+
return v, "VERSION"
|
|
65
|
+
except (OSError, UnicodeDecodeError):
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
# 2. pyproject.toml ([project].version)
|
|
69
|
+
pp = base / "pyproject.toml"
|
|
70
|
+
if pp.is_file():
|
|
71
|
+
try:
|
|
72
|
+
with open(pp, "rb") as f:
|
|
73
|
+
data = tomllib.load(f)
|
|
74
|
+
v = (data.get("project") or {}).get("version")
|
|
75
|
+
if isinstance(v, str) and v:
|
|
76
|
+
return v, "pyproject.toml"
|
|
77
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
# 3. package.json (.version)
|
|
81
|
+
pj = base / "package.json"
|
|
82
|
+
if pj.is_file():
|
|
83
|
+
try:
|
|
84
|
+
with open(pj, encoding="utf-8") as f:
|
|
85
|
+
data = json.load(f)
|
|
86
|
+
v = data.get("version")
|
|
87
|
+
if isinstance(v, str) and v:
|
|
88
|
+
return v, "package.json"
|
|
89
|
+
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
# 4. Cargo.toml ([package].version)
|
|
93
|
+
ct = base / "Cargo.toml"
|
|
94
|
+
if ct.is_file():
|
|
95
|
+
try:
|
|
96
|
+
with open(ct, "rb") as f:
|
|
97
|
+
data = tomllib.load(f)
|
|
98
|
+
v = (data.get("package") or {}).get("version")
|
|
99
|
+
if isinstance(v, str) and v:
|
|
100
|
+
return v, "Cargo.toml"
|
|
101
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
# 5. Tauri — v2 puts version at top level, v1 under .package.version
|
|
105
|
+
tc = base / "src-tauri" / "tauri.conf.json"
|
|
106
|
+
if tc.is_file():
|
|
107
|
+
try:
|
|
108
|
+
with open(tc, encoding="utf-8") as f:
|
|
109
|
+
data = json.load(f)
|
|
110
|
+
v = data.get("version") or (data.get("package") or {}).get("version")
|
|
111
|
+
if isinstance(v, str) and v:
|
|
112
|
+
return v, "src-tauri/tauri.conf.json"
|
|
113
|
+
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
return None, None
|
dct/db.py
ADDED
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
"""SQLAlchemy Core database layer for dct.
|
|
2
|
+
|
|
3
|
+
Defines all tables, engine creation, and connection helpers.
|
|
4
|
+
Uses Core (not ORM) for portability between PostgreSQL and SQLite.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sqlalchemy as sa
|
|
8
|
+
from sqlalchemy import event
|
|
9
|
+
|
|
10
|
+
metadata = sa.MetaData()
|
|
11
|
+
|
|
12
|
+
projects = sa.Table(
|
|
13
|
+
"projects",
|
|
14
|
+
metadata,
|
|
15
|
+
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
|
16
|
+
sa.Column("slug", sa.String(50), nullable=False),
|
|
17
|
+
sa.Column("name", sa.String(200), nullable=False),
|
|
18
|
+
sa.Column("path", sa.String(500)),
|
|
19
|
+
sa.Column("description", sa.Text),
|
|
20
|
+
sa.Column("current_version", sa.String(50)),
|
|
21
|
+
# #222: per-project opt-in for the dct changelog hook (default TRUE; the
|
|
22
|
+
# migration backfills existing projects from their actual dct changelog
|
|
23
|
+
# history). Opt out with `dct project changelog <slug> off`.
|
|
24
|
+
sa.Column("manages_changelog", sa.Boolean, nullable=False, server_default=sa.true()),
|
|
25
|
+
# #225: changelog file location relative to the project root (default repo
|
|
26
|
+
# root). A project keeping its changelog in a subdir points it there and
|
|
27
|
+
# /release resolves {path}/{changelog_path}. Constrained to the project root
|
|
28
|
+
# by set_changelog_path (leading '/' stripped, '..' escape rejected).
|
|
29
|
+
sa.Column("changelog_path", sa.String(500), nullable=False, server_default="changelog.md"),
|
|
30
|
+
sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
|
31
|
+
sa.Column("updated_at", sa.DateTime, nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
|
32
|
+
sa.Column("deleted_at", sa.DateTime),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
items = sa.Table(
|
|
36
|
+
"items",
|
|
37
|
+
metadata,
|
|
38
|
+
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
|
39
|
+
sa.Column("project_id", sa.Integer, sa.ForeignKey("projects.id"), nullable=False),
|
|
40
|
+
sa.Column("item_type", sa.String(20), nullable=False),
|
|
41
|
+
sa.Column("title", sa.String(500), nullable=False),
|
|
42
|
+
sa.Column("description", sa.Text),
|
|
43
|
+
sa.Column("priority", sa.String(10), nullable=False, server_default="P2"),
|
|
44
|
+
sa.Column("status", sa.String(20), nullable=False, server_default="open"),
|
|
45
|
+
sa.Column("component", sa.String(100)),
|
|
46
|
+
sa.Column("source_file", sa.String(500)),
|
|
47
|
+
sa.Column("source_lines", sa.String(100)),
|
|
48
|
+
sa.Column("tags", sa.Text, server_default="[]"),
|
|
49
|
+
sa.Column("resolution", sa.Text),
|
|
50
|
+
# NB: sprint membership is NOT a column. `in_current_sprint` is derived at
|
|
51
|
+
# read time from item_sprints + the project's active sprint (single source
|
|
52
|
+
# of truth, #216). The legacy boolean column is dropped by the migration in
|
|
53
|
+
# _apply_schema_updates.
|
|
54
|
+
sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
|
55
|
+
sa.Column("updated_at", sa.DateTime, nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
|
56
|
+
sa.Column("resolved_at", sa.DateTime),
|
|
57
|
+
sa.Column("deleted_at", sa.DateTime),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
item_notes = sa.Table(
|
|
61
|
+
"item_notes",
|
|
62
|
+
metadata,
|
|
63
|
+
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
|
64
|
+
sa.Column("item_id", sa.Integer, sa.ForeignKey("items.id"), nullable=False),
|
|
65
|
+
sa.Column("note", sa.Text, nullable=False),
|
|
66
|
+
sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
|
67
|
+
sa.Column("deleted_at", sa.DateTime),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
item_checkpoints = sa.Table(
|
|
71
|
+
"item_checkpoints",
|
|
72
|
+
metadata,
|
|
73
|
+
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
|
74
|
+
sa.Column("item_id", sa.Integer, sa.ForeignKey("items.id"), nullable=False),
|
|
75
|
+
sa.Column("text", sa.Text, nullable=False),
|
|
76
|
+
sa.Column("status", sa.String(10), nullable=False, server_default="pending"),
|
|
77
|
+
sa.Column("sort_order", sa.Integer, nullable=False, server_default="0"),
|
|
78
|
+
# `kind` classifies the checkpoint as a 'task' (default, closeable by
|
|
79
|
+
# anyone) or a 'gate' (kind != 'task'). Pending gates block resolve_item.
|
|
80
|
+
# Open string column (not ENUM) so OSS users can define custom gate
|
|
81
|
+
# kinds; core validates against a whitelist in checkpoints.py but
|
|
82
|
+
# unknown kinds are treated as gates (conservative default).
|
|
83
|
+
sa.Column("kind", sa.String(32), nullable=False, server_default="task"),
|
|
84
|
+
sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
|
85
|
+
sa.Column("updated_at", sa.DateTime, nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
|
86
|
+
sa.Column("deleted_at", sa.DateTime),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
changelog_entries = sa.Table(
|
|
90
|
+
"changelog_entries",
|
|
91
|
+
metadata,
|
|
92
|
+
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
|
93
|
+
sa.Column("project_id", sa.Integer, sa.ForeignKey("projects.id"), nullable=False),
|
|
94
|
+
sa.Column("version", sa.String(50)),
|
|
95
|
+
sa.Column("category", sa.String(20), nullable=False),
|
|
96
|
+
sa.Column("entry", sa.Text, nullable=False),
|
|
97
|
+
sa.Column("component", sa.String(100)),
|
|
98
|
+
sa.Column("source_file", sa.String(500)),
|
|
99
|
+
sa.Column("source_lines", sa.String(100)),
|
|
100
|
+
sa.Column("related_item_id", sa.Integer, sa.ForeignKey("items.id")),
|
|
101
|
+
sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
|
102
|
+
sa.Column("updated_at", sa.DateTime, nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
|
103
|
+
sa.Column("released_at", sa.DateTime),
|
|
104
|
+
sa.Column("deleted_at", sa.DateTime),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
session_handoffs = sa.Table(
|
|
108
|
+
"session_handoffs",
|
|
109
|
+
metadata,
|
|
110
|
+
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
|
111
|
+
sa.Column("project_id", sa.Integer, sa.ForeignKey("projects.id"), nullable=False),
|
|
112
|
+
sa.Column("prompt", sa.Text, nullable=False),
|
|
113
|
+
sa.Column("context_summary", sa.Text),
|
|
114
|
+
sa.Column("scope", sa.String(100)),
|
|
115
|
+
sa.Column("created_by", sa.String(200), nullable=False),
|
|
116
|
+
sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
|
117
|
+
sa.Column("consumed_at", sa.DateTime),
|
|
118
|
+
sa.Column("consumed_by", sa.String(200)),
|
|
119
|
+
sa.Column("deleted_at", sa.DateTime),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# ── v0.2.0 schema: plans / sprints / decisions ────────────────────────────────
|
|
123
|
+
|
|
124
|
+
sprints = sa.Table(
|
|
125
|
+
"sprints",
|
|
126
|
+
metadata,
|
|
127
|
+
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
|
128
|
+
sa.Column("project_id", sa.Integer, sa.ForeignKey("projects.id"), nullable=False),
|
|
129
|
+
sa.Column("name", sa.String(200), nullable=False),
|
|
130
|
+
sa.Column("code", sa.String(50)), # short code: "0.5", "M0", "sprint-v0.1-ship"
|
|
131
|
+
sa.Column("kind", sa.String(20), nullable=False, server_default="sprint"), # sprint|phase|milestone|block
|
|
132
|
+
sa.Column("status", sa.String(20), nullable=False, server_default="planned"), # planned|active|deferred|completed|abandoned
|
|
133
|
+
sa.Column("goal", sa.Text),
|
|
134
|
+
sa.Column("started_at", sa.DateTime),
|
|
135
|
+
sa.Column("ended_at", sa.DateTime),
|
|
136
|
+
sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
|
137
|
+
sa.Column("updated_at", sa.DateTime, nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
|
138
|
+
sa.Column("deleted_at", sa.DateTime),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
plans = sa.Table(
|
|
142
|
+
"plans",
|
|
143
|
+
metadata,
|
|
144
|
+
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
|
145
|
+
sa.Column("ulid", sa.String(26), nullable=False, unique=True),
|
|
146
|
+
sa.Column("project_id", sa.Integer, sa.ForeignKey("projects.id"), nullable=False),
|
|
147
|
+
sa.Column("slug", sa.String(200), nullable=False),
|
|
148
|
+
sa.Column("title", sa.String(500), nullable=False),
|
|
149
|
+
sa.Column("kind", sa.String(20), nullable=False), # plan|spec|roadmap|adr|retro|backlog
|
|
150
|
+
sa.Column("source_file", sa.String(1000)), # NULL for DB-only roadmap
|
|
151
|
+
sa.Column("source_hash", sa.String(64)), # sha256 of file at last scan
|
|
152
|
+
sa.Column("status", sa.String(20), nullable=False, server_default="active"), # draft|active|completed|archived
|
|
153
|
+
sa.Column("sprint_id", sa.Integer, sa.ForeignKey("sprints.id")),
|
|
154
|
+
sa.Column("imported_at", sa.DateTime), # NULL = never imported (first-import gate)
|
|
155
|
+
sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
|
156
|
+
sa.Column("updated_at", sa.DateTime, nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
|
157
|
+
sa.Column("archived_at", sa.DateTime),
|
|
158
|
+
sa.Column("deleted_at", sa.DateTime),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
plan_sections = sa.Table(
|
|
162
|
+
"plan_sections",
|
|
163
|
+
metadata,
|
|
164
|
+
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
|
165
|
+
sa.Column("ulid", sa.String(26), nullable=False, unique=True),
|
|
166
|
+
sa.Column("plan_id", sa.Integer, sa.ForeignKey("plans.id"), nullable=False),
|
|
167
|
+
sa.Column("parent_section_id", sa.Integer, sa.ForeignKey("plan_sections.id")),
|
|
168
|
+
sa.Column("heading", sa.Text, nullable=False),
|
|
169
|
+
sa.Column("depth", sa.Integer, nullable=False), # 1=H1, 2=H2, ..., 6=H6
|
|
170
|
+
sa.Column("slug", sa.String(200)),
|
|
171
|
+
sa.Column("sort_order", sa.Integer, nullable=False, server_default="0"),
|
|
172
|
+
sa.Column("section_type", sa.String(30), nullable=False, server_default="generic"),
|
|
173
|
+
sa.Column("section_type_confidence", sa.String(10), server_default="high"), # high|low|uncertain
|
|
174
|
+
sa.Column("needs_llm_review", sa.Boolean, nullable=False, server_default=sa.false()),
|
|
175
|
+
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
|
|
176
|
+
sa.Column("source_lines", sa.String(50)),
|
|
177
|
+
sa.Column("sprint_id", sa.Integer, sa.ForeignKey("sprints.id")),
|
|
178
|
+
sa.Column("converted_to_item_id", sa.Integer, sa.ForeignKey("items.id")),
|
|
179
|
+
sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
|
180
|
+
sa.Column("updated_at", sa.DateTime, nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
|
181
|
+
sa.Column("deleted_at", sa.DateTime),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
plan_checkpoints = sa.Table(
|
|
185
|
+
"plan_checkpoints",
|
|
186
|
+
metadata,
|
|
187
|
+
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
|
188
|
+
sa.Column("ulid", sa.String(26), nullable=False, unique=True),
|
|
189
|
+
sa.Column("plan_section_id", sa.Integer, sa.ForeignKey("plan_sections.id"), nullable=False),
|
|
190
|
+
sa.Column("text", sa.Text, nullable=False),
|
|
191
|
+
sa.Column("status", sa.String(10), nullable=False, server_default="pending"), # pending|done|rejected
|
|
192
|
+
sa.Column("sort_order", sa.Integer, nullable=False, server_default="0"),
|
|
193
|
+
sa.Column("converted_to_item_id", sa.Integer, sa.ForeignKey("items.id")),
|
|
194
|
+
sa.Column("source_lines", sa.String(50)),
|
|
195
|
+
sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
|
196
|
+
sa.Column("updated_at", sa.DateTime, nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
|
197
|
+
sa.Column("deleted_at", sa.DateTime),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
item_sprints = sa.Table(
|
|
201
|
+
"item_sprints",
|
|
202
|
+
metadata,
|
|
203
|
+
sa.Column("item_id", sa.Integer, sa.ForeignKey("items.id"), primary_key=True),
|
|
204
|
+
sa.Column("sprint_id", sa.Integer, sa.ForeignKey("sprints.id"), primary_key=True),
|
|
205
|
+
sa.Column("assigned_at", sa.DateTime, nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
|
206
|
+
sa.Column("removed_at", sa.DateTime),
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
decisions = sa.Table(
|
|
210
|
+
"decisions",
|
|
211
|
+
metadata,
|
|
212
|
+
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
|
213
|
+
sa.Column("ulid", sa.String(26), nullable=False, unique=True),
|
|
214
|
+
sa.Column("project_id", sa.Integer, sa.ForeignKey("projects.id"), nullable=False),
|
|
215
|
+
sa.Column("kind", sa.String(30), nullable=False), # adr|sprint_amendment|inline_plan|open_question
|
|
216
|
+
sa.Column("ref_code", sa.String(50)),
|
|
217
|
+
sa.Column("title", sa.String(500), nullable=False),
|
|
218
|
+
sa.Column("context", sa.Text),
|
|
219
|
+
sa.Column("decision", sa.Text),
|
|
220
|
+
sa.Column("consequences", sa.Text),
|
|
221
|
+
sa.Column("alternatives", sa.Text),
|
|
222
|
+
sa.Column("status", sa.String(20), nullable=False, server_default="proposed"), # proposed|accepted|superseded|rejected
|
|
223
|
+
sa.Column("superseded_by", sa.Integer, sa.ForeignKey("decisions.id")),
|
|
224
|
+
sa.Column("source_file", sa.String(1000)),
|
|
225
|
+
sa.Column("source_lines", sa.String(50)),
|
|
226
|
+
sa.Column("plan_id", sa.Integer, sa.ForeignKey("plans.id")),
|
|
227
|
+
sa.Column("plan_section_id", sa.Integer, sa.ForeignKey("plan_sections.id")),
|
|
228
|
+
sa.Column("sprint_id", sa.Integer, sa.ForeignKey("sprints.id")),
|
|
229
|
+
sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
|
230
|
+
sa.Column("updated_at", sa.DateTime, nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
|
231
|
+
sa.Column("deleted_at", sa.DateTime),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
sa.Index(
|
|
236
|
+
"ix_projects_slug_active",
|
|
237
|
+
projects.c.slug,
|
|
238
|
+
unique=True,
|
|
239
|
+
sqlite_where=projects.c.deleted_at.is_(None),
|
|
240
|
+
postgresql_where=projects.c.deleted_at.is_(None),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
sa.Index("ix_items_project_status_deleted", items.c.project_id, items.c.status, items.c.deleted_at)
|
|
244
|
+
# (ix_items_sprint removed with the in_current_sprint column — membership now
|
|
245
|
+
# lives in item_sprints; the current-sprint EXISTS lookup is served by the
|
|
246
|
+
# item_sprints PK on item_id + ix_sprints_project_status.)
|
|
247
|
+
sa.Index("ix_changelog_project_version_deleted", changelog_entries.c.project_id, changelog_entries.c.version, changelog_entries.c.deleted_at)
|
|
248
|
+
sa.Index("ix_changelog_related_item", changelog_entries.c.related_item_id)
|
|
249
|
+
sa.Index("ix_item_notes_item_deleted", item_notes.c.item_id, item_notes.c.deleted_at)
|
|
250
|
+
sa.Index("ix_checkpoints_item_status_deleted", item_checkpoints.c.item_id, item_checkpoints.c.status, item_checkpoints.c.deleted_at)
|
|
251
|
+
# Partial index: fast lookup of pending gates per item (hot path in resolve_item).
|
|
252
|
+
# Condition must match the predicate in resolve_item exactly for planner to use it.
|
|
253
|
+
sa.Index(
|
|
254
|
+
"ix_checkpoints_pending_gate",
|
|
255
|
+
item_checkpoints.c.item_id,
|
|
256
|
+
sqlite_where=sa.and_(
|
|
257
|
+
item_checkpoints.c.kind != "task",
|
|
258
|
+
item_checkpoints.c.status == "pending",
|
|
259
|
+
item_checkpoints.c.deleted_at.is_(None),
|
|
260
|
+
),
|
|
261
|
+
postgresql_where=sa.and_(
|
|
262
|
+
item_checkpoints.c.kind != "task",
|
|
263
|
+
item_checkpoints.c.status == "pending",
|
|
264
|
+
item_checkpoints.c.deleted_at.is_(None),
|
|
265
|
+
),
|
|
266
|
+
)
|
|
267
|
+
sa.Index(
|
|
268
|
+
"ix_handoffs_unconsumed",
|
|
269
|
+
session_handoffs.c.project_id,
|
|
270
|
+
session_handoffs.c.created_at.desc(),
|
|
271
|
+
sqlite_where=sa.and_(session_handoffs.c.consumed_at.is_(None), session_handoffs.c.deleted_at.is_(None)),
|
|
272
|
+
postgresql_where=sa.and_(session_handoffs.c.consumed_at.is_(None), session_handoffs.c.deleted_at.is_(None)),
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
# v0.2.0 indexes
|
|
276
|
+
sa.Index(
|
|
277
|
+
"ix_plans_project_slug_active",
|
|
278
|
+
plans.c.project_id, plans.c.slug,
|
|
279
|
+
unique=True,
|
|
280
|
+
sqlite_where=plans.c.deleted_at.is_(None),
|
|
281
|
+
postgresql_where=plans.c.deleted_at.is_(None),
|
|
282
|
+
)
|
|
283
|
+
sa.Index("ix_plan_sections_plan", plan_sections.c.plan_id, plan_sections.c.sort_order)
|
|
284
|
+
sa.Index("ix_plan_sections_parent", plan_sections.c.parent_section_id)
|
|
285
|
+
sa.Index(
|
|
286
|
+
"ix_plan_sections_uncertain",
|
|
287
|
+
plan_sections.c.plan_id,
|
|
288
|
+
sqlite_where=sa.and_(plan_sections.c.needs_llm_review.is_(True), plan_sections.c.deleted_at.is_(None)),
|
|
289
|
+
postgresql_where=sa.and_(plan_sections.c.needs_llm_review.is_(True), plan_sections.c.deleted_at.is_(None)),
|
|
290
|
+
)
|
|
291
|
+
sa.Index("ix_plan_checkpoints_section_status", plan_checkpoints.c.plan_section_id, plan_checkpoints.c.status, plan_checkpoints.c.deleted_at)
|
|
292
|
+
sa.Index(
|
|
293
|
+
"ix_sprints_project_code_active",
|
|
294
|
+
sprints.c.project_id, sprints.c.code,
|
|
295
|
+
unique=True,
|
|
296
|
+
sqlite_where=sa.and_(sprints.c.code.isnot(None), sprints.c.deleted_at.is_(None)),
|
|
297
|
+
postgresql_where=sa.and_(sprints.c.code.isnot(None), sprints.c.deleted_at.is_(None)),
|
|
298
|
+
)
|
|
299
|
+
sa.Index("ix_sprints_project_status", sprints.c.project_id, sprints.c.status, sprints.c.deleted_at)
|
|
300
|
+
sa.Index(
|
|
301
|
+
"ix_item_sprints_sprint_active",
|
|
302
|
+
item_sprints.c.sprint_id,
|
|
303
|
+
sqlite_where=item_sprints.c.removed_at.is_(None),
|
|
304
|
+
postgresql_where=item_sprints.c.removed_at.is_(None),
|
|
305
|
+
)
|
|
306
|
+
sa.Index(
|
|
307
|
+
"ix_decisions_project_refcode_active",
|
|
308
|
+
decisions.c.project_id, decisions.c.ref_code,
|
|
309
|
+
unique=True,
|
|
310
|
+
sqlite_where=sa.and_(decisions.c.ref_code.isnot(None), decisions.c.deleted_at.is_(None)),
|
|
311
|
+
postgresql_where=sa.and_(decisions.c.ref_code.isnot(None), decisions.c.deleted_at.is_(None)),
|
|
312
|
+
)
|
|
313
|
+
sa.Index("ix_decisions_project_kind_status", decisions.c.project_id, decisions.c.kind, decisions.c.status, decisions.c.deleted_at)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _set_sqlite_wal(dbapi_connection, _connection_record):
|
|
317
|
+
cursor = dbapi_connection.cursor()
|
|
318
|
+
cursor.execute("PRAGMA foreign_keys=ON")
|
|
319
|
+
try:
|
|
320
|
+
cursor.execute("PRAGMA journal_mode=WAL")
|
|
321
|
+
except Exception:
|
|
322
|
+
pass
|
|
323
|
+
cursor.close()
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def get_engine(database_url: str) -> sa.Engine:
|
|
327
|
+
kwargs = {}
|
|
328
|
+
if database_url.startswith("sqlite"):
|
|
329
|
+
kwargs["connect_args"] = {"check_same_thread": False}
|
|
330
|
+
|
|
331
|
+
engine = sa.create_engine(database_url, **kwargs)
|
|
332
|
+
|
|
333
|
+
if database_url.startswith("sqlite"):
|
|
334
|
+
event.listen(engine, "connect", _set_sqlite_wal)
|
|
335
|
+
|
|
336
|
+
return engine
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def create_tables(engine: sa.Engine) -> None:
|
|
340
|
+
metadata.create_all(engine)
|
|
341
|
+
_apply_schema_updates(engine)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _apply_schema_updates(engine: sa.Engine) -> None:
|
|
345
|
+
"""Idempotent schema updates for existing DBs (lightweight migration runner).
|
|
346
|
+
|
|
347
|
+
Each schema change (column add, index add, ...) is checked and applied
|
|
348
|
+
INDEPENDENTLY. We do NOT treat a logical group (e.g. "sprint migration")
|
|
349
|
+
as atomic — a partial prior run could leave the column in place but the
|
|
350
|
+
index missing, and we must heal each piece on the next call. This mirrors
|
|
351
|
+
the invariant-per-field rule learned from the update_item resolved_at bug
|
|
352
|
+
(core/items.py TERMINAL_STATUSES).
|
|
353
|
+
|
|
354
|
+
Dialect note: PostgreSQL is strict about BOOLEAN literals — `0` (INTEGER)
|
|
355
|
+
does NOT coerce to FALSE, the ALTER fails with DatatypeMismatch; same for
|
|
356
|
+
`1` vs TRUE in index predicates. SQLite is permissive. We emit FALSE/TRUE
|
|
357
|
+
for PostgreSQL and 0/1 for SQLite. Keep this in sync with the
|
|
358
|
+
server_default on the SQLAlchemy column definition (which uses sa.false()
|
|
359
|
+
to portably emit the correct literal at CREATE TABLE time).
|
|
360
|
+
"""
|
|
361
|
+
inspector = sa.inspect(engine)
|
|
362
|
+
if "items" not in inspector.get_table_names():
|
|
363
|
+
return
|
|
364
|
+
|
|
365
|
+
col_names = {c["name"] for c in inspector.get_columns("items")}
|
|
366
|
+
|
|
367
|
+
# #222: opt-in changelog enforcement. Add projects.manages_changelog and,
|
|
368
|
+
# on first add, backfill — a project KEEPS enforcement only if it already
|
|
369
|
+
# has dct changelog history; one with none opts out (so projects registered
|
|
370
|
+
# for item-tracking but keeping their own changelog aren't wrongly blocked).
|
|
371
|
+
# New rows default TRUE via the column server_default; this block is a
|
|
372
|
+
# one-shot (guarded by column absence) so re-runs never override a later
|
|
373
|
+
# explicit `dct project changelog` toggle.
|
|
374
|
+
proj_cols = {c["name"] for c in inspector.get_columns("projects")}
|
|
375
|
+
if "manages_changelog" not in proj_cols:
|
|
376
|
+
is_pg = engine.dialect.name == "postgresql"
|
|
377
|
+
true_lit, false_lit = ("TRUE", "FALSE") if is_pg else ("1", "0")
|
|
378
|
+
with engine.begin() as conn:
|
|
379
|
+
conn.execute(sa.text(
|
|
380
|
+
f"ALTER TABLE projects ADD COLUMN manages_changelog "
|
|
381
|
+
f"BOOLEAN NOT NULL DEFAULT {true_lit}"
|
|
382
|
+
))
|
|
383
|
+
conn.execute(sa.text(
|
|
384
|
+
f"UPDATE projects SET manages_changelog = {false_lit} "
|
|
385
|
+
"WHERE deleted_at IS NULL AND id NOT IN "
|
|
386
|
+
"(SELECT DISTINCT project_id FROM changelog_entries "
|
|
387
|
+
"WHERE deleted_at IS NULL AND project_id IS NOT NULL)"
|
|
388
|
+
))
|
|
389
|
+
|
|
390
|
+
# #225: per-project changelog location. The ADD COLUMN DEFAULT backfills
|
|
391
|
+
# existing rows to 'changelog.md' (repo root) — no conditional UPDATE needed.
|
|
392
|
+
# Guarded by column absence so re-runs never clobber an explicit value set
|
|
393
|
+
# later via `dct project changelog-path`. VARCHAR default is dialect-portable
|
|
394
|
+
# (unlike the BOOLEAN literal dance above).
|
|
395
|
+
if "changelog_path" not in proj_cols:
|
|
396
|
+
with engine.begin() as conn:
|
|
397
|
+
conn.execute(sa.text(
|
|
398
|
+
"ALTER TABLE projects ADD COLUMN changelog_path "
|
|
399
|
+
"VARCHAR(500) NOT NULL DEFAULT 'changelog.md'"
|
|
400
|
+
))
|
|
401
|
+
|
|
402
|
+
# v0.4.0: add item_checkpoints.kind column + partial index for gate lookup.
|
|
403
|
+
# See item #33 (gate checkpoints). Pre-v0.4 checkpoints default to kind='task'
|
|
404
|
+
# via server_default — existing resolve_item behaviour preserved for rows
|
|
405
|
+
# without explicit gate assignment.
|
|
406
|
+
if "item_checkpoints" in inspector.get_table_names():
|
|
407
|
+
cp_cols = {c["name"] for c in inspector.get_columns("item_checkpoints")}
|
|
408
|
+
cp_idx = {ix["name"] for ix in inspector.get_indexes("item_checkpoints")}
|
|
409
|
+
with engine.begin() as conn:
|
|
410
|
+
if "kind" not in cp_cols:
|
|
411
|
+
conn.execute(sa.text(
|
|
412
|
+
"ALTER TABLE item_checkpoints ADD COLUMN kind "
|
|
413
|
+
"VARCHAR(32) NOT NULL DEFAULT 'task'"
|
|
414
|
+
))
|
|
415
|
+
if "ix_checkpoints_pending_gate" not in cp_idx:
|
|
416
|
+
conn.execute(sa.text(
|
|
417
|
+
"CREATE INDEX IF NOT EXISTS ix_checkpoints_pending_gate "
|
|
418
|
+
"ON item_checkpoints(item_id) "
|
|
419
|
+
"WHERE kind != 'task' AND status = 'pending' AND deleted_at IS NULL"
|
|
420
|
+
))
|
|
421
|
+
|
|
422
|
+
# v0.2.0 + #216: migrate the legacy `in_current_sprint` boolean into the
|
|
423
|
+
# item_sprints model, THEN drop the column. Sprint membership now has ONE
|
|
424
|
+
# source of truth (item_sprints + the active sprint) and is derived on read.
|
|
425
|
+
# The seed reads the column via raw SQL (it is intentionally absent from the
|
|
426
|
+
# table metadata), so this whole block self-disables once the column is gone.
|
|
427
|
+
# Order matters: seed BEFORE dropping, and drop the dependent index first
|
|
428
|
+
# (SQLite refuses to drop a column an index still references).
|
|
429
|
+
tables_after = set(inspector.get_table_names())
|
|
430
|
+
|
|
431
|
+
# Legacy sprints are HISTORY, not the current sprint — keep them 'completed'.
|
|
432
|
+
# INDEPENDENT, idempotent invariant (deliberately NOT nested under the column
|
|
433
|
+
# check): on a DB where the column was dropped before this demote existed,
|
|
434
|
+
# the legacy sprint would otherwise stay 'active' and wrongly resolve as the
|
|
435
|
+
# current sprint. Heal per-piece (the rule from this runner's docstring).
|
|
436
|
+
if "sprints" in tables_after:
|
|
437
|
+
with engine.begin() as conn:
|
|
438
|
+
conn.execute(sa.text(
|
|
439
|
+
"UPDATE sprints SET status = 'completed', "
|
|
440
|
+
"ended_at = COALESCE(ended_at, CURRENT_TIMESTAMP), "
|
|
441
|
+
"updated_at = CURRENT_TIMESTAMP "
|
|
442
|
+
"WHERE code = 'legacy' AND status = 'active' AND deleted_at IS NULL"
|
|
443
|
+
))
|
|
444
|
+
|
|
445
|
+
# v0.2.0 + #216: migrate the legacy `in_current_sprint` boolean into the
|
|
446
|
+
# item_sprints model, THEN drop the column. Sprint membership now has ONE
|
|
447
|
+
# source of truth (item_sprints + the active sprint) and is derived on read.
|
|
448
|
+
# The seed reads the column via raw SQL (intentionally absent from the table
|
|
449
|
+
# metadata), so this block self-disables once the column is gone. Order
|
|
450
|
+
# matters: seed BEFORE dropping, drop the dependent index first (SQLite).
|
|
451
|
+
if "in_current_sprint" in col_names and {"sprints", "item_sprints"}.issubset(tables_after):
|
|
452
|
+
_migrate_legacy_sprint_flag(engine)
|
|
453
|
+
with engine.begin() as conn:
|
|
454
|
+
conn.execute(sa.text("DROP INDEX IF EXISTS ix_items_sprint"))
|
|
455
|
+
conn.execute(sa.text("ALTER TABLE items DROP COLUMN in_current_sprint"))
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _migrate_legacy_sprint_flag(engine: sa.Engine) -> None:
|
|
459
|
+
"""Seed a 'legacy' sprint per project with flagged items and link them.
|
|
460
|
+
|
|
461
|
+
Idempotent — for each project with any item where `in_current_sprint=TRUE`,
|
|
462
|
+
we ensure:
|
|
463
|
+
1. A sprints row with code='legacy' exists (created if missing).
|
|
464
|
+
2. Every flagged item has an item_sprints link ROW to that sprint,
|
|
465
|
+
created once if absent. A link later soft-removed via the v0.2 API
|
|
466
|
+
(removed_at set) is left untouched — this backfill is a one-shot seed,
|
|
467
|
+
NOT an authority that overrides v0.2 sprint-membership decisions.
|
|
468
|
+
Repeat invocations make no further writes once the invariant holds — the
|
|
469
|
+
existence probe spans the FULL (item_id, sprint_id) primary key regardless
|
|
470
|
+
of removed_at, so a re-run never collides with a soft-removed row.
|
|
471
|
+
|
|
472
|
+
One-shot seed (#216): reads the legacy `in_current_sprint` column via raw
|
|
473
|
+
SQL because the column is absent from the table metadata and is DROPPED
|
|
474
|
+
right after this seed runs (see _apply_schema_updates). No-op once the
|
|
475
|
+
column is gone, so it is always safe to call. The full-PK existence probe
|
|
476
|
+
means a re-run never collides with a soft-removed link (the #215 lesson).
|
|
477
|
+
"""
|
|
478
|
+
from datetime import datetime, timezone
|
|
479
|
+
|
|
480
|
+
# The column only exists in pre-#216 DBs. Self-disable cleanly otherwise.
|
|
481
|
+
if "in_current_sprint" not in {c["name"] for c in sa.inspect(engine).get_columns("items")}:
|
|
482
|
+
return
|
|
483
|
+
|
|
484
|
+
with engine.begin() as conn:
|
|
485
|
+
# Read the legacy column via literal_column (it is absent from the table
|
|
486
|
+
# metadata) while keeping updated_at a TYPED column, so the value stays a
|
|
487
|
+
# datetime and can be reused as item_sprints.assigned_at.
|
|
488
|
+
flagged_rows = conn.execute(
|
|
489
|
+
sa.select(items.c.id, items.c.project_id, items.c.updated_at).where(
|
|
490
|
+
sa.literal_column("in_current_sprint") == sa.true(),
|
|
491
|
+
items.c.deleted_at.is_(None),
|
|
492
|
+
)
|
|
493
|
+
).fetchall()
|
|
494
|
+
if not flagged_rows:
|
|
495
|
+
return
|
|
496
|
+
|
|
497
|
+
by_project: dict[int, list[tuple[int, "datetime"]]] = {}
|
|
498
|
+
for row in flagged_rows:
|
|
499
|
+
by_project.setdefault(row.project_id, []).append((row.id, row.updated_at))
|
|
500
|
+
|
|
501
|
+
for project_id, flagged in by_project.items():
|
|
502
|
+
existing = conn.execute(
|
|
503
|
+
sa.select(sprints.c.id).where(
|
|
504
|
+
sprints.c.project_id == project_id,
|
|
505
|
+
sprints.c.code == "legacy",
|
|
506
|
+
sprints.c.deleted_at.is_(None),
|
|
507
|
+
)
|
|
508
|
+
).first()
|
|
509
|
+
if existing:
|
|
510
|
+
sprint_id = existing.id
|
|
511
|
+
else:
|
|
512
|
+
row = conn.execute(
|
|
513
|
+
sprints.insert()
|
|
514
|
+
.values(
|
|
515
|
+
project_id=project_id,
|
|
516
|
+
name="Pre-0.1 legacy sprint",
|
|
517
|
+
code="legacy",
|
|
518
|
+
kind="sprint",
|
|
519
|
+
# Legacy sprint is HISTORY, not the current sprint — it is
|
|
520
|
+
# 'completed' so it never resolves as the active/current
|
|
521
|
+
# sprint after #216. Items keep their membership for the
|
|
522
|
+
# record; the user sets a fresh current sprint via /handoff.
|
|
523
|
+
status="completed",
|
|
524
|
+
started_at=datetime.now(timezone.utc),
|
|
525
|
+
ended_at=datetime.now(timezone.utc),
|
|
526
|
+
)
|
|
527
|
+
.returning(sprints.c.id)
|
|
528
|
+
).first()
|
|
529
|
+
assert row is not None, "INSERT RETURNING must return a row"
|
|
530
|
+
sprint_id = row.id
|
|
531
|
+
|
|
532
|
+
# Probe by the FULL primary key (item_id, sprint_id) — deliberately
|
|
533
|
+
# NOT filtered by removed_at. item_sprints_pkey spans both columns
|
|
534
|
+
# irrespective of removed_at, so a soft-removed link still occupies
|
|
535
|
+
# the slot. Filtering removed_at IS NULL here (the original bug)
|
|
536
|
+
# would miss a flagged item whose link was later soft-removed via
|
|
537
|
+
# remove_item_from_sprint, then re-INSERT it -> UniqueViolation.
|
|
538
|
+
already_linked = {
|
|
539
|
+
r.item_id for r in conn.execute(
|
|
540
|
+
sa.select(item_sprints.c.item_id).where(
|
|
541
|
+
item_sprints.c.sprint_id == sprint_id,
|
|
542
|
+
)
|
|
543
|
+
)
|
|
544
|
+
}
|
|
545
|
+
for item_id, updated_at in flagged:
|
|
546
|
+
if item_id in already_linked:
|
|
547
|
+
continue
|
|
548
|
+
conn.execute(
|
|
549
|
+
item_sprints.insert().values(
|
|
550
|
+
item_id=item_id,
|
|
551
|
+
sprint_id=sprint_id,
|
|
552
|
+
assigned_at=updated_at,
|
|
553
|
+
)
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def row_to_dict(row: sa.Row) -> dict:
|
|
558
|
+
return dict(row._mapping)
|