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

1"""Feature-related configuration dataclasses. 

2 

3Covers issue tracking, scanning, sprint management, loop management, 

4and sync configuration. 

5""" 

6 

7from __future__ import annotations 

8 

9from dataclasses import dataclass, field 

10from typing import Any 

11 

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} 

18 

19# Default categories (same as required by default, could include optional defaults) 

20DEFAULT_CATEGORIES: dict[str, dict[str, str]] = { 

21 **REQUIRED_CATEGORIES, 

22} 

23 

24 

25@dataclass 

26class CategoryConfig: 

27 """Configuration for an issue category.""" 

28 

29 prefix: str 

30 dir: str 

31 action: str = "fix" 

32 

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 ) 

41 

42 

43@dataclass 

44class DuplicateDetectionConfig: 

45 """Thresholds for duplicate issue detection.""" 

46 

47 exact_threshold: float = 0.8 

48 similar_threshold: float = 0.5 

49 

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 ) 

57 

58 

59@dataclass 

60class IssuesConfig: 

61 """Issue management configuration.""" 

62 

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) 

71 

72 @classmethod 

73 def from_dict(cls, data: dict[str, Any]) -> IssuesConfig: 

74 """Create IssuesConfig from dictionary. 

75 

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", {})) 

81 

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 

86 

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 ) 

102 

103 def get_category_by_prefix(self, prefix: str) -> CategoryConfig | None: 

104 """Get category config by prefix (e.g., 'BUG', 'FEAT'). 

105 

106 Args: 

107 prefix: Issue type prefix to look up 

108 

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 

116 

117 def get_category_by_dir(self, dir_name: str) -> CategoryConfig | None: 

118 """Get category config by directory name. 

119 

120 Args: 

121 dir_name: Directory name to look up 

122 

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 

130 

131 def get_all_prefixes(self) -> list[str]: 

132 """Get all configured issue type prefixes. 

133 

134 Returns: 

135 List of prefixes (e.g., ['BUG', 'FEAT', 'ENH']) 

136 """ 

137 return [cat.prefix for cat in self.categories.values()] 

138 

139 def get_all_dirs(self) -> list[str]: 

140 """Get all configured issue directory names. 

141 

142 Returns: 

143 List of directory names (e.g., ['bugs', 'features', 'enhancements']) 

144 """ 

145 return [cat.dir for cat in self.categories.values()] 

146 

147 

148@dataclass 

149class ScanConfig: 

150 """Codebase scanning configuration.""" 

151 

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) 

157 

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 ) 

169 

170 

171@dataclass 

172class SprintsConfig: 

173 """Sprint management configuration.""" 

174 

175 sprints_dir: str = ".sprints" 

176 default_timeout: int = 3600 

177 default_max_workers: int = 2 

178 

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 ) 

187 

188 

189@dataclass 

190class LoopsGlyphsConfig: 

191 """Unicode badge/glyph overrides for FSM box diagram state badges.""" 

192 

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" # ⑃ 

199 

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 ) 

211 

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 } 

222 

223 

224@dataclass 

225class LoopsConfig: 

226 """FSM loop configuration.""" 

227 

228 loops_dir: str = ".loops" 

229 glyphs: LoopsGlyphsConfig = field(default_factory=LoopsGlyphsConfig) 

230 

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 ) 

238 

239 

240@dataclass 

241class GitHubSyncConfig: 

242 """GitHub-specific sync configuration.""" 

243 

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 

253 

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 ) 

268 

269 

270@dataclass 

271class SyncConfig: 

272 """Issue sync configuration.""" 

273 

274 enabled: bool = False 

275 provider: str = "github" 

276 github: GitHubSyncConfig = field(default_factory=GitHubSyncConfig) 

277 

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 )