@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 +1 -1
- package/pyproject.toml +1 -1
- package/src/onemancompany/api/routes.py +28 -15
- package/src/onemancompany/core/product.py +48 -14
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -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
|
-
|
|
7201
|
-
|
|
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
|
-
|
|
7227
|
-
|
|
7228
|
-
|
|
7229
|
-
|
|
7230
|
-
|
|
7231
|
-
|
|
7232
|
-
raise HTTPException(status_code=
|
|
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
|
-
|
|
7261
|
-
|
|
7262
|
-
|
|
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
|
-
|
|
7279
|
-
|
|
7280
|
-
|
|
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
|
-
|
|
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
|
|
384
|
-
"""Update issue fields. Returns updated dict
|
|
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
|
-
|
|
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
|
|
408
|
-
"""Close an issue with a resolution. Returns updated dict
|
|
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
|
-
|
|
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
|
|
427
|
-
"""Reopen a closed issue. Increments reopened_count. Returns updated dict
|
|
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
|
-
|
|
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):
|