@1mancompany/onemancompany 0.7.27 → 0.7.30

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.30",
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.30"
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 = [
@@ -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,13 @@ 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}'")
391
392
  for key, value in fields.items():
392
393
  if value is not None:
393
394
  old_value = data.get(key)
@@ -404,14 +405,13 @@ def close_issue(
404
405
  issue_id: str,
405
406
  *,
406
407
  resolution: IssueResolution = IssueResolution.FIXED,
407
- ) -> dict | None:
408
- """Close an issue with a resolution. Returns updated dict or None."""
408
+ ) -> dict:
409
+ """Close an issue with a resolution. Returns updated dict. Raises ValueError if not found."""
409
410
  with _get_slug_lock(slug):
410
411
  path = _issues_dir(slug) / f"{issue_id}.yaml"
411
412
  data = _read_yaml(path)
412
413
  if not data:
413
- logger.warning("close_issue: issue {} not found in {}", issue_id, slug)
414
- return None
414
+ raise ValueError(f"Issue '{issue_id}' not found in product '{slug}'")
415
415
  old_status = data.get("status")
416
416
  _append_history(data, "status", old_status, IssueStatus.DONE.value, changed_by="system")
417
417
  data["status"] = IssueStatus.DONE.value
@@ -423,14 +423,13 @@ def close_issue(
423
423
  return data
424
424
 
425
425
 
426
- def reopen_issue(slug: str, issue_id: str) -> dict | None:
427
- """Reopen a closed issue. Increments reopened_count. Returns updated dict or None."""
426
+ def reopen_issue(slug: str, issue_id: str) -> dict:
427
+ """Reopen a closed issue. Increments reopened_count. Returns updated dict. Raises ValueError if not found."""
428
428
  with _get_slug_lock(slug):
429
429
  path = _issues_dir(slug) / f"{issue_id}.yaml"
430
430
  data = _read_yaml(path)
431
431
  if not data:
432
- logger.warning("reopen_issue: issue {} not found in {}", issue_id, slug)
433
- return None
432
+ raise ValueError(f"Issue '{issue_id}' not found in product '{slug}'")
434
433
  old_status = data.get("status")
435
434
  _append_history(data, "status", old_status, IssueStatus.BACKLOG.value, changed_by="system")
436
435
  data["status"] = IssueStatus.BACKLOG.value
@@ -478,6 +477,13 @@ def add_issue_link(
478
477
  rel_value = relation.value if hasattr(relation, "value") else relation
479
478
  reverse_rel = _REVERSE_RELATION[rel_value]
480
479
 
480
+ # Circular dependency check for blocking relations
481
+ if rel_value == IssueRelation.BLOCKS.value:
482
+ _check_block_cycle(slug, issue_id, target_id)
483
+ elif rel_value == IssueRelation.BLOCKED_BY.value:
484
+ # blocked_by is the reverse: target blocks issue
485
+ _check_block_cycle(slug, target_id, issue_id)
486
+
481
487
  # Add forward link (idempotent)
482
488
  _add_link_entry(slug, issue_id, target_id, rel_value)
483
489
  # Add reverse link
@@ -487,6 +493,34 @@ def add_issue_link(
487
493
  logger.debug("Linked {} —{}→ {}", issue_id, rel_value, target_id)
488
494
 
489
495
 
496
+ def _check_block_cycle(slug: str, blocker_id: str, blocked_id: str) -> None:
497
+ """Raise ValueError if adding 'blocker_id blocks blocked_id' would create a cycle.
498
+
499
+ Walks the existing 'blocks' graph starting from blocked_id to see if
500
+ blocker_id is reachable (meaning blocked_id already transitively blocks blocker_id).
501
+ """
502
+ visited: set[str] = set()
503
+ queue = [blocked_id]
504
+ while queue:
505
+ current = queue.pop()
506
+ if current in visited:
507
+ continue
508
+ visited.add(current)
509
+ issue = load_issue(slug, current)
510
+ if not issue:
511
+ continue
512
+ for link in issue.get("issue_links", []):
513
+ if link["relation"] != IssueRelation.BLOCKS.value:
514
+ continue
515
+ downstream = link["issue_id"]
516
+ if downstream == blocker_id:
517
+ raise ValueError(
518
+ f"Circular dependency: {blocked_id} already transitively "
519
+ f"blocks {blocker_id}"
520
+ )
521
+ queue.append(downstream)
522
+
523
+
490
524
  def _add_link_entry(slug: str, issue_id: str, target_id: str, relation: str) -> None:
491
525
  """Add a single link entry to an issue (idempotent)."""
492
526
  with _get_slug_lock(slug):