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

148 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-16 13:11 -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_intent_arg, add_intent_limit_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 add_intent_arg(parser) 

225 add_intent_limit_arg(parser) 

226 

227 args = parser.parse_args() 

228 

229 if not args.command: 

230 parser.print_help() 

231 return 1 

232 

233 # Determine directories 

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

235 config = BRConfig(project_root) 

236 configure_output(config.cli) 

237 logger = Logger(use_color=use_color_enabled()) 

238 issues_dir = ( 

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

240 ) 

241 

242 if args.command == "summary": 

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

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

245 db_path = resolve_history_db(project_root / DEFAULT_DB_PATH) 

246 issues = scan_completed_issues_from_db(db_path) 

247 if not issues: 

248 issues = scan_completed_issues(issues_dir) 

249 summary = calculate_summary(issues) 

250 

251 if args.json: 

252 print(format_summary_json(summary)) 

253 else: 

254 print(format_summary_text(summary)) 

255 

256 return 0 

257 

258 if args.command == "analyze": 

259 # New analyze logic (FEAT-110) 

260 from datetime import date as date_type 

261 

262 db_path = resolve_history_db(project_root / DEFAULT_DB_PATH) 

263 issues = scan_completed_issues(issues_dir) 

264 

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

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

267 if since_date or until_date: 

268 issues = [ 

269 i 

270 for i in issues 

271 if i.completed_date is not None 

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

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

274 ] 

275 

276 analysis = calculate_analysis( 

277 issues, 

278 issues_dir=issues_dir, 

279 period_type=args.period, 

280 compare_days=args.compare, 

281 project_root=project_root, 

282 db_path=db_path, 

283 ) 

284 

285 if args.format == "json": 

286 print(format_analysis_json(analysis)) 

287 elif args.format == "yaml": 

288 print(format_analysis_yaml(analysis)) 

289 elif args.format == "markdown": 

290 print(format_analysis_markdown(analysis)) 

291 else: 

292 print(format_analysis_text(analysis)) 

293 

294 return 0 

295 

296 if args.command == "sessions": 

297 from little_loops.history_reader import sessions_for_issue 

298 

299 db_path = resolve_history_db(project_root / DEFAULT_DB_PATH) 

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

301 if args.json: 

302 from dataclasses import asdict 

303 

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

305 elif not refs: 

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

307 else: 

308 for r in refs: 

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

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

311 return 0 

312 

313 if args.command == "root": 

314 from little_loops.history_reader import ll_describe, ll_expand 

315 

316 db_path = resolve_history_db(project_root / DEFAULT_DB_PATH) 

317 conn = None 

318 try: 

319 import sqlite3 

320 

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

322 conn.row_factory = sqlite3.Row 

323 root_row = conn.execute( 

324 "SELECT id FROM summary_nodes" 

325 " WHERE session_id IS NULL AND parent_id IS NULL" 

326 " ORDER BY level DESC LIMIT 1" 

327 ).fetchone() 

328 except sqlite3.Error: 

329 root_row = None 

330 finally: 

331 if conn: 

332 conn.close() 

333 

334 if root_row is None: 

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

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

337 return 1 

338 

339 root_id = root_row["id"] 

340 node = ll_describe(root_id, db=db_path) 

341 if node is None: 

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

343 return 1 

344 

345 if args.json: 

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

347 from dataclasses import asdict 

348 

349 print_json( 

350 { 

351 "node": asdict(node), 

352 "message_count": len(messages), 

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

354 if args.expand 

355 else [], 

356 } 

357 ) 

358 else: 

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

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

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

362 if node.content: 

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

364 

365 if args.expand: 

366 messages = ll_expand(root_id, db=db_path) 

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

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

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

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

371 return 0 

372 

373 if args.command == "export": 

374 from datetime import date as date_type 

375 

376 from little_loops.issue_history.analysis import _load_issue_contents 

377 

378 issues = scan_completed_issues(issues_dir) 

379 contents = _load_issue_contents(issues) 

380 

381 since_date = None 

382 if args.since: 

383 since_date = date_type.fromisoformat(args.since) 

384 

385 doc = synthesize_docs( 

386 topic=args.topic, 

387 issues=issues, 

388 contents=contents, 

389 format=args.format, 

390 min_relevance=args.min_relevance, 

391 since=since_date, 

392 issue_type=args.issue_type, 

393 scoring=args.scoring, 

394 ) 

395 

396 if args.output: 

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

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

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

400 else: 

401 print(doc) 

402 

403 return 0 

404 

405 return 1