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/sprints.py
ADDED
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
"""Sprint CRUD + item_sprints link management.
|
|
2
|
+
|
|
3
|
+
First-class sprint entity (v0.2.0, migration 003). Since #216 this is the
|
|
4
|
+
SINGLE source of truth for sprint membership: the legacy
|
|
5
|
+
`items.in_current_sprint` boolean has been dropped, and `in_current_sprint`
|
|
6
|
+
is DERIVED on read from item_sprints + the project's active sprint. The
|
|
7
|
+
"current sprint" is that single active sprint (≤1 active per project,
|
|
8
|
+
enforced here). `db._migrate_legacy_sprint_flag` one-shot-seeds a
|
|
9
|
+
'Pre-0.1 legacy sprint' from the old boolean before the column is dropped.
|
|
10
|
+
|
|
11
|
+
Terminology:
|
|
12
|
+
- **sprint**: a named time-boxed or scope-boxed unit of work. Subtypes
|
|
13
|
+
via `kind`: sprint | phase | milestone | block. The subtype is
|
|
14
|
+
informational; the lifecycle is identical (planned → active →
|
|
15
|
+
[deferred] → completed/abandoned, with completed/abandoned → active as a
|
|
16
|
+
reopen). `deferred` parks a started-but-paused sprint: it keeps its
|
|
17
|
+
`started_at`, frees the ≤1-active-per-project slot, and is filtered out of
|
|
18
|
+
the current-sprint views. Legal transitions are whitelisted in
|
|
19
|
+
`ALLOWED_TRANSITIONS`.
|
|
20
|
+
- **item_sprints**: many-to-many linkage between items and sprints.
|
|
21
|
+
`removed_at` is a soft-delete marker — a removed link preserves the
|
|
22
|
+
history of "this item was in this sprint from X to Y".
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from datetime import datetime, timezone
|
|
28
|
+
|
|
29
|
+
import sqlalchemy as sa
|
|
30
|
+
|
|
31
|
+
from dct.db import item_sprints, items, row_to_dict, sprints
|
|
32
|
+
from dct.core.projects import resolve_project_id
|
|
33
|
+
|
|
34
|
+
VALID_KINDS = {"sprint", "phase", "milestone", "block"}
|
|
35
|
+
VALID_STATUSES = {"planned", "active", "deferred", "completed", "abandoned"}
|
|
36
|
+
|
|
37
|
+
# Legal status transitions. A move not listed here is rejected by update_sprint.
|
|
38
|
+
# Rationale for the notable exclusions:
|
|
39
|
+
# - planned→deferred: a sprint that never started is already parked.
|
|
40
|
+
# - active/deferred→planned: demoting a started sprint is semantically wrong
|
|
41
|
+
# (started_at is already stamped) — the owner rule.
|
|
42
|
+
# - completed/abandoned→active is the only reopen edge (clears ended_at).
|
|
43
|
+
ALLOWED_TRANSITIONS = {
|
|
44
|
+
"planned": {"active", "abandoned"},
|
|
45
|
+
"active": {"deferred", "completed", "abandoned"},
|
|
46
|
+
"deferred": {"active", "completed", "abandoned"},
|
|
47
|
+
"completed": {"active"}, # reopen
|
|
48
|
+
"abandoned": {"active"}, # reopen
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _validate(value: str, valid: set[str], field: str) -> str:
|
|
53
|
+
if value not in valid:
|
|
54
|
+
raise ValueError(f"Invalid {field}: '{value}'. Valid: {', '.join(sorted(valid))}")
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _active_sprint_id(conn: sa.Connection, project_id: int, exclude_id: int | None = None) -> int | None:
|
|
59
|
+
"""Return the id of the project's active sprint (the 'current sprint'), or
|
|
60
|
+
None. `exclude_id` skips a given sprint so a sprint never blocks itself when
|
|
61
|
+
being updated. Underpins the ≤1-active-sprint-per-project invariant (#216)."""
|
|
62
|
+
q = sa.select(sprints.c.id).where(
|
|
63
|
+
sprints.c.project_id == project_id,
|
|
64
|
+
sprints.c.status == "active",
|
|
65
|
+
sprints.c.deleted_at.is_(None),
|
|
66
|
+
)
|
|
67
|
+
if exclude_id is not None:
|
|
68
|
+
q = q.where(sprints.c.id != exclude_id)
|
|
69
|
+
return conn.execute(q).scalar()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ── Sprint CRUD ───────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
def create_sprint(
|
|
75
|
+
engine: sa.Engine,
|
|
76
|
+
project_slug: str,
|
|
77
|
+
name: str,
|
|
78
|
+
code: str | None = None,
|
|
79
|
+
kind: str = "sprint",
|
|
80
|
+
status: str = "planned",
|
|
81
|
+
goal: str | None = None,
|
|
82
|
+
) -> dict:
|
|
83
|
+
"""Create a sprint under the given project.
|
|
84
|
+
|
|
85
|
+
`code` (optional) is a short handle — "0.5", "M0", "sprint-v0.1-ship".
|
|
86
|
+
If provided, uniqueness is enforced per project (partial-index in db.py).
|
|
87
|
+
"""
|
|
88
|
+
if not name or not name.strip():
|
|
89
|
+
raise ValueError("Sprint name cannot be empty")
|
|
90
|
+
_validate(kind, VALID_KINDS, "kind")
|
|
91
|
+
_validate(status, VALID_STATUSES, "status")
|
|
92
|
+
if status == "deferred":
|
|
93
|
+
raise ValueError(
|
|
94
|
+
"A sprint cannot be created with status 'deferred' (it has no "
|
|
95
|
+
"started_at yet); create it 'planned'/'active', then defer it."
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
with engine.begin() as conn:
|
|
99
|
+
project_id = resolve_project_id(conn, project_slug)
|
|
100
|
+
if code:
|
|
101
|
+
existing = conn.execute(
|
|
102
|
+
sa.select(sprints).where(
|
|
103
|
+
sprints.c.project_id == project_id,
|
|
104
|
+
sprints.c.code == code,
|
|
105
|
+
sprints.c.deleted_at.is_(None),
|
|
106
|
+
)
|
|
107
|
+
).first()
|
|
108
|
+
if existing:
|
|
109
|
+
raise ValueError(f"Sprint with code '{code}' already exists in project")
|
|
110
|
+
|
|
111
|
+
# ≤1 active sprint per project — the active one IS the "current sprint"
|
|
112
|
+
# that /pickup and /handoff operate on (#216).
|
|
113
|
+
if status == "active":
|
|
114
|
+
other = _active_sprint_id(conn, project_id)
|
|
115
|
+
if other is not None:
|
|
116
|
+
raise ValueError(
|
|
117
|
+
f"Project already has an active sprint (#{other}); complete or "
|
|
118
|
+
f"abandon it before activating another (≤1 active sprint per project)."
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
started_at = datetime.now(timezone.utc) if status == "active" else None
|
|
122
|
+
row = conn.execute(
|
|
123
|
+
sprints.insert()
|
|
124
|
+
.values(
|
|
125
|
+
project_id=project_id,
|
|
126
|
+
name=name.strip(),
|
|
127
|
+
code=code,
|
|
128
|
+
kind=kind,
|
|
129
|
+
status=status,
|
|
130
|
+
goal=goal,
|
|
131
|
+
started_at=started_at,
|
|
132
|
+
)
|
|
133
|
+
.returning(sprints)
|
|
134
|
+
).first()
|
|
135
|
+
assert row is not None, "INSERT RETURNING must return a row"
|
|
136
|
+
return row_to_dict(row)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def list_sprints(
|
|
140
|
+
engine: sa.Engine,
|
|
141
|
+
project_slug: str | None = None,
|
|
142
|
+
status: str | None = None,
|
|
143
|
+
kind: str | None = None,
|
|
144
|
+
include_deleted: bool = False,
|
|
145
|
+
) -> list[dict]:
|
|
146
|
+
with engine.begin() as conn:
|
|
147
|
+
q = sa.select(sprints).order_by(sprints.c.created_at.desc())
|
|
148
|
+
if project_slug:
|
|
149
|
+
pid = resolve_project_id(conn, project_slug)
|
|
150
|
+
q = q.where(sprints.c.project_id == pid)
|
|
151
|
+
if status:
|
|
152
|
+
q = q.where(sprints.c.status == status)
|
|
153
|
+
if kind:
|
|
154
|
+
q = q.where(sprints.c.kind == kind)
|
|
155
|
+
if not include_deleted:
|
|
156
|
+
q = q.where(sprints.c.deleted_at.is_(None))
|
|
157
|
+
return [row_to_dict(r) for r in conn.execute(q)]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def get_sprint(engine: sa.Engine, sprint_id: int) -> dict | None:
|
|
161
|
+
with engine.begin() as conn:
|
|
162
|
+
row = conn.execute(
|
|
163
|
+
sa.select(sprints).where(
|
|
164
|
+
sprints.c.id == sprint_id, sprints.c.deleted_at.is_(None)
|
|
165
|
+
)
|
|
166
|
+
).first()
|
|
167
|
+
return row_to_dict(row) if row else None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def get_current_sprint(engine: sa.Engine, project_slug: str) -> dict | None:
|
|
171
|
+
"""Return a project's single active sprint — the 'current sprint' that
|
|
172
|
+
/pickup and /handoff act on — or None if there is none.
|
|
173
|
+
|
|
174
|
+
Invariant: ≤1 active sprint per project (enforced by create_sprint /
|
|
175
|
+
update_sprint). The order_by is purely defensive: should two ever slip
|
|
176
|
+
through, the most recently started wins."""
|
|
177
|
+
with engine.begin() as conn:
|
|
178
|
+
pid = resolve_project_id(conn, project_slug)
|
|
179
|
+
row = conn.execute(
|
|
180
|
+
sa.select(sprints)
|
|
181
|
+
.where(
|
|
182
|
+
sprints.c.project_id == pid,
|
|
183
|
+
sprints.c.status == "active",
|
|
184
|
+
sprints.c.deleted_at.is_(None),
|
|
185
|
+
)
|
|
186
|
+
.order_by(sprints.c.started_at.desc())
|
|
187
|
+
).first()
|
|
188
|
+
return row_to_dict(row) if row else None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def get_sprint_by_code(engine: sa.Engine, project_slug: str, code: str) -> dict | None:
|
|
192
|
+
with engine.begin() as conn:
|
|
193
|
+
pid = resolve_project_id(conn, project_slug)
|
|
194
|
+
row = conn.execute(
|
|
195
|
+
sa.select(sprints).where(
|
|
196
|
+
sprints.c.project_id == pid,
|
|
197
|
+
sprints.c.code == code,
|
|
198
|
+
sprints.c.deleted_at.is_(None),
|
|
199
|
+
)
|
|
200
|
+
).first()
|
|
201
|
+
return row_to_dict(row) if row else None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def update_sprint(engine: sa.Engine, sprint_id: int, **kwargs) -> dict | None:
|
|
205
|
+
"""Update mutable sprint fields, enforcing the ALLOWED_TRANSITIONS whitelist.
|
|
206
|
+
|
|
207
|
+
Status semantics:
|
|
208
|
+
- a status equal to the current one is an idempotent no-op for the status
|
|
209
|
+
field (timestamps untouched; other supplied fields still applied);
|
|
210
|
+
- a transition not in ALLOWED_TRANSITIONS raises ValueError listing the
|
|
211
|
+
legal targets for the current status;
|
|
212
|
+
- activation (→active) is capped at ≤1 active sprint per project and
|
|
213
|
+
stamps started_at only when it was NULL (reactivation preserves it);
|
|
214
|
+
- reopen (completed/abandoned → active) clears the stale ended_at;
|
|
215
|
+
- completed/abandoned stamp ended_at;
|
|
216
|
+
- deferred touches neither started_at nor ended_at (parked, keeps both).
|
|
217
|
+
|
|
218
|
+
`code` gets the same per-project uniqueness pre-check as create_sprint —
|
|
219
|
+
a friendly ValueError instead of a raw IntegrityError. `name`, when
|
|
220
|
+
supplied, is stripped and rejected if blank (same rule as create_sprint) so
|
|
221
|
+
update can never store a name create_sprint would have refused.
|
|
222
|
+
|
|
223
|
+
Returns the updated sprint dict, or None when the id is unknown / deleted.
|
|
224
|
+
"""
|
|
225
|
+
allowed = {"name", "code", "kind", "status", "goal"}
|
|
226
|
+
values: dict = {}
|
|
227
|
+
for k, v in kwargs.items():
|
|
228
|
+
if k not in allowed or v is None:
|
|
229
|
+
continue
|
|
230
|
+
if k == "kind":
|
|
231
|
+
_validate(v, VALID_KINDS, "kind")
|
|
232
|
+
elif k == "status":
|
|
233
|
+
_validate(v, VALID_STATUSES, "status")
|
|
234
|
+
elif k == "name":
|
|
235
|
+
# Mirror create_sprint: reject blank names and strip padding so
|
|
236
|
+
# update can never produce a name create_sprint would have refused.
|
|
237
|
+
if not v.strip():
|
|
238
|
+
raise ValueError("Sprint name cannot be empty")
|
|
239
|
+
v = v.strip()
|
|
240
|
+
values[k] = v
|
|
241
|
+
if not values:
|
|
242
|
+
raise ValueError("No valid fields to update")
|
|
243
|
+
|
|
244
|
+
now = datetime.now(timezone.utc)
|
|
245
|
+
|
|
246
|
+
with engine.begin() as conn:
|
|
247
|
+
current = conn.execute(
|
|
248
|
+
sa.select(sprints).where(
|
|
249
|
+
sprints.c.id == sprint_id, sprints.c.deleted_at.is_(None)
|
|
250
|
+
)
|
|
251
|
+
).first()
|
|
252
|
+
if current is None:
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
project_id = current._mapping["project_id"]
|
|
256
|
+
cur_status = current._mapping["status"]
|
|
257
|
+
|
|
258
|
+
# Per-project `code` uniqueness pre-check (friendly ValueError instead of
|
|
259
|
+
# the raw IntegrityError the partial UNIQUE index would otherwise raise).
|
|
260
|
+
new_code = values.get("code")
|
|
261
|
+
if new_code:
|
|
262
|
+
dup = conn.execute(
|
|
263
|
+
sa.select(sprints.c.id).where(
|
|
264
|
+
sprints.c.project_id == project_id,
|
|
265
|
+
sprints.c.code == new_code,
|
|
266
|
+
sprints.c.id != sprint_id,
|
|
267
|
+
sprints.c.deleted_at.is_(None),
|
|
268
|
+
)
|
|
269
|
+
).first()
|
|
270
|
+
if dup is not None:
|
|
271
|
+
raise ValueError(
|
|
272
|
+
f"Sprint with code '{new_code}' already exists in project"
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
# Status transition validation + lifecycle timestamp maintenance.
|
|
276
|
+
new_status = values.get("status")
|
|
277
|
+
if new_status is not None and new_status == cur_status:
|
|
278
|
+
values.pop("status") # idempotent no-op for the status field
|
|
279
|
+
new_status = None
|
|
280
|
+
|
|
281
|
+
if new_status is not None:
|
|
282
|
+
legal = ALLOWED_TRANSITIONS.get(cur_status, set())
|
|
283
|
+
if new_status not in legal:
|
|
284
|
+
targets = ", ".join(sorted(legal)) or "(none)"
|
|
285
|
+
raise ValueError(
|
|
286
|
+
f"Illegal sprint status transition '{cur_status}' -> "
|
|
287
|
+
f"'{new_status}'. Allowed from '{cur_status}': {targets}."
|
|
288
|
+
)
|
|
289
|
+
if new_status == "active":
|
|
290
|
+
other = _active_sprint_id(conn, project_id, exclude_id=sprint_id)
|
|
291
|
+
if other is not None:
|
|
292
|
+
raise ValueError(
|
|
293
|
+
f"Project already has an active sprint (#{other}); complete or "
|
|
294
|
+
f"abandon it before activating another (≤1 active sprint per project)."
|
|
295
|
+
)
|
|
296
|
+
if current._mapping["started_at"] is None:
|
|
297
|
+
values["started_at"] = now # first time becoming active
|
|
298
|
+
if current._mapping["ended_at"] is not None:
|
|
299
|
+
values["ended_at"] = None # reopen — clear stale ended_at
|
|
300
|
+
elif new_status in ("completed", "abandoned"):
|
|
301
|
+
values["ended_at"] = now
|
|
302
|
+
# 'deferred' intentionally touches neither started_at nor ended_at.
|
|
303
|
+
|
|
304
|
+
if not values:
|
|
305
|
+
# Only a same->same status was requested — nothing to write, and
|
|
306
|
+
# timestamps must stay untouched.
|
|
307
|
+
return get_sprint(engine, sprint_id)
|
|
308
|
+
|
|
309
|
+
values["updated_at"] = now
|
|
310
|
+
conn.execute(
|
|
311
|
+
sprints.update()
|
|
312
|
+
.where(sprints.c.id == sprint_id, sprints.c.deleted_at.is_(None))
|
|
313
|
+
.values(**values)
|
|
314
|
+
)
|
|
315
|
+
return get_sprint(engine, sprint_id)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def delete_sprint(engine: sa.Engine, sprint_id: int) -> bool:
|
|
319
|
+
"""Soft-delete a sprint. Does NOT cascade to item_sprints — links remain
|
|
320
|
+
for historical query ('this item WAS in this sprint'). To hide those
|
|
321
|
+
links, call `remove_item_from_sprint` per item before deleting."""
|
|
322
|
+
now = datetime.now(timezone.utc)
|
|
323
|
+
with engine.begin() as conn:
|
|
324
|
+
result = conn.execute(
|
|
325
|
+
sprints.update()
|
|
326
|
+
.where(sprints.c.id == sprint_id, sprints.c.deleted_at.is_(None))
|
|
327
|
+
.values(deleted_at=now)
|
|
328
|
+
)
|
|
329
|
+
return result.rowcount > 0
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# ── item_sprints link management ─────────────────────────────────────────────
|
|
333
|
+
|
|
334
|
+
def assign_item_to_sprint(engine: sa.Engine, item_id: int, sprint_id: int) -> dict:
|
|
335
|
+
"""Create (or reactivate) a link from item to sprint.
|
|
336
|
+
|
|
337
|
+
Idempotent: if the link already exists and is active (removed_at IS NULL),
|
|
338
|
+
returns it unchanged. If it exists but was removed, clears `removed_at`
|
|
339
|
+
to reactivate while preserving `assigned_at` history via the row itself.
|
|
340
|
+
"""
|
|
341
|
+
now = datetime.now(timezone.utc)
|
|
342
|
+
with engine.begin() as conn:
|
|
343
|
+
# Verify both rows exist and aren't soft-deleted.
|
|
344
|
+
item_row = conn.execute(
|
|
345
|
+
sa.select(items.c.id).where(
|
|
346
|
+
items.c.id == item_id, items.c.deleted_at.is_(None)
|
|
347
|
+
)
|
|
348
|
+
).first()
|
|
349
|
+
if not item_row:
|
|
350
|
+
raise ValueError(f"Item #{item_id} not found")
|
|
351
|
+
|
|
352
|
+
sprint_row = conn.execute(
|
|
353
|
+
sa.select(sprints.c.id).where(
|
|
354
|
+
sprints.c.id == sprint_id, sprints.c.deleted_at.is_(None)
|
|
355
|
+
)
|
|
356
|
+
).first()
|
|
357
|
+
if not sprint_row:
|
|
358
|
+
raise ValueError(f"Sprint #{sprint_id} not found")
|
|
359
|
+
|
|
360
|
+
existing = conn.execute(
|
|
361
|
+
sa.select(item_sprints).where(
|
|
362
|
+
item_sprints.c.item_id == item_id,
|
|
363
|
+
item_sprints.c.sprint_id == sprint_id,
|
|
364
|
+
)
|
|
365
|
+
).first()
|
|
366
|
+
if existing:
|
|
367
|
+
if existing._mapping["removed_at"] is not None:
|
|
368
|
+
conn.execute(
|
|
369
|
+
item_sprints.update()
|
|
370
|
+
.where(
|
|
371
|
+
item_sprints.c.item_id == item_id,
|
|
372
|
+
item_sprints.c.sprint_id == sprint_id,
|
|
373
|
+
)
|
|
374
|
+
.values(removed_at=None, assigned_at=now)
|
|
375
|
+
)
|
|
376
|
+
link_row = conn.execute(
|
|
377
|
+
sa.select(item_sprints).where(
|
|
378
|
+
item_sprints.c.item_id == item_id,
|
|
379
|
+
item_sprints.c.sprint_id == sprint_id,
|
|
380
|
+
)
|
|
381
|
+
).first()
|
|
382
|
+
assert link_row is not None
|
|
383
|
+
return row_to_dict(link_row)
|
|
384
|
+
|
|
385
|
+
conn.execute(
|
|
386
|
+
item_sprints.insert().values(
|
|
387
|
+
item_id=item_id, sprint_id=sprint_id, assigned_at=now
|
|
388
|
+
)
|
|
389
|
+
)
|
|
390
|
+
link_row = conn.execute(
|
|
391
|
+
sa.select(item_sprints).where(
|
|
392
|
+
item_sprints.c.item_id == item_id,
|
|
393
|
+
item_sprints.c.sprint_id == sprint_id,
|
|
394
|
+
)
|
|
395
|
+
).first()
|
|
396
|
+
assert link_row is not None
|
|
397
|
+
return row_to_dict(link_row)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def remove_item_from_sprint(
|
|
401
|
+
engine: sa.Engine, item_id: int, sprint_id: int
|
|
402
|
+
) -> dict | None:
|
|
403
|
+
"""Soft-remove the link — sets removed_at. Returns the row, or None
|
|
404
|
+
if the link never existed or was already removed."""
|
|
405
|
+
now = datetime.now(timezone.utc)
|
|
406
|
+
with engine.begin() as conn:
|
|
407
|
+
result = conn.execute(
|
|
408
|
+
item_sprints.update()
|
|
409
|
+
.where(
|
|
410
|
+
item_sprints.c.item_id == item_id,
|
|
411
|
+
item_sprints.c.sprint_id == sprint_id,
|
|
412
|
+
item_sprints.c.removed_at.is_(None),
|
|
413
|
+
)
|
|
414
|
+
.values(removed_at=now)
|
|
415
|
+
)
|
|
416
|
+
if result.rowcount == 0:
|
|
417
|
+
return None
|
|
418
|
+
link_row = conn.execute(
|
|
419
|
+
sa.select(item_sprints).where(
|
|
420
|
+
item_sprints.c.item_id == item_id,
|
|
421
|
+
item_sprints.c.sprint_id == sprint_id,
|
|
422
|
+
)
|
|
423
|
+
).first()
|
|
424
|
+
assert link_row is not None
|
|
425
|
+
return row_to_dict(link_row)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def list_items_in_sprint(
|
|
429
|
+
engine: sa.Engine, sprint_id: int, include_removed: bool = False
|
|
430
|
+
) -> list[dict]:
|
|
431
|
+
"""Return item rows currently (or historically) linked to a sprint."""
|
|
432
|
+
with engine.begin() as conn:
|
|
433
|
+
q = (
|
|
434
|
+
sa.select(items)
|
|
435
|
+
.join(item_sprints, items.c.id == item_sprints.c.item_id)
|
|
436
|
+
.where(item_sprints.c.sprint_id == sprint_id, items.c.deleted_at.is_(None))
|
|
437
|
+
.order_by(item_sprints.c.assigned_at.desc())
|
|
438
|
+
)
|
|
439
|
+
if not include_removed:
|
|
440
|
+
q = q.where(item_sprints.c.removed_at.is_(None))
|
|
441
|
+
return [row_to_dict(r) for r in conn.execute(q)]
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def list_sprints_for_item(
|
|
445
|
+
engine: sa.Engine, item_id: int, include_removed: bool = False
|
|
446
|
+
) -> list[dict]:
|
|
447
|
+
"""Return sprint rows an item is (or was) linked to."""
|
|
448
|
+
with engine.begin() as conn:
|
|
449
|
+
q = (
|
|
450
|
+
sa.select(sprints)
|
|
451
|
+
.join(item_sprints, sprints.c.id == item_sprints.c.sprint_id)
|
|
452
|
+
.where(
|
|
453
|
+
item_sprints.c.item_id == item_id, sprints.c.deleted_at.is_(None)
|
|
454
|
+
)
|
|
455
|
+
.order_by(item_sprints.c.assigned_at.desc())
|
|
456
|
+
)
|
|
457
|
+
if not include_removed:
|
|
458
|
+
q = q.where(item_sprints.c.removed_at.is_(None))
|
|
459
|
+
return [row_to_dict(r) for r in conn.execute(q)]
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
# ── current-sprint operations (the /handoff + /pickup surface) ─────────────────
|
|
463
|
+
# These replace the v0.1 boolean set_sprint/clear_sprint. "Current sprint" = the
|
|
464
|
+
# project's single active sprint (≤1 invariant); membership lives in item_sprints
|
|
465
|
+
# so there is exactly ONE source of truth (#216).
|
|
466
|
+
|
|
467
|
+
def ensure_current_sprint(
|
|
468
|
+
engine: sa.Engine, project_slug: str, name: str | None = None
|
|
469
|
+
) -> dict:
|
|
470
|
+
"""Return the project's current (active) sprint, creating+activating one if
|
|
471
|
+
none exists. Idempotent: a second call returns the same sprint."""
|
|
472
|
+
existing = get_current_sprint(engine, project_slug)
|
|
473
|
+
if existing:
|
|
474
|
+
return existing
|
|
475
|
+
return create_sprint(engine, project_slug, name or "Current sprint", status="active")
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def set_current_sprint_items(
|
|
479
|
+
engine: sa.Engine, project_slug: str, item_ids: list[int]
|
|
480
|
+
) -> dict:
|
|
481
|
+
"""Replace the current sprint's membership with exactly `item_ids`.
|
|
482
|
+
|
|
483
|
+
Auto-creates the current sprint if none exists. Foreign / deleted item ids
|
|
484
|
+
are silently ignored (only items belonging to this project are linked). The
|
|
485
|
+
membership swap is atomic — the sprint is never left half-set.
|
|
486
|
+
"""
|
|
487
|
+
sprint = ensure_current_sprint(engine, project_slug)
|
|
488
|
+
sprint_id = sprint["id"]
|
|
489
|
+
now = datetime.now(timezone.utc)
|
|
490
|
+
|
|
491
|
+
with engine.begin() as conn:
|
|
492
|
+
project_id = resolve_project_id(conn, project_slug)
|
|
493
|
+
valid = (
|
|
494
|
+
set(
|
|
495
|
+
conn.execute(
|
|
496
|
+
sa.select(items.c.id).where(
|
|
497
|
+
items.c.project_id == project_id,
|
|
498
|
+
items.c.id.in_(list(item_ids)),
|
|
499
|
+
items.c.deleted_at.is_(None),
|
|
500
|
+
)
|
|
501
|
+
).scalars()
|
|
502
|
+
)
|
|
503
|
+
if item_ids
|
|
504
|
+
else set()
|
|
505
|
+
)
|
|
506
|
+
current_members = set(
|
|
507
|
+
conn.execute(
|
|
508
|
+
sa.select(item_sprints.c.item_id).where(
|
|
509
|
+
item_sprints.c.sprint_id == sprint_id,
|
|
510
|
+
item_sprints.c.removed_at.is_(None),
|
|
511
|
+
)
|
|
512
|
+
).scalars()
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
# Add — create-or-reactivate by full PK (mirrors assign_item_to_sprint;
|
|
516
|
+
# never collide with a soft-removed row — the #215 lesson).
|
|
517
|
+
for iid in valid - current_members:
|
|
518
|
+
has_row = conn.execute(
|
|
519
|
+
sa.select(item_sprints.c.item_id).where(
|
|
520
|
+
item_sprints.c.item_id == iid,
|
|
521
|
+
item_sprints.c.sprint_id == sprint_id,
|
|
522
|
+
)
|
|
523
|
+
).first()
|
|
524
|
+
if has_row:
|
|
525
|
+
conn.execute(
|
|
526
|
+
item_sprints.update()
|
|
527
|
+
.where(
|
|
528
|
+
item_sprints.c.item_id == iid,
|
|
529
|
+
item_sprints.c.sprint_id == sprint_id,
|
|
530
|
+
)
|
|
531
|
+
.values(removed_at=None, assigned_at=now)
|
|
532
|
+
)
|
|
533
|
+
else:
|
|
534
|
+
conn.execute(
|
|
535
|
+
item_sprints.insert().values(
|
|
536
|
+
item_id=iid, sprint_id=sprint_id, assigned_at=now
|
|
537
|
+
)
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
# Remove — soft-remove dropped members.
|
|
541
|
+
for iid in current_members - valid:
|
|
542
|
+
conn.execute(
|
|
543
|
+
item_sprints.update()
|
|
544
|
+
.where(
|
|
545
|
+
item_sprints.c.item_id == iid,
|
|
546
|
+
item_sprints.c.sprint_id == sprint_id,
|
|
547
|
+
item_sprints.c.removed_at.is_(None),
|
|
548
|
+
)
|
|
549
|
+
.values(removed_at=now)
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
return {
|
|
553
|
+
"project": project_slug,
|
|
554
|
+
"size": len(valid),
|
|
555
|
+
"items": sorted(valid),
|
|
556
|
+
"sprint_id": sprint_id,
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def clear_current_sprint(engine: sa.Engine, project_slug: str) -> dict:
|
|
561
|
+
"""Soft-remove every member of the current sprint. The sprint itself stays
|
|
562
|
+
active (empty), ready for fresh planning. No-op if there is no current
|
|
563
|
+
sprint."""
|
|
564
|
+
sprint = get_current_sprint(engine, project_slug)
|
|
565
|
+
if not sprint:
|
|
566
|
+
return {"project": project_slug, "cleared": 0}
|
|
567
|
+
now = datetime.now(timezone.utc)
|
|
568
|
+
with engine.begin() as conn:
|
|
569
|
+
result = conn.execute(
|
|
570
|
+
item_sprints.update()
|
|
571
|
+
.where(
|
|
572
|
+
item_sprints.c.sprint_id == sprint["id"],
|
|
573
|
+
item_sprints.c.removed_at.is_(None),
|
|
574
|
+
)
|
|
575
|
+
.values(removed_at=now)
|
|
576
|
+
)
|
|
577
|
+
return {"project": project_slug, "cleared": result.rowcount}
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def add_item_to_current_sprint(engine: sa.Engine, project_slug: str, item_id: int) -> dict:
|
|
581
|
+
"""Add ONE item to the project's current sprint (auto-creating it). Additive
|
|
582
|
+
— unlike set_current_sprint_items it does not touch other members. Idempotent
|
|
583
|
+
(create-or-reactivate). Returns {project, sprint_id, item_id, active}."""
|
|
584
|
+
sprint = ensure_current_sprint(engine, project_slug)
|
|
585
|
+
link = assign_item_to_sprint(engine, item_id, sprint["id"])
|
|
586
|
+
return {
|
|
587
|
+
"project": project_slug,
|
|
588
|
+
"sprint_id": sprint["id"],
|
|
589
|
+
"item_id": item_id,
|
|
590
|
+
"active": link["removed_at"] is None,
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def remove_item_from_current_sprint(engine: sa.Engine, project_slug: str, item_id: int) -> dict:
|
|
595
|
+
"""Remove ONE item from the project's current sprint. No-op (removed=False)
|
|
596
|
+
if there is no current sprint or the item is not an active member."""
|
|
597
|
+
sprint = get_current_sprint(engine, project_slug)
|
|
598
|
+
if not sprint:
|
|
599
|
+
return {"project": project_slug, "removed": False}
|
|
600
|
+
res = remove_item_from_sprint(engine, item_id, sprint["id"])
|
|
601
|
+
return {"project": project_slug, "removed": res is not None}
|