@1mancompany/onemancompany 0.7.27 → 0.7.31
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.
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -562,6 +562,135 @@ async def manage_review_tool(
|
|
|
562
562
|
return f"Error: {e}"
|
|
563
563
|
|
|
564
564
|
|
|
565
|
+
# ---------------------------------------------------------------------------
|
|
566
|
+
# B2: Missing CRUD + analytics tools
|
|
567
|
+
# ---------------------------------------------------------------------------
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
@tool
|
|
571
|
+
def delete_issue_tool(product_slug: str, issue_id: str) -> str:
|
|
572
|
+
"""Delete an issue and clean up all links referencing it.
|
|
573
|
+
|
|
574
|
+
Args:
|
|
575
|
+
product_slug: The product slug
|
|
576
|
+
issue_id: The issue ID to delete
|
|
577
|
+
"""
|
|
578
|
+
try:
|
|
579
|
+
prod.delete_issue(product_slug, issue_id)
|
|
580
|
+
return f"Deleted issue {issue_id} from {product_slug}"
|
|
581
|
+
except (ValueError, FileNotFoundError) as e:
|
|
582
|
+
return f"Error: {e}"
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
@tool
|
|
586
|
+
def reopen_issue_tool(product_slug: str, issue_id: str) -> str:
|
|
587
|
+
"""Reopen a closed issue (moves it back to backlog).
|
|
588
|
+
|
|
589
|
+
Args:
|
|
590
|
+
product_slug: The product slug
|
|
591
|
+
issue_id: The issue ID to reopen
|
|
592
|
+
"""
|
|
593
|
+
try:
|
|
594
|
+
issue = prod.reopen_issue(product_slug, issue_id)
|
|
595
|
+
return f"Reopened issue {issue_id}: status={issue['status']}"
|
|
596
|
+
except (ValueError, FileNotFoundError) as e:
|
|
597
|
+
return f"Error: {e}"
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
@tool
|
|
601
|
+
def start_sprint_tool(product_slug: str, sprint_id: str) -> str:
|
|
602
|
+
"""Start a sprint (set it to active). Only one sprint can be active at a time.
|
|
603
|
+
|
|
604
|
+
Args:
|
|
605
|
+
product_slug: The product slug
|
|
606
|
+
sprint_id: The sprint ID to start
|
|
607
|
+
"""
|
|
608
|
+
try:
|
|
609
|
+
sprint = prod.start_sprint(product_slug, sprint_id)
|
|
610
|
+
return f"Started sprint {sprint_id}: {sprint['name']}"
|
|
611
|
+
except (ValueError, FileNotFoundError) as e:
|
|
612
|
+
return f"Error: {e}"
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
@tool
|
|
616
|
+
def delete_sprint_tool(product_slug: str, sprint_id: str) -> str:
|
|
617
|
+
"""Delete a sprint. Cannot delete an active sprint — close it first.
|
|
618
|
+
|
|
619
|
+
Args:
|
|
620
|
+
product_slug: The product slug
|
|
621
|
+
sprint_id: The sprint ID to delete
|
|
622
|
+
"""
|
|
623
|
+
try:
|
|
624
|
+
prod.delete_sprint(product_slug, sprint_id)
|
|
625
|
+
return f"Deleted sprint {sprint_id} from {product_slug}"
|
|
626
|
+
except (ValueError, FileNotFoundError) as e:
|
|
627
|
+
return f"Error: {e}"
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
@tool
|
|
631
|
+
def sprint_analytics_tool(product_slug: str, sprint_id: str) -> str:
|
|
632
|
+
"""Get sprint analytics: velocity (story points completed).
|
|
633
|
+
|
|
634
|
+
Args:
|
|
635
|
+
product_slug: The product slug
|
|
636
|
+
sprint_id: The sprint ID
|
|
637
|
+
"""
|
|
638
|
+
try:
|
|
639
|
+
sprint = prod.load_sprint(product_slug, sprint_id)
|
|
640
|
+
if not sprint:
|
|
641
|
+
return f"Error: Sprint '{sprint_id}' not found"
|
|
642
|
+
velocity = prod.get_sprint_velocity(product_slug, sprint_id)
|
|
643
|
+
issues = prod.list_issues(product_slug, sprint=sprint_id)
|
|
644
|
+
done = sum(1 for i in issues if i.get("status") in ("done", "released"))
|
|
645
|
+
total = len(issues)
|
|
646
|
+
lines = [
|
|
647
|
+
f"Sprint: {sprint['name']} ({sprint['status']})",
|
|
648
|
+
f"Velocity: {velocity} story points",
|
|
649
|
+
f"Issues: {done}/{total} done",
|
|
650
|
+
f"Dates: {sprint['start_date']} → {sprint['end_date']}",
|
|
651
|
+
]
|
|
652
|
+
if sprint.get("goal"):
|
|
653
|
+
lines.append(f"Goal: {sprint['goal']}")
|
|
654
|
+
return "\n".join(lines)
|
|
655
|
+
except (ValueError, FileNotFoundError) as e:
|
|
656
|
+
return f"Error: {e}"
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
@tool
|
|
660
|
+
def version_management_tool(
|
|
661
|
+
product_slug: str,
|
|
662
|
+
action: str,
|
|
663
|
+
resolved_issue_ids: str = "",
|
|
664
|
+
bump: str = "patch",
|
|
665
|
+
) -> str:
|
|
666
|
+
"""Manage product versions. Actions: list, release.
|
|
667
|
+
|
|
668
|
+
Args:
|
|
669
|
+
product_slug: The product slug
|
|
670
|
+
action: 'list' to list versions, 'release' to release a new version
|
|
671
|
+
resolved_issue_ids: Comma-separated issue IDs resolved in this release (for 'release')
|
|
672
|
+
bump: Version bump type: 'patch', 'minor', or 'major' (default: 'patch')
|
|
673
|
+
"""
|
|
674
|
+
try:
|
|
675
|
+
if action == "list":
|
|
676
|
+
versions = prod.list_versions(product_slug)
|
|
677
|
+
if not versions:
|
|
678
|
+
return f"No versions released for {product_slug}"
|
|
679
|
+
lines = [f"Versions for {product_slug}:"]
|
|
680
|
+
for v in versions:
|
|
681
|
+
lines.append(f" {v['version']} — released {v.get('released_at', '?')}")
|
|
682
|
+
return "\n".join(lines)
|
|
683
|
+
|
|
684
|
+
if action == "release":
|
|
685
|
+
ids = [i.strip() for i in resolved_issue_ids.split(",") if i.strip()]
|
|
686
|
+
version = prod.release_version(product_slug, ids, bump=bump)
|
|
687
|
+
return f"Released v{version['version']} with {len(ids)} resolved issues"
|
|
688
|
+
|
|
689
|
+
return f"Error: unknown action '{action}'. Use: list, release"
|
|
690
|
+
except (ValueError, FileNotFoundError) as e:
|
|
691
|
+
return f"Error: {e}"
|
|
692
|
+
|
|
693
|
+
|
|
565
694
|
# ---------------------------------------------------------------------------
|
|
566
695
|
# Export
|
|
567
696
|
# ---------------------------------------------------------------------------
|
|
@@ -581,4 +710,10 @@ PRODUCT_TOOLS = [
|
|
|
581
710
|
unlink_issues_tool,
|
|
582
711
|
check_blocked_issues_tool,
|
|
583
712
|
manage_review_tool,
|
|
713
|
+
delete_issue_tool,
|
|
714
|
+
reopen_issue_tool,
|
|
715
|
+
start_sprint_tool,
|
|
716
|
+
delete_sprint_tool,
|
|
717
|
+
sprint_analytics_tool,
|
|
718
|
+
version_management_tool,
|
|
584
719
|
]
|
|
@@ -7197,8 +7197,11 @@ async def api_list_issues(slug: str, status: str = "", priority: str = "") -> li
|
|
|
7197
7197
|
from onemancompany.core import product as prod
|
|
7198
7198
|
from onemancompany.core.models import IssuePriority, IssueStatus
|
|
7199
7199
|
|
|
7200
|
-
|
|
7201
|
-
|
|
7200
|
+
try:
|
|
7201
|
+
status_filter = IssueStatus(status) if status else None
|
|
7202
|
+
priority_filter = IssuePriority(priority) if priority else None
|
|
7203
|
+
except ValueError as exc:
|
|
7204
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
7202
7205
|
return prod.list_issues(slug, status=status_filter, priority=priority_filter)
|
|
7203
7206
|
|
|
7204
7207
|
|
|
@@ -7223,13 +7226,17 @@ async def api_update_issue(slug: str, issue_id: str, request: Request) -> dict:
|
|
|
7223
7226
|
ISSUE_MUTABLE_FIELDS = {"title", "status", "priority", "assignee_id", "labels", "milestone_version", "description", "story_points", "sprint"}
|
|
7224
7227
|
body = await request.json()
|
|
7225
7228
|
filtered = {k: v for k, v in body.items() if k in ISSUE_MUTABLE_FIELDS}
|
|
7226
|
-
|
|
7227
|
-
|
|
7228
|
-
|
|
7229
|
-
|
|
7230
|
-
|
|
7231
|
-
|
|
7232
|
-
raise HTTPException(status_code=
|
|
7229
|
+
try:
|
|
7230
|
+
if "status" in filtered:
|
|
7231
|
+
filtered["status"] = _IS(filtered["status"]).value
|
|
7232
|
+
if "priority" in filtered:
|
|
7233
|
+
filtered["priority"] = _IP2(filtered["priority"]).value
|
|
7234
|
+
except ValueError as exc:
|
|
7235
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
7236
|
+
try:
|
|
7237
|
+
result = prod.update_issue(slug, issue_id, **filtered)
|
|
7238
|
+
except ValueError as exc:
|
|
7239
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
7233
7240
|
|
|
7234
7241
|
# Publish ISSUE_ASSIGNED event when assignee changes
|
|
7235
7242
|
if "assignee_id" in filtered and filtered["assignee_id"]:
|
|
@@ -7257,9 +7264,14 @@ async def api_close_issue(slug: str, issue_id: str, request: Request) -> dict:
|
|
|
7257
7264
|
|
|
7258
7265
|
body = await request.json() if request.headers.get("content-length", "0") != "0" else {}
|
|
7259
7266
|
resolution_str = body.get("resolution", "fixed")
|
|
7260
|
-
|
|
7261
|
-
|
|
7262
|
-
|
|
7267
|
+
try:
|
|
7268
|
+
resolution = IssueResolution(resolution_str)
|
|
7269
|
+
except ValueError as exc:
|
|
7270
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
7271
|
+
try:
|
|
7272
|
+
result = prod.close_issue(slug, issue_id, resolution=resolution)
|
|
7273
|
+
except ValueError as exc:
|
|
7274
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
7263
7275
|
await event_bus.publish(
|
|
7264
7276
|
CompanyEvent(
|
|
7265
7277
|
type=EventType.ISSUE_CLOSED,
|
|
@@ -7275,9 +7287,10 @@ async def api_reopen_issue(slug: str, issue_id: str) -> dict:
|
|
|
7275
7287
|
"""Reopen a closed issue."""
|
|
7276
7288
|
from onemancompany.core import product as prod
|
|
7277
7289
|
|
|
7278
|
-
|
|
7279
|
-
|
|
7280
|
-
|
|
7290
|
+
try:
|
|
7291
|
+
result = prod.reopen_issue(slug, issue_id)
|
|
7292
|
+
except ValueError as exc:
|
|
7293
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
7281
7294
|
return result
|
|
7282
7295
|
|
|
7283
7296
|
|
|
@@ -287,9 +287,11 @@ def create_issue(
|
|
|
287
287
|
sprint: str | None = None,
|
|
288
288
|
) -> dict:
|
|
289
289
|
"""Create an issue for a product. Returns the issue dict."""
|
|
290
|
-
issue_id = _gen_id("issue_")
|
|
291
290
|
product = load_product(slug)
|
|
292
|
-
|
|
291
|
+
if not product:
|
|
292
|
+
raise ValueError(f"Product '{slug}' not found")
|
|
293
|
+
issue_id = _gen_id("issue_")
|
|
294
|
+
product_id = product["id"]
|
|
293
295
|
now = datetime.now().isoformat()
|
|
294
296
|
|
|
295
297
|
data = {
|
|
@@ -380,14 +382,19 @@ def list_issues(
|
|
|
380
382
|
return results
|
|
381
383
|
|
|
382
384
|
|
|
383
|
-
def update_issue(slug: str, issue_id: str, **fields) -> dict
|
|
384
|
-
"""Update issue fields. Returns updated dict
|
|
385
|
+
def update_issue(slug: str, issue_id: str, **fields) -> dict:
|
|
386
|
+
"""Update issue fields. Returns updated dict. Raises ValueError if not found."""
|
|
385
387
|
with _get_slug_lock(slug):
|
|
386
388
|
path = _issues_dir(slug) / f"{issue_id}.yaml"
|
|
387
389
|
data = _read_yaml(path)
|
|
388
390
|
if not data:
|
|
389
|
-
|
|
390
|
-
|
|
391
|
+
raise ValueError(f"Issue '{issue_id}' not found in product '{slug}'")
|
|
392
|
+
# Validate status transition if status is being changed
|
|
393
|
+
new_status = fields.get("status")
|
|
394
|
+
if new_status is not None:
|
|
395
|
+
current_status = data.get("status", IssueStatus.BACKLOG.value)
|
|
396
|
+
if new_status != current_status:
|
|
397
|
+
_validate_status_transition(current_status, new_status)
|
|
391
398
|
for key, value in fields.items():
|
|
392
399
|
if value is not None:
|
|
393
400
|
old_value = data.get(key)
|
|
@@ -404,14 +411,13 @@ def close_issue(
|
|
|
404
411
|
issue_id: str,
|
|
405
412
|
*,
|
|
406
413
|
resolution: IssueResolution = IssueResolution.FIXED,
|
|
407
|
-
) -> dict
|
|
408
|
-
"""Close an issue with a resolution. Returns updated dict
|
|
414
|
+
) -> dict:
|
|
415
|
+
"""Close an issue with a resolution. Returns updated dict. Raises ValueError if not found."""
|
|
409
416
|
with _get_slug_lock(slug):
|
|
410
417
|
path = _issues_dir(slug) / f"{issue_id}.yaml"
|
|
411
418
|
data = _read_yaml(path)
|
|
412
419
|
if not data:
|
|
413
|
-
|
|
414
|
-
return None
|
|
420
|
+
raise ValueError(f"Issue '{issue_id}' not found in product '{slug}'")
|
|
415
421
|
old_status = data.get("status")
|
|
416
422
|
_append_history(data, "status", old_status, IssueStatus.DONE.value, changed_by="system")
|
|
417
423
|
data["status"] = IssueStatus.DONE.value
|
|
@@ -423,14 +429,13 @@ def close_issue(
|
|
|
423
429
|
return data
|
|
424
430
|
|
|
425
431
|
|
|
426
|
-
def reopen_issue(slug: str, issue_id: str) -> dict
|
|
427
|
-
"""Reopen a closed issue. Increments reopened_count. Returns updated dict
|
|
432
|
+
def reopen_issue(slug: str, issue_id: str) -> dict:
|
|
433
|
+
"""Reopen a closed issue. Increments reopened_count. Returns updated dict. Raises ValueError if not found."""
|
|
428
434
|
with _get_slug_lock(slug):
|
|
429
435
|
path = _issues_dir(slug) / f"{issue_id}.yaml"
|
|
430
436
|
data = _read_yaml(path)
|
|
431
437
|
if not data:
|
|
432
|
-
|
|
433
|
-
return None
|
|
438
|
+
raise ValueError(f"Issue '{issue_id}' not found in product '{slug}'")
|
|
434
439
|
old_status = data.get("status")
|
|
435
440
|
_append_history(data, "status", old_status, IssueStatus.BACKLOG.value, changed_by="system")
|
|
436
441
|
data["status"] = IssueStatus.BACKLOG.value
|
|
@@ -443,6 +448,53 @@ def reopen_issue(slug: str, issue_id: str) -> dict | None:
|
|
|
443
448
|
return data
|
|
444
449
|
|
|
445
450
|
|
|
451
|
+
def delete_issue(slug: str, issue_id: str) -> None:
|
|
452
|
+
"""Delete an issue and clean up all links referencing it. Raises ValueError if not found."""
|
|
453
|
+
issue = load_issue(slug, issue_id)
|
|
454
|
+
if not issue:
|
|
455
|
+
raise ValueError(f"Issue '{issue_id}' not found in product '{slug}'")
|
|
456
|
+
|
|
457
|
+
# Clean up links from other issues pointing to this one
|
|
458
|
+
all_issues = list_issues(slug)
|
|
459
|
+
for other in all_issues:
|
|
460
|
+
if other["id"] == issue_id:
|
|
461
|
+
continue
|
|
462
|
+
links = other.get("issue_links", [])
|
|
463
|
+
if any(l["issue_id"] == issue_id for l in links):
|
|
464
|
+
_remove_link_entry(slug, other["id"], issue_id)
|
|
465
|
+
|
|
466
|
+
# Remove the issue file
|
|
467
|
+
with _get_slug_lock(slug):
|
|
468
|
+
path = _issues_dir(slug) / f"{issue_id}.yaml"
|
|
469
|
+
path.unlink(missing_ok=True)
|
|
470
|
+
mark_dirty(DirtyCategory.PRODUCTS)
|
|
471
|
+
logger.debug("Deleted issue {} from {}", issue_id, slug)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
# ---------------------------------------------------------------------------
|
|
475
|
+
# Issue Status Transitions
|
|
476
|
+
# ---------------------------------------------------------------------------
|
|
477
|
+
|
|
478
|
+
_VALID_TRANSITIONS: dict[str, set[str]] = {
|
|
479
|
+
IssueStatus.BACKLOG.value: {IssueStatus.PLANNED.value, IssueStatus.IN_PROGRESS.value},
|
|
480
|
+
IssueStatus.PLANNED.value: {IssueStatus.IN_PROGRESS.value, IssueStatus.BACKLOG.value},
|
|
481
|
+
IssueStatus.IN_PROGRESS.value: {IssueStatus.IN_REVIEW.value, IssueStatus.DONE.value, IssueStatus.BACKLOG.value},
|
|
482
|
+
IssueStatus.IN_REVIEW.value: {IssueStatus.DONE.value, IssueStatus.IN_PROGRESS.value, IssueStatus.BACKLOG.value},
|
|
483
|
+
IssueStatus.DONE.value: {IssueStatus.RELEASED.value, IssueStatus.BACKLOG.value},
|
|
484
|
+
IssueStatus.RELEASED.value: {IssueStatus.BACKLOG.value},
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _validate_status_transition(current: str, target: str) -> None:
|
|
489
|
+
"""Raise ValueError if the status transition is not allowed."""
|
|
490
|
+
allowed = _VALID_TRANSITIONS.get(current, set())
|
|
491
|
+
if target not in allowed:
|
|
492
|
+
raise ValueError(
|
|
493
|
+
f"Invalid transition: '{current}' → '{target}'. "
|
|
494
|
+
f"Allowed: {sorted(allowed)}"
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
|
|
446
498
|
# ---------------------------------------------------------------------------
|
|
447
499
|
# Issue Links
|
|
448
500
|
# ---------------------------------------------------------------------------
|
|
@@ -478,6 +530,13 @@ def add_issue_link(
|
|
|
478
530
|
rel_value = relation.value if hasattr(relation, "value") else relation
|
|
479
531
|
reverse_rel = _REVERSE_RELATION[rel_value]
|
|
480
532
|
|
|
533
|
+
# Circular dependency check for blocking relations
|
|
534
|
+
if rel_value == IssueRelation.BLOCKS.value:
|
|
535
|
+
_check_block_cycle(slug, issue_id, target_id)
|
|
536
|
+
elif rel_value == IssueRelation.BLOCKED_BY.value:
|
|
537
|
+
# blocked_by is the reverse: target blocks issue
|
|
538
|
+
_check_block_cycle(slug, target_id, issue_id)
|
|
539
|
+
|
|
481
540
|
# Add forward link (idempotent)
|
|
482
541
|
_add_link_entry(slug, issue_id, target_id, rel_value)
|
|
483
542
|
# Add reverse link
|
|
@@ -487,6 +546,34 @@ def add_issue_link(
|
|
|
487
546
|
logger.debug("Linked {} —{}→ {}", issue_id, rel_value, target_id)
|
|
488
547
|
|
|
489
548
|
|
|
549
|
+
def _check_block_cycle(slug: str, blocker_id: str, blocked_id: str) -> None:
|
|
550
|
+
"""Raise ValueError if adding 'blocker_id blocks blocked_id' would create a cycle.
|
|
551
|
+
|
|
552
|
+
Walks the existing 'blocks' graph starting from blocked_id to see if
|
|
553
|
+
blocker_id is reachable (meaning blocked_id already transitively blocks blocker_id).
|
|
554
|
+
"""
|
|
555
|
+
visited: set[str] = set()
|
|
556
|
+
queue = [blocked_id]
|
|
557
|
+
while queue:
|
|
558
|
+
current = queue.pop()
|
|
559
|
+
if current in visited:
|
|
560
|
+
continue
|
|
561
|
+
visited.add(current)
|
|
562
|
+
issue = load_issue(slug, current)
|
|
563
|
+
if not issue:
|
|
564
|
+
continue
|
|
565
|
+
for link in issue.get("issue_links", []):
|
|
566
|
+
if link["relation"] != IssueRelation.BLOCKS.value:
|
|
567
|
+
continue
|
|
568
|
+
downstream = link["issue_id"]
|
|
569
|
+
if downstream == blocker_id:
|
|
570
|
+
raise ValueError(
|
|
571
|
+
f"Circular dependency: {blocked_id} already transitively "
|
|
572
|
+
f"blocks {blocker_id}"
|
|
573
|
+
)
|
|
574
|
+
queue.append(downstream)
|
|
575
|
+
|
|
576
|
+
|
|
490
577
|
def _add_link_entry(slug: str, issue_id: str, target_id: str, relation: str) -> None:
|
|
491
578
|
"""Add a single link entry to an issue (idempotent)."""
|
|
492
579
|
with _get_slug_lock(slug):
|
|
@@ -1215,6 +1302,17 @@ def create_sprint(
|
|
|
1215
1302
|
if not product:
|
|
1216
1303
|
raise ValueError(f"Product '{slug}' not found")
|
|
1217
1304
|
|
|
1305
|
+
# Validate dates
|
|
1306
|
+
try:
|
|
1307
|
+
sd = datetime.strptime(start_date, "%Y-%m-%d")
|
|
1308
|
+
ed = datetime.strptime(end_date, "%Y-%m-%d")
|
|
1309
|
+
except ValueError as exc:
|
|
1310
|
+
raise ValueError(f"Invalid date format: {exc}") from exc
|
|
1311
|
+
if ed <= sd:
|
|
1312
|
+
raise ValueError(
|
|
1313
|
+
f"End date '{end_date}' must be after start date '{start_date}'"
|
|
1314
|
+
)
|
|
1315
|
+
|
|
1218
1316
|
sprint_id = _gen_id("sprint_")
|
|
1219
1317
|
now = datetime.now().isoformat()
|
|
1220
1318
|
|
|
@@ -1286,6 +1384,25 @@ def update_sprint(slug: str, sprint_id: str, **fields) -> dict:
|
|
|
1286
1384
|
return sprint
|
|
1287
1385
|
|
|
1288
1386
|
|
|
1387
|
+
def start_sprint(slug: str, sprint_id: str) -> dict:
|
|
1388
|
+
"""Start a sprint (set status to active). Raises ValueError if already active elsewhere."""
|
|
1389
|
+
return update_sprint(slug, sprint_id, status=SprintStatus.ACTIVE.value)
|
|
1390
|
+
|
|
1391
|
+
|
|
1392
|
+
def delete_sprint(slug: str, sprint_id: str) -> None:
|
|
1393
|
+
"""Delete a sprint. Cannot delete an active sprint. Raises ValueError if not found."""
|
|
1394
|
+
sprint = load_sprint(slug, sprint_id)
|
|
1395
|
+
if not sprint:
|
|
1396
|
+
raise ValueError(f"Sprint '{sprint_id}' not found in '{slug}'")
|
|
1397
|
+
if sprint.get("status") == SprintStatus.ACTIVE.value:
|
|
1398
|
+
raise ValueError(f"Cannot delete active sprint '{sprint_id}'. Close it first.")
|
|
1399
|
+
with _get_slug_lock(slug):
|
|
1400
|
+
path = _sprints_dir(slug) / f"{sprint_id}.yaml"
|
|
1401
|
+
path.unlink(missing_ok=True)
|
|
1402
|
+
mark_dirty(DirtyCategory.PRODUCTS)
|
|
1403
|
+
logger.debug("Deleted sprint {} from {}", sprint_id, slug)
|
|
1404
|
+
|
|
1405
|
+
|
|
1289
1406
|
def get_active_sprint(slug: str) -> dict | None:
|
|
1290
1407
|
"""Return the current active sprint for a product, or None."""
|
|
1291
1408
|
active = list_sprints(slug, status=SprintStatus.ACTIVE.value)
|