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

2

3 from __future__ import annotations

4

5 import argparse

6 import sys

7 from pathlib import Path

8

9 from little_loops.cli.output import print_json

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

11 from little_loops.config import BRConfig

12 from little_loops.logger import Logger

13 from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context

14 from little_loops.sync import GitHubSyncManager, SyncResult, SyncStatus

15

16

17 def 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="""

31 Examples:

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

208 def _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

228 def _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

262 def _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)