diff --git a/.flake8 b/.flake8 index 1926c531..0be5f6b3 100644 --- a/.flake8 +++ b/.flake8 @@ -24,5 +24,6 @@ exclude = env venv .venv + .venv-disabled */test/* */devops_sdk/* \ No newline at end of file diff --git a/azure-devops/azext_devops/dev/migration/_format.py b/azure-devops/azext_devops/dev/migration/_format.py index e5a08e51..67eafc9b 100644 --- a/azure-devops/azext_devops/dev/migration/_format.py +++ b/azure-devops/azext_devops/dev/migration/_format.py @@ -10,6 +10,16 @@ _TARGET_TRUNCATION_LENGTH = 60 +def transform_message_output(result): + if result is None: + return [] + if isinstance(result, dict) and 'message' in result: + row = OrderedDict() + row['Message'] = result['message'] + return [row] + return transform_migration_table_output(result) + + def transform_migrations_table_output(result): migrations = _unwrap_migration_list(result) table_output = [] @@ -24,6 +34,42 @@ def transform_migration_table_output(result): return [_transform_migration_row(result)] +def transform_cutover_review_table_output(result): + if not isinstance(result, dict): + return [] + + failed_count = result.get('failedCount') + blocked_count = result.get('blockedCount') + pending_count = result.get('pendingCount') + total_unprocessed = result.get('totalUnprocessedCount') + failed_items = result.get('failedItems') if isinstance(result.get('failedItems'), list) else [] + + if not failed_items: + row = OrderedDict() + row['FailedCount'] = failed_count + row['BlockedCount'] = blocked_count + row['PendingCount'] = pending_count + row['TotalUnprocessedCount'] = total_unprocessed + row['State'] = None + row['Type'] = None + row['PullRequestUrl'] = None + return [row] + + rows = [] + for index, item in enumerate(failed_items): + row = OrderedDict() + row['FailedCount'] = failed_count if index == 0 else None + 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['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 + rows.append(row) + + return rows + + def _unwrap_migration_list(result): if isinstance(result, dict) and 'value' in result: return result['value'] diff --git a/azure-devops/azext_devops/dev/migration/_help.py b/azure-devops/azext_devops/dev/migration/_help.py index 96c1a5c5..86476892 100644 --- a/azure-devops/azext_devops/dev/migration/_help.py +++ b/azure-devops/azext_devops/dev/migration/_help.py @@ -10,7 +10,7 @@ def load_migration_help(): helps['devops migrations'] = """ type: group short-summary: Manage enterprise live migrations. - long-summary: 'This command group is a part of the azure-devops extension. For ELM migrations, --org should be your Azure DevOps organization URL (for example: https://dev.azure.com/myorg).' + long-summary: 'This command group is a part of the azure-devops extension and is in preview. Availability may be limited (for example, to 1P/allowlisted users). For ELM migrations, --org should be your Azure DevOps organization URL (for example: https://dev.azure.com/myorg).' """ helps['devops migrations list'] = """ @@ -37,13 +37,17 @@ def load_migration_help(): helps['devops migrations create'] = """ type: command short-summary: Create a migration for a repository. + long-summary: 'If --github-token is not provided, the CLI checks ELM_GITHUB_TOKEN and then runs GitHub device flow to acquire a token.' examples: - name: Create a migration. text: | - az devops migrations create --org https://dev.azure.com/myorg --repository-id 00000000-0000-0000-0000-000000000000 --target-repository https://github.com/OrgName/RepoName --target-owner-user-id OwnerUserId --agent-pool MigrationPool + az devops migrations create --org https://dev.azure.com/myorg --repository-id 00000000-0000-0000-0000-000000000000 --target-repository https://github.com/OrgName/RepoName --agent-pool - name: Create a validate-only migration. text: | - az devops migrations create --org https://dev.azure.com/myorg --repository-id 00000000-0000-0000-0000-000000000000 --target-repository https://github.com/OrgName/RepoName --target-owner-user-id OwnerUserId --agent-pool MigrationPool --validate-only --skip-validation ActivePullRequestCount,PullRequestDeltaSize + az devops migrations create --org https://dev.azure.com/myorg --repository-id 00000000-0000-0000-0000-000000000000 --target-repository https://github.com/OrgName/RepoName --agent-pool --validate-only --skip-validation ActivePullRequestCount,PullRequestDeltaSize + - name: Create using a pre-generated GitHub token or PAT. + text: | + az devops migrations create --org https://dev.azure.com/myorg --repository-id 00000000-0000-0000-0000-000000000000 --target-repository https://github.com/OrgName/RepoName --github-token """ helps['devops migrations pause'] = """ @@ -69,6 +73,13 @@ def load_migration_help(): helps['devops migrations abandon'] = """ type: command short-summary: Abandon and delete a migration. + examples: + - name: Abandon and keep repository read-only (default). + text: | + az devops migrations abandon --org https://dev.azure.com/myorg --repository-id 00000000-0000-0000-0000-000000000000 + - name: Abandon and set repository back to read-write. + text: | + az devops migrations abandon --org https://dev.azure.com/myorg --repository-id 00000000-0000-0000-0000-000000000000 --remove-read-only """ helps['devops migrations cutover'] = """ @@ -76,6 +87,24 @@ def load_migration_help(): short-summary: Manage migration cutover. """ + helps['devops migrations cutover review'] = """ + type: command + short-summary: Review unprocessed migration items before cutover. + examples: + - name: Review failures before approving cutover. + text: | + az devops migrations cutover review --org https://dev.azure.com/myorg --repository-id 00000000-0000-0000-0000-000000000000 + """ + + helps['devops migrations cutover approve'] = """ + type: command + short-summary: Approve cutover by accepting a count of unprocessed items. + 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 + """ + helps['devops migrations cutover set'] = """ type: command short-summary: Schedule cutover for a migration. diff --git a/azure-devops/azext_devops/dev/migration/arguments.py b/azure-devops/azext_devops/dev/migration/arguments.py index 4c99a92e..86f13f69 100644 --- a/azure-devops/azext_devops/dev/migration/arguments.py +++ b/azure-devops/azext_devops/dev/migration/arguments.py @@ -24,7 +24,12 @@ def load_migration_arguments(self, _): context.argument('target_repository', options_list='--target-repository', help='Target repository URL (must start with http:// or https://).') context.argument('target_owner_user_id', options_list='--target-owner-user-id', - help='Target repository owner user ID.') + 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.') 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', @@ -36,15 +41,30 @@ def load_migration_arguments(self, _): help='Validation policies to skip. Accepts either a comma-separated list of ' 'policy names (for example, AgentPoolExists,MaxRepoSize) or a non-negative ' 'integer bitmask.') + 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.') with self.argument_context('devops migrations cutover set') as context: context.argument('cutover_date', options_list='--date', type=convert_date_string_to_iso8601, help='The date and time for cutover (ISO 8601).') + with self.argument_context('devops migrations cutover approve') as context: + context.argument('accept_failures', options_list='--accept-failures', type=int, + help='Number of unprocessed migration resources to accept before ' + 'proceeding with cutover.') + with self.argument_context('devops migrations resume') as context: context.argument('validate_only', options_list='--validate-only', action='store_true', help='Resume in validate-only mode.') context.argument('migration', options_list='--migration', action='store_true', help='Promote a succeeded validate-only migration to a full migration ' '(sets validateOnly=false and statusRequested=active).') + + with self.argument_context('devops migrations abandon') as context: + context.argument('remove_read_only', options_list='--remove-read-only', action='store_true', + help='Also set the Azure Repos repository back to read-write state by ' + 'sending removeReadOnly=true.') diff --git a/azure-devops/azext_devops/dev/migration/commands.py b/azure-devops/azext_devops/dev/migration/commands.py index 684803f9..f4b6984e 100644 --- a/azure-devops/azext_devops/dev/migration/commands.py +++ b/azure-devops/azext_devops/dev/migration/commands.py @@ -5,7 +5,10 @@ from azure.cli.core.commands import CliCommandType from azext_devops.dev.common.exception_handler import azure_devops_exception_handler -from ._format import transform_migrations_table_output, transform_migration_table_output +from ._format import (transform_migrations_table_output, + transform_migration_table_output, + transform_message_output, + transform_cutover_review_table_output) migrationOps = CliCommandType( @@ -15,15 +18,18 @@ def load_migration_commands(self, _): - with self.command_group('devops migrations', command_type=migrationOps) as g: + with self.command_group('devops migrations', command_type=migrationOps, is_preview=True) as g: g.command('list', 'list_migrations', table_transformer=transform_migrations_table_output) g.command('status', 'get_migration', table_transformer=transform_migration_table_output) g.command('create', 'create_migration', table_transformer=transform_migration_table_output) - g.command('pause', 'pause_migration', table_transformer=transform_migration_table_output) + g.command('pause', 'pause_migration', table_transformer=transform_message_output) g.command('resume', 'resume_migration', table_transformer=transform_migration_table_output) g.command('abandon', 'delete_migration', - confirmation='Are you sure you want to abandon this migration?') + confirmation='Are you sure you want to abandon this migration?', + table_transformer=transform_message_output) - with self.command_group('devops migrations cutover', command_type=migrationOps) as g: + with self.command_group('devops migrations cutover', command_type=migrationOps, is_preview=True) as g: + g.command('review', 'get_cutover_review', table_transformer=transform_cutover_review_table_output) + 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_migration_table_output) + g.command('cancel', 'cancel_cutover', 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 e2b70545..e472869c 100644 --- a/azure-devops/azext_devops/dev/migration/migration.py +++ b/azure-devops/azext_devops/dev/migration/migration.py @@ -3,21 +3,40 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- +import json +import os import re -from urllib.parse import quote_plus +import subprocess +import time +import sys +from urllib.parse import quote_plus, urlparse +from urllib.request import Request, urlopen +from urllib.error import HTTPError, URLError from msrest import Configuration from msrest.service_client import ServiceClient from msrest.universal_http import ClientRequest from knack.util import CLIError +from knack.log import get_logger + from azext_devops.version import VERSION from azext_devops.dev.common.services import get_connection, resolve_instance from azext_devops.dev.common.uuid import is_uuid +logger = get_logger(__name__) + API_VERSION = '7.2-preview' 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' +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' _SKIP_VALIDATION_POLICIES = { 'none': 0, 'activepullrequestcount': 1, @@ -31,7 +50,12 @@ 'targetrepositorydoesnotexist': 256, 'all': 2147483647, } +_SUCCESS_TERMINAL_STATES = { + 'succeeded', + 'completed' +} _NON_ACTIVE_STATES = { + 'completed', 'succeeded', 'failed', 'suspended' @@ -54,7 +78,12 @@ def list_migrations(include_inactive=False, project=None, organization=None, det project = _normalize_optional_text(project) if project: url += '&project={}'.format(quote_plus(project)) - return _send_request(client, 'GET', url) + 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.' + logger.warning('No migrations found.%s', hint) + return result def _normalize_optional_text(value): @@ -128,43 +157,250 @@ def get_migration(repository_id=None, organization=None, detect=None): return _send_request(client, 'GET', url) -def create_migration(repository_id=None, target_repository=None, target_owner_user_id=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, organization=None, detect=None): + skip_validation=None, service_endpoint_id=None, github_token=None, + organization=None, detect=None): target_repository = _normalize_optional_text(target_repository) target_owner_user_id = _normalize_optional_text(target_owner_user_id) agent_pool = _normalize_optional_text(agent_pool) + service_endpoint_id = _normalize_optional_text(service_endpoint_id) + github_token = _normalize_optional_text(github_token) skip_validation = _parse_skip_validation(skip_validation) if not target_repository: raise CLIError('--target-repository must be specified.') - if not _URL_PATTERN.match(target_repository): - raise CLIError('--target-repository must be a valid URL starting with http:// or https://.') - if not target_owner_user_id: - raise CLIError('--target-owner-user-id 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.') 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 payload = { 'targetRepository': target_repository, - 'targetOwnerUserId': target_owner_user_id, 'validateOnly': bool(validate_only), } + if github_token: + payload['gitHubUserToken'] = github_token + if target_owner_user_id: + payload['targetOwnerUserId'] = target_owner_user_id if agent_pool: payload['agentPoolName'] = agent_pool if cutover_date is not None: payload['scheduledCutoverDate'] = cutover_date if skip_validation is not None: payload['skipValidation'] = skip_validation + if service_endpoint_id: + payload['serviceEndpointId'] = service_endpoint_id - client = _get_service_client(organization) url = _build_migration_url(organization, repository_id) - return _send_request(client, 'POST', url, payload) + try: + return _send_request(client, 'POST', url, payload) + except CLIError as ex: + 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.' + .format(repository_id)) + raise + + +def _resolve_github_user_token(client, organization, target_repository, github_token=None): + token = _normalize_optional_text(github_token) + if token: + return token + + env_token = _normalize_optional_text(os.getenv(GITHUB_TOKEN_ENV_VAR)) + if env_token: + return env_token + + flow_config = _get_device_flow_config(client, organization, target_repository) + client_id = _normalize_optional_text(flow_config.get('clientId')) + enterprise_url = _normalize_optional_text(flow_config.get('enterpriseUrl')) + if not client_id or not enterprise_url: + raise CLIError('Device flow configuration response is missing clientId or enterpriseUrl.') + + return _run_device_flow(client_id, enterprise_url) + + +def _get_device_flow_config(client, organization, target_repository): + urls = [ + _build_device_flow_config_url(organization, target_repository, DEVICE_FLOW_CONFIG_API_PATH), + _build_device_flow_config_url(organization, target_repository, LEGACY_DEVICE_FLOW_CONFIG_API_PATH), + ] + + first_error = None + for index, url in enumerate(urls): + try: + return _send_request(client, 'GET', url) + except CLIError as ex: + if index == 0 and 'status 404' in str(ex): + first_error = ex + continue + if index == 1 and first_error and 'status 404' in str(ex): + raise CLIError('GitHub device-flow configuration is unavailable. ' + 'Provide --github-token or set ELM_GITHUB_TOKEN to continue.') + raise + + if first_error: + raise first_error + raise CLIError('Unable to retrieve device flow configuration.') + + +def _build_device_flow_config_url(base_url, target_repository, api_path=DEVICE_FLOW_CONFIG_API_PATH): + url = base_url.rstrip('/') + api_path + return '{}?targetRepository={}&api-version={}'.format(url, quote_plus(target_repository), API_VERSION) + + +def _run_device_flow(client_id, enterprise_url): + enterprise_url = enterprise_url.rstrip('/') + device_code_response = _post_form('{}{}'.format(enterprise_url, '/login/device/code'), { + 'client_id': client_id, + }) + + device_code = _normalize_optional_text(device_code_response.get('device_code')) + user_code = _normalize_optional_text(device_code_response.get('user_code')) + verification_uri = _normalize_optional_text(device_code_response.get('verification_uri')) + interval = _parse_positive_int(device_code_response.get('interval', 5), 'interval') + expires_in = _parse_positive_int(device_code_response.get('expires_in', 900), 'expires_in') + + if not device_code or not user_code or not verification_uri: + raise CLIError('Invalid device-flow response: missing required fields.') + + print('Open: {}'.format(verification_uri)) + print('Code: {}'.format(user_code)) + if _copy_to_clipboard(user_code): + print('Code copied to clipboard.') + print('Waiting for authorization...') + + deadline = time.monotonic() + expires_in + token_url = '{}{}'.format(enterprise_url, '/login/oauth/access_token') + grant_type = 'urn:ietf:params:oauth:grant-type:device_code' + + while time.monotonic() < deadline: + time.sleep(interval) + poll_response = _post_form(token_url, { + 'client_id': client_id, + 'device_code': device_code, + 'grant_type': grant_type, + }) + + token = _normalize_optional_text(poll_response.get('access_token')) + if token: + return token + + error = _normalize_optional_text(poll_response.get('error')) + if error == 'authorization_pending': + continue + if error == 'slow_down': + interval += 5 + continue + if error == 'access_denied': + raise CLIError('Authorization denied in GitHub device flow.') + if error == 'expired_token': + raise CLIError('Device code expired. Re-run the command to authorize again.') + + description = _normalize_optional_text(poll_response.get('error_description')) + if description: + raise CLIError('GitHub device flow failed: {}'.format(description)) + raise CLIError('GitHub device flow failed: {}'.format(error or 'unknown error')) + + raise CLIError('Timed out waiting for GitHub authorization. Re-run the command and complete login sooner.') + + +def _copy_to_clipboard(text): + if not text: + return False + + commands = [] + if os.name == 'nt': + commands.append(['clip']) + elif sys.platform == 'darwin': + commands.append(['pbcopy']) + else: + commands.extend([ + ['xclip', '-selection', 'clipboard'], + ['xsel', '--clipboard', '--input'], + ]) + + for command in commands: + try: + subprocess.run(command, input=text.encode('utf-8'), check=True, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return True + except (OSError, subprocess.SubprocessError): + continue + + return False + + +def _post_form(url, data): + body = '&'.join(['{}={}'.format(quote_plus(str(key)), quote_plus(str(value))) for key, value in data.items()]) + request = Request(url=url, data=body.encode('utf-8')) + request.add_header('Accept', 'application/json') + request.add_header('Content-Type', 'application/x-www-form-urlencoded') + + try: + with urlopen(request) as response: + content = response.read() + return json.loads(content.decode('utf-8')) + except HTTPError as ex: + if ex.code in (401, 403): + raise CLIError('GitHub device flow is unavailable for this organization. ' + 'This can happen if the GitHub app is not installed or the service is unavailable. ' + 'Try again later, or provide --github-token (or set ELM_GITHUB_TOKEN).') + detail = '' + try: + content = ex.read() + if content: + parsed = json.loads(content.decode('utf-8')) + detail = parsed.get('error_description') or parsed.get('error') or str(parsed) + except Exception: # pylint: disable=broad-except + detail = '' + raise CLIError('GitHub device flow request failed with status {}. {}'.format(ex.code, detail)) + except URLError as ex: + raise CLIError('GitHub device flow request failed: {}'.format(ex.reason)) + + +def _parse_positive_int(value, field_name): + try: + parsed = int(value) + except (TypeError, ValueError): + raise CLIError('Invalid device-flow response: {} must be a positive integer.'.format(field_name)) + + if parsed <= 0: + raise CLIError('Invalid device-flow response: {} must be a positive integer.'.format(field_name)) + return parsed + + +def _validate_target_repository(target_repository): + if not _URL_PATTERN.match(target_repository): + raise CLIError('--target-repository must be a valid URL in the format https://host/org/repo.') + + parsed = urlparse(target_repository) + if parsed.scheme != 'https': + raise CLIError('--target-repository must be a valid URL in the format https://host/org/repo.') + + if not parsed.netloc or parsed.params or parsed.query or parsed.fragment: + raise CLIError('--target-repository must be a valid URL in the format https://host/org/repo.') + + path_parts = [part for part in parsed.path.split('/') if part] + if len(path_parts) != 2: + raise CLIError('--target-repository must be a valid URL in the format https://host/org/repo.') def pause_migration(repository_id=None, organization=None, detect=None): - return _update_migration(repository_id, organization, detect, status_requested='suspended') + result = _update_migration(repository_id, organization, detect, status_requested='suspended') + if not result: + return {'message': 'Migration paused successfully.'} + return result def resume_migration(repository_id=None, validate_only=False, migration=False, organization=None, detect=None): @@ -173,6 +409,17 @@ def resume_migration(repository_id=None, validate_only=False, migration=False, o 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 '' + + if current_stage == 'reviewforcutover': + raise CLIError('Migration is waiting for cutover approval (stage: ReviewForCutover). ' + 'Run "az devops migrations cutover review ' + '--repository-id {repository_id}" to inspect ' + 'unprocessed items, then approve with ' + '"az devops migrations cutover approve ' + '--repository-id {repository_id} --accept-failures ". ' + 'You can also cancel/reschedule cutover or abandon the migration.' + .format(repository_id=repository_id)) if migration and _is_validate_only_succeeded(migration_data): return _promote_to_full_migration(migration_data, repository_id, organization) @@ -184,16 +431,16 @@ def resume_migration(repository_id=None, validate_only=False, migration=False, o .format(state_text)) if _is_migration_terminal(migration_data): - status = _normalize_state(migration_data.get('status')) + status = _get_effective_status(migration_data) is_val_only = migration_data.get('validateOnly') is True - if status == 'succeeded' and is_val_only: - raise CLIError('Validation already succeeded. Promote it with ' - '"az devops migrations resume --repository-id --migration", ' - 'or abandon and create a new migration.') - if status == 'succeeded': - raise CLIError('Migration already succeeded. Use ' - '"az devops migrations abandon --repository-id " to reset, ' - 'then create a new migration.') + if _is_success_terminal_status(status) and is_val_only: + raise CLIError('Validation already completed. Promote it with ' + '"az devops migrations resume --repository-id {} --migration", ' + 'or abandon and create a new migration.'.format(repository_id)) + if _is_success_terminal_status(status): + raise CLIError('Migration already completed. Use ' + '"az devops migrations abandon --repository-id {}" to reset, ' + 'then create a new migration.'.format(repository_id)) validate_only_value = None if validate_only: @@ -213,20 +460,57 @@ def schedule_cutover(repository_id=None, cutover_date=None, organization=None, d def cancel_cutover(repository_id=None, organization=None, detect=None): - return _update_migration(repository_id, organization, detect, scheduled_cutover_date=None, - include_cutover=True) + # 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. + 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 '' + if current_stage == 'cutover': + raise CLIError('Cannot cancel cutover: the migration has already entered the Cutover stage. ' + 'Cancelling at this point is not safe and requires server-side recovery. ' + 'If the cutover appears stuck, contact the ELM service team.') + + result = _update_migration(repository_id, organization, detect=None, + scheduled_cutover_date=CUTOVER_DATE_CLEAR_SENTINEL, + include_cutover=True) + if not result: + return {'message': 'Cutover cancelled successfully.'} + return result -def delete_migration(repository_id=None, organization=None, detect=None): +def get_cutover_review(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_cutover_review_url(organization, repository_id) + 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') + 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) + + +def delete_migration(repository_id=None, remove_read_only=False, 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_migration_url(organization, repository_id) - return _send_request(client, 'DELETE', url) + if remove_read_only: + url += '&removeReadOnly=true' + _send_request(client, 'DELETE', url) + return {'message': 'Migration abandoned successfully.'} -def _update_migration(repository_id, organization, detect, validate_only=None, - status_requested=None, scheduled_cutover_date=None, include_cutover=False): +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): organization = _resolve_org_for_auth(organization, detect) repository_id = _resolve_repository_id(repository_id) client = _get_service_client(organization) @@ -239,9 +523,25 @@ def _update_migration(repository_id, organization, detect, validate_only=None, payload['statusRequested'] = status_requested if include_cutover: payload['scheduledCutoverDate'] = scheduled_cutover_date + if cutover_failure_accepted_count is not None: + payload['cutoverFailureAcceptedCount'] = cutover_failure_accepted_count return _send_request(client, 'PUT', url, payload) +def _parse_non_negative_int(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 non-negative integer.'.format(option_name)) + + if parsed < 0: + raise CLIError('{} must be a non-negative integer.'.format(option_name)) + return parsed + + def _resolve_repository_id(repository_id): if not repository_id: raise CLIError('--repository-id must be specified.') @@ -257,6 +557,20 @@ def _normalize_state(value): return normalized.replace(' ', '').replace('-', '').replace('_', '') +def _is_success_terminal_status(status): + return status in _SUCCESS_TERMINAL_STATES + + +def _get_effective_status(migration): + if not isinstance(migration, dict): + return '' + # Prefer actual migration status over requested status when both are present. + status = _normalize_state(migration.get('status')) + if status: + return status + return _normalize_state(migration.get('statusRequested')) + + def _get_migration_state_text(migration): status_requested = migration.get('statusRequested') status = migration.get('status') @@ -277,7 +591,7 @@ def _is_migration_active(migration): if not isinstance(migration, dict): return False - status = _normalize_state(migration.get('statusRequested') or migration.get('status')) + status = _get_effective_status(migration) if status: return status not in _NON_ACTIVE_STATES @@ -291,15 +605,15 @@ def _is_migration_active(migration): def _is_migration_terminal(migration): if not isinstance(migration, dict): return False - status = _normalize_state(migration.get('status')) - return status in ('succeeded', 'failed') + status = _get_effective_status(migration) + return _is_success_terminal_status(status) or status == 'failed' def _is_validate_only_succeeded(migration): if not isinstance(migration, dict): return False return (migration.get('validateOnly') is True and - _normalize_state(migration.get('status')) == 'succeeded') + _is_success_terminal_status(_get_effective_status(migration))) def _promote_to_full_migration(migration_data, repository_id, organization): @@ -319,6 +633,11 @@ def _build_migration_url(base_url, repository_id=None): return url + '?api-version=' + API_VERSION +def _build_cutover_review_url(base_url, repository_id): + url = base_url.rstrip('/') + MIGRATIONS_API_PATH + '/{}{}'.format(repository_id, CUTOVER_REVIEW_API_PATH_SUFFIX) + return url + '?api-version=' + API_VERSION + + def _get_service_client(organization): config = Configuration(base_url=None) config.add_user_agent('devOpsCli/{}'.format(VERSION)) @@ -338,7 +657,11 @@ def _send_request(client, method, url, content=None): error_detail = '' try: body = response.json() - error_detail = body.get('message') or body.get('Message') or str(body) + precheck_detail = _extract_precheck_issue_detail(body) + if precheck_detail: + error_detail = precheck_detail + else: + 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)) @@ -347,3 +670,38 @@ def _send_request(client, method, url, content=None): if content_type and 'json' in content_type: return response.json() return {} + + +def _extract_precheck_issue_detail(body): + if not isinstance(body, dict): + return None + + issue_collections = [] + for key in ('preCheckIssues', 'PreCheckIssues', 'validationIssues', 'ValidationIssues', 'issues', 'Issues'): + value = body.get(key) + if isinstance(value, list): + issue_collections.extend(value) + + messages = [] + for issue in issue_collections: + if not isinstance(issue, dict): + continue + issue_type = (issue.get('preCheckIssueType') or issue.get('PreCheckIssueType') or + issue.get('issueType') or issue.get('IssueType')) + issue_message = (issue.get('message') or issue.get('Message') or + issue.get('errorMessage') or issue.get('ErrorMessage')) + + issue_type = _normalize_optional_text(issue_type) + issue_message = _normalize_optional_text(issue_message) + + if issue_type and issue_message: + messages.append('[{}] {}'.format(issue_type, issue_message)) + elif issue_type: + messages.append('[{}]'.format(issue_type)) + elif issue_message: + messages.append(issue_message) + + if messages: + return 'Pre-check issues: {}'.format('; '.join(messages)) + + return None 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 04ce932c..23e9127e 100644 --- a/azure-devops/azext_devops/tests/latest/migration/test_migration.py +++ b/azure-devops/azext_devops/tests/latest/migration/test_migration.py @@ -4,6 +4,8 @@ # -------------------------------------------------------------------------------------------- import unittest +import os +from urllib.error import HTTPError try: # Attempt to load mock (works on Python 3.3 and above) @@ -13,10 +15,15 @@ from mock import patch from knack.util import CLIError +import azext_devops.dev.migration.migration as migration_module from azext_devops.dev.migration.migration import (list_migrations, create_migration, cancel_cutover, + get_cutover_review, + approve_cutover, + delete_migration, + pause_migration, resume_migration) @@ -24,6 +31,16 @@ class TestMigrationCommands(unittest.TestCase): _TEST_ORG = 'https://elm.contoso.com/elmo1' + def setUp(self): + self._original_env_token = os.environ.get('ELM_GITHUB_TOKEN') + os.environ['ELM_GITHUB_TOKEN'] = 'env-token-for-tests' + + def tearDown(self): + if self._original_env_token is None: + os.environ.pop('ELM_GITHUB_TOKEN', None) + else: + os.environ['ELM_GITHUB_TOKEN'] = self._original_env_token + def test_list_migrations_calls_get(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, \ @@ -89,21 +106,22 @@ def test_create_migration_payload_defaults_validate_only_false(self): create_migration( repository_id='00000000-0000-0000-0000-000000000000', target_repository='https://example.ghe.com/OrgName/RepoName', - target_owner_user_id='GeoffCoxMSFT', - agent_pool='MigrationPool', + target_owner_user_id='TestOwner', + agent_pool='TestPool', organization=self._TEST_ORG, detect=False ) payload = mock_send.call_args[0][3] self.assertFalse(payload['validateOnly']) + self.assertEqual(payload['gitHubUserToken'], 'env-token-for-tests') def test_create_migration_fails_without_target_repository(self): with self.assertRaises(CLIError) as ctx: create_migration( repository_id='00000000-0000-0000-0000-000000000000', - target_owner_user_id='GeoffCoxMSFT', - agent_pool='MigrationPool', + target_owner_user_id='TestOwner', + agent_pool='TestPool', organization=self._TEST_ORG, detect=False ) @@ -114,12 +132,36 @@ def test_create_migration_fails_with_invalid_target_repository_url(self): create_migration( repository_id='00000000-0000-0000-0000-000000000000', target_repository='ghe.example.com/OrgName/RepoName', - target_owner_user_id='GeoffCoxMSFT', - agent_pool='MigrationPool', + target_owner_user_id='TestOwner', + agent_pool='TestPool', + organization=self._TEST_ORG, + detect=False + ) + self.assertIn('https://host/org/repo', str(ctx.exception)) + + def test_create_migration_fails_with_non_https_target_repository(self): + with self.assertRaises(CLIError) as ctx: + create_migration( + repository_id='00000000-0000-0000-0000-000000000000', + target_repository='http://example.ghe.com/OrgName/RepoName', + target_owner_user_id='TestOwner', + agent_pool='TestPool', + organization=self._TEST_ORG, + detect=False + ) + self.assertIn('https://host/org/repo', str(ctx.exception)) + + def test_create_migration_fails_when_target_repository_path_is_not_org_repo(self): + with self.assertRaises(CLIError) as ctx: + create_migration( + repository_id='00000000-0000-0000-0000-000000000000', + target_repository='https://example.ghe.com/OrgName', + target_owner_user_id='TestOwner', + agent_pool='TestPool', organization=self._TEST_ORG, detect=False ) - self.assertIn('must be a valid URL', str(ctx.exception)) + self.assertIn('https://host/org/repo', str(ctx.exception)) def test_create_migration_without_agent_pool(self): with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ @@ -131,7 +173,7 @@ def test_create_migration_without_agent_pool(self): create_migration( repository_id='00000000-0000-0000-0000-000000000000', target_repository='https://example.ghe.com/OrgName/RepoName', - target_owner_user_id='GeoffCoxMSFT', + target_owner_user_id='TestOwner', organization=self._TEST_ORG, detect=False ) @@ -139,6 +181,434 @@ def test_create_migration_without_agent_pool(self): payload = mock_send.call_args[0][3] self.assertNotIn('agentPoolName', payload) + def test_create_migration_uses_parameter_token_over_environment(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 = {} + mock_resolve.return_value = self._TEST_ORG + + create_migration( + repository_id='00000000-0000-0000-0000-000000000000', + target_repository='https://example.ghe.com/OrgName/RepoName', + target_owner_user_id='TestOwner', + github_token='param-token', + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + self.assertEqual(payload['gitHubUserToken'], 'param-token') + + def test_create_migration_uses_device_flow_when_no_token_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, \ + patch('azext_devops.dev.migration.migration._run_device_flow') as mock_run_device_flow: + mock_resolve.return_value = self._TEST_ORG + mock_send.side_effect = [ + {'clientId': 'client-id-123', 'enterpriseUrl': 'https://example.ghe.com'}, + {} + ] + mock_run_device_flow.return_value = 'device-flow-token' + os.environ.pop('ELM_GITHUB_TOKEN', None) + + create_migration( + repository_id='00000000-0000-0000-0000-000000000000', + target_repository='https://example.ghe.com/OrgName/RepoName', + target_owner_user_id='TestOwner', + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + self.assertEqual(payload['gitHubUserToken'], 'device-flow-token') + mock_run_device_flow.assert_called_once_with('client-id-123', 'https://example.ghe.com') + + def test_create_migration_conflict_returns_clear_message(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.side_effect = CLIError('Request failed with status 409. TF400898: An Internal Error Occurred.') + + with self.assertRaises(CLIError) as ctx: + create_migration( + repository_id='912d0fd3-9c17-4b35-b67b-91848ce4d6bb', + target_repository='https://example.ghe.com/OrgName/RepoName', + github_token='token', + organization=self._TEST_ORG, + detect=False + ) + + self.assertIn('An active migration already exists for repository 912d0fd3-9c17-4b35-b67b-91848ce4d6bb', + str(ctx.exception)) + + def test_create_migration_non_conflict_error_passes_through(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.side_effect = CLIError('Request failed with status 400. Bad request') + + with self.assertRaises(CLIError) as ctx: + create_migration( + repository_id='00000000-0000-0000-0000-000000000000', + target_repository='https://example.ghe.com/OrgName/RepoName', + github_token='token', + organization=self._TEST_ORG, + detect=False + ) + + self.assertIn('status 400', str(ctx.exception)) + + def test_create_migration_with_service_endpoint_skips_device_flow(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, \ + 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 + # 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', + target_owner_user_id='TestOwner', + service_endpoint_id='1df3c9b3-666c-4033-82de-059e7759ddfe', + organization=self._TEST_ORG, + detect=False + ) + + mock_flow.assert_not_called() + mock_run_flow.assert_not_called() + 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) + + def test_create_migration_with_service_endpoint_and_token_rejected(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 + + 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 + ) + + self.assertIn('Specify either --service-endpoint-id or --github-token', str(ctx.exception)) + mock_send.assert_not_called() + + 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. + 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._get_device_flow_config') as mock_flow: + 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', + service_endpoint_id='1df3c9b3-666c-4033-82de-059e7759ddfe', + validate_only=True, + organization=self._TEST_ORG, + detect=False + ) + + mock_flow.assert_not_called() + payload = mock_send.call_args[0][3] + self.assertNotIn('gitHubUserToken', payload) + self.assertEqual(payload['serviceEndpointId'], '1df3c9b3-666c-4033-82de-059e7759ddfe') + self.assertTrue(payload['validateOnly']) + + def test_create_migration_service_endpoint_with_whitespace_github_token_not_rejected(self): + # A whitespace-only --github-token normalizes to None and must not + # trigger the mutual-exclusion error when --service-endpoint-id is set. + 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._get_device_flow_config') as mock_flow: + 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', + service_endpoint_id='1df3c9b3-666c-4033-82de-059e7759ddfe', + github_token=' ', + organization=self._TEST_ORG, + detect=False + ) + + 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) + + def test_create_migration_service_endpoint_conflict_returns_clear_message(self): + # Ensure the 409/TF400898 friendly message still surfaces on the + # service-endpoint code path (no GitHub token preflight to swallow it). + 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.side_effect = CLIError('Request failed with status 409. TF400898: An Internal Error Occurred.') + + with self.assertRaises(CLIError) as ctx: + create_migration( + repository_id='912d0fd3-9c17-4b35-b67b-91848ce4d6bb', + target_repository='https://example.ghe.com/OrgName/RepoName', + service_endpoint_id='1df3c9b3-666c-4033-82de-059e7759ddfe', + organization=self._TEST_ORG, + detect=False + ) + + self.assertIn('An active migration already exists for repository 912d0fd3-9c17-4b35-b67b-91848ce4d6bb', + str(ctx.exception)) + + def test_create_migration_service_endpoint_with_all_optional_fields(self): + # Service endpoint path must coexist with every other optional field + # (agent pool, cutover date, skip validation, target owner). + 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._get_device_flow_config') as mock_flow: + 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', + target_owner_user_id='TestOwner', + service_endpoint_id='1df3c9b3-666c-4033-82de-059e7759ddfe', + agent_pool='TestPool', + cutover_date='2026-06-01T00:00:00Z', + skip_validation='AgentPoolExists', + organization=self._TEST_ORG, + detect=False + ) + + mock_flow.assert_not_called() + payload = mock_send.call_args[0][3] + self.assertEqual(payload['serviceEndpointId'], '1df3c9b3-666c-4033-82de-059e7759ddfe') + self.assertEqual(payload['targetOwnerUserId'], 'TestOwner') + self.assertEqual(payload['agentPoolName'], 'TestPool') + self.assertEqual(payload['scheduledCutoverDate'], '2026-06-01T00:00:00Z') + self.assertEqual(payload['skipValidation'], 4) + self.assertNotIn('gitHubUserToken', payload) + + 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, \ + 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 = {'clientId': 'client-id-only'} + os.environ.pop('ELM_GITHUB_TOKEN', None) + + with self.assertRaises(CLIError) as ctx: + create_migration( + repository_id='00000000-0000-0000-0000-000000000000', + target_repository='https://example.ghe.com/OrgName/RepoName', + organization=self._TEST_ORG, + detect=False + ) + + self.assertIn('missing clientId or enterpriseUrl', str(ctx.exception)) + + def test_build_device_flow_config_url_encodes_target_repository(self): + url = migration_module._build_device_flow_config_url( + self._TEST_ORG, + 'https://example.ghe.com/org name/repo name' + ) + + self.assertIn('/_apis/migrations/deviceFlowConfig?', url) + self.assertIn('targetRepository=https%3A%2F%2Fexample.ghe.com%2Forg+name%2Frepo+name', url) + self.assertIn('api-version=7.2-preview', url) + + def test_get_device_flow_config_falls_back_to_legacy_path_on_404(self): + with patch('azext_devops.dev.migration.migration._send_request') as mock_send: + mock_send.side_effect = [ + CLIError("Request failed with status 404. The controller for path '/_apis/migrations/deviceFlowConfig' was not found."), + {'clientId': 'abc', 'enterpriseUrl': 'https://example.ghe.com'} + ] + + result = migration_module._get_device_flow_config( + client=object(), + organization=self._TEST_ORG, + target_repository='https://example.ghe.com/org/repo' + ) + + self.assertEqual(result['clientId'], 'abc') + self.assertEqual(mock_send.call_count, 2) + + def test_get_device_flow_config_both_paths_404_shows_pat_guidance(self): + with patch('azext_devops.dev.migration.migration._send_request') as mock_send: + mock_send.side_effect = [ + CLIError("Request failed with status 404. The controller for path '/_apis/migrations/deviceFlowConfig' was not found."), + CLIError("Request failed with status 404. The controller for path '/_apis/elm/migrations/deviceFlowConfig' was not found."), + ] + + with self.assertRaises(CLIError) as ctx: + migration_module._get_device_flow_config( + client=object(), + organization=self._TEST_ORG, + target_repository='https://example.ghe.com/org/repo' + ) + + self.assertIn('Provide --github-token or set ELM_GITHUB_TOKEN', str(ctx.exception)) + + def test_run_device_flow_handles_access_denied(self): + with patch('azext_devops.dev.migration.migration._post_form') as mock_post, \ + patch('azext_devops.dev.migration.migration.time.sleep') as mock_sleep, \ + patch('azext_devops.dev.migration.migration.time.monotonic') as mock_monotonic: + mock_sleep.return_value = None + mock_monotonic.side_effect = [0, 0] + mock_post.side_effect = [ + { + 'device_code': 'devcode', + 'user_code': 'ABCD-1234', + 'verification_uri': 'https://example.ghe.com/login/device', + 'interval': 1, + 'expires_in': 900, + }, + {'error': 'access_denied'}, + ] + + with self.assertRaises(CLIError) as ctx: + migration_module._run_device_flow('client-id', 'https://example.ghe.com') + + self.assertIn('Authorization denied', str(ctx.exception)) + + def test_run_device_flow_handles_expired_token(self): + with patch('azext_devops.dev.migration.migration._post_form') as mock_post, \ + patch('azext_devops.dev.migration.migration.time.sleep') as mock_sleep, \ + patch('azext_devops.dev.migration.migration.time.monotonic') as mock_monotonic: + mock_sleep.return_value = None + mock_monotonic.side_effect = [0, 0] + mock_post.side_effect = [ + { + 'device_code': 'devcode', + 'user_code': 'ABCD-1234', + 'verification_uri': 'https://example.ghe.com/login/device', + 'interval': 1, + 'expires_in': 900, + }, + {'error': 'expired_token'}, + ] + + with self.assertRaises(CLIError) as ctx: + migration_module._run_device_flow('client-id', 'https://example.ghe.com') + + self.assertIn('Device code expired', str(ctx.exception)) + + def test_run_device_flow_retries_authorization_pending_and_returns_token(self): + with patch('azext_devops.dev.migration.migration._post_form') as mock_post, \ + patch('azext_devops.dev.migration.migration.time.sleep') as mock_sleep, \ + patch('azext_devops.dev.migration.migration.time.monotonic') as mock_monotonic, \ + patch('azext_devops.dev.migration.migration._copy_to_clipboard') as mock_copy: + mock_sleep.return_value = None + mock_monotonic.side_effect = [0, 0, 1] + mock_copy.return_value = False + mock_post.side_effect = [ + { + 'device_code': 'devcode', + 'user_code': 'ABCD-1234', + 'verification_uri': 'https://example.ghe.com/login/device', + 'interval': 1, + 'expires_in': 900, + }, + {'error': 'authorization_pending'}, + {'access_token': 'token-from-device-flow'}, + ] + + token = migration_module._run_device_flow('client-id', 'https://example.ghe.com') + self.assertEqual(token, 'token-from-device-flow') + + def test_run_device_flow_copies_user_code_to_clipboard_when_available(self): + with patch('azext_devops.dev.migration.migration._post_form') as mock_post, \ + patch('azext_devops.dev.migration.migration.time.sleep') as mock_sleep, \ + patch('azext_devops.dev.migration.migration.time.monotonic') as mock_monotonic, \ + patch('azext_devops.dev.migration.migration._copy_to_clipboard') as mock_copy, \ + patch('azext_devops.dev.migration.migration.print') as mock_print: + mock_sleep.return_value = None + mock_monotonic.side_effect = [0, 0] + mock_copy.return_value = True + mock_post.side_effect = [ + { + 'device_code': 'devcode', + 'user_code': 'ABCD-1234', + 'verification_uri': 'https://example.ghe.com/login/device', + 'interval': 1, + 'expires_in': 900, + }, + {'access_token': 'token-from-device-flow'}, + ] + + token = migration_module._run_device_flow('client-id', 'https://example.ghe.com') + + self.assertEqual(token, 'token-from-device-flow') + mock_copy.assert_called_once_with('ABCD-1234') + mock_print.assert_any_call('Code copied to clipboard.') + + def test_run_device_flow_fails_for_invalid_interval(self): + with patch('azext_devops.dev.migration.migration._post_form') as mock_post: + mock_post.return_value = { + 'device_code': 'devcode', + 'user_code': 'ABCD-1234', + 'verification_uri': 'https://example.ghe.com/login/device', + 'interval': 'abc', + 'expires_in': 900, + } + + with self.assertRaises(CLIError) as ctx: + migration_module._run_device_flow('client-id', 'https://example.ghe.com') + + self.assertIn('Invalid device-flow response: interval must be a positive integer.', str(ctx.exception)) + + def test_run_device_flow_fails_for_invalid_expires_in(self): + with patch('azext_devops.dev.migration.migration._post_form') as mock_post: + mock_post.return_value = { + 'device_code': 'devcode', + 'user_code': 'ABCD-1234', + 'verification_uri': 'https://example.ghe.com/login/device', + 'interval': 5, + 'expires_in': 0, + } + + with self.assertRaises(CLIError) as ctx: + migration_module._run_device_flow('client-id', 'https://example.ghe.com') + + self.assertIn('Invalid device-flow response: expires_in must be a positive integer.', str(ctx.exception)) + + def test_post_form_401_returns_generic_guidance(self): + with patch('azext_devops.dev.migration.migration.urlopen') as mock_urlopen: + mock_urlopen.side_effect = HTTPError( + url='https://example.ghe.com/login/device/code', + code=401, + msg='Unauthorized', + hdrs=None, + fp=None + ) + + with self.assertRaises(CLIError) as ctx: + migration_module._post_form('https://example.ghe.com/login/device/code', { + 'client_id': 'client-id' + }) + + self.assertIn('GitHub device flow is unavailable for this organization.', str(ctx.exception)) + def test_create_migration_payload_includes_optional_fields(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, \ @@ -149,10 +619,10 @@ def test_create_migration_payload_includes_optional_fields(self): create_migration( repository_id='00000000-0000-0000-0000-000000000000', target_repository='https://example.ghe.com/OrgName/RepoName', - target_owner_user_id='GeoffCoxMSFT', + target_owner_user_id='TestOwner', validate_only=True, cutover_date='2030-12-31T11:59:00Z', - agent_pool='MigrationPool', + agent_pool='TestPool', skip_validation=2147483647, organization=self._TEST_ORG, detect=False @@ -161,7 +631,7 @@ def test_create_migration_payload_includes_optional_fields(self): payload = mock_send.call_args[0][3] self.assertTrue(payload['validateOnly']) self.assertEqual(payload['scheduledCutoverDate'], '2030-12-31T11:59:00Z') - self.assertEqual(payload['agentPoolName'], 'MigrationPool') + self.assertEqual(payload['agentPoolName'], 'TestPool') self.assertEqual(payload['skipValidation'], 2147483647) def test_create_migration_skip_validation_accepts_integer_string(self): @@ -174,7 +644,7 @@ def test_create_migration_skip_validation_accepts_integer_string(self): create_migration( repository_id='00000000-0000-0000-0000-000000000000', target_repository='https://example.ghe.com/OrgName/RepoName', - target_owner_user_id='GeoffCoxMSFT', + target_owner_user_id='TestOwner', skip_validation='2147483647', organization=self._TEST_ORG, detect=False @@ -193,7 +663,7 @@ def test_create_migration_skip_validation_accepts_policy_names(self): create_migration( repository_id='00000000-0000-0000-0000-000000000000', target_repository='https://example.ghe.com/OrgName/RepoName', - target_owner_user_id='GeoffCoxMSFT', + target_owner_user_id='TestOwner', skip_validation='PullRequestDeltaSize, AgentPoolExists', organization=self._TEST_ORG, detect=False @@ -212,7 +682,7 @@ def test_create_migration_skip_validation_accepts_all_name(self): create_migration( repository_id='00000000-0000-0000-0000-000000000000', target_repository='https://example.ghe.com/OrgName/RepoName', - target_owner_user_id='GeoffCoxMSFT', + target_owner_user_id='TestOwner', skip_validation='All', organization=self._TEST_ORG, detect=False @@ -226,7 +696,7 @@ def test_create_migration_skip_validation_rejects_invalid_policy_name(self): create_migration( repository_id='00000000-0000-0000-0000-000000000000', target_repository='https://example.ghe.com/OrgName/RepoName', - target_owner_user_id='GeoffCoxMSFT', + target_owner_user_id='TestOwner', skip_validation='BogusPolicy', organization=self._TEST_ORG, detect=False @@ -238,7 +708,7 @@ def test_create_migration_skip_validation_rejects_empty_policy_name(self): create_migration( repository_id='00000000-0000-0000-0000-000000000000', target_repository='https://example.ghe.com/OrgName/RepoName', - target_owner_user_id='GeoffCoxMSFT', + target_owner_user_id='TestOwner', skip_validation='AgentPoolExists,,MaxRepoSize', organization=self._TEST_ORG, detect=False @@ -255,7 +725,7 @@ def test_create_migration_empty_agent_pool_omitted(self): create_migration( repository_id='00000000-0000-0000-0000-000000000000', target_repository='https://example.ghe.com/OrgName/RepoName', - target_owner_user_id='GeoffCoxMSFT', + target_owner_user_id='TestOwner', agent_pool=' ', organization=self._TEST_ORG, detect=False @@ -274,8 +744,8 @@ def test_create_migration_omits_none_skip_validation(self): create_migration( repository_id='00000000-0000-0000-0000-000000000000', target_repository='https://example.ghe.com/OrgName/RepoName', - target_owner_user_id='GeoffCoxMSFT', - agent_pool='MigrationPool', + target_owner_user_id='TestOwner', + agent_pool='TestPool', skip_validation=None, organization=self._TEST_ORG, detect=False @@ -294,15 +764,15 @@ def test_create_migration_trims_agent_pool(self): create_migration( repository_id='00000000-0000-0000-0000-000000000000', target_repository='https://example.ghe.com/OrgName/RepoName', - target_owner_user_id='GeoffCoxMSFT', - agent_pool=' MigrationPool ', + target_owner_user_id='TestOwner', + agent_pool=' TestPool ', skip_validation=42, organization=self._TEST_ORG, detect=False ) payload = mock_send.call_args[0][3] - self.assertEqual(payload['agentPoolName'], 'MigrationPool') + self.assertEqual(payload['agentPoolName'], 'TestPool') self.assertEqual(payload['skipValidation'], 42) def test_create_migration_passes_target_repository_to_api(self): @@ -315,8 +785,8 @@ def test_create_migration_passes_target_repository_to_api(self): create_migration( repository_id='00000000-0000-0000-0000-000000000000', target_repository='https://example.com/OrgName/RepoName', - target_owner_user_id='GeoffCoxMSFT', - agent_pool='MigrationPool', + target_owner_user_id='TestOwner', + agent_pool='TestPool', organization=self._TEST_ORG, detect=False ) @@ -334,9 +804,9 @@ def test_create_migration_validate_only_flag_sends_true(self): create_migration( repository_id='00000000-0000-0000-0000-000000000000', target_repository='https://example.ghe.com/OrgName/RepoName', - target_owner_user_id='GeoffCoxMSFT', + target_owner_user_id='TestOwner', validate_only=True, - agent_pool='MigrationPool', + agent_pool='TestPool', organization=self._TEST_ORG, detect=False ) @@ -354,21 +824,110 @@ def test_create_migration_agent_pool_always_in_payload(self): create_migration( repository_id='00000000-0000-0000-0000-000000000000', target_repository='https://example.ghe.com/OrgName/RepoName', - target_owner_user_id='GeoffCoxMSFT', - agent_pool='MigrationPool', + target_owner_user_id='TestOwner', + agent_pool='TestPool', organization=self._TEST_ORG, detect=False ) payload = mock_send.call_args[0][3] - self.assertEqual(payload['agentPoolName'], 'MigrationPool') + self.assertEqual(payload['agentPoolName'], 'TestPool') - def test_cancel_cutover_sets_null(self): + def test_create_migration_service_endpoint_id_included_in_payload(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._resolve_github_user_token') as mock_token, \ patch('azext_devops.dev.migration.migration._send_request') as mock_send: mock_send.return_value = {} mock_resolve.return_value = self._TEST_ORG + mock_token.return_value = 'ghp_test_token' + + 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='12345678-1234-1234-1234-123456789012', + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + 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) + + def test_create_migration_service_endpoint_id_skips_github_token_resolution(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._resolve_github_user_token') as mock_token, \ + patch('azext_devops.dev.migration.migration._send_request') as mock_send: + mock_send.return_value = {} + mock_resolve.return_value = self._TEST_ORG + mock_token.return_value = 'ghp_test_token' + + create_migration( + repository_id='00000000-0000-0000-0000-000000000000', + target_repository='https://example.ghe.com/OrgName/RepoName', + service_endpoint_id='12345678-1234-1234-1234-123456789012', + organization=self._TEST_ORG, + detect=False + ) + + mock_token.assert_not_called() + + def test_create_migration_service_endpoint_id_omitted_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._resolve_github_user_token') as mock_token, \ + patch('azext_devops.dev.migration.migration._send_request') as mock_send: + mock_send.return_value = {} + mock_resolve.return_value = self._TEST_ORG + mock_token.return_value = 'ghp_test_token' + + create_migration( + repository_id='00000000-0000-0000-0000-000000000000', + target_repository='https://example.ghe.com/OrgName/RepoName', + target_owner_user_id='TestOwner', + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + self.assertNotIn('serviceEndpointId', payload) + + def test_create_migration_empty_service_endpoint_id_omitted(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._resolve_github_user_token') as mock_token, \ + patch('azext_devops.dev.migration.migration._send_request') as mock_send: + mock_send.return_value = {} + mock_resolve.return_value = self._TEST_ORG + mock_token.return_value = 'ghp_test_token' + + 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=' ', + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + self.assertNotIn('serviceEndpointId', payload) + + def test_cancel_cutover_sends_min_value_sentinel(self): + # The ELM service silently ignores `null` for scheduledCutoverDate and only + # treats DateTimeOffset.MinValue ("0001-01-01T00:00:00+00:00") as the clear + # sentinel. Sending null leaves the field set on the server. + 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.get_migration') as mock_get: + mock_get.return_value = {'stage': 'readyForCutover', 'status': 'active'} + mock_send.return_value = {} + mock_resolve.return_value = self._TEST_ORG cancel_cutover( repository_id='00000000-0000-0000-0000-000000000000', @@ -377,7 +936,174 @@ def test_cancel_cutover_sets_null(self): ) payload = mock_send.call_args[0][3] - self.assertIsNone(payload['scheduledCutoverDate']) + self.assertEqual(payload['scheduledCutoverDate'], '0001-01-01T00:00:00+00:00') + + def test_cancel_cutover_returns_success_message_when_empty_response(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, \ + patch('azext_devops.dev.migration.migration.get_migration') as mock_get: + mock_get.return_value = {'stage': 'readyForCutover', 'status': 'active'} + mock_send.return_value = {} + mock_resolve.return_value = self._TEST_ORG + + result = cancel_cutover( + repository_id='00000000-0000-0000-0000-000000000000', + organization=self._TEST_ORG, + detect=False + ) + + self.assertIn('message', result) + self.assertIn('cancelled', result['message'].lower()) + + def test_cancel_cutover_blocked_when_stage_is_cutover(self): + # Service-side Bug 2394803: clearing scheduledCutoverDate after the worker + # has entered the Cutover stage leaves the migration in a state that + # requires server-side recovery. The CLI must block this case until the + # service-side guard ships. + 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.get_migration') as mock_get: + mock_get.return_value = {'stage': 'cutover', 'status': 'active'} + mock_resolve.return_value = self._TEST_ORG + + with self.assertRaises(CLIError) as ctx: + cancel_cutover( + repository_id='00000000-0000-0000-0000-000000000000', + organization=self._TEST_ORG, + detect=False + ) + + self.assertIn('Cutover stage', str(ctx.exception)) + # Must not have called PUT against the migration record. + mock_send.assert_not_called() + + def test_get_cutover_review_calls_get(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 = {'totalUnprocessedCount': 3} + mock_resolve.return_value = self._TEST_ORG + + get_cutover_review( + repository_id='00000000-0000-0000-0000-000000000000', + organization=self._TEST_ORG, + detect=False + ) + + args = mock_send.call_args[0] + self.assertEqual(args[1], 'GET') + self.assertIn('/_apis/elm/migrations/00000000-0000-0000-0000-000000000000/cutoverReview', args[2]) + + def test_approve_cutover_sends_cutover_failure_accepted_count(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=3, + organization=self._TEST_ORG, + detect=False + ) + + args = mock_send.call_args[0] + self.assertEqual(args[1], 'PUT') + self.assertEqual(args[3]['cutoverFailureAcceptedCount'], 3) + + def test_approve_cutover_requires_accept_failures(self): + with self.assertRaises(CLIError) as ctx: + approve_cutover( + repository_id='00000000-0000-0000-0000-000000000000', + organization=self._TEST_ORG, + detect=False + ) + self.assertIn('--accept-failures must be specified', str(ctx.exception)) + + def test_approve_cutover_rejects_negative_accept_failures(self): + with self.assertRaises(CLIError) as ctx: + approve_cutover( + repository_id='00000000-0000-0000-0000-000000000000', + accept_failures=-1, + organization=self._TEST_ORG, + detect=False + ) + self.assertIn('non-negative integer', str(ctx.exception)) + + def test_pause_returns_success_message_when_empty_response(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 = {} + mock_resolve.return_value = self._TEST_ORG + + result = pause_migration( + repository_id='00000000-0000-0000-0000-000000000000', + organization=self._TEST_ORG, + detect=False + ) + + self.assertIn('message', result) + self.assertIn('paused', result['message'].lower()) + + def test_pause_returns_migration_data_when_service_responds(self): + migration_response = {'repositoryId': '00000000-0000-0000-0000-000000000000', 'status': 'suspended'} + 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 = migration_response + mock_resolve.return_value = self._TEST_ORG + + result = pause_migration( + repository_id='00000000-0000-0000-0000-000000000000', + organization=self._TEST_ORG, + detect=False + ) + + self.assertEqual(result, migration_response) + + def test_list_migrations_warns_when_empty(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, \ + patch('azext_devops.dev.migration.migration.logger') as mock_logger: + mock_send.return_value = {'value': []} + mock_resolve.return_value = self._TEST_ORG + + list_migrations(organization=self._TEST_ORG, detect=False) + + mock_logger.warning.assert_called_once() + 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): + 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.logger') as mock_logger: + mock_send.return_value = {'value': []} + mock_resolve.return_value = self._TEST_ORG + + list_migrations(include_inactive=False, organization=self._TEST_ORG, detect=False) + + warning_call = str(mock_logger.warning.call_args) + self.assertIn('include-inactive', warning_call) + + def test_list_migrations_no_hint_when_include_inactive_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, \ + patch('azext_devops.dev.migration.migration.logger') as mock_logger: + mock_send.return_value = {'value': []} + mock_resolve.return_value = self._TEST_ORG + + list_migrations(include_inactive=True, organization=self._TEST_ORG, detect=False) + + warning_call = str(mock_logger.warning.call_args) + self.assertNotIn('include-inactive', warning_call) def test_resume_rejects_both_flags(self): with self.assertRaises(CLIError): @@ -396,6 +1122,18 @@ def test_resume_fails_when_active(self): organization=self._TEST_ORG, detect=False) self.assertIn('az devops migrations pause', str(ctx.exception)) + def test_resume_fails_when_review_for_cutover(self): + with patch('azext_devops.dev.migration.migration.get_migration') as mock_get, \ + patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve: + mock_get.return_value = {'status': 'active', 'stage': 'ReviewForCutover'} + mock_resolve.return_value = self._TEST_ORG + + with self.assertRaises(CLIError) as ctx: + resume_migration(repository_id='00000000-0000-0000-0000-000000000000', + organization=self._TEST_ORG, detect=False) + self.assertIn('cutover review', str(ctx.exception)) + self.assertIn('cutover approve', str(ctx.exception)) + def test_resume_fails_when_active_via_statusRequested(self): with patch('azext_devops.dev.migration.migration.get_migration') as mock_get, \ patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve: @@ -479,6 +1217,28 @@ def test_resume_migration_promotes_validate_only_succeeded(self): self.assertFalse(payload['validateOnly']) self.assertEqual(payload['statusRequested'], 'active') + def test_resume_migration_promotes_validate_only_completed(self): + with patch('azext_devops.dev.migration.migration.get_migration') as mock_get, \ + 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 = {} + mock_get.return_value = { + 'status': 'completed', + 'validateOnly': True, + } + mock_resolve.return_value = self._TEST_ORG + + resume_migration(repository_id='00000000-0000-0000-0000-000000000000', + migration=True, + organization=self._TEST_ORG, detect=False) + + args = mock_send.call_args[0] + self.assertEqual(args[1], 'PUT') + payload = args[3] + self.assertFalse(payload['validateOnly']) + self.assertEqual(payload['statusRequested'], 'active') + def test_resume_migration_promote_uses_only_state_transition_fields(self): with patch('azext_devops.dev.migration.migration.get_migration') as mock_get, \ patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ @@ -520,6 +1280,18 @@ def test_resume_succeeded_without_migration_flag_errors(self): resume_migration(repository_id='00000000-0000-0000-0000-000000000000', organization=self._TEST_ORG, detect=False) self.assertIn('--migration', str(ctx.exception)) + self.assertIn('00000000-0000-0000-0000-000000000000', str(ctx.exception)) + + def test_resume_completed_without_migration_flag_errors(self): + with patch('azext_devops.dev.migration.migration.get_migration') as mock_get, \ + patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve: + mock_get.return_value = {'status': 'completed', 'validateOnly': True} + mock_resolve.return_value = self._TEST_ORG + + with self.assertRaises(CLIError) as ctx: + resume_migration(repository_id='00000000-0000-0000-0000-000000000000', + organization=self._TEST_ORG, detect=False) + self.assertIn('--migration', str(ctx.exception)) def test_resume_succeeded_full_migration_errors(self): with patch('azext_devops.dev.migration.migration.get_migration') as mock_get, \ @@ -531,6 +1303,126 @@ def test_resume_succeeded_full_migration_errors(self): resume_migration(repository_id='00000000-0000-0000-0000-000000000000', organization=self._TEST_ORG, detect=False) self.assertIn('abandon', str(ctx.exception)) + self.assertIn('00000000-0000-0000-0000-000000000000', str(ctx.exception)) + + def test_resume_completed_full_migration_errors(self): + with patch('azext_devops.dev.migration.migration.get_migration') as mock_get, \ + patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve: + mock_get.return_value = {'status': 'completed', 'validateOnly': False} + mock_resolve.return_value = self._TEST_ORG + + with self.assertRaises(CLIError) as ctx: + resume_migration(repository_id='00000000-0000-0000-0000-000000000000', + organization=self._TEST_ORG, detect=False) + self.assertIn('abandon', str(ctx.exception)) + + def test_resume_completed_status_takes_precedence_over_active_status_requested(self): + with patch('azext_devops.dev.migration.migration.get_migration') as mock_get, \ + patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve: + mock_get.return_value = { + 'status': 'completed', + 'statusRequested': 'active', + 'validateOnly': True, + } + mock_resolve.return_value = self._TEST_ORG + + with self.assertRaises(CLIError) as ctx: + resume_migration(repository_id='00000000-0000-0000-0000-000000000000', + organization=self._TEST_ORG, detect=False) + self.assertIn('--migration', str(ctx.exception)) + + def test_resume_completed_status_requested_without_status_is_terminal(self): + with patch('azext_devops.dev.migration.migration.get_migration') as mock_get, \ + patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve: + mock_get.return_value = {'statusRequested': 'completed', 'validateOnly': False} + mock_resolve.return_value = self._TEST_ORG + + with self.assertRaises(CLIError) as ctx: + resume_migration(repository_id='00000000-0000-0000-0000-000000000000', + organization=self._TEST_ORG, detect=False) + self.assertIn('abandon', str(ctx.exception)) + + def test_resume_completed_case_variants_are_treated_as_terminal(self): + with patch('azext_devops.dev.migration.migration.get_migration') as mock_get, \ + patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve: + mock_get.return_value = {'status': 'Com_PleTed', 'validateOnly': False} + mock_resolve.return_value = self._TEST_ORG + + with self.assertRaises(CLIError) as ctx: + resume_migration(repository_id='00000000-0000-0000-0000-000000000000', + organization=self._TEST_ORG, detect=False) + self.assertIn('abandon', str(ctx.exception)) + + def test_abandon_returns_success_message(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 = {} + mock_resolve.return_value = self._TEST_ORG + + result = delete_migration( + repository_id='00000000-0000-0000-0000-000000000000', + organization=self._TEST_ORG, + detect=False + ) + + args = mock_send.call_args[0] + self.assertEqual(args[1], 'DELETE') + self.assertIn('/_apis/elm/migrations/', args[2]) + self.assertNotIn('removeReadOnly=true', args[2]) + self.assertIsInstance(result, dict) + self.assertIn('message', result) + self.assertIn('abandoned successfully', result['message']) + + def test_abandon_remove_read_only_included_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: + mock_send.return_value = {} + mock_resolve.return_value = self._TEST_ORG + + delete_migration( + repository_id='00000000-0000-0000-0000-000000000000', + remove_read_only=True, + organization=self._TEST_ORG, + detect=False + ) + + args = mock_send.call_args[0] + self.assertEqual(args[1], 'DELETE') + self.assertIn('removeReadOnly=true', args[2]) + + def test_send_request_uses_precheck_issue_detail_from_response_body(self): + class MockResponse(object): + status_code = 400 + headers = {'Content-Type': 'application/json'} + + @staticmethod + def json(): + return { + 'validationIssues': [ + { + 'PreCheckIssueType': 'TargetRepositoryDoesNotExist', + 'Message': 'Target repository could not be found.' + } + ], + 'message': 'Generic server message' + } + + 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(), 'POST', 'https://example.test') + + text = str(ctx.exception) + self.assertIn('status 400', text) + self.assertIn('Pre-check issues:', text) + self.assertIn('TargetRepositoryDoesNotExist', text) + self.assertIn('Target repository could not be found.', text) if __name__ == '__main__': diff --git a/doc/elm_migrations_tsg.md b/doc/elm_migrations_tsg.md index c28eb04e..f7e94bc8 100644 --- a/doc/elm_migrations_tsg.md +++ b/doc/elm_migrations_tsg.md @@ -100,8 +100,8 @@ Create (validate-only) → Check status → Resume (--migration) → Monitor → | ADO project name | `MyProject` | The project containing the source repo | | ADO repo name | `my-repo` | The repo you want to migrate | | Target repo URL | `https://example.ghe.com/OrgName/RepoName` | Create the empty target repo in GitHub **before** starting | -| Target owner user ID | `GeoffCoxMSFT` | The GitHub user ID who owns the target repo | -| Agent pool name | `MigrationPool` | Ask your admin | +| GitHub auth token | `` | Optional: pass via `--github-token` or set `ELM_GITHUB_TOKEN` | +| Agent pool name | `` | Ask your admin | ### 3.1 Get the source repository GUID from Azure DevOps @@ -142,8 +142,7 @@ Start with validation to catch any issues **before** moving data. This runs pre- az devops migrations create --detect false \ --repository-id b3e18946-5b39-40ca-8e2f-d0eb683d8a85 \ --target-repository https://example.ghe.com/OrgName/RepoName \ - --target-owner-user-id GeoffCoxMSFT \ - --agent-pool MigrationPool \ + --agent-pool \ --validate-only ``` @@ -151,6 +150,17 @@ 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. + +You can also pass a token or PAT explicitly: + +```powershell +az devops migrations create --detect false \ + --repository-id b3e18946-5b39-40ca-8e2f-d0eb683d8a85 \ + --target-repository https://example.ghe.com/OrgName/RepoName \ + --github-token +``` + **Optional parameters you can add at creation time:** | Parameter | What it does | Example | @@ -299,7 +309,7 @@ az devops migrations resume --detect false --repository-id --validate-onl |---|---|---|---|---| | `list` | `--org` | `--include-inactive`, `--detect` | GET | List migrations. By default only active ones. | | `status` | `--org`, `--repository-id` | `--detect` | GET | Get detailed status for one migration. | -| `create` | `--org`, `--repository-id`, `--target-repository`, `--target-owner-user-id` | `--agent-pool`, `--validate-only`, `--cutover-date`, `--skip-validation`, `--detect` | POST | Create a new 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. | | `pause` | `--org`, `--repository-id` | `--detect` | PUT | Pause an active migration. | | `resume` | `--org`, `--repository-id` | `--validate-only`, `--migration`, `--detect` | PUT | Resume a stopped migration. | | `cutover set` | `--org`, `--repository-id`, `--date` | `--detect` | PUT | Schedule a cutover date/time. | @@ -314,7 +324,8 @@ az devops migrations resume --detect false --repository-id --validate-onl | `--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://`. | -| `--target-owner-user-id` | string | `create` | Target repository owner user ID. | +| `--github-token` | string | `create` | GitHub token used for migration authorization. If omitted, CLI checks `ELM_GITHUB_TOKEN` and then runs device flow. | +| `--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. | | `--migration` | flag | `resume` | Promote succeeded validate-only to full migration (`validateOnly=false`, `statusRequested=active`). Mutually exclusive with `--validate-only`. | @@ -332,7 +343,8 @@ az devops migrations resume --detect false --repository-id --validate-onl | **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 | | **Both `--validate-only` and `--migration` on resume** | Error: "Please specify only one..." | Use only one flag at a time | -| **Missing `--agent-pool` on create** | Error: "--agent-pool must be specified." | Always provide `--agent-pool ` | +| **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` | | **Invalid `--target-repository` format** | Error: "--target-repository must be a valid URL..." | Use a fully qualified URL starting with `http://` or `https://` | | **Invalid `--repository-id`** | Error: "--repository-id must be a valid GUID." | Use `az repos show --query id` to get the correct GUID | | **Bad date format** | Error: "must be a valid date or datetime string" | Use ISO 8601 format, e.g., `2030-12-31T11:59:00Z` | @@ -383,6 +395,13 @@ Advanced form using integer bitmask: az devops migrations create --detect false --repository-id --target-repository --target-owner-user-id --skip-validation 132 ``` +Token/PAT-authenticated examples: + +```powershell +az devops migrations create --detect false --repository-id --target-repository --github-token --skip-validation AgentPoolExists,MaxRepoSize +az devops migrations create --detect false --repository-id --target-repository --github-token --skip-validation 132 +``` + Supported policy names: - `None` @@ -418,6 +437,37 @@ az devops migrations resume --detect false --repository-id --migration 1. If migration already succeeded as full migration, abandon and recreate if needed. +### 409 Conflict — Active Migration Already Exists + +**Symptom:** + +``` +An active migration already exists for repository . Delete (abandon) the existing migration before creating a new one. +``` + +**Cause:** A non-terminal migration already exists for the repository GUID you specified. Only one active migration per repository is allowed at a time. + +**Fix:** + +1. Check the existing migration: + +```powershell +az devops migrations status --detect false --repository-id -o json +``` + +1. If it can be reused (e.g., it succeeded validation and you want to promote it), use `resume --migration` instead of creating a new one. +1. If you want to start fresh, abandon it first and then recreate: + +```powershell +az devops migrations abandon --detect false --repository-id + +az devops migrations create --detect false \ + --repository-id \ + --target-repository https://example.ghe.com/OrgName/RepoName +``` + +--- + ### 406 Not Acceptable **Symptom:** `Request failed with status 406`. diff --git a/doc/getting_started.md b/doc/getting_started.md index 0d30c05c..6fa18d70 100644 --- a/doc/getting_started.md +++ b/doc/getting_started.md @@ -103,4 +103,4 @@ Global Arguments ## Enterprise live migrations -If you are using enterprise live migrations, see the guide at [migrations.md](migrations.md). +If you are using enterprise live migrations (Preview), see the guide at [migrations.md](migrations.md). diff --git a/doc/migrations.md b/doc/migrations.md index 85d31271..d6fcbc38 100644 --- a/doc/migrations.md +++ b/doc/migrations.md @@ -1,6 +1,7 @@ # Enterprise live migrations (ELM) -The `az devops migrations` command group manages enterprise live migrations for repositories. +The `az devops migrations` command group (Preview) manages enterprise live migrations for repositories. +Availability may be limited (for example, to 1P/allowlisted users). ## Prerequisites @@ -33,7 +34,8 @@ Use all three fields together when troubleshooting state transitions. - `--repository-id` is the Azure Repos repository GUID. - `--target-repository` is the target repository URL. -- `--target-owner-user-id` is required for create. +- `--github-token` is optional for create. If not provided, the CLI checks `ELM_GITHUB_TOKEN` and then runs GitHub device flow. +- `--target-owner-user-id` is deprecated and ignored when server-side token ownership resolution is enabled. - `--agent-pool` is optional for create. - `--cutover-date` / `--date` must be ISO 8601, for example: `2030-12-31T11:59:00Z`. - `--skip-validation` accepts either comma-separated policy names or a non-negative integer bitmask. @@ -118,8 +120,7 @@ az devops migrations status --org https://dev.azure.com/myorg \ az devops migrations create --org https://dev.azure.com/myorg \ --repository-id 00000000-0000-0000-0000-000000000000 \ --target-repository https://example.ghe.com/OrgName/RepoName \ - --target-owner-user-id OwnerId \ - --agent-pool MigrationPool + --agent-pool ``` ### Create a validate-only migration @@ -128,11 +129,19 @@ az devops migrations create --org https://dev.azure.com/myorg \ az devops migrations create --org https://dev.azure.com/myorg \ --repository-id 00000000-0000-0000-0000-000000000000 \ --target-repository https://example.ghe.com/OrgName/RepoName \ - --target-owner-user-id OwnerId \ - --agent-pool MigrationPool \ + --agent-pool \ --validate-only ``` +### Create a migration using explicit token or PAT + +```bash +az devops migrations create --org https://dev.azure.com/myorg \ + --repository-id 00000000-0000-0000-0000-000000000000 \ + --target-repository https://example.ghe.com/OrgName/RepoName \ + --github-token +``` + ### Create a migration with skip-validation Recommended form using policy names: @@ -141,7 +150,6 @@ Recommended form using policy names: az devops migrations create --org https://dev.azure.com/myorg \ --repository-id 00000000-0000-0000-0000-000000000000 \ --target-repository https://example.ghe.com/OrgName/RepoName \ - --target-owner-user-id OwnerId \ --skip-validation AgentPoolExists,MaxRepoSize ``` @@ -151,7 +159,6 @@ Advanced form using integer bitmask: az devops migrations create --org https://dev.azure.com/myorg \ --repository-id 00000000-0000-0000-0000-000000000000 \ --target-repository https://example.ghe.com/OrgName/RepoName \ - --target-owner-user-id OwnerId \ --skip-validation 132 ``` @@ -223,6 +230,9 @@ az devops migrations pause --org https://dev.azure.com/myorg \ - Error: `--target-repository` must be valid. Ensure it is a fully qualified URL starting with `http://` or `https://`. +- Error: missing GitHub token or device-flow setup. + Pass `--github-token`, set `ELM_GITHUB_TOKEN`, or complete the interactive GitHub device-flow prompt shown by CLI. + - Error: `--skip-validation` contains unsupported policy names. Use supported names such as `AgentPoolExists`, `MaxRepoSize`, or pass a non-negative integer bitmask. @@ -231,3 +241,15 @@ az devops migrations pause --org https://dev.azure.com/myorg \ - Error: migration already succeeded. Use `abandon` to reset before creating a new migration. + +- Error: active migration already exists for repository. + The create command returns: `"An active migration already exists for repository . Delete (abandon) the existing migration before creating a new one."` This means a non-terminal migration already exists for that repository GUID. Abandon it first, then retry create. + +```bash +az devops migrations abandon --org https://dev.azure.com/myorg \ + --repository-id 00000000-0000-0000-0000-000000000000 + +az devops migrations create --org https://dev.azure.com/myorg \ + --repository-id 00000000-0000-0000-0000-000000000000 \ + --target-repository https://example.ghe.com/OrgName/RepoName +```