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/plans/parser.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
r"""Mechanical markdown parser for dct plans.
|
|
2
|
+
|
|
3
|
+
Pure — takes file content (str), returns a ParseResult dataclass. No I/O,
|
|
4
|
+
no DB, no LLM. Deterministic regex + state machine, code-fence-aware.
|
|
5
|
+
|
|
6
|
+
Grammar per spec §7:
|
|
7
|
+
1. YAML frontmatter — skipped for now (reserved for future use).
|
|
8
|
+
2. Plan ULID anchor — first ``<!-- dct:plan:<ULID> -->`` on any line.
|
|
9
|
+
3. Code fences — ``` ``` ``` or ``~~~`` open/close; skip rules inside.
|
|
10
|
+
4. Headings — `^(#{1,6})\s+(.+)$` outside fences.
|
|
11
|
+
5. Section anchor — next non-empty line after heading may be
|
|
12
|
+
``<!-- dct:section:<ULID> parent=<ULID> -->``.
|
|
13
|
+
6. Section type — regex heuristic on heading text (sprint/task/...).
|
|
14
|
+
7. Checkboxes — `^(\s*)-\s+\[([ x])\]\s+(.*)$`.
|
|
15
|
+
8. Checkpoint anchor — ``<!-- dct:cp:<ULID> parent=<ULID> -->`` between
|
|
16
|
+
``[x/' ']`` and the text.
|
|
17
|
+
9. Checkpoint state — reported as 'done' / 'pending' based on
|
|
18
|
+
``x`` / ``' '``. Callers decide whether to respect
|
|
19
|
+
or ignore it (first-import vs rescan, §9.3).
|
|
20
|
+
|
|
21
|
+
Returns all sections/checkpoints even when they lack ULID anchors — missing
|
|
22
|
+
ULIDs are reported as `None`, so callers (ingest.py) can generate and
|
|
23
|
+
write-back synchronously (§9.1.3 step 2).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import re
|
|
29
|
+
import unicodedata
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from typing import Literal
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
ULID_PATTERN = r"[0-9A-HJKMNP-TV-Z]{26}"
|
|
35
|
+
|
|
36
|
+
RE_PLAN_ANCHOR = re.compile(
|
|
37
|
+
rf"<!--\s*dct:plan:(?P<ulid>{ULID_PATTERN})\s*-->"
|
|
38
|
+
)
|
|
39
|
+
RE_SECTION_ANCHOR = re.compile(
|
|
40
|
+
rf"<!--\s*dct:section:(?P<ulid>{ULID_PATTERN})"
|
|
41
|
+
rf"(?:\s+parent=(?P<parent>{ULID_PATTERN}))?\s*-->"
|
|
42
|
+
)
|
|
43
|
+
RE_CP_ANCHOR = re.compile(
|
|
44
|
+
rf"<!--\s*dct:cp:(?P<ulid>{ULID_PATTERN})"
|
|
45
|
+
rf"(?:\s+parent=(?P<parent>{ULID_PATTERN}))?\s*-->"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
RE_HEADING = re.compile(r"^(?P<hashes>#{1,6})\s+(?P<text>.+?)\s*$")
|
|
49
|
+
RE_CHECKBOX = re.compile(
|
|
50
|
+
r"^(?P<indent>\s*)-\s+\[(?P<state>[ xX])\]\s+(?P<rest>.*)$"
|
|
51
|
+
)
|
|
52
|
+
RE_FENCE = re.compile(r"^(?P<indent>\s*)(?P<marker>```+|~~~+)(?P<info>.*)$")
|
|
53
|
+
|
|
54
|
+
# Confidence-high patterns for section_type classification.
|
|
55
|
+
# Ordering matters — first match wins. All case-insensitive.
|
|
56
|
+
SECTION_TYPE_RULES: list[tuple[str, re.Pattern[str]]] = [
|
|
57
|
+
("sprint", re.compile(r"^sprint\s+[\d.]+", re.IGNORECASE)),
|
|
58
|
+
("phase", re.compile(r"^phase\s+([\d]+|[a-z])\b", re.IGNORECASE)),
|
|
59
|
+
("task", re.compile(r"^task\s+[\w.-]+", re.IGNORECASE)),
|
|
60
|
+
("block", re.compile(r"^block\s+[a-z]\d*", re.IGNORECASE)),
|
|
61
|
+
("milestone", re.compile(r"^milestone\s+[ma-z]\d+", re.IGNORECASE)),
|
|
62
|
+
("decision", re.compile(r"^adr[- ]\d+", re.IGNORECASE)),
|
|
63
|
+
("changelog", re.compile(r"^(added|changed|fixed|removed)\s*$", re.IGNORECASE)),
|
|
64
|
+
("adr_section", re.compile(r"^(context|decision|consequences|alternatives)\s*$", re.IGNORECASE)),
|
|
65
|
+
("reference", re.compile(r"^(appendix|references?|open\s+questions?)\b", re.IGNORECASE)),
|
|
66
|
+
# Numbered spec/plan headings like "1. Problem", "4.1 Glossary",
|
|
67
|
+
# "9.1.1 Discovery", and "9.1 `/dct-import` — discover". These are
|
|
68
|
+
# narrative chapters (prose) — not sprints, not tasks, not references.
|
|
69
|
+
# Placed last so specific patterns above (sprint/phase/task/etc.) still
|
|
70
|
+
# win when the heading starts with e.g. "1. Sprint Foo" (matches sprint
|
|
71
|
+
# pattern via the `Sprint` word). The trailing `\S` (non-whitespace)
|
|
72
|
+
# requirement excludes lone numbers like "1." with no title while
|
|
73
|
+
# accepting titles that start with backticks, brackets, parens, etc.
|
|
74
|
+
("prose", re.compile(r"^\d+(\.\d+)*\.?\s+\S", re.IGNORECASE)),
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class ParsedSection:
|
|
80
|
+
"""A heading-anchored section in the plan. `ulid`/`parent_ulid`=None → caller must mint."""
|
|
81
|
+
|
|
82
|
+
heading: str
|
|
83
|
+
depth: int
|
|
84
|
+
slug: str
|
|
85
|
+
sort_order: int
|
|
86
|
+
section_type: str
|
|
87
|
+
section_type_confidence: Literal["high", "low"] = "high"
|
|
88
|
+
ulid: str | None = None
|
|
89
|
+
parent_ulid: str | None = None
|
|
90
|
+
# Line positions — 1-based, used by writer to inject ULIDs.
|
|
91
|
+
heading_line: int = -1
|
|
92
|
+
anchor_line: int | None = None # existing anchor line if present
|
|
93
|
+
source_lines: str | None = None # "5-87" span; filled on finalize
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class ParsedCheckpoint:
|
|
98
|
+
"""A `- [ ]` / `- [x]` item under a section."""
|
|
99
|
+
|
|
100
|
+
text: str
|
|
101
|
+
initial_state: Literal["pending", "done"]
|
|
102
|
+
sort_order: int
|
|
103
|
+
ulid: str | None = None
|
|
104
|
+
parent_section_ulid: str | None = None
|
|
105
|
+
checkbox_line: int = -1
|
|
106
|
+
source_lines: str | None = None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class ParseResult:
|
|
111
|
+
plan_ulid: str | None = None
|
|
112
|
+
plan_anchor_line: int | None = None # 1-based line where the plan anchor sits, if any
|
|
113
|
+
sections: list[ParsedSection] = field(default_factory=list)
|
|
114
|
+
checkpoints: list[ParsedCheckpoint] = field(default_factory=list)
|
|
115
|
+
warnings: list[str] = field(default_factory=list)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def slugify(text: str) -> str:
|
|
119
|
+
"""Produce a URL-friendly kebab-case slug from heading text.
|
|
120
|
+
|
|
121
|
+
- Strip NFKD combining marks (so `Łąka` → `laka`).
|
|
122
|
+
- Lowercase.
|
|
123
|
+
- Replace non-alphanumeric runs with a single hyphen.
|
|
124
|
+
- Trim edge hyphens; clamp to 200 chars.
|
|
125
|
+
"""
|
|
126
|
+
normalized = unicodedata.normalize("NFKD", text)
|
|
127
|
+
stripped = "".join(c for c in normalized if not unicodedata.combining(c))
|
|
128
|
+
lower = stripped.lower()
|
|
129
|
+
slug = re.sub(r"[^a-z0-9]+", "-", lower).strip("-")
|
|
130
|
+
return slug[:200]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def classify_heading(text: str) -> tuple[str, Literal["high", "low"]]:
|
|
134
|
+
"""Return (section_type, confidence) for a heading text.
|
|
135
|
+
|
|
136
|
+
High-confidence patterns in SECTION_TYPE_RULES win first-match.
|
|
137
|
+
Everything else falls back to ('uncertain', 'low') for LLM review
|
|
138
|
+
downstream (or 'generic' if the caller disables classification).
|
|
139
|
+
"""
|
|
140
|
+
for kind, pattern in SECTION_TYPE_RULES:
|
|
141
|
+
if pattern.match(text):
|
|
142
|
+
return kind, "high"
|
|
143
|
+
return "uncertain", "low"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def parse_plan(content: str) -> ParseResult:
|
|
147
|
+
"""Parse a markdown plan file body and return structured sections/checkpoints.
|
|
148
|
+
|
|
149
|
+
Pure function — no I/O. `content` is the full file content (UTF-8 string).
|
|
150
|
+
Line numbers in the result are 1-based relative to `content`. Thread-safe:
|
|
151
|
+
all state lives in local variables, no module-level mutation.
|
|
152
|
+
"""
|
|
153
|
+
result = ParseResult()
|
|
154
|
+
lines = content.splitlines()
|
|
155
|
+
|
|
156
|
+
in_fence = False
|
|
157
|
+
fence_marker: str | None = None
|
|
158
|
+
|
|
159
|
+
# Pending anchor from a comment that immediately precedes / follows a heading.
|
|
160
|
+
# We attach the NEXT non-empty line's anchor to the heading above.
|
|
161
|
+
last_heading_index: int | None = None # index into result.sections
|
|
162
|
+
|
|
163
|
+
# Stack of (depth, section_index) for parent derivation via nesting.
|
|
164
|
+
heading_stack: list[tuple[int, int]] = []
|
|
165
|
+
|
|
166
|
+
# Structural parent derived from nesting — kept local per call (thread-safe).
|
|
167
|
+
section_parent_index: dict[int, int | None] = {}
|
|
168
|
+
|
|
169
|
+
section_counter = 0
|
|
170
|
+
checkpoint_counter = 0
|
|
171
|
+
|
|
172
|
+
for i, raw_line in enumerate(lines, start=1):
|
|
173
|
+
line = raw_line.rstrip("\n")
|
|
174
|
+
|
|
175
|
+
# 1. Fence tracking — toggles open/close; skip most rules inside.
|
|
176
|
+
fence_match = RE_FENCE.match(line)
|
|
177
|
+
if fence_match:
|
|
178
|
+
marker = fence_match.group("marker")[:3] # normalize to first 3 backticks/tildes
|
|
179
|
+
if not in_fence:
|
|
180
|
+
in_fence = True
|
|
181
|
+
fence_marker = marker
|
|
182
|
+
else:
|
|
183
|
+
# Close only if same fence kind (``` closes ```, not ~~~)
|
|
184
|
+
if fence_marker and marker == fence_marker:
|
|
185
|
+
in_fence = False
|
|
186
|
+
fence_marker = None
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
if in_fence:
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
# 2. Plan anchor — first one wins.
|
|
193
|
+
plan_match = RE_PLAN_ANCHOR.search(line)
|
|
194
|
+
if plan_match and result.plan_ulid is None:
|
|
195
|
+
result.plan_ulid = plan_match.group("ulid")
|
|
196
|
+
result.plan_anchor_line = i
|
|
197
|
+
# Don't `continue` — an anchor can share a line with other content,
|
|
198
|
+
# though the convention is its own line. Keep processing heading/etc.
|
|
199
|
+
|
|
200
|
+
# 3. Heading?
|
|
201
|
+
heading_match = RE_HEADING.match(line)
|
|
202
|
+
if heading_match:
|
|
203
|
+
hashes = heading_match.group("hashes")
|
|
204
|
+
text = heading_match.group("text").strip()
|
|
205
|
+
depth = len(hashes)
|
|
206
|
+
section_type, confidence = classify_heading(text)
|
|
207
|
+
section = ParsedSection(
|
|
208
|
+
heading=text,
|
|
209
|
+
depth=depth,
|
|
210
|
+
slug=slugify(text),
|
|
211
|
+
sort_order=section_counter * 10,
|
|
212
|
+
section_type=section_type,
|
|
213
|
+
section_type_confidence=confidence,
|
|
214
|
+
heading_line=i,
|
|
215
|
+
)
|
|
216
|
+
section_counter += 1
|
|
217
|
+
|
|
218
|
+
# Parent derivation: pop stack until we find a depth < ours.
|
|
219
|
+
while heading_stack and heading_stack[-1][0] >= depth:
|
|
220
|
+
heading_stack.pop()
|
|
221
|
+
# We store ULID parents only after anchors are resolved (§ below);
|
|
222
|
+
# for now, remember the parent section index (for post-pass fill-in).
|
|
223
|
+
parent_section_index = heading_stack[-1][1] if heading_stack else None
|
|
224
|
+
|
|
225
|
+
result.sections.append(section)
|
|
226
|
+
current_index = len(result.sections) - 1
|
|
227
|
+
heading_stack.append((depth, current_index))
|
|
228
|
+
last_heading_index = current_index
|
|
229
|
+
|
|
230
|
+
# Stash parent index in a local sidecar for the post-pass.
|
|
231
|
+
section_parent_index[current_index] = parent_section_index
|
|
232
|
+
continue
|
|
233
|
+
|
|
234
|
+
# 4. Section anchor (HTML comment attached to the most recent heading).
|
|
235
|
+
section_anchor_match = RE_SECTION_ANCHOR.search(line)
|
|
236
|
+
if section_anchor_match and last_heading_index is not None:
|
|
237
|
+
target = result.sections[last_heading_index]
|
|
238
|
+
if target.ulid is None:
|
|
239
|
+
target.ulid = section_anchor_match.group("ulid")
|
|
240
|
+
target.parent_ulid = section_anchor_match.group("parent")
|
|
241
|
+
target.anchor_line = i
|
|
242
|
+
else:
|
|
243
|
+
result.warnings.append(
|
|
244
|
+
f"duplicate_section_anchor at line {i} for heading "
|
|
245
|
+
f"'{target.heading}' — first anchor preserved"
|
|
246
|
+
)
|
|
247
|
+
continue
|
|
248
|
+
|
|
249
|
+
# 5. Checkbox?
|
|
250
|
+
checkbox_match = RE_CHECKBOX.match(line)
|
|
251
|
+
if checkbox_match:
|
|
252
|
+
state_char = checkbox_match.group("state").lower()
|
|
253
|
+
rest = checkbox_match.group("rest")
|
|
254
|
+
|
|
255
|
+
# Checkpoint anchor is embedded inside `rest`, before the text.
|
|
256
|
+
cp_anchor_match = RE_CP_ANCHOR.search(rest)
|
|
257
|
+
if cp_anchor_match:
|
|
258
|
+
cp_ulid = cp_anchor_match.group("ulid")
|
|
259
|
+
cp_parent = cp_anchor_match.group("parent")
|
|
260
|
+
text = rest[cp_anchor_match.end():].strip()
|
|
261
|
+
else:
|
|
262
|
+
cp_ulid = None
|
|
263
|
+
cp_parent = None
|
|
264
|
+
text = rest.strip()
|
|
265
|
+
|
|
266
|
+
if last_heading_index is None:
|
|
267
|
+
result.warnings.append(
|
|
268
|
+
f"orphan_checkpoint at line {i}: no preceding heading"
|
|
269
|
+
)
|
|
270
|
+
# Emit it anyway so the caller can still fix the source.
|
|
271
|
+
parent_section_ulid = None
|
|
272
|
+
else:
|
|
273
|
+
parent_section_ulid = result.sections[last_heading_index].ulid
|
|
274
|
+
# parent_section_ulid may still be None if the section has
|
|
275
|
+
# no anchor yet; the post-pass / writer will fill it in.
|
|
276
|
+
|
|
277
|
+
# Cross-check: if the anchor embeds a parent that disagrees with
|
|
278
|
+
# the structural parent, warn (possible cross-plan copy).
|
|
279
|
+
if (
|
|
280
|
+
cp_parent is not None
|
|
281
|
+
and parent_section_ulid is not None
|
|
282
|
+
and cp_parent != parent_section_ulid
|
|
283
|
+
):
|
|
284
|
+
result.warnings.append(
|
|
285
|
+
f"parent_mismatch at line {i}: checkpoint parent={cp_parent} "
|
|
286
|
+
f"but enclosing section={parent_section_ulid}"
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
result.checkpoints.append(ParsedCheckpoint(
|
|
290
|
+
text=text,
|
|
291
|
+
initial_state="done" if state_char == "x" else "pending",
|
|
292
|
+
sort_order=checkpoint_counter * 10,
|
|
293
|
+
ulid=cp_ulid,
|
|
294
|
+
parent_section_ulid=cp_parent or parent_section_ulid,
|
|
295
|
+
checkbox_line=i,
|
|
296
|
+
))
|
|
297
|
+
checkpoint_counter += 1
|
|
298
|
+
|
|
299
|
+
# Post-pass: fill `parent_ulid` on sections that have an anchored parent.
|
|
300
|
+
for idx, section in enumerate(result.sections):
|
|
301
|
+
parent_idx = section_parent_index.get(idx)
|
|
302
|
+
if parent_idx is not None:
|
|
303
|
+
parent_section = result.sections[parent_idx]
|
|
304
|
+
# Only set if the anchor itself didn't specify a different parent.
|
|
305
|
+
if section.parent_ulid is None:
|
|
306
|
+
section.parent_ulid = parent_section.ulid # may still be None
|
|
307
|
+
|
|
308
|
+
# Warnings for missing anchors
|
|
309
|
+
if result.plan_ulid is None:
|
|
310
|
+
result.warnings.append("plan_anchor_missing")
|
|
311
|
+
|
|
312
|
+
return result
|
dct/core/projects.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Project CRUD operations."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
|
|
6
|
+
import sqlalchemy as sa
|
|
7
|
+
|
|
8
|
+
from dct.db import projects, row_to_dict
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def create_project(
|
|
12
|
+
engine: sa.Engine,
|
|
13
|
+
slug: str,
|
|
14
|
+
name: str,
|
|
15
|
+
path: str | None = None,
|
|
16
|
+
description: str | None = None,
|
|
17
|
+
current_version: str | None = None,
|
|
18
|
+
) -> dict:
|
|
19
|
+
"""Create (or undelete) a project row.
|
|
20
|
+
|
|
21
|
+
When `current_version` is not supplied and `path` is set, attempts best-effort
|
|
22
|
+
version detection from VERSION / pyproject.toml / package.json / Cargo.toml /
|
|
23
|
+
src-tauri/tauri.conf.json. The returned dict carries a transient
|
|
24
|
+
`_detected_source` key naming the file that produced the version — callers
|
|
25
|
+
(CLI) may surface it to the user; it is not persisted.
|
|
26
|
+
"""
|
|
27
|
+
if not slug or not slug.strip():
|
|
28
|
+
raise ValueError("Project slug cannot be empty")
|
|
29
|
+
if not name or not name.strip():
|
|
30
|
+
raise ValueError("Project name cannot be empty")
|
|
31
|
+
|
|
32
|
+
# #227: store the canonical (symlink-resolved) path so CWD detection — which
|
|
33
|
+
# passes os.getcwd() (physical) — always matches what we persisted here.
|
|
34
|
+
if path is not None:
|
|
35
|
+
path = os.path.realpath(path)
|
|
36
|
+
|
|
37
|
+
detected_source: str | None = None
|
|
38
|
+
if current_version is None and path:
|
|
39
|
+
from dct.core.version import detect_version
|
|
40
|
+
detected_version, detected_source = detect_version(path)
|
|
41
|
+
if detected_version:
|
|
42
|
+
current_version = detected_version
|
|
43
|
+
else:
|
|
44
|
+
detected_source = None # no detection → nothing to report
|
|
45
|
+
|
|
46
|
+
with engine.begin() as conn:
|
|
47
|
+
existing = conn.execute(
|
|
48
|
+
sa.select(projects).where(projects.c.slug == slug, projects.c.deleted_at.is_(None))
|
|
49
|
+
).first()
|
|
50
|
+
if existing:
|
|
51
|
+
raise ValueError(f"Project '{slug}' already exists")
|
|
52
|
+
|
|
53
|
+
deleted = conn.execute(
|
|
54
|
+
sa.select(projects).where(projects.c.slug == slug, projects.c.deleted_at.isnot(None))
|
|
55
|
+
).first()
|
|
56
|
+
if deleted:
|
|
57
|
+
conn.execute(
|
|
58
|
+
projects.update()
|
|
59
|
+
.where(projects.c.id == deleted._mapping["id"])
|
|
60
|
+
.values(name=name, path=path, description=description, current_version=current_version, deleted_at=None, updated_at=datetime.now(timezone.utc))
|
|
61
|
+
)
|
|
62
|
+
row = conn.execute(sa.select(projects).where(projects.c.id == deleted._mapping["id"])).first()
|
|
63
|
+
assert row is not None, "undelete must return its row"
|
|
64
|
+
result_dict = row_to_dict(row)
|
|
65
|
+
if detected_source:
|
|
66
|
+
result_dict["_detected_source"] = detected_source
|
|
67
|
+
return result_dict
|
|
68
|
+
|
|
69
|
+
inserted = conn.execute(
|
|
70
|
+
projects.insert()
|
|
71
|
+
.values(slug=slug, name=name, path=path, description=description, current_version=current_version)
|
|
72
|
+
.returning(projects)
|
|
73
|
+
).first()
|
|
74
|
+
assert inserted is not None, "INSERT RETURNING must return a row"
|
|
75
|
+
result_dict = row_to_dict(inserted)
|
|
76
|
+
if detected_source:
|
|
77
|
+
result_dict["_detected_source"] = detected_source
|
|
78
|
+
return result_dict
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def list_projects(engine: sa.Engine, include_deleted: bool = False) -> list[dict]:
|
|
82
|
+
with engine.begin() as conn:
|
|
83
|
+
q = sa.select(projects).order_by(projects.c.slug)
|
|
84
|
+
if not include_deleted:
|
|
85
|
+
q = q.where(projects.c.deleted_at.is_(None))
|
|
86
|
+
return [row_to_dict(r) for r in conn.execute(q)]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def get_project(engine: sa.Engine, slug: str) -> dict | None:
|
|
90
|
+
with engine.begin() as conn:
|
|
91
|
+
row = conn.execute(
|
|
92
|
+
sa.select(projects).where(projects.c.slug == slug, projects.c.deleted_at.is_(None))
|
|
93
|
+
).first()
|
|
94
|
+
return row_to_dict(row) if row else None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_project_by_path(engine: sa.Engine, path: str) -> dict | None:
|
|
98
|
+
"""Find the project whose registered path is a prefix of `path`.
|
|
99
|
+
|
|
100
|
+
Both the query path and every stored path are canonicalized with
|
|
101
|
+
os.path.realpath before comparison (#227). Rationale: `dct project add`
|
|
102
|
+
historically stored os.path.abspath (symlinks intact) while
|
|
103
|
+
`dct project detect` passes os.getcwd(), which returns the PHYSICAL path
|
|
104
|
+
(symlinks resolved) on macOS. A raw-string prefix compare therefore made a
|
|
105
|
+
project registered under a symlinked path (e.g. /tmp → /private/tmp, or a
|
|
106
|
+
repo under a symlinked root) silently undetectable, which turned the
|
|
107
|
+
changelog hook OFF for that repo. realpath on both sides closes the gap and
|
|
108
|
+
is idempotent for already-canonical paths.
|
|
109
|
+
"""
|
|
110
|
+
target = os.path.realpath(path)
|
|
111
|
+
with engine.begin() as conn:
|
|
112
|
+
rows = conn.execute(
|
|
113
|
+
sa.select(projects).where(projects.c.deleted_at.is_(None), projects.c.path.isnot(None))
|
|
114
|
+
).fetchall()
|
|
115
|
+
# Match longest stored path first (most specific project).
|
|
116
|
+
matches = []
|
|
117
|
+
for r in rows:
|
|
118
|
+
stored = os.path.realpath(r._mapping["path"])
|
|
119
|
+
if target == stored or target.startswith(stored.rstrip("/") + "/"):
|
|
120
|
+
matches.append((r, len(stored)))
|
|
121
|
+
if not matches:
|
|
122
|
+
return None
|
|
123
|
+
matches.sort(key=lambda x: x[1], reverse=True)
|
|
124
|
+
return row_to_dict(matches[0][0])
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def delete_project(engine: sa.Engine, slug: str) -> bool:
|
|
128
|
+
with engine.begin() as conn:
|
|
129
|
+
result = conn.execute(
|
|
130
|
+
projects.update()
|
|
131
|
+
.where(projects.c.slug == slug, projects.c.deleted_at.is_(None))
|
|
132
|
+
.values(deleted_at=datetime.now(timezone.utc))
|
|
133
|
+
)
|
|
134
|
+
return result.rowcount > 0
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def set_version(engine: sa.Engine, slug: str, version: str) -> dict | None:
|
|
138
|
+
with engine.begin() as conn:
|
|
139
|
+
result = conn.execute(
|
|
140
|
+
projects.update()
|
|
141
|
+
.where(projects.c.slug == slug, projects.c.deleted_at.is_(None))
|
|
142
|
+
.values(current_version=version, updated_at=datetime.now(timezone.utc))
|
|
143
|
+
)
|
|
144
|
+
if result.rowcount == 0:
|
|
145
|
+
return None
|
|
146
|
+
return get_project(engine, slug)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def set_manages_changelog(engine: sa.Engine, slug: str, value: bool) -> dict | None:
|
|
150
|
+
"""Opt a project in/out of dct changelog-hook enforcement (#222).
|
|
151
|
+
|
|
152
|
+
Returns the updated project row, or None when the slug does not resolve to
|
|
153
|
+
a live project.
|
|
154
|
+
"""
|
|
155
|
+
with engine.begin() as conn:
|
|
156
|
+
result = conn.execute(
|
|
157
|
+
projects.update()
|
|
158
|
+
.where(projects.c.slug == slug, projects.c.deleted_at.is_(None))
|
|
159
|
+
.values(manages_changelog=value, updated_at=datetime.now(timezone.utc))
|
|
160
|
+
)
|
|
161
|
+
if result.rowcount == 0:
|
|
162
|
+
return None
|
|
163
|
+
return get_project(engine, slug)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def project_manages_changelog(engine: sa.Engine, slug: str) -> bool:
|
|
167
|
+
"""True iff the project opts into dct changelog enforcement (#222).
|
|
168
|
+
|
|
169
|
+
False when the project does not exist — the changelog hook treats an
|
|
170
|
+
unknown/unresolved project as 'not managed' (skip), so it never blocks.
|
|
171
|
+
"""
|
|
172
|
+
with engine.begin() as conn:
|
|
173
|
+
row = conn.execute(
|
|
174
|
+
sa.select(projects.c.manages_changelog).where(
|
|
175
|
+
projects.c.slug == slug, projects.c.deleted_at.is_(None)
|
|
176
|
+
)
|
|
177
|
+
).first()
|
|
178
|
+
return bool(row._mapping["manages_changelog"]) if row else False
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def set_changelog_path(engine: sa.Engine, slug: str, relpath: str) -> dict | None:
|
|
182
|
+
"""Set a project's changelog file location, relative to its root (#225).
|
|
183
|
+
|
|
184
|
+
Default is 'changelog.md' (repo root). A project whose changelog lives in a
|
|
185
|
+
subdirectory (e.g. a monorepo package) points it there, and /release writes
|
|
186
|
+
{project.path}/{changelog_path}. The value is normalized and constrained to
|
|
187
|
+
the project root — a leading '/' is stripped and any '..' escape is rejected
|
|
188
|
+
— so /release can only ever write inside the project tree.
|
|
189
|
+
|
|
190
|
+
Returns the updated project row, or None when the slug does not resolve to a
|
|
191
|
+
live project. Raises ValueError on an empty path or a '..' escape.
|
|
192
|
+
"""
|
|
193
|
+
if relpath is None or not relpath.strip():
|
|
194
|
+
raise ValueError("changelog_path cannot be empty")
|
|
195
|
+
rel = os.path.normpath(relpath.strip().lstrip("/"))
|
|
196
|
+
if rel.startswith("..") or os.path.isabs(rel):
|
|
197
|
+
raise ValueError("changelog_path must stay within the project root (no '..' escape)")
|
|
198
|
+
with engine.begin() as conn:
|
|
199
|
+
result = conn.execute(
|
|
200
|
+
projects.update()
|
|
201
|
+
.where(projects.c.slug == slug, projects.c.deleted_at.is_(None))
|
|
202
|
+
.values(changelog_path=rel, updated_at=datetime.now(timezone.utc))
|
|
203
|
+
)
|
|
204
|
+
if result.rowcount == 0:
|
|
205
|
+
return None
|
|
206
|
+
return get_project(engine, slug)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def resolve_changelog_path(project: dict) -> str | None:
|
|
210
|
+
"""Absolute path to a project's changelog file: {path}/{changelog_path} (#225).
|
|
211
|
+
|
|
212
|
+
Returns None when the project has no registered path (nothing to resolve
|
|
213
|
+
against). changelog_path falls back to 'changelog.md' if missing/empty so a
|
|
214
|
+
row predating the column still resolves to the repo-root default.
|
|
215
|
+
"""
|
|
216
|
+
path = project.get("path")
|
|
217
|
+
if not path:
|
|
218
|
+
return None
|
|
219
|
+
return os.path.join(path, project.get("changelog_path") or "changelog.md")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def update_project(
|
|
223
|
+
engine: sa.Engine,
|
|
224
|
+
slug: str,
|
|
225
|
+
new_slug: str | None = None,
|
|
226
|
+
new_name: str | None = None,
|
|
227
|
+
new_description: str | None = None,
|
|
228
|
+
new_path: str | None = None,
|
|
229
|
+
) -> dict | None:
|
|
230
|
+
"""Update one or more mutable fields on a project row.
|
|
231
|
+
|
|
232
|
+
Any non-None kwarg overwrites. Returns the updated row, or None if the
|
|
233
|
+
source `slug` does not resolve to a live project. Raises ValueError when:
|
|
234
|
+
- no fields are supplied (nothing to do — caller bug)
|
|
235
|
+
- `new_slug` or `new_name` is an empty/whitespace string
|
|
236
|
+
- `new_slug` collides with an existing live project
|
|
237
|
+
|
|
238
|
+
project_id stays stable: items/changelog/checkpoints/handoffs keep their
|
|
239
|
+
FK without any cascade needed — rename is purely cosmetic at the DB level.
|
|
240
|
+
"""
|
|
241
|
+
updates: dict = {}
|
|
242
|
+
if new_slug is not None:
|
|
243
|
+
if not new_slug.strip():
|
|
244
|
+
raise ValueError("new_slug cannot be empty")
|
|
245
|
+
updates["slug"] = new_slug
|
|
246
|
+
if new_name is not None:
|
|
247
|
+
if not new_name.strip():
|
|
248
|
+
raise ValueError("new_name cannot be empty")
|
|
249
|
+
updates["name"] = new_name
|
|
250
|
+
if new_description is not None:
|
|
251
|
+
updates["description"] = new_description
|
|
252
|
+
if new_path is not None:
|
|
253
|
+
updates["path"] = new_path
|
|
254
|
+
|
|
255
|
+
if not updates:
|
|
256
|
+
raise ValueError("update_project: no fields supplied (pass at least one new_* kwarg)")
|
|
257
|
+
|
|
258
|
+
updates["updated_at"] = datetime.now(timezone.utc)
|
|
259
|
+
|
|
260
|
+
with engine.begin() as conn:
|
|
261
|
+
current = conn.execute(
|
|
262
|
+
sa.select(projects).where(projects.c.slug == slug, projects.c.deleted_at.is_(None))
|
|
263
|
+
).first()
|
|
264
|
+
if not current:
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
if new_slug is not None and new_slug != slug:
|
|
268
|
+
collision = conn.execute(
|
|
269
|
+
sa.select(projects.c.id).where(
|
|
270
|
+
projects.c.slug == new_slug, projects.c.deleted_at.is_(None)
|
|
271
|
+
)
|
|
272
|
+
).first()
|
|
273
|
+
if collision:
|
|
274
|
+
raise ValueError(f"Project slug '{new_slug}' already exists")
|
|
275
|
+
|
|
276
|
+
conn.execute(
|
|
277
|
+
projects.update()
|
|
278
|
+
.where(projects.c.id == current._mapping["id"])
|
|
279
|
+
.values(**updates)
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
return get_project(engine, new_slug or slug)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def resolve_project_id(conn: sa.Connection, slug: str) -> int:
|
|
286
|
+
"""Get project ID by slug. Raises ValueError if not found.
|
|
287
|
+
|
|
288
|
+
Shared helper used by items / changelog / sprint / handoff core modules
|
|
289
|
+
inside an already-open connection, so they avoid opening nested engine
|
|
290
|
+
transactions. Public (no underscore) because its callers live outside
|
|
291
|
+
this module.
|
|
292
|
+
"""
|
|
293
|
+
row = conn.execute(
|
|
294
|
+
sa.select(projects.c.id).where(projects.c.slug == slug, projects.c.deleted_at.is_(None))
|
|
295
|
+
).first()
|
|
296
|
+
if not row:
|
|
297
|
+
raise ValueError(f"Project '{slug}' not found")
|
|
298
|
+
return row._mapping["id"]
|