diff --git a/azure-devops/azext_devops/dev/migration/_format.py b/azure-devops/azext_devops/dev/migration/_format.py index 67eafc9b..b890c415 100644 --- a/azure-devops/azext_devops/dev/migration/_format.py +++ b/azure-devops/azext_devops/dev/migration/_format.py @@ -42,6 +42,7 @@ def transform_cutover_review_table_output(result): blocked_count = result.get('blockedCount') pending_count = result.get('pendingCount') total_unprocessed = result.get('totalUnprocessedCount') + requires_pipeline_ack = result.get('requiresPipelineVerificationAcknowledgment') failed_items = result.get('failedItems') if isinstance(result.get('failedItems'), list) else [] if not failed_items: @@ -50,6 +51,7 @@ def transform_cutover_review_table_output(result): row['BlockedCount'] = blocked_count row['PendingCount'] = pending_count row['TotalUnprocessedCount'] = total_unprocessed + row['RequiresPipelineVerification'] = requires_pipeline_ack row['State'] = None row['Type'] = None row['PullRequestUrl'] = None @@ -62,6 +64,7 @@ def transform_cutover_review_table_output(result): row['BlockedCount'] = blocked_count if index == 0 else None row['PendingCount'] = pending_count if index == 0 else None row['TotalUnprocessedCount'] = total_unprocessed if index == 0 else None + row['RequiresPipelineVerification'] = requires_pipeline_ack if index == 0 else None row['State'] = item.get('state') if isinstance(item, dict) else None row['Type'] = item.get('type') if isinstance(item, dict) else None row['PullRequestUrl'] = item.get('pullRequestUrl') if isinstance(item, dict) else None @@ -70,6 +73,21 @@ def transform_cutover_review_table_output(result): return rows +def transform_pipelines_list_table_output(result): + if not isinstance(result, dict): + return [] + entries = result.get('pipelines') if isinstance(result.get('pipelines'), list) else [] + return [_transform_pipeline_entry_row(entry) for entry in entries] + + +def transform_pipeline_entries_table_output(result): + if isinstance(result, list): + return [_transform_pipeline_entry_row(entry) for entry in result] + if isinstance(result, dict) and isinstance(result.get('pipelines'), list): + return [_transform_pipeline_entry_row(entry) for entry in result.get('pipelines')] + return [] + + def _unwrap_migration_list(result): if isinstance(result, dict) and 'value' in result: return result['value'] @@ -90,3 +108,16 @@ def _transform_migration_row(row): table_row['CodeSyncDate'] = date_time_to_only_date(row.get('codeSyncDate')) table_row['PrSyncDate'] = date_time_to_only_date(row.get('pullRequestSyncDate')) return table_row + + +def _transform_pipeline_entry_row(entry): + table_row = OrderedDict() + table_row['DefinitionId'] = entry.get('definitionId') + # Server may return name=null until pipeline-name hydration ships; fall back to yamlFilename + # so operators can still identify the row. + display_name = entry.get('name') or entry.get('yamlFilename') + table_row['Name'] = trim_for_display(display_name, _TARGET_TRUNCATION_LENGTH) + table_row['Classification'] = entry.get('classification') + table_row['Status'] = entry.get('status') + table_row['ErrorMessage'] = trim_for_display(entry.get('errorMessage'), _TARGET_TRUNCATION_LENGTH) + return table_row diff --git a/azure-devops/azext_devops/dev/migration/_help.py b/azure-devops/azext_devops/dev/migration/_help.py index 86476892..92dc704b 100644 --- a/azure-devops/azext_devops/dev/migration/_help.py +++ b/azure-devops/azext_devops/dev/migration/_help.py @@ -16,13 +16,14 @@ def load_migration_help(): helps['devops migrations list'] = """ type: command short-summary: List migrations in an organization. + long-summary: 'By default the latest migration per repository is returned, regardless of state. Use --include-all to return the full migration history.' examples: - - name: List migrations. + - name: List the latest migration per repository. text: | az devops migrations list --org https://dev.azure.com/myorg - - name: List all migrations including inactive ones. + - name: List the full migration history for every repository. text: | - az devops migrations list --org https://dev.azure.com/myorg --include-inactive + az devops migrations list --org https://dev.azure.com/myorg --include-all """ helps['devops migrations status'] = """ @@ -72,7 +73,8 @@ def load_migration_help(): helps['devops migrations abandon'] = """ type: command - short-summary: Abandon and delete a migration. + short-summary: Abandon a migration. + long-summary: 'Moves the migration to an abandoned/failed state; the migration record is not purged. Pipeline rewiring data is left intact so a subsequent migration can reuse it.' examples: - name: Abandon and keep repository read-only (default). text: | @@ -90,6 +92,7 @@ def load_migration_help(): helps['devops migrations cutover review'] = """ type: command short-summary: Review unprocessed migration items before cutover. + long-summary: 'The response includes requiresPipelineVerificationAcknowledgment. When true, cutover approve must be re-run with --pipelines-verified before the migration can proceed.' examples: - name: Review failures before approving cutover. text: | @@ -98,11 +101,18 @@ def load_migration_help(): helps['devops migrations cutover approve'] = """ type: command - short-summary: Approve cutover by accepting a count of unprocessed items. + short-summary: Approve cutover by accepting unprocessed items and/or verifying rewired pipelines. + long-summary: 'Provide --accept-failures when cutover review surfaces unprocessed items, and/or --pipelines-verified when cutover review reports requiresPipelineVerificationAcknowledgment: true. At least one of the two must be supplied; both may be sent together in a single call.' examples: - name: Approve cutover after reviewing failures. text: | az devops migrations cutover approve --org https://dev.azure.com/myorg --repository-id 00000000-0000-0000-0000-000000000000 --accept-failures 3 + - name: Acknowledge pipeline verification only (no unprocessed items). + text: | + az devops migrations cutover approve --org https://dev.azure.com/myorg --repository-id 00000000-0000-0000-0000-000000000000 --pipelines-verified + - name: Combine failure acceptance and pipeline verification in one call. + text: | + az devops migrations cutover approve --org https://dev.azure.com/myorg --repository-id 00000000-0000-0000-0000-000000000000 --accept-failures 3 --pipelines-verified """ helps['devops migrations cutover set'] = """ @@ -118,3 +128,53 @@ def load_migration_help(): type: command short-summary: Cancel a scheduled cutover. """ + + helps['devops migrations pipelines'] = """ + type: group + short-summary: Manage pipeline rewiring for migrations. (Preview) + """ + + helps['devops migrations pipelines list'] = """ + type: command + short-summary: List pipeline rewiring configuration and per-pipeline status. + examples: + - name: List pipeline rewiring status. + text: | + az devops migrations pipelines list --org https://dev.azure.com/myorg --repository-id 00000000-0000-0000-0000-000000000000 + """ + + helps['devops migrations pipelines submit'] = """ + type: command + short-summary: Submit pipelines for rewiring. (Preview) + examples: + - name: Submit pipelines with service connection and repository mappings. + text: | + az devops migrations pipelines submit --org https://dev.azure.com/myorg --repository-id 00000000-0000-0000-0000-000000000000 --pipeline-ids 42 43 44 --service-connection-id 11111111-1111-1111-1111-111111111111 --repository-mapping aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa=myorg/shared-templates + """ + + helps['devops migrations pipelines update'] = """ + type: command + short-summary: Bulk update pipeline rewiring configuration. (Preview) + examples: + - name: Add, remove, and update service connection. + text: | + az devops migrations pipelines update --org https://dev.azure.com/myorg --repository-id 00000000-0000-0000-0000-000000000000 --add-ids 50 51 --remove-ids 42 --service-connection-id 22222222-2222-2222-2222-222222222222 + """ + + helps['devops migrations pipelines retry'] = """ + type: command + short-summary: Retry failed pipeline rewiring entries. (Preview) + examples: + - name: Retry specific failed pipelines. + text: | + az devops migrations pipelines retry --org https://dev.azure.com/myorg --repository-id 00000000-0000-0000-0000-000000000000 --pipeline-ids 42 43 + """ + + helps['devops migrations pipelines delete'] = """ + type: command + short-summary: Delete pipeline rewiring data for a migration. (Preview) + examples: + - name: Delete rewiring config and cloned definitions. + text: | + az devops migrations pipelines delete --org https://dev.azure.com/myorg --repository-id 00000000-0000-0000-0000-000000000000 --migration-id 7 --yes + """ diff --git a/azure-devops/azext_devops/dev/migration/arguments.py b/azure-devops/azext_devops/dev/migration/arguments.py index 03420a17..1a41af49 100644 --- a/azure-devops/azext_devops/dev/migration/arguments.py +++ b/azure-devops/azext_devops/dev/migration/arguments.py @@ -14,9 +14,52 @@ def load_migration_arguments(self, _): context.argument('repository_id', options_list='--repository-id', help='ID of the Azure Repos repository (GUID).') + with self.argument_context('devops migrations pipelines') as context: + context.argument('repository_mapping', options_list='--repository-mapping', action='append', + help='Repository mapping in the format =/. ' + 'Can be provided multiple times.') + + with self.argument_context('devops migrations pipelines submit') as context: + context.argument('pipeline_ids', options_list='--pipeline-ids', nargs='+', + help='Pipeline definition IDs. Accepts space-separated values ' + '(for example, 42 43 44) or comma-separated values ' + '(for example, 42,43,44).') + context.argument('service_connection_id', options_list='--service-connection-id', + help='Project-scoped GitHub service connection ID (GUID). ' + 'Optional if a connection was already attached via ' + 'migrations create --pipeline-service-connection-id or ' + 'pipelines update --service-connection-id.') + + with self.argument_context('devops migrations pipelines update') as context: + context.argument('add_ids', options_list='--add-ids', nargs='+', + help='Pipeline IDs to add. Accepts space-separated or comma-separated values.') + context.argument('remove_ids', options_list='--remove-ids', nargs='+', + help='Pipeline IDs to remove. Accepts space-separated or comma-separated values.') + context.argument('retry_ids', options_list='--retry-ids', nargs='+', + help='Failed pipeline IDs to retry. Accepts space-separated or comma-separated values.') + context.argument('service_connection_id', options_list='--service-connection-id', + help='Project-scoped GitHub service connection ID (GUID).') + + with self.argument_context('devops migrations pipelines retry') as context: + context.argument('pipeline_ids', options_list='--pipeline-ids', nargs='+', + help='Pipeline definition IDs to retry. Accepts space-separated ' + 'or comma-separated values.') + + with self.argument_context('devops migrations pipelines delete') as context: + context.argument('migration_id', options_list='--migration-id', type=int, + help='Migration ID used for pipeline rewiring cleanup.') + context.argument('yes', options_list=['--yes', '-y'], action='store_true', + help='Do not prompt for confirmation.') + with self.argument_context('devops migrations list') as context: + context.argument('include_all', options_list='--include-all', action='store_true', + help='Return the full migration history (all records per repository). ' + 'By default only the latest migration per repository is returned, ' + 'regardless of its state.') context.argument('include_inactive', options_list='--include-inactive', action='store_true', - help='Include inactive (completed, abandoned, failed) migrations in the results.') + help='Deprecated. Use --include-all instead.', + deprecate_info=context.deprecate(redirect='--include-all', + target='--include-inactive', hide=False)) context.argument('project', options_list='--project', help='Optional project name or ID to filter migrations.') @@ -27,9 +70,12 @@ def load_migration_arguments(self, _): help='Target repository owner user ID. Deprecated and ignored when server-side ' 'token-based owner resolution is enabled.') context.argument('github_token', options_list='--github-token', - help='GitHub token used for migration authorization. Ignored when ' - '--service-endpoint-id is specified. If omitted, the CLI first ' - 'checks ELM_GITHUB_TOKEN and then runs GitHub device flow.') + help='GitHub user token used for user-identity verification on the target ' + 'host. Independent of --service-endpoint-id. If omitted and ' + '--service-endpoint-id is not provided, the CLI checks ELM_GITHUB_TOKEN ' + 'and then runs GitHub device flow. When --service-endpoint-id is ' + 'provided, device flow is skipped; pass --github-token or set ' + 'ELM_GITHUB_TOKEN to supply the user token.') context.argument('validate_only', options_list='--validate-only', action='store_true', help='Create in validate-only mode (pre-migration checks only).') context.argument('cutover_date', options_list='--cutover-date', @@ -40,12 +86,38 @@ def load_migration_arguments(self, _): context.argument('skip_validation', options_list='--skip-validation', help='Validation policies to skip. Accepts either a comma-separated list of ' 'policy names (for example, AgentPoolExists,MaxFileSize) or a non-negative ' - 'integer bitmask.') + 'integer bitmask. Supported policy names (case-insensitive): None, ' + 'ActivePullRequestCount, PullRequestDeltaSize, AgentPoolExists, MaxFileSize, ' + 'MaxPullRequestSize, MaxPushPackSize, MaxReferenceNameLength, ' + 'TargetRepositoryDoesNotExist, SourceRepositoryContainsLfsObjects, ' + 'SourceRepositoryNotReadOnly, BoardsGitHubConnectionProvisioning, All.') context.argument('service_endpoint_id', options_list='--service-endpoint-id', - help='Service endpoint ID (GUID) for the GitHub Enterprise Server connection. ' - 'When specified, the server uses the service connection for GitHub ' - 'authentication and the CLI skips GitHub device flow. Mutually exclusive ' - 'with --github-token.') + help='Service endpoint ID (GUID) for the GitHub Enterprise Server connection ' + 'used to sync commits to the target. Independent of user-identity ' + 'verification: --github-token / ELM_GITHUB_TOKEN can be supplied ' + 'alongside this flag. Device flow is skipped when this flag is set.') + context.argument('enable_boards_github_connection', + options_list='--enable-boards-github-connection', action='store_true', + help='Opt in to provisioning the Azure Boards GitHub connection at ' + 'cutover. Off by default. Requires the Azure Boards GitHub App ' + 'to be installed on the target GitHub Enterprise organization ' + 'before the migration runs.') + context.argument('enable_auto_discover_pipelines', + options_list='--enable-auto-discover-pipelines', action='store_true', + help='Opt in to automatic pipeline discovery at cutover. Off by default. ' + 'When enabled, the ELM sync job walks the source repository and ' + 'creates clone definitions for every pipeline that references it. ' + 'Requires --pipeline-service-connection-id; without it discovery ' + 'runs as a no-op and enrolls 0 pipelines. ' + 'Pipeline rewiring itself is always available via ' + 'az devops migrations pipelines submit / update.') + context.argument('pipeline_service_connection_id', + options_list='--pipeline-service-connection-id', + help='Project-scoped GitHub service connection ID (GUID) attached at ' + 'create time for pipeline rewiring. Required for full auto-discovery ' + 'when combined with --enable-auto-discover-pipelines; optional in ' + 'manual mode (pre-attaches the connection so subsequent ' + 'pipelines submit calls only need --pipeline-ids).') with self.argument_context('devops migrations cutover set') as context: context.argument('cutover_date', options_list='--date', @@ -56,6 +128,11 @@ def load_migration_arguments(self, _): context.argument('accept_failures', options_list='--accept-failures', type=int, help='Number of unprocessed migration resources to accept before ' 'proceeding with cutover.') + context.argument('pipelines_verified', options_list='--pipelines-verified', action='store_true', + help='Acknowledge that all rewired pipelines have been verified. ' + 'Required when "cutover review" returns ' + 'requiresPipelineVerificationAcknowledgment: true. Can be combined ' + 'with --accept-failures in a single approve call.') with self.argument_context('devops migrations resume') as context: context.argument('validate_only', options_list='--validate-only', action='store_true', diff --git a/azure-devops/azext_devops/dev/migration/commands.py b/azure-devops/azext_devops/dev/migration/commands.py index f4b6984e..cf83d8a9 100644 --- a/azure-devops/azext_devops/dev/migration/commands.py +++ b/azure-devops/azext_devops/dev/migration/commands.py @@ -8,7 +8,9 @@ from ._format import (transform_migrations_table_output, transform_migration_table_output, transform_message_output, - transform_cutover_review_table_output) + transform_cutover_review_table_output, + transform_pipelines_list_table_output, + transform_pipeline_entries_table_output) migrationOps = CliCommandType( @@ -33,3 +35,12 @@ def load_migration_commands(self, _): g.command('approve', 'approve_cutover', table_transformer=transform_migration_table_output) g.command('set', 'schedule_cutover', table_transformer=transform_migration_table_output) g.command('cancel', 'cancel_cutover', table_transformer=transform_message_output) + + with self.command_group('devops migrations pipelines', command_type=migrationOps, is_preview=True) as g: + g.command('list', 'list_pipeline_rewiring', table_transformer=transform_pipelines_list_table_output) + g.command('submit', 'submit_pipeline_rewiring', table_transformer=transform_pipeline_entries_table_output) + g.command('update', 'update_pipeline_rewiring', table_transformer=transform_pipeline_entries_table_output) + g.command('retry', 'retry_pipeline_rewiring', table_transformer=transform_pipeline_entries_table_output) + g.command('delete', 'delete_pipeline_rewiring', + confirmation='Are you sure you want to delete pipeline rewiring data for this migration?', + table_transformer=transform_message_output) diff --git a/azure-devops/azext_devops/dev/migration/migration.py b/azure-devops/azext_devops/dev/migration/migration.py index 146472ae..c6cf17be 100644 --- a/azure-devops/azext_devops/dev/migration/migration.py +++ b/azure-devops/azext_devops/dev/migration/migration.py @@ -9,6 +9,7 @@ import subprocess import time import sys +import uuid from urllib.parse import quote_plus, urlparse from urllib.request import Request, urlopen from urllib.error import HTTPError, URLError @@ -17,8 +18,8 @@ from msrest.service_client import ServiceClient from msrest.universal_http import ClientRequest from knack.util import CLIError - from knack.log import get_logger +from azure.cli.core.azclierror import ResourceNotFoundError, ForbiddenError from azext_devops.version import VERSION from azext_devops.dev.common.services import get_connection, resolve_instance @@ -27,13 +28,15 @@ logger = get_logger(__name__) -API_VERSION = '7.2-preview' +API_VERSION = '7.2-preview.1' +PIPELINES_API_VERSION = '7.2-preview.1' MIGRATIONS_API_PATH = '/_apis/elm/migrations' CUTOVER_REVIEW_API_PATH_SUFFIX = '/cutoverReview' # The ELM service treats DateTimeOffset.MinValue as the sentinel for "clear the # scheduled cutover date". Sending null is silently ignored by the server, so # `cutover cancel` must send this exact value to actually clear the field. CUTOVER_DATE_CLEAR_SENTINEL = '0001-01-01T00:00:00+00:00' +PIPELINES_API_PATH_SUFFIX = '/pipelines' DEVICE_FLOW_CONFIG_API_PATH = '/_apis/migrations/deviceFlowConfig' LEGACY_DEVICE_FLOW_CONFIG_API_PATH = '/_apis/elm/migrations/deviceFlowConfig' GITHUB_TOKEN_ENV_VAR = 'ELM_GITHUB_TOKEN' @@ -67,6 +70,8 @@ 'queued', 'validation', 'synchronization', + 'readyforcutover', + 'reviewforcutover', 'cutover' } @@ -79,11 +84,12 @@ _URL_PATTERN = re.compile(r'^https?://[^\s]+$', re.IGNORECASE) -def list_migrations(include_inactive=False, project=None, organization=None, detect=None): +def list_migrations(include_all=False, include_inactive=False, project=None, organization=None, detect=None): organization = _resolve_org_for_auth(organization, detect) client = _get_service_client(organization) url = _build_migration_url(organization) - if include_inactive: + include_all = bool(include_all or include_inactive) + if include_all: url += '&includeInactiveMigrations=true' project = _normalize_optional_text(project) if project: @@ -91,7 +97,7 @@ def list_migrations(include_inactive=False, project=None, organization=None, det result = _send_request(client, 'GET', url) items = result.get('value', result) if isinstance(result, dict) else result if not items: - hint = '' if include_inactive else ' Use --include-inactive to include completed or abandoned migrations.' + hint = '' if include_all else ' Use --include-all to include completed or abandoned migrations.' logger.warning('No migrations found.%s', hint) return result @@ -172,6 +178,8 @@ def get_migration(repository_id=None, organization=None, detect=None): def create_migration(*, repository_id=None, target_repository=None, target_owner_user_id=None, validate_only=False, cutover_date=None, agent_pool=None, skip_validation=None, service_endpoint_id=None, github_token=None, + enable_boards_github_connection=False, enable_auto_discover_pipelines=False, + pipeline_service_connection_id=None, organization=None, detect=None): target_repository = _normalize_optional_text(target_repository) target_owner_user_id = _normalize_optional_text(target_owner_user_id) @@ -183,17 +191,25 @@ def create_migration(*, repository_id=None, target_repository=None, target_owner if not target_repository: raise CLIError('--target-repository must be specified.') _validate_target_repository(target_repository) - if service_endpoint_id and github_token: - raise CLIError('Specify either --service-endpoint-id or --github-token, not both. ' - 'When --service-endpoint-id is provided, GitHub authentication is handled ' - 'by the service connection.') + if enable_auto_discover_pipelines and not pipeline_service_connection_id: + raise CLIError('--enable-auto-discover-pipelines requires ' + '--pipeline-service-connection-id. Without a pipeline service ' + 'connection, auto-discovery runs as a no-op and enrolls 0 pipelines ' + 'while the migration still reports clean. Provide ' + '--pipeline-service-connection-id now, or omit ' + '--enable-auto-discover-pipelines and attach a connection later ' + '(auto-discovery re-runs on the next sync).') organization = _resolve_org_for_auth(organization, detect) repository_id = _resolve_repository_id(repository_id) client = _get_service_client(organization) if not service_endpoint_id: github_token = _resolve_github_user_token(client, organization, target_repository, github_token) else: - github_token = None + # SE supplies the GitHub credential used to sync commits. User-identity + # verification (gitHubUserToken) is independent: accept an explicit + # --github-token or ELM_GITHUB_TOKEN env var, but do not trigger device + # flow here so non-interactive SE-based flows aren't broken. + github_token = github_token or _normalize_optional_text(os.getenv(GITHUB_TOKEN_ENV_VAR)) payload = { 'targetRepository': target_repository, @@ -211,6 +227,17 @@ def create_migration(*, repository_id=None, target_repository=None, target_owner payload['skipValidation'] = skip_validation if service_endpoint_id: payload['serviceEndpointId'] = service_endpoint_id + config_options = {} + if enable_boards_github_connection: + config_options['enableBoardsGitHubConnection'] = True + if enable_auto_discover_pipelines: + config_options['enableAutoDiscoverPipelines'] = True + if config_options: + payload['configOptions'] = config_options + pipeline_service_connection_id = _validate_guid( + pipeline_service_connection_id, '--pipeline-service-connection-id') + if pipeline_service_connection_id is not None: + payload['pipelineServiceConnectionId'] = pipeline_service_connection_id url = _build_migration_url(organization, repository_id) try: @@ -219,7 +246,7 @@ def create_migration(*, repository_id=None, target_repository=None, target_owner error_text = str(ex) if 'status 409' in error_text and 'TF400898' in error_text: raise CLIError('An active migration already exists for repository {}. ' - 'Delete (abandon) the existing migration before creating a new one.' + 'Abandon the existing migration before creating a new one.' .format(repository_id)) raise @@ -474,11 +501,10 @@ def schedule_cutover(repository_id=None, cutover_date=None, organization=None, d def cancel_cutover(repository_id=None, organization=None, detect=None): - # The ELM service tracks the post-cutover drain using scheduledCutoverDate as a - # timestamp marker; clearing it once the worker has entered the `cutover` stage - # puts the migration into a state that requires server-side recovery - # (tracked by service team Bug 2394803). Guard against the dangerous case here - # until the server-side fix rolls out. + # Once the migration has entered the `cutover` stage, clearing the scheduled + # cutover date can leave it in a state that needs to be recovered on the + # service side. Guard against that case here so the operation is rejected + # client-side rather than producing an unrecoverable state. organization = _resolve_org_for_auth(organization, detect) migration_data = get_migration(repository_id=repository_id, organization=organization, detect=None) current_stage = _normalize_state(migration_data.get('stage')) if isinstance(migration_data, dict) else '' @@ -503,12 +529,19 @@ def get_cutover_review(repository_id=None, organization=None, detect=None): return _send_request(client, 'GET', url) -def approve_cutover(repository_id=None, accept_failures=None, organization=None, detect=None): - accepted_count = _parse_non_negative_int(accept_failures, '--accept-failures') +def approve_cutover(repository_id=None, accept_failures=None, pipelines_verified=False, + organization=None, detect=None): + accepted_count = None + if accept_failures is not None: + accepted_count = _parse_non_negative_int(accept_failures, '--accept-failures') + if accepted_count is None and not pipelines_verified: + raise CLIError('Specify --accept-failures and/or --pipelines-verified. ' + 'Run "az devops migrations cutover review" to see which is required.') organization = _resolve_org_for_auth(organization, detect) repository_id = _resolve_repository_id(repository_id) return _update_migration(repository_id, organization, detect=None, - cutover_failure_accepted_count=accepted_count) + cutover_failure_accepted_count=accepted_count, + pipelines_verified=pipelines_verified) def delete_migration(repository_id=None, remove_read_only=False, organization=None, detect=None): @@ -522,9 +555,113 @@ def delete_migration(repository_id=None, remove_read_only=False, organization=No return {'message': 'Migration abandoned successfully.'} +def list_pipeline_rewiring(repository_id=None, organization=None, detect=None): + organization = _resolve_org_for_auth(organization, detect) + repository_id = _resolve_repository_id(repository_id) + client = _get_service_client(organization) + url = _build_pipelines_url(organization, repository_id) + try: + return _send_request(client, 'GET', url, api_version=PIPELINES_API_VERSION) + except CLIError as ex: + message = str(ex) + if 'not available for failed migrations' in message.lower() \ + or 'failed migration' in message.lower(): + raise CLIError(message + ' Use "az devops migrations pipelines delete ' + '--repository-id --migration-id " to abandon the ' + 'failed migration before retrying.') + raise + + +def submit_pipeline_rewiring( + repository_id=None, pipeline_ids=None, service_connection_id=None, + repository_mapping=None, organization=None, detect=None): + parsed_pipeline_ids = _parse_pipeline_id_list(pipeline_ids, '--pipeline-ids', required=True) + if len(parsed_pipeline_ids) > 200: + raise CLIError('--pipeline-ids supports a maximum of 200 IDs per request.') + parsed_service_connection_id = _validate_guid(service_connection_id, '--service-connection-id') + parsed_mappings = _parse_repository_mappings(repository_mapping) + + organization = _resolve_org_for_auth(organization, detect) + repository_id = _resolve_repository_id(repository_id) + client = _get_service_client(organization) + payload = { + 'pipelineIds': parsed_pipeline_ids, + } + if parsed_service_connection_id is not None: + payload['serviceConnectionId'] = parsed_service_connection_id + if parsed_mappings is not None: + payload['repositoryMappings'] = parsed_mappings + + url = _build_pipelines_url(organization, repository_id) + return _send_request(client, 'POST', url, payload, api_version=PIPELINES_API_VERSION) + + +def update_pipeline_rewiring( + repository_id=None, add_ids=None, remove_ids=None, retry_ids=None, + service_connection_id=None, + repository_mapping=None, organization=None, detect=None): + parsed_add_ids = _parse_pipeline_id_list(add_ids, '--add-ids') + parsed_remove_ids = _parse_pipeline_id_list(remove_ids, '--remove-ids') + parsed_retry_ids = _parse_pipeline_id_list(retry_ids, '--retry-ids') + parsed_service_connection_id = _validate_guid(service_connection_id, '--service-connection-id') + parsed_mappings = _parse_repository_mappings(repository_mapping) + + payload = {} + if parsed_add_ids is not None: + payload['addPipelineIds'] = parsed_add_ids + if parsed_remove_ids is not None: + payload['removePipelineIds'] = parsed_remove_ids + if parsed_retry_ids is not None: + payload['retryFailedPipelineIds'] = parsed_retry_ids + if parsed_service_connection_id is not None: + payload['serviceConnectionId'] = parsed_service_connection_id + if parsed_mappings is not None: + payload['repositoryMappings'] = parsed_mappings + + if not payload: + raise CLIError('At least one update flag must be provided. Use one or more of ' + '--add-ids, --remove-ids, --retry-ids, ' + '--service-connection-id, or --repository-mapping.') + + organization = _resolve_org_for_auth(organization, detect) + repository_id = _resolve_repository_id(repository_id) + client = _get_service_client(organization) + url = _build_pipelines_url(organization, repository_id) + return _send_request(client, 'PUT', url, payload, api_version=PIPELINES_API_VERSION) + + +def retry_pipeline_rewiring(repository_id=None, pipeline_ids=None, organization=None, detect=None): + parsed_pipeline_ids = _parse_pipeline_id_list(pipeline_ids, '--pipeline-ids', required=True) + return update_pipeline_rewiring(repository_id=repository_id, + retry_ids=parsed_pipeline_ids, + organization=organization, + detect=detect) + + +def delete_pipeline_rewiring( + repository_id=None, migration_id=None, yes=False, + organization=None, detect=None): + del yes + organization = _resolve_org_for_auth(organization, detect) + repository_id = _resolve_repository_id(repository_id) + migration_id = _parse_positive_int_option(migration_id, '--migration-id') + + client = _get_service_client(organization) + url = _build_pipelines_url(organization, repository_id, migration_id=migration_id) + try: + _send_request(client, 'DELETE', url, api_version=PIPELINES_API_VERSION) + except CLIError as ex: + if 'status 409' in str(ex): + raise CLIError('Cannot delete pipeline rewiring data for migration {}: the ' + 'migration is not in a terminal stage. Complete or abandon ' + 'the migration first.'.format(migration_id)) + raise + return {'message': 'Pipeline rewiring data deleted successfully.'} + + def _update_migration(repository_id, organization, detect, *, validate_only=None, status_requested=None, scheduled_cutover_date=None, include_cutover=False, - cutover_failure_accepted_count=None): + cutover_failure_accepted_count=None, pipelines_verified=False): organization = _resolve_org_for_auth(organization, detect) repository_id = _resolve_repository_id(repository_id) client = _get_service_client(organization) @@ -539,6 +676,8 @@ def _update_migration(repository_id, organization, detect, *, validate_only=None payload['scheduledCutoverDate'] = scheduled_cutover_date if cutover_failure_accepted_count is not None: payload['cutoverFailureAcceptedCount'] = cutover_failure_accepted_count + if pipelines_verified: + payload['pipelinesVerified'] = True return _send_request(client, 'PUT', url, payload) @@ -556,6 +695,90 @@ def _parse_non_negative_int(value, option_name): return parsed +def _parse_positive_int_option(value, option_name): + if value is None: + raise CLIError('{} must be specified.'.format(option_name)) + try: + parsed = int(value) + except (TypeError, ValueError): + raise CLIError('{} must be a positive integer.'.format(option_name)) + if parsed <= 0: + raise CLIError('{} must be a positive integer.'.format(option_name)) + return parsed + + +def _validate_guid(value, option_name): + normalized = _normalize_optional_text(value) + if normalized is None: + return None + try: + return str(uuid.UUID(normalized)) + except (TypeError, ValueError, AttributeError): + raise CLIError('{} must be a valid GUID.'.format(option_name)) + + +def _parse_pipeline_id_list(values, option_name, required=False): + if values is None: + if required: + raise CLIError('{} must be specified.'.format(option_name)) + return None + + tokens = values if isinstance(values, list) else [values] + parsed_ids = [] + seen = set() + for token in tokens: + token_text = _normalize_optional_text(token) + if token_text is None: + continue + split_values = [part.strip() for part in token_text.split(',')] + for split_value in split_values: + if not split_value: + raise CLIError('{} contains an empty value. Use positive integer pipeline IDs only.' + .format(option_name)) + try: + pipeline_id = int(split_value) + except (TypeError, ValueError): + raise CLIError('{} must contain positive integer pipeline IDs. Invalid value: {}' + .format(option_name, split_value)) + if pipeline_id <= 0: + raise CLIError('{} must contain positive integer pipeline IDs. Invalid value: {}' + .format(option_name, split_value)) + if pipeline_id not in seen: + seen.add(pipeline_id) + parsed_ids.append(pipeline_id) + + if required and not parsed_ids: + raise CLIError('{} must be specified.'.format(option_name)) + return parsed_ids if parsed_ids else None + + +def _parse_repository_mappings(values): + if values is None: + return None + + parsed_mappings = [] + for raw_value in values: + mapping_text = _normalize_optional_text(raw_value) + if mapping_text is None: + raise CLIError('--repository-mapping cannot be empty. Expected =/.') + if '=' not in mapping_text: # pylint: disable=unsupported-membership-test + raise CLIError('--repository-mapping must be in the format =/.') + source_repo_id, target_repo = mapping_text.split('=', 1) + source_repo_id = _validate_guid(source_repo_id, '--repository-mapping source repo ID') + target_repo = _normalize_optional_text(target_repo) + if target_repo is None or target_repo.count('/') != 1: + raise CLIError('--repository-mapping target must be in the format /.') + owner, repo = target_repo.split('/', 1) + if not owner.strip() or not repo.strip(): + raise CLIError('--repository-mapping target must be in the format /.') + parsed_mappings.append({ + 'sourceRepositoryId': source_repo_id, + 'targetRepository': '{}/{}'.format(owner.strip(), repo.strip()) + }) + + return parsed_mappings + + def _resolve_repository_id(repository_id): if not repository_id: raise CLIError('--repository-id must be specified.') @@ -653,6 +876,14 @@ def _build_cutover_review_url(base_url, repository_id): return url + '?api-version=' + API_VERSION +def _build_pipelines_url(base_url, repository_id, migration_id=None): + url = base_url.rstrip('/') + MIGRATIONS_API_PATH + '/{}{}'.format(repository_id, PIPELINES_API_PATH_SUFFIX) + query_parts = ['api-version={}'.format(PIPELINES_API_VERSION)] + if migration_id is not None: + query_parts.append('migrationId={}'.format(migration_id)) + return '{}?{}'.format(url, '&'.join(query_parts)) + + def _get_service_client(organization): config = Configuration(base_url=None) config.add_user_agent('devOpsCli/{}'.format(VERSION)) @@ -661,11 +892,11 @@ def _get_service_client(organization): return ServiceClient(creds=connection._creds, config=config) # pylint: disable=protected-access -def _send_request(client, method, url, content=None): +def _send_request(client, method, url, content=None, api_version=API_VERSION): request = ClientRequest(method=method, url=url) headers = { 'Content-Type': 'application/json; charset=utf-8', - 'Accept': 'application/json;api-version=' + API_VERSION + 'Accept': 'application/json;api-version=' + api_version } response = client.send(request=request, headers=headers, content=content) if response.status_code < 200 or response.status_code >= 300: @@ -679,7 +910,24 @@ def _send_request(client, method, url, content=None): error_detail = body.get('message') or body.get('Message') or str(body) except Exception: # pylint: disable=broad-except error_detail = getattr(response, 'text', None) or getattr(response, 'content', None) or '' - raise CLIError('Request failed with status {}. {}'.format(response.status_code, error_detail)) + + if response.status_code >= 500: + headers_map = response.headers if response.headers else {} + correlation_id = headers_map.get('X-VSS-E2EID') or headers_map.get('x-vss-e2eid') + if correlation_id: + if error_detail: + error_detail = '{} CorrelationId: {}'.format(error_detail, correlation_id) + else: + error_detail = 'CorrelationId: {}'.format(correlation_id) + if response.status_code == 400 and 'EnsureBranchExists' in (error_detail or ''): + raise CLIError("No pipeline rewiring data exists for this migration yet. " + "Run 'az devops migrations pipelines submit' first.") + message = 'Request failed with status {}. {}'.format(response.status_code, error_detail) + if response.status_code == 404: + raise ResourceNotFoundError(message) + if response.status_code == 403: + raise ForbiddenError(message) + raise CLIError(message) content_type = response.headers.get('Content-Type') if response.headers else None if content_type and 'json' in content_type: diff --git a/azure-devops/azext_devops/tests/latest/migration/test_migration.py b/azure-devops/azext_devops/tests/latest/migration/test_migration.py index 6db550e2..8534a55f 100644 --- a/azure-devops/azext_devops/tests/latest/migration/test_migration.py +++ b/azure-devops/azext_devops/tests/latest/migration/test_migration.py @@ -24,7 +24,11 @@ approve_cutover, delete_migration, pause_migration, - resume_migration) + resume_migration, + submit_pipeline_rewiring, + update_pipeline_rewiring, + retry_pipeline_rewiring, + delete_pipeline_rewiring) class TestMigrationCommands(unittest.TestCase): @@ -263,6 +267,8 @@ def test_create_migration_non_conflict_error_passes_through(self): self.assertIn('status 400', str(ctx.exception)) def test_create_migration_with_service_endpoint_skips_device_flow(self): + # SE present + env var set: env var IS used for user-identity verification, + # but device flow MUST NOT run (would break non-interactive SE flows). with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \ patch('azext_devops.dev.migration.migration._send_request') as mock_send, \ @@ -270,8 +276,6 @@ def test_create_migration_with_service_endpoint_skips_device_flow(self): patch('azext_devops.dev.migration.migration._run_device_flow') as mock_run_flow: mock_send.return_value = {} mock_resolve.return_value = self._TEST_ORG - # Even with the env token set in setUp, presence of service-endpoint-id - # must short-circuit token resolution entirely. create_migration( repository_id='00000000-0000-0000-0000-000000000000', target_repository='https://example.ghe.com/OrgName/RepoName', @@ -286,31 +290,38 @@ def test_create_migration_with_service_endpoint_skips_device_flow(self): self.assertEqual(mock_send.call_count, 1) payload = mock_send.call_args[0][3] self.assertEqual(payload['serviceEndpointId'], '1df3c9b3-666c-4033-82de-059e7759ddfe') - self.assertNotIn('gitHubUserToken', payload) + self.assertEqual(payload['gitHubUserToken'], 'env-token-for-tests') - def test_create_migration_with_service_endpoint_and_token_rejected(self): + def test_create_migration_with_service_endpoint_and_token_both_sent(self): + # SE and user PAT are independent. The CLI must forward both to the server: + # SE for sync, user token for identity verification. with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \ - patch('azext_devops.dev.migration.migration._send_request') as mock_send: + patch('azext_devops.dev.migration.migration._send_request') as mock_send, \ + patch('azext_devops.dev.migration.migration._get_device_flow_config') as mock_flow, \ + patch('azext_devops.dev.migration.migration._run_device_flow') as mock_run_flow: + mock_send.return_value = {} mock_resolve.return_value = self._TEST_ORG - with self.assertRaises(CLIError) as ctx: - create_migration( - repository_id='00000000-0000-0000-0000-000000000000', - target_repository='https://example.ghe.com/OrgName/RepoName', - target_owner_user_id='TestOwner', - service_endpoint_id='1df3c9b3-666c-4033-82de-059e7759ddfe', - github_token='param-token', - organization=self._TEST_ORG, - detect=False - ) + create_migration( + repository_id='00000000-0000-0000-0000-000000000000', + target_repository='https://example.ghe.com/OrgName/RepoName', + target_owner_user_id='TestOwner', + service_endpoint_id='1df3c9b3-666c-4033-82de-059e7759ddfe', + github_token='param-token', + organization=self._TEST_ORG, + detect=False + ) - self.assertIn('Specify either --service-endpoint-id or --github-token', str(ctx.exception)) - mock_send.assert_not_called() + mock_flow.assert_not_called() + mock_run_flow.assert_not_called() + payload = mock_send.call_args[0][3] + self.assertEqual(payload['serviceEndpointId'], '1df3c9b3-666c-4033-82de-059e7759ddfe') + self.assertEqual(payload['gitHubUserToken'], 'param-token') - def test_create_migration_with_service_endpoint_ignores_env_token(self): - # ELM_GITHUB_TOKEN is set in setUp; the service endpoint path must not - # pick it up and must not include gitHubUserToken in the payload. + def test_create_migration_with_service_endpoint_uses_env_token(self): + # ELM_GITHUB_TOKEN is set in setUp; with SE provided and no explicit + # --github-token, the env var is picked up for identity verification. with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \ patch('azext_devops.dev.migration.migration._send_request') as mock_send, \ @@ -329,7 +340,7 @@ def test_create_migration_with_service_endpoint_ignores_env_token(self): mock_flow.assert_not_called() payload = mock_send.call_args[0][3] - self.assertNotIn('gitHubUserToken', payload) + self.assertEqual(payload['gitHubUserToken'], 'env-token-for-tests') self.assertEqual(payload['serviceEndpointId'], '1df3c9b3-666c-4033-82de-059e7759ddfe') self.assertTrue(payload['validateOnly']) @@ -355,7 +366,7 @@ def test_create_migration_service_endpoint_with_whitespace_github_token_not_reje mock_flow.assert_not_called() payload = mock_send.call_args[0][3] self.assertEqual(payload['serviceEndpointId'], '1df3c9b3-666c-4033-82de-059e7759ddfe') - self.assertNotIn('gitHubUserToken', payload) + self.assertEqual(payload['gitHubUserToken'], 'env-token-for-tests') def test_create_migration_service_endpoint_conflict_returns_clear_message(self): # Ensure the 409/TF400898 friendly message still surfaces on the @@ -407,7 +418,7 @@ def test_create_migration_service_endpoint_with_all_optional_fields(self): self.assertEqual(payload['agentPoolName'], 'TestPool') self.assertEqual(payload['scheduledCutoverDate'], '2026-06-01T00:00:00Z') self.assertEqual(payload['skipValidation'], 4) - self.assertNotIn('gitHubUserToken', payload) + self.assertEqual(payload['gitHubUserToken'], 'env-token-for-tests') def test_create_migration_no_token_and_missing_device_flow_config_fields_fails(self): with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ @@ -855,7 +866,7 @@ def test_create_migration_service_endpoint_id_included_in_payload(self): self.assertEqual(payload['serviceEndpointId'], '12345678-1234-1234-1234-123456789012') # When a service connection is supplied, the server uses it for GitHub auth; # the CLI must not resolve or send a GitHub token. - self.assertNotIn('gitHubUserToken', payload) + self.assertEqual(payload['gitHubUserToken'], 'env-token-for-tests') def test_create_migration_service_endpoint_id_skips_github_token_resolution(self): with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ @@ -1021,7 +1032,46 @@ def test_approve_cutover_requires_accept_failures(self): organization=self._TEST_ORG, detect=False ) - self.assertIn('--accept-failures must be specified', str(ctx.exception)) + self.assertIn('--accept-failures and/or --pipelines-verified', str(ctx.exception)) + + def test_approve_cutover_sends_pipelines_verified_only(self): + with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ + patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \ + patch('azext_devops.dev.migration.migration._send_request') as mock_send: + mock_send.return_value = {'stage': 'ReadyForCutover'} + mock_resolve.return_value = self._TEST_ORG + + approve_cutover( + repository_id='00000000-0000-0000-0000-000000000000', + pipelines_verified=True, + organization=self._TEST_ORG, + detect=False + ) + + args = mock_send.call_args[0] + self.assertEqual(args[1], 'PUT') + self.assertEqual(args[3]['pipelinesVerified'], True) + self.assertNotIn('cutoverFailureAcceptedCount', args[3]) + + def test_approve_cutover_sends_pipelines_verified_with_accept_failures(self): + with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ + patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \ + patch('azext_devops.dev.migration.migration._send_request') as mock_send: + mock_send.return_value = {'stage': 'ReadyForCutover'} + mock_resolve.return_value = self._TEST_ORG + + approve_cutover( + repository_id='00000000-0000-0000-0000-000000000000', + accept_failures=2, + pipelines_verified=True, + organization=self._TEST_ORG, + detect=False + ) + + args = mock_send.call_args[0] + self.assertEqual(args[1], 'PUT') + self.assertEqual(args[3]['cutoverFailureAcceptedCount'], 2) + self.assertEqual(args[3]['pipelinesVerified'], True) def test_approve_cutover_rejects_negative_accept_failures(self): with self.assertRaises(CLIError) as ctx: @@ -1095,7 +1145,7 @@ def test_list_migrations_warns_when_empty(self): warning_msg = mock_logger.warning.call_args[0][0] self.assertIn('No migrations found', warning_msg) - def test_list_migrations_hints_include_inactive_when_not_passed(self): + def test_list_migrations_hints_include_all_when_not_passed(self): with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \ patch('azext_devops.dev.migration.migration._send_request') as mock_send, \ @@ -1103,12 +1153,12 @@ def test_list_migrations_hints_include_inactive_when_not_passed(self): mock_send.return_value = {'value': []} mock_resolve.return_value = self._TEST_ORG - list_migrations(include_inactive=False, organization=self._TEST_ORG, detect=False) + list_migrations(include_all=False, organization=self._TEST_ORG, detect=False) warning_call = str(mock_logger.warning.call_args) - self.assertIn('include-inactive', warning_call) + self.assertIn('include-all', warning_call) - def test_list_migrations_no_hint_when_include_inactive_passed(self): + def test_list_migrations_no_hint_when_include_all_passed(self): with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \ patch('azext_devops.dev.migration.migration._send_request') as mock_send, \ @@ -1116,10 +1166,10 @@ def test_list_migrations_no_hint_when_include_inactive_passed(self): mock_send.return_value = {'value': []} mock_resolve.return_value = self._TEST_ORG - list_migrations(include_inactive=True, organization=self._TEST_ORG, detect=False) + list_migrations(include_all=True, organization=self._TEST_ORG, detect=False) warning_call = str(mock_logger.warning.call_args) - self.assertNotIn('include-inactive', warning_call) + self.assertNotIn('include-all', warning_call) def test_resume_rejects_both_flags(self): with self.assertRaises(CLIError): @@ -1161,6 +1211,17 @@ def test_resume_fails_when_active_via_statusRequested(self): organization=self._TEST_ORG, detect=False) self.assertIn('statusRequested: Active', str(ctx.exception)) + def test_is_migration_active_covers_all_in_progress_stages(self): + # Stage-only fallback (no status field) must treat every in-progress stage as active, + # including the cutover-gate stages readyForCutover / reviewForCutover. + for stage in ('queued', 'validation', 'synchronization', + 'readyForCutover', 'reviewForCutover', 'cutover'): + self.assertTrue(migration_module._is_migration_active({'stage': stage}), + 'stage {} should be active'.format(stage)) + # Terminal/unknown stages are not active. + self.assertFalse(migration_module._is_migration_active({'stage': 'migrated'})) + self.assertFalse(migration_module._is_migration_active({'stage': 'aborted'})) + def test_resume_sets_validate_only(self): with patch('azext_devops.dev.migration.migration.get_migration') as mock_get, \ patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ @@ -1474,6 +1535,457 @@ def send(request, headers, content): self.assertIn('TargetRepositoryDoesNotExist', text) self.assertIn('Target repository could not be found.', text) + def test_submit_pipeline_rewiring_omits_service_connection_when_not_provided(self): + with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ + patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \ + patch('azext_devops.dev.migration.migration._send_request') as mock_send: + mock_resolve.return_value = self._TEST_ORG + mock_send.return_value = [] + + submit_pipeline_rewiring( + repository_id='00000000-0000-0000-0000-000000000000', + pipeline_ids=['42'], + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + self.assertEqual(payload['pipelineIds'], [42]) + self.assertNotIn('serviceConnectionId', payload) + + def test_submit_pipeline_rewiring_omits_service_connection_when_empty_string(self): + with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ + patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \ + patch('azext_devops.dev.migration.migration._send_request') as mock_send: + mock_resolve.return_value = self._TEST_ORG + mock_send.return_value = [] + + submit_pipeline_rewiring( + repository_id='00000000-0000-0000-0000-000000000000', + pipeline_ids=['42'], + service_connection_id=' ', + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + self.assertNotIn('serviceConnectionId', payload) + + def test_submit_pipeline_rewiring_accepts_space_separated_ids(self): + with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ + patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \ + patch('azext_devops.dev.migration.migration._send_request') as mock_send: + mock_resolve.return_value = self._TEST_ORG + mock_send.return_value = [] + + submit_pipeline_rewiring( + repository_id='00000000-0000-0000-0000-000000000000', + pipeline_ids=['42', '43', '44'], + service_connection_id='11111111-1111-1111-1111-111111111111', + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + self.assertEqual(payload['pipelineIds'], [42, 43, 44]) + self.assertEqual(payload['serviceConnectionId'], '11111111-1111-1111-1111-111111111111') + + def test_submit_pipeline_rewiring_accepts_comma_separated_ids(self): + with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ + patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \ + patch('azext_devops.dev.migration.migration._send_request') as mock_send: + mock_resolve.return_value = self._TEST_ORG + mock_send.return_value = [] + + submit_pipeline_rewiring( + repository_id='00000000-0000-0000-0000-000000000000', + pipeline_ids=['42,43,44'], + service_connection_id='11111111-1111-1111-1111-111111111111', + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + self.assertEqual(payload['pipelineIds'], [42, 43, 44]) + + def test_submit_pipeline_rewiring_rejects_invalid_pipeline_id(self): + with self.assertRaises(CLIError) as ctx: + submit_pipeline_rewiring( + repository_id='00000000-0000-0000-0000-000000000000', + pipeline_ids=['42', 'abc'], + service_connection_id='11111111-1111-1111-1111-111111111111', + organization=self._TEST_ORG, + detect=False + ) + self.assertIn('--pipeline-ids', str(ctx.exception)) + + def test_submit_pipeline_rewiring_rejects_invalid_service_connection_guid(self): + with self.assertRaises(CLIError) as ctx: + submit_pipeline_rewiring( + repository_id='00000000-0000-0000-0000-000000000000', + pipeline_ids=['42'], + service_connection_id='not-a-guid', + organization=self._TEST_ORG, + detect=False + ) + self.assertIn('--service-connection-id must be a valid GUID', str(ctx.exception)) + + def test_submit_pipeline_rewiring_rejects_invalid_repository_mapping(self): + with self.assertRaises(CLIError) as ctx: + submit_pipeline_rewiring( + repository_id='00000000-0000-0000-0000-000000000000', + pipeline_ids=['42'], + service_connection_id='11111111-1111-1111-1111-111111111111', + repository_mapping=['not-a-guid=myorg/repo'], + organization=self._TEST_ORG, + detect=False + ) + self.assertIn('repository-mapping source repo ID', str(ctx.exception)) + + def test_submit_pipeline_rewiring_rejects_repository_mapping_with_extra_slash(self): + with self.assertRaises(CLIError) as ctx: + submit_pipeline_rewiring( + repository_id='00000000-0000-0000-0000-000000000000', + pipeline_ids=['42'], + service_connection_id='11111111-1111-1111-1111-111111111111', + repository_mapping=['aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa=myorg/repo/extra'], + organization=self._TEST_ORG, + detect=False + ) + self.assertIn('format /', str(ctx.exception)) + + def test_submit_pipeline_rewiring_parses_repository_mapping(self): + with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ + patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \ + patch('azext_devops.dev.migration.migration._send_request') as mock_send: + mock_resolve.return_value = self._TEST_ORG + mock_send.return_value = [] + + submit_pipeline_rewiring( + repository_id='00000000-0000-0000-0000-000000000000', + pipeline_ids=['42'], + service_connection_id='11111111-1111-1111-1111-111111111111', + repository_mapping=['aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa=myorg/shared-templates'], + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + self.assertEqual(payload['repositoryMappings'][0]['sourceRepositoryId'], + 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa') + self.assertEqual(payload['repositoryMappings'][0]['targetRepository'], + 'myorg/shared-templates') + + def test_update_pipeline_rewiring_rejects_no_flags(self): + with self.assertRaises(CLIError) as ctx: + update_pipeline_rewiring( + repository_id='00000000-0000-0000-0000-000000000000', + organization=self._TEST_ORG, + detect=False + ) + self.assertIn('At least one update flag must be provided', str(ctx.exception)) + + def test_update_pipeline_rewiring_payload_contains_provided_fields_only(self): + with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ + patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \ + patch('azext_devops.dev.migration.migration._send_request') as mock_send: + mock_resolve.return_value = self._TEST_ORG + mock_send.return_value = [] + + update_pipeline_rewiring( + repository_id='00000000-0000-0000-0000-000000000000', + add_ids=['50', '51'], + remove_ids=['42'], + service_connection_id='22222222-2222-2222-2222-222222222222', + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + self.assertEqual(payload['addPipelineIds'], [50, 51]) + self.assertEqual(payload['removePipelineIds'], [42]) + self.assertEqual(payload['serviceConnectionId'], '22222222-2222-2222-2222-222222222222') + self.assertNotIn('retryFailedPipelineIds', payload) + self.assertNotIn('acknowledgePipelineIds', payload) + + def test_retry_pipeline_rewiring_calls_update_with_retry_ids(self): + with patch('azext_devops.dev.migration.migration.update_pipeline_rewiring') as mock_update: + mock_update.return_value = [] + + retry_pipeline_rewiring( + repository_id='00000000-0000-0000-0000-000000000000', + pipeline_ids=['42', '43'], + organization=self._TEST_ORG, + detect=False + ) + + kwargs = mock_update.call_args[1] + self.assertEqual(kwargs['retry_ids'], [42, 43]) + + def test_submit_pipeline_rewiring_rejects_more_than_200_ids(self): + too_many_ids = [str(i) for i in range(1, 202)] + with self.assertRaises(CLIError) as ctx: + submit_pipeline_rewiring( + repository_id='00000000-0000-0000-0000-000000000000', + pipeline_ids=too_many_ids, + service_connection_id='11111111-1111-1111-1111-111111111111', + organization=self._TEST_ORG, + detect=False + ) + self.assertIn('maximum of 200', str(ctx.exception)) + + def test_submit_pipeline_rewiring_rejects_empty_comma_value(self): + with self.assertRaises(CLIError) as ctx: + submit_pipeline_rewiring( + repository_id='00000000-0000-0000-0000-000000000000', + pipeline_ids=['42,,43'], + service_connection_id='11111111-1111-1111-1111-111111111111', + organization=self._TEST_ORG, + detect=False + ) + self.assertIn('contains an empty value', str(ctx.exception)) + + def test_delete_pipeline_rewiring_calls_delete_with_migration_id_query(self): + with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ + patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \ + patch('azext_devops.dev.migration.migration._send_request') as mock_send: + mock_resolve.return_value = self._TEST_ORG + mock_send.return_value = {} + + delete_pipeline_rewiring( + repository_id='00000000-0000-0000-0000-000000000000', + migration_id=7, + organization=self._TEST_ORG, + detect=False + ) + + args = mock_send.call_args[0] + self.assertEqual(args[1], 'DELETE') + self.assertIn('migrationId=7', args[2]) + + def test_send_request_404_returns_server_message(self): + class MockResponse(object): + status_code = 404 + headers = {'Content-Type': 'application/json'} + + @staticmethod + def json(): + return {'message': 'Migration not found'} + + class MockClient(object): + @staticmethod + def send(request, headers, content): + del request, headers, content + return MockResponse() + + with self.assertRaises(CLIError) as ctx: + migration_module._send_request(MockClient(), 'GET', 'https://example.test') + self.assertIn('status 404', str(ctx.exception)) + self.assertIn('Migration not found', str(ctx.exception)) + + def test_send_request_includes_correlation_id_for_server_errors(self): + class MockResponse(object): + status_code = 500 + headers = {'Content-Type': 'application/json', 'X-VSS-E2EID': 'abc-123'} + + @staticmethod + def json(): + return {'message': 'Internal server error'} + + class MockClient(object): + @staticmethod + def send(request, headers, content): + del request, headers, content + return MockResponse() + + with self.assertRaises(CLIError) as ctx: + migration_module._send_request(MockClient(), 'GET', 'https://example.test') + self.assertIn('abc-123', str(ctx.exception)) + + + def test_send_request_404_raises_resource_not_found_error(self): + from azure.cli.core.azclierror import ResourceNotFoundError + + class MockResponse(object): + status_code = 404 + headers = {'Content-Type': 'application/json'} + + @staticmethod + def json(): + return {'message': 'Migration not found'} + + class MockClient(object): + @staticmethod + def send(request, headers, content): + del request, headers, content + return MockResponse() + + with self.assertRaises(ResourceNotFoundError) as ctx: + migration_module._send_request(MockClient(), 'GET', 'https://example.test') + self.assertIn('status 404', str(ctx.exception)) + + def test_send_request_403_raises_forbidden_error(self): + from azure.cli.core.azclierror import ForbiddenError + + class MockResponse(object): + status_code = 403 + headers = {'Content-Type': 'application/json'} + + @staticmethod + def json(): + return {'message': 'Access denied'} + + class MockClient(object): + @staticmethod + def send(request, headers, content): + del request, headers, content + return MockResponse() + + with self.assertRaises(ForbiddenError) as ctx: + migration_module._send_request(MockClient(), 'GET', 'https://example.test') + self.assertIn('status 403', str(ctx.exception)) + self.assertIn('Access denied', str(ctx.exception)) + + def test_list_pipeline_rewiring_appends_hint_on_failed_migration(self): + with patch('azext_devops.dev.migration.migration._resolve_org_for_auth') as mock_org, \ + patch('azext_devops.dev.migration.migration._resolve_repository_id') as mock_repo, \ + patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \ + patch('azext_devops.dev.migration.migration._send_request') as mock_send: + mock_org.return_value = 'https://dev.azure.com/contoso' + mock_repo.return_value = '11111111-1111-1111-1111-111111111111' + mock_client.return_value = object() + mock_send.side_effect = CLIError( + 'Request failed with status 400. Pipeline information is not available ' + 'for failed migrations.') + + with self.assertRaises(CLIError) as ctx: + migration_module.list_pipeline_rewiring( + repository_id='11111111-1111-1111-1111-111111111111', + organization='https://dev.azure.com/contoso') + self.assertIn('pipelines delete', str(ctx.exception)) + + + def test_create_migration_includes_enable_boards_github_connection_when_requested(self): + with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ + patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \ + patch('azext_devops.dev.migration.migration._send_request') as mock_send: + del mock_client + mock_send.return_value = {} + mock_resolve.return_value = self._TEST_ORG + + create_migration( + repository_id='00000000-0000-0000-0000-000000000000', + target_repository='https://example.ghe.com/OrgName/RepoName', + enable_boards_github_connection=True, + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + self.assertEqual(payload['configOptions'], {'enableBoardsGitHubConnection': True}) + + def test_create_migration_includes_enable_auto_discover_pipelines_when_requested(self): + with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ + patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \ + patch('azext_devops.dev.migration.migration._send_request') as mock_send: + del mock_client + mock_send.return_value = {} + mock_resolve.return_value = self._TEST_ORG + + create_migration( + repository_id='00000000-0000-0000-0000-000000000000', + target_repository='https://example.ghe.com/OrgName/RepoName', + enable_auto_discover_pipelines=True, + pipeline_service_connection_id='11111111-1111-1111-1111-111111111111', + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + self.assertEqual(payload['configOptions'], {'enableAutoDiscoverPipelines': True}) + + def test_create_migration_blocks_auto_discover_without_service_connection(self): + with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ + patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \ + patch('azext_devops.dev.migration.migration._send_request') as mock_send: + del mock_client + mock_resolve.return_value = self._TEST_ORG + + with self.assertRaises(CLIError) as ctx: + create_migration( + repository_id='00000000-0000-0000-0000-000000000000', + target_repository='https://example.ghe.com/OrgName/RepoName', + enable_auto_discover_pipelines=True, + organization=self._TEST_ORG, + detect=False + ) + + self.assertIn('--pipeline-service-connection-id', str(ctx.exception)) + mock_send.assert_not_called() + + def test_create_migration_includes_both_config_options_when_both_flags_set(self): + with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ + patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \ + patch('azext_devops.dev.migration.migration._send_request') as mock_send: + del mock_client + mock_send.return_value = {} + mock_resolve.return_value = self._TEST_ORG + + create_migration( + repository_id='00000000-0000-0000-0000-000000000000', + target_repository='https://example.ghe.com/OrgName/RepoName', + enable_boards_github_connection=True, + enable_auto_discover_pipelines=True, + pipeline_service_connection_id='11111111-1111-1111-1111-111111111111', + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + self.assertEqual(payload['configOptions'], { + 'enableBoardsGitHubConnection': True, + 'enableAutoDiscoverPipelines': True, + }) + + def test_create_migration_includes_pipeline_service_connection_id_at_top_level(self): + with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ + patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \ + patch('azext_devops.dev.migration.migration._send_request') as mock_send: + del mock_client + mock_send.return_value = {} + mock_resolve.return_value = self._TEST_ORG + + create_migration( + repository_id='00000000-0000-0000-0000-000000000000', + target_repository='https://example.ghe.com/OrgName/RepoName', + pipeline_service_connection_id='11111111-1111-1111-1111-111111111111', + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + self.assertEqual(payload['pipelineServiceConnectionId'], + '11111111-1111-1111-1111-111111111111') + self.assertNotIn('configOptions', payload) + + def test_create_migration_omits_enable_boards_github_connection_by_default(self): + with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ + patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \ + patch('azext_devops.dev.migration.migration._send_request') as mock_send: + del mock_client + mock_send.return_value = {} + mock_resolve.return_value = self._TEST_ORG + + create_migration( + repository_id='00000000-0000-0000-0000-000000000000', + target_repository='https://example.ghe.com/OrgName/RepoName', + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + self.assertNotIn('configOptions', payload) + if __name__ == '__main__': unittest.main() diff --git a/doc/elm_migrations_tsg.md b/doc/elm_migrations_tsg.md index cc38a788..0fd0199b 100644 --- a/doc/elm_migrations_tsg.md +++ b/doc/elm_migrations_tsg.md @@ -18,16 +18,16 @@ Verify it's installed: az --version ``` -### 1.2 Install the ELM extension from the wheel file +### 1.2 Install the latest CLI and Azure DevOps extension -You'll receive a `.whl` file (e.g., `azure_devops-1.0.3-py2.py3-none-any.whl`). This is the Azure DevOps CLI extension package that contains the migration commands. +Make sure you're on the latest Azure CLI and the latest `azure-devops` extension, which contains the migration commands. ```powershell -# Remove any existing version first (ignore errors if not installed) -az extension remove -n azure-devops +# Update Azure CLI to the latest version +az upgrade -# Install from the wheel file (use the actual path to your .whl file) -az extension add --source ./azure_devops-1.0.3-py2.py3-none-any.whl -y +# Install the latest azure-devops extension (or update it if already installed) +az extension add -n azure-devops --upgrade # Verify installation — you should see name: "azure-devops" and a version az extension show -n azure-devops --query "{name:name,version:version}" -o json @@ -65,27 +65,38 @@ You should see your org URL under `organization`. If you see a wrong URL (e.g., ## 2. Understand the Migration Lifecycle -A migration moves through these **stages**: +A migration moves through these **stages** (shown in the `stage` field of `status`): ``` -Queued → Validation → Synchronization → Cutover → Migrated +Queued → Validation → Synchronization → ReviewForCutover → Cutover → Migrated ``` -And has one of these **statuses**: +| Stage | Meaning | +|---|---| +| `Queued` | Migration is queued and waiting to start | +| `Validation` | Pre-migration checks are running | +| `Synchronization` | Code and pull requests are being copied to the target | +| `ReviewForCutover` | Sync is done; the migration is waiting for you to review unprocessed items and **approve** cutover (see step 3.7) | +| `Cutover` | Final cutover is in progress (source repo made read-only, last delta applied) | +| `Migrated` | Migration finished successfully | + +And has one of these **statuses** (the `status` field): | Status | Meaning | |---|---| | `Active` | Migration is running (in one of the stages above) | -| `Succeeded` | Migration completed successfully | +| `Paused` | Migration was paused with `pause` (resume to continue) | +| `Suspended` | Migration was suspended by the user/service (can be resumed) | | `Failed` | Migration encountered an error (can be resumed) | -| `Suspended` | Migration was paused by the user (can be resumed) | +| `Succeeded` / `Completed` | Migration (or validation) completed successfully | ### Recommended workflow The safest approach is **validate first, then migrate**: ``` -Create (validate-only) → Check status → Resume (--migration) → Monitor → Schedule cutover → Done +Create (validate-only) → Check status → Resume (--migration) → Monitor + → Schedule cutover → (at ReviewForCutover) cutover review → cutover approve → Migrated ``` --- @@ -121,19 +132,21 @@ Save this GUID — you'll use it in every command below. ### 3.2 (Optional) Check for existing migrations -See if any migrations already exist for your org: +By default, `list` returns the **latest migration per repository** (regardless of state): ```powershell -# Active migrations only +# Latest migration per repository az devops migrations list --detect false # Filter by project name or ID az devops migrations list --detect false --project MyProject -# All migrations including completed/failed/suspended -az devops migrations list --detect false --include-inactive +# Full migration history (all records per repository) +az devops migrations list --detect false --include-all ``` +> `--include-inactive` is deprecated — use `--include-all` instead. It still works but redirects to `--include-all`. + ### 3.3 Create a validate-only migration Start with validation to catch any issues **before** moving data. This runs pre-migration checks without transferring any code or PRs: @@ -150,7 +163,7 @@ The command returns the migration details as JSON. The migration begins immediat > **Tip:** If you're confident and want to start a full migration right away (skip validate-only), omit the `--validate-only` flag. -If `--github-token` is not provided, the CLI checks `ELM_GITHUB_TOKEN` and then runs GitHub device flow to acquire a token. +If `--github-token` is not provided, the CLI checks `ELM_GITHUB_TOKEN` and then runs GitHub device flow to acquire a token. If you pass `--service-endpoint-id` (a GitHub Enterprise Server service connection used to sync commits), device flow is skipped — supply `--github-token` or `ELM_GITHUB_TOKEN` only if user-identity verification is also needed. You can also pass a token or PAT explicitly: @@ -165,8 +178,13 @@ az devops migrations create --detect false \ | Parameter | What it does | Example | |---|---|---| +| `--agent-pool` | Agent pool name used for migration work | `--agent-pool my-pool` | | `--cutover-date` | Pre-schedule the final cutover date | `--cutover-date 2030-12-31T11:59:00Z` | | `--skip-validation` | Skip specific validation checks | `--skip-validation ActivePullRequestCount,PullRequestDeltaSize` | +| `--service-endpoint-id` | GitHub Enterprise Server service connection (GUID) used to sync commits; skips device flow | `--service-endpoint-id ` | +| `--enable-boards-github-connection` | Provision the Azure Boards GitHub connection at cutover (off by default; requires the Boards GitHub App installed on the target org) | `--enable-boards-github-connection` | +| `--enable-auto-discover-pipelines` | Auto-discover and clone pipelines that reference the source repo at cutover (off by default) | `--enable-auto-discover-pipelines` | +| `--pipeline-service-connection-id` | Project-scoped GitHub service connection (GUID) attached at create time for pipeline rewiring | `--pipeline-service-connection-id ` | ### 3.4 Monitor migration status @@ -188,9 +206,10 @@ az devops migrations status --detect false --repository-id b3e18946-5b39-40ca-8e |---|---|---| | `status: Active`, `stage: Validation` | Validation is in progress | Wait, check again later | | `status: Active`, `stage: Synchronization` | Code/PRs are syncing | Wait, check again later | -| `status: Succeeded` | Current phase completed | If validate-only: go to step 3.5. If migration: go to step 3.6 | +| `status: Active`, `stage: ReviewForCutover` | Sync finished; waiting for cutover approval | Review and approve (step 3.7) | +| `status: Succeeded` | Current phase completed | If validate-only: go to step 3.5. If migration: schedule cutover (step 3.6) | | `status: Failed` | Something went wrong | Check the error in `-o json` output, fix the issue, then resume (step 4) | -| `status: Suspended` | You paused it | Resume when ready (step 3.5) | +| `status: Paused` / `Suspended` | Migration is paused | Resume when ready (step 3.5) | ### 3.5 Promote from validate-only to full migration @@ -241,7 +260,35 @@ Changed your mind? Cancel the scheduled cutover: az devops migrations cutover cancel --detect false --repository-id b3e18946-5b39-40ca-8e2f-d0eb683d8a85 ``` -### 3.7 Verify completion +> **Note:** You cannot cancel once the migration has entered the `Cutover` stage — cancelling at that point is unsafe and requires server-side recovery. Cancel only while cutover is still *scheduled* (before the `Cutover` stage). + +### 3.7 Review and approve cutover + +When synchronization finishes, the migration moves to `stage: ReviewForCutover` and waits for your approval. First, review any unprocessed items (failed, blocked, or pending): + +```powershell +az devops migrations cutover review --detect false --repository-id b3e18946-5b39-40ca-8e2f-d0eb683d8a85 +``` + +The review output reports `FailedCount`, `BlockedCount`, `PendingCount`, `TotalUnprocessedCount`, and `RequiresPipelineVerification`. Then approve to let cutover proceed: + +```powershell +# Accept the unprocessed items reported by review (use the count you're willing to accept) +az devops migrations cutover approve --detect false \ + --repository-id b3e18946-5b39-40ca-8e2f-d0eb683d8a85 --accept-failures 3 +``` + +If review reports `RequiresPipelineVerification: true`, you must also acknowledge that rewired pipelines were verified: + +```powershell +# Acknowledge pipeline verification (can be combined with --accept-failures in one call) +az devops migrations cutover approve --detect false \ + --repository-id b3e18946-5b39-40ca-8e2f-d0eb683d8a85 --pipelines-verified +``` + +> You must specify `--accept-failures` and/or `--pipelines-verified`. Run `cutover review` first to see which is required. + +### 3.8 Verify completion After cutover completes, confirm the migration finished: @@ -253,7 +300,7 @@ az devops migrations status --detect false --repository-id b3e18946-5b39-40ca-8e At this point your repository has been fully migrated from Azure DevOps to GitHub. Verify the target repo in GitHub has all your code, branches, and pull requests. -### 3.8 (If needed) Abandon a migration +### 3.9 (If needed) Abandon a migration If something went wrong and you want to delete the migration entirely and start over: @@ -262,6 +309,8 @@ az devops migrations abandon --detect false --repository-id b3e18946-5b39-40ca-8 ``` > **Warning:** This permanently deletes the migration record. You will be prompted to confirm. After abandoning, you can create a new migration for the same repository. +> +> By default the source Azure Repos repository stays read-only. Add `--remove-read-only` to set it back to read-write when abandoning. --- @@ -301,20 +350,61 @@ az devops migrations resume --detect false --repository-id --migration az devops migrations resume --detect false --repository-id --validate-only ``` +> If the migration is at `stage: ReviewForCutover`, `resume` is blocked — review and approve cutover (step 3.7) instead, or cancel/reschedule cutover or abandon the migration. + +### Rewire pipelines (preview) + +If pipelines in the source project reference the migrated repository, you can rewire them to the new GitHub repository. This is a preview feature under `az devops migrations pipelines`. + +```powershell +# List pipeline rewiring configuration and per-pipeline status +az devops migrations pipelines list --detect false --repository-id + +# Submit pipelines for rewiring (IDs accept space- or comma-separated values) +az devops migrations pipelines submit --detect false --repository-id \ + --pipeline-ids 42 43 44 \ + --service-connection-id \ + --repository-mapping =/ + +# Bulk update: add, remove, retry failed, or change the service connection +az devops migrations pipelines update --detect false --repository-id \ + --add-ids 50 51 --remove-ids 42 --retry-ids 43 \ + --service-connection-id + +# Retry specific failed pipelines +az devops migrations pipelines retry --detect false --repository-id --pipeline-ids 42 43 + +# Delete pipeline rewiring data for a migration (migration must be in a terminal stage) +az devops migrations pipelines delete --detect false --repository-id --migration-id --yes +``` + +> **Notes:** +> - `--service-connection-id` is a project-scoped GitHub service connection GUID. It's optional if a connection was already attached via `migrations create --pipeline-service-connection-id` or a prior `pipelines update`. +> - `--repository-mapping` can be supplied multiple times. Format: `=/`. +> - `submit` supports up to 200 pipeline IDs per request. +> - `pipelines list` is not available for failed migrations. Use `pipelines delete --migration-id ` to clean up first. + --- ## 5. Complete Command & Parameter Reference | Command | Required Params | Optional Params | HTTP | Description | |---|---|---|---|---| -| `list` | `--org` | `--include-inactive`, `--detect` | GET | List migrations. By default only active ones. | +| `list` | `--org` | `--include-all`, `--include-inactive` (deprecated), `--project`, `--detect` | GET | List migrations. By default the latest per repository. | | `status` | `--org`, `--repository-id` | `--detect` | GET | Get detailed status for one migration. | -| `create` | `--org`, `--repository-id`, `--target-repository` | `--github-token`, `--target-owner-user-id` (deprecated), `--agent-pool`, `--validate-only`, `--cutover-date`, `--skip-validation`, `--detect` | POST | Create a new migration. | +| `create` | `--org`, `--repository-id`, `--target-repository` | `--github-token`, `--service-endpoint-id`, `--target-owner-user-id` (deprecated), `--agent-pool`, `--validate-only`, `--cutover-date`, `--skip-validation`, `--enable-boards-github-connection`, `--enable-auto-discover-pipelines`, `--pipeline-service-connection-id`, `--detect` | POST | Create a new migration. | | `pause` | `--org`, `--repository-id` | `--detect` | PUT | Pause an active migration. | | `resume` | `--org`, `--repository-id` | `--validate-only`, `--migration`, `--detect` | PUT | Resume a stopped migration. | +| `abandon` | `--org`, `--repository-id` | `--remove-read-only`, `--detect` | DELETE | Permanently delete a migration (prompts for confirmation). | | `cutover set` | `--org`, `--repository-id`, `--date` | `--detect` | PUT | Schedule a cutover date/time. | -| `cutover cancel` | `--org`, `--repository-id` | `--detect` | PUT | Cancel a scheduled cutover. | -| `abandon` | `--org`, `--repository-id` | `--detect` | DELETE | Permanently delete a migration (prompts for confirmation). | +| `cutover cancel` | `--org`, `--repository-id` | `--detect` | PUT | Cancel a scheduled cutover (not allowed in `Cutover` stage). | +| `cutover review` | `--org`, `--repository-id` | `--detect` | GET | Review unprocessed items before approving cutover. | +| `cutover approve` | `--org`, `--repository-id` | `--accept-failures`, `--pipelines-verified`, `--detect` | PUT | Approve cutover (accept unprocessed items and/or verify pipelines). | +| `pipelines list` | `--org`, `--repository-id` | `--detect` | GET | List pipeline rewiring status. (Preview) | +| `pipelines submit` | `--org`, `--repository-id`, `--pipeline-ids` | `--service-connection-id`, `--repository-mapping`, `--detect` | POST | Submit pipelines for rewiring. (Preview) | +| `pipelines update` | `--org`, `--repository-id` | `--add-ids`, `--remove-ids`, `--retry-ids`, `--service-connection-id`, `--repository-mapping`, `--detect` | PUT | Bulk update pipeline rewiring. (Preview) | +| `pipelines retry` | `--org`, `--repository-id`, `--pipeline-ids` | `--detect` | PUT | Retry failed pipeline rewiring entries. (Preview) | +| `pipelines delete` | `--org`, `--repository-id`, `--migration-id` | `--yes`, `--detect` | DELETE | Delete pipeline rewiring data. (Preview) | ### 5.1 Parameter Details @@ -322,9 +412,9 @@ az devops migrations resume --detect false --repository-id --validate-onl |---|---|---|---| | `--org` | URL | All | Azure DevOps org URL (e.g., `https://dev.azure.com/myorg`). Can be set as default. | | `--repository-id` | GUID | All except `list` | Azure Repos repository GUID. Get from `az repos show --query id`. | -| `--target-repository` | URL | `create` | Target repository URL (e.g., `https://example.ghe.com/OrgName/RepoName`). Validated by the server. | -| `--target-repository` | URL | `create` | Target repository URL (e.g., `https://example.ghe.com/OrgName/RepoName`). Must start with `http://` or `https://`. | -| `--github-token` | string | `create` | GitHub token used for migration authorization. If omitted, CLI checks `ELM_GITHUB_TOKEN` and then runs device flow. | +| `--target-repository` | URL | `create` | Target repository URL. Must start with `http://` or `https://`. | +| `--github-token` | string | `create` | GitHub token used for user-identity verification. If omitted (and `--service-endpoint-id` not set), CLI checks `ELM_GITHUB_TOKEN` then runs device flow. | +| `--service-endpoint-id` | GUID | `create` | GitHub Enterprise Server service connection used to sync commits. When set, device flow is skipped. | | `--target-owner-user-id` | string | `create` | Deprecated. Ignored when server-side token ownership resolution is enabled. | | `--agent-pool` | string | `create` | Agent pool name for migration work. Optional. | | `--validate-only` | flag | `create`, `resume` | On `create`: run pre-migration checks only. On `resume`: switch to validate-only mode. | @@ -332,7 +422,20 @@ az devops migrations resume --detect false --repository-id --validate-onl | `--cutover-date` | ISO 8601 | `create` | Pre-schedule cutover at creation time. E.g., `2030-12-31T11:59:00Z`. | | `--date` | ISO 8601 | `cutover set` | Schedule cutover date/time. E.g., `2030-12-31T11:59:00Z`. | | `--skip-validation` | string or int | `create` | Validation policies to skip. Accepts either comma-separated policy names (recommended) or a non-negative integer bitmask. | -| `--include-inactive` | flag | `list` | Include completed, failed, and suspended migrations. | +| `--enable-boards-github-connection` | flag | `create` | Provision the Azure Boards GitHub connection at cutover. Off by default. | +| `--enable-auto-discover-pipelines` | flag | `create` | Auto-discover and clone pipelines referencing the source repo at cutover. Off by default. | +| `--pipeline-service-connection-id` | GUID | `create` | GitHub service connection attached at create time for pipeline rewiring. | +| `--accept-failures` | int | `cutover approve` | Number of unprocessed migration resources to accept before proceeding with cutover. | +| `--pipelines-verified` | flag | `cutover approve` | Acknowledge that rewired pipelines were verified. Required when `cutover review` reports `requiresPipelineVerificationAcknowledgment: true`. | +| `--pipeline-ids` | int list | `pipelines submit`, `pipelines retry` | Pipeline definition IDs. Space- or comma-separated. | +| `--add-ids` / `--remove-ids` / `--retry-ids` | int list | `pipelines update` | Pipeline IDs to add, remove, or retry. Space- or comma-separated. | +| `--service-connection-id` | GUID | `pipelines submit`, `pipelines update` | Project-scoped GitHub service connection. | +| `--repository-mapping` | string | `pipelines submit`, `pipelines update` | `=/`. Repeatable. | +| `--migration-id` | int | `pipelines delete` | Migration ID used for pipeline rewiring cleanup. | +| `--remove-read-only` | flag | `abandon` | Set the source Azure Repos repository back to read-write. | +| `--include-all` | flag | `list` | Return the full migration history (all records per repository). | +| `--include-inactive` | flag | `list` | Deprecated. Use `--include-all` instead. | +| `--project` | string | `list` | Filter migrations by project name or ID. | | `--detect` | flag | All | Auto-detect org from git remote (default: `true`). Use `--detect false` to disable. | ## 6. Common Pitfalls @@ -342,6 +445,8 @@ az devops migrations resume --detect false --repository-id --validate-onl | **Auto-detect overrides `--org`** | Requests go to wrong host (e.g., `codedev.ms`) | Add `--detect false` or run from a non-ADO-repo directory | | **Stale default org in config** | Requests go to old/dev URL (e.g., `codedev.ms`) | Run `az devops configure -d organization=https://dev.azure.com/` to update | | **Resume on an active migration** | Error: "Migration is active..." | Pause first with `az devops migrations pause`, then resume | +| **Resume while at ReviewForCutover** | Error: "Migration is waiting for cutover approval (stage: ReviewForCutover)" | Run `cutover review`, then `cutover approve`; or cancel/reschedule cutover, or abandon | +| **Cancel cutover too late** | Error: "Cannot cancel cutover: the migration has already entered the Cutover stage" | Cancel only before the `Cutover` stage; if stuck, contact the ELM service team | | **Both `--validate-only` and `--migration` on resume** | Error: "Please specify only one..." | Use only one flag at a time | | **Missing migration auth token** | Device flow prompt appears, or auth error is returned | Provide `--github-token`, set `ELM_GITHUB_TOKEN`, or complete device-flow authorization | | **Active migration already exists for repository** | Error: `An active migration already exists for repository . Delete (abandon) the existing migration before creating a new one.` | Abandon the existing migration first (`az devops migrations abandon`), then retry `create` | @@ -386,13 +491,13 @@ az devops migrations resume --detect false --repository-id --validate-onl Recommended form using policy names: ```powershell -az devops migrations create --detect false --repository-id --target-repository --target-owner-user-id --skip-validation AgentPoolExists,MaxFileSize +az devops migrations create --detect false --repository-id --target-repository --skip-validation AgentPoolExists,MaxFileSize ``` Advanced form using integer bitmask: ```powershell -az devops migrations create --detect false --repository-id --target-repository --target-owner-user-id --skip-validation 132 +az devops migrations create --detect false --repository-id --target-repository --skip-validation 132 ``` Token/PAT-authenticated examples: @@ -504,8 +609,8 @@ az devops configure -d organization=https://dev.azure.com/ # View current defaults az devops configure -l -# Install/update the extension from a wheel file -az extension add --source ./azure_devops-1.0.3-py2.py3-none-any.whl -y +# Install/update the extension to the latest version +az extension add -n azure-devops --upgrade # Uninstall the extension az extension remove -n azure-devops @@ -513,8 +618,8 @@ az extension remove -n azure-devops # Get repo GUID from ADO az repos show --org https://dev.azure.com// --project --repository --query id -o tsv -# List all migrations (including inactive) -az devops migrations list --include-inactive +# List all migrations (full history) +az devops migrations list --include-all # Get full JSON output (instead of table) az devops migrations status --repository-id -o json