Coverage for little_loops / cli / issues / set_status.py: 0%
65 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-26 17:38 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-26 17:38 -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 follows ``parent:`` edges **only**, transitively. Association
22 edges (``relates_to:``, ``blocked_by:``) are non-hierarchical and never
23 trigger a cascade — cascading through them silently mutated unrelated
24 issues, including sibling epics (BUG-2265).
26 Args:
27 config: Project configuration
28 args: Parsed arguments with .issue_id, .status, .cascade, .cascade_to
30 Returns:
31 Exit code (0 = success, 1 = error); exit 1 if any child update fails.
32 """
33 from little_loops.cli.issues.show import _resolve_issue_id
34 from little_loops.frontmatter import parse_frontmatter, update_frontmatter
35 from little_loops.issue_progress import _OPEN_STATUSES, _TERMINAL_STATUSES
37 path = _resolve_issue_id(config, args.issue_id)
38 if path is None:
39 print(f"Error: Issue '{args.issue_id}' not found.", file=sys.stderr)
40 return 1
42 # Validate cascade before making any changes
43 if getattr(args, "cascade", False):
44 if args.status not in _TERMINAL_STATUSES:
45 print(
46 f"Error: --cascade is only valid when target status is done or "
47 f"cancelled, got '{args.status}'.",
48 file=sys.stderr,
49 )
50 return 1
52 content = path.read_text()
53 old_status = parse_frontmatter(content).get("status", "unknown")
54 new_content = update_frontmatter(content, {"status": args.status})
55 path.write_text(new_content)
56 print(f"{args.issue_id}: {old_status} → {args.status}")
58 # Capture content snapshot on status transition (Decision 2: Option C — direct call,
59 # same pattern as user_prompt_submit.py calling record_correction() without EventBus).
60 try:
61 from little_loops.session_store import record_issue_snapshot, resolve_history_db
63 db_path = resolve_history_db()
64 record_issue_snapshot(db_path, args.issue_id, args.status, str(path))
65 except Exception:
66 pass
68 # Cascade to children
69 if getattr(args, "cascade", False):
70 fm = parse_frontmatter(content)
71 epic_id = fm.get("id", args.issue_id).upper()
73 from little_loops.issue_parser import find_issues
75 all_issues = find_issues(config)
77 # Cascade follows parent: → child edges ONLY, transitively. relates_to:
78 # and blocked_by: are non-hierarchical association edges; cascading
79 # through them silently flipped the status of unrelated issues —
80 # including sibling epics — during routine epic closure (BUG-2265).
81 children_by_parent: dict[str, list] = {}
82 for i in all_issues:
83 if i.parent:
84 children_by_parent.setdefault(i.parent.upper(), []).append(i)
86 # Transitive closure over parent edges, breadth-first from the epic.
87 descendants: list = []
88 seen: set[str] = {epic_id}
89 queue = list(children_by_parent.get(epic_id, []))
90 while queue:
91 child = queue.pop(0)
92 cid = child.issue_id.upper()
93 if cid in seen:
94 continue
95 seen.add(cid)
96 descendants.append(child)
97 queue.extend(children_by_parent.get(cid, []))
99 active = [c for c in descendants if c.status in _OPEN_STATUSES]
100 skipped = [c for c in descendants if c not in active]
102 print(f" Cascading to {len(active)} active parent-children (default: {args.cascade_to}):")
104 failures = 0
105 for child in active:
106 try:
107 child_content = child.path.read_text()
108 child_new = update_frontmatter(child_content, {"status": args.cascade_to})
109 child.path.write_text(child_new)
110 print(f" {child.issue_id} → {args.cascade_to}")
111 except OSError as exc:
112 print(f" {child.issue_id}: FAILED ({exc})", file=sys.stderr)
113 failures += 1
115 if skipped:
116 print(f" ({len(skipped)} children already terminal/other — unchanged)")
118 if failures:
119 return 1
121 return 0