monoco-toolkit 0.2.4__py3-none-any.whl → 0.2.6__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/project.py +15 -7
- monoco/cli/workspace.py +11 -3
- monoco/core/agent/adapters.py +24 -1
- monoco/core/config.py +81 -3
- monoco/core/integrations.py +8 -0
- monoco/core/lsp.py +7 -0
- monoco/core/output.py +8 -1
- monoco/core/resources/en/SKILL.md +1 -1
- monoco/core/setup.py +8 -1
- monoco/daemon/app.py +18 -12
- monoco/features/agent/commands.py +94 -17
- monoco/features/agent/core.py +48 -0
- monoco/features/agent/resources/en/critique.prompty +16 -0
- monoco/features/agent/resources/en/develop.prompty +16 -0
- monoco/features/agent/resources/en/investigate.prompty +16 -0
- monoco/features/agent/resources/en/refine.prompty +14 -0
- monoco/features/agent/resources/en/verify.prompty +16 -0
- monoco/features/agent/resources/zh/critique.prompty +18 -0
- monoco/features/agent/resources/zh/develop.prompty +18 -0
- monoco/features/agent/resources/zh/investigate.prompty +18 -0
- monoco/features/agent/resources/zh/refine.prompty +16 -0
- monoco/features/agent/resources/zh/verify.prompty +18 -0
- monoco/features/config/commands.py +35 -14
- monoco/features/i18n/commands.py +89 -10
- monoco/features/i18n/core.py +112 -16
- monoco/features/issue/commands.py +254 -85
- monoco/features/issue/core.py +142 -119
- 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 +189 -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 +8 -8
- monoco/features/issue/validator.py +204 -65
- monoco/features/spike/commands.py +45 -24
- monoco/features/spike/core.py +5 -22
- monoco/main.py +11 -17
- {monoco_toolkit-0.2.4.dist-info → monoco_toolkit-0.2.6.dist-info}/METADATA +1 -1
- monoco_toolkit-0.2.6.dist-info/RECORD +96 -0
- monoco/features/issue/executions/refine.md +0 -26
- 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.4.dist-info/RECORD +0 -78
- {monoco_toolkit-0.2.4.dist-info → monoco_toolkit-0.2.6.dist-info}/WHEEL +0 -0
- {monoco_toolkit-0.2.4.dist-info → monoco_toolkit-0.2.6.dist-info}/entry_points.txt +0 -0
- {monoco_toolkit-0.2.4.dist-info → monoco_toolkit-0.2.6.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,49 @@ 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)
|
|
174
153
|
|
|
154
|
+
# Serialize metadata
|
|
175
155
|
yaml_header = yaml.dump(metadata.model_dump(exclude_none=True, mode='json'), sort_keys=False, allow_unicode=True)
|
|
156
|
+
|
|
157
|
+
# Inject Self-Documenting Hints (Interactive Frontmatter)
|
|
158
|
+
if "parent:" not in yaml_header:
|
|
159
|
+
yaml_header += "# parent: <EPIC-ID> # Optional: Parent Issue ID\n"
|
|
160
|
+
if "solution:" not in yaml_header:
|
|
161
|
+
yaml_header += "# solution: null # Required for Closed state (implemented, cancelled, etc.)\n"
|
|
162
|
+
|
|
176
163
|
slug = _get_slug(title)
|
|
177
164
|
filename = f"{issue_id}-{slug}.md"
|
|
178
165
|
|
|
166
|
+
# Enhanced Template with Instructional Comments
|
|
179
167
|
file_content = f"""---
|
|
180
168
|
{yaml_header}---
|
|
181
169
|
|
|
182
170
|
## {issue_id}: {title}
|
|
183
171
|
|
|
184
172
|
## Objective
|
|
173
|
+
<!-- Describe the "Why" and "What" clearly. Focus on value. -->
|
|
185
174
|
|
|
186
175
|
## Acceptance Criteria
|
|
176
|
+
<!-- Define binary conditions for success. -->
|
|
177
|
+
- [ ] Criteria 1
|
|
187
178
|
|
|
188
179
|
## Technical Tasks
|
|
180
|
+
<!-- Breakdown into atomic steps. Use nested lists for sub-tasks. -->
|
|
189
181
|
|
|
190
|
-
|
|
182
|
+
<!-- Status Syntax: -->
|
|
183
|
+
<!-- [ ] To Do -->
|
|
184
|
+
<!-- [/] Doing -->
|
|
185
|
+
<!-- [x] Done -->
|
|
186
|
+
<!-- [~] Cancelled -->
|
|
187
|
+
<!-- - [ ] Parent Task -->
|
|
188
|
+
<!-- - [ ] Sub Task -->
|
|
189
|
+
|
|
190
|
+
- [ ] Task 1
|
|
191
|
+
|
|
192
|
+
## Review Comments
|
|
193
|
+
<!-- Required for Review/Done stage. Record review feedback here. -->
|
|
191
194
|
"""
|
|
192
195
|
file_path = target_dir / filename
|
|
193
196
|
file_path.write_text(file_content)
|
|
@@ -198,32 +201,29 @@ def create_issue_file(
|
|
|
198
201
|
return metadata, file_path
|
|
199
202
|
def get_available_actions(meta: IssueMetadata) -> List[Any]:
|
|
200
203
|
from .models import IssueAction
|
|
204
|
+
from .engine import get_engine
|
|
205
|
+
|
|
206
|
+
engine = get_engine()
|
|
207
|
+
transitions = engine.get_available_transitions(meta)
|
|
208
|
+
|
|
201
209
|
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))
|
|
210
|
+
for t in transitions:
|
|
211
|
+
command = t.command_template.format(id=meta.id) if t.command_template else ""
|
|
217
212
|
|
|
218
|
-
#
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
actions.append(IssueAction(
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
213
|
+
# Determine task if it's an agent action
|
|
214
|
+
task = None
|
|
215
|
+
if t.name == "develop":
|
|
216
|
+
task = "develop"
|
|
217
|
+
|
|
218
|
+
actions.append(IssueAction(
|
|
219
|
+
label=t.label,
|
|
220
|
+
icon=t.icon,
|
|
221
|
+
target_status=t.to_status if t.to_status != meta.status or t.to_stage != meta.stage else None,
|
|
222
|
+
target_stage=t.to_stage if t.to_stage != meta.stage else None,
|
|
223
|
+
target_solution=t.required_solution,
|
|
224
|
+
command=command,
|
|
225
|
+
task=task
|
|
226
|
+
))
|
|
227
227
|
|
|
228
228
|
return actions
|
|
229
229
|
|
|
@@ -261,7 +261,8 @@ def find_issue_path(issues_root: Path, issue_id: str) -> Optional[Path]:
|
|
|
261
261
|
except IndexError:
|
|
262
262
|
return None
|
|
263
263
|
|
|
264
|
-
|
|
264
|
+
reverse_prefix_map = get_reverse_prefix_map(issues_root)
|
|
265
|
+
issue_type = reverse_prefix_map.get(prefix)
|
|
265
266
|
if not issue_type:
|
|
266
267
|
return None
|
|
267
268
|
|
|
@@ -316,30 +317,43 @@ def update_issue(
|
|
|
316
317
|
current_status = IssueStatus(current_status_str.lower())
|
|
317
318
|
except ValueError:
|
|
318
319
|
current_status = IssueStatus.OPEN
|
|
320
|
+
|
|
321
|
+
current_stage_str = data.get("stage")
|
|
322
|
+
current_stage = IssueStage(current_stage_str.lower()) if current_stage_str else None
|
|
319
323
|
|
|
320
324
|
# Logic: Status Update
|
|
321
325
|
target_status = status if status else current_status
|
|
322
326
|
|
|
323
|
-
#
|
|
324
|
-
|
|
327
|
+
# If status is changing, we don't default target_stage to current_stage
|
|
328
|
+
# because the new status might have different allowed stages.
|
|
329
|
+
# enforce_policy will handle setting the correct default stage for the new status.
|
|
330
|
+
if status and status != current_status:
|
|
331
|
+
target_stage = stage
|
|
332
|
+
else:
|
|
333
|
+
target_stage = stage if stage else current_stage
|
|
325
334
|
|
|
326
|
-
#
|
|
327
|
-
|
|
328
|
-
|
|
335
|
+
# Engine Validation
|
|
336
|
+
from .engine import get_engine
|
|
337
|
+
engine = get_engine()
|
|
338
|
+
|
|
339
|
+
# Map solution string to enum if present
|
|
340
|
+
effective_solution = solution
|
|
341
|
+
if not effective_solution and data.get("solution"):
|
|
342
|
+
try:
|
|
343
|
+
effective_solution = IssueSolution(data.get("solution").lower())
|
|
344
|
+
except ValueError:
|
|
345
|
+
pass
|
|
329
346
|
|
|
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
|
-
|
|
347
|
+
# Use engine to validate the transition
|
|
348
|
+
engine.validate_transition(
|
|
349
|
+
from_status=current_status,
|
|
350
|
+
from_stage=current_stage,
|
|
351
|
+
to_status=target_status,
|
|
352
|
+
to_stage=target_stage,
|
|
353
|
+
solution=effective_solution
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
if target_status == "closed":
|
|
343
357
|
# Policy: Dependencies must be closed
|
|
344
358
|
dependencies_to_check = dependencies if dependencies is not None else data.get('dependencies', [])
|
|
345
359
|
if dependencies_to_check:
|
|
@@ -347,8 +361,8 @@ def update_issue(
|
|
|
347
361
|
dep_path = find_issue_path(issues_root, dep_id)
|
|
348
362
|
if dep_path:
|
|
349
363
|
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
|
|
364
|
+
if dep_meta and dep_meta.status != "closed":
|
|
365
|
+
raise ValueError(f"Dependency Block: Cannot close {issue_id} because dependency {dep_id} is [Status: {dep_meta.status}].")
|
|
352
366
|
|
|
353
367
|
# Validate new parent/dependencies/related exist
|
|
354
368
|
if parent is not None and parent != "":
|
|
@@ -367,12 +381,12 @@ def update_issue(
|
|
|
367
381
|
|
|
368
382
|
# Update Data
|
|
369
383
|
if status:
|
|
370
|
-
data['status'] = status
|
|
384
|
+
data['status'] = status
|
|
371
385
|
|
|
372
386
|
if stage:
|
|
373
|
-
data['stage'] = stage
|
|
387
|
+
data['stage'] = stage
|
|
374
388
|
if solution:
|
|
375
|
-
data['solution'] = solution
|
|
389
|
+
data['solution'] = solution
|
|
376
390
|
|
|
377
391
|
if title:
|
|
378
392
|
data['title'] = title
|
|
@@ -415,7 +429,8 @@ def update_issue(
|
|
|
415
429
|
|
|
416
430
|
# Enforce lifecycle policies (defaults, auto-corrections)
|
|
417
431
|
# This ensures that when we update, we also fix invalid states (like Closed but not Done)
|
|
418
|
-
|
|
432
|
+
from .engine import get_engine
|
|
433
|
+
get_engine().enforce_policy(updated_meta)
|
|
419
434
|
|
|
420
435
|
# Delegate to IssueValidator for static state validation
|
|
421
436
|
# We need to construct the full content to validate body-dependent rules (like checkboxes)
|
|
@@ -451,7 +466,8 @@ def update_issue(
|
|
|
451
466
|
if status and status != current_status:
|
|
452
467
|
# Move file
|
|
453
468
|
prefix = issue_id.split("-")[0].upper()
|
|
454
|
-
|
|
469
|
+
reverse_prefix_map = get_reverse_prefix_map(issues_root)
|
|
470
|
+
base_type_dir = get_issue_dir(reverse_prefix_map[prefix], issues_root)
|
|
455
471
|
|
|
456
472
|
try:
|
|
457
473
|
rel_path = path.relative_to(base_type_dir)
|
|
@@ -459,7 +475,7 @@ def update_issue(
|
|
|
459
475
|
except ValueError:
|
|
460
476
|
structure_path = Path(path.name)
|
|
461
477
|
|
|
462
|
-
target_path = base_type_dir / target_status
|
|
478
|
+
target_path = base_type_dir / target_status / structure_path
|
|
463
479
|
|
|
464
480
|
if path != target_path:
|
|
465
481
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -475,7 +491,7 @@ def update_issue(
|
|
|
475
491
|
updated_meta.actions = get_available_actions(updated_meta)
|
|
476
492
|
return updated_meta
|
|
477
493
|
|
|
478
|
-
def start_issue_isolation(issues_root: Path, issue_id: str, mode:
|
|
494
|
+
def start_issue_isolation(issues_root: Path, issue_id: str, mode: str, project_root: Path) -> IssueMetadata:
|
|
479
495
|
"""
|
|
480
496
|
Start physical isolation for an issue (Branch or Worktree).
|
|
481
497
|
"""
|
|
@@ -503,7 +519,7 @@ def start_issue_isolation(issues_root: Path, issue_id: str, mode: IsolationType,
|
|
|
503
519
|
|
|
504
520
|
isolation_meta = None
|
|
505
521
|
|
|
506
|
-
if mode ==
|
|
522
|
+
if mode == "branch":
|
|
507
523
|
if not git.branch_exists(project_root, branch_name):
|
|
508
524
|
git.create_branch(project_root, branch_name, checkout=True)
|
|
509
525
|
else:
|
|
@@ -513,9 +529,9 @@ def start_issue_isolation(issues_root: Path, issue_id: str, mode: IsolationType,
|
|
|
513
529
|
if current != branch_name:
|
|
514
530
|
git.checkout_branch(project_root, branch_name)
|
|
515
531
|
|
|
516
|
-
isolation_meta = IssueIsolation(type=
|
|
532
|
+
isolation_meta = IssueIsolation(type="branch", ref=branch_name)
|
|
517
533
|
|
|
518
|
-
elif mode ==
|
|
534
|
+
elif mode == "worktree":
|
|
519
535
|
wt_path = project_root / ".monoco" / "worktrees" / f"{issue_id.lower()}-{slug}"
|
|
520
536
|
|
|
521
537
|
# Check if worktree exists physically
|
|
@@ -526,7 +542,7 @@ def start_issue_isolation(issues_root: Path, issue_id: str, mode: IsolationType,
|
|
|
526
542
|
wt_path.parent.mkdir(parents=True, exist_ok=True)
|
|
527
543
|
git.worktree_add(project_root, branch_name, wt_path)
|
|
528
544
|
|
|
529
|
-
isolation_meta = IssueIsolation(type=
|
|
545
|
+
isolation_meta = IssueIsolation(type="worktree", ref=branch_name, path=str(wt_path))
|
|
530
546
|
|
|
531
547
|
# Persist Metadata
|
|
532
548
|
# We load raw, update isolation field, save.
|
|
@@ -538,7 +554,7 @@ def start_issue_isolation(issues_root: Path, issue_id: str, mode: IsolationType,
|
|
|
538
554
|
|
|
539
555
|
data['isolation'] = isolation_meta.model_dump(mode='json')
|
|
540
556
|
# Also ensure stage is DOING (logic link)
|
|
541
|
-
data['stage'] =
|
|
557
|
+
data['stage'] = "doing"
|
|
542
558
|
data['updated_at'] = current_time()
|
|
543
559
|
|
|
544
560
|
new_yaml = yaml.dump(data, sort_keys=False, allow_unicode=True)
|
|
@@ -700,7 +716,10 @@ def list_issues(issues_root: Path, recursive_workspace: bool = False) -> List[Is
|
|
|
700
716
|
List all issues in the project.
|
|
701
717
|
"""
|
|
702
718
|
issues = []
|
|
703
|
-
|
|
719
|
+
engine = get_engine(str(issues_root.parent))
|
|
720
|
+
all_types = engine.get_all_types()
|
|
721
|
+
|
|
722
|
+
for issue_type in all_types:
|
|
704
723
|
base_dir = get_issue_dir(issue_type, issues_root)
|
|
705
724
|
for status_dir in ["open", "backlog", "closed"]:
|
|
706
725
|
d = base_dir / status_dir
|
|
@@ -739,21 +758,21 @@ def get_board_data(issues_root: Path) -> Dict[str, List[IssueMetadata]]:
|
|
|
739
758
|
Get open issues grouped by their stage for Kanban view.
|
|
740
759
|
"""
|
|
741
760
|
board = {
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
761
|
+
"draft": [],
|
|
762
|
+
"doing": [],
|
|
763
|
+
"review": [],
|
|
764
|
+
"done": []
|
|
746
765
|
}
|
|
747
766
|
|
|
748
767
|
issues = list_issues(issues_root)
|
|
749
768
|
for issue in issues:
|
|
750
|
-
if issue.status ==
|
|
751
|
-
stage_val = issue.stage
|
|
769
|
+
if issue.status == "open" and issue.stage:
|
|
770
|
+
stage_val = issue.stage
|
|
752
771
|
if stage_val in board:
|
|
753
772
|
board[stage_val].append(issue)
|
|
754
|
-
elif issue.status ==
|
|
773
|
+
elif issue.status == "closed":
|
|
755
774
|
# Optionally show recently closed items in DONE column
|
|
756
|
-
board[
|
|
775
|
+
board["done"].append(issue)
|
|
757
776
|
|
|
758
777
|
return board
|
|
759
778
|
|
|
@@ -763,14 +782,14 @@ def validate_issue_integrity(meta: IssueMetadata, all_issue_ids: Set[str] = set(
|
|
|
763
782
|
UI-agnostic.
|
|
764
783
|
"""
|
|
765
784
|
errors = []
|
|
766
|
-
if meta.status ==
|
|
785
|
+
if meta.status == "closed" and not meta.solution:
|
|
767
786
|
errors.append(f"Solution Missing: {meta.id} is closed but has no solution field.")
|
|
768
787
|
|
|
769
788
|
if meta.parent:
|
|
770
789
|
if all_issue_ids and meta.parent not in all_issue_ids:
|
|
771
790
|
errors.append(f"Broken Link: {meta.id} refers to non-existent parent {meta.parent}.")
|
|
772
791
|
|
|
773
|
-
if meta.status ==
|
|
792
|
+
if meta.status == "backlog" and meta.stage != "freezed":
|
|
774
793
|
errors.append(f"Lifecycle Error: {meta.id} is backlog but stage is not freezed (found: {meta.stage}).")
|
|
775
794
|
|
|
776
795
|
return errors
|
|
@@ -815,7 +834,8 @@ def update_issue_content(issues_root: Path, issue_id: str, new_content: str) ->
|
|
|
815
834
|
# Reuse logic from update_issue (simplified)
|
|
816
835
|
|
|
817
836
|
prefix = issue_id.split("-")[0].upper()
|
|
818
|
-
|
|
837
|
+
reverse_prefix_map = get_reverse_prefix_map(issues_root)
|
|
838
|
+
base_type_dir = get_issue_dir(reverse_prefix_map[prefix], issues_root)
|
|
819
839
|
|
|
820
840
|
# Calculate structure path (preserve subdir)
|
|
821
841
|
try:
|
|
@@ -828,7 +848,7 @@ def update_issue_content(issues_root: Path, issue_id: str, new_content: str) ->
|
|
|
828
848
|
# Fallback if path is weird
|
|
829
849
|
structure_path = Path(path.name)
|
|
830
850
|
|
|
831
|
-
target_path = base_type_dir / meta.status
|
|
851
|
+
target_path = base_type_dir / meta.status / structure_path
|
|
832
852
|
|
|
833
853
|
if path != target_path:
|
|
834
854
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -980,9 +1000,9 @@ def check_issue_match(issue: IssueMetadata, explicit_positives: List[str], terms
|
|
|
980
1000
|
searchable_parts = [
|
|
981
1001
|
issue.id,
|
|
982
1002
|
issue.title,
|
|
983
|
-
issue.status
|
|
984
|
-
issue.type
|
|
985
|
-
str(issue.stage
|
|
1003
|
+
issue.status,
|
|
1004
|
+
issue.type,
|
|
1005
|
+
str(issue.stage) if issue.stage else "",
|
|
986
1006
|
*(issue.tags or []),
|
|
987
1007
|
*(issue.dependencies or []),
|
|
988
1008
|
*(issue.related or []),
|
|
@@ -1041,7 +1061,10 @@ def search_issues(issues_root: Path, query: str) -> List[IssueMetadata]:
|
|
|
1041
1061
|
# To support deep search (Body), we need to read files.
|
|
1042
1062
|
# Let's iterate files directly.
|
|
1043
1063
|
|
|
1044
|
-
|
|
1064
|
+
engine = get_engine(str(issues_root.parent))
|
|
1065
|
+
all_types = engine.get_all_types()
|
|
1066
|
+
|
|
1067
|
+
for issue_type in all_types:
|
|
1045
1068
|
base_dir = get_issue_dir(issue_type, issues_root)
|
|
1046
1069
|
for status_dir in ["open", "backlog", "closed"]:
|
|
1047
1070
|
d = base_dir / status_dir
|
|
@@ -1089,7 +1112,7 @@ def recalculate_parent(issues_root: Path, parent_id: str):
|
|
|
1089
1112
|
return
|
|
1090
1113
|
|
|
1091
1114
|
total = len(children)
|
|
1092
|
-
closed = len([c for c in children if c.status ==
|
|
1115
|
+
closed = len([c for c in children if c.status == "closed"])
|
|
1093
1116
|
# Progress string: "3/5"
|
|
1094
1117
|
progress_str = f"{closed}/{total}"
|
|
1095
1118
|
|
|
@@ -1129,8 +1152,8 @@ def recalculate_parent(issues_root: Path, parent_id: str):
|
|
|
1129
1152
|
|
|
1130
1153
|
if current_status == "open" and current_stage == "draft":
|
|
1131
1154
|
# 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 ==
|
|
1155
|
+
active_children = [c for c in children if c.status == "open" and c.stage != "draft"]
|
|
1156
|
+
closed_children = [c for c in children if c.status == "closed"]
|
|
1134
1157
|
|
|
1135
1158
|
if active_children or closed_children:
|
|
1136
1159
|
data["stage"] = "doing"
|
|
@@ -1212,7 +1235,7 @@ def move_issue(
|
|
|
1212
1235
|
|
|
1213
1236
|
# 4. Construct target path
|
|
1214
1237
|
target_type_dir = get_issue_dir(issue.type, target_issues_root)
|
|
1215
|
-
target_status_dir = target_type_dir / issue.status
|
|
1238
|
+
target_status_dir = target_type_dir / issue.status
|
|
1216
1239
|
|
|
1217
1240
|
# Preserve subdirectory structure if any
|
|
1218
1241
|
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
|