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
« 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."""
3from __future__ import annotations
5import argparse
6import sys
7from pathlib import Path
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
16def main_history() -> int:
17 """Entry point for ll-history command.
19 Display summary statistics and analysis for completed issues.
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 )
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 )
55 subparsers = parser.add_subparsers(dest="command", help="Available commands")
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 )
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 )
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 )
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)
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)
223 add_config_arg(parser)
225 args = parser.parse_args()
227 if not args.command:
228 parser.print_help()
229 return 1
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 )
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)
249 if args.json:
250 print(format_summary_json(summary))
251 else:
252 print(format_summary_text(summary))
254 return 0
256 if args.command == "analyze":
257 # New analyze logic (FEAT-110)
258 from datetime import date as date_type
260 issues = scan_completed_issues(issues_dir)
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 ]
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 )
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))
290 return 0
292 if args.command == "sessions":
293 from little_loops.history_reader import sessions_for_issue
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
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
309 if args.command == "root":
310 from little_loops.history_reader import ll_describe, ll_expand
312 db_path = resolve_history_db(project_root / DEFAULT_DB_PATH)
313 conn = None
314 try:
315 import sqlite3
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()
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
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
341 if args.json:
342 messages = ll_expand(root_id, db=db_path) if args.expand else []
343 from dataclasses import asdict
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]}")
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
369 if args.command == "export":
370 from datetime import date as date_type
372 from little_loops.issue_history.analysis import _load_issue_contents
374 issues = scan_completed_issues(issues_dir)
375 contents = _load_issue_contents(issues)
377 since_date = None
378 if args.since:
379 since_date = date_type.fromisoformat(args.since)
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 )
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)
399 return 0
401 return 1