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.
Files changed (87) hide show
  1. dct/__init__.py +6 -0
  2. dct/cli.py +1659 -0
  3. dct/config.py +150 -0
  4. dct/core/__init__.py +0 -0
  5. dct/core/analytics.py +382 -0
  6. dct/core/changelog.py +112 -0
  7. dct/core/checkpoints.py +205 -0
  8. dct/core/decisions.py +238 -0
  9. dct/core/engine.py +32 -0
  10. dct/core/handoff.py +151 -0
  11. dct/core/ids.py +25 -0
  12. dct/core/items.py +382 -0
  13. dct/core/plans/__init__.py +6 -0
  14. dct/core/plans/classify.py +94 -0
  15. dct/core/plans/crud.py +680 -0
  16. dct/core/plans/ingest.py +408 -0
  17. dct/core/plans/parser.py +312 -0
  18. dct/core/projects.py +298 -0
  19. dct/core/sprint.py +315 -0
  20. dct/core/sprints.py +601 -0
  21. dct/core/version.py +116 -0
  22. dct/db.py +558 -0
  23. dct/export.py +107 -0
  24. dct/hooks/check-changelog-on-stop.sh +49 -0
  25. dct/hooks/check-changelog.sh +143 -0
  26. dct/hooks/dct-plan-autoscan.sh +54 -0
  27. dct/hooks/dct-task-mirror.sh +62 -0
  28. dct/server.py +1812 -0
  29. dct/setup.py +258 -0
  30. dct/skills/clog/SKILL.md +73 -0
  31. dct/skills/commit/SKILL.md +153 -0
  32. dct/skills/dct-import/SKILL.md +329 -0
  33. dct/skills/dct-init/SKILL.md +289 -0
  34. dct/skills/handoff/SKILL.md +136 -0
  35. dct/skills/pickup/SKILL.md +115 -0
  36. dct/skills/plan-resolve-uncertain/SKILL.md +82 -0
  37. dct/skills/plan-status/SKILL.md +79 -0
  38. dct/skills/release/SKILL.md +281 -0
  39. dct/skills/track/SKILL.md +121 -0
  40. dct/skills/track-list/SKILL.md +134 -0
  41. dct/skills/track-resolve/SKILL.md +53 -0
  42. dct/skills/track-update/SKILL.md +109 -0
  43. dct/web/__init__.py +1 -0
  44. dct/web/app.py +83 -0
  45. dct/web/blueprints/__init__.py +5 -0
  46. dct/web/blueprints/analytics.py +34 -0
  47. dct/web/blueprints/changelog.py +40 -0
  48. dct/web/blueprints/decisions.py +23 -0
  49. dct/web/blueprints/events.py +69 -0
  50. dct/web/blueprints/handoffs.py +26 -0
  51. dct/web/blueprints/home.py +15 -0
  52. dct/web/blueprints/items.py +128 -0
  53. dct/web/blueprints/plans.py +53 -0
  54. dct/web/blueprints/projects.py +23 -0
  55. dct/web/blueprints/sprints.py +71 -0
  56. dct/web/changes.py +77 -0
  57. dct/web/serializers.py +109 -0
  58. dct/web/static/app.css +609 -0
  59. dct/web/static/app.js +62 -0
  60. dct/web/static/vendor/SOURCES.txt +10 -0
  61. dct/web/static/vendor/htmx.min.js +1 -0
  62. dct/web/static/vendor/uPlot.iife.min.js +2 -0
  63. dct/web/static/vendor/uPlot.min.css +1 -0
  64. dct/web/templates/analytics.html +63 -0
  65. dct/web/templates/base.html +106 -0
  66. dct/web/templates/changelog/list.html +23 -0
  67. dct/web/templates/decisions/list.html +22 -0
  68. dct/web/templates/handoffs/list.html +45 -0
  69. dct/web/templates/home.html +20 -0
  70. dct/web/templates/item_detail.html +91 -0
  71. dct/web/templates/items_list.html +44 -0
  72. dct/web/templates/partials/_bars.html +15 -0
  73. dct/web/templates/partials/_checkpoint_steps.html +22 -0
  74. dct/web/templates/partials/_items_table.html +23 -0
  75. dct/web/templates/partials/_plan_section.html +24 -0
  76. dct/web/templates/partials/_project_card.html +24 -0
  77. dct/web/templates/plans/detail.html +16 -0
  78. dct/web/templates/plans/list.html +15 -0
  79. dct/web/templates/project_home.html +51 -0
  80. dct/web/templates/sprints/detail.html +37 -0
  81. dct/web/templates/sprints/list.html +35 -0
  82. dctracker-1.0.0.dist-info/METADATA +357 -0
  83. dctracker-1.0.0.dist-info/RECORD +87 -0
  84. dctracker-1.0.0.dist-info/WHEEL +5 -0
  85. dctracker-1.0.0.dist-info/entry_points.txt +2 -0
  86. dctracker-1.0.0.dist-info/licenses/LICENSE +21 -0
  87. dctracker-1.0.0.dist-info/top_level.txt +1 -0
