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

2

3 from __future__ import annotations

4

5 import argparse

6 import sys

7 from pathlib import Path

8

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

10 from little_loops.cli_args import add_config_arg, add_intent_arg, add_intent_limit_arg, add_json_arg

11 from little_loops.config import BRConfig

12 from little_loops.logger import Logger

13 from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context, resolve_history_db

14

15

16 def 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="""

44 Examples:

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