Coverage for little_loops / cli / history.py: 10%

104 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-04 12:21 -0500

1"""ll-history: Display summary statistics and analysis for completed issues.""" 

2 

3from __future__ import annotations 

4 

5import argparse 

6import sys 

7from pathlib import Path 

8 

9from little_loops.cli.output import configure_output, print_json, use_color_enabled 

10from little_loops.cli_args import add_config_arg, add_json_arg 

11from little_loops.config import BRConfig 

12from little_loops.logger import Logger 

13from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context 

14 

15 

16def main_history() -> int: 

17 """Entry point for ll-history command. 

18 

19 Display summary statistics and analysis for completed issues. 

20 

21 Returns: 

22 Exit code (0 = success) 

23 """ 

24 with cli_event_context(DEFAULT_DB_PATH, "ll-history", sys.argv[1:]): 

25 from little_loops.issue_history import ( 

26 calculate_analysis, 

27 calculate_summary, 

28 format_analysis_json, 

29 format_analysis_markdown, 

30 format_analysis_text, 

31 format_analysis_yaml, 

32 format_summary_json, 

33 format_summary_text, 

34 scan_completed_issues, 

35 scan_completed_issues_from_db, 

36 synthesize_docs, 

37 ) 

38 

39 parser = argparse.ArgumentParser( 

40 prog="ll-history", 

41 description="Display summary statistics and analysis for completed issues", 

42 formatter_class=argparse.RawDescriptionHelpFormatter, 

43 epilog=""" 

44Examples: 

45 %(prog)s summary # Show summary statistics 

46 %(prog)s summary --json # Output as JSON 

47 %(prog)s analyze # Full analysis report 

48 %(prog)s analyze --format markdown # Markdown report 

49 %(prog)s analyze --compare 30 # Compare last 30 days to previous 

50 %(prog)s export "session log" # Export topic-filtered issue excerpts 

51 %(prog)s export "sprint CLI" --output docs/arch/sprint.md 

52""", 

53 ) 

54 

55 subparsers = parser.add_subparsers(dest="command", help="Available commands") 

56 

57 # summary subcommand (existing) 

58 summary_parser = subparsers.add_parser("summary", help="Show issue statistics") 

59 summary_parser.add_argument( 

60 "-j", 

61 "--json", 

62 action="store_true", 

63 help="Output as JSON instead of formatted text", 

64 ) 

65 summary_parser.add_argument( 

66 "-d", 

67 "--directory", 

68 type=Path, 

69 default=None, 

70 help="Path to issues directory (default: .issues)", 

71 ) 

72 

73 # analyze subcommand (new - FEAT-110) 

74 analyze_parser = subparsers.add_parser( 

75 "analyze", 

76 help="Full analysis with trends, subsystems, and debt metrics", 

77 ) 

78 analyze_parser.add_argument( 

79 "-f", 

80 "--format", 

81 type=str, 

82 choices=["text", "json", "markdown", "yaml"], 

83 default="text", 

84 help="Output format (default: text)", 

85 ) 

86 analyze_parser.add_argument( 

87 "-d", 

88 "--directory", 

89 type=Path, 

90 default=None, 

91 help="Path to issues directory (default: .issues)", 

92 ) 

93 analyze_parser.add_argument( 

94 "-p", 

95 "--period", 

96 type=str, 

97 choices=["weekly", "monthly", "quarterly"], 

98 default="monthly", 

99 help="Grouping period for trends (default: monthly)", 

100 ) 

101 date_filter_group = analyze_parser.add_mutually_exclusive_group() 

102 date_filter_group.add_argument( 

103 "-c", 

104 "--compare", 

105 type=int, 

106 default=None, 

107 metavar="DAYS", 

108 help="Compare last N days to previous N days", 

109 ) 

110 date_filter_group.add_argument( 

111 "--since", 

112 "-S", 

113 type=str, 

114 default=None, 

115 metavar="DATE", 

116 help="Only analyze issues completed on or after DATE (YYYY-MM-DD)", 

117 ) 

118 analyze_parser.add_argument( 

119 "--until", 

120 type=str, 

121 default=None, 

122 metavar="DATE", 

123 help="Only analyze issues completed on or before DATE (YYYY-MM-DD)", 

124 ) 

125 

126 # export subcommand (FEAT-503, renamed from generate-docs in ENH-523) 

127 gendocs_parser = subparsers.add_parser( 

128 "export", 

129 help="Export topic-filtered excerpts from completed issue history", 

130 ) 

131 gendocs_parser.add_argument( 

132 "topic", 

133 type=str, 

134 help="Topic, area, or system to generate documentation for", 

135 ) 

136 gendocs_parser.add_argument( 

137 "--output", 

138 "-o", 

139 type=Path, 

140 default=None, 

141 help="Write output to file instead of stdout", 

142 ) 

143 gendocs_parser.add_argument( 

144 "-f", 

145 "--format", 

146 type=str, 

147 choices=["narrative", "structured"], 

148 default="narrative", 

149 help="Output format (default: narrative)", 

150 ) 

151 gendocs_parser.add_argument( 

152 "-d", 

153 "--directory", 

154 type=Path, 

155 default=None, 

156 help="Path to issues directory (default: .issues)", 

157 ) 

158 gendocs_parser.add_argument( 

159 "--since", 

160 "-S", 

161 type=str, 

162 default=None, 

163 metavar="DATE", 

164 help="Only include issues completed after DATE (YYYY-MM-DD)", 

165 ) 

