@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 +1 -1
- package/pyproject.toml +1 -1
- package/src/onemancompany/agents/product_tools.py +147 -1
- package/src/onemancompany/api/routes.py +137 -0
- package/src/onemancompany/core/config.py +1 -0
- package/src/onemancompany/core/models.py +9 -0
- package/src/onemancompany/core/product.py +241 -3
- package/src/onemancompany/core/product_triggers.py +84 -1
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -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
|
-
"
|
|
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
|
-
|
|
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:
|
|
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
|