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

144 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-05-22 16:19 -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 %(prog)s reopen BUG-042 # Reopen GitHub issue for BUG-042 

37 %(prog)s reopen --all-reopened # Reopen all issues moved back to active locally 

38""", 

39 ) 

40 

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

42 

43 # Status subcommand 

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

45 

46 # Push subcommand 

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

48 push_parser.add_argument( 

49 "issue_ids", 

50 nargs="*", 

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

52 ) 

53 

54 # Pull subcommand 

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

56 pull_parser.add_argument( 

57 "--labels", 

58 "-l", 

59 type=str, 

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

61 ) 

62 

63 # Diff subcommand 

64 diff_parser = subparsers.add_parser( 

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

66 ) 

67 diff_parser.add_argument( 

68 "issue_id", 

69 nargs="?", 

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

71 ) 

72 

73 # Close subcommand 

74 close_parser = subparsers.add_parser( 

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

76 ) 

77 close_parser.add_argument( 

78 "issue_ids", 

79 nargs="*", 

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

81 ) 

82 close_parser.add_argument( 

83 "--all-completed", 

84 action="store_true", 

85 help="Close all GitHub issues whose local counterparts have status: done or status: cancelled", 

86 ) 

87 

88 # Reopen subcommand 

89 reopen_parser = subparsers.add_parser( 

90 "reopen", help="Reopen GitHub issues for locally-active issues" 

91 ) 

92 reopen_parser.add_argument( 

93 "issue_ids", 

94 nargs="*", 

95 help="Specific issue IDs to reopen (e.g., BUG-042)", 

96 ) 

97 reopen_parser.add_argument( 

98 "--all-reopened", 

99 action="store_true", 

100 help="Reopen all GitHub issues whose local counterparts have moved back to active", 

101 ) 

102 

103 # Common args 

104 add_config_arg(parser) 

105 add_quiet_arg(parser) 

106 add_dry_run_arg(parser) 

107 

108 args = parser.parse_args() 

109 

110 if not args.action: 

111 parser.print_help() 

112 return 1 

113 

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

115 config = BRConfig(project_root) 

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

117 

118 # Check sync is enabled 

119 if not config.sync.enabled: 

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

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

122 return 1 

123 

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

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

126 

127 if args.action == "status": 

128 status = manager.get_status() 

129 _print_sync_status(status, logger) 

130 return 0 

131 

132 elif args.action == "push": 

133 if dry_run: 

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

135 issue_ids = args.issue_ids if args.issue_ids else None 

136 result = manager.push_issues(issue_ids) 

137 _print_sync_result(result, logger) 

138 return 0 if result.success else 1 

139 

140 elif args.action == "pull": 

141 if dry_run: 

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

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

144 result = manager.pull_issues(labels) 

145 _print_sync_result(result, logger) 

146 return 0 if result.success else 1 

147 

148 elif args.action == "diff": 

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

150 if issue_id: 

151 result = manager.diff_issue(issue_id) 

152 _print_diff_result(result, logger) 

153 else: 

154 result = manager.diff_all() 

155 _print_sync_result(result, logger) 

156 return 0 if result.success else 1 

157 

158 elif args.action == "close": 

159 if dry_run: 

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

161 issue_ids = args.issue_ids if args.issue_ids else None 

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

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

164 _print_sync_result(result, logger) 

165 return 0 if result.success else 1 

166 

167 elif args.action == "reopen": 

168 if dry_run: 

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

170 issue_ids = args.issue_ids if args.issue_ids else None 

171 all_reopened = getattr(args, "all_reopened", False) 

172 result = manager.reopen_issues(issue_ids, all_reopened=all_reopened) 

173 _print_sync_result(result, logger) 

174 return 0 if result.success else 1 

175 

176 return 1 

177 

178 

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

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

181 logger.info("=" * 80) 

182 logger.info("SYNC STATUS") 

183 logger.info("=" * 80) 

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

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

186 logger.info("") 

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

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

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

190 logger.info("") 

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

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

193 if status.github_error: 

194 logger.info("") 

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

196 logger.info("=" * 80) 

197 

198 

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

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

201 logger.info("=" * 80) 

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

203 logger.info("=" * 80) 

204 logger.info("") 

205 logger.info("## SUMMARY") 

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

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

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

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

210 logger.info("") 

211 if result.created: 

212 logger.info("## CREATED") 

213 for item in result.created: 

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

215 logger.info("") 

216 if result.updated: 

217 logger.info("## UPDATED") 

218 for item in result.updated: 

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

220 logger.info("") 

221 if result.failed: 

222 logger.info("## FAILED") 

223 for issue_id, reason in result.failed: 

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

225 logger.info("") 

226 if result.errors: 

227 logger.info("## ERRORS") 

228 for error in result.errors: 

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

230 logger.info("=" * 80) 

231 

232 

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

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

235 if result.errors: 

236 for error in result.errors: 

237 logger.error(error) 

238 return 

239 

240 if result.skipped: 

241 for item in result.skipped: 

242 logger.info(item) 

243 return 

244 

245 if result.updated: 

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

247 logger.info("") 

248 

249 # Diff lines are stored in created field 

250 for line in result.created: 

251 logger.info(line)