166 gendocs_parser.add_argument( 

167 "--min-relevance", 

168 type=float, 

169 default=0.5, 

170 metavar="FLOAT", 

171 help="Minimum relevance score threshold (default: 0.5)", 

172 ) 

173 gendocs_parser.add_argument( 

174 "--type", 

175 type=str, 

176 choices=["BUG", "FEAT", "ENH", "EPIC"], 

177 default=None, 

178 dest="issue_type", 

179 help="Filter by issue type", 

180 ) 

181 gendocs_parser.add_argument( 

182 "--scoring", 

183 type=str, 

184 choices=["intersection", "bm25", "hybrid"], 

185 default="intersection", 

186 help="Relevance scoring method: intersection (default), bm25, or hybrid", 

187 ) 

188 

189 # sessions subcommand (ENH-1711) 

190 sessions_parser = subparsers.add_parser( 

191 "sessions", 

192 help="List sessions that touched an issue", 

193 ) 

194 sessions_parser.add_argument( 

195 "issue_id", 

196 metavar="ISSUE_ID", 

197 help="Issue ID (e.g., ENH-1710)", 

198 ) 

199 sessions_parser.add_argument( 

200 "--limit", type=int, default=20, metavar="N", help="Maximum results (default: 20)" 

201 ) 

202 add_json_arg(sessions_parser) 

203 

204 add_config_arg(parser) 

205 

206 args = parser.parse_args() 

207 

208 if not args.command: 

209 parser.print_help() 

210 return 1 

211 

212 # Determine directories 

213 project_root = args.config or Path.cwd() 

214 config = BRConfig(project_root) 

215 configure_output(config.cli) 

216 logger = Logger(use_color=use_color_enabled()) 

217 issues_dir = ( 

218 getattr(args, "directory", None) or config.project_root / config.issues.base_dir 

219 ) 

220 

221 if args.command == "summary": 

222 # Prefer the unified session DB when populated; fall back to the 

223 # file-parsing path when the DB is missing or empty (ENH-1621). 

224 db_path = project_root / DEFAULT_DB_PATH 

225 issues = scan_completed_issues_from_db(db_path) 

226 if not issues: 

227 issues = scan_completed_issues(issues_dir) 

228 summary = calculate_summary(issues) 

229 

230 if args.json: 

231 print(format_summary_json(summary)) 

232 else: 

233 print(format_summary_text(summary)) 

234 

235 return 0 

236 

237 if args.command == "analyze": 

238 # New analyze logic (FEAT-110) 

239 from datetime import date as date_type 

240 

241 issues = scan_completed_issues(issues_dir) 

242 

243 since_date = date_type.fromisoformat(args.since) if args.since else None 

244 until_date = date_type.fromisoformat(args.until) if args.until else None 

245 if since_date or until_date: 

246 issues = [ 

247 i 

248 for i in issues 

249 if i.completed_date is not None 

250 and (since_date is None or i.completed_date >= since_date) 

251 and (until_date is None or i.completed_date <= until_date) 

252 ] 

253 

254 analysis = calculate_analysis( 

255 issues, 

256 issues_dir=issues_dir, 

257 period_type=args.period, 

258 compare_days=args.compare, 

259 project_root=project_root, 

260 ) 

261 

262 if args.format == "json": 

263 print(format_analysis_json(analysis)) 

264 elif args.format == "yaml": 

265 print(format_analysis_yaml(analysis)) 

266 elif args.format == "markdown": 

267 print(format_analysis_markdown(analysis)) 

268 else: 

269 print(format_analysis_text(analysis)) 

270 

271 return 0 

272 

273 if args.command == "sessions": 

274 from little_loops.history_reader import sessions_for_issue 

275 from little_loops.session_store import DEFAULT_DB_PATH as _SS_DB 

276 

277 db_path = project_root / _SS_DB 

278 refs = sessions_for_issue(args.issue_id, limit=args.limit, db=db_path) 

279 if args.json: 

280 from dataclasses import asdict 

281 

282 print_json([asdict(r) for r in refs]) 

283 elif not refs: 

284 print(f"No sessions found for {args.issue_id}.") 

285 else: 

286 for r in refs: 

287 path = r.jsonl_path or "(no path)" 

288 print(f"{r.session_id} {path}") 

289 return 0 

290 

291 if args.command == "export": 

292 from datetime import date as date_type 

293 

294 from little_loops.issue_history.analysis import _load_issue_contents 

295 

296 issues = scan_completed_issues(issues_dir) 

297 contents = _load_issue_contents(issues) 

298 

299 since_date = None 

300 if args.since: 

301 since_date = date_type.fromisoformat(args.since) 

302 

303 doc = synthesize_docs( 

304 topic=args.topic, 

305 issues=issues, 

306 contents=contents, 

307 format=args.format, 

308 min_relevance=args.min_relevance, 

309 since=since_date, 

310 issue_type=args.issue_type, 

311 scoring=args.scoring, 

312 ) 

313 

314 if args.output: 

315 args.output.parent.mkdir(parents=True, exist_ok=True) 

316 args.output.write_text(doc, encoding="utf-8") 

317 logger.success(f"Documentation written to {args.output}") 

318 else: 

319 print(doc) 

320 

321 return 0 

322 

323 return 1