Coverage for little_loops/config/features.py: 74%

1"""Feature-related configuration dataclasses.

2

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

4 and sync configuration.

5"""

6

7 from __future__ import annotations

8

9 from dataclasses import dataclass, field

10 from typing import Any

11

12# Required categories that must always exist (cannot be removed by user config)

13 REQUIRED_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)

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

21 **REQUIRED_CATEGORIES,

22}

23

24

25@dataclass

26 class 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

44 class IssuesConfig:

45 """Issue management configuration."""

46

47 base_dir: str = ".issues"

48 categories: dict[str, CategoryConfig] = field(default_factory=dict)

49 completed_dir: str = "completed"

50 deferred_dir: str = "deferred"

51 priorities: list[str] = field(default_factory=lambda: ["P0", "P1", "P2", "P3", "P4", "P5"])

52 templates_dir: str | None = None

53 capture_template: str = "full"

54

55 @classmethod

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

57 """Create IssuesConfig from dictionary.

58

59 Required categories (bugs, features, enhancements) are automatically

60 included if not specified in user config.

61 """

62 # Start with user categories or empty dict

63 categories_data = dict(data.get("categories", {}))

64

65 # Ensure required categories exist (merge with defaults)

66 for key, defaults in REQUIRED_CATEGORIES.items():

67 if key not in categories_data:

68 categories_data[key] = defaults

69

70 categories = {

71 key: CategoryConfig.from_dict(key, value) for key, value in categories_data.items()

72 }

73 return cls(

74 base_dir=data.get("base_dir", ".issues"),

75 categories=categories,

76 completed_dir=data.get("completed_dir", "completed"),

77 deferred_dir=data.get("deferred_dir", "deferred"),

78 priorities=data.get("priorities", ["P0", "P1", "P2", "P3", "P4", "P5"]),

79 templates_dir=data.get("templates_dir"),

80 capture_template=data.get("capture_template", "full"),

81 )

82

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

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

85

86 Args:

87 prefix: Issue type prefix to look up

88

89 Returns:

90 CategoryConfig if found, None otherwise

91 """

92 for category in self.categories.values():

93 if category.prefix == prefix:

94 return category

95 return None

96

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

98 """Get category config by directory name.

99

100 Args:

101 dir_name: Directory name to look up

102

103 Returns:

104 CategoryConfig if found, None otherwise

105 """

106 for category in self.categories.values():

107 if category.dir == dir_name:

108 return category

109 return None

110

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

112 """Get all configured issue type prefixes.

113

114 Returns:

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

116 """

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

118

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

120 """Get all configured issue directory names.

121

122 Returns:

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

124 """

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

126

127

128@dataclass

129 class ScanConfig:

130 """Codebase scanning configuration."""

131

132 focus_dirs: list[str] = field(default_factory=lambda: ["src/", "tests/"])

133 exclude_patterns: list[str] = field(

134 default_factory=lambda: ["**/node_modules/**", "**/__pycache__/**", "**/.git/**"]

135 )

136 custom_agents: list[str] = field(default_factory=list)

137

138 @classmethod

139 def from_dict(cls, data: dict[str, Any]) -> ScanConfig:

140 """Create ScanConfig from dictionary."""

141 return cls(

142 focus_dirs=data.get("focus_dirs", ["src/", "tests/"]),

143 exclude_patterns=data.get(

144 "exclude_patterns",

145 ["**/node_modules/**", "**/__pycache__/**", "**/.git/**"],

146 ),

147 custom_agents=data.get("custom_agents", []),

148 )

149

150

151@dataclass

152 class SprintsConfig:

153 """Sprint management configuration."""

154

155 sprints_dir: str = ".sprints"

156 default_timeout: int = 3600

157 default_max_workers: int = 2

158

159 @classmethod

160 def from_dict(cls, data: dict[str, Any]) -> SprintsConfig:

161 """Create SprintsConfig from dictionary."""

162 return cls(

163 sprints_dir=data.get("sprints_dir", ".sprints"),

164 default_timeout=data.get("default_timeout", 3600),

165 default_max_workers=data.get("default_max_workers", 2),

166 )

167

168

169@dataclass

170 class LoopsConfig:

171 """FSM loop configuration."""

172

173 loops_dir: str = ".loops"

174

175 @classmethod

176 def from_dict(cls, data: dict[str, Any]) -> LoopsConfig:

177 """Create LoopsConfig from dictionary."""

178 return cls(

179 loops_dir=data.get("loops_dir", ".loops"),

180 )

181

182

183@dataclass

184 class GitHubSyncConfig:

185 """GitHub-specific sync configuration."""

186

187 repo: str | None = None

188 label_mapping: dict[str, str] = field(

189 default_factory=lambda: {"BUG": "bug", "FEAT": "enhancement", "ENH": "enhancement"}

190 )

191 priority_labels: bool = True

192 sync_completed: bool = False

193 state_file: str = ".claude/ll-sync-state.json"

194 pull_template: str = "minimal"

195

196 @classmethod

197 def from_dict(cls, data: dict[str, Any]) -> GitHubSyncConfig:

198 """Create GitHubSyncConfig from dictionary."""

199 return cls(

200 repo=data.get("repo"),

201 label_mapping=data.get(

202 "label_mapping", {"BUG": "bug", "FEAT": "enhancement", "ENH": "enhancement"}

203 ),

204 priority_labels=data.get("priority_labels", True),

205 sync_completed=data.get("sync_completed", False),

206 state_file=data.get("state_file", ".claude/ll-sync-state.json"),

207 pull_template=data.get("pull_template", "minimal"),

208 )

209

210

211@dataclass

212 class SyncConfig:

213 """Issue sync configuration."""

214

215 enabled: bool = False

216 provider: str = "github"

217 github: GitHubSyncConfig = field(default_factory=GitHubSyncConfig)

218

219 @classmethod

220 def from_dict(cls, data: dict[str, Any]) -> SyncConfig:

221 """Create SyncConfig from dictionary."""

222 return cls(

223 enabled=data.get("enabled", False),

224 provider=data.get("provider", "github"),

225 github=GitHubSyncConfig.from_dict(data.get("github", {})),

226 )