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.
- roadmap/__init__.py +27 -0
- roadmap/adapters/__init__.py +1 -0
- roadmap/adapters/base_paginated_adapter.py +117 -0
- roadmap/adapters/cli/__init__.py +249 -0
- roadmap/adapters/cli/analysis/__init__.py +5 -0
- roadmap/adapters/cli/analysis/commands.py +370 -0
- roadmap/adapters/cli/analysis/presenter.py +125 -0
- roadmap/adapters/cli/archive_operations.py +86 -0
- roadmap/adapters/cli/cli_command_helpers.py +109 -0
- roadmap/adapters/cli/cli_confirmations.py +74 -0
- roadmap/adapters/cli/cli_error_handlers.py +146 -0
- roadmap/adapters/cli/cli_validators.py +39 -0
- roadmap/adapters/cli/commands/sync_status.py +308 -0
- roadmap/adapters/cli/comment/__init__.py +5 -0
- roadmap/adapters/cli/comment/commands.py +106 -0
- roadmap/adapters/cli/config/__init__.py +5 -0
- roadmap/adapters/cli/config/commands.py +198 -0
- roadmap/adapters/cli/console_exports.py +10 -0
- roadmap/adapters/cli/core.py +26 -0
- roadmap/adapters/cli/crud/__init__.py +47 -0
- roadmap/adapters/cli/crud/base_archive.py +208 -0
- roadmap/adapters/cli/crud/base_create.py +154 -0
- roadmap/adapters/cli/crud/base_delete.py +155 -0
- roadmap/adapters/cli/crud/base_restore.py +283 -0
- roadmap/adapters/cli/crud/base_update.py +193 -0
- roadmap/adapters/cli/crud/crud_helpers.py +206 -0
- roadmap/adapters/cli/crud/crud_utils.py +152 -0
- roadmap/adapters/cli/crud/entity_builders.py +374 -0
- roadmap/adapters/cli/data/__init__.py +8 -0
- roadmap/adapters/cli/data/commands.py +149 -0
- roadmap/adapters/cli/decorators.py +208 -0
- roadmap/adapters/cli/dtos/__init__.py +155 -0
- roadmap/adapters/cli/exception_handler.py +105 -0
- roadmap/adapters/cli/git/__init__.py +8 -0
- roadmap/adapters/cli/git/commands.py +475 -0
- roadmap/adapters/cli/git/handlers/__init__.py +1 -0
- roadmap/adapters/cli/git/handlers/git_authentication_handler.py +124 -0
- roadmap/adapters/cli/git/handlers/git_branch_handler.py +238 -0
- roadmap/adapters/cli/git/handlers/git_connectivity_handler.py +122 -0
- roadmap/adapters/cli/git/handlers/git_hooks_handler.py +105 -0
- roadmap/adapters/cli/git/hooks_config.py +327 -0
- roadmap/adapters/cli/git/status_display.py +158 -0
- roadmap/adapters/cli/health/__init__.py +8 -0
- roadmap/adapters/cli/health/db_integrity.py +204 -0
- roadmap/adapters/cli/health/enhancer.py +227 -0
- roadmap/adapters/cli/health/fixer.py +295 -0
- roadmap/adapters/cli/health/fixers/__init__.py +10 -0
- roadmap/adapters/cli/health/fixers/corrupted_comments_fixer.py +231 -0
- roadmap/adapters/cli/health/fixers/data_integrity_fixer.py +137 -0
- roadmap/adapters/cli/health/fixers/duplicate_issues_fixer.py +138 -0
- roadmap/adapters/cli/health/fixers/folder_structure_fixer.py +114 -0
- roadmap/adapters/cli/health/fixers/label_normalization_fixer.py +162 -0
- roadmap/adapters/cli/health/fixers/milestone_name_normalization_fixer.py +181 -0
- roadmap/adapters/cli/health/fixers/milestone_naming_compliance_fixer.py +244 -0
- roadmap/adapters/cli/health/fixers/milestone_parse_error_fixer.py +162 -0
- roadmap/adapters/cli/health/fixers/milestone_validation_fixer.py +211 -0
- roadmap/adapters/cli/health/fixers/old_backups_fixer.py +135 -0
- roadmap/adapters/cli/health/fixers/orphaned_issues_fixer.py +252 -0
- roadmap/adapters/cli/health/formatter.py +250 -0
- roadmap/adapters/cli/health/formatters.py +427 -0
- roadmap/adapters/cli/health/scan.py +293 -0
- roadmap/adapters/cli/init/__init__.py +5 -0
- roadmap/adapters/cli/init/commands.py +587 -0
- roadmap/adapters/cli/init.py +9 -0
- roadmap/adapters/cli/issues/__init__.py +70 -0
- roadmap/adapters/cli/issues/archive.py +137 -0
- roadmap/adapters/cli/issues/archive_class.py +253 -0
- roadmap/adapters/cli/issues/block.py +39 -0
- roadmap/adapters/cli/issues/close.py +216 -0
- roadmap/adapters/cli/issues/comment.py +178 -0
- roadmap/adapters/cli/issues/create.py +162 -0
- roadmap/adapters/cli/issues/delete.py +31 -0
- roadmap/adapters/cli/issues/deps.py +161 -0
- roadmap/adapters/cli/issues/issue_status_helpers.py +111 -0
- roadmap/adapters/cli/issues/link.py +308 -0
- roadmap/adapters/cli/issues/list.py +402 -0
- roadmap/adapters/cli/issues/lookup.py +96 -0
- roadmap/adapters/cli/issues/progress.py +102 -0
- roadmap/adapters/cli/issues/restore.py +96 -0
- roadmap/adapters/cli/issues/restore_class.py +57 -0
- roadmap/adapters/cli/issues/start.py +165 -0
- roadmap/adapters/cli/issues/sync_status.py +374 -0
- roadmap/adapters/cli/issues/unblock.py +43 -0
- roadmap/adapters/cli/issues/unlink.py +104 -0
- roadmap/adapters/cli/issues/update.py +120 -0
- roadmap/adapters/cli/issues/view.py +36 -0
- roadmap/adapters/cli/layout.py +220 -0
- roadmap/adapters/cli/mappers.py +200 -0
- roadmap/adapters/cli/milestones/__init__.py +52 -0
- roadmap/adapters/cli/milestones/archive.py +125 -0
- roadmap/adapters/cli/milestones/archive_class.py +236 -0
- roadmap/adapters/cli/milestones/assign.py +62 -0
- roadmap/adapters/cli/milestones/close.py +177 -0
- roadmap/adapters/cli/milestones/create.py +131 -0
- roadmap/adapters/cli/milestones/delete.py +27 -0
- roadmap/adapters/cli/milestones/kanban.py +123 -0
- roadmap/adapters/cli/milestones/list.py +87 -0
- roadmap/adapters/cli/milestones/recalculate.py +79 -0
- roadmap/adapters/cli/milestones/restore.py +87 -0
- roadmap/adapters/cli/milestones/restore_class.py +99 -0
- roadmap/adapters/cli/milestones/update.py +61 -0
- roadmap/adapters/cli/milestones/view.py +142 -0
- roadmap/adapters/cli/output_manager.py +319 -0
- roadmap/adapters/cli/presentation/base_presenter.py +121 -0
- roadmap/adapters/cli/presentation/cleanup_presenter.py +235 -0
- roadmap/adapters/cli/presentation/core_initialization_presenter.py +277 -0
- roadmap/adapters/cli/presentation/crud_presenter.py +134 -0
- roadmap/adapters/cli/presentation/daily_summary_presenter.py +266 -0
- roadmap/adapters/cli/presentation/issue_presenter.py +258 -0
- roadmap/adapters/cli/presentation/milestone_list_presenter.py +135 -0
- roadmap/adapters/cli/presentation/milestone_presenter.py +297 -0
- roadmap/adapters/cli/presentation/project_presenter.py +233 -0
- roadmap/adapters/cli/presentation/project_status_presenter.py +186 -0
- roadmap/adapters/cli/presentation/table_builders.py +95 -0
- roadmap/adapters/cli/projects/__init__.py +42 -0
- roadmap/adapters/cli/projects/archive.py +127 -0
- roadmap/adapters/cli/projects/archive_class.py +96 -0
- roadmap/adapters/cli/projects/close.py +94 -0
- roadmap/adapters/cli/projects/create.py +50 -0
- roadmap/adapters/cli/projects/delete.py +27 -0
- roadmap/adapters/cli/projects/list.py +227 -0
- roadmap/adapters/cli/projects/restore.py +87 -0
- roadmap/adapters/cli/projects/restore_class.py +56 -0
- roadmap/adapters/cli/projects/update.py +57 -0
- roadmap/adapters/cli/projects/view.py +157 -0
- roadmap/adapters/cli/services/__init__.py +1 -0
- roadmap/adapters/cli/services/daily_summary_service.py +313 -0
- roadmap/adapters/cli/services/export_manager.py +268 -0
- roadmap/adapters/cli/services/milestone_list_service.py +266 -0
- roadmap/adapters/cli/services/project_initialization_service.py +29 -0
- roadmap/adapters/cli/services/project_status_service.py +462 -0
- roadmap/adapters/cli/services/sync_service.py +62 -0
- roadmap/adapters/cli/status.py +408 -0
- roadmap/adapters/cli/styling.py +21 -0
- roadmap/adapters/cli/sync.py +1117 -0
- roadmap/adapters/cli/sync_context.py +432 -0
- roadmap/adapters/cli/sync_handlers/__init__.py +77 -0
- roadmap/adapters/cli/sync_handlers/apply_ops.py +522 -0
- roadmap/adapters/cli/sync_handlers/baseline_ops.py +366 -0
- roadmap/adapters/cli/sync_handlers/conflict_ops.py +161 -0
- roadmap/adapters/cli/sync_handlers/dry_run_display.py +237 -0
- roadmap/adapters/cli/sync_handlers/interactive_resolver.py +396 -0
- roadmap/adapters/cli/sync_handlers/progress_tracker.py +128 -0
- roadmap/adapters/cli/sync_metrics_command.py +190 -0
- roadmap/adapters/cli/sync_presenter.py +58 -0
- roadmap/adapters/cli/sync_validation.py +295 -0
- roadmap/adapters/cli/today.py +52 -0
- roadmap/adapters/cli/utils/click_options.py +38 -0
- roadmap/adapters/git/__init__.py +1 -0
- roadmap/adapters/git/git.py +208 -0
- roadmap/adapters/git/git_branch_manager.py +400 -0
- roadmap/adapters/git/git_command_executor.py +56 -0
- roadmap/adapters/git/git_commit_analyzer.py +334 -0
- roadmap/adapters/git/git_hooks.py +22 -0
- roadmap/adapters/git/git_hooks_manager.py +455 -0
- roadmap/adapters/git/git_repository_info.py +65 -0
- roadmap/adapters/git/hook_installer.py +106 -0
- roadmap/adapters/git/hook_registry.py +97 -0
- roadmap/adapters/git/hook_script_generator.py +58 -0
- roadmap/adapters/git/sync_monitor.py +409 -0
- roadmap/adapters/git/workflow_automation.py +286 -0
- roadmap/adapters/github/__init__.py +1 -0
- roadmap/adapters/github/github.py +360 -0
- roadmap/adapters/github/handlers/__init__.py +17 -0
- roadmap/adapters/github/handlers/base.py +337 -0
- roadmap/adapters/github/handlers/collaborators.py +165 -0
- roadmap/adapters/github/handlers/comments.py +118 -0
- roadmap/adapters/github/handlers/issues.py +150 -0
- roadmap/adapters/github/handlers/labels.py +164 -0
- roadmap/adapters/github/handlers/milestones.py +104 -0
- roadmap/adapters/persistence/__init__.py +35 -0
- roadmap/adapters/persistence/conflict_resolver.py +190 -0
- roadmap/adapters/persistence/database_manager.py +492 -0
- roadmap/adapters/persistence/entity_sync_coordinators.py +672 -0
- roadmap/adapters/persistence/file_locking.py +348 -0
- roadmap/adapters/persistence/file_parser.py +92 -0
- roadmap/adapters/persistence/file_synchronizer.py +107 -0
- roadmap/adapters/persistence/focused_managers.py +140 -0
- roadmap/adapters/persistence/git_history.py +330 -0
- roadmap/adapters/persistence/parser/__init__.py +20 -0
- roadmap/adapters/persistence/parser/frontmatter.py +152 -0
- roadmap/adapters/persistence/parser/issue.py +245 -0
- roadmap/adapters/persistence/parser/milestone.py +146 -0
- roadmap/adapters/persistence/parser/project.py +106 -0
- roadmap/adapters/persistence/persistence.py +288 -0
- roadmap/adapters/persistence/repositories/__init__.py +15 -0
- roadmap/adapters/persistence/repositories/issue_repository.py +223 -0
- roadmap/adapters/persistence/repositories/milestone_repository.py +140 -0
- roadmap/adapters/persistence/repositories/project_repository.py +168 -0
- roadmap/adapters/persistence/repositories/remote_link_repository.py +355 -0
- roadmap/adapters/persistence/repositories/sync_state_repository.py +66 -0
- roadmap/adapters/persistence/storage/__init__.py +36 -0
- roadmap/adapters/persistence/storage/conflicts.py +101 -0
- roadmap/adapters/persistence/storage/connection_manager.py +79 -0
- roadmap/adapters/persistence/storage/issue_storage.py +89 -0
- roadmap/adapters/persistence/storage/milestone_storage.py +80 -0
- roadmap/adapters/persistence/storage/project_storage.py +102 -0
- roadmap/adapters/persistence/storage/queries.py +227 -0
- roadmap/adapters/persistence/storage/state_manager.py +652 -0
- roadmap/adapters/persistence/storage/sync_state_storage.py +207 -0
- roadmap/adapters/persistence/sync_metrics_repository.py +342 -0
- roadmap/adapters/persistence/sync_orchestrator.py +293 -0
- roadmap/adapters/persistence/sync_state_tracker.py +86 -0
- roadmap/adapters/persistence/yaml_repositories.py +711 -0
- roadmap/adapters/sync/__init__.py +21 -0
- roadmap/adapters/sync/backend_factory.py +108 -0
- roadmap/adapters/sync/backends/__init__.py +15 -0
- roadmap/adapters/sync/backends/converters.py +209 -0
- roadmap/adapters/sync/backends/github_backend_helpers.py +380 -0
- roadmap/adapters/sync/backends/github_client.py +99 -0
- roadmap/adapters/sync/backends/github_sync_backend.py +792 -0
- roadmap/adapters/sync/backends/github_sync_ops.py +908 -0
- roadmap/adapters/sync/backends/services/__init__.py +1 -0
- roadmap/adapters/sync/backends/services/github_authentication_service.py +105 -0
- roadmap/adapters/sync/backends/services/github_issue_delete_service.py +423 -0
- roadmap/adapters/sync/backends/services/github_issue_dependency_service.py +63 -0
- roadmap/adapters/sync/backends/services/github_issue_fetch_service.py +158 -0
- roadmap/adapters/sync/backends/services/github_label_sync_service.py +126 -0
- roadmap/adapters/sync/backends/services/github_local_issue_sync_service.py +229 -0
- roadmap/adapters/sync/backends/services/github_milestone_fetch_service.py +204 -0
- roadmap/adapters/sync/backends/vanilla_git_sync_backend.py +198 -0
- roadmap/adapters/sync/services/__init__.py +11 -0
- roadmap/adapters/sync/services/baseline_state_handler.py +83 -0
- roadmap/adapters/sync/services/conflict_converter.py +85 -0
- roadmap/adapters/sync/services/issue_persistence_service.py +263 -0
- roadmap/adapters/sync/services/issue_state_service.py +243 -0
- roadmap/adapters/sync/services/local_change_filter.py +129 -0
- roadmap/adapters/sync/services/pull_result_processor.py +71 -0
- roadmap/adapters/sync/services/remote_issue_creation_service.py +112 -0
- roadmap/adapters/sync/services/sync_analysis_service.py +250 -0
- roadmap/adapters/sync/services/sync_authentication_service.py +57 -0
- roadmap/adapters/sync/services/sync_data_fetch_service.py +212 -0
- roadmap/adapters/sync/services/sync_linking_service.py +329 -0
- roadmap/adapters/sync/services/sync_report_service.py +62 -0
- roadmap/adapters/sync/services/sync_state_update_service.py +51 -0
- roadmap/adapters/sync/sync_cache_orchestrator.py +454 -0
- roadmap/adapters/sync/sync_merge_engine.py +416 -0
- roadmap/adapters/sync/sync_merge_orchestrator.py +1313 -0
- roadmap/adapters/sync/sync_retrieval_orchestrator.py +810 -0
- roadmap/application/services/__init__.py +1 -0
- roadmap/application/services/deduplicate_service.py +517 -0
- roadmap/application/use_cases/__init__.py +1 -0
- roadmap/common/__init__.py +308 -0
- roadmap/common/cache.py +209 -0
- roadmap/common/cli_errors.py +147 -0
- roadmap/common/configuration/__init__.py +33 -0
- roadmap/common/configuration/config_loader.py +264 -0
- roadmap/common/configuration/config_manager.py +164 -0
- roadmap/common/configuration/config_schema.py +145 -0
- roadmap/common/configuration/github/__init__.py +9 -0
- roadmap/common/configuration/github/config_manager.py +154 -0
- roadmap/common/configuration/github/token_resolver.py +84 -0
- roadmap/common/console.py +184 -0
- roadmap/common/constants.py +134 -0
- roadmap/common/datetime_parser.py +319 -0
- roadmap/common/error_formatter.py +84 -0
- roadmap/common/errors/__init__.py +143 -0
- roadmap/common/errors/error_base.py +73 -0
- roadmap/common/errors/error_file.py +82 -0
- roadmap/common/errors/error_git.py +67 -0
- roadmap/common/errors/error_handler.py +166 -0
- roadmap/common/errors/error_network.py +90 -0
- roadmap/common/errors/error_security.py +78 -0
- roadmap/common/errors/error_standards.py +561 -0
- roadmap/common/errors/error_validation.py +111 -0
- roadmap/common/errors/exceptions.py +432 -0
- roadmap/common/formatters/__init__.py +29 -0
- roadmap/common/formatters/base_table_formatter.py +96 -0
- roadmap/common/formatters/export/__init__.py +7 -0
- roadmap/common/formatters/export/issue_exporter.py +97 -0
- roadmap/common/formatters/helpers.py +28 -0
- roadmap/common/formatters/kanban/__init__.py +9 -0
- roadmap/common/formatters/kanban/layout.py +48 -0
- roadmap/common/formatters/kanban/organizer.py +81 -0
- roadmap/common/formatters/output/__init__.py +15 -0
- roadmap/common/formatters/tables/__init__.py +11 -0
- roadmap/common/formatters/tables/column_factory.py +312 -0
- roadmap/common/formatters/tables/issue_table.py +276 -0
- roadmap/common/formatters/tables/milestone_table.py +247 -0
- roadmap/common/formatters/tables/project_table.py +195 -0
- roadmap/common/formatters/text/__init__.py +57 -0
- roadmap/common/formatters/text/basic.py +217 -0
- roadmap/common/formatters/text/display.py +42 -0
- roadmap/common/formatters/text/duration.py +36 -0
- roadmap/common/formatters/text/operations.py +325 -0
- roadmap/common/formatters/text/status_badges.py +57 -0
- roadmap/common/initialization/github/__init__.py +11 -0
- roadmap/common/initialization/github/setup_service.py +238 -0
- roadmap/common/initialization/github/setup_validator.py +78 -0
- roadmap/common/logging/__init__.py +113 -0
- roadmap/common/logging/audit_logging.py +5 -0
- roadmap/common/logging/correlation.py +45 -0
- roadmap/common/logging/decorators.py +267 -0
- roadmap/common/logging/error_logging.py +217 -0
- roadmap/common/logging/formatters.py +124 -0
- roadmap/common/logging/loggers.py +68 -0
- roadmap/common/logging/performance_tracking.py +267 -0
- roadmap/common/logging/utils.py +335 -0
- roadmap/common/models/__init__.py +44 -0
- roadmap/common/models/cli_models.py +118 -0
- roadmap/common/models/config_models.py +199 -0
- roadmap/common/models/output_models.py +381 -0
- roadmap/common/observability/__init__.py +28 -0
- roadmap/common/observability/instrumentation.py +103 -0
- roadmap/common/observability/observability.py +76 -0
- roadmap/common/observability/otel_init.py +109 -0
- roadmap/common/output_formatter.py +393 -0
- roadmap/common/progress.py +379 -0
- roadmap/common/result.py +396 -0
- roadmap/common/security/__init__.py +48 -0
- roadmap/common/security/exceptions.py +13 -0
- roadmap/common/security/export_cleanup.py +79 -0
- roadmap/common/security/file_operations.py +104 -0
- roadmap/common/security/filename_sanitization.py +54 -0
- roadmap/common/security/logging.py +27 -0
- roadmap/common/security/path_validation.py +137 -0
- roadmap/common/security/temp_files.py +41 -0
- roadmap/common/services/__init__.py +65 -0
- roadmap/common/services/decorators.py +183 -0
- roadmap/common/services/logging_utils.py +208 -0
- roadmap/common/services/metrics.py +161 -0
- roadmap/common/services/performance.py +219 -0
- roadmap/common/services/profiling.py +288 -0
- roadmap/common/services/retry.py +288 -0
- roadmap/common/status_style_manager.py +114 -0
- roadmap/common/union_find.py +88 -0
- roadmap/common/update_constants.py +13 -0
- roadmap/common/utils/__init__.py +65 -0
- roadmap/common/utils/cli_helpers.py +381 -0
- roadmap/common/utils/file_utils.py +440 -0
- roadmap/common/utils/path_utils.py +38 -0
- roadmap/common/utils/status_utils.py +130 -0
- roadmap/common/utils/timezone_utils.py +480 -0
- roadmap/common/validation/__init__.py +37 -0
- roadmap/common/validation/field_validator.py +104 -0
- roadmap/common/validation/result.py +37 -0
- roadmap/common/validation/roadmap_validator.py +310 -0
- roadmap/common/validation/schema_validator.py +36 -0
- roadmap/common/validation/validators.py +50 -0
- roadmap/core/__init__.py +1 -0
- roadmap/core/domain/__init__.py +30 -0
- roadmap/core/domain/comment.py +24 -0
- roadmap/core/domain/health.py +15 -0
- roadmap/core/domain/issue.py +274 -0
- roadmap/core/domain/milestone.py +173 -0
- roadmap/core/domain/ports/__init__.py +1 -0
- roadmap/core/domain/ports/baseline_repository.py +24 -0
- roadmap/core/domain/ports/issue_repository.py +34 -0
- roadmap/core/domain/ports/remote_backend_port.py +31 -0
- roadmap/core/domain/project.py +130 -0
- roadmap/core/interfaces/__init__.py +109 -0
- roadmap/core/interfaces/assignee_validator.py +40 -0
- roadmap/core/interfaces/backend_factory.py +175 -0
- roadmap/core/interfaces/github.py +102 -0
- roadmap/core/interfaces/parsers.py +140 -0
- roadmap/core/interfaces/persistence.py +45 -0
- roadmap/core/interfaces/repositories.py +174 -0
- roadmap/core/interfaces/state_managers.py +111 -0
- roadmap/core/interfaces/state_storage.py +64 -0
- roadmap/core/interfaces/sync_backend.py +275 -0
- roadmap/core/interfaces/sync_services.py +119 -0
- roadmap/core/interfaces/sync_validators.py +180 -0
- roadmap/core/models/__init__.py +31 -0
- roadmap/core/models/sync_models.py +162 -0
- roadmap/core/models.py +115 -0
- roadmap/core/observability/__init__.py +17 -0
- roadmap/core/observability/sync_metrics.py +699 -0
- roadmap/core/repositories.py +200 -0
- roadmap/core/services/__init__.py +313 -0
- roadmap/core/services/baseline/__init__.py +1 -0
- roadmap/core/services/baseline/baseline_builder_progress.py +274 -0
- roadmap/core/services/baseline/baseline_retriever.py +63 -0
- roadmap/core/services/baseline/baseline_selector.py +248 -0
- roadmap/core/services/baseline/baseline_state_retriever.py +284 -0
- roadmap/core/services/baseline/optimized_baseline_builder.py +425 -0
- roadmap/core/services/comment/__init__.py +1 -0
- roadmap/core/services/comment/comment_service.py +277 -0
- roadmap/core/services/git/__init__.py +1 -0
- roadmap/core/services/git/git_hook_auto_sync_service.py +403 -0
- roadmap/core/services/github/__init__.py +1 -0
- roadmap/core/services/github/github_change_detector.py +162 -0
- roadmap/core/services/github/github_config_validator.py +148 -0
- roadmap/core/services/github/github_conflict_detector.py +179 -0
- roadmap/core/services/github/github_entity_classifier.py +51 -0
- roadmap/core/services/github/github_integration_service.py +439 -0
- roadmap/core/services/github/github_issue_client.py +346 -0
- roadmap/core/services/health/__init__.py +1 -0
- roadmap/core/services/health/backup_cleanup_service.py +202 -0
- roadmap/core/services/health/data_integrity_validator_service.py +90 -0
- roadmap/core/services/health/entity_health_scanner.py +460 -0
- roadmap/core/services/health/file_repair_service.py +193 -0
- roadmap/core/services/health/health_check_service.py +166 -0
- roadmap/core/services/health/health_models.py +80 -0
- roadmap/core/services/health/infrastructure_validator_service.py +328 -0
- roadmap/core/services/health/issue_health_scanner.py +263 -0
- roadmap/core/services/helpers/__init__.py +18 -0
- roadmap/core/services/helpers/status_change_helpers.py +88 -0
- roadmap/core/services/initialization/__init__.py +19 -0
- roadmap/core/services/initialization/utils.py +104 -0
- roadmap/core/services/initialization/validator.py +78 -0
- roadmap/core/services/initialization/workflow.py +182 -0
- roadmap/core/services/initialization_service.py +111 -0
- roadmap/core/services/issue/__init__.py +1 -0
- roadmap/core/services/issue/assignee_validation_service.py +312 -0
- roadmap/core/services/issue/issue_creation_service.py +309 -0
- roadmap/core/services/issue/issue_filter_service.py +302 -0
- roadmap/core/services/issue/issue_matching_service.py +127 -0
- roadmap/core/services/issue/issue_service.py +765 -0
- roadmap/core/services/issue/issue_update_service.py +144 -0
- roadmap/core/services/issue/start_issue_service.py +174 -0
- roadmap/core/services/issue_helpers/__init__.py +18 -0
- roadmap/core/services/issue_helpers/issue_filters.py +296 -0
- roadmap/core/services/milestone_service.py +438 -0
- roadmap/core/services/project/__init__.py +1 -0
- roadmap/core/services/project/project_service.py +434 -0
- roadmap/core/services/project/project_status_service.py +125 -0
- roadmap/core/services/project_init/__init__.py +22 -0
- roadmap/core/services/project_init/context_detection.py +146 -0
- roadmap/core/services/project_init/creation.py +76 -0
- roadmap/core/services/project_init/detection.py +51 -0
- roadmap/core/services/project_init/template.py +228 -0
- roadmap/core/services/status_change_service.py +88 -0
- roadmap/core/services/sync/__init__.py +1 -0
- roadmap/core/services/sync/batch_processor.py +290 -0
- roadmap/core/services/sync/conflict_resolver.py +154 -0
- roadmap/core/services/sync/dependency_resolver.py +367 -0
- roadmap/core/services/sync/duplicate_detector.py +771 -0
- roadmap/core/services/sync/duplicate_resolver.py +315 -0
- roadmap/core/services/sync/error_classification.py +470 -0
- roadmap/core/services/sync/performance.py +263 -0
- roadmap/core/services/sync/sync_change_computer.py +211 -0
- roadmap/core/services/sync/sync_checkpoint.py +348 -0
- roadmap/core/services/sync/sync_conflict_detector.py +147 -0
- roadmap/core/services/sync/sync_conflict_resolver.py +448 -0
- roadmap/core/services/sync/sync_errors.py +383 -0
- roadmap/core/services/sync/sync_key_normalizer.py +222 -0
- roadmap/core/services/sync/sync_metadata_service.py +361 -0
- roadmap/core/services/sync/sync_plan.py +171 -0
- roadmap/core/services/sync/sync_plan_executor.py +447 -0
- roadmap/core/services/sync/sync_report.py +296 -0
- roadmap/core/services/sync/sync_state.py +173 -0
- roadmap/core/services/sync/sync_state_comparator.py +690 -0
- roadmap/core/services/sync/sync_state_manager.py +431 -0
- roadmap/core/services/sync/sync_state_normalizer.py +98 -0
- roadmap/core/services/sync/sync_three_way.py +78 -0
- roadmap/core/services/sync/three_way_merger.py +178 -0
- roadmap/core/services/utils/__init__.py +1 -0
- roadmap/core/services/utils/configuration_service.py +113 -0
- roadmap/core/services/utils/critical_path_calculator.py +418 -0
- roadmap/core/services/utils/dependency_analyzer.py +360 -0
- roadmap/core/services/utils/field_conflict_detector.py +126 -0
- roadmap/core/services/utils/remote_fetcher.py +202 -0
- roadmap/core/services/utils/remote_state_normalizer.py +110 -0
- roadmap/core/services/utils/retry_policy.py +51 -0
- roadmap/core/services/validator_base.py +130 -0
- roadmap/core/services/validators/__init__.py +28 -0
- roadmap/core/services/validators/_utils.py +20 -0
- roadmap/core/services/validators/archivable_issues_validator.py +71 -0
- roadmap/core/services/validators/archivable_milestones_validator.py +69 -0
- roadmap/core/services/validators/backup_validator.py +89 -0
- roadmap/core/services/validators/data_integrity_validator.py +84 -0
- roadmap/core/services/validators/duplicate_issues_validator.py +75 -0
- roadmap/core/services/validators/duplicate_milestones_validator.py +141 -0
- roadmap/core/services/validators/folder_structure_validator.py +165 -0
- roadmap/core/services/validators/health_status_utils.py +28 -0
- roadmap/core/services/validators/milestone_naming_validator.py +146 -0
- roadmap/core/services/validators/missing_headlines_validator.py +97 -0
- roadmap/core/services/validators/orphaned_issues_validator.py +142 -0
- roadmap/core/services/validators/orphaned_milestones_validator.py +85 -0
- roadmap/core/utils/git_remote_parser.py +83 -0
- roadmap/core/utils/github_urls.py +66 -0
- roadmap/domain/validation.py +30 -0
- roadmap/infrastructure/__init__.py +55 -0
- roadmap/infrastructure/coordination/__init__.py +8 -0
- roadmap/infrastructure/coordination/coordinator_params.py +82 -0
- roadmap/infrastructure/coordination/core.py +446 -0
- roadmap/infrastructure/coordination/git_coordinator.py +160 -0
- roadmap/infrastructure/coordination/initialization.py +348 -0
- roadmap/infrastructure/coordination/issue_coordinator.py +154 -0
- roadmap/infrastructure/coordination/issue_operations.py +456 -0
- roadmap/infrastructure/coordination/milestone_coordinator.py +135 -0
- roadmap/infrastructure/coordination/milestone_operations.py +201 -0
- roadmap/infrastructure/coordination/project_coordinator.py +113 -0
- roadmap/infrastructure/coordination/project_operations.py +144 -0
- roadmap/infrastructure/coordination/team_coordinator.py +107 -0
- roadmap/infrastructure/coordination/user_operations.py +150 -0
- roadmap/infrastructure/coordination/validation_coordinator.py +249 -0
- roadmap/infrastructure/coordination_gateway.py +152 -0
- roadmap/infrastructure/git/__init__.py +6 -0
- roadmap/infrastructure/git/git_integration_ops.py +234 -0
- roadmap/infrastructure/git_gateway.py +35 -0
- roadmap/infrastructure/github_gateway.py +52 -0
- roadmap/infrastructure/maintenance/__init__.py +5 -0
- roadmap/infrastructure/maintenance/cleanup.py +483 -0
- roadmap/infrastructure/observability/__init__.py +7 -0
- roadmap/infrastructure/observability/health.py +196 -0
- roadmap/infrastructure/observability/health_formatter.py +73 -0
- roadmap/infrastructure/observability/specialized_health_checkers.py +188 -0
- roadmap/infrastructure/persistence_gateway.py +164 -0
- roadmap/infrastructure/security/__init__.py +1 -0
- roadmap/infrastructure/security/credentials.py +489 -0
- roadmap/infrastructure/sync_gateway.py +94 -0
- roadmap/infrastructure/validation/__init__.py +7 -0
- roadmap/infrastructure/validation/file_enumeration.py +219 -0
- roadmap/infrastructure/validation/github_assignee_validator.py +47 -0
- roadmap/infrastructure/validation/github_validator.py +101 -0
- roadmap/infrastructure/validation/milestone_consistency_validator.py +96 -0
- roadmap/infrastructure/validation/vanilla_assignee_validator.py +37 -0
- roadmap/infrastructure/validation_gateway.py +96 -0
- roadmap/presentation/formatters/sync_metrics_formatter.py +355 -0
- roadmap/settings.py +345 -0
- roadmap/templates/github_workflows/ci-cd-roadmap.yml +241 -0
- roadmap/templates/github_workflows/issue-lifecycle.yml +236 -0
- roadmap/templates/github_workflows/roadmap-integration.yml +137 -0
- roadmap/templates/github_workflows/roadmap-starter.yml +59 -0
- roadmap/version.py +381 -0
- roadmap_cli-0.1.1.dist-info/METADATA +448 -0
- roadmap_cli-0.1.1.dist-info/RECORD +521 -0
- roadmap_cli-0.1.1.dist-info/WHEEL +4 -0
- roadmap_cli-0.1.1.dist-info/entry_points.txt +2 -0
- 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
|