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

85 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-05-22 16:19 -0500

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

2 

3from __future__ import annotations 

4 

5import argparse 

6from pathlib import Path 

7 

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

9from little_loops.cli_args import parse_issue_ids 

10from little_loops.logger import Logger 

11from little_loops.sprint import SprintManager 

12 

13 

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

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

16 logger = Logger() 

17 sprint = manager.load(args.sprint) 

18 if not sprint: 

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

20 return 1 

21 

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

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

24 return 1 

25 

26 original_issues = list(sprint.issues) 

27 changed = False 

28 

29 # --add: add new issue IDs 

30 if args.add: 

31 add_ids = parse_issue_ids(args.add) 

32 if add_ids: 

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

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

35 if invalid: 

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

37 

38 existing = set(sprint.issues) 

39 added = [] 

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

41 if issue_id not in existing: 

42 sprint.issues.append(issue_id) 

43 added.append(issue_id) 

44 else: 

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

46 if added: 

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

48 changed = True 

49 

50 # --remove: remove issue IDs 

51 if args.remove: 

52 remove_ids = parse_issue_ids(args.remove) 

53 if remove_ids: 

54 before = len(sprint.issues) 

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

56 removed_count = before - len(sprint.issues) 

57 not_found = remove_ids - set(original_issues) 

58 if not_found: 

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

60 if removed_count > 0: 

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

62 changed = True 

63 

64 # --prune: remove invalid and completed references 

65 if args.prune: 

66 valid = manager.validate_issues(sprint.issues) 

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

68 

69 # Also detect completed issues via frontmatter status (ENH-1424) 

70 from little_loops.frontmatter import parse_frontmatter 

71 

72 completed_ids = { 

73 issue_id 

74 for issue_id, path in valid.items() 

75 if parse_frontmatter(path.read_text(encoding="utf-8")).get("status", "open") 

76 in ("done", "cancelled") 

77 } 

78 

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

80 if prune_ids: 

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

82 pruned_invalid = invalid_ids & prune_ids 

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

84 if pruned_invalid: 

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

86 if pruned_completed: 

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

88 changed = True 

89 else: 

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

91 

92 # Save if changed 

93 if changed: 

94 sprint.save(manager.sprints_dir) 

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

96 if original_issues != sprint.issues: 

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

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

99 

100 # --revalidate: re-run dependency analysis 

101 if args.revalidate: 

102 valid = manager.validate_issues(sprint.issues) 

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

104 if issue_infos: 

105 from little_loops.dependency_mapper import ( 

106 analyze_dependencies, 

107 gather_all_issue_ids, 

108 ) 

109 

110 _config = manager.config 

111 _issues_dir = ( 

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

113 ) 

114 _all_known_ids = gather_all_issue_ids(_issues_dir, config=_config) 

115 issue_contents = _build_issue_contents(issue_infos) 

116 dep_report = analyze_dependencies( 

117 issue_infos, issue_contents, all_known_ids=_all_known_ids 

118 ) 

119 _render_dependency_analysis(dep_report, logger) 

120 else: 

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

122 

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

124 if invalid: 

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

126 

127 return 0