@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1mancompany/onemancompany",
3
- "version": "0.7.30",
3
+ "version": "0.7.31",
4
4
  "description": "The AI Operating System for One-Person Companies",
5
5
  "bin": {
6
6
  "onemancompany": "bin/cli.js"
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "onemancompany"
3
- version = "0.7.30"
3
+ version = "0.7.31"
4
4
  description = "A one-man company simulation with pixel art visualization and LangChain AI agents"
5
5
  requires-python = ">=3.12"
6
6
  dependencies = [
@@ -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)