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

162 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-26 17:38 -0500

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

2 

3from __future__ import annotations 

4 

5import argparse 

6import sys 

7from pathlib import Path 

8 

9from little_loops.cli.output import print_json 

10from little_loops.cli_args import add_config_arg, add_dry_run_arg, add_json_arg, add_quiet_arg 

11from little_loops.config import BRConfig 

12from little_loops.logger import Logger 

13from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context 

14from little_loops.sync import GitHubSyncManager, SyncResult, SyncStatus 

15 

16 

17def main_sync() -> int: 

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

19 

20 Sync local issues with GitHub Issues. 

21 

22 Returns: 

23 Exit code (0 = success) 

24 """ 

25 with cli_event_context(DEFAULT_DB_PATH, "ll-sync", sys.argv[1:]): 

26 parser = argparse.ArgumentParser( 

27 prog="ll-sync", 

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

29 formatter_class=argparse.RawDescriptionHelpFormatter, 

30 epilog=""" 

31Examples: 

32 %(prog)s status # Show sync status 

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

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

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

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

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

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

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

40 %(prog)s reopen BUG-042 # Reopen GitHub issue for BUG-042 

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

42 %(prog)s reconcile # Promote feature-branch issues to done when PR is merged 

43""", 

44 ) 

45 

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

47 

48 # Status subcommand 

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

50 add_json_arg(status_parser) 

51 

52 # Push subcommand 

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

54 push_parser.add_argument( 

55 "issue_ids", 

56 nargs="*", 

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

58 ) 

59 

60 # Pull subcommand 

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

62 pull_parser.add_argument( 

63 "--labels", 

64 "-l", 

65 type=str, 

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

67 ) 

68 

69 # Diff subcommand 

70 diff_parser = subparsers.add_parser( 

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

72 ) 

73 diff_parser.add_argument( 

74 "issue_id", 

75 nargs="?", 

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

77 ) 

78 add_json_arg(diff_parser) 

79 

80 # Close subcommand 

81 close_parser = subparsers.add_parser( 

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

83 ) 

84 close_parser.add_argument( 

85 "issue_ids", 

86 nargs="*", 

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

88 ) 

89 close_parser.add_argument( 

90 "--all-completed", 

91 action="store_true", 

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

93 ) 

94 

95 # Reconcile subcommand 

96 subparsers.add_parser( 

97 "reconcile", 

98 help="Promote feature-branch issues to done when their PR is merged", 

99 ) 

100 

101 # Reopen subcommand 

102 reopen_parser = subparsers.add_parser( 

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

104 ) 

105 reopen_parser.add_argument( 

106 "issue_ids", 

107 nargs="*", 

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

109 ) 

110 reopen_parser.add_argument( 

111 "--all-reopened", 

112 action="store_true", 

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

114 ) 

115 

116 # Common args 

117 add_config_arg(parser) 

118 add_quiet_arg(parser) 

119 add_dry_run_arg(parser) 

120 

121 args = parser.parse_args() 

122 

123 if not args.action: 

124 parser.print_help() 

125 return 1 

126 

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

128 config = BRConfig(project_root) 

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

130 

131 # Check sync is enabled 

132 if not config.sync.enabled: 

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

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

135 return 1 

136 

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

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

139 

140 if args.action == "status": 

141 status = manager.get_status() 

142 if getattr(args, "json", False): 

143 print_json(status.to_dict()) 

144 else: 

145 _print_sync_status(status, logger) 

146 return 0 

147 

148 elif args.action == "push": 

149 if dry_run: 

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

151 issue_ids = args.issue_ids if args.issue_ids else None 

152 result = manager.push_issues(issue_ids) 

153 _print_sync_result(result, logger) 

154 return 0 if result.success else 1 

155 

156 elif args.action == "pull": 

157 if dry_run: 

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

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

160 result = manager.pull_issues(labels) 

161 _print_sync_result(result, logger) 

162 return 0 if result.success else 1 

163 

164 elif args.action == "diff": 

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

166 if issue_id: 

167 result = manager.diff_issue(issue_id) 

168 else: 

169 result = manager.diff_all() 

170 if getattr(args, "json", False): 

171 print_json(result.to_dict()) 

172 elif issue_id: 

173 _print_diff_result(result, logger) 

174 else: 

175 _print_sync_result(result, logger) 

176 return 0 if result.success else 1 

177 

178 elif args.action == "close": 

179 if dry_run: 

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

181 issue_ids = args.issue_ids if args.issue_ids else None 

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

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

184 _print_sync_result(result, logger) 

185 return 0 if result.success else 1 

186 

187 elif args.action == "reopen": 

188 if dry_run: 

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

190 issue_ids = args.issue_ids if args.issue_ids else None 

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

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

193 _print_sync_result(result, logger) 

194 return 0 if result.success else 1 

195 

196 elif args.action == "reconcile": 

197 if dry_run: 

198 logger.info( 

199 "[DRY RUN] Showing which feature-branch issues would be promoted (no changes will be made)" 

200 ) 

201 count = manager.reconcile_pr_merges() 

202 logger.info(f"Reconcile complete: {count} issue(s) promoted to done") 

203 return 0 

204 

205 return 1 

206 

207 

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

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

210 logger.info("=" * 80) 

211 logger.info("SYNC STATUS") 

212 logger.info("=" * 80) 

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

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

215 logger.info("") 

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

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

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

219 logger.info("") 

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

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

222 if status.github_error: 

223 logger.info("") 

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

225 logger.info("=" * 80) 

226 

227 

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

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

230 logger.info("=" * 80) 

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

232 logger.info("=" * 80) 

233 logger.info("") 

234 logger.info("## SUMMARY") 

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

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

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

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

239 logger.info("") 

240 if result.created: 

241 logger.info("## CREATED") 

242 for item in result.created: 

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

244 logger.info("") 

245 if result.updated: 

246 logger.info("## UPDATED") 

247 for item in result.updated: 

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

249 logger.info("") 

250 if result.failed: 

251 logger.info("## FAILED") 

252 for issue_id, reason in result.failed: 

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

254 logger.info("") 

255 if result.errors: 

256 logger.info("## ERRORS") 

257 for error in result.errors: 

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

259 logger.info("=" * 80) 

260 

261 

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

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

264 if result.errors: 

265 for error in result.errors: 

266 logger.error(error) 

267 return 

268 

269 if result.skipped: 

270 for item in result.skipped: 

271 logger.info(item) 

272 return 

273 

274 if result.updated: 

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

276 logger.info("") 

277 

278 # Diff lines are stored in created field 

279 for line in result.created: 

280 logger.info(line)