Coverage for little_loops / config / features.py: 91%
108 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-04-11 23:20 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-04-11 23:20 -0500
1"""Feature-related configuration dataclasses.
3Covers issue tracking, scanning, sprint management, loop management,
4and sync configuration.
5"""
7from __future__ import annotations
9from dataclasses import dataclass, field
10from typing import Any
12# Required categories that must always exist (cannot be removed by user config)
13REQUIRED_CATEGORIES: dict[str, dict[str, str]] = {
14 "bugs": {"prefix": "BUG", "dir": "bugs", "action": "fix"},
15 "features": {"prefix": "FEAT", "dir": "features", "action": "implement"},
16 "enhancements": {"prefix": "ENH", "dir": "enhancements", "action": "improve"},
17}
19# Default categories (same as required by default, could include optional defaults)
20DEFAULT_CATEGORIES: dict[str, dict[str, str]] = {
21 **REQUIRED_CATEGORIES,
22}
25@dataclass
26class CategoryConfig:
27 """Configuration for an issue category."""
29 prefix: str
30 dir: str
31 action: str = "fix"
33 @classmethod
34 def from_dict(cls, key: str, data: dict[str, Any]) -> CategoryConfig:
35 """Create CategoryConfig from dictionary."""
36 return cls(
37 prefix=data.get("prefix", key.upper()[:3]),
38 dir=data.get("dir", key),
39 action=data.get("action", "fix"),
40 )
43@dataclass
44class DuplicateDetectionConfig:
45 """Thresholds for duplicate issue detection."""
47 exact_threshold: float = 0.8
48 similar_threshold: float = 0.5
50 @classmethod
51 def from_dict(cls, data: dict[str, Any]) -> DuplicateDetectionConfig:
52 """Create DuplicateDetectionConfig from dictionary."""
53 return cls(
54 exact_threshold=data.get("exact_threshold", 0.8),
55 similar_threshold=data.get("similar_threshold", 0.5),
56 )
59@dataclass
60class IssuesConfig:
61 """Issue management configuration."""
63 base_dir: str = ".issues"
64 categories: dict[str, CategoryConfig] = field(default_factory=dict)
65 completed_dir: str = "completed"
66 deferred_dir: str = "deferred"
67 priorities: list[str] = field(default_factory=lambda: ["P0", "P1", "P2", "P3", "P4", "P5"])
68 templates_dir: str | None = None
69 capture_template: str = "full"
70 duplicate_detection: DuplicateDetectionConfig = field(default_factory=DuplicateDetectionConfig)
72 @classmethod
73 def from_dict(cls, data: dict[str, Any]) -> IssuesConfig:
74 """Create IssuesConfig from dictionary.
76 Required categories (bugs, features, enhancements) are automatically
77 included if not specified in user config.
78 """
79 # Start with user categories or empty dict
80 categories_data = dict(data.get("categories", {}))
82 # Ensure required categories exist (merge with defaults)
83 for key, defaults in REQUIRED_CATEGORIES.items():
84 if key not in categories_data:
85 categories_data[key] = defaults
87 categories = {
88 key: CategoryConfig.from_dict(key, value) for key, value in categories_data.items()
89 }
90 return cls(
91 base_dir=data.get("base_dir", ".issues"),
92 categories=categories,
93 completed_dir=data.get("completed_dir", "completed"),
94 deferred_dir=data.get("deferred_dir", "deferred"),
95 priorities=data.get("priorities", ["P0", "P1", "P2", "P3", "P4", "P5"]),
96 templates_dir=data.get("templates_dir"),
97 capture_template=data.get("capture_template", "full"),
98 duplicate_detection=DuplicateDetectionConfig.from_dict(
99 data.get("duplicate_detection", {})
100 ),
101 )
103 def get_category_by_prefix(self, prefix: str) -> CategoryConfig | None:
104 """Get category config by prefix (e.g., 'BUG', 'FEAT').
106 Args:
107 prefix: Issue type prefix to look up
109 Returns:
110 CategoryConfig if found, None otherwise
111 """
112 for category in self.categories.values():
113 if category.prefix == prefix:
114 return category
115 return None
117 def get_category_by_dir(self, dir_name: str) -> CategoryConfig | None:
118 """Get category config by directory name.
120 Args:
121 dir_name: Directory name to look up
123 Returns:
124 CategoryConfig if found, None otherwise
125 """
126 for category in self.categories.values():
127 if category.dir == dir_name:
128 return category
129 return None
131 def get_all_prefixes(self) -> list[str]:
132 """Get all configured issue type prefixes.
134 Returns:
135 List of prefixes (e.g., ['BUG', 'FEAT', 'ENH'])
136 """
137 return [cat.prefix for cat in self.categories.values()]
139 def get_all_dirs(self) -> list[str]:
140 """Get all configured issue directory names.
142 Returns:
143 List of directory names (e.g., ['bugs', 'features', 'enhancements'])
144 """
145 return [cat.dir for cat in self.categories.values()]
148@dataclass
149class ScanConfig:
150 """Codebase scanning configuration."""
152 focus_dirs: list[str] = field(default_factory=lambda: ["src/", "tests/"])
153 exclude_patterns: list[str] = field(
154 default_factory=lambda: ["**/node_modules/**", "**/__pycache__/**", "**/.git/**"]
155 )
156 custom_agents: list[str] = field(default_factory=list)
158 @classmethod
159 def from_dict(cls, data: dict[str, Any]) -> ScanConfig:
160 """Create ScanConfig from dictionary."""
161 return cls(
162 focus_dirs=data.get("focus_dirs", ["src/", "tests/"]),
163 exclude_patterns=data.get(
164 "exclude_patterns",
165 ["**/node_modules/**", "**/__pycache__/**", "**/.git/**"],
166 ),
167 custom_agents=data.get("custom_agents", []),
168 )
171@dataclass
172class SprintsConfig:
173 """Sprint management configuration."""
175 sprints_dir: str = ".sprints"
176 default_timeout: int = 3600
177 default_max_workers: int = 2
179 @classmethod
180 def from_dict(cls, data: dict[str, Any]) -> SprintsConfig:
181 """Create SprintsConfig from dictionary."""
182 return cls(
183 sprints_dir=data.get("sprints_dir", ".sprints"),
184 default_timeout=data.get("default_timeout", 3600),
185 default_max_workers=data.get("default_max_workers", 2),
186 )
189@dataclass
190class LoopsGlyphsConfig:
191 """Unicode badge/glyph overrides for FSM box diagram state badges."""
193 prompt: str = "\u2726" # ✦
194 slash_command: str = "/\u2501\u25ba" # /━►
195 shell: str = "\u276f_" # ❯_
196 mcp_tool: str = "\u26a1" # ⚡
197 sub_loop: str = "\u21b3\u27f3" # ↳⟳
198 route: str = "\u2443" # ⑃
200 @classmethod
201 def from_dict(cls, data: dict[str, Any]) -> LoopsGlyphsConfig:
202 """Create LoopsGlyphsConfig from dictionary."""
203 return cls(
204 prompt=data.get("prompt", "\u2726"),
205 slash_command=data.get("slash_command", "/\u2501\u25ba"),
206 shell=data.get("shell", "\u276f_"),
207 mcp_tool=data.get("mcp_tool", "\u26a1"),
208 sub_loop=data.get("sub_loop", "\u21b3\u27f3"),
209 route=data.get("route", "\u2443"),
210 )
212 def to_dict(self) -> dict[str, str]:
213 """Convert to a glyph-key→string dict for use by _get_state_badge."""
214 return {
215 "prompt": self.prompt,
216 "slash_command": self.slash_command,
217 "shell": self.shell,
218 "mcp_tool": self.mcp_tool,
219 "sub_loop": self.sub_loop,
220 "route": self.route,
221 }
224@dataclass
225class LoopsConfig:
226 """FSM loop configuration."""
228 loops_dir: str = ".loops"
229 glyphs: LoopsGlyphsConfig = field(default_factory=LoopsGlyphsConfig)
231 @classmethod
232 def from_dict(cls, data: dict[str, Any]) -> LoopsConfig:
233 """Create LoopsConfig from dictionary."""
234 return cls(
235 loops_dir=data.get("loops_dir", ".loops"),
236 glyphs=LoopsGlyphsConfig.from_dict(data.get("glyphs", {})),
237 )
240@dataclass
241class GitHubSyncConfig:
242 """GitHub-specific sync configuration."""
244 repo: str | None = None
245 label_mapping: dict[str, str] = field(
246 default_factory=lambda: {"BUG": "bug", "FEAT": "enhancement", "ENH": "enhancement"}
247 )
248 priority_labels: bool = True
249 sync_completed: bool = False
250 state_file: str = ".ll/ll-sync-state.json"
251 pull_template: str = "minimal"
252 pull_limit: int = 500
254 @classmethod
255 def from_dict(cls, data: dict[str, Any]) -> GitHubSyncConfig:
256 """Create GitHubSyncConfig from dictionary."""
257 return cls(
258 repo=data.get("repo"),
259 label_mapping=data.get(
260 "label_mapping", {"BUG": "bug", "FEAT": "enhancement", "ENH": "enhancement"}
261 ),
262 priority_labels=data.get("priority_labels", True),
263 sync_completed=data.get("sync_completed", False),
264 state_file=data.get("state_file", ".ll/ll-sync-state.json"),
265 pull_template=data.get("pull_template", "minimal"),
266 pull_limit=data.get("pull_limit", 500),
267 )
270@dataclass
271class SyncConfig:
272 """Issue sync configuration."""
274 enabled: bool = False
275 provider: str = "github"
276 github: GitHubSyncConfig = field(default_factory=GitHubSyncConfig)
278 @classmethod
279 def from_dict(cls, data: dict[str, Any]) -> SyncConfig:
280 """Create SyncConfig from dictionary."""
281 return cls(
282 enabled=data.get("enabled", False),
283 provider=data.get("provider", "github"),
284 github=GitHubSyncConfig.from_dict(data.get("github", {})),
285 )