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

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

2

3 from __future__ import annotations

4

5 import argparse

6 from pathlib import Path

7

8 from little_loops.cli_args import add_config_arg, add_dry_run_arg, add_quiet_arg

9 from little_loops.config import BRConfig

10 from little_loops.logger import Logger

11 from little_loops.sync import GitHubSyncManager, SyncResult, SyncStatus

12

13

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

27 Examples:

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

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

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

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