Coverage for little_loops / cli / issues / set_status.py: 0%
54 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-15 17:27 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-15 17:27 -0500
1"""ll-issues set-status: Transition an issue to a new status value."""
3from __future__ import annotations
5import argparse
6import sys
7from typing import TYPE_CHECKING
9if TYPE_CHECKING:
10 from little_loops.config import BRConfig
13def cmd_set_status(config: BRConfig, args: argparse.Namespace) -> int:
14 """Write a new status value into an issue's YAML frontmatter.
16 Validates the target status against the canonical enum before writing.
17 Prints the before→after transition to stdout on success.
19 When ``--cascade`` is set, also propagates the status to active children
20 (those with status ``open``, ``in_progress``, or ``blocked``). Child
21 resolution uses union of ``relates_to:`` (forward) + ``parent:`` (backward)
22 lookups.
24 Args:
25 config: Project configuration
26 args: Parsed arguments with .issue_id, .status, .cascade, .cascade_to
28 Returns:
29 Exit code (0 = success, 1 = error); exit 1 if any child update fails.
30 """
31 from little_loops.cli.issues.show import _resolve_issue_id
32 from little_loops.frontmatter import parse_frontmatter, update_frontmatter
33 from little_loops.issue_progress import _OPEN_STATUSES, _TERMINAL_STATUSES
35 path = _resolve_issue_id(config, args.issue_id)
36 if path is None:
37 print(f"Error: Issue '{args.issue_id}' not found.", file=sys.stderr)
38 return 1
40 # Validate cascade before making any changes
41 if getattr(args, "cascade", False):
42 if args.status not in _TERMINAL_STATUSES:
43 print(
44 f"Error: --cascade is only valid when target status is done or "
45 f"cancelled, got '{args.status}'.",
46 file=sys.stderr,
47 )
48 return 1
50 content = path.read_text()
51 old_status = parse_frontmatter(content).get("status", "unknown")
52 new_content = update_frontmatter(content, {"status": args.status})
53 path.write_text(new_content)
54 print(f"{args.issue_id}: {old_status} → {args.status}")
56 # Capture content snapshot on status transition (Decision 2: Option C — direct call,
57 # same pattern as user_prompt_submit.py calling record_correction() without EventBus).
58 try:
59 from little_loops.session_store import record_issue_snapshot, resolve_history_db
61 db_path = resolve_history_db()
62 record_issue_snapshot(db_path, args.issue_id, args.status, str(path))
63 except Exception:
64 pass
66 # Cascade to children
67 if getattr(args, "cascade", False):
68 fm = parse_frontmatter(content)
69 epic_id = fm.get("id", args.issue_id).upper()
71 from little_loops.issue_parser import find_issues
73 all_issues = find_issues(config)
75 # Union of forward (relates_to:) + backward (parent:)
76 forward_ids: set[str] = set(fm.get("relates_to", []))
77 backward_ids: set[str] = {
78 i.issue_id for i in all_issues if i.parent and i.parent.upper() == epic_id
79 }
80 child_ids = forward_ids | backward_ids
82 children = [i for i in all_issues if i.issue_id.upper() in child_ids]
83 active = [c for c in children if c.status in _OPEN_STATUSES]
84 skipped = [c for c in children if c not in active]
86 print(f" Cascading to {len(active)} active children (default: {args.cascade_to}):")
88 failures = 0
89 for child in active:
90 try:
91 child_content = child.path.read_text()
92 child_new = update_frontmatter(child_content, {"status": args.cascade_to})
93 child.path.write_text(child_new)
94 print(f" {child.issue_id} → {args.cascade_to}")
95 except OSError as exc:
96 print(f" {child.issue_id}: FAILED ({exc})", file=sys.stderr)
97 failures += 1
99 if skipped:
100 print(f" ({len(skipped)} children already terminal/other — unchanged)")
102 if failures:
103 return 1
105 return 0