@alexey_platkovsky/taskpilot 0.1.0-beta.1
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/LICENSE +201 -0
- package/README.md +141 -0
- package/bin/taskpilot +454 -0
- package/package.json +30 -0
- package/requirements.lock +66 -0
- package/src/taskpilot/__init__.py +1 -0
- package/src/taskpilot/__pycache__/__init__.cpython-311.pyc +0 -0
- package/src/taskpilot/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/__init__.py +6 -0
- package/src/taskpilot/cli/__pycache__/__init__.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/app.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/app.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/context.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/context.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/errors.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/errors.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/exit_codes.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/exit_codes.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/output.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/output.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/registry.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/workspace.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/workspace.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/app.py +61 -0
- package/src/taskpilot/cli/commands/__init__.py +6 -0
- package/src/taskpilot/cli/commands/__pycache__/__init__.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/init.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/init.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/item.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/item.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/project.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/project.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/serve.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/serve.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/validate.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/validate.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/commands/init.py +116 -0
- package/src/taskpilot/cli/commands/item.py +305 -0
- package/src/taskpilot/cli/commands/project.py +50 -0
- package/src/taskpilot/cli/commands/serve.py +78 -0
- package/src/taskpilot/cli/commands/validate.py +61 -0
- package/src/taskpilot/cli/context.py +36 -0
- package/src/taskpilot/cli/errors.py +53 -0
- package/src/taskpilot/cli/exit_codes.py +20 -0
- package/src/taskpilot/cli/output.py +77 -0
- package/src/taskpilot/cli/workspace.py +33 -0
- package/src/taskpilot/core/__init__.py +5 -0
- package/src/taskpilot/core/__pycache__/__init__.cpython-311.pyc +0 -0
- package/src/taskpilot/core/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/taskpilot/core/__pycache__/comments.cpython-311.pyc +0 -0
- package/src/taskpilot/core/__pycache__/comments.cpython-314.pyc +0 -0
- package/src/taskpilot/core/__pycache__/item_io.cpython-311.pyc +0 -0
- package/src/taskpilot/core/__pycache__/item_io.cpython-314.pyc +0 -0
- package/src/taskpilot/core/__pycache__/layout.cpython-311.pyc +0 -0
- package/src/taskpilot/core/__pycache__/layout.cpython-314.pyc +0 -0
- package/src/taskpilot/core/__pycache__/loader.cpython-314.pyc +0 -0
- package/src/taskpilot/core/__pycache__/models.cpython-311.pyc +0 -0
- package/src/taskpilot/core/__pycache__/models.cpython-314.pyc +0 -0
- package/src/taskpilot/core/__pycache__/project.cpython-311.pyc +0 -0
- package/src/taskpilot/core/__pycache__/project.cpython-314.pyc +0 -0
- package/src/taskpilot/core/__pycache__/timestamps.cpython-311.pyc +0 -0
- package/src/taskpilot/core/__pycache__/timestamps.cpython-314.pyc +0 -0
- package/src/taskpilot/core/__pycache__/validation.cpython-311.pyc +0 -0
- package/src/taskpilot/core/__pycache__/validation.cpython-314.pyc +0 -0
- package/src/taskpilot/core/__pycache__/yaml_io.cpython-311.pyc +0 -0
- package/src/taskpilot/core/__pycache__/yaml_io.cpython-314.pyc +0 -0
- package/src/taskpilot/core/comments.py +238 -0
- package/src/taskpilot/core/item_io.py +123 -0
- package/src/taskpilot/core/layout.py +137 -0
- package/src/taskpilot/core/loader.py +102 -0
- package/src/taskpilot/core/models.py +114 -0
- package/src/taskpilot/core/project.py +151 -0
- package/src/taskpilot/core/timestamps.py +54 -0
- package/src/taskpilot/core/validation.py +385 -0
- package/src/taskpilot/core/yaml_io.py +57 -0
- package/src/taskpilot/server/__init__.py +0 -0
- package/src/taskpilot/server/__pycache__/__init__.cpython-311.pyc +0 -0
- package/src/taskpilot/server/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/taskpilot/server/__pycache__/app.cpython-311.pyc +0 -0
- package/src/taskpilot/server/__pycache__/app.cpython-314.pyc +0 -0
- package/src/taskpilot/server/__pycache__/schemas.cpython-311.pyc +0 -0
- package/src/taskpilot/server/__pycache__/schemas.cpython-314.pyc +0 -0
- package/src/taskpilot/server/app.py +134 -0
- package/src/taskpilot/server/routes/__init__.py +0 -0
- package/src/taskpilot/server/routes/__pycache__/__init__.cpython-311.pyc +0 -0
- package/src/taskpilot/server/routes/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/taskpilot/server/routes/__pycache__/projects.cpython-311.pyc +0 -0
- package/src/taskpilot/server/routes/__pycache__/projects.cpython-314.pyc +0 -0
- package/src/taskpilot/server/routes/projects.py +160 -0
- package/src/taskpilot/server/schemas.py +76 -0
- package/src/taskpilot/services/__init__.py +8 -0
- package/src/taskpilot/services/__pycache__/__init__.cpython-311.pyc +0 -0
- package/src/taskpilot/services/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/taskpilot/services/__pycache__/comment_service.cpython-311.pyc +0 -0
- package/src/taskpilot/services/__pycache__/comment_service.cpython-314.pyc +0 -0
- package/src/taskpilot/services/__pycache__/errors.cpython-311.pyc +0 -0
- package/src/taskpilot/services/__pycache__/errors.cpython-314.pyc +0 -0
- package/src/taskpilot/services/__pycache__/hierarchy.cpython-311.pyc +0 -0
- package/src/taskpilot/services/__pycache__/hierarchy.cpython-314.pyc +0 -0
- package/src/taskpilot/services/__pycache__/item_service.cpython-311.pyc +0 -0
- package/src/taskpilot/services/__pycache__/item_service.cpython-314.pyc +0 -0
- package/src/taskpilot/services/__pycache__/link_service.cpython-311.pyc +0 -0
- package/src/taskpilot/services/__pycache__/link_service.cpython-314.pyc +0 -0
- package/src/taskpilot/services/__pycache__/operation_validation.cpython-311.pyc +0 -0
- package/src/taskpilot/services/__pycache__/operation_validation.cpython-314.pyc +0 -0
- package/src/taskpilot/services/__pycache__/project_service.cpython-311.pyc +0 -0
- package/src/taskpilot/services/__pycache__/project_service.cpython-314.pyc +0 -0
- package/src/taskpilot/services/__pycache__/registry.cpython-311.pyc +0 -0
- package/src/taskpilot/services/__pycache__/registry.cpython-314.pyc +0 -0
- package/src/taskpilot/services/__pycache__/reverse_links.cpython-314.pyc +0 -0
- package/src/taskpilot/services/comment_service.py +62 -0
- package/src/taskpilot/services/errors.py +26 -0
- package/src/taskpilot/services/hierarchy.py +107 -0
- package/src/taskpilot/services/item_service.py +264 -0
- package/src/taskpilot/services/link_service.py +97 -0
- package/src/taskpilot/services/operation_validation.py +52 -0
- package/src/taskpilot/services/project_service.py +111 -0
- package/src/taskpilot/services/registry.py +194 -0
- package/src/taskpilot/services/reverse_links.py +60 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
6
|
+
|
|
7
|
+
from taskpilot.services.registry import RegistryEntry, list_projects as registry_list
|
|
8
|
+
from taskpilot.core.layout import WorkspacePaths
|
|
9
|
+
from taskpilot.server.schemas import (
|
|
10
|
+
ItemDetail,
|
|
11
|
+
ItemSummary,
|
|
12
|
+
ItemUpdateInput,
|
|
13
|
+
ProjectSummary,
|
|
14
|
+
ValidationFindingOut,
|
|
15
|
+
ValidationReportOut,
|
|
16
|
+
)
|
|
17
|
+
from taskpilot.core.validation import validate_workspace
|
|
18
|
+
from taskpilot.services import comment_service as comment_svc
|
|
19
|
+
from taskpilot.services import item_service as item_svc
|
|
20
|
+
from taskpilot.services.errors import ValidationFailed
|
|
21
|
+
|
|
22
|
+
router = APIRouter(tags=["projects"])
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _registry_entry(request: Request, project_id: str) -> RegistryEntry:
|
|
26
|
+
registry_dir: str = request.app.state.registry_dir
|
|
27
|
+
for entry in registry_list(Path(registry_dir)):
|
|
28
|
+
if entry.id == project_id:
|
|
29
|
+
return entry
|
|
30
|
+
raise HTTPException(status_code=404, detail=f"Project not found: {project_id}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _paths(entry: RegistryEntry) -> WorkspacePaths:
|
|
34
|
+
return WorkspacePaths.for_root(Path(entry.path))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _require_item_in_project(entry: RegistryEntry, item_id: str) -> None:
|
|
38
|
+
"""Reject item ids that do not belong to the path project's key (404).
|
|
39
|
+
|
|
40
|
+
Item ids are project-key prefixed (e.g. ``VP-1``). Scoping the lookup to the
|
|
41
|
+
path ``project_id`` keeps the route honest if a workspace ever holds items
|
|
42
|
+
from more than one project key.
|
|
43
|
+
"""
|
|
44
|
+
if not item_id.startswith(f"{entry.key}-"):
|
|
45
|
+
raise HTTPException(
|
|
46
|
+
status_code=404, detail=f"No item found with id {item_id!r}"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _item_summary(item) -> dict:
|
|
51
|
+
d = item.model_dump()
|
|
52
|
+
d["valid"] = True
|
|
53
|
+
return d
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _item_detail(item, ws: WorkspacePaths) -> dict:
|
|
57
|
+
d = item.model_dump()
|
|
58
|
+
try:
|
|
59
|
+
comments = comment_svc.list_comments(ws, item.id)
|
|
60
|
+
d["comments"] = [c.model_dump() for c in comments]
|
|
61
|
+
except ValidationFailed:
|
|
62
|
+
d["comments"] = []
|
|
63
|
+
d["valid"] = True
|
|
64
|
+
return d
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@router.get("/health")
|
|
68
|
+
def health() -> dict[str, str]:
|
|
69
|
+
return {"status": "ok"}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@router.get("/projects", response_model=list[ProjectSummary])
|
|
73
|
+
def list_all_projects(request: Request) -> list[ProjectSummary]:
|
|
74
|
+
registry_dir: str = request.app.state.registry_dir
|
|
75
|
+
entries = registry_list(Path(registry_dir))
|
|
76
|
+
return [
|
|
77
|
+
ProjectSummary(
|
|
78
|
+
id=e.id,
|
|
79
|
+
key=e.key,
|
|
80
|
+
name=e.name,
|
|
81
|
+
active=e.active,
|
|
82
|
+
)
|
|
83
|
+
for e in entries
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@router.get(
|
|
88
|
+
"/projects/{project_id}/items",
|
|
89
|
+
response_model=list[ItemSummary],
|
|
90
|
+
)
|
|
91
|
+
def list_project_items(request: Request, project_id: str) -> list[ItemSummary]:
|
|
92
|
+
entry = _registry_entry(request, project_id)
|
|
93
|
+
ws = _paths(entry)
|
|
94
|
+
items = item_svc.list_items(ws, project=entry.key, include_deleted=False)
|
|
95
|
+
valid_summaries: list[ItemSummary] = [
|
|
96
|
+
ItemSummary(**_item_summary(i)) for i in items
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
invalid_stubs = item_svc.list_invalid_item_stubs(ws, project=entry.key)
|
|
100
|
+
invalid_summaries: list[ItemSummary] = [
|
|
101
|
+
ItemSummary(
|
|
102
|
+
id=item_id,
|
|
103
|
+
title=f"[unparseable: {item_id}.yaml]",
|
|
104
|
+
type="unknown",
|
|
105
|
+
status="unknown",
|
|
106
|
+
priority="unknown",
|
|
107
|
+
valid=False,
|
|
108
|
+
findings=[
|
|
109
|
+
ValidationFindingOut(
|
|
110
|
+
severity="error",
|
|
111
|
+
code="parse_error",
|
|
112
|
+
path=rel_path,
|
|
113
|
+
message=error_msg,
|
|
114
|
+
)
|
|
115
|
+
],
|
|
116
|
+
)
|
|
117
|
+
for item_id, rel_path, error_msg in invalid_stubs
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
return valid_summaries + invalid_summaries
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@router.get(
|
|
124
|
+
"/projects/{project_id}/validate",
|
|
125
|
+
response_model=ValidationReportOut,
|
|
126
|
+
)
|
|
127
|
+
def validate_project(request: Request, project_id: str) -> ValidationReportOut:
|
|
128
|
+
entry = _registry_entry(request, project_id)
|
|
129
|
+
ws = _paths(entry)
|
|
130
|
+
return ValidationReportOut(**validate_workspace(ws).to_dict())
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@router.get(
|
|
134
|
+
"/projects/{project_id}/items/{item_id}",
|
|
135
|
+
response_model=ItemDetail,
|
|
136
|
+
)
|
|
137
|
+
def get_item_detail(request: Request, project_id: str, item_id: str) -> ItemDetail:
|
|
138
|
+
entry = _registry_entry(request, project_id)
|
|
139
|
+
_require_item_in_project(entry, item_id)
|
|
140
|
+
ws = _paths(entry)
|
|
141
|
+
item = item_svc.read_item(ws, item_id)
|
|
142
|
+
return ItemDetail(**_item_detail(item, ws))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@router.patch(
|
|
146
|
+
"/projects/{project_id}/items/{item_id}",
|
|
147
|
+
response_model=ItemDetail,
|
|
148
|
+
)
|
|
149
|
+
def patch_item(
|
|
150
|
+
request: Request,
|
|
151
|
+
project_id: str,
|
|
152
|
+
item_id: str,
|
|
153
|
+
body: ItemUpdateInput,
|
|
154
|
+
) -> ItemDetail:
|
|
155
|
+
entry = _registry_entry(request, project_id)
|
|
156
|
+
_require_item_in_project(entry, item_id)
|
|
157
|
+
ws = _paths(entry)
|
|
158
|
+
fields = body.model_dump(exclude_unset=True)
|
|
159
|
+
item = item_svc.update_item(ws, item_id, **fields)
|
|
160
|
+
return ItemDetail(**_item_detail(item, ws))
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict
|
|
4
|
+
|
|
5
|
+
from taskpilot.core.models import Item
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ProjectSummary(BaseModel):
|
|
9
|
+
id: str
|
|
10
|
+
key: str
|
|
11
|
+
name: str
|
|
12
|
+
active: bool
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CommentOut(BaseModel):
|
|
16
|
+
schema_version: int
|
|
17
|
+
created_at: str
|
|
18
|
+
created_by: str | None = None
|
|
19
|
+
body: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ValidationFindingOut(BaseModel):
|
|
23
|
+
severity: str
|
|
24
|
+
code: str
|
|
25
|
+
path: str
|
|
26
|
+
field: str | None = None
|
|
27
|
+
item_id: str | None = None
|
|
28
|
+
message: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ValidationSummaryOut(BaseModel):
|
|
32
|
+
errors: int
|
|
33
|
+
warnings: int
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ValidationReportOut(BaseModel):
|
|
37
|
+
ok: bool
|
|
38
|
+
summary: ValidationSummaryOut
|
|
39
|
+
findings: list[ValidationFindingOut]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ItemSummary(BaseModel):
|
|
43
|
+
id: str
|
|
44
|
+
title: str
|
|
45
|
+
type: str
|
|
46
|
+
status: str
|
|
47
|
+
priority: str
|
|
48
|
+
created_at: str | None = None
|
|
49
|
+
updated_at: str | None = None
|
|
50
|
+
parent_id: str | None = None
|
|
51
|
+
valid: bool = True
|
|
52
|
+
findings: list[ValidationFindingOut] = []
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ItemDetail(Item):
|
|
56
|
+
"""Full item representation returned by detail and patch endpoints.
|
|
57
|
+
|
|
58
|
+
Extends the domain Item with API-only fields: comments (populated from the
|
|
59
|
+
comment service) and validity metadata (valid flag + findings). The config
|
|
60
|
+
override drops extra=allow so unknown YAML fields never leak into responses.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
model_config = ConfigDict(
|
|
64
|
+
extra="ignore", use_enum_values=True, validate_default=True
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
comments: list[CommentOut] = []
|
|
68
|
+
valid: bool = True
|
|
69
|
+
findings: list[ValidationFindingOut] = []
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ItemUpdateInput(BaseModel):
|
|
73
|
+
title: str | None = None
|
|
74
|
+
description: str | None = None
|
|
75
|
+
priority: str | None = None
|
|
76
|
+
status: str | None = None
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""TaskPilot domain/service layer (feature F002).
|
|
2
|
+
|
|
3
|
+
Stateless business-rule operations for projects, items, comments, and links.
|
|
4
|
+
This is the single source of business rules shared by the CLI, REST API, and
|
|
5
|
+
future MCP adapters. Services orchestrate the F001 storage primitives in
|
|
6
|
+
``taskpilot.core`` and own domain rules (hierarchy, links, soft delete,
|
|
7
|
+
pre-write validation); they never import adapter or framework code.
|
|
8
|
+
"""
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Comment domain service: add, list (task F002-T6, requirement F002-R6).
|
|
2
|
+
|
|
3
|
+
Comments are append-only Markdown files under ``comments/<item_id>/``; the
|
|
4
|
+
filename timestamp is the comment identity. Adding a comment never touches the
|
|
5
|
+
item YAML. This service wraps the F001 comment primitives with domain rules:
|
|
6
|
+
the target item must exist, and low-level parse/validation failures surface as
|
|
7
|
+
:class:`ValidationFailed`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from pydantic import ValidationError
|
|
15
|
+
|
|
16
|
+
from taskpilot.core import comments as core_comments
|
|
17
|
+
from taskpilot.core.comments import Comment, CommentParseError
|
|
18
|
+
from taskpilot.core.layout import WorkspacePaths
|
|
19
|
+
from taskpilot.core.timestamps import utc_now_iso
|
|
20
|
+
from taskpilot.services.errors import NotFound, ValidationFailed
|
|
21
|
+
|
|
22
|
+
__all__ = ["add_comment", "list_comments"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def add_comment(
|
|
26
|
+
paths: WorkspacePaths,
|
|
27
|
+
item_id: str,
|
|
28
|
+
*,
|
|
29
|
+
body: str,
|
|
30
|
+
created_by: str,
|
|
31
|
+
now: str | None = None,
|
|
32
|
+
) -> Path:
|
|
33
|
+
"""Add a comment to ``item_id`` and return the written file path (F002-R6).
|
|
34
|
+
|
|
35
|
+
A comment's identity *is* its filename (spec ``0002``), so the path — not the
|
|
36
|
+
in-memory model — is the useful result for callers such as the CLI, matching
|
|
37
|
+
the core ``write_comment`` contract. The owning item must exist; a timestamped
|
|
38
|
+
``.md`` file is written (same-second collisions get ``-N`` suffixes) and the
|
|
39
|
+
item YAML is left untouched. Raises :class:`NotFound` for an unknown item and
|
|
40
|
+
:class:`ValidationFailed` for invalid comment fields (e.g. empty author).
|
|
41
|
+
"""
|
|
42
|
+
if not paths.item_file(item_id).is_file():
|
|
43
|
+
raise NotFound(f"No item found with id {item_id!r}")
|
|
44
|
+
try:
|
|
45
|
+
comment = Comment(
|
|
46
|
+
created_at=now or utc_now_iso(), created_by=created_by, body=body
|
|
47
|
+
)
|
|
48
|
+
except ValidationError as exc:
|
|
49
|
+
raise ValidationFailed(f"Cannot add comment to {item_id!r}: {exc}") from exc
|
|
50
|
+
return core_comments.write_comment(paths, item_id, comment)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def list_comments(paths: WorkspacePaths, item_id: str) -> list[Comment]:
|
|
54
|
+
"""List ``item_id``'s comments ordered chronologically (F002-R6).
|
|
55
|
+
|
|
56
|
+
Returns an empty list when the item has no comments. Raises
|
|
57
|
+
:class:`ValidationFailed` if a stored comment file is malformed.
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
return core_comments.list_comments(paths, item_id)
|
|
61
|
+
except (CommentParseError, ValidationError) as exc:
|
|
62
|
+
raise ValidationFailed(f"Invalid comment for {item_id!r}: {exc}") from exc
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Domain-service error types (feature F002).
|
|
2
|
+
|
|
3
|
+
Services raise these instead of leaking pydantic ``ValidationError``, ``OSError``,
|
|
4
|
+
or other low-level exceptions to adapters. Each error carries a human-readable
|
|
5
|
+
message so CLI/API adapters can render a descriptive response (F002-R8).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
__all__ = ["ServiceError", "ValidationFailed", "NotFound", "ConflictError"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ServiceError(Exception):
|
|
14
|
+
"""Base class for all domain-service errors."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ValidationFailed(ServiceError):
|
|
18
|
+
"""Operation input failed validation before any file was written (F002-R8)."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NotFound(ServiceError):
|
|
22
|
+
"""A requested project or item does not exist."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ConflictError(ServiceError):
|
|
26
|
+
"""The operation conflicts with existing canonical state (e.g. create over an existing project)."""
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Type-based parent/child hierarchy rules (task F002-T3, requirement F002-R3).
|
|
2
|
+
|
|
3
|
+
Enforced whenever an item's ``parent_id`` is set (on create or update). Allowed
|
|
4
|
+
parent -> child types (see ``docs/architecture.md`` "Hierarchy"):
|
|
5
|
+
|
|
6
|
+
epic -> feature, task
|
|
7
|
+
feature -> task
|
|
8
|
+
task -> bug
|
|
9
|
+
bug -> (none)
|
|
10
|
+
|
|
11
|
+
Also rejects an item parenting itself and any change that would close a cycle.
|
|
12
|
+
The strictly-decreasing type table already makes cycles impossible for valid
|
|
13
|
+
types; the self/cycle guards are explicit so intent survives future table
|
|
14
|
+
changes and so error messages are precise.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from taskpilot.core.layout import WorkspacePaths
|
|
20
|
+
from taskpilot.services.errors import NotFound, ValidationFailed
|
|
21
|
+
|
|
22
|
+
__all__ = ["ALLOWED_CHILD_TYPES", "validate_parent", "validate_can_parent_children"]
|
|
23
|
+
|
|
24
|
+
#: Allowed child types keyed by parent type.
|
|
25
|
+
ALLOWED_CHILD_TYPES: dict[str, set[str]] = {
|
|
26
|
+
"epic": {"feature", "task"},
|
|
27
|
+
"feature": {"task"},
|
|
28
|
+
"task": {"bug"},
|
|
29
|
+
"bug": set(),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def validate_parent(
|
|
34
|
+
paths: WorkspacePaths,
|
|
35
|
+
*,
|
|
36
|
+
child_id: str,
|
|
37
|
+
child_type: str,
|
|
38
|
+
parent_id: str | None,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Validate that ``child`` may have ``parent_id`` as its parent.
|
|
41
|
+
|
|
42
|
+
No-op when ``parent_id`` is ``None``. Raises :class:`ValidationFailed` when the
|
|
43
|
+
parent is the child itself, does not exist, is a type that cannot parent
|
|
44
|
+
``child_type``, or when the assignment would close an ancestor cycle.
|
|
45
|
+
"""
|
|
46
|
+
from taskpilot.services.item_service import read_item # local import avoids a cycle
|
|
47
|
+
|
|
48
|
+
if parent_id is None:
|
|
49
|
+
return
|
|
50
|
+
if parent_id == child_id:
|
|
51
|
+
raise ValidationFailed(f"Item {child_id!r} cannot be its own parent")
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
parent = read_item(paths, parent_id)
|
|
55
|
+
except NotFound as exc:
|
|
56
|
+
raise ValidationFailed(
|
|
57
|
+
f"parent_id references unknown item {parent_id!r}"
|
|
58
|
+
) from exc
|
|
59
|
+
|
|
60
|
+
allowed = ALLOWED_CHILD_TYPES.get(parent.type, set())
|
|
61
|
+
if child_type not in allowed:
|
|
62
|
+
raise ValidationFailed(
|
|
63
|
+
f"A {parent.type} cannot be the parent of a {child_type} "
|
|
64
|
+
f"(allowed children of {parent.type}: {sorted(allowed) or 'none'})"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Walk the parent's ancestors; reaching child_id means this closes a cycle.
|
|
68
|
+
seen: set[str] = set()
|
|
69
|
+
cursor = parent
|
|
70
|
+
while cursor.parent_id:
|
|
71
|
+
if cursor.parent_id == child_id:
|
|
72
|
+
raise ValidationFailed(
|
|
73
|
+
f"Setting parent of {child_id!r} to {parent_id!r} would create a cycle"
|
|
74
|
+
)
|
|
75
|
+
if cursor.parent_id in seen:
|
|
76
|
+
break # pre-existing cycle elsewhere; stop rather than loop forever
|
|
77
|
+
seen.add(cursor.parent_id)
|
|
78
|
+
try:
|
|
79
|
+
cursor = read_item(paths, cursor.parent_id)
|
|
80
|
+
except (NotFound, ValidationFailed):
|
|
81
|
+
break
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def validate_can_parent_children(
|
|
85
|
+
paths: WorkspacePaths,
|
|
86
|
+
*,
|
|
87
|
+
parent_id: str,
|
|
88
|
+
parent_type: str,
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Ensure ``parent_type`` may still parent every item currently pointing at it.
|
|
91
|
+
|
|
92
|
+
Called when an item's ``type`` changes: a parent must not be retyped into a
|
|
93
|
+
type that cannot legally parent its existing children (which would persist an
|
|
94
|
+
item-graph the hierarchy table forbids). Raises :class:`ValidationFailed`
|
|
95
|
+
naming the first offending child.
|
|
96
|
+
"""
|
|
97
|
+
from taskpilot.services.item_service import (
|
|
98
|
+
list_items,
|
|
99
|
+
) # local import avoids a cycle
|
|
100
|
+
|
|
101
|
+
allowed = ALLOWED_CHILD_TYPES.get(parent_type, set())
|
|
102
|
+
for child in list_items(paths, include_deleted=True):
|
|
103
|
+
if child.parent_id == parent_id and child.type not in allowed:
|
|
104
|
+
raise ValidationFailed(
|
|
105
|
+
f"Cannot change {parent_id!r} to type {parent_type!r}: it parents "
|
|
106
|
+
f"{child.id!r} (a {child.type}), which a {parent_type} cannot parent"
|
|
107
|
+
)
|