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
« 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."""
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_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
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)
224 add_intent_arg(parser)
225 add_intent_limit_arg(parser)
227 args = parser.parse_args()
229 if not args.command:
230 parser.print_help()
231 return 1
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 )
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)
251 if args.json:
252 print(format_summary_json(summary))
253 else:
254 print(format_summary_text(summary))
256 return 0
258 if args.command == "analyze":
259 # New analyze logic (FEAT-110)
260 from datetime import date as date_type
262 db_path = resolve_history_db(project_root / DEFAULT_DB_PATH)
263 issues = scan_completed_issues(issues_dir)
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 ]
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 )
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))
294 return 0
296 if args.command == "sessions":
297 from little_loops.history_reader import sessions_for_issue
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
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
313 if args.command == "root":
314 from little_loops.history_reader import ll_describe, ll_expand
316 db_path = resolve_history_db(project_root / DEFAULT_DB_PATH)
317 conn = None
318 try:
319 import sqlite3
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()
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
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
345 if args.json:
346 messages = ll_expand(root_id, db=db_path) if args.expand else []
347 from dataclasses import asdict
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]}")
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
373 if args.command == "export":
374 from datetime import date as date_type
376 from little_loops.issue_history.analysis import _load_issue_contents
378 issues = scan_completed_issues(issues_dir)
379 contents = _load_issue_contents(issues)
381 since_date = None
382 if args.since:
383 since_date = date_type.fromisoformat(args.since)
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 )
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)
403 return 0
405 return 1