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/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
|