roadmap-cli 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (521) hide show
  1. roadmap/__init__.py +27 -0
  2. roadmap/adapters/__init__.py +1 -0
  3. roadmap/adapters/base_paginated_adapter.py +117 -0
  4. roadmap/adapters/cli/__init__.py +249 -0
  5. roadmap/adapters/cli/analysis/__init__.py +5 -0
  6. roadmap/adapters/cli/analysis/commands.py +370 -0
  7. roadmap/adapters/cli/analysis/presenter.py +125 -0
  8. roadmap/adapters/cli/archive_operations.py +86 -0
  9. roadmap/adapters/cli/cli_command_helpers.py +109 -0
  10. roadmap/adapters/cli/cli_confirmations.py +74 -0
  11. roadmap/adapters/cli/cli_error_handlers.py +146 -0
  12. roadmap/adapters/cli/cli_validators.py +39 -0
  13. roadmap/adapters/cli/commands/sync_status.py +308 -0
  14. roadmap/adapters/cli/comment/__init__.py +5 -0
  15. roadmap/adapters/cli/comment/commands.py +106 -0
  16. roadmap/adapters/cli/config/__init__.py +5 -0
  17. roadmap/adapters/cli/config/commands.py +198 -0
  18. roadmap/adapters/cli/console_exports.py +10 -0
  19. roadmap/adapters/cli/core.py +26 -0
  20. roadmap/adapters/cli/crud/__init__.py +47 -0
  21. roadmap/adapters/cli/crud/base_archive.py +208 -0
  22. roadmap/adapters/cli/crud/base_create.py +154 -0
  23. roadmap/adapters/cli/crud/base_delete.py +155 -0
  24. roadmap/adapters/cli/crud/base_restore.py +283 -0
  25. roadmap/adapters/cli/crud/base_update.py +193 -0
  26. roadmap/adapters/cli/crud/crud_helpers.py +206 -0
  27. roadmap/adapters/cli/crud/crud_utils.py +152 -0
  28. roadmap/adapters/cli/crud/entity_builders.py +374 -0
  29. roadmap/adapters/cli/data/__init__.py +8 -0
  30. roadmap/adapters/cli/data/commands.py +149 -0
  31. roadmap/adapters/cli/decorators.py +208 -0
  32. roadmap/adapters/cli/dtos/__init__.py +155 -0
  33. roadmap/adapters/cli/exception_handler.py +105 -0
  34. roadmap/adapters/cli/git/__init__.py +8 -0
  35. roadmap/adapters/cli/git/commands.py +475 -0
  36. roadmap/adapters/cli/git/handlers/__init__.py +1 -0
  37. roadmap/adapters/cli/git/handlers/git_authentication_handler.py +124 -0
  38. roadmap/adapters/cli/git/handlers/git_branch_handler.py +238 -0
  39. roadmap/adapters/cli/git/handlers/git_connectivity_handler.py +122 -0
  40. roadmap/adapters/cli/git/handlers/git_hooks_handler.py +105 -0
  41. roadmap/adapters/cli/git/hooks_config.py +327 -0
  42. roadmap/adapters/cli/git/status_display.py +158 -0
  43. roadmap/adapters/cli/health/__init__.py +8 -0
  44. roadmap/adapters/cli/health/db_integrity.py +204 -0
  45. roadmap/adapters/cli/health/enhancer.py +227 -0
  46. roadmap/adapters/cli/health/fixer.py +295 -0
  47. roadmap/adapters/cli/health/fixers/__init__.py +10 -0
  48. roadmap/adapters/cli/health/fixers/corrupted_comments_fixer.py +231 -0
  49. roadmap/adapters/cli/health/fixers/data_integrity_fixer.py +137 -0
  50. roadmap/adapters/cli/health/fixers/duplicate_issues_fixer.py +138 -0
  51. roadmap/adapters/cli/health/fixers/folder_structure_fixer.py +114 -0
  52. roadmap/adapters/cli/health/fixers/label_normalization_fixer.py +162 -0
  53. roadmap/adapters/cli/health/fixers/milestone_name_normalization_fixer.py +181 -0
  54. roadmap/adapters/cli/health/fixers/milestone_naming_compliance_fixer.py +244 -0
  55. roadmap/adapters/cli/health/fixers/milestone_parse_error_fixer.py +162 -0
  56. roadmap/adapters/cli/health/fixers/milestone_validation_fixer.py +211 -0
  57. roadmap/adapters/cli/health/fixers/old_backups_fixer.py +135 -0
  58. roadmap/adapters/cli/health/fixers/orphaned_issues_fixer.py +252 -0
  59. roadmap/adapters/cli/health/formatter.py +250 -0
  60. roadmap/adapters/cli/health/formatters.py +427 -0
  61. roadmap/adapters/cli/health/scan.py +293 -0
  62. roadmap/adapters/cli/init/__init__.py +5 -0
  63. roadmap/adapters/cli/init/commands.py +587 -0
  64. roadmap/adapters/cli/init.py +9 -0
  65. roadmap/adapters/cli/issues/__init__.py +70 -0
  66. roadmap/adapters/cli/issues/archive.py +137 -0
  67. roadmap/adapters/cli/issues/archive_class.py +253 -0
  68. roadmap/adapters/cli/issues/block.py +39 -0
  69. roadmap/adapters/cli/issues/close.py +216 -0
  70. roadmap/adapters/cli/issues/comment.py +178 -0
  71. roadmap/adapters/cli/issues/create.py +162 -0
  72. roadmap/adapters/cli/issues/delete.py +31 -0
  73. roadmap/adapters/cli/issues/deps.py +161 -0
  74. roadmap/adapters/cli/issues/issue_status_helpers.py +111 -0
  75. roadmap/adapters/cli/issues/link.py +308 -0
  76. roadmap/adapters/cli/issues/list.py +402 -0
  77. roadmap/adapters/cli/issues/lookup.py +96 -0
  78. roadmap/adapters/cli/issues/progress.py +102 -0
  79. roadmap/adapters/cli/issues/restore.py +96 -0
  80. roadmap/adapters/cli/issues/restore_class.py +57 -0
  81. roadmap/adapters/cli/issues/start.py +165 -0
  82. roadmap/adapters/cli/issues/sync_status.py +374 -0
  83. roadmap/adapters/cli/issues/unblock.py +43 -0
  84. roadmap/adapters/cli/issues/unlink.py +104 -0
  85. roadmap/adapters/cli/issues/update.py +120 -0
  86. roadmap/adapters/cli/issues/view.py +36 -0
  87. roadmap/adapters/cli/layout.py +220 -0
  88. roadmap/adapters/cli/mappers.py +200 -0
  89. roadmap/adapters/cli/milestones/__init__.py +52 -0
  90. roadmap/adapters/cli/milestones/archive.py +125 -0
  91. roadmap/adapters/cli/milestones/archive_class.py +236 -0
  92. roadmap/adapters/cli/milestones/assign.py +62 -0
  93. roadmap/adapters/cli/milestones/close.py +177 -0
  94. roadmap/adapters/cli/milestones/create.py +131 -0
  95. roadmap/adapters/cli/milestones/delete.py +27 -0
  96. roadmap/adapters/cli/milestones/kanban.py +123 -0
  97. roadmap/adapters/cli/milestones/list.py +87 -0
  98. roadmap/adapters/cli/milestones/recalculate.py +79 -0
  99. roadmap/adapters/cli/milestones/restore.py +87 -0
  100. roadmap/adapters/cli/milestones/restore_class.py +99 -0
  101. roadmap/adapters/cli/milestones/update.py +61 -0
  102. roadmap/adapters/cli/milestones/view.py +142 -0
  103. roadmap/adapters/cli/output_manager.py +319 -0
  104. roadmap/adapters/cli/presentation/base_presenter.py +121 -0
  105. roadmap/adapters/cli/presentation/cleanup_presenter.py +235 -0
  106. roadmap/adapters/cli/presentation/core_initialization_presenter.py +277 -0
  107. roadmap/adapters/cli/presentation/crud_presenter.py +134 -0
  108. roadmap/adapters/cli/presentation/daily_summary_presenter.py +266 -0
  109. roadmap/adapters/cli/presentation/issue_presenter.py +258 -0
  110. roadmap/adapters/cli/presentation/milestone_list_presenter.py +135 -0
  111. roadmap/adapters/cli/presentation/milestone_presenter.py +297 -0
  112. roadmap/adapters/cli/presentation/project_presenter.py +233 -0
  113. roadmap/adapters/cli/presentation/project_status_presenter.py +186 -0
  114. roadmap/adapters/cli/presentation/table_builders.py +95 -0
  115. roadmap/adapters/cli/projects/__init__.py +42 -0
  116. roadmap/adapters/cli/projects/archive.py +127 -0
  117. roadmap/adapters/cli/projects/archive_class.py +96 -0
  118. roadmap/adapters/cli/projects/close.py +94 -0
  119. roadmap/adapters/cli/projects/create.py +50 -0
  120. roadmap/adapters/cli/projects/delete.py +27 -0
  121. roadmap/adapters/cli/projects/list.py +227 -0
  122. roadmap/adapters/cli/projects/restore.py +87 -0
  123. roadmap/adapters/cli/projects/restore_class.py +56 -0
  124. roadmap/adapters/cli/projects/update.py +57 -0
  125. roadmap/adapters/cli/projects/view.py +157 -0
  126. roadmap/adapters/cli/services/__init__.py +1 -0
  127. roadmap/adapters/cli/services/daily_summary_service.py +313 -0
  128. roadmap/adapters/cli/services/export_manager.py +268 -0
  129. roadmap/adapters/cli/services/milestone_list_service.py +266 -0
  130. roadmap/adapters/cli/services/project_initialization_service.py +29 -0
  131. roadmap/adapters/cli/services/project_status_service.py +462 -0
  132. roadmap/adapters/cli/services/sync_service.py +62 -0
  133. roadmap/adapters/cli/status.py +408 -0
  134. roadmap/adapters/cli/styling.py +21 -0
  135. roadmap/adapters/cli/sync.py +1117 -0
  136. roadmap/adapters/cli/sync_context.py +432 -0
  137. roadmap/adapters/cli/sync_handlers/__init__.py +77 -0
  138. roadmap/adapters/cli/sync_handlers/apply_ops.py +522 -0
  139. roadmap/adapters/cli/sync_handlers/baseline_ops.py +366 -0
  140. roadmap/adapters/cli/sync_handlers/conflict_ops.py +161 -0
  141. roadmap/adapters/cli/sync_handlers/dry_run_display.py +237 -0
  142. roadmap/adapters/cli/sync_handlers/interactive_resolver.py +396 -0
  143. roadmap/adapters/cli/sync_handlers/progress_tracker.py +128 -0
  144. roadmap/adapters/cli/sync_metrics_command.py +190 -0
  145. roadmap/adapters/cli/sync_presenter.py +58 -0
  146. roadmap/adapters/cli/sync_validation.py +295 -0
  147. roadmap/adapters/cli/today.py +52 -0
  148. roadmap/adapters/cli/utils/click_options.py +38 -0
  149. roadmap/adapters/git/__init__.py +1 -0
  150. roadmap/adapters/git/git.py +208 -0
  151. roadmap/adapters/git/git_branch_manager.py +400 -0
  152. roadmap/adapters/git/git_command_executor.py +56 -0
  153. roadmap/adapters/git/git_commit_analyzer.py +334 -0
  154. roadmap/adapters/git/git_hooks.py +22 -0
  155. roadmap/adapters/git/git_hooks_manager.py +455 -0
  156. roadmap/adapters/git/git_repository_info.py +65 -0
  157. roadmap/adapters/git/hook_installer.py +106 -0
  158. roadmap/adapters/git/hook_registry.py +97 -0
  159. roadmap/adapters/git/hook_script_generator.py +58 -0
  160. roadmap/adapters/git/sync_monitor.py +409 -0
  161. roadmap/adapters/git/workflow_automation.py +286 -0
  162. roadmap/adapters/github/__init__.py +1 -0
  163. roadmap/adapters/github/github.py +360 -0
  164. roadmap/adapters/github/handlers/__init__.py +17 -0
  165. roadmap/adapters/github/handlers/base.py +337 -0
  166. roadmap/adapters/github/handlers/collaborators.py +165 -0
  167. roadmap/adapters/github/handlers/comments.py +118 -0
  168. roadmap/adapters/github/handlers/issues.py +150 -0
  169. roadmap/adapters/github/handlers/labels.py +164 -0
  170. roadmap/adapters/github/handlers/milestones.py +104 -0
  171. roadmap/adapters/persistence/__init__.py +35 -0
  172. roadmap/adapters/persistence/conflict_resolver.py +190 -0
  173. roadmap/adapters/persistence/database_manager.py +492 -0
  174. roadmap/adapters/persistence/entity_sync_coordinators.py +672 -0
  175. roadmap/adapters/persistence/file_locking.py +348 -0
  176. roadmap/adapters/persistence/file_parser.py +92 -0
  177. roadmap/adapters/persistence/file_synchronizer.py +107 -0
  178. roadmap/adapters/persistence/focused_managers.py +140 -0
  179. roadmap/adapters/persistence/git_history.py +330 -0
  180. roadmap/adapters/persistence/parser/__init__.py +20 -0
  181. roadmap/adapters/persistence/parser/frontmatter.py +152 -0
  182. roadmap/adapters/persistence/parser/issue.py +245 -0
  183. roadmap/adapters/persistence/parser/milestone.py +146 -0
  184. roadmap/adapters/persistence/parser/project.py +106 -0
  185. roadmap/adapters/persistence/persistence.py +288 -0
  186. roadmap/adapters/persistence/repositories/__init__.py +15 -0
  187. roadmap/adapters/persistence/repositories/issue_repository.py +223 -0
  188. roadmap/adapters/persistence/repositories/milestone_repository.py +140 -0
  189. roadmap/adapters/persistence/repositories/project_repository.py +168 -0
  190. roadmap/adapters/persistence/repositories/remote_link_repository.py +355 -0
  191. roadmap/adapters/persistence/repositories/sync_state_repository.py +66 -0
  192. roadmap/adapters/persistence/storage/__init__.py +36 -0
  193. roadmap/adapters/persistence/storage/conflicts.py +101 -0
  194. roadmap/adapters/persistence/storage/connection_manager.py +79 -0
  195. roadmap/adapters/persistence/storage/issue_storage.py +89 -0
  196. roadmap/adapters/persistence/storage/milestone_storage.py +80 -0
  197. roadmap/adapters/persistence/storage/project_storage.py +102 -0
  198. roadmap/adapters/persistence/storage/queries.py +227 -0
  199. roadmap/adapters/persistence/storage/state_manager.py +652 -0
  200. roadmap/adapters/persistence/storage/sync_state_storage.py +207 -0
  201. roadmap/adapters/persistence/sync_metrics_repository.py +342 -0
  202. roadmap/adapters/persistence/sync_orchestrator.py +293 -0
  203. roadmap/adapters/persistence/sync_state_tracker.py +86 -0
  204. roadmap/adapters/persistence/yaml_repositories.py +711 -0
  205. roadmap/adapters/sync/__init__.py +21 -0
  206. roadmap/adapters/sync/backend_factory.py +108 -0
  207. roadmap/adapters/sync/backends/__init__.py +15 -0
  208. roadmap/adapters/sync/backends/converters.py +209 -0
  209. roadmap/adapters/sync/backends/github_backend_helpers.py +380 -0
  210. roadmap/adapters/sync/backends/github_client.py +99 -0
  211. roadmap/adapters/sync/backends/github_sync_backend.py +792 -0
  212. roadmap/adapters/sync/backends/github_sync_ops.py +908 -0
  213. roadmap/adapters/sync/backends/services/__init__.py +1 -0
  214. roadmap/adapters/sync/backends/services/github_authentication_service.py +105 -0
  215. roadmap/adapters/sync/backends/services/github_issue_delete_service.py +423 -0
  216. roadmap/adapters/sync/backends/services/github_issue_dependency_service.py +63 -0
  217. roadmap/adapters/sync/backends/services/github_issue_fetch_service.py +158 -0
  218. roadmap/adapters/sync/backends/services/github_label_sync_service.py +126 -0
  219. roadmap/adapters/sync/backends/services/github_local_issue_sync_service.py +229 -0
  220. roadmap/adapters/sync/backends/services/github_milestone_fetch_service.py +204 -0
  221. roadmap/adapters/sync/backends/vanilla_git_sync_backend.py +198 -0
  222. roadmap/adapters/sync/services/__init__.py +11 -0
  223. roadmap/adapters/sync/services/baseline_state_handler.py +83 -0
  224. roadmap/adapters/sync/services/conflict_converter.py +85 -0
  225. roadmap/adapters/sync/services/issue_persistence_service.py +263 -0
  226. roadmap/adapters/sync/services/issue_state_service.py +243 -0
  227. roadmap/adapters/sync/services/local_change_filter.py +129 -0
  228. roadmap/adapters/sync/services/pull_result_processor.py +71 -0
  229. roadmap/adapters/sync/services/remote_issue_creation_service.py +112 -0
  230. roadmap/adapters/sync/services/sync_analysis_service.py +250 -0
  231. roadmap/adapters/sync/services/sync_authentication_service.py +57 -0
  232. roadmap/adapters/sync/services/sync_data_fetch_service.py +212 -0
  233. roadmap/adapters/sync/services/sync_linking_service.py +329 -0
  234. roadmap/adapters/sync/services/sync_report_service.py +62 -0
  235. roadmap/adapters/sync/services/sync_state_update_service.py +51 -0
  236. roadmap/adapters/sync/sync_cache_orchestrator.py +454 -0
  237. roadmap/adapters/sync/sync_merge_engine.py +416 -0
  238. roadmap/adapters/sync/sync_merge_orchestrator.py +1313 -0
  239. roadmap/adapters/sync/sync_retrieval_orchestrator.py +810 -0
  240. roadmap/application/services/__init__.py +1 -0
  241. roadmap/application/services/deduplicate_service.py +517 -0
  242. roadmap/application/use_cases/__init__.py +1 -0
  243. roadmap/common/__init__.py +308 -0
  244. roadmap/common/cache.py +209 -0
  245. roadmap/common/cli_errors.py +147 -0
  246. roadmap/common/configuration/__init__.py +33 -0
  247. roadmap/common/configuration/config_loader.py +264 -0
  248. roadmap/common/configuration/config_manager.py +164 -0
  249. roadmap/common/configuration/config_schema.py +145 -0
  250. roadmap/common/configuration/github/__init__.py +9 -0
  251. roadmap/common/configuration/github/config_manager.py +154 -0
  252. roadmap/common/configuration/github/token_resolver.py +84 -0
  253. roadmap/common/console.py +184 -0
  254. roadmap/common/constants.py +134 -0
  255. roadmap/common/datetime_parser.py +319 -0
  256. roadmap/common/error_formatter.py +84 -0
  257. roadmap/common/errors/__init__.py +143 -0
  258. roadmap/common/errors/error_base.py +73 -0
  259. roadmap/common/errors/error_file.py +82 -0
  260. roadmap/common/errors/error_git.py +67 -0
  261. roadmap/common/errors/error_handler.py +166 -0
  262. roadmap/common/errors/error_network.py +90 -0
  263. roadmap/common/errors/error_security.py +78 -0
  264. roadmap/common/errors/error_standards.py +561 -0
  265. roadmap/common/errors/error_validation.py +111 -0
  266. roadmap/common/errors/exceptions.py +432 -0
  267. roadmap/common/formatters/__init__.py +29 -0
  268. roadmap/common/formatters/base_table_formatter.py +96 -0
  269. roadmap/common/formatters/export/__init__.py +7 -0
  270. roadmap/common/formatters/export/issue_exporter.py +97 -0
  271. roadmap/common/formatters/helpers.py +28 -0
  272. roadmap/common/formatters/kanban/__init__.py +9 -0
  273. roadmap/common/formatters/kanban/layout.py +48 -0
  274. roadmap/common/formatters/kanban/organizer.py +81 -0
  275. roadmap/common/formatters/output/__init__.py +15 -0
  276. roadmap/common/formatters/tables/__init__.py +11 -0
  277. roadmap/common/formatters/tables/column_factory.py +312 -0
  278. roadmap/common/formatters/tables/issue_table.py +276 -0
  279. roadmap/common/formatters/tables/milestone_table.py +247 -0
  280. roadmap/common/formatters/tables/project_table.py +195 -0
  281. roadmap/common/formatters/text/__init__.py +57 -0
  282. roadmap/common/formatters/text/basic.py +217 -0
  283. roadmap/common/formatters/text/display.py +42 -0
  284. roadmap/common/formatters/text/duration.py +36 -0
  285. roadmap/common/formatters/text/operations.py +325 -0
  286. roadmap/common/formatters/text/status_badges.py +57 -0
  287. roadmap/common/initialization/github/__init__.py +11 -0
  288. roadmap/common/initialization/github/setup_service.py +238 -0
  289. roadmap/common/initialization/github/setup_validator.py +78 -0
  290. roadmap/common/logging/__init__.py +113 -0
  291. roadmap/common/logging/audit_logging.py +5 -0
  292. roadmap/common/logging/correlation.py +45 -0
  293. roadmap/common/logging/decorators.py +267 -0
  294. roadmap/common/logging/error_logging.py +217 -0
  295. roadmap/common/logging/formatters.py +124 -0
  296. roadmap/common/logging/loggers.py +68 -0
  297. roadmap/common/logging/performance_tracking.py +267 -0
  298. roadmap/common/logging/utils.py +335 -0
  299. roadmap/common/models/__init__.py +44 -0
  300. roadmap/common/models/cli_models.py +118 -0
  301. roadmap/common/models/config_models.py +199 -0
  302. roadmap/common/models/output_models.py +381 -0
  303. roadmap/common/observability/__init__.py +28 -0
  304. roadmap/common/observability/instrumentation.py +103 -0
  305. roadmap/common/observability/observability.py +76 -0
  306. roadmap/common/observability/otel_init.py +109 -0
  307. roadmap/common/output_formatter.py +393 -0
  308. roadmap/common/progress.py +379 -0
  309. roadmap/common/result.py +396 -0
  310. roadmap/common/security/__init__.py +48 -0
  311. roadmap/common/security/exceptions.py +13 -0
  312. roadmap/common/security/export_cleanup.py +79 -0
  313. roadmap/common/security/file_operations.py +104 -0
  314. roadmap/common/security/filename_sanitization.py +54 -0
  315. roadmap/common/security/logging.py +27 -0
  316. roadmap/common/security/path_validation.py +137 -0
  317. roadmap/common/security/temp_files.py +41 -0
  318. roadmap/common/services/__init__.py +65 -0
  319. roadmap/common/services/decorators.py +183 -0
  320. roadmap/common/services/logging_utils.py +208 -0
  321. roadmap/common/services/metrics.py +161 -0
  322. roadmap/common/services/performance.py +219 -0
  323. roadmap/common/services/profiling.py +288 -0
  324. roadmap/common/services/retry.py +288 -0
  325. roadmap/common/status_style_manager.py +114 -0
  326. roadmap/common/union_find.py +88 -0
  327. roadmap/common/update_constants.py +13 -0
  328. roadmap/common/utils/__init__.py +65 -0
  329. roadmap/common/utils/cli_helpers.py +381 -0
  330. roadmap/common/utils/file_utils.py +440 -0
  331. roadmap/common/utils/path_utils.py +38 -0
  332. roadmap/common/utils/status_utils.py +130 -0
  333. roadmap/common/utils/timezone_utils.py +480 -0
  334. roadmap/common/validation/__init__.py +37 -0
  335. roadmap/common/validation/field_validator.py +104 -0
  336. roadmap/common/validation/result.py +37 -0
  337. roadmap/common/validation/roadmap_validator.py +310 -0
  338. roadmap/common/validation/schema_validator.py +36 -0
  339. roadmap/common/validation/validators.py +50 -0
  340. roadmap/core/__init__.py +1 -0
  341. roadmap/core/domain/__init__.py +30 -0
  342. roadmap/core/domain/comment.py +24 -0
  343. roadmap/core/domain/health.py +15 -0
  344. roadmap/core/domain/issue.py +274 -0
  345. roadmap/core/domain/milestone.py +173 -0
  346. roadmap/core/domain/ports/__init__.py +1 -0
  347. roadmap/core/domain/ports/baseline_repository.py +24 -0
  348. roadmap/core/domain/ports/issue_repository.py +34 -0
  349. roadmap/core/domain/ports/remote_backend_port.py +31 -0
  350. roadmap/core/domain/project.py +130 -0
  351. roadmap/core/interfaces/__init__.py +109 -0
  352. roadmap/core/interfaces/assignee_validator.py +40 -0
  353. roadmap/core/interfaces/backend_factory.py +175 -0
  354. roadmap/core/interfaces/github.py +102 -0
  355. roadmap/core/interfaces/parsers.py +140 -0
  356. roadmap/core/interfaces/persistence.py +45 -0
  357. roadmap/core/interfaces/repositories.py +174 -0
  358. roadmap/core/interfaces/state_managers.py +111 -0
  359. roadmap/core/interfaces/state_storage.py +64 -0
  360. roadmap/core/interfaces/sync_backend.py +275 -0
  361. roadmap/core/interfaces/sync_services.py +119 -0
  362. roadmap/core/interfaces/sync_validators.py +180 -0
  363. roadmap/core/models/__init__.py +31 -0
  364. roadmap/core/models/sync_models.py +162 -0
  365. roadmap/core/models.py +115 -0
  366. roadmap/core/observability/__init__.py +17 -0
  367. roadmap/core/observability/sync_metrics.py +699 -0
  368. roadmap/core/repositories.py +200 -0
  369. roadmap/core/services/__init__.py +313 -0
  370. roadmap/core/services/baseline/__init__.py +1 -0
  371. roadmap/core/services/baseline/baseline_builder_progress.py +274 -0
  372. roadmap/core/services/baseline/baseline_retriever.py +63 -0
  373. roadmap/core/services/baseline/baseline_selector.py +248 -0
  374. roadmap/core/services/baseline/baseline_state_retriever.py +284 -0
  375. roadmap/core/services/baseline/optimized_baseline_builder.py +425 -0
  376. roadmap/core/services/comment/__init__.py +1 -0
  377. roadmap/core/services/comment/comment_service.py +277 -0
  378. roadmap/core/services/git/__init__.py +1 -0
  379. roadmap/core/services/git/git_hook_auto_sync_service.py +403 -0
  380. roadmap/core/services/github/__init__.py +1 -0
  381. roadmap/core/services/github/github_change_detector.py +162 -0
  382. roadmap/core/services/github/github_config_validator.py +148 -0
  383. roadmap/core/services/github/github_conflict_detector.py +179 -0
  384. roadmap/core/services/github/github_entity_classifier.py +51 -0
  385. roadmap/core/services/github/github_integration_service.py +439 -0
  386. roadmap/core/services/github/github_issue_client.py +346 -0
  387. roadmap/core/services/health/__init__.py +1 -0
  388. roadmap/core/services/health/backup_cleanup_service.py +202 -0
  389. roadmap/core/services/health/data_integrity_validator_service.py +90 -0
  390. roadmap/core/services/health/entity_health_scanner.py +460 -0
  391. roadmap/core/services/health/file_repair_service.py +193 -0
  392. roadmap/core/services/health/health_check_service.py +166 -0
  393. roadmap/core/services/health/health_models.py +80 -0
  394. roadmap/core/services/health/infrastructure_validator_service.py +328 -0
  395. roadmap/core/services/health/issue_health_scanner.py +263 -0
  396. roadmap/core/services/helpers/__init__.py +18 -0
  397. roadmap/core/services/helpers/status_change_helpers.py +88 -0
  398. roadmap/core/services/initialization/__init__.py +19 -0
  399. roadmap/core/services/initialization/utils.py +104 -0
  400. roadmap/core/services/initialization/validator.py +78 -0
  401. roadmap/core/services/initialization/workflow.py +182 -0
  402. roadmap/core/services/initialization_service.py +111 -0
  403. roadmap/core/services/issue/__init__.py +1 -0
  404. roadmap/core/services/issue/assignee_validation_service.py +312 -0
  405. roadmap/core/services/issue/issue_creation_service.py +309 -0
  406. roadmap/core/services/issue/issue_filter_service.py +302 -0
  407. roadmap/core/services/issue/issue_matching_service.py +127 -0
  408. roadmap/core/services/issue/issue_service.py +765 -0
  409. roadmap/core/services/issue/issue_update_service.py +144 -0
  410. roadmap/core/services/issue/start_issue_service.py +174 -0
  411. roadmap/core/services/issue_helpers/__init__.py +18 -0
  412. roadmap/core/services/issue_helpers/issue_filters.py +296 -0
  413. roadmap/core/services/milestone_service.py +438 -0
  414. roadmap/core/services/project/__init__.py +1 -0
  415. roadmap/core/services/project/project_service.py +434 -0
  416. roadmap/core/services/project/project_status_service.py +125 -0
  417. roadmap/core/services/project_init/__init__.py +22 -0
  418. roadmap/core/services/project_init/context_detection.py +146 -0
  419. roadmap/core/services/project_init/creation.py +76 -0
  420. roadmap/core/services/project_init/detection.py +51 -0
  421. roadmap/core/services/project_init/template.py +228 -0
  422. roadmap/core/services/status_change_service.py +88 -0
  423. roadmap/core/services/sync/__init__.py +1 -0
  424. roadmap/core/services/sync/batch_processor.py +290 -0
  425. roadmap/core/services/sync/conflict_resolver.py +154 -0
  426. roadmap/core/services/sync/dependency_resolver.py +367 -0
  427. roadmap/core/services/sync/duplicate_detector.py +771 -0
  428. roadmap/core/services/sync/duplicate_resolver.py +315 -0
  429. roadmap/core/services/sync/error_classification.py +470 -0
  430. roadmap/core/services/sync/performance.py +263 -0
  431. roadmap/core/services/sync/sync_change_computer.py +211 -0
  432. roadmap/core/services/sync/sync_checkpoint.py +348 -0
  433. roadmap/core/services/sync/sync_conflict_detector.py +147 -0
  434. roadmap/core/services/sync/sync_conflict_resolver.py +448 -0
  435. roadmap/core/services/sync/sync_errors.py +383 -0
  436. roadmap/core/services/sync/sync_key_normalizer.py +222 -0
  437. roadmap/core/services/sync/sync_metadata_service.py +361 -0
  438. roadmap/core/services/sync/sync_plan.py +171 -0
  439. roadmap/core/services/sync/sync_plan_executor.py +447 -0
  440. roadmap/core/services/sync/sync_report.py +296 -0
  441. roadmap/core/services/sync/sync_state.py +173 -0
  442. roadmap/core/services/sync/sync_state_comparator.py +690 -0
  443. roadmap/core/services/sync/sync_state_manager.py +431 -0
  444. roadmap/core/services/sync/sync_state_normalizer.py +98 -0
  445. roadmap/core/services/sync/sync_three_way.py +78 -0
  446. roadmap/core/services/sync/three_way_merger.py +178 -0
  447. roadmap/core/services/utils/__init__.py +1 -0
  448. roadmap/core/services/utils/configuration_service.py +113 -0
  449. roadmap/core/services/utils/critical_path_calculator.py +418 -0
  450. roadmap/core/services/utils/dependency_analyzer.py +360 -0
  451. roadmap/core/services/utils/field_conflict_detector.py +126 -0
  452. roadmap/core/services/utils/remote_fetcher.py +202 -0
  453. roadmap/core/services/utils/remote_state_normalizer.py +110 -0
  454. roadmap/core/services/utils/retry_policy.py +51 -0
  455. roadmap/core/services/validator_base.py +130 -0
  456. roadmap/core/services/validators/__init__.py +28 -0
  457. roadmap/core/services/validators/_utils.py +20 -0
  458. roadmap/core/services/validators/archivable_issues_validator.py +71 -0
  459. roadmap/core/services/validators/archivable_milestones_validator.py +69 -0
  460. roadmap/core/services/validators/backup_validator.py +89 -0
  461. roadmap/core/services/validators/data_integrity_validator.py +84 -0
  462. roadmap/core/services/validators/duplicate_issues_validator.py +75 -0
  463. roadmap/core/services/validators/duplicate_milestones_validator.py +141 -0
  464. roadmap/core/services/validators/folder_structure_validator.py +165 -0
  465. roadmap/core/services/validators/health_status_utils.py +28 -0
  466. roadmap/core/services/validators/milestone_naming_validator.py +146 -0
  467. roadmap/core/services/validators/missing_headlines_validator.py +97 -0
  468. roadmap/core/services/validators/orphaned_issues_validator.py +142 -0
  469. roadmap/core/services/validators/orphaned_milestones_validator.py +85 -0
  470. roadmap/core/utils/git_remote_parser.py +83 -0
  471. roadmap/core/utils/github_urls.py +66 -0
  472. roadmap/domain/validation.py +30 -0
  473. roadmap/infrastructure/__init__.py +55 -0
  474. roadmap/infrastructure/coordination/__init__.py +8 -0
  475. roadmap/infrastructure/coordination/coordinator_params.py +82 -0
  476. roadmap/infrastructure/coordination/core.py +446 -0
  477. roadmap/infrastructure/coordination/git_coordinator.py +160 -0
  478. roadmap/infrastructure/coordination/initialization.py +348 -0
  479. roadmap/infrastructure/coordination/issue_coordinator.py +154 -0
  480. roadmap/infrastructure/coordination/issue_operations.py +456 -0
  481. roadmap/infrastructure/coordination/milestone_coordinator.py +135 -0
  482. roadmap/infrastructure/coordination/milestone_operations.py +201 -0
  483. roadmap/infrastructure/coordination/project_coordinator.py +113 -0
  484. roadmap/infrastructure/coordination/project_operations.py +144 -0
  485. roadmap/infrastructure/coordination/team_coordinator.py +107 -0
  486. roadmap/infrastructure/coordination/user_operations.py +150 -0
  487. roadmap/infrastructure/coordination/validation_coordinator.py +249 -0
  488. roadmap/infrastructure/coordination_gateway.py +152 -0
  489. roadmap/infrastructure/git/__init__.py +6 -0
  490. roadmap/infrastructure/git/git_integration_ops.py +234 -0
  491. roadmap/infrastructure/git_gateway.py +35 -0
  492. roadmap/infrastructure/github_gateway.py +52 -0
  493. roadmap/infrastructure/maintenance/__init__.py +5 -0
  494. roadmap/infrastructure/maintenance/cleanup.py +483 -0
  495. roadmap/infrastructure/observability/__init__.py +7 -0
  496. roadmap/infrastructure/observability/health.py +196 -0
  497. roadmap/infrastructure/observability/health_formatter.py +73 -0
  498. roadmap/infrastructure/observability/specialized_health_checkers.py +188 -0
  499. roadmap/infrastructure/persistence_gateway.py +164 -0
  500. roadmap/infrastructure/security/__init__.py +1 -0
  501. roadmap/infrastructure/security/credentials.py +489 -0
  502. roadmap/infrastructure/sync_gateway.py +94 -0
  503. roadmap/infrastructure/validation/__init__.py +7 -0
  504. roadmap/infrastructure/validation/file_enumeration.py +219 -0
  505. roadmap/infrastructure/validation/github_assignee_validator.py +47 -0
  506. roadmap/infrastructure/validation/github_validator.py +101 -0
  507. roadmap/infrastructure/validation/milestone_consistency_validator.py +96 -0
  508. roadmap/infrastructure/validation/vanilla_assignee_validator.py +37 -0
  509. roadmap/infrastructure/validation_gateway.py +96 -0
  510. roadmap/presentation/formatters/sync_metrics_formatter.py +355 -0
  511. roadmap/settings.py +345 -0
  512. roadmap/templates/github_workflows/ci-cd-roadmap.yml +241 -0
  513. roadmap/templates/github_workflows/issue-lifecycle.yml +236 -0
  514. roadmap/templates/github_workflows/roadmap-integration.yml +137 -0
  515. roadmap/templates/github_workflows/roadmap-starter.yml +59 -0
  516. roadmap/version.py +381 -0
  517. roadmap_cli-0.1.1.dist-info/METADATA +448 -0
  518. roadmap_cli-0.1.1.dist-info/RECORD +521 -0
  519. roadmap_cli-0.1.1.dist-info/WHEEL +4 -0
  520. roadmap_cli-0.1.1.dist-info/entry_points.txt +2 -0
  521. roadmap_cli-0.1.1.dist-info/licenses/LICENSE.md +21 -0
