@1mancompany/onemancompany 0.7.23 → 0.7.25

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.23",
3
+ "version": "0.7.25",
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.23"
3
+ version = "0.7.25"
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 = [
@@ -11,7 +11,7 @@ from langchain_core.tools import tool
11
11
  from loguru import logger
12
12
 
13
13
  from onemancompany.core import product as prod
14
- from onemancompany.core.models import IssueResolution, IssuePriority, IssueStatus
14
+ from onemancompany.core.models import IssueRelation, IssueResolution, IssuePriority, IssueStatus
15
15
 
16
16
 
17
17
  # ---------------------------------------------------------------------------
@@ -21,6 +21,7 @@ from onemancompany.core.models import IssueResolution, IssuePriority, IssueStatu
21
21
  _RESOLUTION_MAP = {r.value: r for r in IssueResolution}
22
22
  _PRIORITY_MAP = {p.value: p for p in IssuePriority}
23
23
  _STATUS_MAP = {s.value: s for s in IssueStatus}
24
+ _RELATION_MAP = {r.value: r for r in IssueRelation}
24
25
 
25
26
 
26
27
  def _resolve_caller_id() -> str:
@@ -420,6 +421,147 @@ async def get_sprint_info_tool(
420
421
  return f"Error: {e}"
421
422
 
422
423
 
424
+ # ---------------------------------------------------------------------------
425
+ # Issue link tools
426
+ # ---------------------------------------------------------------------------
427
+
428
+
429
+ @tool
430
+ async def link_issues_tool(
431
+ product_slug: str,
432
+ issue_id: str,
433
+ target_id: str,
434
+ relation: str,
435
+ ) -> str:
436
+ """Link two issues with a dependency or relation.
437
+
438
+ Args:
439
+ product_slug: The product slug
440
+ issue_id: Source issue ID
441
+ target_id: Target issue ID
442
+ relation: blocks, blocked_by, or relates_to
443
+ """
444
+ rel = _RELATION_MAP.get(relation)
445
+ if rel is None:
446
+ return f"Error: invalid relation '{relation}'. Must be one of: {', '.join(_RELATION_MAP)}"
447
+ try:
448
+ prod.add_issue_link(product_slug, issue_id, target_id, rel)
449
+ logger.debug("link_issues_tool: {} —{}→ {}", issue_id, relation, target_id)
450
+ return f"Linked {issue_id} —{relation}→ {target_id}"
451
+ except ValueError as e:
452
+ return f"Error: {e}"
453
+
454
+
455
+ @tool
456
+ async def unlink_issues_tool(
457
+ product_slug: str,
458
+ issue_id: str,
459
+ target_id: str,
460
+ ) -> str:
461
+ """Remove all links between two issues.
462
+
463
+ Args:
464
+ product_slug: The product slug
465
+ issue_id: First issue ID
466
+ target_id: Second issue ID
467
+ """
468
+ prod.remove_issue_link(product_slug, issue_id, target_id)
469
+ logger.debug("unlink_issues_tool: {} ↔ {}", issue_id, target_id)
470
+ return f"Unlinked {issue_id} ↔ {target_id}"
471
+
472
+
473
+ @tool
474
+ async def check_blocked_issues_tool(
475
+ product_slug: str,
476
+ ) -> str:
477
+ """List all issues that are currently blocked by unfinished dependencies.
478
+
479
+ Args:
480
+ product_slug: The product slug
481
+ """
482
+ all_issues = prod.list_issues(product_slug)
483
+ blocked = []
484
+ for issue in all_issues:
485
+ if issue.get("status") in (IssueStatus.DONE.value, IssueStatus.RELEASED.value):
486
+ continue
487
+ if prod.is_blocked(product_slug, issue["id"]):
488
+ blockers = [
489
+ l["issue_id"] for l in issue.get("issue_links", [])
490
+ if l["relation"] == IssueRelation.BLOCKED_BY.value
491
+ ]
492
+ blocked.append(f"- [{issue.get('priority', '?')}] {issue['title']} ({issue['id']}) blocked by: {', '.join(blockers)}")
493
+ if not blocked:
494
+ return "No blocked issues found"
495
+ return f"Blocked issues ({len(blocked)}):\n" + "\n".join(blocked)
496
+
497
+
498
+ @tool
499
+ async def manage_review_tool(
500
+ product_slug: str,
501
+ action: str,
502
+ review_id: str = "",
503
+ item_key: str = "",
504
+ checked: str = "",
505
+ ) -> str:
506
+ """Manage product review checklists: list, view, check items, or complete.
507
+
508
+ Args:
509
+ product_slug: The product slug
510
+ action: list, view, check, uncheck, or complete
511
+ review_id: Review ID (required for view/check/uncheck/complete)
512
+ item_key: Checklist item key (required for check/uncheck)
513
+ checked: 'true' or 'false' (for check/uncheck, overrides action)
514
+ """
515
+ try:
516
+ if action == "list":
517
+ reviews = prod.list_reviews(product_slug)
518
+ if not reviews:
519
+ return "No reviews found"
520
+ lines = []
521
+ for r in reviews:
522
+ checked_count = sum(1 for i in r.get("items", []) if i.get("checked"))
523
+ total = len(r.get("items", []))
524
+ lines.append(f"- [{r['status']}] {r['id']} ({r['trigger']}) {checked_count}/{total} items checked")
525
+ return "\n".join(lines)
526
+
527
+ if not review_id:
528
+ return "Error: review_id is required for this action"
529
+
530
+ if action == "view":
531
+ review = prod.load_review(product_slug, review_id)
532
+ if not review:
533
+ return f"Review '{review_id}' not found"
534
+ lines = [
535
+ f"**Review {review['id']}**",
536
+ f"Status: {review['status']}",
537
+ f"Trigger: {review['trigger']} ({review.get('trigger_ref', '')})",
538
+ f"Owner: {review['owner']}",
539
+ "",
540
+ "Checklist:",
541
+ ]
542
+ for item in review.get("items", []):
543
+ mark = "✓" if item.get("checked") else "○"
544
+ lines.append(f" {mark} [{item['key']}] {item['label']}")
545
+ return "\n".join(lines)
546
+
547
+ if action in ("check", "uncheck"):
548
+ if not item_key:
549
+ return "Error: item_key is required for check/uncheck"
550
+ is_checked = action == "check"
551
+ if checked:
552
+ is_checked = checked.lower() == "true"
553
+ prod.update_review_item(product_slug, review_id, item_key, checked=is_checked)
554
+ return f"{'Checked' if is_checked else 'Unchecked'} item '{item_key}' in review {review_id}"
555
+
556
+ if action == "complete":
557
+ review = prod.complete_review(product_slug, review_id)
558
+ return f"Review {review_id} completed at {review['completed_at']}"
559
+
560
+ return f"Error: unknown action '{action}'. Use: list, view, check, uncheck, complete"
561
+ except ValueError as e:
562
+ return f"Error: {e}"
563
+
564
+
423
565
  # ---------------------------------------------------------------------------
424
566
  # Export
425
567
  # ---------------------------------------------------------------------------
@@ -435,4 +577,8 @@ PRODUCT_TOOLS = [
435
577
  create_sprint_tool,
436
578
  close_sprint_tool,
437
579
  get_sprint_info_tool,
580
+ link_issues_tool,
581
+ unlink_issues_tool,
582
+ check_blocked_issues_tool,
583
+ manage_review_tool,
438
584
  ]
@@ -7336,6 +7336,12 @@ async def api_product_detail(slug: str) -> dict:
7336
7336
  active_sprint = prod.get_active_sprint(slug)
7337
7337
  suggested_capacity = prod.suggest_capacity(slug)
7338
7338
 
7339
+ # Reviews
7340
+ reviews = prod.list_reviews(slug)
7341
+
7342
+ # Blocked issues count
7343
+ blocked_count = sum(1 for i in issues if prod.is_blocked(slug, i["id"]))
7344
+
7339
7345
  return {
7340
7346
  "product": product,
7341
7347
  "issues": issues,
@@ -7344,6 +7350,8 @@ async def api_product_detail(slug: str) -> dict:
7344
7350
  "sprints": sprints,
7345
7351
  "active_sprint": active_sprint,
7346
7352
  "suggested_capacity": suggested_capacity,
7353
+ "reviews": reviews,
7354
+ "blocked_issues_count": blocked_count,
7347
7355
  }
7348
7356
 
7349
7357
 
@@ -7508,3 +7516,132 @@ async def api_suggest_sprint_capacity(slug: str) -> dict:
7508
7516
 
7509
7517
  suggestion = prod.suggest_capacity(slug)
7510
7518
  return {"suggested_capacity": suggestion}
7519
+
7520
+
7521
+ # ---------------------------------------------------------------------------
7522
+ # Issue Links
7523
+ # ---------------------------------------------------------------------------
7524
+
7525
+
7526
+ @router.post("/api/product/{slug}/issue/{issue_id}/link")
7527
+ async def api_add_issue_link(slug: str, issue_id: str, request: Request) -> dict:
7528
+ """Add a link between two issues."""
7529
+ from onemancompany.core import product as prod
7530
+ from onemancompany.core.models import IssueRelation
7531
+
7532
+ body = await request.json()
7533
+ target_id = body.get("target_id", "")
7534
+ relation = body.get("relation", "")
7535
+
7536
+ if not target_id or not relation:
7537
+ raise HTTPException(status_code=400, detail="target_id and relation are required")
7538
+
7539
+ rel_map = {r.value: r for r in IssueRelation}
7540
+ rel = rel_map.get(relation)
7541
+ if not rel:
7542
+ raise HTTPException(status_code=400, detail=f"Invalid relation. Must be one of: {', '.join(rel_map)}")
7543
+
7544
+ try:
7545
+ prod.add_issue_link(slug, issue_id, target_id, rel)
7546
+ except ValueError as exc:
7547
+ raise HTTPException(status_code=400, detail=str(exc))
7548
+
7549
+ return {"linked": True, "issue_id": issue_id, "target_id": target_id, "relation": relation}
7550
+
7551
+
7552
+ @router.delete("/api/product/{slug}/issue/{issue_id}/link/{target_id}")
7553
+ async def api_remove_issue_link(slug: str, issue_id: str, target_id: str) -> dict:
7554
+ """Remove all links between two issues."""
7555
+ from onemancompany.core import product as prod
7556
+
7557
+ prod.remove_issue_link(slug, issue_id, target_id)
7558
+ return {"unlinked": True, "issue_id": issue_id, "target_id": target_id}
7559
+
7560
+
7561
+ @router.get("/api/product/{slug}/issue/{issue_id}/links")
7562
+ async def api_get_issue_links(slug: str, issue_id: str) -> list[dict]:
7563
+ """Get all links for an issue."""
7564
+ from onemancompany.core import product as prod
7565
+
7566
+ return prod.get_issue_links(slug, issue_id)
7567
+
7568
+
7569
+ @router.get("/api/product/{slug}/blocked-issues")
7570
+ async def api_blocked_issues(slug: str) -> list[dict]:
7571
+ """List all blocked issues for a product."""
7572
+ from onemancompany.core import product as prod
7573
+
7574
+ all_issues = prod.list_issues(slug)
7575
+ blocked = []
7576
+ for issue in all_issues:
7577
+ if prod.is_blocked(slug, issue["id"]):
7578
+ blocked.append(issue)
7579
+ return blocked
7580
+
7581
+
7582
+ # ---------------------------------------------------------------------------
7583
+ # Reviews
7584
+ # ---------------------------------------------------------------------------
7585
+
7586
+
7587
+ @router.post("/api/product/{slug}/review")
7588
+ async def api_create_review(slug: str, request: Request) -> dict:
7589
+ """Create a review checklist."""
7590
+ from onemancompany.core import product as prod
7591
+
7592
+ body = await request.json()
7593
+ trigger = body.get("trigger", "manual")
7594
+ trigger_ref = body.get("trigger_ref", "")
7595
+ owner = body.get("owner", "")
7596
+
7597
+ review = prod.create_review(
7598
+ slug=slug,
7599
+ trigger=trigger,
7600
+ trigger_ref=trigger_ref,
7601
+ owner=owner,
7602
+ )
7603
+ return review
7604
+
7605
+
7606
+ @router.get("/api/product/{slug}/reviews")
7607
+ async def api_list_reviews(slug: str, status: str = "") -> list[dict]:
7608
+ """List reviews for a product, optionally filtered by status."""
7609
+ from onemancompany.core import product as prod
7610
+
7611
+ return prod.list_reviews(slug, status=status or None)
7612
+
7613
+
7614
+ @router.get("/api/product/{slug}/review/{review_id}")
7615
+ async def api_get_review(slug: str, review_id: str) -> dict:
7616
+ """Get a single review."""
7617
+ from onemancompany.core import product as prod
7618
+
7619
+ review = prod.load_review(slug, review_id)
7620
+ if not review:
7621
+ raise HTTPException(status_code=404, detail=f"Review '{review_id}' not found")
7622
+ return review
7623
+
7624
+
7625
+ @router.put("/api/product/{slug}/review/{review_id}/item/{item_key}")
7626
+ async def api_update_review_item(slug: str, review_id: str, item_key: str, request: Request) -> dict:
7627
+ """Check or uncheck a review checklist item."""
7628
+ from onemancompany.core import product as prod
7629
+
7630
+ body = await request.json()
7631
+ checked = body.get("checked", False)
7632
+
7633
+ try:
7634
+ return prod.update_review_item(slug, review_id, item_key, checked=checked)
7635
+ except ValueError as exc:
7636
+ raise HTTPException(status_code=400, detail=str(exc))
7637
+
7638
+
7639
+ @router.post("/api/product/{slug}/review/{review_id}/complete")
7640
+ async def api_complete_review(slug: str, review_id: str) -> dict:
7641
+ """Complete a review (all items must be checked)."""
7642
+ from onemancompany.core import product as prod
7643
+
7644
+ try:
7645
+ return prod.complete_review(slug, review_id)
7646
+ except ValueError as exc:
7647
+ raise HTTPException(status_code=400, detail=str(exc))
@@ -62,6 +62,7 @@ PRODUCT_YAML_FILENAME = "product.yaml"
62
62
  ISSUES_DIR_NAME = "issues"
63
63
  VERSIONS_DIR_NAME = "versions"
64
64
  SPRINTS_DIR_NAME = "sprints"
65
+ REVIEWS_DIR_NAME = "reviews"
65
66
  TALENT_PERSONA_FILENAME = "talent_persona.md"
66
67
  MCP_CONFIG_FILENAME = "mcp_config.json"
67
68
  CONVERSATIONS_DIR_NAME = "conversations"
@@ -164,6 +164,8 @@ class EventType(str, Enum):
164
164
  VERSION_RELEASED = "version_released"
165
165
  SPRINT_CREATED = "sprint_created"
166
166
  SPRINT_CLOSED = "sprint_closed"
167
+ REVIEW_CREATED = "review_created"
168
+ REVIEW_COMPLETED = "review_completed"
167
169
 
168
170
 
169
171
  class ProductStatus(str, Enum):
@@ -199,6 +201,13 @@ class IssueResolution(str, Enum):
199
201
  BY_DESIGN = "by_design"
200
202
 
201
203
 
204
+ class IssueRelation(str, Enum):
205
+ """Relationship type between two issues."""
206
+ BLOCKS = "blocks"
207
+ BLOCKED_BY = "blocked_by"
208
+ RELATES_TO = "relates_to"
209
+
210
+
202
211
  class SprintStatus(str, Enum):
203
212
  """Sprint lifecycle status."""
204
213
  PLANNING = "planning"
@@ -21,11 +21,13 @@ from onemancompany.core.config import (
21
21
  ISSUES_DIR_NAME,
22
22
  PRODUCT_YAML_FILENAME,
23
23
  PRODUCTS_DIR,
24
+ REVIEWS_DIR_NAME,
24
25
  SPRINTS_DIR_NAME,
25
26
  VERSIONS_DIR_NAME,
26
27
  DirtyCategory,
27
28
  )
28
29
  from onemancompany.core.models import (
30
+ IssueRelation,
29
31
  IssueResolution,
30
32
  IssuePriority,
31
33
  IssueStatus,
@@ -299,7 +301,7 @@ def create_issue(
299
301
  "labels": labels or [],
300
302
  "assignee_id": assignee_id,
301
303
  "linked_task_ids": [],
302
- "linked_issue_ids": [],
304
+ "issue_links": [],
303
305
  "milestone_version": milestone_version,
304
306
  "created_at": now,
305
307
  "created_by": created_by,
@@ -321,10 +323,26 @@ def create_issue(
321
323
 
322
324
 
323
325
  def load_issue(slug: str, issue_id: str) -> dict | None:
324
- """Load a single issue by ID. Returns None if not found."""
326
+ """Load a single issue by ID. Returns None if not found.
327
+
328
+ Auto-migrates old ``linked_issue_ids`` format to ``issue_links``.
329
+ """
325
330
  path = _issues_dir(slug) / f"{issue_id}.yaml"
326
331
  data = _read_yaml(path)
327
- return data if data else None
332
+ if not data:
333
+ return None
334
+
335
+ # Auto-migrate: linked_issue_ids → issue_links
336
+ if "linked_issue_ids" in data and "issue_links" not in data:
337
+ old_ids = data.pop("linked_issue_ids", [])
338
+ data["issue_links"] = [
339
+ {"issue_id": iid, "relation": IssueRelation.RELATES_TO.value}
340
+ for iid in old_ids
341
+ ]
342
+ _write_yaml(path, data)
343
+ logger.debug("Migrated linked_issue_ids → issue_links for {}", issue_id)
344
+
345
+ return data
328
346
 
329
347
 
330
348
  def list_issues(
@@ -424,6 +442,226 @@ def reopen_issue(slug: str, issue_id: str) -> dict | None:
424
442
  return data
425
443
 
426
444
 
445
+ # ---------------------------------------------------------------------------
446
+ # Issue Links
447
+ # ---------------------------------------------------------------------------
448
+
449
+ _REVERSE_RELATION = {
450
+ IssueRelation.BLOCKS.value: IssueRelation.BLOCKED_BY.value,
451
+ IssueRelation.BLOCKED_BY.value: IssueRelation.BLOCKS.value,
452
+ IssueRelation.RELATES_TO.value: IssueRelation.RELATES_TO.value,
453
+ }
454
+
455
+
456
+ def add_issue_link(
457
+ slug: str,
458
+ issue_id: str,
459
+ target_id: str,
460
+ relation: IssueRelation,
461
+ ) -> None:
462
+ """Add a bidirectional link between two issues.
463
+
464
+ Raises ValueError on self-reference or if either issue is not found.
465
+ Idempotent — re-adding the same link is a no-op.
466
+ """
467
+ if issue_id == target_id:
468
+ raise ValueError("Cannot link an issue to itself (self-reference)")
469
+
470
+ issue = load_issue(slug, issue_id)
471
+ if not issue:
472
+ raise ValueError(f"Issue '{issue_id}' not found in '{slug}'")
473
+ target = load_issue(slug, target_id)
474
+ if not target:
475
+ raise ValueError(f"Issue '{target_id}' not found in '{slug}'")
476
+
477
+ rel_value = relation.value if hasattr(relation, "value") else relation
478
+ reverse_rel = _REVERSE_RELATION[rel_value]
479
+
480
+ # Add forward link (idempotent)
481
+ _add_link_entry(slug, issue_id, target_id, rel_value)
482
+ # Add reverse link
483
+ _add_link_entry(slug, target_id, issue_id, reverse_rel)
484
+
485
+ mark_dirty(DirtyCategory.PRODUCTS)
486
+ logger.debug("Linked {} —{}→ {}", issue_id, rel_value, target_id)
487
+
488
+
489
+ def _add_link_entry(slug: str, issue_id: str, target_id: str, relation: str) -> None:
490
+ """Add a single link entry to an issue (idempotent)."""
491
+ with _get_slug_lock(slug):
492
+ path = _issues_dir(slug) / f"{issue_id}.yaml"
493
+ data = _read_yaml(path)
494
+ if not data:
495
+ return
496
+ links = data.setdefault("issue_links", [])
497
+ # Idempotent check
498
+ if any(l["issue_id"] == target_id and l["relation"] == relation for l in links):
499
+ return
500
+ links.append({
501
+ "issue_id": target_id,
502
+ "relation": relation,
503
+ "created_at": datetime.now().isoformat(),
504
+ })
505
+ _write_yaml(path, data)
506
+
507
+
508
+ def remove_issue_link(slug: str, issue_id: str, target_id: str) -> None:
509
+ """Remove all links between two issues (both directions). Silently ignores missing links."""
510
+ _remove_link_entry(slug, issue_id, target_id)
511
+ _remove_link_entry(slug, target_id, issue_id)
512
+ mark_dirty(DirtyCategory.PRODUCTS)
513
+ logger.debug("Unlinked {} ↔ {}", issue_id, target_id)
514
+
515
+
516
+ def _remove_link_entry(slug: str, issue_id: str, target_id: str) -> None:
517
+ """Remove all link entries from issue_id to target_id."""
518
+ with _get_slug_lock(slug):
519
+ path = _issues_dir(slug) / f"{issue_id}.yaml"
520
+ data = _read_yaml(path)
521
+ if not data:
522
+ return
523
+ links = data.get("issue_links", [])
524
+ data["issue_links"] = [l for l in links if l["issue_id"] != target_id]
525
+ _write_yaml(path, data)
526
+
527
+
528
+ def get_issue_links(slug: str, issue_id: str) -> list[dict]:
529
+ """Return the issue_links list for an issue."""
530
+ issue = load_issue(slug, issue_id)
531
+ if not issue:
532
+ return []
533
+ return issue.get("issue_links", [])
534
+
535
+
536
+ def is_blocked(slug: str, issue_id: str) -> bool:
537
+ """Check if an issue is blocked by any unfinished blocker."""
538
+ issue = load_issue(slug, issue_id)
539
+ if not issue:
540
+ return False
541
+ links = issue.get("issue_links", [])
542
+ for link in links:
543
+ if link["relation"] != IssueRelation.BLOCKED_BY.value:
544
+ continue
545
+ blocker = load_issue(slug, link["issue_id"])
546
+ if blocker and blocker.get("status") not in _DONE_STATUSES:
547
+ return True
548
+ return False
549
+
550
+
551
+ # ---------------------------------------------------------------------------
552
+ # Review Checklist
553
+ # ---------------------------------------------------------------------------
554
+
555
+ _DEFAULT_REVIEW_ITEMS = [
556
+ {"key": "update_kr", "label": "更新 KR 进度", "checked": False},
557
+ {"key": "review_issues", "label": "Review open issues", "checked": False},
558
+ {"key": "assign_backlog", "label": "安排 backlog 优先级", "checked": False},
559
+ {"key": "create_issues", "label": "创建新 issues", "checked": False},
560
+ ]
561
+
562
+
563
+ def _reviews_dir(slug: str) -> Path:
564
+ return _product_dir(slug) / REVIEWS_DIR_NAME
565
+
566
+
567
+ def create_review(
568
+ slug: str,
569
+ *,
570
+ trigger: str,
571
+ trigger_ref: str = "",
572
+ owner: str,
573
+ items: list[dict] | None = None,
574
+ ) -> dict:
575
+ """Create a review checklist for a product. Returns the review dict."""
576
+ review_id = _gen_id("rev_")
577
+ now = datetime.now().isoformat()
578
+
579
+ data = {
580
+ "id": review_id,
581
+ "product_slug": slug,
582
+ "trigger": trigger,
583
+ "trigger_ref": trigger_ref,
584
+ "created_at": now,
585
+ "owner": owner,
586
+ "status": "open",
587
+ "items": items if items is not None else [dict(i) for i in _DEFAULT_REVIEW_ITEMS],
588
+ "completed_at": None,
589
+ }
590
+
591
+ rdir = _reviews_dir(slug)
592
+ rdir.mkdir(parents=True, exist_ok=True)
593
+ with _get_slug_lock(slug):
594
+ _write_yaml(rdir / f"{review_id}.yaml", data)
595
+ mark_dirty(DirtyCategory.PRODUCTS)
596
+ logger.debug("[PRODUCT] Review created: {} in {}", review_id, slug)
597
+ return data
598
+
599
+
600
+ def load_review(slug: str, review_id: str) -> dict | None:
601
+ """Load a single review by ID."""
602
+ path = _reviews_dir(slug) / f"{review_id}.yaml"
603
+ if not path.exists():
604
+ return None
605
+ return _read_yaml(path)
606
+
607
+
608
+ def list_reviews(slug: str, status: str | None = None) -> list[dict]:
609
+ """List all reviews for a product, optionally filtered by status."""
610
+ rdir = _reviews_dir(slug)
611
+ if not rdir.exists():
612
+ return []
613
+ reviews = []
614
+ for f in sorted(rdir.iterdir()):
615
+ if f.suffix == ".yaml":
616
+ data = _read_yaml(f)
617
+ if data and (status is None or data.get("status") == status):
618
+ reviews.append(data)
619
+ return reviews
620
+
621
+
622
+ def update_review_item(slug: str, review_id: str, item_key: str, *, checked: bool) -> dict:
623
+ """Check or uncheck a review item. Returns updated review dict.
624
+
625
+ Raises ValueError if review or item key not found.
626
+ """
627
+ with _get_slug_lock(slug):
628
+ path = _reviews_dir(slug) / f"{review_id}.yaml"
629
+ data = _read_yaml(path)
630
+ if not data:
631
+ raise ValueError(f"Review '{review_id}' not found in '{slug}'")
632
+ for item in data.get("items", []):
633
+ if item["key"] == item_key:
634
+ item["checked"] = checked
635
+ _write_yaml(path, data)
636
+ mark_dirty(DirtyCategory.PRODUCTS)
637
+ return data
638
+ raise ValueError(f"Item key '{item_key}' not found in review '{review_id}'")
639
+
640
+
641
+ def complete_review(slug: str, review_id: str) -> dict:
642
+ """Mark a review as completed. All items must be checked.
643
+
644
+ Raises ValueError if review not found, already completed, or has unchecked items.
645
+ """
646
+ with _get_slug_lock(slug):
647
+ path = _reviews_dir(slug) / f"{review_id}.yaml"
648
+ data = _read_yaml(path)
649
+ if not data:
650
+ raise ValueError(f"Review '{review_id}' not found in '{slug}'")
651
+ if data.get("status") == "completed":
652
+ raise ValueError(f"Review '{review_id}' is already completed")
653
+ unchecked = [i for i in data.get("items", []) if not i.get("checked")]
654
+ if unchecked:
655
+ keys = ", ".join(i["key"] for i in unchecked)
656
+ raise ValueError(f"Cannot complete review: unchecked items: {keys}")
657
+ data["status"] = "completed"
658
+ data["completed_at"] = datetime.now().isoformat()
659
+ _write_yaml(path, data)
660
+ mark_dirty(DirtyCategory.PRODUCTS)
661
+ logger.debug("[PRODUCT] Review completed: {}", review_id)
662
+ return data
663
+
664
+
427
665
  # ---------------------------------------------------------------------------
428
666
  # Product Versioning
429
667
  # ---------------------------------------------------------------------------
@@ -12,6 +12,7 @@ from loguru import logger
12
12
  from onemancompany.core.events import CompanyEvent, event_bus
13
13
  from onemancompany.core.models import (
14
14
  EventType,
15
+ IssueRelation,
15
16
  IssuePriority,
16
17
  IssueResolution,
17
18
  IssueStatus,
@@ -493,7 +494,50 @@ async def run_product_check(product_slug: str) -> dict:
493
494
  if len(unscheduled_low) >= _BACKLOG_GROOMING_THRESHOLD:
494
495
  actions_taken.append(f"{len(unscheduled_low)} P2/P3 issues unscheduled — backlog grooming needed")
495
496
 
496
- # --- Step 5: Check if owner review is needed ---
497
+ # --- Step 5: Stale review check (open > 24h) ---
498
+ from datetime import datetime as _datetime, timedelta as _timedelta
499
+
500
+ open_reviews = prod.list_reviews(product_slug, status="open")
501
+ _STALE_REVIEW_HOURS = 24
502
+ stale_reviews = []
503
+ for rev in open_reviews:
504
+ try:
505
+ created = _datetime.fromisoformat(rev.get("created_at", ""))
506
+ if _datetime.now() - created > _timedelta(hours=_STALE_REVIEW_HOURS):
507
+ stale_reviews.append(rev)
508
+ except (ValueError, TypeError):
509
+ logger.debug("[PRODUCT_CHECK] Invalid created_at on review {}", rev.get("id"))
510
+ if stale_reviews:
511
+ actions_taken.append(f"{len(stale_reviews)} stale review(s) open > {_STALE_REVIEW_HOURS}h")
512
+
513
+ # --- Step 6: Blocked issue check (blocked > 7 days) ---
514
+ _BLOCKED_DAYS_THRESHOLD = 7
515
+ for issue in all_issues:
516
+ if issue.get("status") in (IssueStatus.DONE.value, IssueStatus.RELEASED.value):
517
+ continue
518
+ links = issue.get("issue_links", [])
519
+ blocked_links = [
520
+ link for link in links
521
+ if link["relation"] == IssueRelation.BLOCKED_BY.value
522
+ and _is_blocker_unresolved(product_slug, link["issue_id"])
523
+ ]
524
+ if not blocked_links:
525
+ continue
526
+ # Use the oldest blocked_by link's created_at to determine how long blocked
527
+ oldest_blocked_at = None
528
+ for link in blocked_links:
529
+ try:
530
+ link_created = _datetime.fromisoformat(link.get("created_at", ""))
531
+ if oldest_blocked_at is None or link_created < oldest_blocked_at:
532
+ oldest_blocked_at = link_created
533
+ except (ValueError, TypeError):
534
+ logger.debug("[PRODUCT_CHECK] Invalid created_at on link in issue {}", issue.get("id"))
535
+ if oldest_blocked_at and _datetime.now() - oldest_blocked_at > _timedelta(days=_BLOCKED_DAYS_THRESHOLD):
536
+ actions_taken.append(
537
+ f"Issue '{issue['title']}' blocked for >{_BLOCKED_DAYS_THRESHOLD} days"
538
+ )
539
+
540
+ # --- Step 7: Check if owner review is needed ---
497
541
  # Conditions: backlog issues with no one working, or KRs at 0% with completed projects
498
542
  needs_review = False
499
543
  review_reasons = []
@@ -530,6 +574,11 @@ async def run_product_check(product_slug: str) -> dict:
530
574
  needs_review = True
531
575
  review_reasons.append(f"{len(unscheduled_low)} P2/P3 issues need sprint assignment")
532
576
 
577
+ # Stale reviews → needs owner review
578
+ if stale_reviews:
579
+ needs_review = True
580
+ review_reasons.append(f"{len(stale_reviews)} stale review(s) pending")
581
+
533
582
  if needs_review:
534
583
  reason = "; ".join(review_reasons)
535
584
  notified = await notify_owner(product_slug, reason=reason)
@@ -620,6 +669,38 @@ async def handle_issue_assigned(event: CompanyEvent) -> None:
620
669
  )
621
670
 
622
671
 
672
+ def _is_blocker_unresolved(slug: str, issue_id: str) -> bool:
673
+ """Check if a blocker issue is still unresolved (not done/released)."""
674
+ blocker = prod.load_issue(slug, issue_id)
675
+ if not blocker:
676
+ return False
677
+ return blocker.get("status") not in (IssueStatus.DONE.value, IssueStatus.RELEASED.value)
678
+
679
+
680
+ async def handle_sprint_closed(event: CompanyEvent) -> None:
681
+ """When a sprint is closed, auto-create a review checklist for the product owner."""
682
+ slug = event.payload.get("product_slug", "")
683
+ sprint_id = event.payload.get("sprint_id", "")
684
+
685
+ if not slug:
686
+ logger.debug("[PRODUCT_TRIGGER] handle_sprint_closed: no product_slug, skip")
687
+ return
688
+
689
+ product = prod.load_product(slug)
690
+ if not product:
691
+ logger.warning("[PRODUCT_TRIGGER] handle_sprint_closed: product '{}' not found", slug)
692
+ return
693
+
694
+ owner_id = product.get("owner_id", "")
695
+ prod.create_review(
696
+ slug=slug,
697
+ trigger="sprint_closed",
698
+ trigger_ref=sprint_id,
699
+ owner=owner_id,
700
+ )
701
+ logger.info("[PRODUCT_TRIGGER] Auto-created review for sprint {} in {}", sprint_id, slug)
702
+
703
+
623
704
  # ---------------------------------------------------------------------------
624
705
  # Registration
625
706
  # ---------------------------------------------------------------------------
@@ -649,6 +730,8 @@ def register_product_triggers() -> "asyncio.Task":
649
730
  # Only handle if it has product context
650
731
  if event.payload.get("product_slug"):
651
732
  await handle_project_complete(event)
733
+ elif event.type == EventType.SPRINT_CLOSED:
734
+ await handle_sprint_closed(event)
652
735
  except Exception:
653
736
  logger.exception(
654
737
  "[PRODUCT_TRIGGER] Error handling event {}", event.type