Coverage for little_loops/cli/sprint/edit.py: 9%

1"""ll-sprint edit subcommand."""

2

3 from __future__ import annotations

4

5 import argparse

6 from pathlib import Path

7

8 from little_loops.cli.sprint._helpers import _build_issue_contents, _render_dependency_analysis

9 from little_loops.cli_args import parse_issue_ids

10 from little_loops.logger import Logger

11 from little_loops.sprint import SprintManager

12

13

14 def _cmd_sprint_edit(args: argparse.Namespace, manager: SprintManager) -> int:

15 """Edit a sprint's issue list."""

16 import re

17

18 logger = Logger()

19 sprint = manager.load(args.sprint)

20 if not sprint:

21 logger.error(f"Sprint not found: {args.sprint}")

22 return 1

23

24 if not args.add and not args.remove and not args.prune and not args.revalidate:

25 logger.error("No edit flags specified. Use --add, --remove, --prune, or --revalidate.")

26 return 1

27

28 original_issues = list(sprint.issues)

29 changed = False

30

31 # --add: add new issue IDs

32 if args.add:

33 add_ids = parse_issue_ids(args.add)

34 if add_ids:

35 valid = manager.validate_issues(list(add_ids))

36 invalid = add_ids - set(valid.keys())

37 if invalid:

38 logger.warning(f"Issue IDs not found (skipping): {', '.join(sorted(invalid))}")

39

40 existing = set(sprint.issues)

41 added = []

42 for issue_id in sorted(valid.keys()):

43 if issue_id not in existing:

44 sprint.issues.append(issue_id)

45 added.append(issue_id)

46 else:

47 logger.info(f"Already in sprint: {issue_id}")

48 if added:

49 logger.success(f"Added: {', '.join(added)}")

50 changed = True

51

52 # --remove: remove issue IDs

53 if args.remove:

54 remove_ids = parse_issue_ids(args.remove)

55 if remove_ids:

56 before = len(sprint.issues)

57 sprint.issues = [i for i in sprint.issues if i not in remove_ids]

58 removed_count = before - len(sprint.issues)

59 not_found = remove_ids - set(original_issues)

60 if not_found:

61 logger.warning(f"Not in sprint: {', '.join(sorted(not_found))}")

62 if removed_count > 0:

63 logger.success(f"Removed {removed_count} issue(s)")

64 changed = True

65

66 # --prune: remove invalid and completed references

67 if args.prune:

68 valid = manager.validate_issues(sprint.issues)

69 invalid_ids = set(sprint.issues) - set(valid.keys())

70

71 # Also detect completed issues

72 completed_ids: set[str] = set()

73 if manager.config:

74 completed_dir = manager.config.get_completed_dir()

75 if completed_dir.exists():

76 for path in completed_dir.glob("*.md"):

77 match = re.search(r"(BUG|FEAT|ENH)-(\d+)", path.name)

78 if match:

79 completed_ids.add(f"{match.group(1)}-{match.group(2)}")

80

81 prune_ids = invalid_ids | (completed_ids & set(sprint.issues))

82 if prune_ids:

83 sprint.issues = [i for i in sprint.issues if i not in prune_ids]

84 pruned_invalid = invalid_ids & prune_ids

85 pruned_completed = (completed_ids & set(original_issues)) - invalid_ids

86 if pruned_invalid:

87 logger.success(f"Pruned invalid: {', '.join(sorted(pruned_invalid))}")

88 if pruned_completed:

89 logger.success(f"Pruned completed: {', '.join(sorted(pruned_completed))}")

90 changed = True

91 else:

92 logger.info("Nothing to prune — all issues are valid and active")

93

94 # Save if changed

95 if changed:

96 sprint.save(manager.sprints_dir)

97 logger.success(f"Saved {args.sprint} ({len(sprint.issues)} issues)")

98 if original_issues != sprint.issues:

99 logger.info(f" Was: {', '.join(original_issues)}")

100 logger.info(f" Now: {', '.join(sprint.issues)}")

101

102 # --revalidate: re-run dependency analysis

103 if args.revalidate:

104 valid = manager.validate_issues(sprint.issues)

105 issue_infos = manager.load_issue_infos(list(valid.keys()))

106 if issue_infos:

107 from little_loops.dependency_mapper import (

108 analyze_dependencies,

109 gather_all_issue_ids,

110 )

111

112 _config = manager.config

113 _issues_dir = (

114 _config.project_root / _config.issues.base_dir if _config else Path(".issues")

115 )

116 _all_known_ids = gather_all_issue_ids(_issues_dir, config=_config)

117 issue_contents = _build_issue_contents(issue_infos)

118 dep_report = analyze_dependencies(

119 issue_infos, issue_contents, all_known_ids=_all_known_ids

120 )

121 _render_dependency_analysis(dep_report, logger)

122 else:

123 logger.info("No valid issues to analyze")

124

125 invalid = set(sprint.issues) - set(valid.keys())

126 if invalid:

127 logger.warning(f"{len(invalid)} issue(s) not found: {', '.join(sorted(invalid))}")

128

129 return 0