@@ -0,0 +1,1313 @@
1
+ """Generic sync orchestrator that works with any sync backend.
2
+
3
+ This orchestrator provides the high-level sync logic (detecting changes,
4
+ reporting conflicts, etc.) and delegates backend-specific operations
5
+ to SyncBackendInterface implementations.
6
+ """
7
+
8
+ import time
9
+ from typing import Any
10
+
11
+ from structlog import get_logger
12
+
13
+ from roadmap.adapters.sync.services.sync_analysis_service import SyncAnalysisService
14
+ from roadmap.adapters.sync.services.sync_authentication_service import (
15
+ SyncAuthenticationService,
16
+ )
17
+ from roadmap.adapters.sync.services.sync_data_fetch_service import SyncDataFetchService
18
+ from roadmap.adapters.sync.services.sync_report_service import SyncReportService
19
+ from roadmap.adapters.sync.sync_merge_engine import SyncMergeEngine
20
+ from roadmap.application.services.deduplicate_service import DeduplicateService
21
+ from roadmap.core.domain.issue import Issue
22
+ from roadmap.core.interfaces.sync_backend import SyncBackendInterface
23
+ from roadmap.core.models.sync_models import SyncIssue
24
+ from roadmap.core.observability.sync_metrics import get_observability
25
+ from roadmap.core.services.sync.duplicate_detector import DuplicateDetector
26
+ from roadmap.core.services.sync.duplicate_resolver import DuplicateResolver
27
+ from roadmap.core.services.sync.sync_conflict_resolver import (
28
+ Conflict,
29
+ SyncConflictResolver,
30
+ )
31
+ from roadmap.core.services.sync.sync_plan import (
32
+ PullAction,
33
+ PushAction,
34
+ ResolveConflictAction,
35
+ SyncPlan,
36
+ )
37
+ from roadmap.core.services.sync.sync_report import SyncReport
38
+ from roadmap.core.services.sync.sync_state_comparator import SyncStateComparator
39
+ from roadmap.core.services.sync.sync_state_manager import SyncStateManager
40
+ from roadmap.infrastructure.coordination.core import RoadmapCore
41
+
42
+ logger = get_logger(__name__)
43
+
44
+
45
+ class SyncMergeOrchestrator:
46
+ """Orchestrates sync using a pluggable backend implementation."""
47
+
48
+ def __init__(
49
+ self,
50
+ core: RoadmapCore,
51
+ backend: SyncBackendInterface,
52
+ state_comparator: SyncStateComparator | None = None,
53
+ conflict_resolver: SyncConflictResolver | None = None,
54
+ enable_duplicate_detection: bool = True,
55
+ duplicate_title_threshold: float = 0.90,
56
+ duplicate_content_threshold: float = 0.85,
57
+ duplicate_auto_resolve_threshold: float = 0.95,
58
+ ):
59
+ """Initialize orchestrator with core services and backend.
60
+
61
+ Args:
62
+ core: RoadmapCore instance with access to issues
63
+ backend: SyncBackendInterface implementation (GitHub, vanilla Git, etc.)
64
+ state_comparator: SyncStateComparator for detecting changes (optional, creates default)
65
+ conflict_resolver: SyncConflictResolver for resolving conflicts (optional, creates default)
66
+ enable_duplicate_detection: Whether to enable duplicate detection (default: True)
67
+ duplicate_title_threshold: Minimum similarity for title duplicates (0.0-1.0, default: 0.90)
68
+ duplicate_content_threshold: Minimum similarity for content duplicates (0.0-1.0, default: 0.85)
69
+ duplicate_auto_resolve_threshold: Minimum confidence for auto-resolution (0.0-1.0, default: 0.95)
70
+ """
71
+ self.core = core
72
+ self.backend = backend
73
+ # Pass backend to comparator for key normalization
74
+ self.state_comparator = state_comparator or SyncStateComparator(backend=backend)
75
+ self.conflict_resolver = conflict_resolver or SyncConflictResolver()
76
+ self.state_manager = SyncStateManager(core.roadmap_dir, db_manager=core.db)
77
+ self.enable_duplicate_detection = enable_duplicate_detection
78
+
79
+ # Initialize observability tracker
80
+ self._observability = get_observability()
81
+ self._current_operation_id: str | None = None
82
+
83
+ # Initialize delegated services
84
+ self._auth_service = SyncAuthenticationService(backend)
85
+ self._fetch_service = SyncDataFetchService(core, backend)
86
+ self._analysis_service = SyncAnalysisService(
87
+ self.state_comparator, self.state_manager, core=core
88
+ )
89
+
90
+ # Initialize duplicate detection services with configurable thresholds
91
+ self._duplicate_detector = DuplicateDetector(
92
+ title_similarity_threshold=duplicate_title_threshold,
93
+ content_similarity_threshold=duplicate_content_threshold,
94
+ auto_resolve_threshold=duplicate_auto_resolve_threshold,
95
+ )
96
+ self._duplicate_resolver = DuplicateResolver(
97
+ issue_service=core.issue_service,
98
+ auto_resolve_threshold=duplicate_auto_resolve_threshold,
99
+ )
100
+
101
+ # Extraction: delegate helper implementations to a dedicated engine
102
+ self._engine = SyncMergeEngine(
103
+ core=self.core,
104
+ backend=self.backend,
105
+ state_comparator=self.state_comparator,
106
+ conflict_resolver=self.conflict_resolver,
107
+ state_manager=self.state_manager,
108
+ )
109
+
110
+ # Helper extraction to reduce complexity of sync_all_issues
111
+ def _ensure_authenticated(self, report: SyncReport) -> bool:
112
+ return self._auth_service.ensure_authenticated(report)
113
+
114
+ def _fetch_remote_issues(self, report: SyncReport):
115
+ return self._fetch_service.fetch_remote_issues(report)
116
+
117
+ def _fetch_local_issues(self, report: SyncReport):
118
+ return self._fetch_service.fetch_local_issues(report)
119
+
120
+ def _count_active_archived(self, local_issues):
121
+ return self._fetch_service.count_active_archived(local_issues)
122
+
123
+ def _count_milestones(self):
124
+ return self._fetch_service.count_milestones()
125
+
126
+ def _count_remote_stats(self, remote_issues_data):
127
+ return self._fetch_service.count_remote_stats(remote_issues_data)
128
+
129
+ def _analyze_changes(self, local_issues_dict, remote_issues_data, base_state):
130
+ return self._engine._analyze_changes(
131
+ local_issues_dict, remote_issues_data, base_state
132
+ )
133
+
134
+ def _resolve_conflicts_if_needed(self, conflicts, force_local, force_remote):
135
+ return self._engine._resolve_conflicts_if_needed(
136
+ conflicts, force_local, force_remote
137
+ )
138
+
139
+ def _apply_plan(
140
+ self,
141
+ updates,
142
+ resolved_issues,
143
+ pulls,
144
+ dry_run,
145
+ push_only,
146
+ pull_only,
147
+ report: SyncReport,
148
+ ):
149
+ return self._engine._apply_plan(
150
+ updates, resolved_issues, pulls, dry_run, push_only, pull_only, report
151
+ )
152
+
153
+ def _load_baseline_state(self):
154
+ """Load baseline state from database with fallback to manager.
155
+
156
+ Returns:
157
+ SyncState from database, or None if not found
158
+ """
159
+ return self._engine._load_baseline_state()
160
+
161
+ def _process_fetched_pull_result(self, fetched):
162
+ return self._engine._process_fetched_pull_result(fetched)
163
+
164
+ def _update_baseline_for_pulled(self, pulled_remote_ids: list[str]) -> None:
165
+ return self._engine._update_baseline_for_pulled(pulled_remote_ids)
166
+
167
+ def analyze_all_issues(
168
+ self,
169
+ push_only: bool = False,
170
+ pull_only: bool = False,
171
+ interactive_duplicates: bool = False,
172
+ ) -> tuple[SyncPlan, SyncReport]:
173
+ """Pure analysis pass that returns a SyncPlan and SyncReport without side-effects.
174
+
175
+ This method performs authentication and data retrieval (reads only),
176
+ runs duplicate detection, runs the three-way analysis, and converts the result
177
+ into a list of `Action`s bundled into a `SyncPlan`. No database or file writes are
178
+ performed here — actions are merely declared for an Executor to apply.
179
+
180
+ Args:
181
+ push_only: Only push local changes, skip pulling remote
182
+ pull_only: Only pull remote changes, skip pushing local
183
+ interactive_duplicates: Use interactive resolution for duplicate matches
184
+
185
+ Returns:
186
+ Tuple of (SyncPlan, SyncReport)
187
+ """
188
+ report = SyncReport()
189
+ plan = SyncPlan()
190
+
191
+ try:
192
+ # Helper: authenticate and fetch remote issues
193
+ def _auth_and_fetch_remote():
194
+ # Handle Result type from backend.authenticate()
195
+ auth_result = self.backend.authenticate()
196
+ if auth_result.is_err():
197
+ error = auth_result.unwrap_err()
198
+ report.error = str(error)
199
+ return None
200
+
201
+ # Handle Result type from backend.get_issues()
202
+ issues_result = self.backend.get_issues()
203
+ if issues_result.is_err():
204
+ error = issues_result.unwrap_err()
205
+ report.error = str(error)
206
+ return None
207
+
208
+ return issues_result.unwrap()
209
+
210
+ # Helper: fetch local issues safely
211
+ def _fetch_local_safe():
212
+ try:
213
+ return self.core.issues.list_all_including_archived() or []
214
+ except Exception as e:
215
+ report.error = f"Failed to fetch local issues: {str(e)}"
216
+ return None
217
+
218
+ remote_issues_data = _auth_and_fetch_remote()
219
+ if remote_issues_data is None:
220
+ return plan, report
221
+
222
+ local_issues = _fetch_local_safe()
223
+ if local_issues is None:
224
+ return plan, report
225
+
226
+ logger.info(
227
+ "DEBUG: Fetched issues",
228
+ local_count=len(local_issues),
229
+ remote_count=len(remote_issues_data)
230
+ if isinstance(remote_issues_data, dict)
231
+ else "not_a_dict",
232
+ )
233
+
234
+ local_issues_dict = {issue.id: issue for issue in local_issues}
235
+ try:
236
+ base_state = self._load_baseline_state()
237
+ except Exception:
238
+ base_state = None
239
+
240
+ # Helper: run comparator and classify changes
241
+ def _run_analysis(local_dict, remote_data, base_state):
242
+ logger.info(
243
+ "DEBUG: Starting three-way analysis",
244
+ local_count=len(local_dict),
245
+ remote_count=len(remote_data),
246
+ base_count=len(base_state.base_issues) if base_state else 0,
247
+ )
248
+ changes = self.state_comparator.analyze_three_way(
249
+ local_dict,
250
+ remote_data,
251
+ base_state.base_issues if base_state else None,
252
+ )
253
+ logger.info(
254
+ "DEBUG: Three-way analysis complete", changes_count=len(changes)
255
+ )
256
+ conflicts = [c for c in changes if c.has_conflict]
257
+ local_only_changes = [c for c in changes if c.is_local_only_change()]
258
+ remote_only_changes = [c for c in changes if c.is_remote_only_change()]
259
+ no_changes = [c for c in changes if c.conflict_type == "no_change"]
260
+ logger.info(
261
+ "DEBUG: Classified changes",
262
+ conflicts=len(conflicts),
263
+ local_only=len(local_only_changes),
264
+ remote_only=len(remote_only_changes),
265
+ no_changes=len(no_changes),
266
+ )
267
+ return (
268
+ changes,
269
+ conflicts,
270
+ local_only_changes,
271
+ remote_only_changes,
272
+ no_changes,
273
+ )
274
+
275
+ (
276
+ changes,
277
+ conflicts,
278
+ local_only_changes,
279
+ remote_only_changes,
280
+ no_changes,
281
+ ) = _run_analysis(local_issues_dict, remote_issues_data, base_state)
282
+
283
+ # Helper: build a plan from classified changes
284
+ def _build_plan_from_changes(
285
+ changes, local_only_changes, remote_only_changes, conflicts
286
+ ):
287
+ local_plan = SyncPlan()
288
+ if not pull_only:
289
+ for c in local_only_changes:
290
+ if c.local_state:
291
+ local_plan.add(
292
+ PushAction(
293
+ issue_id=c.issue_id,
294
+ issue_payload=c.local_state.__dict__,
295
+ )
296
+ )
297
+ if not push_only:
298
+ for c in remote_only_changes:
299
+ local_plan.add(
300
+ PullAction(
301
+ issue_id=c.issue_id,
302
+ remote_payload=c.remote_state
303
+ if hasattr(c, "remote_state")
304
+ else {},
305
+ )
306
+ )
307
+ for c in conflicts:
308
+ local_plan.add(
309
+ ResolveConflictAction(
310
+ issue_id=c.issue_id,
311
+ resolution={"conflict_fields": c.local_changes or {}},
312
+ )
313
+ )
314
+ return local_plan
315
+
316
+ plan = _build_plan_from_changes(
317
+ changes, local_only_changes, remote_only_changes, conflicts
318
+ )
319
+
320
+ # Fill report metadata similar to existing sync flow
321
+ report.total_issues = len(local_issues)
322
+ report.conflicts_detected = len(conflicts)
323
+ report.issues_up_to_date = len(no_changes)
324
+ report.issues_needs_push = len(local_only_changes)
325
+ report.issues_needs_pull = len(remote_only_changes)
326
+ report.changes = changes
327
+
328
+ return plan, report
329
+
330
+ except Exception as e:
331
+ report.error = str(e)
332
+ return plan, report
333
+
334
+ def _filter_unchanged_issues_from_base(
335
+ self,
336
+ issues: list,
337
+ *_args,
338
+ **_kwargs,
339
+ ) -> list:
340
+ # This helper was removed during refactor: filtering of unchanged
341
+ # issues is now performed inside the `SyncStateComparator` analysis
342
+ # pipeline. Kept as a no-op for backward compatibility in case of
343
+ # external callers expecting the method to exist.
344
+ return issues
345
+
346
+ def _convert_issue_changes_to_conflicts(
347
+ self,
348
+ issue_changes: list,
349
+ ) -> list[Conflict]:
350
+ """Convert three-way IssueChange objects to Conflict objects.
351
+
352
+ For compatibility with the existing conflict resolver,
353
+ converts the new IssueChange format to the legacy Conflict format.
354
+
355
+ Args:
356
+ issue_changes: List of IssueChange objects with conflicts
357
+
358
+ Returns:
359
+ List of Conflict objects
360
+ """
361
+ return self._engine._convert_issue_changes_to_conflicts(issue_changes)
362
+
363
+ def _create_issue_from_remote(
364
+ self, remote_id: str | int, remote_issue: SyncIssue
365
+ ) -> Issue:
366
+ """Create a local Issue from remote SyncIssue data.
367
+
368
+ Extracts relevant fields from remote issue and creates a local Issue object.
369
+ Adds "synced:from-github" label to mark as synced from remote.
370
+ Uses remote milestone if available, otherwise defaults to backlog.
371
+
372
+ Args:
373
+ remote_id: Remote issue ID (number)
374
+ remote_issue: SyncIssue object with remote data including:
375
+ - title: Issue title
376
+ - headline: Short description
377
+ - status: 'open', 'closed', etc.
378
+ - labels: List of label names
379
+ - assignee: Assignee login
380
+ - milestone: Milestone title or None
381
+ - backend_id: GitHub issue number
382
+
383
+ Returns:
384
+ New Issue object ready to be created
385
+ """
386
+ # Delegate to the extracted SyncMergeEngine implementation
387
+ return self._engine._create_issue_from_remote(
388
+ remote_id=remote_id, remote_issue=remote_issue
389
+ )
390
+
391
+ def _match_and_link_remote_issues(
392
+ self,
393
+ local_issues_dict: dict,
394
+ remote_issues_data: dict,
395
+ dry_run: bool = False,
396
+ ) -> dict[str, list[Any]]:
397
+ """Match unlinked remote issues to local issues and establish links.
398
+
399
+ For remote issues without existing local links, use similarity matching
400
+ to find potential local counterparts and link them.
401
+
402
+ Args:
403
+ local_issues_dict: Dict of local Issue objects keyed by ID
404
+ remote_issues_data: Dict of remote issue dicts keyed by ID
405
+ dry_run: If True, don't actually save changes to files
406
+
407
+ Returns:
408
+ Dict with keys 'auto_linked', 'potential_duplicates', 'new_remote'
409
+ containing lists of remote issue IDs
410
+ """
411
+ return self._engine._match_and_link_remote_issues(
412
+ local_issues_dict, remote_issues_data, dry_run=dry_run
413
+ )
414
+
415
+ def _load_baseline_safe(self):
416
+ """Load baseline state but swallow errors and return None on failure."""
417
+ return self._analysis_service.load_baseline_safe()
418
+
419
+ def _analyze_and_classify(self, local_issues_dict, remote_issues_data, base_state):
420
+ """Run comparator and classify changes into categories used by sync flow."""
421
+ return self._analysis_service.analyze_and_classify(
422
+ local_issues_dict, remote_issues_data, base_state
423
+ )
424
+
425
+ def _populate_report_fields(
426
+ self,
427
+ report,
428
+ local_issues,
429
+ active_issues_count,
430
+ archived_issues_count,
431
+ all_milestones,
432
+ active_milestones_count,
433
+ archived_milestones_count,
434
+ remote_issues_count,
435
+ remote_open_count,
436
+ remote_closed_count,
437
+ remote_milestones_count,
438
+ conflicts,
439
+ up_to_date,
440
+ local_only_changes,
441
+ remote_only_changes,
442
+ changes,
443
+ ):
444
+ """Populate the SyncReport fields from computed values."""
445
+ SyncReportService.populate_report_fields(
446
+ report,
447
+ local_issues,
448
+ active_issues_count,
449
+ archived_issues_count,
450
+ all_milestones,
451
+ active_milestones_count,
452
+ archived_milestones_count,
453
+ remote_issues_count,
454
+ remote_open_count,
455
+ remote_closed_count,
456
+ remote_milestones_count,
457
+ conflicts,
458
+ up_to_date,
459
+ local_only_changes,
460
+ remote_only_changes,
461
+ changes,
462
+ )
463
+
464
+ def sync_all_issues(
465
+ self,
466
+ dry_run: bool = True,
467
+ force_local: bool = False,
468
+ force_remote: bool = False,
469
+ push_only: bool = False,
470
+ pull_only: bool = False,
471
+ interactive_duplicates: bool = False,
472
+ ) -> SyncReport:
473
+ """Sync all issues using the configured backend.
474
+
475
+ Args:
476
+ dry_run: If True, only detect changes without applying them
477
+ force_local: Resolve conflicts by keeping local changes
478
+ force_remote: Resolve conflicts by keeping remote changes
479
+ push_only: If True, only push local changes (skip pulling remote)
480
+ pull_only: If True, only pull remote changes (skip pushing local)
481
+ interactive_duplicates: If True, use interactive resolution for duplicate matches
482
+
483
+ Returns:
484
+ SyncReport with detected changes and conflicts
485
+ """
486
+ from roadmap.common.logging import get_stack_trace
487
+
488
+ # Start metrics tracking
489
+ self._current_operation_id = self._observability.start_operation(
490
+ self.backend.__class__.__name__
491
+ )
492
+ sync_start_time = time.time()
493
+
494
+ report = SyncReport()
495
+ report.operation_id = self._current_operation_id
496
+
497
+ try:
498
+ logger.info(
499
+ "sync_all_issues_starting",
500
+ dry_run=dry_run,
501
+ force_local=force_local,
502
+ force_remote=force_remote,
503
+ sync_mode="analysis" if dry_run else "apply",
504
+ operation_id=self._current_operation_id,
505
+ )
506
+ logger.debug(
507
+ "sync_triggered_from",
508
+ stack=get_stack_trace(depth=4),
509
+ )
510
+
511
+ # Phase 1: Initialize
512
+ remote_issues_data, local_issues_dict = self._sync_initialize(
513
+ dry_run, interactive_duplicates, report
514
+ )
515
+ if remote_issues_data is None or local_issues_dict is None:
516
+ return report
517
+
518
+ # Phase 2: Deduplicate (detect and remove duplicates from data structures)
519
+ remote_issues_data, local_issues_dict = self._sync_deduplicate(
520
+ remote_issues_data,
521
+ local_issues_dict,
522
+ interactive_duplicates,
523
+ dry_run,
524
+ report,
525
+ )
526
+ if remote_issues_data is None or local_issues_dict is None:
527
+ return report
528
+
529
+ # Phase 3: Analyze changes
530
+ analysis_start = time.time()
531
+ (
532
+ changes,
533
+ conflicts,
534
+ local_only_changes,
535
+ remote_only_changes,
536
+ no_changes,
537
+ updates,
538
+ pulls,
539
+ up_to_date,
540
+ ) = self._sync_analyze(local_issues_dict, remote_issues_data, report)
541
+ analysis_duration = time.time() - analysis_start
542
+ if self._current_operation_id:
543
+ self._observability.record_phase_timing(
544
+ self._current_operation_id,
545
+ "analysis",
546
+ analysis_duration,
547
+ )
548
+
549
+ # Phase 4: Resolve and apply
550
+ apply_start = time.time()
551
+ report = self._sync_resolve_and_apply(
552
+ conflicts,
553
+ updates,
554
+ pulls,
555
+ dry_run,
556
+ force_local,
557
+ force_remote,
558
+ push_only,
559
+ pull_only,
560
+ report,
561
+ )
562
+ apply_duration = time.time() - apply_start
563
+ if self._current_operation_id:
564
+ self._observability.record_phase_timing(
565
+ self._current_operation_id,
566
+ "merge",
567
+ apply_duration,
568
+ )
569
+
570
+ # Phase 5: Execute duplicate resolution (merge/archive)
571
+ local_issues_after_sync = list(local_issues_dict.values())
572
+ self._execute_duplicate_resolution(
573
+ local_issues_after_sync,
574
+ report=report,
575
+ dry_run=dry_run,
576
+ interactive_duplicates=interactive_duplicates,
577
+ )
578
+
579
+ # Phase 6: Record metrics
580
+ self._sync_record_metrics(
581
+ sync_start_time,
582
+ conflicts,
583
+ local_issues_dict,
584
+ report,
585
+ )
586
+
587
+ logger.info("sync_all_issues_completed", error=report.error)
588
+ return report
589
+
590
+ except Exception as e:
591
+ report.error = str(e)
592
+ if self._current_operation_id:
593
+ self._observability.record_error(
594
+ self._current_operation_id,
595
+ type(e).__name__,
596
+ str(e),
597
+ )
598
+ logger.exception("sync_all_issues_failed")
599
+ return report
600
+
601
+ def _sync_initialize(
602
+ self,
603
+ dry_run: bool,
604
+ interactive_duplicates: bool,
605
+ report: SyncReport,
606
+ ) -> tuple[dict[str, SyncIssue] | None, dict[str, Issue] | None]:
607
+ """Initialize sync by fetching issues (deduplication is now a separate phase).
608
+
609
+ Returns:
610
+ Tuple of (remote_issues_data, local_issues_dict) or (None, None) on error
611
+ """
612
+ # Authenticate
613
+ if not self._ensure_authenticated(report):
614
+ return None, None
615
+
616
+ # Fetch remote + local
617
+ fetch_start = time.time()
618
+ remote_issues_data = self._fetch_remote_issues(report)
619
+ if remote_issues_data is None:
620
+ return None, None
621
+
622
+ local_issues = self._fetch_local_issues(report)
623
+ if local_issues is None:
624
+ return None, None
625
+ fetch_duration = time.time() - fetch_start
626
+
627
+ if self._current_operation_id:
628
+ self._observability.record_fetch(
629
+ self._current_operation_id,
630
+ count=len(remote_issues_data),
631
+ duration=fetch_duration,
632
+ )
633
+
634
+ local_issues_dict = {issue.id: issue for issue in local_issues}
635
+
636
+ logger.info(
637
+ "sync_state_fetched",
638
+ local_count=len(local_issues_dict),
639
+ remote_count=len(remote_issues_data),
640
+ )
641
+
642
+ return remote_issues_data, local_issues_dict
643
+
644
+ def _sync_deduplicate(
645
+ self,
646
+ remote_issues_data: dict[str, SyncIssue],
647
+ local_issues_dict: dict[str, Issue],
648
+ interactive_duplicates: bool,
649
+ dry_run: bool,
650
+ report: SyncReport,
651
+ ) -> tuple[dict[str, SyncIssue] | None, dict[str, Issue] | None]:
652
+ """Deduplicate phase: Detect and remove duplicates from data structures.
653
+
654
+ This is a SEPARATE phase that runs BEFORE analysis. It:
655
+ 1. Detects duplicates in local and remote issues
656
+ 2. Executes resolution (delete/archive) actions immediately
657
+ 3. REMOVES duplicates from the data structures
658
+ 4. Returns only canonical issues for analysis
659
+
660
+ Returns:
661
+ Tuple of (deduplicated_remote_issues_data, deduplicated_local_issues_dict)
662
+ or (None, None) on error
663
+ """
664
+ if not self.enable_duplicate_detection:
665
+ logger.info("duplicate_detection_disabled")
666
+ return remote_issues_data, local_issues_dict
667
+
668
+ local_issues = list(local_issues_dict.values())
669
+
670
+ logger.info(
671
+ "deduplication_phase_starting",
672
+ local_issues_count=len(local_issues),
673
+ remote_issues_count=len(remote_issues_data),
674
+ )
675
+
676
+ # Create and execute deduplicate service
677
+ dedup_service = DeduplicateService(
678
+ issue_repo=self.core.issue_service.repository,
679
+ duplicate_detector=self._duplicate_detector,
680
+ backend=self.backend, # Pass backend for remote duplicate closure
681
+ )
682
+
683
+ response = dedup_service.execute(
684
+ local_issues=local_issues,
685
+ remote_issues=remote_issues_data,
686
+ dry_run=dry_run,
687
+ )
688
+
689
+ if self._current_operation_id:
690
+ self._observability.record_local_dedup(
691
+ self._current_operation_id,
692
+ before=len(local_issues),
693
+ after=len(response.local_issues),
694
+ duration=response.local_dedup_duration,
695
+ )
696
+ self._observability.record_remote_dedup(
697
+ self._current_operation_id,
698
+ before=len(remote_issues_data),
699
+ after=len(response.remote_issues),
700
+ duration=response.remote_dedup_duration,
701
+ )
702
+ total_dups = response.local_duplicates + response.remote_duplicates
703
+ if total_dups:
704
+ self._observability.record_duplicate_detected(
705
+ self._current_operation_id,
706
+ count=total_dups,
707
+ )
708
+ self._observability.record_duplicate_resolved(
709
+ self._current_operation_id,
710
+ count=total_dups,
711
+ deleted=response.duplicates_removed,
712
+ archived=0,
713
+ )
714
+
715
+ logger.info(
716
+ "deduplication_phase_complete",
717
+ duplicates_removed=response.duplicates_removed,
718
+ )
719
+
720
+ # Update report with deduplication metrics
721
+ # duplicates_removed represents hard-deleted duplicates
722
+ report.issues_deleted = response.duplicates_removed
723
+
724
+ # Convert returned lists back to dict format for compatibility
725
+ deduplicated_local_dict = {issue.id: issue for issue in response.local_issues}
726
+ deduplicated_remote_dict = response.remote_issues
727
+
728
+ # Critical: Update baseline to remove deleted duplicates
729
+ # This ensures the invariant: after sync, local == remote == baseline
730
+ if response.duplicates_removed > 0 and not dry_run:
731
+ self._update_baseline_after_dedup(
732
+ deduplicated_local_dict,
733
+ deduplicated_remote_dict,
734
+ )
735
+
736
+ logger.info(
737
+ "deduplication_results",
738
+ local_before=len(local_issues_dict),
739
+ local_after=len(deduplicated_local_dict),
740
+ remote_before=len(remote_issues_data),
741
+ remote_after=len(deduplicated_remote_dict),
742
+ )
743
+
744
+ return deduplicated_remote_dict, deduplicated_local_dict
745
+
746
+ def _update_baseline_after_dedup(
747
+ self,
748
+ deduplicated_local_dict: dict[str, Issue],
749
+ deduplicated_remote_dict: dict[str, SyncIssue],
750
+ ) -> None:
751
+ """Update baseline to remove duplicates that were deleted.
752
+
753
+ After deduplication deletes issues from the database, we must also
754
+ update the baseline state to maintain the invariant:
755
+ baseline == local == remote
756
+
757
+ Args:
758
+ deduplicated_local_dict: Local issues dict after deduplication
759
+ deduplicated_remote_dict: Remote issues dict after deduplication
760
+ """
761
+ initial_count = 0
762
+ final_count = 0
763
+ try:
764
+ # Load current baseline from the core DB storage
765
+ baseline_state = self.core.db.get_sync_baseline()
766
+ if not baseline_state:
767
+ logger.info("baseline_not_found_after_dedup", skip_update=True)
768
+ return
769
+
770
+ initial_count = len(baseline_state)
771
+
772
+ # Get remaining issue IDs from deduplicated dicts (no re-enumeration!)
773
+ remaining_ids = set(deduplicated_local_dict.keys()) | set(
774
+ deduplicated_remote_dict.keys()
775
+ )
776
+
777
+ # Filter baseline to only include issues that still exist
778
+ filtered_baseline = {
779
+ issue_id: base_state
780
+ for issue_id, base_state in baseline_state.items()
781
+ if issue_id in remaining_ids
782
+ }
783
+
784
+ # Save updated baseline
785
+ success = self.core.db.save_sync_baseline(filtered_baseline)
786
+ final_count = len(filtered_baseline)
787
+
788
+ logger.info(
789
+ "baseline_updated_after_dedup",
790
+ deleted_count=initial_count - final_count,
791
+ baseline_before=initial_count,
792
+ baseline_after=final_count,
793
+ success=success,
794
+ )
795
+
796
+ except Exception as e:
797
+ logger.error(
798
+ "baseline_update_after_dedup_failed",
799
+ error=str(e),
800
+ deleted_count=initial_count - final_count,
801
+ )
802
+
803
+ def _sync_analyze(
804
+ self,
805
+ local_issues_dict: dict[str, Issue],
806
+ remote_issues_data: dict[str, SyncIssue],
807
+ report: SyncReport,
808
+ ) -> tuple[list, list, list, list, list, list, list, list]:
809
+ """Analyze changes between local and remote issues.
810
+
811
+ Returns:
812
+ Tuple of (changes, conflicts, local_only, remote_only, no_changes, updates, pulls, up_to_date)
813
+ """
814
+ # Update report with counts
815
+ local_issues = list(local_issues_dict.values())
816
+ active_issues_count, archived_issues_count = self._count_active_archived(
817
+ local_issues
818
+ )
819
+ all_milestones, active_milestones_count, archived_milestones_count = (
820
+ self._count_milestones()
821
+ )
822
+ (
823
+ remote_issues_count,
824
+ remote_open_count,
825
+ remote_closed_count,
826
+ remote_milestones_count,
827
+ ) = self._count_remote_stats(remote_issues_data)
828
+
829
+ # Load baseline state
830
+ base_state = self._load_baseline_safe()
831
+
832
+ # Match and link remote issues
833
+ _ = self._match_and_link_remote_issues(
834
+ local_issues_dict, remote_issues_data, dry_run=True
835
+ )
836
+
837
+ # Analyze changes
838
+ (
839
+ changes,
840
+ conflicts,
841
+ local_only_changes,
842
+ remote_only_changes,
843
+ no_changes,
844
+ updates,
845
+ pulls,
846
+ up_to_date,
847
+ ) = self._analyze_and_classify(
848
+ local_issues_dict, remote_issues_data, base_state
849
+ )
850
+
851
+ logger.debug(
852
+ "sync_analysis_complete",
853
+ conflicts=len(conflicts),
854
+ updates=len(updates),
855
+ pulls=len(pulls),
856
+ up_to_date=len(up_to_date),
857
+ )
858
+ if remote_only_changes:
859
+ logger.info(
860
+ "sync_remote_only_detected",
861
+ count=len(remote_only_changes),
862
+ hint="remote issues appear missing or unlinked locally",
863
+ )
864
+
865
+ # Populate report
866
+ self._populate_report_fields(
867
+ report,
868
+ local_issues,
869
+ active_issues_count,
870
+ archived_issues_count,
871
+ all_milestones,
872
+ active_milestones_count,
873
+ archived_milestones_count,
874
+ remote_issues_count,
875
+ remote_open_count,
876
+ remote_closed_count,
877
+ remote_milestones_count,
878
+ conflicts,
879
+ up_to_date,
880
+ local_only_changes,
881
+ remote_only_changes,
882
+ changes,
883
+ )
884
+
885
+ return (
886
+ changes,
887
+ conflicts,
888
+ local_only_changes,
889
+ remote_only_changes,
890
+ no_changes,
891
+ updates,
892
+ pulls,
893
+ up_to_date,
894
+ )
895
+
896
+ def _sync_resolve_and_apply(
897
+ self,
898
+ conflicts: list,
899
+ updates: list,
900
+ pulls: list,
901
+ dry_run: bool,
902
+ force_local: bool,
903
+ force_remote: bool,
904
+ push_only: bool,
905
+ pull_only: bool,
906
+ report: SyncReport,
907
+ ) -> SyncReport:
908
+ """Resolve conflicts and apply changes.
909
+
910
+ Args:
911
+ conflicts: List of conflicted issues
912
+ updates: List of issues to update
913
+ pulls: List of issues to pull
914
+ dry_run: Whether in dry_run mode
915
+ force_local: Force local resolution
916
+ force_remote: Force remote resolution
917
+ push_only: Only push changes
918
+ pull_only: Only pull changes
919
+ report: SyncReport to update
920
+
921
+ Returns:
922
+ Updated SyncReport
923
+ """
924
+ # Resolve conflicts
925
+ resolved_issues = self._resolve_conflicts_if_needed(
926
+ conflicts, force_local, force_remote
927
+ )
928
+
929
+ # Apply changes
930
+ updates_count = len(updates)
931
+ resolved_count = len(resolved_issues)
932
+ pulls_count = len(pulls)
933
+ should_apply = not dry_run and bool(updates or resolved_issues or pulls)
934
+
935
+ logger.info(
936
+ "applying_changes_check",
937
+ dry_run=dry_run,
938
+ updates_count=updates_count,
939
+ resolved_count=resolved_count,
940
+ pulls_count=pulls_count,
941
+ condition_result=should_apply,
942
+ )
943
+
944
+ if should_apply:
945
+ apply_start = time.time()
946
+ report = self._apply_plan(
947
+ updates,
948
+ resolved_issues,
949
+ pulls,
950
+ dry_run,
951
+ push_only,
952
+ pull_only,
953
+ report,
954
+ )
955
+ apply_duration = time.time() - apply_start
956
+ if self._current_operation_id:
957
+ total_applied = report.issues_pushed + report.issues_pulled
958
+ if total_applied > 0:
959
+ push_duration = (
960
+ apply_duration * (report.issues_pushed / total_applied)
961
+ if report.issues_pushed
962
+ else 0.0
963
+ )
964
+ pull_duration = (
965
+ apply_duration * (report.issues_pulled / total_applied)
966
+ if report.issues_pulled
967
+ else 0.0
968
+ )
969
+ else:
970
+ push_duration = 0.0
971
+ pull_duration = 0.0
972
+
973
+ if report.issues_pushed:
974
+ self._observability.record_push(
975
+ self._current_operation_id,
976
+ count=report.issues_pushed,
977
+ duration=push_duration,
978
+ )
979
+ if report.issues_pulled:
980
+ self._observability.record_pull(
981
+ self._current_operation_id,
982
+ count=report.issues_pulled,
983
+ duration=pull_duration,
984
+ )
985
+ elif dry_run:
986
+ logger.info("sync_dry_run_mode", skip_apply=True)
987
+
988
+ return report
989
+
990
+ def _execute_duplicate_resolution(
991
+ self,
992
+ actions_or_issues: list | None = None,
993
+ report: SyncReport | None = None,
994
+ dry_run: bool = False,
995
+ interactive_duplicates: bool = False,
996
+ ) -> None:
997
+ """Execute duplicate resolution (flexible signature for compatibility).
998
+
999
+ Can be called two ways:
1000
+ 1. Legacy: _execute_duplicate_resolution(actions_list, report)
1001
+ - Takes list of pre-made ResolutionAction objects
1002
+ - Executes them directly
1003
+ 2. Full: _execute_duplicate_resolution(local_issues, report=..., dry_run=..., interactive_duplicates=...)
1004
+ - Takes list of local issues
1005
+ - Detects and resolves duplicates
1006
+
1007
+ Args:
1008
+ actions_or_issues: List of actions OR list of issues (determines mode)
1009
+ report: SyncReport to update (positional for legacy compatibility)
1010
+ dry_run: If True, don't persist changes
1011
+ interactive_duplicates: If True, use interactive resolution
1012
+ """
1013
+ # Ensure we have a report
1014
+ if report is None:
1015
+ from roadmap.core.services.sync.sync_report import SyncReport
1016
+
1017
+ report = SyncReport()
1018
+
1019
+ # Empty list? Just return
1020
+ if not actions_or_issues:
1021
+ return
1022
+
1023
+ # Check if we're in legacy mode by looking at the first item
1024
+ first_item = actions_or_issues[0]
1025
+
1026
+ # Legacy mode indicators: Has 'action_type' attribute
1027
+ # Modern Issue objects don't have action_type
1028
+ is_legacy_action = hasattr(first_item, "action_type") and hasattr(
1029
+ first_item, "issue_id"
1030
+ )
1031
+
1032
+ if is_legacy_action:
1033
+ # Legacy mode: execute pre-made actions
1034
+ self._execute_resolution_actions(actions_or_issues, report, dry_run)
1035
+ return
1036
+
1037
+ # Full mode: detect and resolve
1038
+ if not self.enable_duplicate_detection:
1039
+ return
1040
+
1041
+ local_issues = actions_or_issues
1042
+
1043
+ try:
1044
+ logger.info(
1045
+ "duplicate_resolution_phase_starting", issue_count=len(local_issues)
1046
+ )
1047
+
1048
+ # Detect duplicates in local issues
1049
+ detector_result = self._duplicate_detector.detect_all(local_issues, {})
1050
+ if isinstance(detector_result, tuple):
1051
+ local_duplicates, remote_duplicates = detector_result
1052
+ else:
1053
+ local_duplicates = detector_result
1054
+ remote_duplicates = []
1055
+
1056
+ all_duplicates = local_duplicates + remote_duplicates
1057
+ if not all_duplicates:
1058
+ logger.info("duplicate_resolution_phase_complete", duplicates_found=0)
1059
+ return
1060
+
1061
+ logger.info(
1062
+ "duplicates_detected_for_resolution",
1063
+ total_matches=len(all_duplicates),
1064
+ )
1065
+
1066
+ # Resolve duplicates
1067
+ if interactive_duplicates:
1068
+ resolution_actions = self._duplicate_resolver.resolve_interactive(
1069
+ all_duplicates
1070
+ )
1071
+ else:
1072
+ resolution_result = self._duplicate_resolver.resolve_automatic(
1073
+ all_duplicates
1074
+ )
1075
+ if resolution_result.is_err():
1076
+ logger.warning(
1077
+ "automatic_resolution_failed",
1078
+ error=resolution_result.unwrap_err(),
1079
+ severity="operational",
1080
+ )
1081
+ return
1082
+
1083
+ resolution_actions = resolution_result.unwrap()
1084
+
1085
+ if not resolution_actions:
1086
+ logger.info("duplicate_resolution_phase_complete", actions_count=0)
1087
+ return
1088
+
1089
+ # Execute the resolved actions
1090
+ self._execute_resolution_actions(resolution_actions, report, dry_run)
1091
+
1092
+ except Exception as e:
1093
+ logger.error(
1094
+ "duplicate_resolution_phase_failed",
1095
+ error=str(e),
1096
+ severity="system_error",
1097
+ )
1098
+
1099
+ def _execute_resolution_actions(
1100
+ self,
1101
+ actions: list,
1102
+ report: SyncReport,
1103
+ dry_run: bool = False,
1104
+ ) -> None:
1105
+ """Execute a list of resolution actions.
1106
+
1107
+ Args:
1108
+ actions: List of ResolutionAction objects to execute
1109
+ report: SyncReport to update with counts
1110
+ dry_run: If True, don't persist changes
1111
+ """
1112
+ merged_count = 0
1113
+ archived_count = 0
1114
+ skipped_count = 0
1115
+ errors: list[str] = []
1116
+
1117
+ for action in actions:
1118
+ action_result = SyncMergeOrchestrator._execute_single_resolution_action(
1119
+ self,
1120
+ action,
1121
+ dry_run,
1122
+ errors,
1123
+ )
1124
+ if action_result == "merged":
1125
+ merged_count += 1
1126
+ elif action_result == "archived":
1127
+ archived_count += 1
1128
+ else:
1129
+ skipped_count += 1
1130
+
1131
+ # Update report
1132
+ total_actions = len(actions)
1133
+ report.duplicates_detected = total_actions
1134
+ report.duplicates_auto_resolved = merged_count + archived_count
1135
+ report.issues_deleted = merged_count
1136
+ report.issues_archived = archived_count
1137
+
1138
+ logger.info(
1139
+ "resolution_actions_executed",
1140
+ total=total_actions,
1141
+ deleted=merged_count,
1142
+ archived=archived_count,
1143
+ skipped=skipped_count,
1144
+ errors=len(errors),
1145
+ dry_run=dry_run,
1146
+ )
1147
+
1148
+ if errors:
1149
+ logger.warning("resolution_errors", count=len(errors))
1150
+ for error in errors:
1151
+ logger.warning("resolution_error", detail=error)
1152
+
1153
+ def _execute_single_resolution_action(
1154
+ self,
1155
+ action,
1156
+ dry_run: bool,
1157
+ errors: list[str],
1158
+ ) -> str:
1159
+ """Execute one resolution action and return merged|archived|skipped."""
1160
+ if SyncMergeOrchestrator._action_has_preexisting_error(self, action):
1161
+ return "skipped"
1162
+
1163
+ action_type = getattr(action, "action_type", None)
1164
+ issue_id = getattr(action, "issue_id", None)
1165
+ if not issue_id:
1166
+ return "skipped"
1167
+
1168
+ if action_type in {"delete", "merge"}:
1169
+ return SyncMergeOrchestrator._handle_delete_resolution(
1170
+ self,
1171
+ issue_id,
1172
+ dry_run,
1173
+ errors,
1174
+ )
1175
+ if action_type == "archive":
1176
+ return SyncMergeOrchestrator._handle_archive_resolution(
1177
+ self,
1178
+ issue_id,
1179
+ dry_run,
1180
+ errors,
1181
+ )
1182
+ return "skipped"
1183
+
1184
+ def _action_has_preexisting_error(self, action) -> bool:
1185
+ """Check and log when action is pre-marked with an error."""
1186
+ if not (hasattr(action, "error") and action.error):
1187
+ return False
1188
+ logger.debug(
1189
+ "skipping_action_with_error",
1190
+ issue_id=getattr(action, "issue_id", None),
1191
+ error=action.error,
1192
+ )
1193
+ return True
1194
+
1195
+ def _handle_delete_resolution(
1196
+ self,
1197
+ issue_id: str,
1198
+ dry_run: bool,
1199
+ errors: list[str],
1200
+ ) -> str:
1201
+ """Handle delete/merge resolution action."""
1202
+ if dry_run:
1203
+ return "merged"
1204
+ try:
1205
+ self.core.issue_service.delete_issue(issue_id)
1206
+ logger.info("duplicate_deleted", issue_id=issue_id)
1207
+ return "merged"
1208
+ except Exception as e:
1209
+ errors.append(str(e))
1210
+ logger.warning("delete_failed", issue_id=issue_id, error=str(e))
1211
+ return "skipped"
1212
+
1213
+ def _handle_archive_resolution(
1214
+ self,
1215
+ issue_id: str,
1216
+ dry_run: bool,
1217
+ errors: list[str],
1218
+ ) -> str:
1219
+ """Handle archive resolution action."""
1220
+ if dry_run:
1221
+ return "archived"
1222
+ try:
1223
+ issue = self.core.issue_service.get_issue(issue_id)
1224
+ if not issue:
1225
+ logger.warning("archive_issue_not_found", issue_id=issue_id)
1226
+ return "skipped"
1227
+ from roadmap.common.constants import Status
1228
+
1229
+ issue.status = Status.ARCHIVED
1230
+ self.core.issue_service.update_issue(issue)
1231
+ logger.info("duplicate_archived", issue_id=issue_id)
1232
+ return "archived"
1233
+ except Exception as e:
1234
+ errors.append(str(e))
1235
+ logger.warning("archive_failed", issue_id=issue_id, error=str(e))
1236
+ return "skipped"
1237
+
1238
+ def _sync_record_metrics(
1239
+ self,
1240
+ sync_start_time: float,
1241
+ conflicts: list,
1242
+ local_issues_dict: dict[str, Issue],
1243
+ report: SyncReport,
1244
+ ) -> None:
1245
+ """Record sync operation metrics.
1246
+
1247
+ Args:
1248
+ sync_start_time: Timestamp when sync started
1249
+ conflicts: List of conflicts encountered
1250
+ local_issues_dict: Dictionary of local issues
1251
+ report: SyncReport to attach metrics to
1252
+ """
1253
+ if not self._current_operation_id:
1254
+ return
1255
+
1256
+ sync_duration = time.time() - sync_start_time
1257
+ self._observability.record_conflict(
1258
+ self._current_operation_id, count=len(conflicts)
1259
+ )
1260
+ self._observability.record_metadata(
1261
+ self._current_operation_id,
1262
+ "total_duration_seconds",
1263
+ round(sync_duration, 4),
1264
+ )
1265
+ self._observability.record_sync_links(
1266
+ self._current_operation_id, created_count=len(local_issues_dict)
1267
+ )
1268
+ if report.error:
1269
+ self._observability.record_error(
1270
+ self._current_operation_id,
1271
+ "sync_error",
1272
+ report.error,
1273
+ )
1274
+ if report.errors:
1275
+ for issue_id, message in report.errors.items():
1276
+ self._observability.record_error(
1277
+ self._current_operation_id,
1278
+ "issue_error",
1279
+ f"{issue_id}: {message}",
1280
+ )
1281
+ bucket = self._bucket_error(message)
1282
+ report.error_buckets[bucket] = report.error_buckets.get(bucket, 0) + 1
1283
+ final_metrics = self._observability.finalize(self._current_operation_id)
1284
+ report.metrics = final_metrics
1285
+
1286
+ # Store metrics to database for historical tracking
1287
+ from roadmap.core.services.sync.sync_metadata_service import (
1288
+ SyncMetadataService,
1289
+ )
1290
+
1291
+ metadata_service = SyncMetadataService(self.core)
1292
+ metadata_service.store_sync_metrics(
1293
+ self.core, self._current_operation_id, final_metrics.to_dict()
1294
+ )
1295
+
1296
+ @staticmethod
1297
+ def _bucket_error(message: str) -> str:
1298
+ lowered = message.lower()
1299
+ if "rate limit" in lowered or "429" in lowered:
1300
+ return "rate_limited"
1301
+ if "permission" in lowered or "403" in lowered:
1302
+ return "permission"
1303
+ if "not found" in lowered or "404" in lowered:
1304
+ return "not_found"
1305
+ if "milestone" in lowered:
1306
+ return "milestone"
1307
+ if "timeout" in lowered:
1308
+ return "timeout"
1309
+ if "database" in lowered or "sqlite" in lowered:
1310
+ return "database"
1311
+ if "yaml" in lowered or "file" in lowered:
1312
+ return "filesystem"
1313
+ return "other"