Coverage for little_loops / cli / sync.py: 8%

133 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-03-18 16:18 -0500

1"""ll-sync: GitHub Issues sync.""" 

2 

3from __future__ import annotations 

4 

5import argparse 

6from pathlib import Path 

7 

8from little_loops.cli_args import add_config_arg, add_dry_run_arg, add_quiet_arg 

9from little_loops.config import BRConfig 

10from little_loops.logger import Logger 

11from little_loops.sync import GitHubSyncManager, SyncResult, SyncStatus 

12 

13 

14def main_sync() -> int: 

15 """Entry point for ll-sync command. 

16 

17 Sync local issues with GitHub Issues. 

18 

19 Returns: 

20 Exit code (0 = success) 

21 """ 

22 parser = argparse.ArgumentParser( 

23 prog="ll-sync", 

24 description="Sync local .issues/ files with GitHub Issues", 

25 formatter_class=argparse.RawDescriptionHelpFormatter, 

26 epilog=""" 

27Examples: 

28 %(prog)s status # Show sync status 

29 %(prog)s push # Push all local issues to GitHub 

30 %(prog)s push BUG-123 # Push specific issue 

31 %(prog)s pull # Pull GitHub Issues to local 

32 %(prog)s diff BUG-123 # Show diff for specific issue 

33 %(prog)s diff # Show diff summary for all synced issues 

34 %(prog)s close ENH-123 # Close GitHub issue for ENH-123 

35 %(prog)s close --all-completed # Close all completed issues on GitHub 

36""", 

37 ) 

38 

39 subparsers = parser.add_subparsers(dest="action", help="Sync action") 

40 

41 # Status subcommand 

42 subparsers.add_parser("status", help="Show sync status") 

43 

44 # Push subcommand 

45 push_parser = subparsers.add_parser("push", help="Push local issues to GitHub") 

46 push_parser.add_argument( 

47 "issue_ids", 

48 nargs="*", 

49 help="Specific issue IDs to push (e.g., BUG-123)", 

50 ) 

51 

52 # Pull subcommand 

53 pull_parser = subparsers.add_parser("pull", help="Pull GitHub Issues to local") 

54 pull_parser.add_argument( 

55 "--labels", 

56 "-l", 

57 type=str, 

58 help="Filter by labels (comma-separated)", 

59 ) 

60 

61 # Diff subcommand 

62 diff_parser = subparsers.add_parser( 

63 "diff", help="Show differences between local and GitHub issues" 

64 ) 

65 diff_parser.add_argument( 

66 "issue_id", 

67 nargs="?", 

68 help="Specific issue ID to diff (e.g., BUG-123). Omit for summary of all.", 

69 ) 

70 

71 # Close subcommand 

72 close_parser = subparsers.add_parser( 

73 "close", help="Close GitHub issues for completed local issues" 

74 ) 

75 close_parser.add_argument( 

76 "issue_ids", 

77 nargs="*", 

78 help="Specific issue IDs to close (e.g., ENH-123)", 

79 ) 

80 close_parser.add_argument( 

81 "--all-completed", 

82 action="store_true", 

83 help="Close all GitHub issues whose local counterparts are in completed/", 

84 ) 

85 

86 # Common args 

87 add_config_arg(parser) 

88 add_quiet_arg(parser) 

89 add_dry_run_arg(parser) 

90 

91 args = parser.parse_args() 

92 

93 if not args.action: 

94 parser.print_help() 

95 return 1 

96 

97 project_root = args.config or Path.cwd() 

98 config = BRConfig(project_root) 

99 logger = Logger(verbose=not getattr(args, "quiet", False)) 

100 

101 # Check sync is enabled 

102 if not config.sync.enabled: 

103 logger.error("Sync is not enabled. Add to .claude/ll-config.json:") 

104 logger.error(' "sync": { "enabled": true }') 

105 return 1 

106 

107 dry_run = getattr(args, "dry_run", False) 

108 manager = GitHubSyncManager(config, logger, dry_run=dry_run) 

109 

110 if args.action == "status": 

111 status = manager.get_status() 

112 _print_sync_status(status, logger) 

113 return 0 

114 

115 elif args.action == "push": 

116 if dry_run: 

117 logger.info("[DRY RUN] Showing what would be pushed (no changes will be made)") 

