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