@1mancompany/onemancompany 0.7.30 → 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
|
]
|
|
@@ -389,6 +389,12 @@ def update_issue(slug: str, issue_id: str, **fields) -> dict:
|
|
|
389
389
|
data = _read_yaml(path)
|
|
390
390
|
if not data:
|
|
391
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)
|
|
392
398
|
for key, value in fields.items():
|
|
393
399
|
if value is not None:
|
|
394
400
|
old_value = data.get(key)
|
|
@@ -442,6 +448,53 @@ def reopen_issue(slug: str, issue_id: str) -> dict:
|
|
|
442
448
|
return data
|
|
443
449
|
|
|
444
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
|
+
|
|
445
498
|
# ---------------------------------------------------------------------------
|
|
446
499
|
# Issue Links
|
|
447
500
|
# ---------------------------------------------------------------------------
|
|
@@ -1249,6 +1302,17 @@ def create_sprint(
|
|
|
1249
1302
|
if not product:
|
|
1250
1303
|
raise ValueError(f"Product '{slug}' not found")
|
|
1251
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
|
+
|
|
1252
1316
|
sprint_id = _gen_id("sprint_")
|
|
1253
1317
|
now = datetime.now().isoformat()
|
|
1254
1318
|
|
|
@@ -1320,6 +1384,25 @@ def update_sprint(slug: str, sprint_id: str, **fields) -> dict:
|
|
|
1320
1384
|
return sprint
|
|
1321
1385
|
|
|
1322
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
|
+
|
|
1323
1406
|
def get_active_sprint(slug: str) -> dict | None:
|
|
1324
1407
|
"""Return the current active sprint for a product, or None."""
|
|
1325
1408
|
active = list_sprints(slug, status=SprintStatus.ACTIVE.value)
|