monoco-toolkit 0.2.8__py3-none-any.whl → 0.3.1__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 +35 -31
- monoco/cli/workspace.py +26 -16
- monoco/core/agent/__init__.py +0 -2
- monoco/core/agent/action.py +44 -20
- monoco/core/agent/adapters.py +20 -16
- monoco/core/agent/protocol.py +5 -4
- monoco/core/agent/state.py +21 -21
- monoco/core/config.py +90 -33
- monoco/core/execution.py +21 -16
- monoco/core/feature.py +8 -5
- monoco/core/git.py +61 -30
- monoco/core/hooks.py +57 -0
- monoco/core/injection.py +47 -44
- monoco/core/integrations.py +50 -35
- monoco/core/lsp.py +12 -1
- monoco/core/output.py +35 -16
- monoco/core/registry.py +3 -2
- monoco/core/setup.py +190 -124
- monoco/core/skills.py +121 -107
- monoco/core/state.py +12 -10
- monoco/core/sync.py +85 -56
- monoco/core/telemetry.py +10 -6
- monoco/core/workspace.py +26 -19
- monoco/daemon/app.py +123 -79
- monoco/daemon/commands.py +14 -13
- monoco/daemon/models.py +11 -3
- monoco/daemon/reproduce_stats.py +8 -8
- monoco/daemon/services.py +32 -33
- monoco/daemon/stats.py +59 -40
- monoco/features/config/commands.py +38 -25
- monoco/features/i18n/adapter.py +4 -5
- monoco/features/i18n/commands.py +83 -49
- monoco/features/i18n/core.py +94 -54
- monoco/features/issue/adapter.py +6 -7
- monoco/features/issue/commands.py +468 -272
- monoco/features/issue/core.py +419 -312
- monoco/features/issue/domain/lifecycle.py +33 -23
- monoco/features/issue/domain/models.py +71 -38
- monoco/features/issue/domain/parser.py +92 -69
- monoco/features/issue/domain/workspace.py +19 -16
- monoco/features/issue/engine/__init__.py +3 -3
- monoco/features/issue/engine/config.py +18 -25
- monoco/features/issue/engine/machine.py +72 -39
- monoco/features/issue/engine/models.py +4 -2
- monoco/features/issue/linter.py +287 -157
- monoco/features/issue/lsp/definition.py +26 -19
- monoco/features/issue/migration.py +45 -34
- monoco/features/issue/models.py +29 -13
- monoco/features/issue/monitor.py +24 -8
- monoco/features/issue/resources/en/SKILL.md +6 -2
- monoco/features/issue/validator.py +395 -208
- monoco/features/skills/__init__.py +0 -1
- monoco/features/skills/core.py +24 -18
- monoco/features/spike/adapter.py +4 -5
- monoco/features/spike/commands.py +51 -38
- monoco/features/spike/core.py +24 -16
- monoco/main.py +34 -21
- {monoco_toolkit-0.2.8.dist-info → monoco_toolkit-0.3.1.dist-info}/METADATA +1 -1
- monoco_toolkit-0.3.1.dist-info/RECORD +84 -0
- monoco_toolkit-0.2.8.dist-info/RECORD +0 -83
- {monoco_toolkit-0.2.8.dist-info → monoco_toolkit-0.3.1.dist-info}/WHEEL +0 -0
- {monoco_toolkit-0.2.8.dist-info → monoco_toolkit-0.3.1.dist-info}/entry_points.txt +0 -0
- {monoco_toolkit-0.2.8.dist-info → monoco_toolkit-0.3.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,27 +1,28 @@
|
|
|
1
1
|
import re
|
|
2
2
|
from pathlib import Path
|
|
3
|
-
from typing import Dict,
|
|
3
|
+
from typing import Dict, Optional
|
|
4
4
|
from pydantic import BaseModel
|
|
5
|
-
from .
|
|
6
|
-
|
|
7
|
-
from monoco.core.config import get_config, MonocoConfig
|
|
8
|
-
from monoco.core.lsp import Location, Range, Position
|
|
5
|
+
from monoco.core.config import get_config
|
|
6
|
+
|
|
9
7
|
|
|
10
8
|
class IssueLocation(BaseModel):
|
|
11
9
|
project_id: str
|
|
12
10
|
file_path: str
|
|
13
11
|
issue_id: str
|
|
14
12
|
|
|
13
|
+
|
|
15
14
|
class WorkspaceSymbolIndex:
|
|
16
15
|
"""
|
|
17
16
|
Maintains a global index of all issues in the Monoco Workspace.
|
|
18
17
|
Allows resolving Issue IDs (local or namespaced) to file locations.
|
|
19
18
|
"""
|
|
20
|
-
|
|
19
|
+
|
|
21
20
|
def __init__(self, root_path: Path):
|
|
22
21
|
self.root_path = root_path
|
|
23
22
|
self.index: Dict[str, IssueLocation] = {} # Map<FullID, Location>
|
|
24
|
-
self.local_map: Dict[
|
|
23
|
+
self.local_map: Dict[
|
|
24
|
+
str, str
|
|
25
|
+
] = {} # Map<LocalID, FullID> for current context project
|
|
25
26
|
self._is_indexed = False
|
|
26
27
|
|
|
27
28
|
def build_index(self, recursive: bool = True):
|
|
@@ -29,15 +30,15 @@ class WorkspaceSymbolIndex:
|
|
|
29
30
|
Scans the workspace and subprojects to build the index.
|
|
30
31
|
"""
|
|
31
32
|
self.index.clear()
|
|
32
|
-
|
|
33
|
+
|
|
33
34
|
# 1. Index local project
|
|
34
35
|
project_name = "local"
|
|
35
36
|
conf = get_config(str(self.root_path))
|
|
36
37
|
if conf and conf.project and conf.project.name:
|
|
37
38
|
project_name = conf.project.name.lower()
|
|
38
|
-
|
|
39
|
+
|
|
39
40
|
self._index_project(self.root_path, project_name)
|
|
40
|
-
|
|
41
|
+
|
|
41
42
|
# 2. Index workspace members
|
|
42
43
|
if recursive:
|
|
43
44
|
try:
|
|
@@ -47,7 +48,7 @@ class WorkspaceSymbolIndex:
|
|
|
47
48
|
self._index_project(member_root, member_name.lower())
|
|
48
49
|
except Exception:
|
|
49
50
|
pass
|
|
50
|
-
|
|
51
|
+
|
|
51
52
|
self._is_indexed = True
|
|
52
53
|
|
|
53
54
|
def _index_project(self, project_root: Path, project_name: str):
|
|
@@ -69,12 +70,14 @@ class WorkspaceSymbolIndex:
|
|
|
69
70
|
loc = IssueLocation(
|
|
70
71
|
project_id=project_name,
|
|
71
72
|
file_path=str(f.absolute()),
|
|
72
|
-
issue_id=issue_id
|
|
73
|
+
issue_id=issue_id,
|
|
73
74
|
)
|
|
74
75
|
self.index[full_id] = loc
|
|
75
|
-
self.index[issue_id] = loc
|
|
76
|
+
self.index[issue_id] = loc # Alias for local lookup
|
|
76
77
|
|
|
77
|
-
def resolve(
|
|
78
|
+
def resolve(
|
|
79
|
+
self, issue_id: str, context_project: Optional[str] = None
|
|
80
|
+
) -> Optional[IssueLocation]:
|
|
78
81
|
"""
|
|
79
82
|
Resolves an issue ID to its location.
|
|
80
83
|
Supports 'Project::ID' and 'ID'.
|
|
@@ -94,11 +97,11 @@ class WorkspaceSymbolIndex:
|
|
|
94
97
|
# 1. Try exact match
|
|
95
98
|
if issue_id in self.index:
|
|
96
99
|
return self.index[issue_id]
|
|
97
|
-
|
|
100
|
+
|
|
98
101
|
# 2. Try contextual resolution if it's a local ID
|
|
99
102
|
if "::" not in issue_id and context_project:
|
|
100
103
|
full_id = f"{context_project}::{issue_id}"
|
|
101
104
|
if full_id in self.index:
|
|
102
105
|
return self.index[full_id]
|
|
103
|
-
|
|
106
|
+
|
|
104
107
|
return None
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
from typing import Optional
|
|
2
|
-
from .models import Transition
|
|
3
2
|
|
|
4
3
|
from .machine import StateMachine
|
|
5
4
|
from .config import DEFAULT_ISSUE_CONFIG
|
|
6
5
|
from monoco.core.config import get_config
|
|
7
6
|
|
|
7
|
+
|
|
8
8
|
def get_engine(project_root: Optional[str] = None) -> StateMachine:
|
|
9
9
|
# 1. Load Core Config (merges workspace & project yamls)
|
|
10
10
|
core_config = get_config(project_root)
|
|
11
|
-
|
|
11
|
+
|
|
12
12
|
# 2. Start with Defaults
|
|
13
13
|
# Use model_copy to avoid mutating the global default instance
|
|
14
14
|
final_config = DEFAULT_ISSUE_CONFIG.model_copy(deep=True)
|
|
15
|
-
|
|
15
|
+
|
|
16
16
|
# 3. Merge User Overrides
|
|
17
17
|
if core_config.issue:
|
|
18
18
|
# core_config.issue is already an IssueSchemaConfig (parse/validated by Pydantic)
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
from monoco.core.config import IssueSchemaConfig, IssueTypeConfig, TransitionConfig
|
|
1
|
+
from monoco.core.config import IssueSchemaConfig, IssueTypeConfig, TransitionConfig
|
|
2
2
|
|
|
3
3
|
DEFAULT_ISSUE_CONFIG = IssueSchemaConfig(
|
|
4
4
|
types=[
|
|
5
5
|
IssueTypeConfig(name="epic", label="Epic", prefix="EPIC", folder="Epics"),
|
|
6
|
-
IssueTypeConfig(
|
|
6
|
+
IssueTypeConfig(
|
|
7
|
+
name="feature", label="Feature", prefix="FEAT", folder="Features"
|
|
8
|
+
),
|
|
7
9
|
IssueTypeConfig(name="chore", label="Chore", prefix="CHORE", folder="Chores"),
|
|
8
10
|
IssueTypeConfig(name="fix", label="Fix", prefix="FIX", folder="Fixes"),
|
|
9
11
|
],
|
|
@@ -12,8 +14,6 @@ DEFAULT_ISSUE_CONFIG = IssueSchemaConfig(
|
|
|
12
14
|
solutions=["implemented", "cancelled", "wontfix", "duplicate"],
|
|
13
15
|
workflows=[
|
|
14
16
|
# --- UNIVERSAL AGENT ACTIONS ---
|
|
15
|
-
|
|
16
|
-
|
|
17
17
|
# --- OPEN -> OPEN Transitions (Stage changes) ---
|
|
18
18
|
TransitionConfig(
|
|
19
19
|
name="start",
|
|
@@ -24,11 +24,8 @@ DEFAULT_ISSUE_CONFIG = IssueSchemaConfig(
|
|
|
24
24
|
to_status="open",
|
|
25
25
|
to_stage="doing",
|
|
26
26
|
command_template="monoco issue start {id}",
|
|
27
|
-
description="Start working on the issue"
|
|
27
|
+
description="Start working on the issue",
|
|
28
28
|
),
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
29
|
TransitionConfig(
|
|
33
30
|
name="stop",
|
|
34
31
|
label="Stop",
|
|
@@ -38,7 +35,7 @@ DEFAULT_ISSUE_CONFIG = IssueSchemaConfig(
|
|
|
38
35
|
to_status="open",
|
|
39
36
|
to_stage="draft",
|
|
40
37
|
command_template="monoco issue stop {id}",
|
|
41
|
-
description="Stop working and return to draft"
|
|
38
|
+
description="Stop working and return to draft",
|
|
42
39
|
),
|
|
43
40
|
TransitionConfig(
|
|
44
41
|
name="submit",
|
|
@@ -49,7 +46,7 @@ DEFAULT_ISSUE_CONFIG = IssueSchemaConfig(
|
|
|
49
46
|
to_status="open",
|
|
50
47
|
to_stage="review",
|
|
51
48
|
command_template="monoco issue submit {id}",
|
|
52
|
-
description="Submit for review"
|
|
49
|
+
description="Submit for review",
|
|
53
50
|
),
|
|
54
51
|
TransitionConfig(
|
|
55
52
|
name="reject",
|
|
@@ -60,9 +57,8 @@ DEFAULT_ISSUE_CONFIG = IssueSchemaConfig(
|
|
|
60
57
|
to_status="open",
|
|
61
58
|
to_stage="doing",
|
|
62
59
|
command_template="monoco issue update {id} --stage doing",
|
|
63
|
-
description="Reject review and return to doing"
|
|
60
|
+
description="Reject review and return to doing",
|
|
64
61
|
),
|
|
65
|
-
|
|
66
62
|
# --- OPEN -> CLOSED Transitions ---
|
|
67
63
|
TransitionConfig(
|
|
68
64
|
name="accept",
|
|
@@ -74,7 +70,7 @@ DEFAULT_ISSUE_CONFIG = IssueSchemaConfig(
|
|
|
74
70
|
to_stage="done",
|
|
75
71
|
required_solution="implemented",
|
|
76
72
|
command_template="monoco issue close {id} --solution implemented",
|
|
77
|
-
description="Accept and close issue"
|
|
73
|
+
description="Accept and close issue",
|
|
78
74
|
),
|
|
79
75
|
TransitionConfig(
|
|
80
76
|
name="close_done",
|
|
@@ -86,7 +82,7 @@ DEFAULT_ISSUE_CONFIG = IssueSchemaConfig(
|
|
|
86
82
|
to_stage="done",
|
|
87
83
|
required_solution="implemented",
|
|
88
84
|
command_template="monoco issue close {id} --solution implemented",
|
|
89
|
-
description="Close completed issue"
|
|
85
|
+
description="Close completed issue",
|
|
90
86
|
),
|
|
91
87
|
TransitionConfig(
|
|
92
88
|
name="cancel",
|
|
@@ -98,7 +94,7 @@ DEFAULT_ISSUE_CONFIG = IssueSchemaConfig(
|
|
|
98
94
|
to_stage="done",
|
|
99
95
|
required_solution="cancelled",
|
|
100
96
|
command_template="monoco issue cancel {id}",
|
|
101
|
-
description="Cancel the issue"
|
|
97
|
+
description="Cancel the issue",
|
|
102
98
|
),
|
|
103
99
|
TransitionConfig(
|
|
104
100
|
name="wontfix",
|
|
@@ -109,9 +105,8 @@ DEFAULT_ISSUE_CONFIG = IssueSchemaConfig(
|
|
|
109
105
|
to_stage="done",
|
|
110
106
|
required_solution="wontfix",
|
|
111
107
|
command_template="monoco issue close {id} --solution wontfix",
|
|
112
|
-
description="Mark as won't fix"
|
|
108
|
+
description="Mark as won't fix",
|
|
113
109
|
),
|
|
114
|
-
|
|
115
110
|
# --- BACKLOG Transitions ---
|
|
116
111
|
TransitionConfig(
|
|
117
112
|
name="push",
|
|
@@ -121,9 +116,8 @@ DEFAULT_ISSUE_CONFIG = IssueSchemaConfig(
|
|
|
121
116
|
to_status="backlog",
|
|
122
117
|
to_stage="freezed",
|
|
123
118
|
command_template="monoco issue backlog push {id}",
|
|
124
|
-
description="Move issue to backlog"
|
|
119
|
+
description="Move issue to backlog",
|
|
125
120
|
),
|
|
126
|
-
|
|
127
121
|
TransitionConfig(
|
|
128
122
|
name="pull",
|
|
129
123
|
label="Pull",
|
|
@@ -132,7 +126,7 @@ DEFAULT_ISSUE_CONFIG = IssueSchemaConfig(
|
|
|
132
126
|
to_status="open",
|
|
133
127
|
to_stage="draft",
|
|
134
128
|
command_template="monoco issue backlog pull {id}",
|
|
135
|
-
description="Restore issue from backlog"
|
|
129
|
+
description="Restore issue from backlog",
|
|
136
130
|
),
|
|
137
131
|
TransitionConfig(
|
|
138
132
|
name="cancel_backlog",
|
|
@@ -143,9 +137,8 @@ DEFAULT_ISSUE_CONFIG = IssueSchemaConfig(
|
|
|
143
137
|
to_stage="done",
|
|
144
138
|
required_solution="cancelled",
|
|
145
139
|
command_template="monoco issue cancel {id}",
|
|
146
|
-
description="Cancel backlog issue"
|
|
140
|
+
description="Cancel backlog issue",
|
|
147
141
|
),
|
|
148
|
-
|
|
149
142
|
# --- CLOSED Transitions ---
|
|
150
143
|
TransitionConfig(
|
|
151
144
|
name="reopen",
|
|
@@ -155,7 +148,7 @@ DEFAULT_ISSUE_CONFIG = IssueSchemaConfig(
|
|
|
155
148
|
to_status="open",
|
|
156
149
|
to_stage="draft",
|
|
157
150
|
command_template="monoco issue open {id}",
|
|
158
|
-
description="Reopen a closed issue"
|
|
151
|
+
description="Reopen a closed issue",
|
|
159
152
|
),
|
|
160
153
|
TransitionConfig(
|
|
161
154
|
name="reopen_from_done",
|
|
@@ -166,7 +159,7 @@ DEFAULT_ISSUE_CONFIG = IssueSchemaConfig(
|
|
|
166
159
|
to_status="open",
|
|
167
160
|
to_stage="draft",
|
|
168
161
|
command_template="monoco issue open {id}",
|
|
169
|
-
description="Reopen a done issue"
|
|
162
|
+
description="Reopen a done issue",
|
|
170
163
|
),
|
|
171
|
-
]
|
|
164
|
+
],
|
|
172
165
|
)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from typing import List, Optional, Dict
|
|
2
2
|
from monoco.core.config import IssueSchemaConfig, TransitionConfig
|
|
3
|
-
from ..models import
|
|
3
|
+
from ..models import IssueMetadata
|
|
4
|
+
|
|
4
5
|
|
|
5
6
|
class StateMachine:
|
|
6
7
|
def __init__(self, config: IssueSchemaConfig):
|
|
@@ -30,15 +31,20 @@ class StateMachine:
|
|
|
30
31
|
return []
|
|
31
32
|
return [t.name for t in self.issue_config.types]
|
|
32
33
|
|
|
33
|
-
def can_transition(
|
|
34
|
-
|
|
34
|
+
def can_transition(
|
|
35
|
+
self,
|
|
36
|
+
current_status: str,
|
|
37
|
+
current_stage: Optional[str],
|
|
38
|
+
target_status: str,
|
|
39
|
+
target_stage: Optional[str],
|
|
40
|
+
) -> bool:
|
|
35
41
|
"""Check if a transition path exists."""
|
|
36
42
|
for t in self.transitions:
|
|
37
43
|
if t.from_status and t.from_status != current_status:
|
|
38
44
|
continue
|
|
39
45
|
if t.from_stage and t.from_stage != current_stage:
|
|
40
46
|
continue
|
|
41
|
-
|
|
47
|
+
|
|
42
48
|
if t.to_status == target_status:
|
|
43
49
|
if target_stage is None or t.to_stage == target_stage:
|
|
44
50
|
return True
|
|
@@ -56,11 +62,11 @@ class StateMachine:
|
|
|
56
62
|
# Match status
|
|
57
63
|
if t.from_status and t.from_status != meta.status:
|
|
58
64
|
continue
|
|
59
|
-
|
|
65
|
+
|
|
60
66
|
# Match stage
|
|
61
67
|
if t.from_stage and t.from_stage != meta.stage:
|
|
62
68
|
continue
|
|
63
|
-
|
|
69
|
+
|
|
64
70
|
# Special case for 'Cancel': don't show if already DONE or CLOSED
|
|
65
71
|
if t.name == "cancel" and meta.stage == "done":
|
|
66
72
|
continue
|
|
@@ -68,9 +74,14 @@ class StateMachine:
|
|
|
68
74
|
allowed.append(t)
|
|
69
75
|
return allowed
|
|
70
76
|
|
|
71
|
-
def find_transition(
|
|
72
|
-
|
|
73
|
-
|
|
77
|
+
def find_transition(
|
|
78
|
+
self,
|
|
79
|
+
from_status: str,
|
|
80
|
+
from_stage: Optional[str],
|
|
81
|
+
to_status: str,
|
|
82
|
+
to_stage: Optional[str],
|
|
83
|
+
solution: Optional[str] = None,
|
|
84
|
+
) -> Optional[TransitionConfig]:
|
|
74
85
|
"""Find a specific transition rule."""
|
|
75
86
|
candidates = []
|
|
76
87
|
for t in self.transitions:
|
|
@@ -82,15 +93,15 @@ class StateMachine:
|
|
|
82
93
|
continue
|
|
83
94
|
if t.from_stage and t.from_stage != from_stage:
|
|
84
95
|
continue
|
|
85
|
-
|
|
96
|
+
|
|
86
97
|
# Check if this transition matches the target
|
|
87
98
|
if t.to_status == to_status:
|
|
88
99
|
if to_stage is None or t.to_stage == to_stage:
|
|
89
100
|
candidates.append(t)
|
|
90
|
-
|
|
101
|
+
|
|
91
102
|
if not candidates:
|
|
92
103
|
return None
|
|
93
|
-
|
|
104
|
+
|
|
94
105
|
# If we have a solution, find the transition that requires it
|
|
95
106
|
if solution:
|
|
96
107
|
for t in candidates:
|
|
@@ -102,84 +113,106 @@ class StateMachine:
|
|
|
102
113
|
if t.required_solution is None:
|
|
103
114
|
return t
|
|
104
115
|
return None
|
|
105
|
-
|
|
116
|
+
|
|
106
117
|
# Otherwise return the first one that has NO required_solution
|
|
107
118
|
for t in candidates:
|
|
108
119
|
if t.required_solution is None:
|
|
109
120
|
return t
|
|
110
|
-
|
|
121
|
+
|
|
111
122
|
return candidates[0]
|
|
112
123
|
|
|
113
|
-
def validate_transition(
|
|
114
|
-
|
|
115
|
-
|
|
124
|
+
def validate_transition(
|
|
125
|
+
self,
|
|
126
|
+
from_status: str,
|
|
127
|
+
from_stage: Optional[str],
|
|
128
|
+
to_status: str,
|
|
129
|
+
to_stage: Optional[str],
|
|
130
|
+
solution: Optional[str] = None,
|
|
131
|
+
) -> None:
|
|
116
132
|
"""
|
|
117
133
|
Validate if a transition is allowed. Raises ValueError if not.
|
|
118
134
|
"""
|
|
119
135
|
if from_status == to_status and from_stage == to_stage:
|
|
120
|
-
return
|
|
136
|
+
return # No change is always allowed (unless we want to enforce specific updates)
|
|
137
|
+
|
|
138
|
+
transition = self.find_transition(
|
|
139
|
+
from_status, from_stage, to_status, to_stage, solution
|
|
140
|
+
)
|
|
121
141
|
|
|
122
|
-
transition = self.find_transition(from_status, from_stage, to_status, to_stage, solution)
|
|
123
|
-
|
|
124
142
|
if not transition:
|
|
125
|
-
raise ValueError(
|
|
126
|
-
|
|
143
|
+
raise ValueError(
|
|
144
|
+
f"Lifecycle Policy: Transition from {from_status}({from_stage if from_stage else 'None'}) "
|
|
145
|
+
f"to {to_status}({to_stage if to_stage else 'None'}) is not defined."
|
|
146
|
+
)
|
|
127
147
|
|
|
128
148
|
if transition.required_solution and solution != transition.required_solution:
|
|
129
|
-
|
|
149
|
+
raise ValueError(
|
|
150
|
+
f"Lifecycle Policy: Transition '{transition.label}' requires solution '{transition.required_solution}'."
|
|
151
|
+
)
|
|
130
152
|
|
|
131
153
|
def enforce_policy(self, meta: IssueMetadata) -> None:
|
|
132
154
|
"""
|
|
133
155
|
Apply consistency rules to IssueMetadata.
|
|
134
156
|
"""
|
|
135
157
|
from ..models import current_time
|
|
136
|
-
|
|
158
|
+
|
|
137
159
|
if meta.status == "backlog":
|
|
138
160
|
meta.stage = "freezed"
|
|
139
|
-
|
|
161
|
+
|
|
140
162
|
elif meta.status == "closed":
|
|
141
163
|
if meta.stage != "done":
|
|
142
164
|
meta.stage = "done"
|
|
143
165
|
if not meta.closed_at:
|
|
144
166
|
meta.closed_at = current_time()
|
|
145
|
-
|
|
167
|
+
|
|
146
168
|
elif meta.status == "open":
|
|
147
169
|
if meta.stage is None:
|
|
148
170
|
meta.stage = "draft"
|
|
149
171
|
|
|
150
|
-
def validate_transition(
|
|
151
|
-
|
|
152
|
-
|
|
172
|
+
def validate_transition(
|
|
173
|
+
self,
|
|
174
|
+
from_status: str,
|
|
175
|
+
from_stage: Optional[str],
|
|
176
|
+
to_status: str,
|
|
177
|
+
to_stage: Optional[str],
|
|
178
|
+
solution: Optional[str] = None,
|
|
179
|
+
) -> None:
|
|
153
180
|
"""
|
|
154
181
|
Validate if a transition is allowed. Raises ValueError if not.
|
|
155
182
|
"""
|
|
156
183
|
if from_status == to_status and from_stage == to_stage:
|
|
157
|
-
return
|
|
184
|
+
return # No change is always allowed (unless we want to enforce specific updates)
|
|
185
|
+
|
|
186
|
+
transition = self.find_transition(
|
|
187
|
+
from_status, from_stage, to_status, to_stage, solution
|
|
188
|
+
)
|
|
158
189
|
|
|
159
|
-
transition = self.find_transition(from_status, from_stage, to_status, to_stage, solution)
|
|
160
|
-
|
|
161
190
|
if not transition:
|
|
162
|
-
raise ValueError(
|
|
163
|
-
|
|
191
|
+
raise ValueError(
|
|
192
|
+
f"Lifecycle Policy: Transition from {from_status}({from_stage if from_stage else 'None'}) "
|
|
193
|
+
f"to {to_status}({to_stage if to_stage else 'None'}) is not defined."
|
|
194
|
+
)
|
|
164
195
|
|
|
165
196
|
if transition.required_solution and solution != transition.required_solution:
|
|
166
|
-
|
|
197
|
+
raise ValueError(
|
|
198
|
+
f"Lifecycle Policy: Transition '{transition.label}' requires solution '{transition.required_solution}'."
|
|
199
|
+
)
|
|
167
200
|
|
|
168
201
|
def enforce_policy(self, meta: IssueMetadata) -> None:
|
|
169
202
|
"""
|
|
170
203
|
Apply consistency rules to IssueMetadata.
|
|
171
204
|
"""
|
|
172
205
|
from ..models import current_time
|
|
173
|
-
|
|
206
|
+
|
|
174
207
|
if meta.status == "backlog":
|
|
175
208
|
meta.stage = "freezed"
|
|
176
|
-
|
|
209
|
+
|
|
177
210
|
elif meta.status == "closed":
|
|
178
211
|
if meta.stage != "done":
|
|
179
212
|
meta.stage = "done"
|
|
180
213
|
if not meta.closed_at:
|
|
181
214
|
meta.closed_at = current_time()
|
|
182
|
-
|
|
215
|
+
|
|
183
216
|
elif meta.status == "open":
|
|
184
217
|
if meta.stage is None:
|
|
185
|
-
meta.stage = "draft"
|
|
218
|
+
meta.stage = "draft"
|
|
@@ -1,18 +1,20 @@
|
|
|
1
|
-
from typing import List, Optional
|
|
1
|
+
from typing import List, Optional
|
|
2
2
|
from pydantic import BaseModel
|
|
3
3
|
|
|
4
|
+
|
|
4
5
|
class Transition(BaseModel):
|
|
5
6
|
name: str
|
|
6
7
|
label: str
|
|
7
8
|
icon: Optional[str] = None
|
|
8
9
|
from_status: Optional[str] = None # None means any
|
|
9
|
-
from_stage: Optional[str] = None
|
|
10
|
+
from_stage: Optional[str] = None # None means any
|
|
10
11
|
to_status: str
|
|
11
12
|
to_stage: Optional[str] = None
|
|
12
13
|
required_solution: Optional[str] = None
|
|
13
14
|
description: str = ""
|
|
14
15
|
command_template: Optional[str] = None
|
|
15
16
|
|
|
17
|
+
|
|
16
18
|
class StateMachineConfig(BaseModel):
|
|
17
19
|
transitions: List[Transition]
|
|
18
20
|
# We can add more config like default stages for statuses etc.
|