monoco-toolkit 0.2.5__py3-none-any.whl → 0.2.7__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/core/agent/adapters.py +24 -1
- monoco/core/config.py +77 -17
- monoco/core/integrations.py +8 -0
- monoco/core/lsp.py +7 -0
- monoco/core/output.py +8 -1
- monoco/core/resources/zh/SKILL.md +6 -7
- monoco/core/setup.py +8 -0
- monoco/features/i18n/resources/zh/SKILL.md +5 -5
- monoco/features/issue/commands.py +135 -55
- monoco/features/issue/core.py +157 -122
- 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 +32 -11
- monoco/features/issue/lsp/__init__.py +3 -0
- monoco/features/issue/lsp/definition.py +72 -0
- monoco/features/issue/models.py +26 -9
- monoco/features/issue/resources/zh/SKILL.md +8 -9
- monoco/features/issue/validator.py +181 -65
- monoco/features/spike/core.py +5 -22
- monoco/features/spike/resources/zh/SKILL.md +2 -2
- monoco/main.py +2 -26
- monoco_toolkit-0.2.7.dist-info/METADATA +129 -0
- {monoco_toolkit-0.2.5.dist-info → monoco_toolkit-0.2.7.dist-info}/RECORD +33 -27
- monoco/features/agent/commands.py +0 -166
- monoco/features/agent/doctor.py +0 -30
- monoco/features/pty/core.py +0 -185
- monoco/features/pty/router.py +0 -138
- monoco/features/pty/server.py +0 -56
- monoco_toolkit-0.2.5.dist-info/METADATA +0 -93
- {monoco_toolkit-0.2.5.dist-info → monoco_toolkit-0.2.7.dist-info}/WHEEL +0 -0
- {monoco_toolkit-0.2.5.dist-info → monoco_toolkit-0.2.7.dist-info}/entry_points.txt +0 -0
- {monoco_toolkit-0.2.5.dist-info → monoco_toolkit-0.2.7.dist-info}/licenses/LICENSE +0 -0
monoco/features/issue/core.py
CHANGED
|
@@ -10,35 +10,21 @@ from monoco.core.config import get_config, MonocoConfig
|
|
|
10
10
|
from monoco.core.lsp import DiagnosticSeverity
|
|
11
11
|
from .validator import IssueValidator
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
IssueType.EPIC: "EPIC",
|
|
15
|
-
IssueType.FEATURE: "FEAT",
|
|
16
|
-
IssueType.CHORE: "CHORE",
|
|
17
|
-
IssueType.FIX: "FIX"
|
|
18
|
-
}
|
|
13
|
+
from .engine import get_engine
|
|
19
14
|
|
|
20
|
-
|
|
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()
|
|
21
18
|
|
|
22
|
-
def
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
# Enforce stage=done for closed issues
|
|
32
|
-
if meta.stage != IssueStage.DONE:
|
|
33
|
-
meta.stage = IssueStage.DONE
|
|
34
|
-
# Auto-fill closed_at if missing
|
|
35
|
-
if not meta.closed_at:
|
|
36
|
-
meta.closed_at = current_time()
|
|
37
|
-
|
|
38
|
-
elif meta.status == IssueStatus.OPEN:
|
|
39
|
-
# Ensure valid stage for open status
|
|
40
|
-
if meta.stage is None:
|
|
41
|
-
meta.stage = IssueStage.DRAFT
|
|
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
|
|
42
28
|
|
|
43
29
|
def _get_slug(title: str) -> str:
|
|
44
30
|
slug = title.lower()
|
|
@@ -52,15 +38,6 @@ def _get_slug(title: str) -> str:
|
|
|
52
38
|
|
|
53
39
|
return slug
|
|
54
40
|
|
|
55
|
-
def get_issue_dir(issue_type: IssueType, issues_root: Path) -> Path:
|
|
56
|
-
mapping = {
|
|
57
|
-
IssueType.EPIC: "Epics",
|
|
58
|
-
IssueType.FEATURE: "Features",
|
|
59
|
-
IssueType.CHORE: "Chores",
|
|
60
|
-
IssueType.FIX: "Fixes",
|
|
61
|
-
}
|
|
62
|
-
return issues_root / mapping[issue_type]
|
|
63
|
-
|
|
64
41
|
def parse_issue(file_path: Path) -> Optional[IssueMetadata]:
|
|
65
42
|
if not file_path.suffix == ".md":
|
|
66
43
|
return None
|
|
@@ -105,8 +82,9 @@ def parse_issue_detail(file_path: Path) -> Optional[IssueDetail]:
|
|
|
105
82
|
except Exception:
|
|
106
83
|
return None
|
|
107
84
|
|
|
108
|
-
def find_next_id(issue_type:
|
|
109
|
-
|
|
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")
|
|
110
88
|
pattern = re.compile(rf"{prefix}-(\d+)")
|
|
111
89
|
max_id = 0
|
|
112
90
|
|
|
@@ -147,7 +125,7 @@ def create_issue_file(
|
|
|
147
125
|
|
|
148
126
|
issue_id = find_next_id(issue_type, issues_root)
|
|
149
127
|
base_type_dir = get_issue_dir(issue_type, issues_root)
|
|
150
|
-
target_dir = base_type_dir / status
|
|
128
|
+
target_dir = base_type_dir / status
|
|
151
129
|
|
|
152
130
|
if subdir:
|
|
153
131
|
target_dir = target_dir / subdir
|
|
@@ -170,24 +148,50 @@ def create_issue_file(
|
|
|
170
148
|
)
|
|
171
149
|
|
|
172
150
|
# Enforce lifecycle policies (defaults, auto-corrections)
|
|
173
|
-
|
|
151
|
+
from .engine import get_engine
|
|
152
|
+
get_engine().enforce_policy(metadata)
|
|
153
|
+
|
|
154
|
+
# Serialize metadata
|
|
155
|
+
# Explicitly exclude actions and path from file persistence
|
|
156
|
+
yaml_header = yaml.dump(metadata.model_dump(exclude_none=True, mode='json', exclude={'actions', 'path'}), sort_keys=False, allow_unicode=True)
|
|
157
|
+
|
|
158
|
+
# Inject Self-Documenting Hints (Interactive Frontmatter)
|
|
159
|
+
if "parent:" not in yaml_header:
|
|
160
|
+
yaml_header += "# parent: <EPIC-ID> # Optional: Parent Issue ID\n"
|
|
161
|
+
if "solution:" not in yaml_header:
|
|
162
|
+
yaml_header += "# solution: null # Required for Closed state (implemented, cancelled, etc.)\n"
|
|
174
163
|
|
|
175
|
-
yaml_header = yaml.dump(metadata.model_dump(exclude_none=True, mode='json'), sort_keys=False, allow_unicode=True)
|
|
176
164
|
slug = _get_slug(title)
|
|
177
165
|
filename = f"{issue_id}-{slug}.md"
|
|
178
166
|
|
|
167
|
+
# Enhanced Template with Instructional Comments
|
|
179
168
|
file_content = f"""---
|
|
180
169
|
{yaml_header}---
|
|
181
170
|
|
|
182
171
|
## {issue_id}: {title}
|
|
183
172
|
|
|
184
173
|
## Objective
|
|
174
|
+
<!-- Describe the "Why" and "What" clearly. Focus on value. -->
|
|
185
175
|
|
|
186
176
|
## Acceptance Criteria
|
|
177
|
+
<!-- Define binary conditions for success. -->
|
|
178
|
+
- [ ] Criteria 1
|
|
187
179
|
|
|
188
180
|
## Technical Tasks
|
|
181
|
+
<!-- Breakdown into atomic steps. Use nested lists for sub-tasks. -->
|
|
189
182
|
|
|
190
|
-
|
|
183
|
+
<!-- Status Syntax: -->
|
|
184
|
+
<!-- [ ] To Do -->
|
|
185
|
+
<!-- [/] Doing -->
|
|
186
|
+
<!-- [x] Done -->
|
|
187
|
+
<!-- [~] Cancelled -->
|
|
188
|
+
<!-- - [ ] Parent Task -->
|
|
189
|
+
<!-- - [ ] Sub Task -->
|
|
190
|
+
|
|
191
|
+
- [ ] Task 1
|
|
192
|
+
|
|
193
|
+
## Review Comments
|
|
194
|
+
<!-- Required for Review/Done stage. Record review feedback here. -->
|
|
191
195
|
"""
|
|
192
196
|
file_path = target_dir / filename
|
|
193
197
|
file_path.write_text(file_content)
|
|
@@ -198,32 +202,23 @@ def create_issue_file(
|
|
|
198
202
|
return metadata, file_path
|
|
199
203
|
def get_available_actions(meta: IssueMetadata) -> List[Any]:
|
|
200
204
|
from .models import IssueAction
|
|
205
|
+
from .engine import get_engine
|
|
206
|
+
|
|
207
|
+
engine = get_engine()
|
|
208
|
+
transitions = engine.get_available_transitions(meta)
|
|
209
|
+
|
|
201
210
|
actions = []
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
# Stage-based movements
|
|
205
|
-
if meta.stage == IssueStage.DRAFT:
|
|
206
|
-
actions.append(IssueAction(label="Start", target_status=IssueStatus.OPEN, target_stage=IssueStage.DOING))
|
|
207
|
-
actions.append(IssueAction(label="Freeze", target_status=IssueStatus.BACKLOG))
|
|
208
|
-
elif meta.stage == IssueStage.DOING:
|
|
209
|
-
actions.append(IssueAction(label="Stop", target_status=IssueStatus.OPEN, target_stage=IssueStage.DRAFT))
|
|
210
|
-
actions.append(IssueAction(label="Submit", target_status=IssueStatus.OPEN, target_stage=IssueStage.REVIEW))
|
|
211
|
-
elif meta.stage == IssueStage.REVIEW:
|
|
212
|
-
actions.append(IssueAction(label="Approve", target_status=IssueStatus.CLOSED, target_stage=IssueStage.DONE, target_solution=IssueSolution.IMPLEMENTED))
|
|
213
|
-
actions.append(IssueAction(label="Reject", target_status=IssueStatus.OPEN, target_stage=IssueStage.DOING))
|
|
214
|
-
elif meta.stage == IssueStage.DONE:
|
|
215
|
-
actions.append(IssueAction(label="Reopen", target_status=IssueStatus.OPEN, target_stage=IssueStage.DRAFT))
|
|
216
|
-
actions.append(IssueAction(label="Close", target_status=IssueStatus.CLOSED, target_stage=IssueStage.DONE, target_solution=IssueSolution.IMPLEMENTED))
|
|
211
|
+
for t in transitions:
|
|
212
|
+
command = t.command_template.format(id=meta.id) if t.command_template else ""
|
|
217
213
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
actions.append(IssueAction(label="Reopen", target_status=IssueStatus.OPEN, target_stage=IssueStage.DRAFT))
|
|
214
|
+
actions.append(IssueAction(
|
|
215
|
+
label=t.label,
|
|
216
|
+
icon=t.icon,
|
|
217
|
+
target_status=t.to_status if t.to_status != meta.status or t.to_stage != meta.stage else None,
|
|
218
|
+
target_stage=t.to_stage if t.to_stage != meta.stage else None,
|
|
219
|
+
target_solution=t.required_solution,
|
|
220
|
+
command=command
|
|
221
|
+
))
|
|
227
222
|
|
|
228
223
|
return actions
|
|
229
224
|
|
|
@@ -261,7 +256,8 @@ def find_issue_path(issues_root: Path, issue_id: str) -> Optional[Path]:
|
|
|
261
256
|
except IndexError:
|
|
262
257
|
return None
|
|
263
258
|
|
|
264
|
-
|
|
259
|
+
reverse_prefix_map = get_reverse_prefix_map(issues_root)
|
|
260
|
+
issue_type = reverse_prefix_map.get(prefix)
|
|
265
261
|
if not issue_type:
|
|
266
262
|
return None
|
|
267
263
|
|
|
@@ -316,30 +312,43 @@ def update_issue(
|
|
|
316
312
|
current_status = IssueStatus(current_status_str.lower())
|
|
317
313
|
except ValueError:
|
|
318
314
|
current_status = IssueStatus.OPEN
|
|
315
|
+
|
|
316
|
+
current_stage_str = data.get("stage")
|
|
317
|
+
current_stage = IssueStage(current_stage_str.lower()) if current_stage_str else None
|
|
319
318
|
|
|
320
319
|
# Logic: Status Update
|
|
321
320
|
target_status = status if status else current_status
|
|
322
321
|
|
|
323
|
-
#
|
|
324
|
-
|
|
322
|
+
# If status is changing, we don't default target_stage to current_stage
|
|
323
|
+
# because the new status might have different allowed stages.
|
|
324
|
+
# enforce_policy will handle setting the correct default stage for the new status.
|
|
325
|
+
if status and status != current_status:
|
|
326
|
+
target_stage = stage
|
|
327
|
+
else:
|
|
328
|
+
target_stage = stage if stage else current_stage
|
|
329
|
+
|
|
330
|
+
# Engine Validation
|
|
331
|
+
from .engine import get_engine
|
|
332
|
+
engine = get_engine()
|
|
325
333
|
|
|
326
|
-
#
|
|
327
|
-
|
|
328
|
-
|
|
334
|
+
# Map solution string to enum if present
|
|
335
|
+
effective_solution = solution
|
|
336
|
+
if not effective_solution and data.get("solution"):
|
|
337
|
+
try:
|
|
338
|
+
effective_solution = IssueSolution(data.get("solution").lower())
|
|
339
|
+
except ValueError:
|
|
340
|
+
pass
|
|
329
341
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
if current_data_stage == IssueStage.DOING.value:
|
|
341
|
-
raise ValueError("Cannot close issue in progress (Doing). Please review (`monoco issue submit`) or stop (`monoco issue open`) first.")
|
|
342
|
-
|
|
342
|
+
# Use engine to validate the transition
|
|
343
|
+
engine.validate_transition(
|
|
344
|
+
from_status=current_status,
|
|
345
|
+
from_stage=current_stage,
|
|
346
|
+
to_status=target_status,
|
|
347
|
+
to_stage=target_stage,
|
|
348
|
+
solution=effective_solution
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
if target_status == "closed":
|
|
343
352
|
# Policy: Dependencies must be closed
|
|
344
353
|
dependencies_to_check = dependencies if dependencies is not None else data.get('dependencies', [])
|
|
345
354
|
if dependencies_to_check:
|
|
@@ -347,8 +356,8 @@ def update_issue(
|
|
|
347
356
|
dep_path = find_issue_path(issues_root, dep_id)
|
|
348
357
|
if dep_path:
|
|
349
358
|
dep_meta = parse_issue(dep_path)
|
|
350
|
-
if dep_meta and dep_meta.status !=
|
|
351
|
-
raise ValueError(f"Dependency Block: Cannot close {issue_id} because dependency {dep_id} is [Status: {dep_meta.status
|
|
359
|
+
if dep_meta and dep_meta.status != "closed":
|
|
360
|
+
raise ValueError(f"Dependency Block: Cannot close {issue_id} because dependency {dep_id} is [Status: {dep_meta.status}].")
|
|
352
361
|
|
|
353
362
|
# Validate new parent/dependencies/related exist
|
|
354
363
|
if parent is not None and parent != "":
|
|
@@ -367,12 +376,12 @@ def update_issue(
|
|
|
367
376
|
|
|
368
377
|
# Update Data
|
|
369
378
|
if status:
|
|
370
|
-
data['status'] = status
|
|
379
|
+
data['status'] = status
|
|
371
380
|
|
|
372
381
|
if stage:
|
|
373
|
-
data['stage'] = stage
|
|
382
|
+
data['stage'] = stage
|
|
374
383
|
if solution:
|
|
375
|
-
data['solution'] = solution
|
|
384
|
+
data['solution'] = solution
|
|
376
385
|
|
|
377
386
|
if title:
|
|
378
387
|
data['title'] = title
|
|
@@ -415,7 +424,8 @@ def update_issue(
|
|
|
415
424
|
|
|
416
425
|
# Enforce lifecycle policies (defaults, auto-corrections)
|
|
417
426
|
# This ensures that when we update, we also fix invalid states (like Closed but not Done)
|
|
418
|
-
|
|
427
|
+
from .engine import get_engine
|
|
428
|
+
get_engine().enforce_policy(updated_meta)
|
|
419
429
|
|
|
420
430
|
# Delegate to IssueValidator for static state validation
|
|
421
431
|
# We need to construct the full content to validate body-dependent rules (like checkboxes)
|
|
@@ -431,7 +441,8 @@ def update_issue(
|
|
|
431
441
|
raise ValueError(f"Failed to validate updated metadata: {e}")
|
|
432
442
|
|
|
433
443
|
# Serialize back
|
|
434
|
-
|
|
444
|
+
# Explicitly exclude actions and path from file persistence
|
|
445
|
+
new_yaml = yaml.dump(updated_meta.model_dump(exclude_none=True, mode='json', exclude={'actions', 'path'}), sort_keys=False, allow_unicode=True)
|
|
435
446
|
|
|
436
447
|
# Reconstruct File
|
|
437
448
|
match_header = re.search(r"^---(.*?)---", content, re.DOTALL | re.MULTILINE)
|
|
@@ -451,7 +462,8 @@ def update_issue(
|
|
|
451
462
|
if status and status != current_status:
|
|
452
463
|
# Move file
|
|
453
464
|
prefix = issue_id.split("-")[0].upper()
|
|
454
|
-
|
|
465
|
+
reverse_prefix_map = get_reverse_prefix_map(issues_root)
|
|
466
|
+
base_type_dir = get_issue_dir(reverse_prefix_map[prefix], issues_root)
|
|
455
467
|
|
|
456
468
|
try:
|
|
457
469
|
rel_path = path.relative_to(base_type_dir)
|
|
@@ -459,7 +471,7 @@ def update_issue(
|
|
|
459
471
|
except ValueError:
|
|
460
472
|
structure_path = Path(path.name)
|
|
461
473
|
|
|
462
|
-
target_path = base_type_dir / target_status
|
|
474
|
+
target_path = base_type_dir / target_status / structure_path
|
|
463
475
|
|
|
464
476
|
if path != target_path:
|
|
465
477
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -475,7 +487,7 @@ def update_issue(
|
|
|
475
487
|
updated_meta.actions = get_available_actions(updated_meta)
|
|
476
488
|
return updated_meta
|
|
477
489
|
|
|
478
|
-
def start_issue_isolation(issues_root: Path, issue_id: str, mode:
|
|
490
|
+
def start_issue_isolation(issues_root: Path, issue_id: str, mode: str, project_root: Path) -> IssueMetadata:
|
|
479
491
|
"""
|
|
480
492
|
Start physical isolation for an issue (Branch or Worktree).
|
|
481
493
|
"""
|
|
@@ -503,7 +515,7 @@ def start_issue_isolation(issues_root: Path, issue_id: str, mode: IsolationType,
|
|
|
503
515
|
|
|
504
516
|
isolation_meta = None
|
|
505
517
|
|
|
506
|
-
if mode ==
|
|
518
|
+
if mode == "branch":
|
|
507
519
|
if not git.branch_exists(project_root, branch_name):
|
|
508
520
|
git.create_branch(project_root, branch_name, checkout=True)
|
|
509
521
|
else:
|
|
@@ -513,9 +525,9 @@ def start_issue_isolation(issues_root: Path, issue_id: str, mode: IsolationType,
|
|
|
513
525
|
if current != branch_name:
|
|
514
526
|
git.checkout_branch(project_root, branch_name)
|
|
515
527
|
|
|
516
|
-
isolation_meta = IssueIsolation(type=
|
|
528
|
+
isolation_meta = IssueIsolation(type="branch", ref=branch_name)
|
|
517
529
|
|
|
518
|
-
elif mode ==
|
|
530
|
+
elif mode == "worktree":
|
|
519
531
|
wt_path = project_root / ".monoco" / "worktrees" / f"{issue_id.lower()}-{slug}"
|
|
520
532
|
|
|
521
533
|
# Check if worktree exists physically
|
|
@@ -526,7 +538,7 @@ def start_issue_isolation(issues_root: Path, issue_id: str, mode: IsolationType,
|
|
|
526
538
|
wt_path.parent.mkdir(parents=True, exist_ok=True)
|
|
527
539
|
git.worktree_add(project_root, branch_name, wt_path)
|
|
528
540
|
|
|
529
|
-
isolation_meta = IssueIsolation(type=
|
|
541
|
+
isolation_meta = IssueIsolation(type="worktree", ref=branch_name, path=str(wt_path))
|
|
530
542
|
|
|
531
543
|
# Persist Metadata
|
|
532
544
|
# We load raw, update isolation field, save.
|
|
@@ -538,7 +550,7 @@ def start_issue_isolation(issues_root: Path, issue_id: str, mode: IsolationType,
|
|
|
538
550
|
|
|
539
551
|
data['isolation'] = isolation_meta.model_dump(mode='json')
|
|
540
552
|
# Also ensure stage is DOING (logic link)
|
|
541
|
-
data['stage'] =
|
|
553
|
+
data['stage'] = "doing"
|
|
542
554
|
data['updated_at'] = current_time()
|
|
543
555
|
|
|
544
556
|
new_yaml = yaml.dump(data, sort_keys=False, allow_unicode=True)
|
|
@@ -663,7 +675,7 @@ description: Monoco Issue System 的官方技能定义。将 Issue 视为通用
|
|
|
663
675
|
- **Status Level (Lowercase)**: `open`, `backlog`, `closed`
|
|
664
676
|
|
|
665
677
|
### 路径流转
|
|
666
|
-
使用 `monoco issue
|
|
678
|
+
使用 `monoco issue`:
|
|
667
679
|
1. **Create**: `monoco issue create <type> --title "..."`
|
|
668
680
|
2. **Transition**: `monoco issue open/close/backlog <id>`
|
|
669
681
|
3. **View**: `monoco issue scope`
|
|
@@ -700,7 +712,10 @@ def list_issues(issues_root: Path, recursive_workspace: bool = False) -> List[Is
|
|
|
700
712
|
List all issues in the project.
|
|
701
713
|
"""
|
|
702
714
|
issues = []
|
|
703
|
-
|
|
715
|
+
engine = get_engine(str(issues_root.parent))
|
|
716
|
+
all_types = engine.get_all_types()
|
|
717
|
+
|
|
718
|
+
for issue_type in all_types:
|
|
704
719
|
base_dir = get_issue_dir(issue_type, issues_root)
|
|
705
720
|
for status_dir in ["open", "backlog", "closed"]:
|
|
706
721
|
d = base_dir / status_dir
|
|
@@ -726,6 +741,22 @@ def list_issues(issues_root: Path, recursive_workspace: bool = False) -> List[Is
|
|
|
726
741
|
member_issues = list_issues(member_issues_dir, False)
|
|
727
742
|
for m in member_issues:
|
|
728
743
|
# Namespace the ID to avoid collisions and indicate origin
|
|
744
|
+
# CRITICAL: Also namespace references to keep parent-child structure intact
|
|
745
|
+
if m.parent and "::" not in m.parent:
|
|
746
|
+
m.parent = f"{name}::{m.parent}"
|
|
747
|
+
|
|
748
|
+
if m.dependencies:
|
|
749
|
+
m.dependencies = [
|
|
750
|
+
f"{name}::{d}" if d and "::" not in d else d
|
|
751
|
+
for d in m.dependencies
|
|
752
|
+
]
|
|
753
|
+
|
|
754
|
+
if m.related:
|
|
755
|
+
m.related = [
|
|
756
|
+
f"{name}::{r}" if r and "::" not in r else r
|
|
757
|
+
for r in m.related
|
|
758
|
+
]
|
|
759
|
+
|
|
729
760
|
m.id = f"{name}::{m.id}"
|
|
730
761
|
issues.append(m)
|
|
731
762
|
except Exception:
|
|
@@ -739,21 +770,21 @@ def get_board_data(issues_root: Path) -> Dict[str, List[IssueMetadata]]:
|
|
|
739
770
|
Get open issues grouped by their stage for Kanban view.
|
|
740
771
|
"""
|
|
741
772
|
board = {
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
773
|
+
"draft": [],
|
|
774
|
+
"doing": [],
|
|
775
|
+
"review": [],
|
|
776
|
+
"done": []
|
|
746
777
|
}
|
|
747
778
|
|
|
748
779
|
issues = list_issues(issues_root)
|
|
749
780
|
for issue in issues:
|
|
750
|
-
if issue.status ==
|
|
751
|
-
stage_val = issue.stage
|
|
781
|
+
if issue.status == "open" and issue.stage:
|
|
782
|
+
stage_val = issue.stage
|
|
752
783
|
if stage_val in board:
|
|
753
784
|
board[stage_val].append(issue)
|
|
754
|
-
elif issue.status ==
|
|
785
|
+
elif issue.status == "closed":
|
|
755
786
|
# Optionally show recently closed items in DONE column
|
|
756
|
-
board[
|
|
787
|
+
board["done"].append(issue)
|
|
757
788
|
|
|
758
789
|
return board
|
|
759
790
|
|
|
@@ -763,14 +794,14 @@ def validate_issue_integrity(meta: IssueMetadata, all_issue_ids: Set[str] = set(
|
|
|
763
794
|
UI-agnostic.
|
|
764
795
|
"""
|
|
765
796
|
errors = []
|
|
766
|
-
if meta.status ==
|
|
797
|
+
if meta.status == "closed" and not meta.solution:
|
|
767
798
|
errors.append(f"Solution Missing: {meta.id} is closed but has no solution field.")
|
|
768
799
|
|
|
769
800
|
if meta.parent:
|
|
770
801
|
if all_issue_ids and meta.parent not in all_issue_ids:
|
|
771
802
|
errors.append(f"Broken Link: {meta.id} refers to non-existent parent {meta.parent}.")
|
|
772
803
|
|
|
773
|
-
if meta.status ==
|
|
804
|
+
if meta.status == "backlog" and meta.stage != "freezed":
|
|
774
805
|
errors.append(f"Lifecycle Error: {meta.id} is backlog but stage is not freezed (found: {meta.stage}).")
|
|
775
806
|
|
|
776
807
|
return errors
|
|
@@ -815,7 +846,8 @@ def update_issue_content(issues_root: Path, issue_id: str, new_content: str) ->
|
|
|
815
846
|
# Reuse logic from update_issue (simplified)
|
|
816
847
|
|
|
817
848
|
prefix = issue_id.split("-")[0].upper()
|
|
818
|
-
|
|
849
|
+
reverse_prefix_map = get_reverse_prefix_map(issues_root)
|
|
850
|
+
base_type_dir = get_issue_dir(reverse_prefix_map[prefix], issues_root)
|
|
819
851
|
|
|
820
852
|
# Calculate structure path (preserve subdir)
|
|
821
853
|
try:
|
|
@@ -828,7 +860,7 @@ def update_issue_content(issues_root: Path, issue_id: str, new_content: str) ->
|
|
|
828
860
|
# Fallback if path is weird
|
|
829
861
|
structure_path = Path(path.name)
|
|
830
862
|
|
|
831
|
-
target_path = base_type_dir / meta.status
|
|
863
|
+
target_path = base_type_dir / meta.status / structure_path
|
|
832
864
|
|
|
833
865
|
if path != target_path:
|
|
834
866
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -980,9 +1012,9 @@ def check_issue_match(issue: IssueMetadata, explicit_positives: List[str], terms
|
|
|
980
1012
|
searchable_parts = [
|
|
981
1013
|
issue.id,
|
|
982
1014
|
issue.title,
|
|
983
|
-
issue.status
|
|
984
|
-
issue.type
|
|
985
|
-
str(issue.stage
|
|
1015
|
+
issue.status,
|
|
1016
|
+
issue.type,
|
|
1017
|
+
str(issue.stage) if issue.stage else "",
|
|
986
1018
|
*(issue.tags or []),
|
|
987
1019
|
*(issue.dependencies or []),
|
|
988
1020
|
*(issue.related or []),
|
|
@@ -1041,7 +1073,10 @@ def search_issues(issues_root: Path, query: str) -> List[IssueMetadata]:
|
|
|
1041
1073
|
# To support deep search (Body), we need to read files.
|
|
1042
1074
|
# Let's iterate files directly.
|
|
1043
1075
|
|
|
1044
|
-
|
|
1076
|
+
engine = get_engine(str(issues_root.parent))
|
|
1077
|
+
all_types = engine.get_all_types()
|
|
1078
|
+
|
|
1079
|
+
for issue_type in all_types:
|
|
1045
1080
|
base_dir = get_issue_dir(issue_type, issues_root)
|
|
1046
1081
|
for status_dir in ["open", "backlog", "closed"]:
|
|
1047
1082
|
d = base_dir / status_dir
|
|
@@ -1089,7 +1124,7 @@ def recalculate_parent(issues_root: Path, parent_id: str):
|
|
|
1089
1124
|
return
|
|
1090
1125
|
|
|
1091
1126
|
total = len(children)
|
|
1092
|
-
closed = len([c for c in children if c.status ==
|
|
1127
|
+
closed = len([c for c in children if c.status == "closed"])
|
|
1093
1128
|
# Progress string: "3/5"
|
|
1094
1129
|
progress_str = f"{closed}/{total}"
|
|
1095
1130
|
|
|
@@ -1129,8 +1164,8 @@ def recalculate_parent(issues_root: Path, parent_id: str):
|
|
|
1129
1164
|
|
|
1130
1165
|
if current_status == "open" and current_stage == "draft":
|
|
1131
1166
|
# Check if any child is active
|
|
1132
|
-
active_children = [c for c in children if c.status ==
|
|
1133
|
-
closed_children = [c for c in children if c.status ==
|
|
1167
|
+
active_children = [c for c in children if c.status == "open" and c.stage != "draft"]
|
|
1168
|
+
closed_children = [c for c in children if c.status == "closed"]
|
|
1134
1169
|
|
|
1135
1170
|
if active_children or closed_children:
|
|
1136
1171
|
data["stage"] = "doing"
|
|
@@ -1212,7 +1247,7 @@ def move_issue(
|
|
|
1212
1247
|
|
|
1213
1248
|
# 4. Construct target path
|
|
1214
1249
|
target_type_dir = get_issue_dir(issue.type, target_issues_root)
|
|
1215
|
-
target_status_dir = target_type_dir / issue.status
|
|
1250
|
+
target_status_dir = target_type_dir / issue.status
|
|
1216
1251
|
|
|
1217
1252
|
# Preserve subdirectory structure if any
|
|
1218
1253
|
try:
|
|
File without changes
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from typing import List, Optional, Callable
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
from ..models import IssueStatus, IssueStage, IssueSolution, current_time
|
|
4
|
+
from .models import Issue
|
|
5
|
+
|
|
6
|
+
class Transition(BaseModel):
|
|
7
|
+
name: str
|
|
8
|
+
from_status: Optional[IssueStatus] = None # None means any
|
|
9
|
+
from_stage: Optional[IssueStage] = None # None means any
|
|
10
|
+
to_status: IssueStatus
|
|
11
|
+
to_stage: Optional[IssueStage] = None
|
|
12
|
+
required_solution: Optional[IssueSolution] = None
|
|
13
|
+
description: str = ""
|
|
14
|
+
|
|
15
|
+
def is_allowed(self, issue: Issue) -> bool:
|
|
16
|
+
if self.from_status and issue.status != self.from_status:
|
|
17
|
+
return False
|
|
18
|
+
if self.from_stage and issue.frontmatter.stage != self.from_stage:
|
|
19
|
+
return False
|
|
20
|
+
return True
|
|
21
|
+
|
|
22
|
+
class TransitionService:
|
|
23
|
+
def __init__(self):
|
|
24
|
+
self.transitions: List[Transition] = [
|
|
25
|
+
# Open -> Backlog
|
|
26
|
+
Transition(
|
|
27
|
+
name="freeze",
|
|
28
|
+
from_status=IssueStatus.OPEN,
|
|
29
|
+
to_status=IssueStatus.BACKLOG,
|
|
30
|
+
to_stage=IssueStage.FREEZED,
|
|
31
|
+
description="Move open issue to backlog"
|
|
32
|
+
),
|
|
33
|
+
# Backlog -> Open
|
|
34
|
+
Transition(
|
|
35
|
+
name="activate",
|
|
36
|
+
from_status=IssueStatus.BACKLOG,
|
|
37
|
+
to_status=IssueStatus.OPEN,
|
|
38
|
+
to_stage=IssueStage.DRAFT, # Reset to draft?
|
|
39
|
+
description="Restore issue from backlog"
|
|
40
|
+
),
|
|
41
|
+
# Open (Draft) -> Open (Doing)
|
|
42
|
+
Transition(
|
|
43
|
+
name="start",
|
|
44
|
+
from_status=IssueStatus.OPEN,
|
|
45
|
+
from_stage=IssueStage.DRAFT,
|
|
46
|
+
to_status=IssueStatus.OPEN,
|
|
47
|
+
to_stage=IssueStage.DOING,
|
|
48
|
+
description="Start working on the issue"
|
|
49
|
+
),
|
|
50
|
+
# Open (Doing) -> Open (Review)
|
|
51
|
+
Transition(
|
|
52
|
+
name="submit",
|
|
53
|
+
from_status=IssueStatus.OPEN,
|
|
54
|
+
from_stage=IssueStage.DOING,
|
|
55
|
+
to_status=IssueStatus.OPEN,
|
|
56
|
+
to_stage=IssueStage.REVIEW,
|
|
57
|
+
description="Submit for review"
|
|
58
|
+
),
|
|
59
|
+
# Open (Review) -> Open (Doing) - reject
|
|
60
|
+
Transition(
|
|
61
|
+
name="reject",
|
|
62
|
+
from_status=IssueStatus.OPEN,
|
|
63
|
+
from_stage=IssueStage.REVIEW,
|
|
64
|
+
to_status=IssueStatus.OPEN,
|
|
65
|
+
to_stage=IssueStage.DOING,
|
|
66
|
+
description="Reject review and return to doing"
|
|
67
|
+
),
|
|
68
|
+
# Open (Review) -> Closed (Implemented)
|
|
69
|
+
Transition(
|
|
70
|
+
name="accept",
|
|
71
|
+
from_status=IssueStatus.OPEN,
|
|
72
|
+
from_stage=IssueStage.REVIEW,
|
|
73
|
+
to_status=IssueStatus.CLOSED,
|
|
74
|
+
to_stage=IssueStage.DONE,
|
|
75
|
+
required_solution=IssueSolution.IMPLEMENTED,
|
|
76
|
+
description="Accept and close issue"
|
|
77
|
+
),
|
|
78
|
+
# Direct Close (Cancel, Wontfix, Duplicate)
|
|
79
|
+
Transition(
|
|
80
|
+
name="cancel",
|
|
81
|
+
to_status=IssueStatus.CLOSED,
|
|
82
|
+
to_stage=IssueStage.DONE,
|
|
83
|
+
required_solution=IssueSolution.CANCELLED,
|
|
84
|
+
description="Cancel the issue"
|
|
85
|
+
),
|
|
86
|
+
Transition(
|
|
87
|
+
name="wontfix",
|
|
88
|
+
to_status=IssueStatus.CLOSED,
|
|
89
|
+
to_stage=IssueStage.DONE,
|
|
90
|
+
required_solution=IssueSolution.WONTFIX,
|
|
91
|
+
description="Mark as wontfix"
|
|
92
|
+
),
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
def get_available_transitions(self, issue: Issue) -> List[Transition]:
|
|
96
|
+
return [t for t in self.transitions if t.is_allowed(issue)]
|
|
97
|
+
|
|
98
|
+
def apply_transition(self, issue: Issue, transition_name: str) -> Issue:
|
|
99
|
+
# Find transition
|
|
100
|
+
candidates = [t for t in self.transitions if t.name == transition_name]
|
|
101
|
+
valid_transition = None
|
|
102
|
+
for t in candidates:
|
|
103
|
+
if t.is_allowed(issue):
|
|
104
|
+
valid_transition = t
|
|
105
|
+
break
|
|
106
|
+
|
|
107
|
+
if not valid_transition:
|
|
108
|
+
raise ValueError(f"Transition '{transition_name}' is not allowed for current state.")
|
|
109
|
+
|
|
110
|
+
# Apply changes
|
|
111
|
+
issue.frontmatter.status = valid_transition.to_status
|
|
112
|
+
if valid_transition.to_stage:
|
|
113
|
+
issue.frontmatter.stage = valid_transition.to_stage
|
|
114
|
+
if valid_transition.required_solution:
|
|
115
|
+
issue.frontmatter.solution = valid_transition.required_solution
|
|
116
|
+
|
|
117
|
+
issue.frontmatter.updated_at = current_time()
|
|
118
|
+
|
|
119
|
+
# Logic for closed_at, opened_at etc.
|
|
120
|
+
if valid_transition.to_status == IssueStatus.CLOSED and issue.frontmatter.closed_at is None:
|
|
121
|
+
issue.frontmatter.closed_at = current_time()
|
|
122
|
+
|
|
123
|
+
if valid_transition.to_status == IssueStatus.OPEN and issue.frontmatter.opened_at is None:
|
|
124
|
+
issue.frontmatter.opened_at = current_time()
|
|
125
|
+
|
|
126
|
+
return issue
|