|
6 | 6 | import shutil |
7 | 7 | import subprocess |
8 | 8 | import sys |
9 | | -from datetime import datetime, timezone |
| 9 | +from datetime import datetime, time, timezone |
10 | 10 | from pathlib import Path |
11 | 11 | from typing import Any |
12 | 12 | from uuid import uuid4 |
@@ -125,6 +125,29 @@ def _session_sort_key(item: tuple[Path, dict[str, Any]]) -> str: |
125 | 125 | return str(payload.get("ended_at") or payload.get("started_at") or path.name) |
126 | 126 |
|
127 | 127 |
|
| 128 | +def _parse_iso_datetime(value: object) -> datetime | None: |
| 129 | + if not isinstance(value, str) or not value: |
| 130 | + return None |
| 131 | + normalized = value.replace("Z", "+00:00") |
| 132 | + try: |
| 133 | + parsed = datetime.fromisoformat(normalized) |
| 134 | + except ValueError: |
| 135 | + return None |
| 136 | + if parsed.tzinfo is None: |
| 137 | + return parsed.replace(tzinfo=timezone.utc) |
| 138 | + return parsed.astimezone(timezone.utc) |
| 139 | + |
| 140 | + |
| 141 | +def _parse_since(value: str | None) -> datetime | None: |
| 142 | + if value is None: |
| 143 | + return None |
| 144 | + try: |
| 145 | + parsed_date = datetime.strptime(value, "%Y-%m-%d").date() |
| 146 | + except ValueError as exc: |
| 147 | + raise ValueError("--since must use YYYY-MM-DD") from exc |
| 148 | + return datetime.combine(parsed_date, time.min, tzinfo=timezone.utc) |
| 149 | + |
| 150 | + |
128 | 151 | def _collect_sessions(root: Path) -> tuple[list[tuple[Path, dict[str, Any]]], int]: |
129 | 152 | sessions: list[tuple[Path, dict[str, Any]]] = [] |
130 | 153 | skipped = 0 |
@@ -157,6 +180,28 @@ def _dirty_count(snapshot: dict[str, Any]) -> int: |
157 | 180 | return len(dirty) if isinstance(dirty, list) else 0 |
158 | 181 |
|
159 | 182 |
|
| 183 | +def _snapshot(payload: dict[str, Any]) -> dict[str, Any]: |
| 184 | + if isinstance(payload.get("end"), dict): |
| 185 | + return payload["end"] |
| 186 | + if isinstance(payload.get("start"), dict): |
| 187 | + return payload["start"] |
| 188 | + return {} |
| 189 | + |
| 190 | + |
| 191 | +def _branch(snapshot: dict[str, Any]) -> str | None: |
| 192 | + git = snapshot.get("git") |
| 193 | + if isinstance(git, dict) and isinstance(git.get("branch"), str): |
| 194 | + return git["branch"] |
| 195 | + return None |
| 196 | + |
| 197 | + |
| 198 | +def _next_step(snapshot: dict[str, Any]) -> str | None: |
| 199 | + dogfood = snapshot.get("dogfood") |
| 200 | + if isinstance(dogfood, dict) and isinstance(dogfood.get("next"), str): |
| 201 | + return dogfood["next"] |
| 202 | + return None |
| 203 | + |
| 204 | + |
160 | 205 | def _display_session(path: Path, payload: dict[str, Any]) -> None: |
161 | 206 | print(f"session: {path}") |
162 | 207 | print(f"id: {payload.get('id', path.name)}") |
@@ -465,6 +510,72 @@ def show(*, target: Path, session: str | Path) -> int: |
465 | 510 | return 0 |
466 | 511 |
|
467 | 512 |
|
| 513 | +def recap(*, target: Path, limit: int = 5, since: str | None = None) -> int: |
| 514 | + if limit < 1: |
| 515 | + print("error: --limit must be a positive integer", file=sys.stderr) |
| 516 | + return 2 |
| 517 | + try: |
| 518 | + since_dt = _parse_since(since) |
| 519 | + except ValueError as exc: |
| 520 | + print(f"error: {exc}", file=sys.stderr) |
| 521 | + return 2 |
| 522 | + |
| 523 | + target = target.expanduser().resolve() |
| 524 | + if not target.is_dir(): |
| 525 | + print(f"error: --target is not a directory: {target}", file=sys.stderr) |
| 526 | + return 2 |
| 527 | + root = _work_root(target) |
| 528 | + sessions, skipped = _collect_sessions(root) |
| 529 | + if since_dt is not None: |
| 530 | + sessions = [ |
| 531 | + (path, payload) |
| 532 | + for path, payload in sessions |
| 533 | + if (_parse_iso_datetime(payload.get("ended_at") or payload.get("started_at")) or datetime.min.replace(tzinfo=timezone.utc)) |
| 534 | + >= since_dt |
| 535 | + ] |
| 536 | + sessions = sessions[:limit] |
| 537 | + |
| 538 | + print(f"work recap: {target}") |
| 539 | + if since: |
| 540 | + print(f"since: {since}") |
| 541 | + print(f"sessions: {len(sessions)}") |
| 542 | + if skipped: |
| 543 | + print(f"skipped: {skipped}", file=sys.stderr) |
| 544 | + if not sessions: |
| 545 | + print(f"no work sessions found in {root}") |
| 546 | + return 0 |
| 547 | + |
| 548 | + branches = sorted({branch for _, payload in sessions if (branch := _branch(_snapshot(payload)))}) |
| 549 | + if branches: |
| 550 | + print(f"branches: {', '.join(branches)}") |
| 551 | + handoffs = [str(payload.get("handoff")) for _, payload in sessions if payload.get("handoff")] |
| 552 | + if handoffs: |
| 553 | + print(f"handoffs: {len(handoffs)}") |
| 554 | + |
| 555 | + print("items:") |
| 556 | + for path, payload in sessions: |
| 557 | + snapshot = _snapshot(payload) |
| 558 | + title = str(payload.get("title") or payload.get("id") or path.name) |
| 559 | + print(f"- {title}") |
| 560 | + print(f" id: {payload.get('id', path.name)}") |
| 561 | + print(f" status: {payload.get('status', 'unknown')}") |
| 562 | + print(f" started: {payload.get('started_at', '')}") |
| 563 | + if payload.get("ended_at"): |
| 564 | + print(f" ended: {payload['ended_at']}") |
| 565 | + branch = _branch(snapshot) |
| 566 | + if branch: |
| 567 | + print(f" branch: {branch}") |
| 568 | + print(f" dirty_files: {_dirty_count(snapshot)}") |
| 569 | + if payload.get("note"): |
| 570 | + print(f" note: {_short(str(payload['note']))}") |
| 571 | + if payload.get("handoff"): |
| 572 | + print(f" handoff: {payload['handoff']}") |
| 573 | + next_text = _next_step(snapshot) |
| 574 | + if next_text: |
| 575 | + print(f" next: {_short(next_text)}") |
| 576 | + return 0 |
| 577 | + |
| 578 | + |
468 | 579 | def status(*, target: Path, limit: int = 12) -> int: |
469 | 580 | if limit < 1: |
470 | 581 | print("error: --limit must be a positive integer", file=sys.stderr) |
|
0 commit comments