@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1mancompany/onemancompany",
3
- "version": "0.7.27",
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.27"
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
  ]
@@ -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
- status_filter = IssueStatus(status) if status else None
7201
- priority_filter = IssuePriority(priority) if priority else None
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
- if "status" in filtered:
7227
- filtered["status"] = _IS(filtered["status"]).value
7228
- if "priority" in filtered:
7229
- filtered["priority"] = _IP2(filtered["priority"]).value
7230
- result = prod.update_issue(slug, issue_id, **filtered)
7231
- if not result:
7232
- raise HTTPException(status_code=404, detail=f"Issue '{issue_id}' not found")
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
- result = prod.close_issue(slug, issue_id, resolution=IssueResolution(resolution_str))
7261
- if not result:
7262
- raise HTTPException(status_code=404, detail=f"Issue '{issue_id}' not found")
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
- result = prod.reopen_issue(slug, issue_id)
7279
- if not result:
7280
- raise HTTPException(status_code=404, detail=f"Issue '{issue_id}' not found")
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
- product_id = product["id"] if product else ""
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 | None:
384
- """Update issue fields. Returns updated dict or None if not found."""
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
- logger.warning("update_issue: issue {} not found in {}", issue_id, slug)
390
- return None
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 | None:
408
- """Close an issue with a resolution. Returns updated dict or None."""
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
- logger.warning("close_issue: issue {} not found in {}", issue_id, slug)
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 | None:
427
- """Reopen a closed issue. Increments reopened_count. Returns updated dict or None."""
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
- logger.warning("reopen_issue: issue {} not found in {}", issue_id, slug)
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)