dct/config.py ADDED
@@ -0,0 +1,150 @@
1
+ """Configuration loading for dct.
2
+
3
+ Priority: DCT_DATABASE_URL env var > ~/.claude/dct.toml > defaults.
4
+ """
5
+
6
+ import os
7
+ import tomllib
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+
11
+ DEFAULT_CONFIG_PATH = Path.home() / ".claude" / "dct.toml"
12
+ DEFAULT_DB_PATH = Path.home() / ".claude" / "dct.db"
13
+
14
+
15
+ @dataclass
16
+ class PlansConfig:
17
+ """[plans] section — v0.2.0 parser/classifier/hook behaviour."""
18
+ auto_resolve_uncertain: bool = True
19
+ classify_model: str = "claude-haiku-4-5-20251001"
20
+ classify_timeout_seconds: int = 30
21
+ surface_on_session_start: bool = False
22
+ discovery_include: list[str] = field(default_factory=list)
23
+ discovery_exclude: list[str] = field(default_factory=list)
24
+
25
+
26
+ @dataclass
27
+ class MirrorConfig:
28
+ """[mirror] section — v0.4.0 TaskCreate ↔ dct integration.
29
+
30
+ task_to_dct: 'auto' mirrors every TaskCreate/TodoWrite into a session-scoped
31
+ item as kind='cc-task' checkpoints (non-blocking).
32
+ 'ask' skips mirror — Claude keeps TaskCreate and dct separate.
33
+ 'off' disables the hook entirely.
34
+ """
35
+ task_to_dct: str = "auto"
36
+
37
+
38
+ @dataclass
39
+ class WebConfig:
40
+ """[web] section — v0.4.1 read-only browser viewer.
41
+
42
+ The viewer is reached at ``http://localhost:<port>`` — plain localhost
43
+ binding, no privileged port.
44
+
45
+ host bind address (localhost-only by design)
46
+ port preferred port; auto_fallback scans upward if busy
47
+ auto_fallback pick the next free port when `port` is occupied
48
+ open_browser open the browser on `dct web start`
49
+ refresh_poll_ms SSE change-token poll interval (milliseconds)
50
+ """
51
+ host: str = "127.0.0.1"
52
+ port: int = 8787
53
+ auto_fallback: bool = True
54
+ open_browser: bool = False
55
+ refresh_poll_ms: int = 2000
56
+
57
+
58
+ @dataclass
59
+ class Config:
60
+ database_url: str = ""
61
+ dct_root: str = "" # absolute path to the dct repo (for MCP server references)
62
+ plans: PlansConfig = field(default_factory=PlansConfig)
63
+ mirror: MirrorConfig = field(default_factory=MirrorConfig)
64
+ web: WebConfig = field(default_factory=WebConfig)
65
+
66
+ def __post_init__(self):
67
+ if not self.database_url:
68
+ self.database_url = f"sqlite:///{DEFAULT_DB_PATH}"
69
+ if not self.dct_root:
70
+ self.dct_root = str(Path(__file__).resolve().parent.parent)
71
+
72
+
73
+ def load_config(config_path: Path | None = None) -> Config:
74
+ path = config_path or Path(os.environ.get("DCT_CONFIG_PATH", str(DEFAULT_CONFIG_PATH)))
75
+ data: dict = {}
76
+
77
+ if path.exists():
78
+ with open(path, "rb") as f:
79
+ data = tomllib.load(f)
80
+
81
+ db_section = data.get("database", {})
82
+ database_url = db_section.get("url", "")
83
+ dct_root = data.get("dct_root", "")
84
+
85
+ plans_section = data.get("plans", {})
86
+ plans = PlansConfig(
87
+ auto_resolve_uncertain=bool(plans_section.get("auto_resolve_uncertain", True)),
88
+ classify_model=str(plans_section.get("classify_model", "claude-haiku-4-5-20251001")),
89
+ classify_timeout_seconds=int(plans_section.get("classify_timeout_seconds", 30)),
90
+ surface_on_session_start=bool(plans_section.get("surface_on_session_start", False)),
91
+ discovery_include=list(plans_section.get("discovery_include", [])),
92
+ discovery_exclude=list(plans_section.get("discovery_exclude", [])),
93
+ )
94
+
95
+ mirror_section = data.get("mirror", {})
96
+ mirror_mode = str(mirror_section.get("task_to_dct", "auto"))
97
+ if mirror_mode not in ("auto", "ask", "off"):
98
+ mirror_mode = "auto"
99
+ mirror = MirrorConfig(task_to_dct=mirror_mode)
100
+
101
+ web_section = data.get("web", {})
102
+ web = WebConfig(
103
+ host=str(web_section.get("host", "127.0.0.1")),
104
+ port=int(web_section.get("port", 8787)),
105
+ auto_fallback=bool(web_section.get("auto_fallback", True)),
106
+ open_browser=bool(web_section.get("open_browser", False)),
107
+ refresh_poll_ms=int(web_section.get("refresh_poll_ms", 2000)),
108
+ )
109
+
110
+ # Env var overrides config file
111
+ env_url = os.environ.get("DCT_DATABASE_URL")
112
+ if env_url:
113
+ database_url = env_url
114
+
115
+ return Config(database_url=database_url, dct_root=dct_root, plans=plans, mirror=mirror, web=web)
116
+
117
+
118
+ def save_config(config: Config, config_path: Path | None = None) -> Path:
119
+ path = config_path or DEFAULT_CONFIG_PATH
120
+ path.parent.mkdir(parents=True, exist_ok=True)
121
+
122
+ lines = []
123
+ if config.dct_root:
124
+ lines.append(f'dct_root = "{config.dct_root}"\n\n')
125
+ lines.append('[database]\n')
126
+ lines.append(f'url = "{config.database_url}"\n\n')
127
+ lines.append('[plans]\n')
128
+ lines.append(f'auto_resolve_uncertain = {str(config.plans.auto_resolve_uncertain).lower()}\n')
129
+ lines.append(f'classify_model = "{config.plans.classify_model}"\n')
130
+ lines.append(f'classify_timeout_seconds = {config.plans.classify_timeout_seconds}\n')
131
+ lines.append(f'surface_on_session_start = {str(config.plans.surface_on_session_start).lower()}\n')
132
+ if config.plans.discovery_include:
133
+ inc = ", ".join(f'"{p}"' for p in config.plans.discovery_include)
134
+ lines.append(f'discovery_include = [{inc}]\n')
135
+ if config.plans.discovery_exclude:
136
+ exc = ", ".join(f'"{p}"' for p in config.plans.discovery_exclude)
137
+ lines.append(f'discovery_exclude = [{exc}]\n')
138
+
139
+ lines.append('\n[mirror]\n')
140
+ lines.append(f'task_to_dct = "{config.mirror.task_to_dct}"\n')
141
+
142
+ lines.append('\n[web]\n')
143
+ lines.append(f'host = "{config.web.host}"\n')
144
+ lines.append(f'port = {config.web.port}\n')
145
+ lines.append(f'auto_fallback = {str(config.web.auto_fallback).lower()}\n')
146
+ lines.append(f'open_browser = {str(config.web.open_browser).lower()}\n')
147
+ lines.append(f'refresh_poll_ms = {config.web.refresh_poll_ms}\n')
148
+
149
+ path.write_text("".join(lines))
150
+ return path
dct/core/__init__.py ADDED
File without changes
dct/core/analytics.py ADDED
@@ -0,0 +1,382 @@
1
+ """Pure aggregation helpers over dct.core data.
2
+
3
+ Engine-first, JSON-native return dicts, no ORM. Every query is a single
4
+ GROUP BY (or a constant number of grouped queries merged in Python) — never a
5
+ per-project loop (no N+1). All aggregates exclude soft-deleted rows
6
+ (`deleted_at IS NULL`; `item_sprints` uses `removed_at IS NULL`).
7
+
8
+ Read-only: this module issues SELECTs only. Used by the web viewer
9
+ (dct/web) and exposable later via MCP/CLI.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from datetime import datetime, timedelta, timezone, time
15
+
16
+ import sqlalchemy as sa
17
+
18
+ from dct.db import (
19
+ projects, items, item_checkpoints, changelog_entries,
20
+ session_handoffs, sprints, item_sprints,
21
+ )
22
+
23
+ OPEN_STATUSES = ("open", "in_progress")
24
+ TERMINAL_STATUSES = ("resolved", "wont_fix")
25
+ NON_GATE_KINDS = ("task", "cc-task") # everything else is a gate (see resolve_item)
26
+
27
+
28
+ def _iso(dt) -> str | None:
29
+ """datetime -> ISO 8601 string; None passthrough."""
30
+ return dt.isoformat() if dt is not None else None
31
+
32
+
33
+ def _safe_tags(raw) -> list[str]:
34
+ """Tolerant tag parse: JSON-TEXT | None | garbage -> list[str].
35
+
36
+ Reuses items._parse_tags (returns [] on malformed JSON) and additionally
37
+ guards against non-list / non-str payloads so an adversarial `tags` value
38
+ can never crash an aggregate.
39
+ """
40
+ from dct.core.items import _parse_tags
41
+
42
+ parsed = _parse_tags(raw)
43
+ if not isinstance(parsed, list):
44
+ return []
45
+ return [t for t in parsed if isinstance(t, str) and t]
46
+
47
+
48
+ def radar(engine: sa.Engine) -> dict:
49
+ """Cross-project operational rollup — one grouped query per table, merged.
50
+
51
+ open_total counts status='open'; by_priority breaks down those open items;
52
+ in_progress counts status='in_progress'; stale_Nd counts items with status
53
+ in (open,in_progress) whose updated_at is older than N days.
54
+ """
55
+ now = datetime.now(timezone.utc)
56
+ anchor7 = now - timedelta(days=7)
57
+ anchor30 = now - timedelta(days=30)
58
+
59
+ def _open_pri(pri):
60
+ return sa.func.sum(
61
+ sa.case((sa.and_(items.c.status == "open", items.c.priority == pri), 1), else_=0)
62
+ )
63
+
64
+ items_q = (
65
+ sa.select(
66
+ items.c.project_id.label("project_id"),
67
+ sa.func.sum(sa.case((items.c.status == "open", 1), else_=0)).label("open_total"),
68
+ _open_pri("P1").label("p1"),
69
+ _open_pri("P2").label("p2"),
70
+ _open_pri("P3").label("p3"),
71
+ _open_pri("future").label("pfuture"),
72
+ sa.func.sum(sa.case((items.c.status == "in_progress", 1), else_=0)).label("in_progress"),
73
+ sa.func.sum(sa.case(
74
+ (sa.and_(items.c.status.in_(OPEN_STATUSES), items.c.updated_at < anchor7), 1), else_=0
75
+ )).label("stale_7d"),
76
+ sa.func.sum(sa.case(
77
+ (sa.and_(items.c.status.in_(OPEN_STATUSES), items.c.updated_at < anchor30), 1), else_=0
78
+ )).label("stale_30d"),
79
+ )
80
+ .where(items.c.deleted_at.is_(None))
81
+ .group_by(items.c.project_id)
82
+ )
83
+ handoffs_q = (
84
+ sa.select(session_handoffs.c.project_id.label("project_id"), sa.func.count().label("cnt"))
85
+ .where(session_handoffs.c.consumed_at.is_(None), session_handoffs.c.deleted_at.is_(None))
86
+ .group_by(session_handoffs.c.project_id)
87
+ )
88
+ clog_q = (
89
+ sa.select(changelog_entries.c.project_id.label("project_id"), sa.func.count().label("cnt"))
90
+ .where(changelog_entries.c.version.is_(None), changelog_entries.c.deleted_at.is_(None))
91
+ .group_by(changelog_entries.c.project_id)
92
+ )
93
+
94
+ with engine.connect() as conn:
95
+ proj_rows = conn.execute(
96
+ sa.select(projects.c.id, projects.c.slug, projects.c.name)
97
+ .where(projects.c.deleted_at.is_(None))
98
+ .order_by(projects.c.slug)
99
+ ).fetchall()
100
+ items_by = {r.project_id: r for r in conn.execute(items_q)}
101
+ handoffs_by = {r.project_id: int(r.cnt) for r in conn.execute(handoffs_q)}
102
+ clog_by = {r.project_id: int(r.cnt) for r in conn.execute(clog_q)}
103
+
104
+ out_projects = []
105
+ tot_open = tot_ip = tot_stale7 = tot_handoff = 0
106
+ for p in proj_rows:
107
+ it = items_by.get(p.id)
108
+ open_total = int(it.open_total) if it else 0
109
+ in_progress = int(it.in_progress) if it else 0
110
+ stale_7d = int(it.stale_7d) if it else 0
111
+ stale_30d = int(it.stale_30d) if it else 0
112
+ by_priority = {
113
+ "P1": int(it.p1) if it else 0,
114
+ "P2": int(it.p2) if it else 0,
115
+ "P3": int(it.p3) if it else 0,
116
+ "future": int(it.pfuture) if it else 0,
117
+ }
118
+ unconsumed = handoffs_by.get(p.id, 0)
119
+ unreleased = clog_by.get(p.id, 0)
120
+ out_projects.append({
121
+ "slug": p.slug,
122
+ "name": p.name,
123
+ "open_total": open_total,
124
+ "by_priority": by_priority,
125
+ "in_progress": in_progress,
126
+ "stale_7d": stale_7d,
127
+ "stale_30d": stale_30d,
128
+ "unconsumed_handoffs": unconsumed,
129
+ "unreleased_changelog": unreleased,
130
+ })
131
+ tot_open += open_total
132
+ tot_ip += in_progress
133
+ tot_stale7 += stale_7d
134
+ tot_handoff += unconsumed
135
+
136
+ return {
137
+ "projects": out_projects,
138
+ "totals": {
139
+ "open_total": tot_open,
140
+ "in_progress": tot_ip,
141
+ "stale_7d": tot_stale7,
142
+ "unconsumed_handoffs": tot_handoff,
143
+ },
144
+ }
145
+
146
+
147
+ def backlog_health(engine: sa.Engine, project_slug: str) -> dict:
148
+ """Single-project distributions, aging, hotspots, tags, checkpoint stats.
149
+
150
+ "open backlog" (aging / hotspots / tags) = OPEN_STATUSES (open + in_progress).
151
+ Distributions (by_type/priority/status/component) span all non-deleted items.
152
+ """
153
+ from dct.core.projects import resolve_project_id
154
+
155
+ now = datetime.now(timezone.utc)
156
+ a1w = now - timedelta(days=7)
157
+ a4w = now - timedelta(days=28)
158
+ a3m = now - timedelta(days=90)
159
+
160
+ with engine.connect() as conn:
161
+ pid = resolve_project_id(conn, project_slug)
162
+ base = sa.and_(items.c.project_id == pid, items.c.deleted_at.is_(None))
163
+ open_base = sa.and_(base, items.c.status.in_(OPEN_STATUSES))
164
+
165
+ def _group(col):
166
+ return conn.execute(
167
+ sa.select(col, sa.func.count().label("cnt")).where(base).group_by(col)
168
+ ).fetchall()
169
+
170
+ by_type = {r[0]: int(r.cnt) for r in _group(items.c.item_type)}
171
+ by_priority = {r[0]: int(r.cnt) for r in _group(items.c.priority)}
172
+ by_status = {r[0]: int(r.cnt) for r in _group(items.c.status)}
173
+
174
+ comp_rows = conn.execute(
175
+ sa.select(items.c.component, sa.func.count().label("cnt"))
176
+ .where(base, items.c.component.isnot(None))
177
+ .group_by(items.c.component)
178
+ .order_by(sa.func.count().desc(), items.c.component)
179
+ ).fetchall()
180
+ by_component = [{"component": r.component, "count": int(r.cnt)} for r in comp_rows]
181
+
182
+ aging_row = conn.execute(
183
+ sa.select(
184
+ sa.func.sum(sa.case((items.c.created_at > a1w, 1), else_=0)).label("lt_1w"),
185
+ sa.func.sum(sa.case(
186
+ (sa.and_(items.c.created_at <= a1w, items.c.created_at > a4w), 1), else_=0
187
+ )).label("w1_4"),
188
+ sa.func.sum(sa.case(
189
+ (sa.and_(items.c.created_at <= a4w, items.c.created_at > a3m), 1), else_=0
190
+ )).label("m1_3"),
191
+ sa.func.sum(sa.case((items.c.created_at <= a3m, 1), else_=0)).label("gt_3m"),
192
+ ).where(open_base)
193
+ ).first()
194
+ aging = {
195
+ "lt_1w": int(aging_row.lt_1w or 0),
196
+ "w1_4": int(aging_row.w1_4 or 0),
197
+ "m1_3": int(aging_row.m1_3 or 0),
198
+ "gt_3m": int(aging_row.gt_3m or 0),
199
+ }
200
+
201
+ def _hotspot(col):
202
+ rows = conn.execute(
203
+ sa.select(col, sa.func.count().label("cnt"))
204
+ .where(open_base, col.isnot(None))
205
+ .group_by(col)
206
+ .having(sa.func.count() >= 2)
207
+ .order_by(sa.func.count().desc(), col)
208
+ ).fetchall()
209
+ return [{"name": r[0], "count": int(r.cnt)} for r in rows]
210
+
211
+ hotspots = {"components": _hotspot(items.c.component), "files": _hotspot(items.c.source_file)}
212
+
213
+ tag_rows = conn.execute(sa.select(items.c.tags).where(open_base)).fetchall()
214
+
215
+ cp_join = item_checkpoints.join(items, items.c.id == item_checkpoints.c.item_id)
216
+ cp_filter = sa.and_(
217
+ items.c.project_id == pid,
218
+ items.c.deleted_at.is_(None),
219
+ item_checkpoints.c.deleted_at.is_(None),
220
+ )
221
+ cp_status_rows = conn.execute(
222
+ sa.select(item_checkpoints.c.status, sa.func.count().label("cnt"))
223
+ .select_from(cp_join).where(cp_filter)
224
+ .group_by(item_checkpoints.c.status)
225
+ ).fetchall()
226
+ cp_by = {r.status: int(r.cnt) for r in cp_status_rows}
227
+
228
+ gate_rows = conn.execute(
229
+ sa.select(item_checkpoints.c.kind, sa.func.count().label("cnt"))
230
+ .select_from(cp_join)
231
+ .where(cp_filter, item_checkpoints.c.kind.notin_(NON_GATE_KINDS))
232
+ .group_by(item_checkpoints.c.kind)
233
+ ).fetchall()
234
+ gates_by_kind = {r.kind: int(r.cnt) for r in gate_rows}
235
+
236
+ # tag frequency (parsed defensively outside the connection)
237
+ tag_counts: dict[str, int] = {}
238
+ for r in tag_rows:
239
+ for t in _safe_tags(r[0]):
240
+ tag_counts[t] = tag_counts.get(t, 0) + 1
241
+ tags = [
242
+ {"tag": t, "count": c}
243
+ for t, c in sorted(tag_counts.items(), key=lambda kv: (-kv[1], kv[0]))
244
+ ]
245
+
246
+ done = cp_by.get("done", 0)
247
+ pending = cp_by.get("pending", 0)
248
+ rejected = cp_by.get("rejected", 0)
249
+ denom = done + pending
250
+ completion_rate = round(done / denom, 4) if denom else 0.0
251
+
252
+ return {
253
+ "by_type": by_type,
254
+ "by_priority": by_priority,
255
+ "by_status": by_status,
256
+ "by_component": by_component,
257
+ "aging": aging,
258
+ "hotspots": hotspots,
259
+ "tags": tags,
260
+ "checkpoints": {
261
+ "pending": pending,
262
+ "done": done,
263
+ "rejected": rejected,
264
+ "gates_by_kind": gates_by_kind,
265
+ "completion_rate": completion_rate,
266
+ },
267
+ }
268
+
269
+
270
+ def velocity(engine: sa.Engine, project_slug: str, weeks: int = 26) -> dict:
271
+ """Single-project time-series. Weeks are bucketed by their Monday (ISO date
272
+ string). Bucketing is done in Python (portable across SQLite/Postgres) after
273
+ a constant number of windowed SELECTs — never a per-week query loop.
274
+ """
275
+ from dct.core.projects import resolve_project_id
276
+
277
+ now = datetime.now(timezone.utc)
278
+ today = now.date()
279
+ this_monday = today - timedelta(days=today.weekday())
280
+ start_monday = this_monday - timedelta(days=7 * (weeks - 1))
281
+ week_keys = [(start_monday + timedelta(days=7 * i)).isoformat() for i in range(weeks)]
282
+ window_start = datetime.combine(start_monday, time(0, 0, 0))
283
+
284
+ def _monday_key(dt) -> str:
285
+ d = dt.date() if isinstance(dt, datetime) else dt
286
+ return (d - timedelta(days=d.weekday())).isoformat()
287
+
288
+ with engine.connect() as conn:
289
+ pid = resolve_project_id(conn, project_slug)
290
+
291
+ resolved_rows = conn.execute(
292
+ sa.select(items.c.resolved_at).where(
293
+ items.c.project_id == pid,
294
+ items.c.deleted_at.is_(None),
295
+ items.c.status.in_(TERMINAL_STATUSES),
296
+ items.c.resolved_at.isnot(None),
297
+ items.c.resolved_at >= window_start,
298
+ )
299
+ ).fetchall()
300
+ created_rows = conn.execute(
301
+ sa.select(items.c.created_at).where(
302
+ items.c.project_id == pid,
303
+ items.c.deleted_at.is_(None),
304
+ items.c.created_at >= window_start,
305
+ )
306
+ ).fetchall()
307
+ clog_rows = conn.execute(
308
+ sa.select(changelog_entries.c.category, changelog_entries.c.created_at).where(
309
+ changelog_entries.c.project_id == pid,
310
+ changelog_entries.c.deleted_at.is_(None),
311
+ changelog_entries.c.created_at >= window_start,
312
+ )
313
+ ).fetchall()
314
+ release_rows = conn.execute(
315
+ sa.select(
316
+ changelog_entries.c.version,
317
+ sa.func.min(changelog_entries.c.released_at).label("released_at"),
318
+ )
319
+ .where(
320
+ changelog_entries.c.project_id == pid,
321
+ changelog_entries.c.deleted_at.is_(None),
322
+ changelog_entries.c.version.isnot(None),
323
+ changelog_entries.c.released_at.isnot(None),
324
+ )
325
+ .group_by(changelog_entries.c.version)
326
+ .order_by(sa.func.min(changelog_entries.c.released_at))
327
+ ).fetchall()
328
+
329
+ isp = item_sprints.alias("isp")
330
+ live_link = sa.and_(isp.c.removed_at.is_(None), items.c.id.isnot(None))
331
+ sprint_rows = conn.execute(
332
+ sa.select(
333
+ sprints.c.name,
334
+ sprints.c.code,
335
+ sa.func.sum(sa.case((live_link, 1), else_=0)).label("size"),
336
+ sa.func.sum(sa.case(
337
+ (sa.and_(live_link, items.c.status.in_(TERMINAL_STATUSES)), 1), else_=0
338
+ )).label("done"),
339
+ )
340
+ .select_from(
341
+ sprints
342
+ .outerjoin(isp, isp.c.sprint_id == sprints.c.id)
343
+ .outerjoin(items, sa.and_(items.c.id == isp.c.item_id, items.c.deleted_at.is_(None)))
344
+ )
345
+ .where(sprints.c.project_id == pid, sprints.c.deleted_at.is_(None))
346
+ .group_by(sprints.c.id, sprints.c.name, sprints.c.code, sprints.c.created_at)
347
+ .order_by(sprints.c.created_at)
348
+ ).fetchall()
349
+
350
+ resolved_per = {k: 0 for k in week_keys}
351
+ for r in resolved_rows:
352
+ k = _monday_key(r[0])
353
+ if k in resolved_per:
354
+ resolved_per[k] += 1
355
+ created_per = {k: 0 for k in week_keys}
356
+ for r in created_rows:
357
+ k = _monday_key(r[0])
358
+ if k in created_per:
359
+ created_per[k] += 1
360
+ clog_per = {k: {"added": 0, "changed": 0, "fixed": 0, "removed": 0} for k in week_keys}
361
+ for r in clog_rows:
362
+ k = _monday_key(r.created_at)
363
+ if k in clog_per and r.category in clog_per[k]:
364
+ clog_per[k][r.category] += 1
365
+
366
+ return {
367
+ "resolved_per_week": [{"week": k, "count": resolved_per[k]} for k in week_keys],
368
+ "created_per_week": [{"week": k, "count": created_per[k]} for k in week_keys],
369
+ "backlog_growth": [
370
+ {"week": k, "created": created_per[k], "resolved": resolved_per[k],
371
+ "net": created_per[k] - resolved_per[k]}
372
+ for k in week_keys
373
+ ],
374
+ "changelog_per_week": [{"week": k, **clog_per[k]} for k in week_keys],
375
+ "release_cadence": [
376
+ {"version": r.version, "released_at": _iso(r.released_at)} for r in release_rows
377
+ ],
378
+ "sprint_completion": [
379
+ {"name": r.name, "code": r.code, "done": int(r.done or 0), "size": int(r.size or 0)}
380
+ for r in sprint_rows
381
+ ],
382
+ }
dct/core/changelog.py ADDED
@@ -0,0 +1,112 @@
1
+ """Changelog CRUD operations with soft deletes."""
2
+
3
+ from datetime import datetime, timezone
4
+
5
+ import sqlalchemy as sa
6
+
7
+ from dct.db import changelog_entries, projects, row_to_dict
8
+ from dct.core.projects import resolve_project_id
9
+
10
+ VALID_CATEGORIES = {"added", "changed", "fixed", "removed"}
11
+
12
+
13
+ def add_entry(
14
+ engine: sa.Engine,
15
+ project_slug: str,
16
+ category: str,
17
+ entry: str,
18
+ version: str | None = None,
19
+ component: str | None = None,
20
+ source_file: str | None = None,
21
+ source_lines: str | None = None,
22
+ related_item_id: int | None = None,
23
+ ) -> dict:
24
+ if not entry or not entry.strip():
25
+ raise ValueError("Changelog entry cannot be empty")
26
+ if category not in VALID_CATEGORIES:
27
+ raise ValueError(f"Invalid category: '{category}'. Valid: {', '.join(sorted(VALID_CATEGORIES))}")
28
+
29
+ with engine.begin() as conn:
30
+ project_id = resolve_project_id(conn, project_slug)
31
+ result = conn.execute(
32
+ changelog_entries.insert()
33
+ .values(
34
+ project_id=project_id,
35
+ version=version,
36
+ category=category,
37
+ entry=entry,
38
+ component=component,
39
+ source_file=source_file,
40
+ source_lines=source_lines,
41
+ related_item_id=related_item_id,
42
+ )
43
+ .returning(changelog_entries)
44
+ )
45
+ return row_to_dict(result.first())
46
+
47
+
48
+ def list_entries(
49
+ engine: sa.Engine,
50
+ project_slug: str,
51
+ version: str | None = None,
52
+ unreleased_only: bool = False,
53
+ ) -> list[dict]:
54
+ with engine.begin() as conn:
55
+ pid = resolve_project_id(conn, project_slug)
56
+ q = (
57
+ sa.select(changelog_entries)
58
+ .where(changelog_entries.c.project_id == pid, changelog_entries.c.deleted_at.is_(None))
59
+ .order_by(changelog_entries.c.created_at.desc())
60
+ )
61
+ if unreleased_only:
62
+ q = q.where(changelog_entries.c.version.is_(None))
63
+ elif version:
64
+ q = q.where(changelog_entries.c.version == version)
65
+ return [row_to_dict(r) for r in conn.execute(q)]
66
+
67
+
68
+ def count_unreleased(engine: sa.Engine, project_slug: str) -> int:
69
+ with engine.begin() as conn:
70
+ pid = resolve_project_id(conn, project_slug)
71
+ result = conn.execute(
72
+ sa.select(sa.func.count())
73
+ .select_from(changelog_entries)
74
+ .where(
75
+ changelog_entries.c.project_id == pid,
76
+ changelog_entries.c.version.is_(None),
77
+ changelog_entries.c.deleted_at.is_(None),
78
+ )
79
+ )
80
+ return result.scalar() or 0
81
+
82
+
83
+ def release_entries(engine: sa.Engine, project_slug: str, version: str) -> int:
84
+ """Stamp all unreleased entries with a version and update project's current_version."""
85
+ now = datetime.now(timezone.utc)
86
+ with engine.begin() as conn:
87
+ pid = resolve_project_id(conn, project_slug)
88
+ result = conn.execute(
89
+ changelog_entries.update()
90
+ .where(
91
+ changelog_entries.c.project_id == pid,
92
+ changelog_entries.c.version.is_(None),
93
+ changelog_entries.c.deleted_at.is_(None),
94
+ )
95
+ .values(version=version, released_at=now, updated_at=now)
96
+ )
97
+ conn.execute(
98
+ projects.update()
99
+ .where(projects.c.id == pid)
100
+ .values(current_version=version, updated_at=now)
101
+ )
102
+ return result.rowcount
103
+
104
+
105
+ def delete_entry(engine: sa.Engine, entry_id: int) -> bool:
106
+ with engine.begin() as conn:
107
+ result = conn.execute(
108
+ changelog_entries.update()
109
+ .where(changelog_entries.c.id == entry_id, changelog_entries.c.deleted_at.is_(None))
110
+ .values(deleted_at=datetime.now(timezone.utc))
111
+ )
112
+ return result.rowcount > 0