monoco-toolkit 0.2.5__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/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 +179 -55
- monoco/features/issue/core.py +263 -124
- 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 +118 -12
- monoco/features/issue/lsp/__init__.py +3 -0
- monoco/features/issue/lsp/definition.py +72 -0
- monoco/features/issue/models.py +27 -9
- monoco/features/issue/resources/en/AGENTS.md +5 -0
- monoco/features/issue/resources/en/SKILL.md +26 -2
- monoco/features/issue/resources/zh/AGENTS.md +5 -0
- monoco/features/issue/resources/zh/SKILL.md +34 -10
- monoco/features/issue/validator.py +252 -66
- 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.8.dist-info/METADATA +136 -0
- {monoco_toolkit-0.2.5.dist-info → monoco_toolkit-0.2.8.dist-info}/RECORD +36 -30
- 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.8.dist-info}/WHEEL +0 -0
- {monoco_toolkit-0.2.5.dist-info → monoco_toolkit-0.2.8.dist-info}/entry_points.txt +0 -0
- {monoco_toolkit-0.2.5.dist-info → monoco_toolkit-0.2.8.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
from monoco.core.config import IssueSchemaConfig, IssueTypeConfig, TransitionConfig, StateMachineConfig
|
|
2
|
+
|
|
3
|
+
DEFAULT_ISSUE_CONFIG = IssueSchemaConfig(
|
|
4
|
+
types=[
|
|
5
|
+
IssueTypeConfig(name="epic", label="Epic", prefix="EPIC", folder="Epics"),
|
|
6
|
+
IssueTypeConfig(name="feature", label="Feature", prefix="FEAT", folder="Features"),
|
|
7
|
+
IssueTypeConfig(name="chore", label="Chore", prefix="CHORE", folder="Chores"),
|
|
8
|
+
IssueTypeConfig(name="fix", label="Fix", prefix="FIX", folder="Fixes"),
|
|
9
|
+
],
|
|
10
|
+
statuses=["open", "closed", "backlog"],
|
|
11
|
+
stages=["draft", "doing", "review", "done", "freezed"],
|
|
12
|
+
solutions=["implemented", "cancelled", "wontfix", "duplicate"],
|
|
13
|
+
workflows=[
|
|
14
|
+
# --- UNIVERSAL AGENT ACTIONS ---
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# --- OPEN -> OPEN Transitions (Stage changes) ---
|
|
18
|
+
TransitionConfig(
|
|
19
|
+
name="start",
|
|
20
|
+
label="Start",
|
|
21
|
+
icon="$(play)",
|
|
22
|
+
from_status="open",
|
|
23
|
+
from_stage="draft",
|
|
24
|
+
to_status="open",
|
|
25
|
+
to_stage="doing",
|
|
26
|
+
command_template="monoco issue start {id}",
|
|
27
|
+
description="Start working on the issue"
|
|
28
|
+
),
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
TransitionConfig(
|
|
33
|
+
name="stop",
|
|
34
|
+
label="Stop",
|
|
35
|
+
icon="$(stop)",
|
|
36
|
+
from_status="open",
|
|
37
|
+
from_stage="doing",
|
|
38
|
+
to_status="open",
|
|
39
|
+
to_stage="draft",
|
|
40
|
+
command_template="monoco issue stop {id}",
|
|
41
|
+
description="Stop working and return to draft"
|
|
42
|
+
),
|
|
43
|
+
TransitionConfig(
|
|
44
|
+
name="submit",
|
|
45
|
+
label="Submit",
|
|
46
|
+
icon="$(check)",
|
|
47
|
+
from_status="open",
|
|
48
|
+
from_stage="doing",
|
|
49
|
+
to_status="open",
|
|
50
|
+
to_stage="review",
|
|
51
|
+
command_template="monoco issue submit {id}",
|
|
52
|
+
description="Submit for review"
|
|
53
|
+
),
|
|
54
|
+
TransitionConfig(
|
|
55
|
+
name="reject",
|
|
56
|
+
label="Reject",
|
|
57
|
+
icon="$(error)",
|
|
58
|
+
from_status="open",
|
|
59
|
+
from_stage="review",
|
|
60
|
+
to_status="open",
|
|
61
|
+
to_stage="doing",
|
|
62
|
+
command_template="monoco issue update {id} --stage doing",
|
|
63
|
+
description="Reject review and return to doing"
|
|
64
|
+
),
|
|
65
|
+
|
|
66
|
+
# --- OPEN -> CLOSED Transitions ---
|
|
67
|
+
TransitionConfig(
|
|
68
|
+
name="accept",
|
|
69
|
+
label="Accept",
|
|
70
|
+
icon="$(pass-filled)",
|
|
71
|
+
from_status="open",
|
|
72
|
+
from_stage="review",
|
|
73
|
+
to_status="closed",
|
|
74
|
+
to_stage="done",
|
|
75
|
+
required_solution="implemented",
|
|
76
|
+
command_template="monoco issue close {id} --solution implemented",
|
|
77
|
+
description="Accept and close issue"
|
|
78
|
+
),
|
|
79
|
+
TransitionConfig(
|
|
80
|
+
name="close_done",
|
|
81
|
+
label="Close",
|
|
82
|
+
icon="$(close)",
|
|
83
|
+
from_status="open",
|
|
84
|
+
from_stage="done",
|
|
85
|
+
to_status="closed",
|
|
86
|
+
to_stage="done",
|
|
87
|
+
required_solution="implemented",
|
|
88
|
+
command_template="monoco issue close {id} --solution implemented",
|
|
89
|
+
description="Close completed issue"
|
|
90
|
+
),
|
|
91
|
+
TransitionConfig(
|
|
92
|
+
name="cancel",
|
|
93
|
+
label="Cancel",
|
|
94
|
+
icon="$(trash)",
|
|
95
|
+
from_status="open",
|
|
96
|
+
# Allowed from any stage except DONE (though core.py had a check for it)
|
|
97
|
+
to_status="closed",
|
|
98
|
+
to_stage="done",
|
|
99
|
+
required_solution="cancelled",
|
|
100
|
+
command_template="monoco issue cancel {id}",
|
|
101
|
+
description="Cancel the issue"
|
|
102
|
+
),
|
|
103
|
+
TransitionConfig(
|
|
104
|
+
name="wontfix",
|
|
105
|
+
label="Won't Fix",
|
|
106
|
+
icon="$(circle-slash)",
|
|
107
|
+
from_status="open",
|
|
108
|
+
to_status="closed",
|
|
109
|
+
to_stage="done",
|
|
110
|
+
required_solution="wontfix",
|
|
111
|
+
command_template="monoco issue close {id} --solution wontfix",
|
|
112
|
+
description="Mark as won't fix"
|
|
113
|
+
),
|
|
114
|
+
|
|
115
|
+
# --- BACKLOG Transitions ---
|
|
116
|
+
TransitionConfig(
|
|
117
|
+
name="push",
|
|
118
|
+
label="Push to Backlog",
|
|
119
|
+
icon="$(archive)",
|
|
120
|
+
from_status="open",
|
|
121
|
+
to_status="backlog",
|
|
122
|
+
to_stage="freezed",
|
|
123
|
+
command_template="monoco issue backlog push {id}",
|
|
124
|
+
description="Move issue to backlog"
|
|
125
|
+
),
|
|
126
|
+
|
|
127
|
+
TransitionConfig(
|
|
128
|
+
name="pull",
|
|
129
|
+
label="Pull",
|
|
130
|
+
icon="$(arrow-up)",
|
|
131
|
+
from_status="backlog",
|
|
132
|
+
to_status="open",
|
|
133
|
+
to_stage="draft",
|
|
134
|
+
command_template="monoco issue backlog pull {id}",
|
|
135
|
+
description="Restore issue from backlog"
|
|
136
|
+
),
|
|
137
|
+
TransitionConfig(
|
|
138
|
+
name="cancel_backlog",
|
|
139
|
+
label="Cancel",
|
|
140
|
+
icon="$(trash)",
|
|
141
|
+
from_status="backlog",
|
|
142
|
+
to_status="closed",
|
|
143
|
+
to_stage="done",
|
|
144
|
+
required_solution="cancelled",
|
|
145
|
+
command_template="monoco issue cancel {id}",
|
|
146
|
+
description="Cancel backlog issue"
|
|
147
|
+
),
|
|
148
|
+
|
|
149
|
+
# --- CLOSED Transitions ---
|
|
150
|
+
TransitionConfig(
|
|
151
|
+
name="reopen",
|
|
152
|
+
label="Reopen",
|
|
153
|
+
icon="$(refresh)",
|
|
154
|
+
from_status="closed",
|
|
155
|
+
to_status="open",
|
|
156
|
+
to_stage="draft",
|
|
157
|
+
command_template="monoco issue open {id}",
|
|
158
|
+
description="Reopen a closed issue"
|
|
159
|
+
),
|
|
160
|
+
TransitionConfig(
|
|
161
|
+
name="reopen_from_done",
|
|
162
|
+
label="Reopen",
|
|
163
|
+
icon="$(refresh)",
|
|
164
|
+
from_status="open",
|
|
165
|
+
from_stage="done",
|
|
166
|
+
to_status="open",
|
|
167
|
+
to_stage="draft",
|
|
168
|
+
command_template="monoco issue open {id}",
|
|
169
|
+
description="Reopen a done issue"
|
|
170
|
+
),
|
|
171
|
+
]
|
|
172
|
+
)
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
from typing import List, Optional, Dict
|
|
2
|
+
from monoco.core.config import IssueSchemaConfig, TransitionConfig
|
|
3
|
+
from ..models import IssueStatus, IssueStage, IssueMetadata, IssueSolution, IssueType
|
|
4
|
+
|
|
5
|
+
class StateMachine:
|
|
6
|
+
def __init__(self, config: IssueSchemaConfig):
|
|
7
|
+
self.issue_config = config
|
|
8
|
+
self.transitions = config.workflows or []
|
|
9
|
+
|
|
10
|
+
def get_type_config(self, type_name: str):
|
|
11
|
+
if not self.issue_config.types:
|
|
12
|
+
return None
|
|
13
|
+
for t in self.issue_config.types:
|
|
14
|
+
if t.name == type_name:
|
|
15
|
+
return t
|
|
16
|
+
return None
|
|
17
|
+
|
|
18
|
+
def get_prefix_map(self) -> Dict[str, str]:
|
|
19
|
+
if not self.issue_config.types:
|
|
20
|
+
return {}
|
|
21
|
+
return {t.name: t.prefix for t in self.issue_config.types}
|
|
22
|
+
|
|
23
|
+
def get_folder_map(self) -> Dict[str, str]:
|
|
24
|
+
if not self.issue_config.types:
|
|
25
|
+
return {}
|
|
26
|
+
return {t.name: t.folder for t in self.issue_config.types}
|
|
27
|
+
|
|
28
|
+
def get_all_types(self) -> List[str]:
|
|
29
|
+
if not self.issue_config.types:
|
|
30
|
+
return []
|
|
31
|
+
return [t.name for t in self.issue_config.types]
|
|
32
|
+
|
|
33
|
+
def can_transition(self, current_status: str, current_stage: Optional[str],
|
|
34
|
+
target_status: str, target_stage: Optional[str]) -> bool:
|
|
35
|
+
"""Check if a transition path exists."""
|
|
36
|
+
for t in self.transitions:
|
|
37
|
+
if t.from_status and t.from_status != current_status:
|
|
38
|
+
continue
|
|
39
|
+
if t.from_stage and t.from_stage != current_stage:
|
|
40
|
+
continue
|
|
41
|
+
|
|
42
|
+
if t.to_status == target_status:
|
|
43
|
+
if target_stage is None or t.to_stage == target_stage:
|
|
44
|
+
return True
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
def get_available_transitions(self, meta: IssueMetadata) -> List[TransitionConfig]:
|
|
48
|
+
"""Get all transitions allowed from the current state of the issue."""
|
|
49
|
+
allowed = []
|
|
50
|
+
for t in self.transitions:
|
|
51
|
+
# Universal actions (no from_status/stage) are always allowed
|
|
52
|
+
if t.from_status is None and t.from_stage is None:
|
|
53
|
+
allowed.append(t)
|
|
54
|
+
continue
|
|
55
|
+
|
|
56
|
+
# Match status
|
|
57
|
+
if t.from_status and t.from_status != meta.status:
|
|
58
|
+
continue
|
|
59
|
+
|
|
60
|
+
# Match stage
|
|
61
|
+
if t.from_stage and t.from_stage != meta.stage:
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
# Special case for 'Cancel': don't show if already DONE or CLOSED
|
|
65
|
+
if t.name == "cancel" and meta.stage == "done":
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
allowed.append(t)
|
|
69
|
+
return allowed
|
|
70
|
+
|
|
71
|
+
def find_transition(self, from_status: str, from_stage: Optional[str],
|
|
72
|
+
to_status: str, to_stage: Optional[str],
|
|
73
|
+
solution: Optional[str] = None) -> Optional[TransitionConfig]:
|
|
74
|
+
"""Find a specific transition rule."""
|
|
75
|
+
candidates = []
|
|
76
|
+
for t in self.transitions:
|
|
77
|
+
# Skip non-transitions (agent actions with same status/stage)
|
|
78
|
+
if t.from_status is None and t.from_stage is None:
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
if t.from_status and t.from_status != from_status:
|
|
82
|
+
continue
|
|
83
|
+
if t.from_stage and t.from_stage != from_stage:
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
# Check if this transition matches the target
|
|
87
|
+
if t.to_status == to_status:
|
|
88
|
+
if to_stage is None or t.to_stage == to_stage:
|
|
89
|
+
candidates.append(t)
|
|
90
|
+
|
|
91
|
+
if not candidates:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
# If we have a solution, find the transition that requires it
|
|
95
|
+
if solution:
|
|
96
|
+
for t in candidates:
|
|
97
|
+
if t.required_solution == solution:
|
|
98
|
+
return t
|
|
99
|
+
# If solution provided but none of the transitions match it,
|
|
100
|
+
# we should return None (unless there is a transition with NO required_solution)
|
|
101
|
+
for t in candidates:
|
|
102
|
+
if t.required_solution is None:
|
|
103
|
+
return t
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
# Otherwise return the first one that has NO required_solution
|
|
107
|
+
for t in candidates:
|
|
108
|
+
if t.required_solution is None:
|
|
109
|
+
return t
|
|
110
|
+
|
|
111
|
+
return candidates[0]
|
|
112
|
+
|
|
113
|
+
def validate_transition(self, from_status: str, from_stage: Optional[str],
|
|
114
|
+
to_status: str, to_stage: Optional[str],
|
|
115
|
+
solution: Optional[str] = None) -> None:
|
|
116
|
+
"""
|
|
117
|
+
Validate if a transition is allowed. Raises ValueError if not.
|
|
118
|
+
"""
|
|
119
|
+
if from_status == to_status and from_stage == to_stage:
|
|
120
|
+
return # No change is always allowed (unless we want to enforce specific updates)
|
|
121
|
+
|
|
122
|
+
transition = self.find_transition(from_status, from_stage, to_status, to_stage, solution)
|
|
123
|
+
|
|
124
|
+
if not transition:
|
|
125
|
+
raise ValueError(f"Lifecycle Policy: Transition from {from_status}({from_stage if from_stage else 'None'}) "
|
|
126
|
+
f"to {to_status}({to_stage if to_stage else 'None'}) is not defined.")
|
|
127
|
+
|
|
128
|
+
if transition.required_solution and solution != transition.required_solution:
|
|
129
|
+
raise ValueError(f"Lifecycle Policy: Transition '{transition.label}' requires solution '{transition.required_solution}'.")
|
|
130
|
+
|
|
131
|
+
def enforce_policy(self, meta: IssueMetadata) -> None:
|
|
132
|
+
"""
|
|
133
|
+
Apply consistency rules to IssueMetadata.
|
|
134
|
+
"""
|
|
135
|
+
from ..models import current_time
|
|
136
|
+
|
|
137
|
+
if meta.status == "backlog":
|
|
138
|
+
meta.stage = "freezed"
|
|
139
|
+
|
|
140
|
+
elif meta.status == "closed":
|
|
141
|
+
if meta.stage != "done":
|
|
142
|
+
meta.stage = "done"
|
|
143
|
+
if not meta.closed_at:
|
|
144
|
+
meta.closed_at = current_time()
|
|
145
|
+
|
|
146
|
+
elif meta.status == "open":
|
|
147
|
+
if meta.stage is None:
|
|
148
|
+
meta.stage = "draft"
|
|
149
|
+
|
|
150
|
+
def validate_transition(self, from_status: str, from_stage: Optional[str],
|
|
151
|
+
to_status: str, to_stage: Optional[str],
|
|
152
|
+
solution: Optional[str] = None) -> None:
|
|
153
|
+
"""
|
|
154
|
+
Validate if a transition is allowed. Raises ValueError if not.
|
|
155
|
+
"""
|
|
156
|
+
if from_status == to_status and from_stage == to_stage:
|
|
157
|
+
return # No change is always allowed (unless we want to enforce specific updates)
|
|
158
|
+
|
|
159
|
+
transition = self.find_transition(from_status, from_stage, to_status, to_stage, solution)
|
|
160
|
+
|
|
161
|
+
if not transition:
|
|
162
|
+
raise ValueError(f"Lifecycle Policy: Transition from {from_status}({from_stage if from_stage else 'None'}) "
|
|
163
|
+
f"to {to_status}({to_stage if to_stage else 'None'}) is not defined.")
|
|
164
|
+
|
|
165
|
+
if transition.required_solution and solution != transition.required_solution:
|
|
166
|
+
raise ValueError(f"Lifecycle Policy: Transition '{transition.label}' requires solution '{transition.required_solution}'.")
|
|
167
|
+
|
|
168
|
+
def enforce_policy(self, meta: IssueMetadata) -> None:
|
|
169
|
+
"""
|
|
170
|
+
Apply consistency rules to IssueMetadata.
|
|
171
|
+
"""
|
|
172
|
+
from ..models import current_time
|
|
173
|
+
|
|
174
|
+
if meta.status == "backlog":
|
|
175
|
+
meta.stage = "freezed"
|
|
176
|
+
|
|
177
|
+
elif meta.status == "closed":
|
|
178
|
+
if meta.stage != "done":
|
|
179
|
+
meta.stage = "done"
|
|
180
|
+
if not meta.closed_at:
|
|
181
|
+
meta.closed_at = current_time()
|
|
182
|
+
|
|
183
|
+
elif meta.status == "open":
|
|
184
|
+
if meta.stage is None:
|
|
185
|
+
meta.stage = "draft"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from typing import List, Optional, Any
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
|
|
4
|
+
class Transition(BaseModel):
|
|
5
|
+
name: str
|
|
6
|
+
label: str
|
|
7
|
+
icon: Optional[str] = None
|
|
8
|
+
from_status: Optional[str] = None # None means any
|
|
9
|
+
from_stage: Optional[str] = None # None means any
|
|
10
|
+
to_status: str
|
|
11
|
+
to_stage: Optional[str] = None
|
|
12
|
+
required_solution: Optional[str] = None
|
|
13
|
+
description: str = ""
|
|
14
|
+
command_template: Optional[str] = None
|
|
15
|
+
|
|
16
|
+
class StateMachineConfig(BaseModel):
|
|
17
|
+
transitions: List[Transition]
|
|
18
|
+
# We can add more config like default stages for statuses etc.
|
monoco/features/issue/linter.py
CHANGED
|
@@ -4,13 +4,40 @@ from rich.console import Console
|
|
|
4
4
|
from rich.table import Table
|
|
5
5
|
import typer
|
|
6
6
|
import re
|
|
7
|
-
|
|
7
|
+
from monoco.core import git
|
|
8
8
|
from . import core
|
|
9
9
|
from .validator import IssueValidator
|
|
10
10
|
from monoco.core.lsp import Diagnostic, DiagnosticSeverity
|
|
11
11
|
|
|
12
12
|
console = Console()
|
|
13
13
|
|
|
14
|
+
def check_environment_policy(project_root: Path):
|
|
15
|
+
"""
|
|
16
|
+
Guardrail: Prevent direct modifications on protected branches (main/master).
|
|
17
|
+
"""
|
|
18
|
+
# Only enforce if it is a git repo
|
|
19
|
+
try:
|
|
20
|
+
if not git.is_git_repo(project_root):
|
|
21
|
+
return
|
|
22
|
+
|
|
23
|
+
current_branch = git.get_current_branch(project_root)
|
|
24
|
+
# Standard protected branches
|
|
25
|
+
if current_branch in ["main", "master", "production"]:
|
|
26
|
+
# Check if dirty (uncommitted changes)
|
|
27
|
+
changed_files = git.get_git_status(project_root)
|
|
28
|
+
if changed_files:
|
|
29
|
+
console.print(f"\n[bold red]🛑 Environment Policy Violation[/bold red]")
|
|
30
|
+
console.print(f"You are modifying code directly on protected branch: [bold cyan]{current_branch}[/bold cyan]")
|
|
31
|
+
console.print(f"Found {len(changed_files)} uncommitted changes.")
|
|
32
|
+
console.print(f"[yellow]Action Required:[/yellow] Please stash your changes and switch to a feature branch.")
|
|
33
|
+
console.print(f" > git stash")
|
|
34
|
+
console.print(f" > monoco issue start <ID> --branch")
|
|
35
|
+
console.print(f" > git stash pop")
|
|
36
|
+
raise typer.Exit(code=1)
|
|
37
|
+
except Exception:
|
|
38
|
+
# Fail safe: Do not block linting if git check fails unexpectedly
|
|
39
|
+
pass
|
|
40
|
+
|
|
14
41
|
def check_integrity(issues_root: Path, recursive: bool = False) -> List[Diagnostic]:
|
|
15
42
|
"""
|
|
16
43
|
Verify the integrity of the Issues directory using LSP Validator.
|
|
@@ -38,27 +65,48 @@ def check_integrity(issues_root: Path, recursive: bool = False) -> List[Diagnost
|
|
|
38
65
|
meta = core.parse_issue(f)
|
|
39
66
|
if meta:
|
|
40
67
|
local_id = meta.id
|
|
41
|
-
full_id = f"{project_name}::{local_id}"
|
|
68
|
+
full_id = f"{project_name}::{local_id}"
|
|
42
69
|
|
|
43
70
|
all_issue_ids.add(local_id)
|
|
44
|
-
|
|
45
|
-
all_issue_ids.add(full_id)
|
|
71
|
+
all_issue_ids.add(full_id)
|
|
46
72
|
|
|
47
73
|
project_issues.append((f, meta))
|
|
48
74
|
return project_issues
|
|
49
75
|
|
|
50
|
-
|
|
76
|
+
from monoco.core.config import get_config
|
|
77
|
+
conf = get_config(str(issues_root.parent))
|
|
78
|
+
|
|
79
|
+
# Identify local project name
|
|
80
|
+
local_project_name = "local"
|
|
81
|
+
if conf and conf.project and conf.project.name:
|
|
82
|
+
local_project_name = conf.project.name.lower()
|
|
83
|
+
|
|
84
|
+
# Find Topmost Workspace Root
|
|
85
|
+
workspace_root = issues_root.parent
|
|
86
|
+
for parent in [workspace_root] + list(workspace_root.parents):
|
|
87
|
+
if (parent / ".monoco" / "workspace.yaml").exists() or (parent / ".monoco" / "project.yaml").exists():
|
|
88
|
+
workspace_root = parent
|
|
89
|
+
|
|
90
|
+
# Collect from local issues_root
|
|
91
|
+
all_issues.extend(collect_project_issues(issues_root, local_project_name))
|
|
51
92
|
|
|
52
93
|
if recursive:
|
|
53
94
|
try:
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
95
|
+
# Re-read config from workspace root to get all members
|
|
96
|
+
ws_conf = get_config(str(workspace_root))
|
|
97
|
+
|
|
98
|
+
# Index Root project if different from current
|
|
99
|
+
if workspace_root != issues_root.parent:
|
|
100
|
+
root_issues_dir = workspace_root / "Issues"
|
|
101
|
+
if root_issues_dir.exists():
|
|
102
|
+
all_issues.extend(collect_project_issues(root_issues_dir, ws_conf.project.name.lower()))
|
|
103
|
+
|
|
104
|
+
# Index all members
|
|
105
|
+
for member_name, rel_path in ws_conf.project.members.items():
|
|
106
|
+
member_root = (workspace_root / rel_path).resolve()
|
|
59
107
|
member_issues_dir = member_root / "Issues"
|
|
60
|
-
if member_issues_dir.exists():
|
|
61
|
-
collect_project_issues(member_issues_dir, member_name)
|
|
108
|
+
if member_issues_dir.exists() and member_issues_dir != issues_root:
|
|
109
|
+
all_issues.extend(collect_project_issues(member_issues_dir, member_name.lower()))
|
|
62
110
|
except Exception:
|
|
63
111
|
pass
|
|
64
112
|
|
|
@@ -89,6 +137,10 @@ def run_lint(issues_root: Path, recursive: bool = False, fix: bool = False, form
|
|
|
89
137
|
format: Output format (table, json)
|
|
90
138
|
file_path: Optional path to a single file to validate (LSP mode)
|
|
91
139
|
"""
|
|
140
|
+
# 0. Environment Policy Check (Guardrail)
|
|
141
|
+
# We assume issues_root.parent is the project root or close enough for git context
|
|
142
|
+
check_environment_policy(issues_root.parent)
|
|
143
|
+
|
|
92
144
|
# Single-file mode (for LSP integration)
|
|
93
145
|
if file_path:
|
|
94
146
|
file = Path(file_path).resolve()
|
|
@@ -210,6 +262,56 @@ def run_lint(issues_root: Path, recursive: bool = False, fix: bool = False, form
|
|
|
210
262
|
new_content = new_content.rstrip() + "\n\n## Review Comments\n\n- [ ] Self-Review\n"
|
|
211
263
|
has_changes = True
|
|
212
264
|
|
|
265
|
+
if "Malformed ID" in d.message:
|
|
266
|
+
lines = new_content.splitlines()
|
|
267
|
+
if d.range and d.range.start.line < len(lines):
|
|
268
|
+
line_idx = d.range.start.line
|
|
269
|
+
line = lines[line_idx]
|
|
270
|
+
# Remove # from quoted strings or raw values
|
|
271
|
+
new_line = line.replace("'#", "'").replace('"#', '"')
|
|
272
|
+
if new_line != line:
|
|
273
|
+
lines[line_idx] = new_line
|
|
274
|
+
new_content = "\n".join(lines) + "\n"
|
|
275
|
+
has_changes = True
|
|
276
|
+
|
|
277
|
+
if "Tag Check: Missing required context tags" in d.message:
|
|
278
|
+
# Extract missing tags from message
|
|
279
|
+
# Message format: "Tag Check: Missing required context tags: #TAG1, #TAG2"
|
|
280
|
+
try:
|
|
281
|
+
parts = d.message.split(": ")
|
|
282
|
+
if len(parts) >= 3:
|
|
283
|
+
tags_str = parts[-1]
|
|
284
|
+
missing_tags = [t.strip() for t in tags_str.split(",")]
|
|
285
|
+
|
|
286
|
+
# We need to update content via core.update_issue logic effectively
|
|
287
|
+
# But we are in a loop potentially with other string edits.
|
|
288
|
+
# IMPORTANT: Mixed strategy (Regex vs Object Update) is risky.
|
|
289
|
+
# However, tags are in YAML frontmatter.
|
|
290
|
+
# Since we might have modified new_content already (string), using core.update_issue on file is dangerous (race condition with memory).
|
|
291
|
+
# Better to append to tags list in YAML via regex or yaml parser on new_content.
|
|
292
|
+
|
|
293
|
+
# Parsing Frontmatter from new_content
|
|
294
|
+
fm_match = re.search(r"^---(.*?)---", new_content, re.DOTALL | re.MULTILINE)
|
|
295
|
+
if fm_match:
|
|
296
|
+
import yaml
|
|
297
|
+
fm_text = fm_match.group(1)
|
|
298
|
+
data = yaml.safe_load(fm_text) or {}
|
|
299
|
+
current_tags = data.get('tags', [])
|
|
300
|
+
if not isinstance(current_tags, list): current_tags = []
|
|
301
|
+
|
|
302
|
+
# Add missing
|
|
303
|
+
updated_tags = sorted(list(set(current_tags) | set(missing_tags)))
|
|
304
|
+
data['tags'] = updated_tags
|
|
305
|
+
|
|
306
|
+
# Dump back
|
|
307
|
+
new_fm_text = yaml.dump(data, sort_keys=False, allow_unicode=True)
|
|
308
|
+
|
|
309
|
+
# Replace FM block
|
|
310
|
+
new_content = new_content.replace(fm_match.group(1), "\n" + new_fm_text)
|
|
311
|
+
has_changes = True
|
|
312
|
+
except Exception as ex:
|
|
313
|
+
console.print(f"[red]Failed to fix tags: {ex}[/red]")
|
|
314
|
+
|
|
213
315
|
if has_changes:
|
|
214
316
|
path.write_text(new_content)
|
|
215
317
|
fixed_count += 1
|
|
@@ -267,5 +369,9 @@ def run_lint(issues_root: Path, recursive: bool = False, fix: bool = False, form
|
|
|
267
369
|
console.print(table)
|
|
268
370
|
|
|
269
371
|
if any(d.severity == DiagnosticSeverity.Error for d in issues):
|
|
372
|
+
console.print("\n[yellow]Tip: Run 'monoco issue lint --fix' to attempt automatic repairs.[/yellow]")
|
|
270
373
|
raise typer.Exit(code=1)
|
|
374
|
+
|
|
375
|
+
if issues:
|
|
376
|
+
console.print("\n[yellow]Tip: Run 'monoco issue lint --fix' to attempt automatic repairs.[/yellow]")
|
|
271
377
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Optional, List
|
|
3
|
+
from monoco.core.lsp import Location, Position, Range
|
|
4
|
+
from ..domain.parser import MarkdownParser
|
|
5
|
+
from ..domain.workspace import WorkspaceSymbolIndex, IssueLocation
|
|
6
|
+
|
|
7
|
+
class DefinitionProvider:
|
|
8
|
+
def __init__(self, workspace_root: Path):
|
|
9
|
+
self.workspace_root = workspace_root
|
|
10
|
+
self.index = WorkspaceSymbolIndex(workspace_root)
|
|
11
|
+
# Lazy indexing handled by the index class itself
|
|
12
|
+
|
|
13
|
+
def provide_definition(self, file_path: Path, position: Position) -> List[Location]:
|
|
14
|
+
"""
|
|
15
|
+
Resolve definition at the given position in the file.
|
|
16
|
+
"""
|
|
17
|
+
if not file_path.exists():
|
|
18
|
+
return []
|
|
19
|
+
|
|
20
|
+
content = file_path.read_text()
|
|
21
|
+
|
|
22
|
+
# 1. Parse the document to find spans
|
|
23
|
+
# We only need to find the span at the specific line
|
|
24
|
+
issue = MarkdownParser.parse(content, path=str(file_path))
|
|
25
|
+
|
|
26
|
+
target_span = None
|
|
27
|
+
for block in issue.body.blocks:
|
|
28
|
+
# Check if position is within block
|
|
29
|
+
# Note: block.line_start is inclusive, line_end is exclusive for content
|
|
30
|
+
if block.line_start <= position.line < block.line_end:
|
|
31
|
+
for span in block.spans:
|
|
32
|
+
if span.range.start.line == position.line:
|
|
33
|
+
# Check character range
|
|
34
|
+
if span.range.start.character <= position.character <= span.range.end.character:
|
|
35
|
+
target_span = span
|
|
36
|
+
break
|
|
37
|
+
if target_span:
|
|
38
|
+
break
|
|
39
|
+
|
|
40
|
+
if not target_span:
|
|
41
|
+
return []
|
|
42
|
+
|
|
43
|
+
# 2. Resolve based on span type
|
|
44
|
+
if target_span.type in ["wikilink", "issue_id"]:
|
|
45
|
+
issue_id = target_span.metadata.get("issue_id")
|
|
46
|
+
if issue_id:
|
|
47
|
+
# Resolve using Workspace Index
|
|
48
|
+
location = self.index.resolve(issue_id, context_project=self._get_context_project(file_path))
|
|
49
|
+
if location:
|
|
50
|
+
return [
|
|
51
|
+
Location(
|
|
52
|
+
uri=f"file://{location.file_path}",
|
|
53
|
+
range=Range(
|
|
54
|
+
start=Position(line=0, character=0),
|
|
55
|
+
end=Position(line=0, character=0)
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
return []
|
|
61
|
+
|
|
62
|
+
def _get_context_project(self, file_path: Path) -> Optional[str]:
|
|
63
|
+
# Simple heuristic: look for parent directory name if it's a known project structure?
|
|
64
|
+
# Or rely on configuration.
|
|
65
|
+
# For now, let's assume the index handles context if passed, or we pass None.
|
|
66
|
+
# Actually resolving context project from file path is tricky without config loaded for that specific root.
|
|
67
|
+
# Let's try to deduce from path relative to workspace root.
|
|
68
|
+
try:
|
|
69
|
+
rel = file_path.relative_to(self.workspace_root)
|
|
70
|
+
return rel.parts[0] # First dir is likely project name in a workspace
|
|
71
|
+
except ValueError:
|
|
72
|
+
return "local"
|