monoco-toolkit 0.1.1__py3-none-any.whl → 0.2.8__py3-none-any.whl
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.
- monoco/cli/__init__.py +0 -0
- monoco/cli/project.py +87 -0
- monoco/cli/workspace.py +46 -0
- monoco/core/agent/__init__.py +5 -0
- monoco/core/agent/action.py +144 -0
- monoco/core/agent/adapters.py +129 -0
- monoco/core/agent/protocol.py +31 -0
- monoco/core/agent/state.py +106 -0
- monoco/core/config.py +212 -17
- monoco/core/execution.py +62 -0
- monoco/core/feature.py +58 -0
- monoco/core/git.py +51 -2
- monoco/core/injection.py +196 -0
- monoco/core/integrations.py +242 -0
- monoco/core/lsp.py +68 -0
- monoco/core/output.py +21 -3
- monoco/core/registry.py +36 -0
- monoco/core/resources/en/AGENTS.md +8 -0
- monoco/core/resources/en/SKILL.md +66 -0
- monoco/core/resources/zh/AGENTS.md +8 -0
- monoco/core/resources/zh/SKILL.md +65 -0
- monoco/core/setup.py +96 -110
- monoco/core/skills.py +444 -0
- monoco/core/state.py +53 -0
- monoco/core/sync.py +224 -0
- monoco/core/telemetry.py +4 -1
- monoco/core/workspace.py +85 -20
- monoco/daemon/app.py +127 -58
- monoco/daemon/models.py +4 -0
- monoco/daemon/services.py +56 -155
- monoco/features/config/commands.py +125 -44
- monoco/features/i18n/adapter.py +29 -0
- monoco/features/i18n/commands.py +89 -10
- monoco/features/i18n/core.py +113 -27
- monoco/features/i18n/resources/en/AGENTS.md +8 -0
- monoco/features/i18n/resources/en/SKILL.md +94 -0
- monoco/features/i18n/resources/zh/AGENTS.md +8 -0
- monoco/features/i18n/resources/zh/SKILL.md +94 -0
- monoco/features/issue/adapter.py +34 -0
- monoco/features/issue/commands.py +343 -101
- monoco/features/issue/core.py +384 -150
- monoco/features/issue/domain/__init__.py +0 -0
- monoco/features/issue/domain/lifecycle.py +126 -0
- monoco/features/issue/domain/models.py +170 -0
- monoco/features/issue/domain/parser.py +223 -0
- monoco/features/issue/domain/workspace.py +104 -0
- monoco/features/issue/engine/__init__.py +22 -0
- monoco/features/issue/engine/config.py +172 -0
- monoco/features/issue/engine/machine.py +185 -0
- monoco/features/issue/engine/models.py +18 -0
- monoco/features/issue/linter.py +325 -120
- monoco/features/issue/lsp/__init__.py +3 -0
- monoco/features/issue/lsp/definition.py +72 -0
- monoco/features/issue/migration.py +134 -0
- monoco/features/issue/models.py +46 -24
- monoco/features/issue/monitor.py +94 -0
- monoco/features/issue/resources/en/AGENTS.md +20 -0
- monoco/features/issue/resources/en/SKILL.md +111 -0
- monoco/features/issue/resources/zh/AGENTS.md +20 -0
- monoco/features/issue/resources/zh/SKILL.md +138 -0
- monoco/features/issue/validator.py +455 -0
- monoco/features/spike/adapter.py +30 -0
- monoco/features/spike/commands.py +45 -24
- monoco/features/spike/core.py +6 -40
- monoco/features/spike/resources/en/AGENTS.md +7 -0
- monoco/features/spike/resources/en/SKILL.md +74 -0
- monoco/features/spike/resources/zh/AGENTS.md +7 -0
- monoco/features/spike/resources/zh/SKILL.md +74 -0
- monoco/main.py +91 -2
- monoco_toolkit-0.2.8.dist-info/METADATA +136 -0
- monoco_toolkit-0.2.8.dist-info/RECORD +83 -0
- monoco_toolkit-0.1.1.dist-info/METADATA +0 -93
- monoco_toolkit-0.1.1.dist-info/RECORD +0 -33
- {monoco_toolkit-0.1.1.dist-info → monoco_toolkit-0.2.8.dist-info}/WHEEL +0 -0
- {monoco_toolkit-0.1.1.dist-info → monoco_toolkit-0.2.8.dist-info}/entry_points.txt +0 -0
- {monoco_toolkit-0.1.1.dist-info → monoco_toolkit-0.2.8.dist-info}/licenses/LICENSE +0 -0
monoco/features/issue/core.py
CHANGED
|
@@ -6,16 +6,25 @@ from typing import List, Dict, Optional, Tuple, Any, Set, Set
|
|
|
6
6
|
from datetime import datetime
|
|
7
7
|
from .models import IssueMetadata, IssueType, IssueStatus, IssueSolution, IssueStage, IssueDetail, IsolationType, IssueIsolation, IssueID, current_time, generate_uid
|
|
8
8
|
from monoco.core import git
|
|
9
|
-
from monoco.core.config import get_config
|
|
9
|
+
from monoco.core.config import get_config, MonocoConfig
|
|
10
|
+
from monoco.core.lsp import DiagnosticSeverity
|
|
11
|
+
from .validator import IssueValidator
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
IssueType.EPIC: "EPIC",
|
|
13
|
-
IssueType.FEATURE: "FEAT",
|
|
14
|
-
IssueType.CHORE: "CHORE",
|
|
15
|
-
IssueType.FIX: "FIX"
|
|
16
|
-
}
|
|
13
|
+
from .engine import get_engine
|
|
17
14
|
|
|
18
|
-
|
|
15
|
+
def get_prefix_map(issues_root: Path) -> Dict[str, str]:
|
|
16
|
+
engine = get_engine(str(issues_root.parent))
|
|
17
|
+
return engine.get_prefix_map()
|
|
18
|
+
|
|
19
|
+
def get_reverse_prefix_map(issues_root: Path) -> Dict[str, str]:
|
|
20
|
+
prefix_map = get_prefix_map(issues_root)
|
|
21
|
+
return {v: k for k, v in prefix_map.items()}
|
|
22
|
+
|
|
23
|
+
def get_issue_dir(issue_type: str, issues_root: Path) -> Path:
|
|
24
|
+
engine = get_engine(str(issues_root.parent))
|
|
25
|
+
folder_map = engine.get_folder_map()
|
|
26
|
+
folder = folder_map.get(issue_type, issue_type.capitalize() + "s")
|
|
27
|
+
return issues_root / folder
|
|
19
28
|
|
|
20
29
|
def _get_slug(title: str) -> str:
|
|
21
30
|
slug = title.lower()
|
|
@@ -29,15 +38,6 @@ def _get_slug(title: str) -> str:
|
|
|
29
38
|
|
|
30
39
|
return slug
|
|
31
40
|
|
|
32
|
-
def get_issue_dir(issue_type: IssueType, issues_root: Path) -> Path:
|
|
33
|
-
mapping = {
|
|
34
|
-
IssueType.EPIC: "Epics",
|
|
35
|
-
IssueType.FEATURE: "Features",
|
|
36
|
-
IssueType.CHORE: "Chores",
|
|
37
|
-
IssueType.FIX: "Fixes",
|
|
38
|
-
}
|
|
39
|
-
return issues_root / mapping[issue_type]
|
|
40
|
-
|
|
41
41
|
def parse_issue(file_path: Path) -> Optional[IssueMetadata]:
|
|
42
42
|
if not file_path.suffix == ".md":
|
|
43
43
|
return None
|
|
@@ -51,7 +51,11 @@ def parse_issue(file_path: Path) -> Optional[IssueMetadata]:
|
|
|
51
51
|
data = yaml.safe_load(match.group(1))
|
|
52
52
|
if not isinstance(data, dict):
|
|
53
53
|
return None
|
|
54
|
-
|
|
54
|
+
|
|
55
|
+
data['path'] = str(file_path.absolute())
|
|
56
|
+
meta = IssueMetadata(**data)
|
|
57
|
+
meta.actions = get_available_actions(meta)
|
|
58
|
+
return meta
|
|
55
59
|
except Exception:
|
|
56
60
|
return None
|
|
57
61
|
|
|
@@ -72,12 +76,15 @@ def parse_issue_detail(file_path: Path) -> Optional[IssueDetail]:
|
|
|
72
76
|
data = yaml.safe_load(yaml_str)
|
|
73
77
|
if not isinstance(data, dict):
|
|
74
78
|
return None
|
|
79
|
+
|
|
80
|
+
data['path'] = str(file_path.absolute())
|
|
75
81
|
return IssueDetail(**data, body=body, raw_content=content)
|
|
76
82
|
except Exception:
|
|
77
83
|
return None
|
|
78
84
|
|
|
79
|
-
def find_next_id(issue_type:
|
|
80
|
-
|
|
85
|
+
def find_next_id(issue_type: str, issues_root: Path) -> str:
|
|
86
|
+
prefix_map = get_prefix_map(issues_root)
|
|
87
|
+
prefix = prefix_map.get(issue_type, "ISSUE")
|
|
81
88
|
pattern = re.compile(rf"{prefix}-(\d+)")
|
|
82
89
|
max_id = 0
|
|
83
90
|
|
|
@@ -118,13 +125,35 @@ def create_issue_file(
|
|
|
118
125
|
|
|
119
126
|
issue_id = find_next_id(issue_type, issues_root)
|
|
120
127
|
base_type_dir = get_issue_dir(issue_type, issues_root)
|
|
121
|
-
target_dir = base_type_dir / status
|
|
128
|
+
target_dir = base_type_dir / status
|
|
122
129
|
|
|
123
130
|
if subdir:
|
|
124
131
|
target_dir = target_dir / subdir
|
|
125
132
|
|
|
126
133
|
target_dir.mkdir(parents=True, exist_ok=True)
|
|
127
134
|
|
|
135
|
+
# Auto-Populate Tags with required IDs (Requirement: Maintain tags field with parent/deps/related/self IDs)
|
|
136
|
+
# Ensure they are prefixed with '#' for tagging convention if not present (usually tags are just strings, but user asked for #ID)
|
|
137
|
+
auto_tags = set(tags) if tags else set()
|
|
138
|
+
|
|
139
|
+
# 1. Add Parent
|
|
140
|
+
if parent:
|
|
141
|
+
auto_tags.add(f"#{parent}")
|
|
142
|
+
|
|
143
|
+
# 2. Add Dependencies
|
|
144
|
+
for dep in dependencies:
|
|
145
|
+
auto_tags.add(f"#{dep}")
|
|
146
|
+
|
|
147
|
+
# 3. Add Related
|
|
148
|
+
for rel in related:
|
|
149
|
+
auto_tags.add(f"#{rel}")
|
|
150
|
+
|
|
151
|
+
# 4. Add Self (as per instruction "auto add this issue... number")
|
|
152
|
+
# Note: issue_id is generated just above
|
|
153
|
+
auto_tags.add(f"#{issue_id}")
|
|
154
|
+
|
|
155
|
+
final_tags = sorted(list(auto_tags))
|
|
156
|
+
|
|
128
157
|
metadata = IssueMetadata(
|
|
129
158
|
id=issue_id,
|
|
130
159
|
uid=generate_uid(), # Generate global unique identifier
|
|
@@ -136,85 +165,107 @@ def create_issue_file(
|
|
|
136
165
|
dependencies=dependencies,
|
|
137
166
|
related=related,
|
|
138
167
|
sprint=sprint,
|
|
139
|
-
tags=
|
|
168
|
+
tags=final_tags,
|
|
140
169
|
opened_at=current_time() if status == IssueStatus.OPEN else None
|
|
141
170
|
)
|
|
171
|
+
|
|
172
|
+
# Enforce lifecycle policies (defaults, auto-corrections)
|
|
173
|
+
from .engine import get_engine
|
|
174
|
+
get_engine().enforce_policy(metadata)
|
|
142
175
|
|
|
176
|
+
# Serialize metadata
|
|
177
|
+
# Explicitly exclude actions and path from file persistence
|
|
178
|
+
yaml_header = yaml.dump(metadata.model_dump(exclude_none=True, mode='json', exclude={'actions', 'path'}), sort_keys=False, allow_unicode=True)
|
|
143
179
|
|
|
144
|
-
|
|
180
|
+
# Inject Self-Documenting Hints (Interactive Frontmatter)
|
|
181
|
+
if "parent:" not in yaml_header:
|
|
182
|
+
yaml_header += "# parent: <EPIC-ID> # Optional: Parent Issue ID\n"
|
|
183
|
+
if "solution:" not in yaml_header:
|
|
184
|
+
yaml_header += "# solution: null # Required for Closed state (implemented, cancelled, etc.)\n"
|
|
185
|
+
|
|
186
|
+
if "dependencies:" not in yaml_header:
|
|
187
|
+
yaml_header += "# dependencies: [] # List of dependency IDs\n"
|
|
188
|
+
if "related:" not in yaml_header:
|
|
189
|
+
yaml_header += "# related: [] # List of related issue IDs\n"
|
|
190
|
+
if "files:" not in yaml_header:
|
|
191
|
+
yaml_header += "# files: [] # List of modified files\n"
|
|
192
|
+
|
|
145
193
|
slug = _get_slug(title)
|
|
146
194
|
filename = f"{issue_id}-{slug}.md"
|
|
147
195
|
|
|
196
|
+
# Enhanced Template with Instructional Comments
|
|
148
197
|
file_content = f"""---
|
|
149
198
|
{yaml_header}---
|
|
150
199
|
|
|
151
200
|
## {issue_id}: {title}
|
|
152
201
|
|
|
153
202
|
## Objective
|
|
203
|
+
<!-- Describe the "Why" and "What" clearly. Focus on value. -->
|
|
154
204
|
|
|
155
205
|
## Acceptance Criteria
|
|
206
|
+
<!-- Define binary conditions for success. -->
|
|
207
|
+
- [ ] Criteria 1
|
|
156
208
|
|
|
157
209
|
## Technical Tasks
|
|
210
|
+
<!-- Breakdown into atomic steps. Use nested lists for sub-tasks. -->
|
|
158
211
|
|
|
159
|
-
|
|
212
|
+
<!-- Status Syntax: -->
|
|
213
|
+
<!-- [ ] To Do -->
|
|
214
|
+
<!-- [/] Doing -->
|
|
215
|
+
<!-- [x] Done -->
|
|
216
|
+
<!-- [~] Cancelled -->
|
|
217
|
+
<!-- - [ ] Parent Task -->
|
|
218
|
+
<!-- - [ ] Sub Task -->
|
|
219
|
+
|
|
220
|
+
- [ ] Task 1
|
|
221
|
+
|
|
222
|
+
## Review Comments
|
|
223
|
+
<!-- Required for Review/Done stage. Record review feedback here. -->
|
|
160
224
|
"""
|
|
161
225
|
file_path = target_dir / filename
|
|
162
226
|
file_path.write_text(file_content)
|
|
163
227
|
|
|
228
|
+
# Inject path into returned metadata
|
|
229
|
+
metadata.path = str(file_path.absolute())
|
|
230
|
+
|
|
164
231
|
return metadata, file_path
|
|
165
|
-
def
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
)
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
232
|
+
def get_available_actions(meta: IssueMetadata) -> List[Any]:
|
|
233
|
+
from .models import IssueAction
|
|
234
|
+
from .engine import get_engine
|
|
235
|
+
|
|
236
|
+
engine = get_engine()
|
|
237
|
+
transitions = engine.get_available_transitions(meta)
|
|
238
|
+
|
|
239
|
+
actions = []
|
|
240
|
+
for t in transitions:
|
|
241
|
+
command = t.command_template.format(id=meta.id) if t.command_template else ""
|
|
242
|
+
|
|
243
|
+
actions.append(IssueAction(
|
|
244
|
+
label=t.label,
|
|
245
|
+
icon=t.icon,
|
|
246
|
+
target_status=t.to_status if t.to_status != meta.status or t.to_stage != meta.stage else None,
|
|
247
|
+
target_stage=t.to_stage if t.to_stage != meta.stage else None,
|
|
248
|
+
target_solution=t.required_solution,
|
|
249
|
+
command=command
|
|
250
|
+
))
|
|
181
251
|
|
|
182
|
-
|
|
183
|
-
if not target_solution:
|
|
184
|
-
raise ValueError(f"Closing an issue requires a solution. Please provide --solution or edit the file metadata.")
|
|
185
|
-
|
|
186
|
-
# Policy: IMPLEMENTED requires REVIEW stage (unless we are already in REVIEW)
|
|
187
|
-
# Check current stage.
|
|
188
|
-
if target_solution == IssueSolution.IMPLEMENTED.value:
|
|
189
|
-
# If we are transitioning FROM Review, it's fine.
|
|
190
|
-
# If we are transitioning TO Closed, current stage must be Review.
|
|
191
|
-
if current_stage != IssueStage.REVIEW:
|
|
192
|
-
raise ValueError(f"Lifecycle Policy: 'Implemented' issues must be submitted for review first.\nCurrent stage: {current_stage}\nAction: Run `monoco issue submit {issue_id}`.")
|
|
193
|
-
|
|
194
|
-
# Policy: No closing from DOING (General Safety)
|
|
195
|
-
if current_stage == IssueStage.DOING:
|
|
196
|
-
raise ValueError("Cannot close issue in progress (Doing). Please review (`monoco issue submit`) or stop (`monoco issue open`) first.")
|
|
197
|
-
|
|
198
|
-
# Policy: Dependencies must be closed
|
|
199
|
-
if issue_dependencies:
|
|
200
|
-
for dep_id in issue_dependencies:
|
|
201
|
-
dep_path = find_issue_path(issues_root, dep_id)
|
|
202
|
-
if dep_path:
|
|
203
|
-
dep_meta = parse_issue(dep_path)
|
|
204
|
-
if dep_meta and dep_meta.status != IssueStatus.CLOSED:
|
|
205
|
-
raise ValueError(f"Dependency Block: Cannot close {issue_id} because dependency {dep_id} is not closed.")
|
|
252
|
+
return actions
|
|
206
253
|
|
|
207
254
|
def find_issue_path(issues_root: Path, issue_id: str) -> Optional[Path]:
|
|
208
255
|
parsed = IssueID(issue_id)
|
|
209
256
|
|
|
210
257
|
if not parsed.is_local:
|
|
258
|
+
if not parsed.namespace:
|
|
259
|
+
return None
|
|
260
|
+
|
|
211
261
|
# Resolve Workspace
|
|
212
|
-
#
|
|
213
|
-
# This is a weak assumption but fits current architecture.
|
|
262
|
+
# Traverse up from issues_root to find a config that defines the namespace
|
|
214
263
|
project_root = issues_root.parent
|
|
215
|
-
conf = get_config(str(project_root))
|
|
216
264
|
|
|
265
|
+
# Try current root first
|
|
266
|
+
conf = MonocoConfig.load(str(project_root))
|
|
217
267
|
member_rel_path = conf.project.members.get(parsed.namespace)
|
|
268
|
+
|
|
218
269
|
if not member_rel_path:
|
|
219
270
|
return None
|
|
220
271
|
|
|
@@ -234,7 +285,8 @@ def find_issue_path(issues_root: Path, issue_id: str) -> Optional[Path]:
|
|
|
234
285
|
except IndexError:
|
|
235
286
|
return None
|
|
236
287
|
|
|
237
|
-
|
|
288
|
+
reverse_prefix_map = get_reverse_prefix_map(issues_root)
|
|
289
|
+
issue_type = reverse_prefix_map.get(prefix)
|
|
238
290
|
if not issue_type:
|
|
239
291
|
return None
|
|
240
292
|
|
|
@@ -244,7 +296,20 @@ def find_issue_path(issues_root: Path, issue_id: str) -> Optional[Path]:
|
|
|
244
296
|
return f
|
|
245
297
|
return None
|
|
246
298
|
|
|
247
|
-
def update_issue(
|
|
299
|
+
def update_issue(
|
|
300
|
+
issues_root: Path,
|
|
301
|
+
issue_id: str,
|
|
302
|
+
status: Optional[IssueStatus] = None,
|
|
303
|
+
stage: Optional[IssueStage] = None,
|
|
304
|
+
solution: Optional[IssueSolution] = None,
|
|
305
|
+
title: Optional[str] = None,
|
|
306
|
+
parent: Optional[str] = None,
|
|
307
|
+
sprint: Optional[str] = None,
|
|
308
|
+
dependencies: Optional[List[str]] = None,
|
|
309
|
+
related: Optional[List[str]] = None,
|
|
310
|
+
tags: Optional[List[str]] = None,
|
|
311
|
+
files: Optional[List[str]] = None
|
|
312
|
+
) -> IssueMetadata:
|
|
248
313
|
path = find_issue_path(issues_root, issue_id)
|
|
249
314
|
if not path:
|
|
250
315
|
raise FileNotFoundError(f"Issue {issue_id} not found.")
|
|
@@ -253,7 +318,7 @@ def update_issue(issues_root: Path, issue_id: str, status: Optional[IssueStatus]
|
|
|
253
318
|
content = path.read_text()
|
|
254
319
|
|
|
255
320
|
# Split Frontmatter and Body
|
|
256
|
-
match = re.search(r"^---(.*?)---\n(.*)
|
|
321
|
+
match = re.search(r"^---(.*?)---\n(.*)\n", content, re.DOTALL | re.MULTILINE)
|
|
257
322
|
if not match:
|
|
258
323
|
# Fallback
|
|
259
324
|
match_simple = re.search(r"^---(.*?)---", content, re.DOTALL | re.MULTILINE)
|
|
@@ -277,50 +342,100 @@ def update_issue(issues_root: Path, issue_id: str, status: Optional[IssueStatus]
|
|
|
277
342
|
current_status = IssueStatus(current_status_str.lower())
|
|
278
343
|
except ValueError:
|
|
279
344
|
current_status = IssueStatus.OPEN
|
|
345
|
+
|
|
346
|
+
current_stage_str = data.get("stage")
|
|
347
|
+
current_stage = IssueStage(current_stage_str.lower()) if current_stage_str else None
|
|
280
348
|
|
|
281
349
|
# Logic: Status Update
|
|
282
350
|
target_status = status if status else current_status
|
|
283
351
|
|
|
284
|
-
#
|
|
285
|
-
|
|
352
|
+
# If status is changing, we don't default target_stage to current_stage
|
|
353
|
+
# because the new status might have different allowed stages.
|
|
354
|
+
# enforce_policy will handle setting the correct default stage for the new status.
|
|
355
|
+
if status and status != current_status:
|
|
356
|
+
target_stage = stage
|
|
357
|
+
else:
|
|
358
|
+
target_stage = stage if stage else current_stage
|
|
286
359
|
|
|
287
|
-
#
|
|
288
|
-
|
|
289
|
-
|
|
360
|
+
# Engine Validation
|
|
361
|
+
from .engine import get_engine
|
|
362
|
+
engine = get_engine()
|
|
363
|
+
|
|
364
|
+
# Map solution string to enum if present
|
|
365
|
+
effective_solution = solution
|
|
366
|
+
if not effective_solution and data.get("solution"):
|
|
367
|
+
try:
|
|
368
|
+
effective_solution = IssueSolution(data.get("solution").lower())
|
|
369
|
+
except ValueError:
|
|
370
|
+
pass
|
|
290
371
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
# Policy: No closing from DOING (General Safety)
|
|
303
|
-
if current_data_stage == IssueStage.DOING.value:
|
|
304
|
-
raise ValueError("Cannot close issue in progress (Doing). Please review (`monoco issue submit`) or stop (`monoco issue open`) first.")
|
|
305
|
-
|
|
372
|
+
# Use engine to validate the transition
|
|
373
|
+
engine.validate_transition(
|
|
374
|
+
from_status=current_status,
|
|
375
|
+
from_stage=current_stage,
|
|
376
|
+
to_status=target_status,
|
|
377
|
+
to_stage=target_stage,
|
|
378
|
+
solution=effective_solution
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
if target_status == "closed":
|
|
306
382
|
# Policy: Dependencies must be closed
|
|
307
|
-
|
|
308
|
-
if
|
|
309
|
-
for dep_id in
|
|
383
|
+
dependencies_to_check = dependencies if dependencies is not None else data.get('dependencies', [])
|
|
384
|
+
if dependencies_to_check:
|
|
385
|
+
for dep_id in dependencies_to_check:
|
|
310
386
|
dep_path = find_issue_path(issues_root, dep_id)
|
|
311
387
|
if dep_path:
|
|
312
388
|
dep_meta = parse_issue(dep_path)
|
|
313
|
-
if dep_meta and dep_meta.status !=
|
|
314
|
-
raise ValueError(f"Dependency Block: Cannot close {issue_id} because dependency {dep_id} is [Status: {dep_meta.status
|
|
389
|
+
if dep_meta and dep_meta.status != "closed":
|
|
390
|
+
raise ValueError(f"Dependency Block: Cannot close {issue_id} because dependency {dep_id} is [Status: {dep_meta.status}].")
|
|
391
|
+
|
|
392
|
+
# Validate new parent/dependencies/related exist
|
|
393
|
+
if parent is not None and parent != "":
|
|
394
|
+
if not find_issue_path(issues_root, parent):
|
|
395
|
+
raise ValueError(f"Parent issue {parent} not found.")
|
|
396
|
+
|
|
397
|
+
if dependencies is not None:
|
|
398
|
+
for dep_id in dependencies:
|
|
399
|
+
if not find_issue_path(issues_root, dep_id):
|
|
400
|
+
raise ValueError(f"Dependency issue {dep_id} not found.")
|
|
401
|
+
|
|
402
|
+
if related is not None:
|
|
403
|
+
for rel_id in related:
|
|
404
|
+
if not find_issue_path(issues_root, rel_id):
|
|
405
|
+
raise ValueError(f"Related issue {rel_id} not found.")
|
|
315
406
|
|
|
316
407
|
# Update Data
|
|
317
408
|
if status:
|
|
318
|
-
data['status'] = status
|
|
409
|
+
data['status'] = status
|
|
319
410
|
|
|
320
411
|
if stage:
|
|
321
|
-
data['stage'] = stage
|
|
412
|
+
data['stage'] = stage
|
|
322
413
|
if solution:
|
|
323
|
-
data['solution'] = solution
|
|
414
|
+
data['solution'] = solution
|
|
415
|
+
|
|
416
|
+
if title:
|
|
417
|
+
data['title'] = title
|
|
418
|
+
|
|
419
|
+
if parent is not None:
|
|
420
|
+
if parent == "":
|
|
421
|
+
data.pop('parent', None) # Remove parent field
|
|
422
|
+
else:
|
|
423
|
+
data['parent'] = parent
|
|
424
|
+
|
|
425
|
+
if sprint is not None:
|
|
426
|
+
data['sprint'] = sprint
|
|
427
|
+
|
|
428
|
+
if dependencies is not None:
|
|
429
|
+
data['dependencies'] = dependencies
|
|
430
|
+
|
|
431
|
+
if related is not None:
|
|
432
|
+
data['related'] = related
|
|
433
|
+
|
|
434
|
+
if tags is not None:
|
|
435
|
+
data['tags'] = tags
|
|
436
|
+
|
|
437
|
+
if files is not None:
|
|
438
|
+
data['files'] = files
|
|
324
439
|
|
|
325
440
|
# Lifecycle Hooks
|
|
326
441
|
# 1. Opened At: If transitioning to OPEN
|
|
@@ -336,14 +451,31 @@ def update_issue(issues_root: Path, issue_id: str, status: Optional[IssueStatus]
|
|
|
336
451
|
# Touch updated_at
|
|
337
452
|
data['updated_at'] = current_time()
|
|
338
453
|
|
|
339
|
-
# Re-hydrate through Model
|
|
454
|
+
# Re-hydrate through Model
|
|
340
455
|
try:
|
|
341
456
|
updated_meta = IssueMetadata(**data)
|
|
457
|
+
|
|
458
|
+
# Enforce lifecycle policies (defaults, auto-corrections)
|
|
459
|
+
# This ensures that when we update, we also fix invalid states (like Closed but not Done)
|
|
460
|
+
from .engine import get_engine
|
|
461
|
+
get_engine().enforce_policy(updated_meta)
|
|
462
|
+
|
|
463
|
+
# Delegate to IssueValidator for static state validation
|
|
464
|
+
# We need to construct the full content to validate body-dependent rules (like checkboxes)
|
|
465
|
+
# Note: 'body' here is the OLD body. We assume update_issue doesn't change body.
|
|
466
|
+
# If body is invalid (unchecked boxes) and we move to DONE, this MUST fail.
|
|
467
|
+
validator = IssueValidator(issues_root)
|
|
468
|
+
diagnostics = validator.validate(updated_meta, body)
|
|
469
|
+
errors = [d for d in diagnostics if d.severity == DiagnosticSeverity.Error]
|
|
470
|
+
if errors:
|
|
471
|
+
raise ValueError(f"Validation Failed: {errors[0].message}")
|
|
472
|
+
|
|
342
473
|
except Exception as e:
|
|
343
474
|
raise ValueError(f"Failed to validate updated metadata: {e}")
|
|
344
475
|
|
|
345
476
|
# Serialize back
|
|
346
|
-
|
|
477
|
+
# Explicitly exclude actions and path from file persistence
|
|
478
|
+
new_yaml = yaml.dump(updated_meta.model_dump(exclude_none=True, mode='json', exclude={'actions', 'path'}), sort_keys=False, allow_unicode=True)
|
|
347
479
|
|
|
348
480
|
# Reconstruct File
|
|
349
481
|
match_header = re.search(r"^---(.*?)---", content, re.DOTALL | re.MULTILINE)
|
|
@@ -363,7 +495,8 @@ def update_issue(issues_root: Path, issue_id: str, status: Optional[IssueStatus]
|
|
|
363
495
|
if status and status != current_status:
|
|
364
496
|
# Move file
|
|
365
497
|
prefix = issue_id.split("-")[0].upper()
|
|
366
|
-
|
|
498
|
+
reverse_prefix_map = get_reverse_prefix_map(issues_root)
|
|
499
|
+
base_type_dir = get_issue_dir(reverse_prefix_map[prefix], issues_root)
|
|
367
500
|
|
|
368
501
|
try:
|
|
369
502
|
rel_path = path.relative_to(base_type_dir)
|
|
@@ -371,19 +504,23 @@ def update_issue(issues_root: Path, issue_id: str, status: Optional[IssueStatus]
|
|
|
371
504
|
except ValueError:
|
|
372
505
|
structure_path = Path(path.name)
|
|
373
506
|
|
|
374
|
-
target_path = base_type_dir / target_status
|
|
507
|
+
target_path = base_type_dir / target_status / structure_path
|
|
375
508
|
|
|
376
509
|
if path != target_path:
|
|
377
510
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
378
511
|
path.rename(target_path)
|
|
512
|
+
path = target_path # Update local path variable for returned meta
|
|
379
513
|
|
|
380
514
|
# Hook: Recursive Aggregation (FEAT-0003)
|
|
381
515
|
if updated_meta.parent:
|
|
382
516
|
recalculate_parent(issues_root, updated_meta.parent)
|
|
383
|
-
|
|
517
|
+
|
|
518
|
+
# Update returned metadata with final absolute path
|
|
519
|
+
updated_meta.path = str(path.absolute())
|
|
520
|
+
updated_meta.actions = get_available_actions(updated_meta)
|
|
384
521
|
return updated_meta
|
|
385
522
|
|
|
386
|
-
def start_issue_isolation(issues_root: Path, issue_id: str, mode:
|
|
523
|
+
def start_issue_isolation(issues_root: Path, issue_id: str, mode: str, project_root: Path) -> IssueMetadata:
|
|
387
524
|
"""
|
|
388
525
|
Start physical isolation for an issue (Branch or Worktree).
|
|
389
526
|
"""
|
|
@@ -392,6 +529,8 @@ def start_issue_isolation(issues_root: Path, issue_id: str, mode: IsolationType,
|
|
|
392
529
|
raise FileNotFoundError(f"Issue {issue_id} not found.")
|
|
393
530
|
|
|
394
531
|
issue = parse_issue(path)
|
|
532
|
+
if not issue:
|
|
533
|
+
raise ValueError(f"Could not parse metadata for issue {issue_id}")
|
|
395
534
|
|
|
396
535
|
# Idempotency / Conflict Check
|
|
397
536
|
if issue.isolation:
|
|
@@ -409,7 +548,7 @@ def start_issue_isolation(issues_root: Path, issue_id: str, mode: IsolationType,
|
|
|
409
548
|
|
|
410
549
|
isolation_meta = None
|
|
411
550
|
|
|
412
|
-
if mode ==
|
|
551
|
+
if mode == "branch":
|
|
413
552
|
if not git.branch_exists(project_root, branch_name):
|
|
414
553
|
git.create_branch(project_root, branch_name, checkout=True)
|
|
415
554
|
else:
|
|
@@ -419,9 +558,9 @@ def start_issue_isolation(issues_root: Path, issue_id: str, mode: IsolationType,
|
|
|
419
558
|
if current != branch_name:
|
|
420
559
|
git.checkout_branch(project_root, branch_name)
|
|
421
560
|
|
|
422
|
-
isolation_meta = IssueIsolation(type=
|
|
561
|
+
isolation_meta = IssueIsolation(type="branch", ref=branch_name)
|
|
423
562
|
|
|
424
|
-
elif mode ==
|
|
563
|
+
elif mode == "worktree":
|
|
425
564
|
wt_path = project_root / ".monoco" / "worktrees" / f"{issue_id.lower()}-{slug}"
|
|
426
565
|
|
|
427
566
|
# Check if worktree exists physically
|
|
@@ -432,7 +571,7 @@ def start_issue_isolation(issues_root: Path, issue_id: str, mode: IsolationType,
|
|
|
432
571
|
wt_path.parent.mkdir(parents=True, exist_ok=True)
|
|
433
572
|
git.worktree_add(project_root, branch_name, wt_path)
|
|
434
573
|
|
|
435
|
-
isolation_meta = IssueIsolation(type=
|
|
574
|
+
isolation_meta = IssueIsolation(type="worktree", ref=branch_name, path=str(wt_path))
|
|
436
575
|
|
|
437
576
|
# Persist Metadata
|
|
438
577
|
# We load raw, update isolation field, save.
|
|
@@ -444,7 +583,7 @@ def start_issue_isolation(issues_root: Path, issue_id: str, mode: IsolationType,
|
|
|
444
583
|
|
|
445
584
|
data['isolation'] = isolation_meta.model_dump(mode='json')
|
|
446
585
|
# Also ensure stage is DOING (logic link)
|
|
447
|
-
data['stage'] =
|
|
586
|
+
data['stage'] = "doing"
|
|
448
587
|
data['updated_at'] = current_time()
|
|
449
588
|
|
|
450
589
|
new_yaml = yaml.dump(data, sort_keys=False, allow_unicode=True)
|
|
@@ -467,6 +606,9 @@ def prune_issue_resources(issues_root: Path, issue_id: str, force: bool, project
|
|
|
467
606
|
raise FileNotFoundError(f"Issue {issue_id} not found.")
|
|
468
607
|
|
|
469
608
|
issue = parse_issue(path)
|
|
609
|
+
if not issue:
|
|
610
|
+
raise ValueError(f"Could not parse metadata for issue {issue_id}")
|
|
611
|
+
|
|
470
612
|
deleted_items = []
|
|
471
613
|
|
|
472
614
|
if not issue.isolation:
|
|
@@ -532,6 +674,77 @@ def delete_issue_file(issues_root: Path, issue_id: str):
|
|
|
532
674
|
raise FileNotFoundError(f"Issue {issue_id} not found.")
|
|
533
675
|
|
|
534
676
|
path.unlink()
|
|
677
|
+
|
|
678
|
+
def sync_issue_files(issues_root: Path, issue_id: str, project_root: Path) -> List[str]:
|
|
679
|
+
"""
|
|
680
|
+
Sync 'files' field in issue metadata with actual changed files in git.
|
|
681
|
+
Strategies:
|
|
682
|
+
1. Isolation Ref: If issue has isolation (branch/worktree), use that ref.
|
|
683
|
+
2. Convention: If no isolation, look for branch `*/<id>-*`.
|
|
684
|
+
3. Current Branch: If current branch matches pattern.
|
|
685
|
+
|
|
686
|
+
Compares against default branch (usually 'main' or 'master').
|
|
687
|
+
"""
|
|
688
|
+
path = find_issue_path(issues_root, issue_id)
|
|
689
|
+
if not path:
|
|
690
|
+
raise FileNotFoundError(f"Issue {issue_id} not found.")
|
|
691
|
+
|
|
692
|
+
issue = parse_issue(path)
|
|
693
|
+
if not issue:
|
|
694
|
+
raise ValueError(f"Could not parse issue {issue_id}")
|
|
695
|
+
|
|
696
|
+
# Determine Target Branch
|
|
697
|
+
target_ref = None
|
|
698
|
+
|
|
699
|
+
if issue.isolation and issue.isolation.ref:
|
|
700
|
+
target_ref = issue.isolation.ref
|
|
701
|
+
else:
|
|
702
|
+
# Heuristic Search
|
|
703
|
+
# 1. Is current branch related?
|
|
704
|
+
current = git.get_current_branch(project_root)
|
|
705
|
+
if issue_id.lower() in current.lower():
|
|
706
|
+
target_ref = current
|
|
707
|
+
else:
|
|
708
|
+
# 2. Search for branch
|
|
709
|
+
# Limitation: core.git doesn't list all branches yet.
|
|
710
|
+
# We skip this for now to avoid complexity, relying on isolation or current context.
|
|
711
|
+
pass
|
|
712
|
+
|
|
713
|
+
if not target_ref:
|
|
714
|
+
raise RuntimeError(f"Could not determine git branch for Issue {issue_id}. Please ensure issue is started or you are on the feature branch.")
|
|
715
|
+
|
|
716
|
+
# Determine Base Branch (assume main, or config?)
|
|
717
|
+
# For now hardcode main, eventually read from config
|
|
718
|
+
base_ref = "main"
|
|
719
|
+
|
|
720
|
+
# Check if base exists, if not try master
|
|
721
|
+
if not git.branch_exists(project_root, base_ref):
|
|
722
|
+
if git.branch_exists(project_root, "master"):
|
|
723
|
+
base_ref = "master"
|
|
724
|
+
else:
|
|
725
|
+
# Fallback: remote/main?
|
|
726
|
+
pass
|
|
727
|
+
|
|
728
|
+
# Git Diff
|
|
729
|
+
# git diff --name-only base...target
|
|
730
|
+
cmd = ["diff", "--name-only", f"{base_ref}...{target_ref}"]
|
|
731
|
+
code, stdout, stderr = git._run_git(cmd, project_root)
|
|
732
|
+
|
|
733
|
+
if code != 0:
|
|
734
|
+
raise RuntimeError(f"Git diff failed: {stderr}")
|
|
735
|
+
|
|
736
|
+
changed_files = [f.strip() for f in stdout.splitlines() if f.strip()]
|
|
737
|
+
|
|
738
|
+
# Sort for consistency
|
|
739
|
+
changed_files.sort()
|
|
740
|
+
|
|
741
|
+
# Update Issue
|
|
742
|
+
# Only update if changed
|
|
743
|
+
if changed_files != issue.files:
|
|
744
|
+
update_issue(issues_root, issue_id, files=changed_files)
|
|
745
|
+
return changed_files
|
|
746
|
+
|
|
747
|
+
return []
|
|
535
748
|
|
|
536
749
|
# Resources
|
|
537
750
|
SKILL_CONTENT = """
|
|
@@ -566,7 +779,7 @@ description: Monoco Issue System 的官方技能定义。将 Issue 视为通用
|
|
|
566
779
|
- **Status Level (Lowercase)**: `open`, `backlog`, `closed`
|
|
567
780
|
|
|
568
781
|
### 路径流转
|
|
569
|
-
使用 `monoco issue
|
|
782
|
+
使用 `monoco issue`:
|
|
570
783
|
1. **Create**: `monoco issue create <type> --title "..."`
|
|
571
784
|
2. **Transition**: `monoco issue open/close/backlog <id>`
|
|
572
785
|
3. **View**: `monoco issue scope`
|
|
@@ -574,15 +787,6 @@ description: Monoco Issue System 的官方技能定义。将 Issue 视为通用
|
|
|
574
787
|
5. **Modification**: `monoco issue start/submit/delete <id>`
|
|
575
788
|
"""
|
|
576
789
|
|
|
577
|
-
PROMPT_CONTENT = """
|
|
578
|
-
### Issue Management
|
|
579
|
-
System for managing tasks using `monoco issue`.
|
|
580
|
-
- **Create**: `monoco issue create <type> -t "Title"` (types: epic, feature, chore, fix)
|
|
581
|
-
- **Status**: `monoco issue open|close|backlog <id>`
|
|
582
|
-
- **Check**: `monoco issue lint` (Must run after manual edits)
|
|
583
|
-
- **Lifecycle**: `monoco issue start|submit|delete <id>`
|
|
584
|
-
- **Structure**: `Issues/{CapitalizedPluralType}/{lowercase_status}/` (e.g. `Issues/Features/open/`). Do not deviate.
|
|
585
|
-
"""
|
|
586
790
|
|
|
587
791
|
def init(issues_root: Path):
|
|
588
792
|
"""Initialize the Issues directory structure."""
|
|
@@ -603,9 +807,7 @@ def get_resources() -> Dict[str, Any]:
|
|
|
603
807
|
"skills": {
|
|
604
808
|
"issues-management": SKILL_CONTENT
|
|
605
809
|
},
|
|
606
|
-
"prompts": {
|
|
607
|
-
"issues-management": PROMPT_CONTENT
|
|
608
|
-
}
|
|
810
|
+
"prompts": {} # Handled by adapter via resource files
|
|
609
811
|
}
|
|
610
812
|
|
|
611
813
|
|
|
@@ -614,7 +816,10 @@ def list_issues(issues_root: Path, recursive_workspace: bool = False) -> List[Is
|
|
|
614
816
|
List all issues in the project.
|
|
615
817
|
"""
|
|
616
818
|
issues = []
|
|
617
|
-
|
|
819
|
+
engine = get_engine(str(issues_root.parent))
|
|
820
|
+
all_types = engine.get_all_types()
|
|
821
|
+
|
|
822
|
+
for issue_type in all_types:
|
|
618
823
|
base_dir = get_issue_dir(issue_type, issues_root)
|
|
619
824
|
for status_dir in ["open", "backlog", "closed"]:
|
|
620
825
|
d = base_dir / status_dir
|
|
@@ -640,6 +845,22 @@ def list_issues(issues_root: Path, recursive_workspace: bool = False) -> List[Is
|
|
|
640
845
|
member_issues = list_issues(member_issues_dir, False)
|
|
641
846
|
for m in member_issues:
|
|
642
847
|
# Namespace the ID to avoid collisions and indicate origin
|
|
848
|
+
# CRITICAL: Also namespace references to keep parent-child structure intact
|
|
849
|
+
if m.parent and "::" not in m.parent:
|
|
850
|
+
m.parent = f"{name}::{m.parent}"
|
|
851
|
+
|
|
852
|
+
if m.dependencies:
|
|
853
|
+
m.dependencies = [
|
|
854
|
+
f"{name}::{d}" if d and "::" not in d else d
|
|
855
|
+
for d in m.dependencies
|
|
856
|
+
]
|
|
857
|
+
|
|
858
|
+
if m.related:
|
|
859
|
+
m.related = [
|
|
860
|
+
f"{name}::{r}" if r and "::" not in r else r
|
|
861
|
+
for r in m.related
|
|
862
|
+
]
|
|
863
|
+
|
|
643
864
|
m.id = f"{name}::{m.id}"
|
|
644
865
|
issues.append(m)
|
|
645
866
|
except Exception:
|
|
@@ -653,21 +874,21 @@ def get_board_data(issues_root: Path) -> Dict[str, List[IssueMetadata]]:
|
|
|
653
874
|
Get open issues grouped by their stage for Kanban view.
|
|
654
875
|
"""
|
|
655
876
|
board = {
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
877
|
+
"draft": [],
|
|
878
|
+
"doing": [],
|
|
879
|
+
"review": [],
|
|
880
|
+
"done": []
|
|
660
881
|
}
|
|
661
882
|
|
|
662
883
|
issues = list_issues(issues_root)
|
|
663
884
|
for issue in issues:
|
|
664
|
-
if issue.status ==
|
|
665
|
-
stage_val = issue.stage
|
|
885
|
+
if issue.status == "open" and issue.stage:
|
|
886
|
+
stage_val = issue.stage
|
|
666
887
|
if stage_val in board:
|
|
667
888
|
board[stage_val].append(issue)
|
|
668
|
-
elif issue.status ==
|
|
889
|
+
elif issue.status == "closed":
|
|
669
890
|
# Optionally show recently closed items in DONE column
|
|
670
|
-
board[
|
|
891
|
+
board["done"].append(issue)
|
|
671
892
|
|
|
672
893
|
return board
|
|
673
894
|
|
|
@@ -677,14 +898,14 @@ def validate_issue_integrity(meta: IssueMetadata, all_issue_ids: Set[str] = set(
|
|
|
677
898
|
UI-agnostic.
|
|
678
899
|
"""
|
|
679
900
|
errors = []
|
|
680
|
-
if meta.status ==
|
|
901
|
+
if meta.status == "closed" and not meta.solution:
|
|
681
902
|
errors.append(f"Solution Missing: {meta.id} is closed but has no solution field.")
|
|
682
903
|
|
|
683
904
|
if meta.parent:
|
|
684
905
|
if all_issue_ids and meta.parent not in all_issue_ids:
|
|
685
906
|
errors.append(f"Broken Link: {meta.id} refers to non-existent parent {meta.parent}.")
|
|
686
907
|
|
|
687
|
-
if meta.status ==
|
|
908
|
+
if meta.status == "backlog" and meta.stage != "freezed":
|
|
688
909
|
errors.append(f"Lifecycle Error: {meta.id} is backlog but stage is not freezed (found: {meta.stage}).")
|
|
689
910
|
|
|
690
911
|
return errors
|
|
@@ -729,7 +950,8 @@ def update_issue_content(issues_root: Path, issue_id: str, new_content: str) ->
|
|
|
729
950
|
# Reuse logic from update_issue (simplified)
|
|
730
951
|
|
|
731
952
|
prefix = issue_id.split("-")[0].upper()
|
|
732
|
-
|
|
953
|
+
reverse_prefix_map = get_reverse_prefix_map(issues_root)
|
|
954
|
+
base_type_dir = get_issue_dir(reverse_prefix_map[prefix], issues_root)
|
|
733
955
|
|
|
734
956
|
# Calculate structure path (preserve subdir)
|
|
735
957
|
try:
|
|
@@ -742,7 +964,7 @@ def update_issue_content(issues_root: Path, issue_id: str, new_content: str) ->
|
|
|
742
964
|
# Fallback if path is weird
|
|
743
965
|
structure_path = Path(path.name)
|
|
744
966
|
|
|
745
|
-
target_path = base_type_dir / meta.status
|
|
967
|
+
target_path = base_type_dir / meta.status / structure_path
|
|
746
968
|
|
|
747
969
|
if path != target_path:
|
|
748
970
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -769,7 +991,10 @@ def generate_delivery_report(issues_root: Path, issue_id: str, project_root: Pat
|
|
|
769
991
|
commits = git.search_commits_by_message(project_root, f"Ref: {issue_id}")
|
|
770
992
|
|
|
771
993
|
if not commits:
|
|
772
|
-
|
|
994
|
+
meta = parse_issue(path)
|
|
995
|
+
if not meta:
|
|
996
|
+
raise ValueError(f"Could not parse metadata for issue {issue_id}")
|
|
997
|
+
return meta
|
|
773
998
|
|
|
774
999
|
# 2. Aggregate Data
|
|
775
1000
|
all_files = set()
|
|
@@ -825,7 +1050,10 @@ def generate_delivery_report(issues_root: Path, issue_id: str, project_root: Pat
|
|
|
825
1050
|
# We can add it to 'extra' or extend the model later.
|
|
826
1051
|
# For now, just persisting the text is enough for FEAT-0002.
|
|
827
1052
|
|
|
828
|
-
|
|
1053
|
+
meta = parse_issue(path)
|
|
1054
|
+
if not meta:
|
|
1055
|
+
raise ValueError(f"Could not parse metadata for issue {issue_id}")
|
|
1056
|
+
return meta
|
|
829
1057
|
|
|
830
1058
|
def get_children(issues_root: Path, parent_id: str) -> List[IssueMetadata]:
|
|
831
1059
|
"""Find all direct children of an issue."""
|
|
@@ -888,9 +1116,9 @@ def check_issue_match(issue: IssueMetadata, explicit_positives: List[str], terms
|
|
|
888
1116
|
searchable_parts = [
|
|
889
1117
|
issue.id,
|
|
890
1118
|
issue.title,
|
|
891
|
-
issue.status
|
|
892
|
-
issue.type
|
|
893
|
-
str(issue.stage
|
|
1119
|
+
issue.status,
|
|
1120
|
+
issue.type,
|
|
1121
|
+
str(issue.stage) if issue.stage else "",
|
|
894
1122
|
*(issue.tags or []),
|
|
895
1123
|
*(issue.dependencies or []),
|
|
896
1124
|
*(issue.related or []),
|
|
@@ -949,7 +1177,10 @@ def search_issues(issues_root: Path, query: str) -> List[IssueMetadata]:
|
|
|
949
1177
|
# To support deep search (Body), we need to read files.
|
|
950
1178
|
# Let's iterate files directly.
|
|
951
1179
|
|
|
952
|
-
|
|
1180
|
+
engine = get_engine(str(issues_root.parent))
|
|
1181
|
+
all_types = engine.get_all_types()
|
|
1182
|
+
|
|
1183
|
+
for issue_type in all_types:
|
|
953
1184
|
base_dir = get_issue_dir(issue_type, issues_root)
|
|
954
1185
|
for status_dir in ["open", "backlog", "closed"]:
|
|
955
1186
|
d = base_dir / status_dir
|
|
@@ -997,7 +1228,7 @@ def recalculate_parent(issues_root: Path, parent_id: str):
|
|
|
997
1228
|
return
|
|
998
1229
|
|
|
999
1230
|
total = len(children)
|
|
1000
|
-
closed = len([c for c in children if c.status ==
|
|
1231
|
+
closed = len([c for c in children if c.status == "closed"])
|
|
1001
1232
|
# Progress string: "3/5"
|
|
1002
1233
|
progress_str = f"{closed}/{total}"
|
|
1003
1234
|
|
|
@@ -1033,12 +1264,12 @@ def recalculate_parent(issues_root: Path, parent_id: str):
|
|
|
1033
1264
|
# FEAT-0003 Req: "If first child starts doing, auto-start Parent?"
|
|
1034
1265
|
# If parent is OPEN/TODO and child is DOING/REVIEW/DONE, set parent to DOING?
|
|
1035
1266
|
current_status = data.get("status", "open").lower()
|
|
1036
|
-
current_stage = data.get("stage", "
|
|
1267
|
+
current_stage = data.get("stage", "draft").lower()
|
|
1037
1268
|
|
|
1038
|
-
if current_status == "open" and current_stage == "
|
|
1269
|
+
if current_status == "open" and current_stage == "draft":
|
|
1039
1270
|
# Check if any child is active
|
|
1040
|
-
active_children = [c for c in children if c.status ==
|
|
1041
|
-
closed_children = [c for c in children if c.status ==
|
|
1271
|
+
active_children = [c for c in children if c.status == "open" and c.stage != "draft"]
|
|
1272
|
+
closed_children = [c for c in children if c.status == "closed"]
|
|
1042
1273
|
|
|
1043
1274
|
if active_children or closed_children:
|
|
1044
1275
|
data["stage"] = "doing"
|
|
@@ -1120,7 +1351,7 @@ def move_issue(
|
|
|
1120
1351
|
|
|
1121
1352
|
# 4. Construct target path
|
|
1122
1353
|
target_type_dir = get_issue_dir(issue.type, target_issues_root)
|
|
1123
|
-
target_status_dir = target_type_dir / issue.status
|
|
1354
|
+
target_status_dir = target_type_dir / issue.status
|
|
1124
1355
|
|
|
1125
1356
|
# Preserve subdirectory structure if any
|
|
1126
1357
|
try:
|
|
@@ -1143,7 +1374,7 @@ def move_issue(
|
|
|
1143
1374
|
# 5. Update content if ID changed
|
|
1144
1375
|
if new_id != old_id:
|
|
1145
1376
|
# Update frontmatter
|
|
1146
|
-
content = issue.raw_content
|
|
1377
|
+
content = issue.raw_content or ""
|
|
1147
1378
|
match = re.search(r"^---(.*?)---", content, re.DOTALL | re.MULTILINE)
|
|
1148
1379
|
if match:
|
|
1149
1380
|
yaml_str = match.group(1)
|
|
@@ -1159,9 +1390,9 @@ def move_issue(
|
|
|
1159
1390
|
|
|
1160
1391
|
new_content = f"---\n{new_yaml}---{body}"
|
|
1161
1392
|
else:
|
|
1162
|
-
new_content = issue.raw_content
|
|
1393
|
+
new_content = issue.raw_content or ""
|
|
1163
1394
|
else:
|
|
1164
|
-
new_content = issue.raw_content
|
|
1395
|
+
new_content = issue.raw_content or ""
|
|
1165
1396
|
|
|
1166
1397
|
# 6. Write to target
|
|
1167
1398
|
target_path.write_text(new_content)
|
|
@@ -1171,4 +1402,7 @@ def move_issue(
|
|
|
1171
1402
|
|
|
1172
1403
|
# 8. Return updated metadata
|
|
1173
1404
|
final_meta = parse_issue(target_path)
|
|
1405
|
+
if not final_meta:
|
|
1406
|
+
raise ValueError(f"Failed to parse moved issue at {target_path}")
|
|
1407
|
+
|
|
1174
1408
|
return final_meta, target_path
|