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

145 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-08 15:34 -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, resolve_history_db 

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 # root subcommand (ENH-1955) 

205 root_parser = subparsers.add_parser( 

206 "root", 

207 help="Show the project-root summary node (top-level condensed view)", 

208 ) 

209 root_parser.add_argument( 

210 "--expand", 

211 action="store_true", 

212 help="Expand and display all messages under the root node", 

213 ) 

214 root_parser.add_argument( 

215 "--limit", 

216 type=int, 

217 default=20, 

218 metavar="N", 

219 help="Maximum messages to show with --expand (default: 20)", 

220 ) 

221 add_json_arg(root_parser) 

222 

223 add_config_arg(parser) 

224 

225 args = parser.parse_args() 

226 

227 if not args.command: 

228 parser.print_help() 

229 return 1 

230 

231 # Determine directories 

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

233 config = BRConfig(project_root) 

234 configure_output(config.cli) 

235 logger = Logger(use_color=use_color_enabled()) 

236 issues_dir = ( 

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

238 ) 

239 

240 if args.command == "summary": 

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

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

243 db_path = resolve_history_db(project_root / DEFAULT_DB_PATH) 

244 issues = scan_completed_issues_from_db(db_path) 

245 if not issues: 

246 issues = scan_completed_issues(issues_dir) 

247 summary = calculate_summary(issues) 

248 

249 if args.json: 

250 print(format_summary_json(summary)) 

251 else: 

252 print(format_summary_text(summary)) 

253 

254 return 0 

255 

256 if args.command == "analyze": 

257 # New analyze logic (FEAT-110) 

258 from datetime import date as date_type 

259 

260 issues = scan_completed_issues(issues_dir) 

261 

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

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

264 if since_date or until_date: 

265 issues = [ 

266 i 

267 for i in issues 

268 if i.completed_date is not None 

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

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

271 ] 

272 

273 analysis = calculate_analysis( 

274 issues, 

275 issues_dir=issues_dir, 

276 period_type=args.period, 

277 compare_days=args.compare, 

278 project_root=project_root, 

279 ) 

280 

281 if args.format == "json": 

282 print(format_analysis_json(analysis)) 

283 elif args.format == "yaml": 

284 print(format_analysis_yaml(analysis)) 

285 elif args.format == "markdown": 

286 print(format_analysis_markdown(analysis)) 

287 else: 

288 print(format_analysis_text(analysis)) 

289 

290 return 0 

291 

292 if args.command == "sessions": 

293 from little_loops.history_reader import sessions_for_issue 

294 

295 db_path = resolve_history_db(project_root / DEFAULT_DB_PATH) 

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

297 if args.json: 

298 from dataclasses import asdict 

299 

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

301 elif not refs: 

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

303 else: 

304 for r in refs: 

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

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

307 return 0 

308 

309 if args.command == "root": 

310 from little_loops.history_reader import ll_describe, ll_expand 

311 

312 db_path = resolve_history_db(project_root / DEFAULT_DB_PATH) 

313 conn = None 

314 try: 

315 import sqlite3 

316 

317 conn = sqlite3.connect(str(db_path)) 

318 conn.row_factory = sqlite3.Row 

319 root_row = conn.execute( 

320 "SELECT id FROM summary_nodes" 

321 " WHERE session_id IS NULL AND parent_id IS NULL" 

322 " ORDER BY level DESC LIMIT 1" 

323 ).fetchone() 

324 except sqlite3.Error: 

325 root_row = None 

326 finally: 

327 if conn: 

328 conn.close() 

329 

330 if root_row is None: 

331 print("No project-root summary node found.") 

332 print("Run 'll-session backfill' with compaction enabled to generate one.") 

333 return 1 

334 

335 root_id = root_row["id"] 

336 node = ll_describe(root_id, db=db_path) 

337 if node is None: 

338 print(f"Root summary node {root_id} metadata not found.") 

339 return 1 

340 

341 if args.json: 

342 messages = ll_expand(root_id, db=db_path) if args.expand else [] 

343 from dataclasses import asdict 

344 

345 print_json( 

346 { 

347 "node": asdict(node), 

348 "message_count": len(messages), 

349 "messages": [dict(m) for m in messages[: args.limit]] 

350 if args.expand 

351 else [], 

352 } 

353 ) 

354 else: 

355 print(f"id={node.id} kind={node.kind} level={node.level}") 

356 print(f"ts_start={node.ts_start} ts_end={node.ts_end}") 

357 print(f"tokens={node.tokens} created_at={node.created_at}") 

358 if node.content: 

359 print(f"content: {node.content[:300]}") 

360 

361 if args.expand: 

362 messages = ll_expand(root_id, db=db_path) 

363 print(f"\n--- {len(messages)} messages covered ---") 

364 for m in messages[: args.limit]: 

365 snippet = (m.get("content") or "")[:120].replace("\n", " ") 

366 print(f"{m.get('ts', '')} {snippet}") 

367 return 0 

368 

369 if args.command == "export": 

370 from datetime import date as date_type 

371 

372 from little_loops.issue_history.analysis import _load_issue_contents 

373 

374 issues = scan_completed_issues(issues_dir) 

375 contents = _load_issue_contents(issues) 

376 

377 since_date = None 

378 if args.since: 

379 since_date = date_type.fromisoformat(args.since) 

380 

381 doc = synthesize_docs( 

382 topic=args.topic, 

383 issues=issues, 

384 contents=contents, 

385 format=args.format, 

386 min_relevance=args.min_relevance, 

387 since=since_date, 

388 issue_type=args.issue_type, 

389 scoring=args.scoring, 

390 ) 

391 

392 if args.output: 

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

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

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

396 else: 

397 print(doc) 

398 

399 return 0 

400 

401 return 1