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,771 @@
1
+ """Duplicate detection for sync operations.
2
+
3
+ This module provides duplicate detection strategies to identify issues that may
4
+ represent the same entity across local and remote systems. Detects ID collisions,
5
+ title duplicates, and content duplicates using various similarity metrics.
6
+ """
7
+
8
+ import time
9
+ from difflib import SequenceMatcher
10
+ from enum import StrEnum
11
+
12
+ import structlog
13
+
14
+ from roadmap.common.union_find import UnionFind
15
+ from roadmap.core.domain.issue import Issue
16
+ from roadmap.core.models.sync_models import SyncIssue
17
+
18
+ logger = structlog.get_logger()
19
+
20
+
21
+ class MatchType(StrEnum):
22
+ """Type of duplicate match detected."""
23
+
24
+ ID_COLLISION = "id_collision" # Same ID, different content
25
+ TITLE_EXACT = "title_exact" # Exact title match
26
+ TITLE_SIMILAR = "title_similar" # >90% title similarity
27
+ CONTENT_SIMILAR = "content_similar" # >85% content similarity
28
+
29
+
30
+ class RecommendedAction(StrEnum):
31
+ """Recommended action for handling a duplicate match."""
32
+
33
+ AUTO_MERGE = "auto_merge" # High confidence, can auto-resolve
34
+ MANUAL_REVIEW = "manual_review" # Requires human judgment
35
+ SKIP = "skip" # Low confidence, likely false positive
36
+
37
+
38
+ class DuplicateMatch:
39
+ """Represents a potential duplicate between local and remote issues.
40
+
41
+ Attributes:
42
+ local_issue: The local Issue object
43
+ remote_issue: The remote SyncIssue object
44
+ match_type: Type of duplicate detected
45
+ confidence: Confidence score (0.0 to 1.0)
46
+ recommended_action: Suggested action for handling this match
47
+ similarity_details: Additional details about the match
48
+ """
49
+
50
+ local_issue: Issue
51
+ remote_issue: SyncIssue
52
+ match_type: MatchType
53
+ confidence: float
54
+ recommended_action: RecommendedAction
55
+ similarity_details: dict[str, float | str] | None = None
56
+
57
+ def __init__(
58
+ self,
59
+ local_issue: Issue,
60
+ remote_issue: SyncIssue,
61
+ match_type: MatchType,
62
+ confidence: float,
63
+ recommended_action: RecommendedAction,
64
+ similarity_details: dict[str, float | str] | None = None,
65
+ ) -> None:
66
+ """Initialize a DuplicateMatch with required metadata.
67
+
68
+ Args:
69
+ local_issue: Local issue instance.
70
+ remote_issue: Remote sync issue instance.
71
+ match_type: The detected match type.
72
+ confidence: Confidence score between 0.0 and 1.0.
73
+ recommended_action: Suggested resolution action.
74
+ similarity_details: Optional extras about similarity metrics.
75
+ """
76
+ self.local_issue = local_issue
77
+ self.remote_issue = remote_issue
78
+ self.match_type = match_type
79
+ self.confidence = confidence
80
+ self.recommended_action = recommended_action
81
+ self.similarity_details = similarity_details
82
+
83
+ # Validate confidence is in valid range.
84
+ if not 0.0 <= self.confidence <= 1.0:
85
+ raise ValueError(
86
+ f"Confidence must be between 0.0 and 1.0, got {self.confidence}"
87
+ )
88
+
89
+
90
+ class DuplicateDetector:
91
+ """Detects potential duplicates between local and remote issues.
92
+
93
+ Uses multiple detection strategies:
94
+ - ID collision detection (same GitHub number, different content)
95
+ - Title matching (exact and fuzzy)
96
+ - Content similarity (using SequenceMatcher)
97
+ """
98
+
99
+ def __init__(
100
+ self,
101
+ title_similarity_threshold: float = 0.90,
102
+ content_similarity_threshold: float = 0.85,
103
+ auto_resolve_threshold: float = 0.95,
104
+ enable_fuzzy_matching: bool = False,
105
+ ) -> None:
106
+ """Initialize duplicate detector with configurable thresholds.
107
+
108
+ Args:
109
+ title_similarity_threshold: Minimum similarity for title duplicates (default: 0.90)
110
+ content_similarity_threshold: Minimum similarity for content duplicates (default: 0.85)
111
+ auto_resolve_threshold: Minimum confidence for auto-resolution (default: 0.95)
112
+ enable_fuzzy_matching: Enable fuzzy matching in local/remote self-dedup (slower, catches more)
113
+ """
114
+ self.title_similarity_threshold = title_similarity_threshold
115
+ self.content_similarity_threshold = content_similarity_threshold
116
+ self.auto_resolve_threshold = auto_resolve_threshold
117
+ self.enable_fuzzy_matching = enable_fuzzy_matching
118
+
119
+ def detect_all(
120
+ self, local_issues: list[Issue], remote_issues: dict[str, SyncIssue]
121
+ ) -> list[DuplicateMatch]:
122
+ """Run all detection strategies and return all matches.
123
+
124
+ NOTE: This method expects deduplicated input sets (via local_self_dedup and
125
+ remote_self_dedup) before being called for cross-comparison. Without dedup
126
+ preprocessing, title matching can produce thousands of spurious matches.
127
+
128
+ Args:
129
+ local_issues: List of deduplicated local Issue objects
130
+ remote_issues: Dictionary mapping remote IDs to deduplicated SyncIssue objects
131
+
132
+ Returns:
133
+ List of DuplicateMatch objects, sorted by confidence (highest first)
134
+ """
135
+ matches: list[DuplicateMatch] = []
136
+
137
+ for local_issue in local_issues:
138
+ # Check for ID collisions first (highest priority)
139
+ # This catches: same GitHub issue number with different local ID
140
+ id_matches = self._detect_id_collisions(local_issue, remote_issues)
141
+ matches.extend(id_matches)
142
+
143
+ # Title matching: now safe because input should be deduplicated via
144
+ # local_self_dedup() and remote_self_dedup() in the orchestrator.
145
+ # With deduplicated canonical sets (~100 issues each), this is O(100²) = 10K comparisons.
146
+ # Without dedup, with 1800+ issues each, this would be O(3M+) and produce 80K+ false positives.
147
+ title_matches = self._detect_title_duplicates(local_issue, remote_issues)
148
+ matches.extend(title_matches)
149
+
150
+ # NOTE: Content duplicate detection disabled - too expensive even with dedup
151
+ # content_matches = self._detect_content_duplicates(
152
+ # local_issue, remote_issues
153
+ # )
154
+ # matches.extend(content_matches)
155
+
156
+ # Remove duplicate matches (same local/remote pair)
157
+ matches = self._deduplicate_matches(matches)
158
+
159
+ # Sort by confidence (highest first)
160
+ matches.sort(key=lambda m: m.confidence, reverse=True)
161
+
162
+ return matches
163
+
164
+ def _detect_id_collisions(
165
+ self, local_issue: Issue, remote_issues: dict[str, SyncIssue]
166
+ ) -> list[DuplicateMatch]:
167
+ """Detect ID collisions (same GitHub number, different content).
168
+
169
+ Args:
170
+ local_issue: Local issue to check
171
+ remote_issues: Dictionary of remote issues
172
+
173
+ Returns:
174
+ List of DuplicateMatch objects for ID collisions
175
+ """
176
+ matches: list[DuplicateMatch] = []
177
+
178
+ # Check if local issue has a GitHub remote link
179
+ if not local_issue.remote_ids or "github" not in local_issue.remote_ids:
180
+ return matches
181
+
182
+ # Extract GitHub issue number
183
+ github_number = local_issue.remote_ids.get("github")
184
+ if not github_number:
185
+ return matches
186
+
187
+ # Check if remote has an issue with the same number
188
+ for _remote_id, remote_issue in remote_issues.items():
189
+ remote_number = (
190
+ remote_issue.backend_id
191
+ if remote_issue.backend_name == "github"
192
+ else None
193
+ )
194
+ if remote_number == github_number:
195
+ # Found ID collision - same number, check if content differs
196
+ title_similarity = self._calculate_text_similarity(
197
+ local_issue.title, remote_issue.title
198
+ )
199
+ content_similarity = self._calculate_text_similarity(
200
+ local_issue.content or "",
201
+ getattr(remote_issue, "headline", "") or "",
202
+ )
203
+
204
+ # If content is significantly different, it's a collision
205
+ if title_similarity < 0.80 or content_similarity < 0.80:
206
+ matches.append(
207
+ DuplicateMatch(
208
+ local_issue=local_issue,
209
+ remote_issue=remote_issue,
210
+ match_type=MatchType.ID_COLLISION,
211
+ confidence=1.0, # ID collision is definitive
212
+ recommended_action=RecommendedAction.MANUAL_REVIEW,
213
+ similarity_details={
214
+ "github_number": github_number,
215
+ "title_similarity": title_similarity,
216
+ "content_similarity": content_similarity,
217
+ },
218
+ )
219
+ )
220
+
221
+ return matches
222
+
223
+ def _detect_title_duplicates(
224
+ self, local_issue: Issue, remote_issues: dict[str, SyncIssue]
225
+ ) -> list[DuplicateMatch]:
226
+ """Detect title duplicates (exact and fuzzy matching).
227
+
228
+ NOTE: This method works well only when called with deduplicated inputs.
229
+ Without dedup preprocessing, fuzzy matching can produce thousands of
230
+ spurious matches with large datasets.
231
+
232
+ Args:
233
+ local_issue: Local issue to check
234
+ remote_issues: Dictionary of remote issues
235
+
236
+ Returns:
237
+ List of DuplicateMatch objects for title duplicates
238
+ """
239
+ matches: list[DuplicateMatch] = []
240
+ local_title = local_issue.title.strip().lower()
241
+
242
+ for _remote_id, remote_issue in remote_issues.items():
243
+ remote_title = remote_issue.title.strip().lower()
244
+
245
+ # Check for exact match first
246
+ if local_title == remote_title:
247
+ matches.append(
248
+ DuplicateMatch(
249
+ local_issue=local_issue,
250
+ remote_issue=remote_issue,
251
+ match_type=MatchType.TITLE_EXACT,
252
+ confidence=0.98, # High confidence for exact match
253
+ recommended_action=RecommendedAction.AUTO_MERGE,
254
+ similarity_details={
255
+ "title_similarity": 1.0,
256
+ "match_reason": "exact_title_match",
257
+ },
258
+ )
259
+ )
260
+ continue
261
+
262
+ # Check for fuzzy title match using SequenceMatcher
263
+ # Safe to do here because input should be deduplicated
264
+ similarity = SequenceMatcher(None, local_title, remote_title).ratio()
265
+
266
+ if similarity >= self.title_similarity_threshold:
267
+ matches.append(
268
+ DuplicateMatch(
269
+ local_issue=local_issue,
270
+ remote_issue=remote_issue,
271
+ match_type=MatchType.TITLE_SIMILAR,
272
+ confidence=similarity,
273
+ recommended_action=RecommendedAction.MANUAL_REVIEW,
274
+ similarity_details={
275
+ "title_similarity": similarity,
276
+ "match_reason": "fuzzy_title_match",
277
+ },
278
+ )
279
+ )
280
+
281
+ return matches
282
+
283
+ def _detect_content_duplicates(
284
+ self, local_issue: Issue, remote_issues: dict[str, SyncIssue]
285
+ ) -> list[DuplicateMatch]:
286
+ """Detect content duplicates (>85% text similarity).
287
+
288
+ Args:
289
+ local_issue: Local issue to check
290
+ remote_issues: Dictionary of remote issues
291
+
292
+ Returns:
293
+ List of DuplicateMatch objects for content duplicates
294
+ """
295
+ matches: list[DuplicateMatch] = []
296
+
297
+ # Skip if local issue has no content
298
+ if not local_issue.content or not local_issue.content.strip():
299
+ return matches
300
+
301
+ # Skip if no content to compare
302
+ local_content = local_issue.content.strip() if local_issue.content else ""
303
+
304
+ for _remote_id, remote_issue in remote_issues.items():
305
+ # Get remote content (try headline or metadata)
306
+ remote_content = ""
307
+ if hasattr(remote_issue, "headline") and remote_issue.headline:
308
+ remote_content = remote_issue.headline.strip()
309
+ elif hasattr(remote_issue, "metadata") and remote_issue.metadata.get(
310
+ "content"
311
+ ):
312
+ remote_content = remote_issue.metadata.get("content", "").strip()
313
+
314
+ # Skip if remote issue has no content
315
+ if not remote_content:
316
+ continue
317
+
318
+ # Calculate content similarity
319
+ content_similarity = self._calculate_text_similarity(
320
+ local_content, remote_content
321
+ )
322
+
323
+ if content_similarity >= self.content_similarity_threshold:
324
+ # Also check title similarity for better confidence
325
+ title_similarity = self._calculate_text_similarity(
326
+ local_issue.title, remote_issue.title
327
+ )
328
+
329
+ # Combined confidence: weighted average (60% content, 40% title)
330
+ combined_confidence = (content_similarity * 0.6) + (
331
+ title_similarity * 0.4
332
+ )
333
+
334
+ # Determine action based on combined confidence
335
+ action = (
336
+ RecommendedAction.AUTO_MERGE
337
+ if combined_confidence >= self.auto_resolve_threshold
338
+ else RecommendedAction.MANUAL_REVIEW
339
+ )
340
+
341
+ matches.append(
342
+ DuplicateMatch(
343
+ local_issue=local_issue,
344
+ remote_issue=remote_issue,
345
+ match_type=MatchType.CONTENT_SIMILAR,
346
+ confidence=combined_confidence,
347
+ recommended_action=action,
348
+ similarity_details={
349
+ "content_similarity": content_similarity,
350
+ "title_similarity": title_similarity,
351
+ "match_reason": "content_similarity",
352
+ },
353
+ )
354
+ )
355
+
356
+ return matches
357
+
358
+ def _calculate_text_similarity(self, text1: str, text2: str) -> float:
359
+ """Calculate similarity between two text strings using SequenceMatcher.
360
+
361
+ Args:
362
+ text1: First text string
363
+ text2: Second text string
364
+
365
+ Returns:
366
+ Similarity ratio between 0.0 and 1.0
367
+ """
368
+ # Normalize whitespace and case
369
+ text1 = " ".join(text1.lower().split())
370
+ text2 = " ".join(text2.lower().split())
371
+
372
+ # Use SequenceMatcher for similarity calculation
373
+ matcher = SequenceMatcher(None, text1, text2)
374
+ return matcher.ratio()
375
+
376
+ def _extract_github_number(self, github_link: str) -> str | None:
377
+ """Extract GitHub issue number from a link or ID.
378
+
379
+ Args:
380
+ github_link: GitHub issue link or ID (e.g., "https://github.com/owner/repo/issues/123" or "#123")
381
+
382
+ Returns:
383
+ GitHub issue number as string, or None if not found
384
+ """
385
+ if not github_link:
386
+ return None
387
+
388
+ # Handle direct number format like "#123"
389
+ if github_link.startswith("#"):
390
+ return github_link[1:]
391
+
392
+ # Handle full URL format
393
+ parts = github_link.rstrip("/").split("/")
394
+ if len(parts) >= 2 and parts[-2] == "issues":
395
+ return parts[-1]
396
+
397
+ return None
398
+
399
+ def _deduplicate_matches(
400
+ self, matches: list[DuplicateMatch]
401
+ ) -> list[DuplicateMatch]:
402
+ """Remove duplicate matches for the same local/remote pair.
403
+
404
+ Keeps the match with highest confidence.
405
+
406
+ Args:
407
+ matches: List of DuplicateMatch objects
408
+
409
+ Returns:
410
+ Deduplicated list of matches
411
+ """
412
+ # Group by (local_id, remote_id) pair
413
+ match_map: dict[tuple[str, str], DuplicateMatch] = {}
414
+
415
+ for match in matches:
416
+ key = (match.local_issue.id, match.remote_issue.id)
417
+
418
+ # Keep match with higher confidence
419
+ if key not in match_map or match.confidence > match_map[key].confidence:
420
+ match_map[key] = match
421
+
422
+ return list(match_map.values())
423
+
424
+ def _build_title_buckets_local(
425
+ self, local_issues: list[Issue]
426
+ ) -> dict[str, list[Issue]]:
427
+ title_buckets: dict[str, list[Issue]] = {}
428
+ for issue in local_issues:
429
+ title_buckets.setdefault(issue.title, []).append(issue)
430
+ return title_buckets
431
+
432
+ def _union_title_buckets_local(
433
+ self, uf: UnionFind, title_buckets: dict[str, list[Issue]]
434
+ ) -> int:
435
+ title_match_count = 0
436
+ for issues_in_bucket in title_buckets.values():
437
+ if len(issues_in_bucket) > 1:
438
+ first_issue_id = id(issues_in_bucket[0])
439
+ first_issue = issues_in_bucket[0]
440
+ for other_issue in issues_in_bucket[1:]:
441
+ uf.union(first_issue_id, id(other_issue))
442
+ logger.debug(
443
+ "duplicate_detected_local_title_match",
444
+ primary_id=first_issue.id,
445
+ primary_title=first_issue.title,
446
+ duplicate_id=other_issue.id,
447
+ duplicate_title=other_issue.title,
448
+ match_type="title_exact",
449
+ )
450
+ title_match_count += 1
451
+ return title_match_count
452
+
453
+ def _union_github_id_collisions_local(
454
+ self, uf: UnionFind, local_issues: list[Issue]
455
+ ) -> int:
456
+ id_collision_count = 0
457
+ github_id_map: dict[str, list[Issue]] = {}
458
+ for issue in local_issues:
459
+ github_id = issue.remote_ids.get("github")
460
+ if github_id:
461
+ github_id_key = str(github_id)
462
+ github_id_map.setdefault(github_id_key, []).append(issue)
463
+
464
+ for github_id, issues_with_id in github_id_map.items():
465
+ if len(issues_with_id) > 1:
466
+ first_issue_id = id(issues_with_id[0])
467
+ first_issue = issues_with_id[0]
468
+ for other_issue in issues_with_id[1:]:
469
+ uf.union(first_issue_id, id(other_issue))
470
+ logger.debug(
471
+ "duplicate_detected_local_id_collision",
472
+ primary_id=first_issue.id,
473
+ primary_title=first_issue.title,
474
+ duplicate_id=other_issue.id,
475
+ duplicate_title=other_issue.title,
476
+ match_type="id_collision",
477
+ github_id=str(github_id),
478
+ )
479
+ id_collision_count += 1
480
+ return id_collision_count
481
+
482
+ def _apply_fuzzy_matches_local(
483
+ self,
484
+ uf: UnionFind,
485
+ local_issues: list[Issue],
486
+ title_buckets: dict[str, list[Issue]],
487
+ ) -> int:
488
+ similarity_match_count = 0
489
+ if not self.enable_fuzzy_matching:
490
+ return similarity_match_count
491
+
492
+ fuzzy_buckets: dict[str, list[Issue]] = {}
493
+ for issue in local_issues:
494
+ if issue.title in title_buckets and len(title_buckets[issue.title]) > 1:
495
+ continue
496
+ normalized_title = issue.title.lower().strip()
497
+ if normalized_title:
498
+ fuzzy_key = normalized_title[:3]
499
+ fuzzy_buckets.setdefault(fuzzy_key, []).append(issue)
500
+
501
+ for issues_in_bucket in fuzzy_buckets.values():
502
+ if len(issues_in_bucket) > 1:
503
+ for i, issue1 in enumerate(issues_in_bucket):
504
+ for issue2 in issues_in_bucket[i + 1 :]:
505
+ title_similarity = self._calculate_text_similarity(
506
+ issue1.title, issue2.title
507
+ )
508
+ if title_similarity >= self.title_similarity_threshold:
509
+ uf.union(id(issue1), id(issue2))
510
+ logger.debug(
511
+ "duplicate_detected_local_similarity",
512
+ primary_id=issue1.id,
513
+ primary_title=issue1.title,
514
+ duplicate_id=issue2.id,
515
+ duplicate_title=issue2.title,
516
+ match_type="title_similar",
517
+ similarity=round(title_similarity, 3),
518
+ )
519
+ similarity_match_count += 1
520
+ return similarity_match_count
521
+
522
+ def _build_title_buckets_remote(
523
+ self, remote_issues: dict[str, "SyncIssue"], remote_id_list: list[str]
524
+ ) -> dict[str, list[str]]:
525
+ title_buckets: dict[str, list[str]] = {}
526
+ for rid in remote_id_list:
527
+ issue = remote_issues[rid]
528
+ title_buckets.setdefault(issue.title, []).append(rid)
529
+ return title_buckets
530
+
531
+ def _union_title_buckets_remote(
532
+ self,
533
+ uf: UnionFind,
534
+ title_buckets: dict[str, list[str]],
535
+ remote_issues: dict[str, "SyncIssue"],
536
+ ) -> int:
537
+ title_match_count = 0
538
+ for rids_in_bucket in title_buckets.values():
539
+ if len(rids_in_bucket) > 1:
540
+ first_rid = rids_in_bucket[0]
541
+ first_issue = remote_issues[first_rid]
542
+ for other_rid in rids_in_bucket[1:]:
543
+ other_issue = remote_issues[other_rid]
544
+ uf.union(first_rid, other_rid)
545
+ logger.debug(
546
+ "duplicate_detected_remote_title_match",
547
+ primary_id=first_issue.id,
548
+ primary_title=first_issue.title,
549
+ duplicate_id=other_issue.id,
550
+ duplicate_title=other_issue.title,
551
+ match_type="title_exact",
552
+ )
553
+ title_match_count += 1
554
+ return title_match_count
555
+
556
+ def _union_backend_id_collisions_remote(
557
+ self,
558
+ uf: UnionFind,
559
+ remote_issues: dict[str, "SyncIssue"],
560
+ remote_id_list: list[str],
561
+ ) -> int:
562
+ id_collision_count = 0
563
+ backend_id_map: dict[str, list[str]] = {}
564
+ for rid in remote_id_list:
565
+ issue = remote_issues[rid]
566
+ if issue.backend_id:
567
+ backend_id_key = str(issue.backend_id)
568
+ backend_id_map.setdefault(backend_id_key, []).append(rid)
569
+
570
+ for backend_id, rids_with_id in backend_id_map.items():
571
+ if len(rids_with_id) > 1:
572
+ first_rid = rids_with_id[0]
573
+ first_issue = remote_issues[first_rid]
574
+ for other_rid in rids_with_id[1:]:
575
+ other_issue = remote_issues[other_rid]
576
+ uf.union(first_rid, other_rid)
577
+ logger.debug(
578
+ "duplicate_detected_remote_id_collision",
579
+ primary_id=first_issue.id,
580
+ primary_title=first_issue.title,
581
+ duplicate_id=other_issue.id,
582
+ duplicate_title=other_issue.title,
583
+ match_type="id_collision",
584
+ backend_id=str(backend_id),
585
+ )
586
+ id_collision_count += 1
587
+ return id_collision_count
588
+
589
+ def _apply_fuzzy_matches_remote(
590
+ self,
591
+ uf: UnionFind,
592
+ remote_issues: dict[str, "SyncIssue"],
593
+ title_buckets: dict[str, list[str]],
594
+ remote_id_list: list[str],
595
+ ) -> int:
596
+ similarity_match_count = 0
597
+ if not self.enable_fuzzy_matching:
598
+ return similarity_match_count
599
+
600
+ fuzzy_buckets: dict[str, list[str]] = {}
601
+ for rid in remote_id_list:
602
+ issue = remote_issues[rid]
603
+ if issue.title in title_buckets and len(title_buckets[issue.title]) > 1:
604
+ continue
605
+
606
+ normalized = issue.title.lower().strip()
607
+ if normalized:
608
+ fuzzy_key = normalized[:3]
609
+ fuzzy_buckets.setdefault(fuzzy_key, []).append(rid)
610
+
611
+ for bucket_rids in fuzzy_buckets.values():
612
+ for i, rid1 in enumerate(bucket_rids):
613
+ for rid2 in bucket_rids[i + 1 :]:
614
+ issue1 = remote_issues[rid1]
615
+ issue2 = remote_issues[rid2]
616
+ similarity = self._calculate_text_similarity(
617
+ issue1.title, issue2.title
618
+ )
619
+
620
+ if similarity >= self.title_similarity_threshold:
621
+ uf.union(rid1, rid2)
622
+ similarity_match_count += 1
623
+ logger.debug(
624
+ "duplicate_detected_remote_similarity",
625
+ primary_id=issue1.id,
626
+ primary_title=issue1.title,
627
+ duplicate_id=issue2.id,
628
+ duplicate_title=issue2.title,
629
+ match_type="title_similar",
630
+ similarity=round(similarity, 3),
631
+ )
632
+ return similarity_match_count
633
+
634
+ def local_self_dedup(self, local_issues: list[Issue]) -> list[Issue]:
635
+ """Deduplicate local issues by grouping canonical duplicates.
636
+
637
+ Uses union-find with title bucketing to reduce comparison cardinality.
638
+ Groups issues by fuzzy title hash FIRST, then compares within buckets.
639
+
640
+ Args:
641
+ local_issues: List of local issues to deduplicate.
642
+
643
+ Returns:
644
+ List of canonical representative issues (one per equivalence class).
645
+
646
+ Performance:
647
+ - Bucketing by title: O(n)
648
+ - Pairwise comparisons within buckets: O(n) in average case
649
+ - Dedup output: O(n)
650
+ """
651
+ start_time = time.time()
652
+ input_count = len(local_issues)
653
+
654
+ if input_count == 0:
655
+ return []
656
+
657
+ # Build union-find with issue IDs
658
+ issue_by_id = {id(issue): issue for issue in local_issues}
659
+ uf = UnionFind(list(issue_by_id.keys()))
660
+
661
+ id_collision_count = 0
662
+ title_match_count = 0
663
+ similarity_match_count = 0
664
+
665
+ title_buckets = self._build_title_buckets_local(local_issues)
666
+ title_match_count = self._union_title_buckets_local(uf, title_buckets)
667
+ id_collision_count = self._union_github_id_collisions_local(uf, local_issues)
668
+ similarity_match_count = self._apply_fuzzy_matches_local(
669
+ uf,
670
+ local_issues,
671
+ title_buckets,
672
+ )
673
+
674
+ # Extract canonical representatives
675
+ representatives = uf.get_representatives()
676
+ canonical_issues = [
677
+ issue_by_id[rep_id] for rep_id in representatives if rep_id in issue_by_id
678
+ ]
679
+
680
+ output_count = len(canonical_issues)
681
+ elapsed_time = time.time() - start_time
682
+
683
+ logger.info(
684
+ "local_self_dedup: input=%d, id_collisions=%d, title_matches=%d, "
685
+ "similarity_matches=%d, output=%d, time=%.2fs, reduction=%.1f%%",
686
+ input_count,
687
+ id_collision_count,
688
+ title_match_count,
689
+ similarity_match_count,
690
+ output_count,
691
+ elapsed_time,
692
+ ((input_count - output_count) / input_count * 100)
693
+ if input_count > 0
694
+ else 0,
695
+ )
696
+
697
+ return canonical_issues
698
+
699
+ def remote_self_dedup(
700
+ self, remote_issues: dict[str, "SyncIssue"]
701
+ ) -> dict[str, "SyncIssue"]:
702
+ """Deduplicate remote issues by grouping canonical duplicates.
703
+
704
+ Uses union-find to efficiently cluster duplicate issues within the remote
705
+ set. This reduces the number of issues before comparing with local.
706
+
707
+ Args:
708
+ remote_issues: Dict of remote issue IDs to SyncIssue objects.
709
+
710
+ Returns:
711
+ Dict of canonical remote issues (one per equivalence class).
712
+
713
+ Performance:
714
+ - Creates union-find: O(n)
715
+ - Pairwise comparisons: O(n²) with early termination
716
+ - Dedup output: O(n)
717
+ """
718
+ start_time = time.time()
719
+ input_count = len(remote_issues)
720
+
721
+ if input_count == 0:
722
+ return {}
723
+
724
+ # Build union-find with remote issue IDs (strings)
725
+ remote_id_list = list(remote_issues.keys())
726
+ uf = UnionFind(remote_id_list)
727
+
728
+ id_collision_count = 0
729
+ title_match_count = 0
730
+ similarity_match_count = 0
731
+
732
+ title_buckets = self._build_title_buckets_remote(remote_issues, remote_id_list)
733
+ title_match_count = self._union_title_buckets_remote(
734
+ uf,
735
+ title_buckets,
736
+ remote_issues,
737
+ )
738
+ id_collision_count = self._union_backend_id_collisions_remote(
739
+ uf,
740
+ remote_issues,
741
+ remote_id_list,
742
+ )
743
+ similarity_match_count = self._apply_fuzzy_matches_remote(
744
+ uf,
745
+ remote_issues,
746
+ title_buckets,
747
+ remote_id_list,
748
+ )
749
+
750
+ # Extract canonical representatives
751
+ representatives = uf.get_representatives()
752
+ canonical_remote = {rep_id: remote_issues[rep_id] for rep_id in representatives}
753
+
754
+ output_count = len(canonical_remote)
755
+ elapsed_time = time.time() - start_time
756
+
757
+ logger.info(
758
+ "remote_self_dedup: input=%d, id_collisions=%d, title_matches=%d, "
759
+ "similarity_matches=%d, output=%d, time=%.2fs, reduction=%.1f%%",
760
+ input_count,
761
+ id_collision_count,
762
+ title_match_count,
763
+ similarity_match_count,
764
+ output_count,
765
+ elapsed_time,
766
+ ((input_count - output_count) / input_count * 100)
767
+ if input_count > 0
768
+ else 0,
769
+ )
770
+
771
+ return canonical_remote