dctracker 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. dct/__init__.py +6 -0
  2. dct/cli.py +1659 -0
  3. dct/config.py +150 -0
  4. dct/core/__init__.py +0 -0
  5. dct/core/analytics.py +382 -0
  6. dct/core/changelog.py +112 -0
  7. dct/core/checkpoints.py +205 -0
  8. dct/core/decisions.py +238 -0
  9. dct/core/engine.py +32 -0
  10. dct/core/handoff.py +151 -0
  11. dct/core/ids.py +25 -0
  12. dct/core/items.py +382 -0
  13. dct/core/plans/__init__.py +6 -0
  14. dct/core/plans/classify.py +94 -0
  15. dct/core/plans/crud.py +680 -0
  16. dct/core/plans/ingest.py +408 -0
  17. dct/core/plans/parser.py +312 -0
  18. dct/core/projects.py +298 -0
  19. dct/core/sprint.py +315 -0
  20. dct/core/sprints.py +601 -0
  21. dct/core/version.py +116 -0
  22. dct/db.py +558 -0
  23. dct/export.py +107 -0
  24. dct/hooks/check-changelog-on-stop.sh +49 -0
  25. dct/hooks/check-changelog.sh +143 -0
  26. dct/hooks/dct-plan-autoscan.sh +54 -0
  27. dct/hooks/dct-task-mirror.sh +62 -0
  28. dct/server.py +1812 -0
  29. dct/setup.py +258 -0
  30. dct/skills/clog/SKILL.md +73 -0
  31. dct/skills/commit/SKILL.md +153 -0
  32. dct/skills/dct-import/SKILL.md +329 -0
  33. dct/skills/dct-init/SKILL.md +289 -0
  34. dct/skills/handoff/SKILL.md +136 -0
  35. dct/skills/pickup/SKILL.md +115 -0
  36. dct/skills/plan-resolve-uncertain/SKILL.md +82 -0
  37. dct/skills/plan-status/SKILL.md +79 -0
  38. dct/skills/release/SKILL.md +281 -0
  39. dct/skills/track/SKILL.md +121 -0
  40. dct/skills/track-list/SKILL.md +134 -0
  41. dct/skills/track-resolve/SKILL.md +53 -0
  42. dct/skills/track-update/SKILL.md +109 -0
  43. dct/web/__init__.py +1 -0
  44. dct/web/app.py +83 -0
  45. dct/web/blueprints/__init__.py +5 -0
  46. dct/web/blueprints/analytics.py +34 -0
  47. dct/web/blueprints/changelog.py +40 -0
  48. dct/web/blueprints/decisions.py +23 -0
  49. dct/web/blueprints/events.py +69 -0
  50. dct/web/blueprints/handoffs.py +26 -0
  51. dct/web/blueprints/home.py +15 -0
  52. dct/web/blueprints/items.py +128 -0
  53. dct/web/blueprints/plans.py +53 -0
  54. dct/web/blueprints/projects.py +23 -0
  55. dct/web/blueprints/sprints.py +71 -0
  56. dct/web/changes.py +77 -0
  57. dct/web/serializers.py +109 -0
  58. dct/web/static/app.css +609 -0
  59. dct/web/static/app.js +62 -0
  60. dct/web/static/vendor/SOURCES.txt +10 -0
  61. dct/web/static/vendor/htmx.min.js +1 -0
  62. dct/web/static/vendor/uPlot.iife.min.js +2 -0
  63. dct/web/static/vendor/uPlot.min.css +1 -0
  64. dct/web/templates/analytics.html +63 -0
  65. dct/web/templates/base.html +106 -0
  66. dct/web/templates/changelog/list.html +23 -0
  67. dct/web/templates/decisions/list.html +22 -0
  68. dct/web/templates/handoffs/list.html +45 -0
  69. dct/web/templates/home.html +20 -0
  70. dct/web/templates/item_detail.html +91 -0
  71. dct/web/templates/items_list.html +44 -0
  72. dct/web/templates/partials/_bars.html +15 -0
  73. dct/web/templates/partials/_checkpoint_steps.html +22 -0
  74. dct/web/templates/partials/_items_table.html +23 -0
  75. dct/web/templates/partials/_plan_section.html +24 -0
  76. dct/web/templates/partials/_project_card.html +24 -0
  77. dct/web/templates/plans/detail.html +16 -0
  78. dct/web/templates/plans/list.html +15 -0
  79. dct/web/templates/project_home.html +51 -0
  80. dct/web/templates/sprints/detail.html +37 -0
  81. dct/web/templates/sprints/list.html +35 -0
  82. dctracker-1.0.0.dist-info/METADATA +357 -0
  83. dctracker-1.0.0.dist-info/RECORD +87 -0
  84. dctracker-1.0.0.dist-info/WHEEL +5 -0
  85. dctracker-1.0.0.dist-info/entry_points.txt +2 -0
  86. dctracker-1.0.0.dist-info/licenses/LICENSE +21 -0
  87. dctracker-1.0.0.dist-info/top_level.txt +1 -0
dct/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}