118 issue_ids = args.issue_ids if args.issue_ids else None 

119 result = manager.push_issues(issue_ids) 

120 _print_sync_result(result, logger) 

121 return 0 if result.success else 1 

122 

123 elif args.action == "pull": 

124 if dry_run: 

125 logger.info("[DRY RUN] Showing what would be pulled (no changes will be made)") 

126 labels = args.labels.split(",") if args.labels else None 

127 result = manager.pull_issues(labels) 

128 _print_sync_result(result, logger) 

129 return 0 if result.success else 1 

130 

131 elif args.action == "diff": 

132 issue_id = getattr(args, "issue_id", None) 

133 if issue_id: 

134 result = manager.diff_issue(issue_id) 

135 _print_diff_result(result, logger) 

136 else: 

137 result = manager.diff_all() 

138 _print_sync_result(result, logger) 

139 return 0 if result.success else 1 

140 

141 elif args.action == "close": 

142 if dry_run: 

143 logger.info("[DRY RUN] Showing what would be closed (no changes will be made)") 

144 issue_ids = args.issue_ids if args.issue_ids else None 

145 all_completed = getattr(args, "all_completed", False) 

146 result = manager.close_issues(issue_ids, all_completed=all_completed) 

147 _print_sync_result(result, logger) 

148 return 0 if result.success else 1 

149 

150 return 1 

151 

152 

153def _print_sync_status(status: SyncStatus, logger: Logger) -> None: 

154 """Print sync status in formatted output.""" 

155 logger.info("=" * 80) 

156 logger.info("SYNC STATUS") 

157 logger.info("=" * 80) 

158 logger.info(f"Provider: {status.provider}") 

159 logger.info(f"Repository: {status.repo}") 

160 logger.info("") 

161 logger.info(f"Local Issues: {status.local_total}") 

162 logger.info(f"Synced to GitHub: {status.local_synced}") 

163 logger.info(f"GitHub Issues: {status.github_total}") 

164 logger.info("") 

165 logger.info(f"Unsynced local: {status.local_unsynced} (local only, not on GitHub)") 

166 logger.info(f"GitHub-only: {status.github_only} (on GitHub, not local)") 

167 if status.github_error: 

168 logger.info("") 

169 logger.warning(f"GitHub data may be incomplete: {status.github_error}") 

170 logger.info("=" * 80) 

171 

172 

173def _print_sync_result(result: SyncResult, logger: Logger) -> None: 

174 """Print sync result in formatted output.""" 

175 logger.info("=" * 80) 

176 logger.info(f"SYNC {result.action.upper()} {'COMPLETE' if result.success else 'FAILED'}") 

177 logger.info("=" * 80) 

178 logger.info("") 

179 logger.info("## SUMMARY") 

180 logger.info(f"- Created: {len(result.created)}") 

181 logger.info(f"- Updated: {len(result.updated)}") 

182 logger.info(f"- Skipped: {len(result.skipped)}") 

183 logger.info(f"- Failed: {len(result.failed)}") 

184 logger.info("") 

185 if result.created: 

186 logger.info("## CREATED") 

187 for item in result.created: 

188 logger.info(f" - {item}") 

189 logger.info("") 

190 if result.updated: 

191 logger.info("## UPDATED") 

192 for item in result.updated: 

193 logger.info(f" - {item}") 

194 logger.info("") 

195 if result.failed: 

196 logger.info("## FAILED") 

197 for issue_id, reason in result.failed: 

198 logger.error(f" - {issue_id}: {reason}") 

199 logger.info("") 

200 if result.errors: 

201 logger.info("## ERRORS") 

202 for error in result.errors: 

203 logger.error(f" - {error}") 

204 logger.info("=" * 80) 

205 

206 

207def _print_diff_result(result: SyncResult, logger: Logger) -> None: 

208 """Print diff result showing unified diff output.""" 

209 if result.errors: 

210 for error in result.errors: 

211 logger.error(error) 

212 return 

213 

214 if result.skipped: 

215 for item in result.skipped: 

216 logger.info(item) 

217 return 

218 

219 if result.updated: 

220 logger.info(result.updated[0]) 

221 logger.info("") 

222 

223 # Diff lines are stored in created field 

224 for line in result.created: 

225 logger.info(line)