diff --git a/.flake8 b/.flake8 index caa89c014..1926c5317 100644 --- a/.flake8 +++ b/.flake8 @@ -21,5 +21,8 @@ exclude = scripts doc build_scripts + env + venv + .venv */test/* */devops_sdk/* \ No newline at end of file diff --git a/.github/workflows/pr-workflow.yml b/.github/workflows/pr-workflow.yml index 64ca0c81f..bc1b8d404 100644 --- a/.github/workflows/pr-workflow.yml +++ b/.github/workflows/pr-workflow.yml @@ -140,6 +140,7 @@ jobs: path: ${{ github.workspace }}/azure-devops/htmlcov Run_Style_Check: + needs: Build_Publish_Azure_CLI_Test_SDK runs-on: macOS-latest steps: - uses: actions/checkout@v4 diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 0db4b55e3..63c35ff14 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -3,15 +3,18 @@ "tasks": [ { "label": "BuildWheel", - "command": "${command:python.interpreterPath}", + "type": "shell", + "command": "python", "args": [ "setup.py", "sdist", "bdist_wheel" ], - "type": "shell", "options": { - "cwd": "${workspaceRoot}/azure-devops/" + "cwd": "${workspaceRoot}/azure-devops/", + "env": { + "PATH": "${workspaceRoot}\\env\\Scripts;${env:PATH}" + } }, "presentation": { "echo": true, diff --git a/README.md b/README.md index e6fdd4cf3..e25d870ae 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ $az [group] [subgroup] [command] {parameters} ``` Adding the Azure DevOps Extension adds `devops`, `pipelines`, `artifacts`, `boards` and `repos` groups. +Enterprise live migrations are available under `az devops migrations`. For usage and help content for any command, pass in the -h parameter, for example: ```bash @@ -43,6 +44,7 @@ Group Subgroups: admin : Manage administration operations. + migrations : Manage enterprise live migrations. extension : Manage extensions. project : Manage team projects. security : Manage security related operations. @@ -64,6 +66,7 @@ Commands: - Checkout the CLI docs at [docs.microsoft.com - Azure DevOps CLI](https://docs.microsoft.com/azure/devops/cli/). - Check out other examples in the [How-to guides](https://docs.microsoft.com/azure/devops/cli/?view=azure-devops#how-to-guides) section. - You can view the various commands and its usage here - [docs.microsoft.com - Azure DevOps Extension Reference](https://docs.microsoft.com/en-us/cli/azure/devops?view=azure-cli-latest) +- Enterprise live migrations guide: [doc/migrations.md](doc/migrations.md) ## Contribute diff --git a/azure-devops/azext_devops/__init__.py b/azure-devops/azext_devops/__init__.py index a2c4f30b6..6c22154c1 100644 --- a/azure-devops/azext_devops/__init__.py +++ b/azure-devops/azext_devops/__init__.py @@ -21,6 +21,8 @@ def load_command_table(self, args): load_admin_commands(self, args) from azext_devops.dev.boards.commands import load_work_commands load_work_commands(self, args) + from azext_devops.dev.migration.commands import load_migration_commands + load_migration_commands(self, args) from azext_devops.dev.pipelines.commands import load_build_commands load_build_commands(self, args) from azext_devops.dev.repos.commands import load_code_commands @@ -36,6 +38,8 @@ def load_arguments(self, command): load_admin_arguments(self, command) from azext_devops.dev.boards.arguments import load_work_arguments load_work_arguments(self, command) + from azext_devops.dev.migration.arguments import load_migration_arguments + load_migration_arguments(self, command) from azext_devops.dev.pipelines.arguments import load_build_arguments load_build_arguments(self, command) from azext_devops.dev.repos.arguments import load_code_arguments @@ -47,8 +51,8 @@ def load_arguments(self, command): @staticmethod def post_parse_args(_cli_ctx, **kwargs): - if (kwargs.get('command', None) and - kwargs['command'].startswith(('devops', 'boards', 'artifacts', 'pipelines', 'repos'))): + command = kwargs.get('command', None) + if command and command.startswith(('devops', 'boards', 'artifacts', 'pipelines', 'repos', 'migrations')): from azext_devops.dev.common.telemetry import set_tracking_data # we need to set tracking data only after we know that all args are valid, # otherwise we may log EUII data that a user inadvertently sent as an argument diff --git a/azure-devops/azext_devops/dev/common/arguments.py b/azure-devops/azext_devops/dev/common/arguments.py index c5eae8abf..a4805429c 100644 --- a/azure-devops/azext_devops/dev/common/arguments.py +++ b/azure-devops/azext_devops/dev/common/arguments.py @@ -25,8 +25,7 @@ def convert_date_string_to_iso8601(value, argument=None): if d.tzinfo is None: from dateutil.tz import tzlocal d = d.replace(tzinfo=tzlocal()) - d = d.isoformat() - return d + return d.isoformat() def convert_date_only_string_to_iso8601(value, argument=None): diff --git a/azure-devops/azext_devops/dev/migration/__init__.py b/azure-devops/azext_devops/dev/migration/__init__.py new file mode 100644 index 000000000..e2f28bdcd --- /dev/null +++ b/azure-devops/azext_devops/dev/migration/__init__.py @@ -0,0 +1,8 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from ._help import load_migration_help + +load_migration_help() diff --git a/azure-devops/azext_devops/dev/migration/_format.py b/azure-devops/azext_devops/dev/migration/_format.py new file mode 100644 index 000000000..e5a08e51d --- /dev/null +++ b/azure-devops/azext_devops/dev/migration/_format.py @@ -0,0 +1,46 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from collections import OrderedDict +from azext_devops.dev.common.format import trim_for_display, date_time_to_only_date + + +_TARGET_TRUNCATION_LENGTH = 60 + + +def transform_migrations_table_output(result): + migrations = _unwrap_migration_list(result) + table_output = [] + for item in migrations: + table_output.append(_transform_migration_row(item)) + return table_output + + +def transform_migration_table_output(result): + if result is None: + return [] + return [_transform_migration_row(result)] + + +def _unwrap_migration_list(result): + if isinstance(result, dict) and 'value' in result: + return result['value'] + if isinstance(result, list): + return result + return [] + + +def _transform_migration_row(row): + table_row = OrderedDict() + table_row['RepositoryId'] = row.get('repositoryId') + table_row['TargetRepository'] = trim_for_display(row.get('targetRepository'), + _TARGET_TRUNCATION_LENGTH) + table_row['Status'] = row.get('status') + table_row['Stage'] = row.get('stage') + table_row['ValidateOnly'] = row.get('validateOnly') + table_row['CutoverDate'] = date_time_to_only_date(row.get('scheduledCutoverDate')) + table_row['CodeSyncDate'] = date_time_to_only_date(row.get('codeSyncDate')) + table_row['PrSyncDate'] = date_time_to_only_date(row.get('pullRequestSyncDate')) + return table_row diff --git a/azure-devops/azext_devops/dev/migration/_help.py b/azure-devops/azext_devops/dev/migration/_help.py new file mode 100644 index 000000000..96c1a5c55 --- /dev/null +++ b/azure-devops/azext_devops/dev/migration/_help.py @@ -0,0 +1,91 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from knack.help_files import helps + + +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).' + """ + + helps['devops migrations list'] = """ + type: command + short-summary: List migrations in an organization. + examples: + - name: List migrations. + text: | + az devops migrations list --org https://dev.azure.com/myorg + - name: List all migrations including inactive ones. + text: | + az devops migrations list --org https://dev.azure.com/myorg --include-inactive + """ + + helps['devops migrations status'] = """ + type: command + short-summary: Get migration status for a repository. + examples: + - name: Get migration status by repository id. + text: | + az devops migrations status --org https://dev.azure.com/myorg --repository-id 00000000-0000-0000-0000-000000000000 + """ + + helps['devops migrations create'] = """ + type: command + short-summary: Create a migration for a repository. + 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 + - 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 + """ + + helps['devops migrations pause'] = """ + type: command + short-summary: Pause an active migration. + """ + + helps['devops migrations resume'] = """ + type: command + short-summary: Resume a stopped (paused, failed) migration. + examples: + - name: Resume using the current mode. + text: | + az devops migrations resume --org https://dev.azure.com/myorg --repository-id 00000000-0000-0000-0000-000000000000 + - name: Resume in validate-only mode. + text: | + az devops migrations resume --org https://dev.azure.com/myorg --repository-id 00000000-0000-0000-0000-000000000000 --validate-only + - name: Continue migration (clears validate-only mode). + text: | + az devops migrations resume --org https://dev.azure.com/myorg --repository-id 00000000-0000-0000-0000-000000000000 --migration + """ + + helps['devops migrations abandon'] = """ + type: command + short-summary: Abandon and delete a migration. + """ + + helps['devops migrations cutover'] = """ + type: group + short-summary: Manage migration cutover. + """ + + helps['devops migrations cutover set'] = """ + type: command + short-summary: Schedule cutover for a migration. + examples: + - name: Schedule cutover. + text: | + az devops migrations cutover set --org https://dev.azure.com/myorg --repository-id 00000000-0000-0000-0000-000000000000 --date 2030-12-31T11:59:00Z + """ + + helps['devops migrations cutover cancel'] = """ + type: command + short-summary: Cancel a scheduled cutover. + """ diff --git a/azure-devops/azext_devops/dev/migration/arguments.py b/azure-devops/azext_devops/dev/migration/arguments.py new file mode 100644 index 000000000..4c99a92e6 --- /dev/null +++ b/azure-devops/azext_devops/dev/migration/arguments.py @@ -0,0 +1,50 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from azext_devops.dev.common.arguments import convert_date_string_to_iso8601 +from azext_devops.dev.team.arguments import load_global_args + + +# pylint: disable=too-many-statements +def load_migration_arguments(self, _): + with self.argument_context('devops migrations') as context: + load_global_args(context) + context.argument('repository_id', options_list='--repository-id', + help='ID of the Azure Repos repository (GUID).') + + with self.argument_context('devops migrations list') as context: + context.argument('include_inactive', options_list='--include-inactive', action='store_true', + help='Include inactive (completed, abandoned, failed) migrations in the results.') + context.argument('project', options_list='--project', + help='Optional project name or ID to filter migrations.') + + with self.argument_context('devops migrations create') as context: + 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.') + 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', + type=convert_date_string_to_iso8601, + help='Scheduled cutover date/time (ISO 8601).') + context.argument('agent_pool', options_list='--agent-pool', + help='Agent pool name to use for migration work.') + context.argument('skip_validation', options_list='--skip-validation', + help='Validation policies to skip. Accepts either a comma-separated list of ' + 'policy names (for example, AgentPoolExists,MaxRepoSize) or a non-negative ' + 'integer bitmask.') + + 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 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).') diff --git a/azure-devops/azext_devops/dev/migration/commands.py b/azure-devops/azext_devops/dev/migration/commands.py new file mode 100644 index 000000000..684803f91 --- /dev/null +++ b/azure-devops/azext_devops/dev/migration/commands.py @@ -0,0 +1,29 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +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 + + +migrationOps = CliCommandType( + operations_tmpl='azext_devops.dev.migration.migration#{}', + exception_handler=azure_devops_exception_handler +) + + +def load_migration_commands(self, _): + with self.command_group('devops migrations', command_type=migrationOps) 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('resume', 'resume_migration', table_transformer=transform_migration_table_output) + g.command('abandon', 'delete_migration', + confirmation='Are you sure you want to abandon this migration?') + + with self.command_group('devops migrations cutover', command_type=migrationOps) as g: + g.command('set', 'schedule_cutover', table_transformer=transform_migration_table_output) + g.command('cancel', 'cancel_cutover', table_transformer=transform_migration_table_output) diff --git a/azure-devops/azext_devops/dev/migration/migration.py b/azure-devops/azext_devops/dev/migration/migration.py new file mode 100644 index 000000000..e2b70545d --- /dev/null +++ b/azure-devops/azext_devops/dev/migration/migration.py @@ -0,0 +1,349 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import re +from urllib.parse import quote_plus + +from msrest import Configuration +from msrest.service_client import ServiceClient +from msrest.universal_http import ClientRequest +from knack.util import CLIError + +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 + + +API_VERSION = '7.2-preview' +MIGRATIONS_API_PATH = '/_apis/elm/migrations' +_SKIP_VALIDATION_POLICIES = { + 'none': 0, + 'activepullrequestcount': 1, + 'pullrequestdeltasize': 2, + 'agentpoolexists': 4, + 'maxfilesize': 8, + 'maxpullrequestsize': 16, + 'maxpushpacksize': 32, + 'maxreferencenamelength': 64, + 'maxreposize': 128, + 'targetrepositorydoesnotexist': 256, + 'all': 2147483647, +} +_NON_ACTIVE_STATES = { + 'succeeded', + 'failed', + 'suspended' +} +_ACTIVE_STAGES = { + 'queued', + 'validation', + 'synchronization', + 'cutover' +} +_URL_PATTERN = re.compile(r'^https?://[^\s]+$', re.IGNORECASE) + + +def list_migrations(include_inactive=False, project=None, organization=None, detect=None): + organization = _resolve_org_for_auth(organization, detect) + client = _get_service_client(organization) + url = _build_migration_url(organization) + if include_inactive: + url += '&includeInactiveMigrations=true' + project = _normalize_optional_text(project) + if project: + url += '&project={}'.format(quote_plus(project)) + return _send_request(client, 'GET', url) + + +def _normalize_optional_text(value): + if value is None: + return None + normalized = str(value).strip() + return normalized if normalized else None + + +def _parse_skip_validation(skip_validation): + if skip_validation is None: + return None + + if isinstance(skip_validation, int): + if skip_validation < 0: + raise CLIError('--skip-validation must be a non-negative integer or a comma-separated list ' + 'of policy names.') + return skip_validation + + normalized = _normalize_optional_text(skip_validation) + if normalized is None: + raise CLIError('--skip-validation cannot be empty. Provide a non-negative integer or a ' + 'comma-separated list of policy names.') + + if normalized.isdigit(): + return int(normalized) + + policies = [policy.strip() for policy in normalized.split(',')] + if any(not policy for policy in policies): + raise CLIError('--skip-validation contains an empty policy name. Provide a comma-separated ' + 'list such as "AgentPoolExists,MaxRepoSize".') + + mask = 0 + invalid_policies = [] + for policy in policies: + key = _normalize_state(policy) + if key not in _SKIP_VALIDATION_POLICIES: + invalid_policies.append(policy) + continue + value = _SKIP_VALIDATION_POLICIES[key] + if value == _SKIP_VALIDATION_POLICIES['all']: + return value + mask |= value + + if invalid_policies: + raise CLIError('--skip-validation contains unsupported policy name(s): {}. Supported values: {}. ' + 'You can also pass a non-negative integer bitmask.' + .format(', '.join(invalid_policies), + ', '.join([ + 'None', + 'ActivePullRequestCount', + 'PullRequestDeltaSize', + 'AgentPoolExists', + 'MaxFileSize', + 'MaxPullRequestSize', + 'MaxPushPackSize', + 'MaxReferenceNameLength', + 'MaxRepoSize', + 'TargetRepositoryDoesNotExist', + 'All' + ]))) + + return mask + + +def get_migration(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_migration_url(organization, repository_id) + return _send_request(client, 'GET', url) + + +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): + 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) + 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.') + + organization = _resolve_org_for_auth(organization, detect) + repository_id = _resolve_repository_id(repository_id) + + payload = { + 'targetRepository': target_repository, + 'targetOwnerUserId': target_owner_user_id, + 'validateOnly': bool(validate_only), + } + 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 + + client = _get_service_client(organization) + url = _build_migration_url(organization, repository_id) + return _send_request(client, 'POST', url, payload) + + +def pause_migration(repository_id=None, organization=None, detect=None): + return _update_migration(repository_id, organization, detect, status_requested='suspended') + + +def resume_migration(repository_id=None, validate_only=False, migration=False, organization=None, detect=None): + if validate_only and migration: + raise CLIError('Please specify only one of --validate-only or --migration.') + + organization = _resolve_org_for_auth(organization, detect) + migration_data = get_migration(repository_id=repository_id, organization=organization, detect=None) + + if migration and _is_validate_only_succeeded(migration_data): + return _promote_to_full_migration(migration_data, repository_id, organization) + + if _is_migration_active(migration_data): + state_text = _get_migration_state_text(migration_data) + raise CLIError('Migration is currently active ({}). Pause it first using ' + '"az devops migrations pause --repository-id " before resuming or changing mode.' + .format(state_text)) + + if _is_migration_terminal(migration_data): + status = _normalize_state(migration_data.get('status')) + 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.') + + validate_only_value = None + if validate_only: + validate_only_value = True + elif migration: + validate_only_value = False + + return _update_migration(repository_id, organization, None, + validate_only=validate_only_value, status_requested='active') + + +def schedule_cutover(repository_id=None, cutover_date=None, organization=None, detect=None): + if not cutover_date: + raise CLIError('--date must be specified.') + return _update_migration(repository_id, organization, detect, scheduled_cutover_date=cutover_date, + include_cutover=True) + + +def cancel_cutover(repository_id=None, organization=None, detect=None): + return _update_migration(repository_id, organization, detect, scheduled_cutover_date=None, + include_cutover=True) + + +def delete_migration(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_migration_url(organization, repository_id) + return _send_request(client, 'DELETE', url) + + +def _update_migration(repository_id, organization, detect, validate_only=None, + status_requested=None, scheduled_cutover_date=None, include_cutover=False): + 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) + + payload = {} + if validate_only is not None: + payload['validateOnly'] = bool(validate_only) + if status_requested is not None: + payload['statusRequested'] = status_requested + if include_cutover: + payload['scheduledCutoverDate'] = scheduled_cutover_date + return _send_request(client, 'PUT', url, payload) + + +def _resolve_repository_id(repository_id): + if not repository_id: + raise CLIError('--repository-id must be specified.') + if not is_uuid(repository_id): + raise CLIError('--repository-id must be a valid GUID.') + return repository_id + + +def _normalize_state(value): + if value is None: + return '' + normalized = str(value).strip().lower() + return normalized.replace(' ', '').replace('-', '').replace('_', '') + + +def _get_migration_state_text(migration): + status_requested = migration.get('statusRequested') + status = migration.get('status') + stage = migration.get('stage') + + parts = [] + if status_requested: + parts.append('statusRequested: {}'.format(status_requested)) + if status: + parts.append('status: {}'.format(status)) + if stage: + parts.append('stage: {}'.format(stage)) + + return ', '.join(parts) if parts else 'state unknown' + + +def _is_migration_active(migration): + if not isinstance(migration, dict): + return False + + status = _normalize_state(migration.get('statusRequested') or migration.get('status')) + if status: + return status not in _NON_ACTIVE_STATES + + stage = _normalize_state(migration.get('stage')) + if stage: + return stage in _ACTIVE_STAGES + + return False + + +def _is_migration_terminal(migration): + if not isinstance(migration, dict): + return False + status = _normalize_state(migration.get('status')) + return status in ('succeeded', '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') + + +def _promote_to_full_migration(migration_data, repository_id, organization): + del migration_data + return _update_migration(repository_id, organization, detect=None, + validate_only=False, status_requested='active') + + +def _resolve_org_for_auth(organization, detect): + return resolve_instance(detect=detect, organization=organization) + + +def _build_migration_url(base_url, repository_id=None): + url = base_url.rstrip('/') + MIGRATIONS_API_PATH + if repository_id: + url += '/{}'.format(repository_id) + return url + '?api-version=' + API_VERSION + + +def _get_service_client(organization): + config = Configuration(base_url=None) + config.add_user_agent('devOpsCli/{}'.format(VERSION)) + config.retry_policy.policy.status_forcelist = [] + connection = get_connection(organization) + return ServiceClient(creds=connection._creds, config=config) # pylint: disable=protected-access + + +def _send_request(client, method, url, content=None): + request = ClientRequest(method=method, url=url) + headers = { + 'Content-Type': 'application/json; charset=utf-8', + 'Accept': 'application/json;api-version=' + API_VERSION + } + response = client.send(request=request, headers=headers, content=content) + if response.status_code < 200 or response.status_code >= 300: + error_detail = '' + try: + body = response.json() + 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)) + + content_type = response.headers.get('Content-Type') if response.headers else None + if content_type and 'json' in content_type: + return response.json() + return {} diff --git a/azure-devops/azext_devops/tests/latest/common/test_arguments.py b/azure-devops/azext_devops/tests/latest/common/test_arguments.py index 92b913f48..e0dfa4fad 100644 --- a/azure-devops/azext_devops/tests/latest/common/test_arguments.py +++ b/azure-devops/azext_devops/tests/latest/common/test_arguments.py @@ -3,8 +3,9 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- +import json import unittest -from azext_devops.dev.common.arguments import should_detect +from azext_devops.dev.common.arguments import should_detect, convert_date_string_to_iso8601 class TestArgumentsMethods(unittest.TestCase): @@ -14,5 +15,99 @@ def test_should_detect(self): self.assertEqual(should_detect(None), True) +class TestConvertDateStringToIso8601(unittest.TestCase): + + def _assert_valid(self, input_value, expected_substring=None): + result = convert_date_string_to_iso8601(input_value) + self.assertIsInstance(result, str, 'Expected string, got {}'.format(type(result))) + # Must be JSON-serializable as a string field + json.dumps({'date': result}) + if expected_substring: + self.assertIn(expected_substring, result) + return result + + # --- timezone-aware inputs (the original bug) --- + + def test_utc_z_suffix(self): + result = self._assert_valid('2026-03-24T20:55:00Z', '2026-03-24T20:55:00') + self.assertIn('+00:00', result) + + def test_utc_explicit_offset(self): + result = self._assert_valid('2026-03-24T20:55:00+00:00', '2026-03-24T20:55:00') + self.assertIn('+00:00', result) + + def test_negative_offset(self): + result = self._assert_valid('2026-03-24T20:55:00-07:00') + self.assertIn('-07:00', result) + + def test_positive_offset(self): + result = self._assert_valid('2026-03-24T20:55:00+05:30') + self.assertIn('+05:30', result) + + def test_milliseconds_with_z(self): + result = self._assert_valid('2026-03-24T20:55:00.000Z') + self.assertIn('+00:00', result) + + # --- timezone-naive inputs (should get local tz applied) --- + + def test_naive_datetime(self): + self._assert_valid('2026-03-24T20:55:00') + + def test_date_only(self): + result = self._assert_valid('2026-03-24') + self.assertIn('2026-03-24T00:00:00', result) + + def test_human_readable_date(self): + result = self._assert_valid('March 24, 2026') + self.assertIn('2026-03-24', result) + + def test_slash_date(self): + result = self._assert_valid('03/24/2026') + self.assertIn('2026-03-24', result) + + # --- invalid inputs --- + + def test_empty_string_raises(self): + with self.assertRaises(ValueError): + convert_date_string_to_iso8601('') + + def test_whitespace_raises(self): + with self.assertRaises(ValueError): + convert_date_string_to_iso8601(' ') + + def test_garbage_string_raises(self): + with self.assertRaises(ValueError): + convert_date_string_to_iso8601('not-a-date') + + def test_null_string_raises(self): + with self.assertRaises(ValueError): + convert_date_string_to_iso8601('null') + + def test_none_raises(self): + with self.assertRaises((ValueError, TypeError)): + convert_date_string_to_iso8601(None) + + # --- argument name in error message --- + + def test_error_includes_argument_name(self): + with self.assertRaises(ValueError) as ctx: + convert_date_string_to_iso8601('bad', argument='scheduled-cutover-date') + self.assertIn('scheduled-cutover-date', str(ctx.exception)) + + def test_error_without_argument_name(self): + with self.assertRaises(ValueError) as ctx: + convert_date_string_to_iso8601('bad') + self.assertIn('bad', str(ctx.exception)) + + # --- JSON payload round-trip (simulates what _send_request does) --- + + def test_json_payload_roundtrip(self): + result = convert_date_string_to_iso8601('2026-03-24T20:55:00Z') + payload = {'scheduledCutoverDate': result} + serialized = json.dumps(payload) + deserialized = json.loads(serialized) + self.assertEqual(deserialized['scheduledCutoverDate'], result) + + if __name__ == '__main__': unittest.main() \ No newline at end of file diff --git a/azure-devops/azext_devops/tests/latest/migration/__init__.py b/azure-devops/azext_devops/tests/latest/migration/__init__.py new file mode 100644 index 000000000..34913fb39 --- /dev/null +++ b/azure-devops/azext_devops/tests/latest/migration/__init__.py @@ -0,0 +1,4 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- diff --git a/azure-devops/azext_devops/tests/latest/migration/test_migration.py b/azure-devops/azext_devops/tests/latest/migration/test_migration.py new file mode 100644 index 000000000..04ce932c7 --- /dev/null +++ b/azure-devops/azext_devops/tests/latest/migration/test_migration.py @@ -0,0 +1,537 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import unittest + +try: + # Attempt to load mock (works on Python 3.3 and above) + from unittest.mock import patch +except ImportError: + # Attempt to load mock (works on Python version below 3.3) + from mock import patch + +from knack.util import CLIError + +from azext_devops.dev.migration.migration import (list_migrations, + create_migration, + cancel_cutover, + resume_migration) + + +class TestMigrationCommands(unittest.TestCase): + + _TEST_ORG = 'https://elm.contoso.com/elmo1' + + 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, \ + patch('azext_devops.dev.migration.migration._send_request') as mock_send: + mock_send.return_value = {} + mock_resolve.return_value = self._TEST_ORG + + list_migrations(organization=self._TEST_ORG, detect=False) + + mock_resolve.assert_called_once_with(detect=False, organization=self._TEST_ORG) + args = mock_send.call_args[0] + self.assertEqual(args[1], 'GET') + self.assertTrue(args[2].startswith(self._TEST_ORG.rstrip('/'))) + self.assertIn('/_apis/elm/migrations', args[2]) + self.assertNotIn('includeInactiveMigrations', args[2]) + + def test_list_migrations_include_inactive(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 + + list_migrations(include_inactive=True, organization=self._TEST_ORG, detect=False) + + args = mock_send.call_args[0] + self.assertEqual(args[1], 'GET') + self.assertIn('includeInactiveMigrations=true', args[2]) + + def test_list_migrations_with_project_filter(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 + + list_migrations(project='MyProject', organization=self._TEST_ORG, detect=False) + + args = mock_send.call_args[0] + self.assertEqual(args[1], 'GET') + self.assertIn('project=MyProject', args[2]) + + def test_list_migrations_with_project_filter_url_encoded(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 + + list_migrations(project='My Project', organization=self._TEST_ORG, detect=False) + + args = mock_send.call_args[0] + self.assertEqual(args[1], 'GET') + self.assertIn('project=My+Project', args[2]) + + def test_create_migration_payload_defaults_validate_only_false(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='GeoffCoxMSFT', + agent_pool='MigrationPool', + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + self.assertFalse(payload['validateOnly']) + + 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', + organization=self._TEST_ORG, + detect=False + ) + self.assertIn('--target-repository must be specified', str(ctx.exception)) + + def test_create_migration_fails_with_invalid_target_repository_url(self): + with self.assertRaises(CLIError) as ctx: + create_migration( + repository_id='00000000-0000-0000-0000-000000000000', + target_repository='ghe.example.com/OrgName/RepoName', + target_owner_user_id='GeoffCoxMSFT', + agent_pool='MigrationPool', + organization=self._TEST_ORG, + detect=False + ) + self.assertIn('must be a valid URL', str(ctx.exception)) + + def test_create_migration_without_agent_pool(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='GeoffCoxMSFT', + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + self.assertNotIn('agentPoolName', payload) + + 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, \ + 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='GeoffCoxMSFT', + validate_only=True, + cutover_date='2030-12-31T11:59:00Z', + agent_pool='MigrationPool', + skip_validation=2147483647, + organization=self._TEST_ORG, + detect=False + ) + + 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['skipValidation'], 2147483647) + + def test_create_migration_skip_validation_accepts_integer_string(self): + with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ + patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \ + patch('azext_devops.dev.migration.migration._send_request') as mock_send: + mock_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='GeoffCoxMSFT', + skip_validation='2147483647', + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + self.assertEqual(payload['skipValidation'], 2147483647) + + def test_create_migration_skip_validation_accepts_policy_names(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='GeoffCoxMSFT', + skip_validation='PullRequestDeltaSize, AgentPoolExists', + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + self.assertEqual(payload['skipValidation'], 6) + + def test_create_migration_skip_validation_accepts_all_name(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='GeoffCoxMSFT', + skip_validation='All', + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + self.assertEqual(payload['skipValidation'], 2147483647) + + def test_create_migration_skip_validation_rejects_invalid_policy_name(self): + 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='GeoffCoxMSFT', + skip_validation='BogusPolicy', + organization=self._TEST_ORG, + detect=False + ) + self.assertIn('unsupported policy name', str(ctx.exception)) + + def test_create_migration_skip_validation_rejects_empty_policy_name(self): + 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='GeoffCoxMSFT', + skip_validation='AgentPoolExists,,MaxRepoSize', + organization=self._TEST_ORG, + detect=False + ) + self.assertIn('contains an empty policy name', str(ctx.exception)) + + def test_create_migration_empty_agent_pool_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._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='GeoffCoxMSFT', + agent_pool=' ', + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + self.assertNotIn('agentPoolName', payload) + + def test_create_migration_omits_none_skip_validation(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='GeoffCoxMSFT', + agent_pool='MigrationPool', + skip_validation=None, + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + self.assertNotIn('skipValidation', payload) + + def test_create_migration_trims_agent_pool(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='GeoffCoxMSFT', + agent_pool=' MigrationPool ', + skip_validation=42, + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + self.assertEqual(payload['agentPoolName'], 'MigrationPool') + self.assertEqual(payload['skipValidation'], 42) + + def test_create_migration_passes_target_repository_to_api(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.com/OrgName/RepoName', + target_owner_user_id='GeoffCoxMSFT', + agent_pool='MigrationPool', + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + self.assertEqual(payload['targetRepository'], 'https://example.com/OrgName/RepoName') + + def test_create_migration_validate_only_flag_sends_true(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='GeoffCoxMSFT', + validate_only=True, + agent_pool='MigrationPool', + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + self.assertTrue(payload['validateOnly']) + + def test_create_migration_agent_pool_always_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._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='GeoffCoxMSFT', + agent_pool='MigrationPool', + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + self.assertEqual(payload['agentPoolName'], 'MigrationPool') + + def test_cancel_cutover_sets_null(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 + + cancel_cutover( + repository_id='00000000-0000-0000-0000-000000000000', + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + self.assertIsNone(payload['scheduledCutoverDate']) + + def test_resume_rejects_both_flags(self): + with self.assertRaises(CLIError): + resume_migration(repository_id='00000000-0000-0000-0000-000000000000', + validate_only=True, migration=True, + organization=self._TEST_ORG, detect=False) + + def test_resume_fails_when_active(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': 'synchronization'} + 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('az devops migrations pause', 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: + mock_get.return_value = {'statusRequested': 'Active', 'stage': 'validation'} + 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('statusRequested: Active', str(ctx.exception)) + + def test_resume_sets_validate_only(self): + with patch('azext_devops.dev.migration.migration.get_migration') as mock_get, \ + patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ + 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': 'suspended'} + mock_resolve.return_value = self._TEST_ORG + + resume_migration(repository_id='00000000-0000-0000-0000-000000000000', + validate_only=True, + organization=self._TEST_ORG, detect=False) + + payload = mock_send.call_args[0][3] + self.assertTrue(payload['validateOnly']) + self.assertEqual(payload['statusRequested'], 'active') + + def test_resume_sets_migration(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': 'suspended'} + mock_resolve.return_value = self._TEST_ORG + + resume_migration(repository_id='00000000-0000-0000-0000-000000000000', + migration=True, + organization=self._TEST_ORG, detect=False) + + payload = mock_send.call_args[0][3] + self.assertFalse(payload['validateOnly']) + self.assertEqual(payload['statusRequested'], 'active') + + def test_resume_without_flags_preserves_mode(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': 'failed'} + mock_resolve.return_value = self._TEST_ORG + + resume_migration(repository_id='00000000-0000-0000-0000-000000000000', + organization=self._TEST_ORG, detect=False) + + payload = mock_send.call_args[0][3] + self.assertNotIn('validateOnly', payload) + self.assertEqual(payload['statusRequested'], 'active') + + def test_resume_migration_promotes_validate_only_succeeded(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': 'succeeded', + '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, \ + 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': 'succeeded', + 'validateOnly': True, + 'targetRepository': 'https://ghe.example.com/org/repo', + 'targetOwnerUserId': 'testuser', + 'agentPoolName': 'MyPool', + 'scheduledCutoverDate': '2030-06-01T00:00:00Z', + 'skipValidation': 2147483647, + } + mock_resolve.return_value = self._TEST_ORG + + resume_migration(repository_id='00000000-0000-0000-0000-000000000000', + migration=True, + organization=self._TEST_ORG, detect=False) + + payload = mock_send.call_args[0][3] + self.assertEqual(payload['validateOnly'], False) + self.assertEqual(payload['statusRequested'], 'active') + self.assertEqual(set(payload.keys()), {'validateOnly', 'statusRequested'}) + self.assertNotIn('agentPoolName', payload) + self.assertNotIn('scheduledCutoverDate', payload) + self.assertNotIn('targetRepository', payload) + self.assertNotIn('targetOwnerUserId', payload) + self.assertNotIn('skipValidation', payload) + + def test_resume_succeeded_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': 'succeeded', '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, \ + patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve: + mock_get.return_value = {'status': 'succeeded', '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)) + + +if __name__ == '__main__': + unittest.main() diff --git a/azure-devops/azure_devops-1.0.3/azext_devops/devops_sdk/__init__.py b/azure-devops/azure_devops-1.0.3/azext_devops/devops_sdk/__init__.py new file mode 100644 index 000000000..34913fb39 --- /dev/null +++ b/azure-devops/azure_devops-1.0.3/azext_devops/devops_sdk/__init__.py @@ -0,0 +1,4 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- diff --git a/doc/elm_migrations_tsg.md b/doc/elm_migrations_tsg.md new file mode 100644 index 000000000..c28eb04e3 --- /dev/null +++ b/doc/elm_migrations_tsg.md @@ -0,0 +1,478 @@ +# ELM Migrations — End-to-End Guide & Troubleshooting (TSG) + +Migrate Git repositories from Azure DevOps to GitHub using the `az devops migrations` CLI commands. + +> **Shell note:** Examples use `\` for line continuation (bash/zsh). In PowerShell, use backtick `` ` `` instead, or put the entire command on one line. + +--- + +## 1. Prerequisites & Setup + +### 1.1 Install Azure CLI (if not already installed) + +Follow [Install the Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli). + +Verify it's installed: + +```powershell +az --version +``` + +### 1.2 Install the ELM extension from the wheel file + +You'll receive a `.whl` file (e.g., `azure_devops-1.0.3-py2.py3-none-any.whl`). This is the Azure DevOps CLI extension package that contains the migration commands. + +```powershell +# Remove any existing version first (ignore errors if not installed) +az extension remove -n azure-devops + +# Install from the wheel file (use the actual path to your .whl file) +az extension add --source ./azure_devops-1.0.3-py2.py3-none-any.whl -y + +# Verify installation — you should see name: "azure-devops" and a version +az extension show -n azure-devops --query "{name:name,version:version}" -o json +``` + +### 1.3 Sign in + +```powershell +# Option A: Azure AD / Entra ID (recommended) +az login + +# Option B: Personal Access Token (needs "Full access" or at minimum Code Read/Write scope) +az devops login +``` + +### 1.4 Set your default org (recommended) + +This saves you from typing `--org` on every single command: + +```powershell +az devops configure -d organization=https://dev.azure.com/ +``` + +If your local git remote points to a different org, add `--detect false` to migration commands to prevent auto-detect from choosing the wrong org. + +### 1.5 Verify your config + +```powershell +az devops configure -l +``` + +You should see your org URL under `organization`. If you see a wrong URL (e.g., `codedev.ms` or an old org URL), re-run step 1.4 with the correct URL. + +--- + +## 2. Understand the Migration Lifecycle + +A migration moves through these **stages**: + +``` +Queued → Validation → Synchronization → Cutover → Migrated +``` + +And has one of these **statuses**: + +| Status | Meaning | +|---|---| +| `Active` | Migration is running (in one of the stages above) | +| `Succeeded` | Migration completed successfully | +| `Failed` | Migration encountered an error (can be resumed) | +| `Suspended` | Migration was paused by the user (can be resumed) | + +### Recommended workflow + +The safest approach is **validate first, then migrate**: + +``` +Create (validate-only) → Check status → Resume (--migration) → Monitor → Schedule cutover → Done +``` + +--- + +## 3. End-to-End Walkthrough + +### What you'll need before starting + +| Item | Example | How to get it | +|---|---|---| +| Azure DevOps org URL | `https://dev.azure.com/myorg` | Your ADO org URL — used for `--org` and to look up repo GUIDs | +| 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 | + +### 3.1 Get the source repository GUID from Azure DevOps + +Every migration command uses a repository GUID (not the repo name). Get it from your ADO org: + +```powershell +az repos show --org https://dev.azure.com/myorg/ --project MyProject --repository my-repo --query id -o tsv +``` + +Example output: + +```text +b3e18946-5b39-40ca-8e2f-d0eb683d8a85 +``` + +Save this GUID — you'll use it in every command below. + +### 3.2 (Optional) Check for existing migrations + +See if any migrations already exist for your org: + +```powershell +# Active migrations only +az devops migrations list --detect false + +# Filter by project name or ID +az devops migrations list --detect false --project MyProject + +# All migrations including completed/failed/suspended +az devops migrations list --detect false --include-inactive +``` + +### 3.3 Create a validate-only migration + +Start with validation to catch any issues **before** moving data. This runs pre-migration checks without transferring any code or PRs: + +```powershell +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 \ + --validate-only +``` + +The command returns the migration details as JSON. The migration begins immediately in the background. + +> **Tip:** If you're confident and want to start a full migration right away (skip validate-only), omit the `--validate-only` flag. + +**Optional parameters you can add at creation time:** + +| Parameter | What it does | Example | +|---|---|---| +| `--cutover-date` | Pre-schedule the final cutover date | `--cutover-date 2030-12-31T11:59:00Z` | +| `--skip-validation` | Skip specific validation checks | `--skip-validation ActivePullRequestCount,PullRequestDeltaSize` | + +### 3.4 Monitor migration status + +Check status anytime — run this as often as you need: + +```powershell +az devops migrations status --detect false --repository-id b3e18946-5b39-40ca-8e2f-d0eb683d8a85 +``` + +For the full JSON response (useful for debugging): + +```powershell +az devops migrations status --detect false --repository-id b3e18946-5b39-40ca-8e2f-d0eb683d8a85 -o json +``` + +**How to read the output:** + +| You see this | It means | What to do next | +|---|---|---| +| `status: Active`, `stage: Validation` | Validation is in progress | Wait, check again later | +| `status: Active`, `stage: Synchronization` | Code/PRs are syncing | Wait, check again later | +| `status: Succeeded` | Current phase completed | If validate-only: go to step 3.5. If migration: go to step 3.6 | +| `status: Failed` | Something went wrong | Check the error in `-o json` output, fix the issue, then resume (step 4) | +| `status: Suspended` | You paused it | Resume when ready (step 3.5) | + +### 3.5 Promote from validate-only to full migration + +**When to do this:** After step 3.4 shows `status: Succeeded` (validation passed). + +Resume in migration mode: + +```powershell +# Promote validate-only success to full migration (this starts data movement) +az devops migrations resume --detect false --repository-id b3e18946-5b39-40ca-8e2f-d0eb683d8a85 --migration +``` + +Under the hood, this updates the existing migration (PUT) with: + +- `validateOnly=false` +- `statusRequested=active` + +No new migration is created. + +> **If you get an active-state error:** +> +> `Migration is currently active (...). Pause it first using "az devops migrations pause --repository-id " before resuming or changing mode.` +> +> run: + +```powershell +az devops migrations pause --detect false --repository-id b3e18946-5b39-40ca-8e2f-d0eb683d8a85 +``` + +then retry `resume --migration`. + +After this, monitor with step 3.4 until `stage: Synchronization` is running. + +### 3.6 Schedule cutover + +Once synchronization is running and you're ready to finalize the migration: + +```powershell +az devops migrations cutover set --detect false \ + --repository-id b3e18946-5b39-40ca-8e2f-d0eb683d8a85 --date 2030-12-31T11:59:00Z +``` + +> **Date format:** Must be ISO 8601. Examples: `2030-12-31T11:59:00Z`, `2030-06-15T08:00:00-07:00` + +Changed your mind? Cancel the scheduled cutover: + +```powershell +az devops migrations cutover cancel --detect false --repository-id b3e18946-5b39-40ca-8e2f-d0eb683d8a85 +``` + +### 3.7 Verify completion + +After cutover completes, confirm the migration finished: + +```powershell +az devops migrations status --detect false --repository-id b3e18946-5b39-40ca-8e2f-d0eb683d8a85 +``` + +**Success looks like:** `status: Succeeded`, `stage: Migrated`. + +At this point your repository has been fully migrated from Azure DevOps to GitHub. Verify the target repo in GitHub has all your code, branches, and pull requests. + +### 3.8 (If needed) Abandon a migration + +If something went wrong and you want to delete the migration entirely and start over: + +```powershell +az devops migrations abandon --detect false --repository-id b3e18946-5b39-40ca-8e2f-d0eb683d8a85 +``` + +> **Warning:** This permanently deletes the migration record. You will be prompted to confirm. After abandoning, you can create a new migration for the same repository. + +--- + +## 4. Other Scenarios + +### Pause and resume without changing mode + +If you need to temporarily stop a migration and restart it in the same mode: + +```powershell +# Pause +az devops migrations pause --detect false --repository-id + +# Resume (keeps whatever mode — validate-only or full migration — it was in) +az devops migrations resume --detect false --repository-id +``` + +### Switch back to validate-only after starting full migration + +Changed your mind after promoting to full migration? You can go back: + +```powershell +az devops migrations pause --detect false --repository-id +az devops migrations resume --detect false --repository-id --validate-only +``` + +### Resume a failed migration + +If a migration fails (you'll see `status: Failed` in the status output), you can resume it directly — no pause needed since it's already stopped: + +```powershell +# Resume in the same mode +az devops migrations resume --detect false --repository-id + +# Or resume and switch mode at the same time +az devops migrations resume --detect false --repository-id --migration +az devops migrations resume --detect false --repository-id --validate-only +``` + +--- + +## 5. Complete Command & Parameter Reference + +| Command | Required Params | Optional Params | HTTP | Description | +|---|---|---|---|---| +| `list` | `--org` | `--include-inactive`, `--detect` | GET | List migrations. By default only active ones. | +| `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. | +| `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. | +| `cutover cancel` | `--org`, `--repository-id` | `--detect` | PUT | Cancel a scheduled cutover. | +| `abandon` | `--org`, `--repository-id` | `--detect` | DELETE | Permanently delete a migration (prompts for confirmation). | + +### 5.1 Parameter Details + +| Parameter | Type | Used By | Description | +|---|---|---|---| +| `--org` | URL | All | Azure DevOps org URL (e.g., `https://dev.azure.com/myorg`). Can be set as default. | +| `--repository-id` | GUID | All except `list` | Azure Repos repository GUID. Get from `az repos show --query id`. | +| `--target-repository` | URL | `create` | Target repository URL (e.g., `https://example.ghe.com/OrgName/RepoName`). Validated by the server. | +| `--target-repository` | URL | `create` | Target repository URL (e.g., `https://example.ghe.com/OrgName/RepoName`). Must start with `http://` or `https://`. | +| `--target-owner-user-id` | string | `create` | Target repository owner user ID. | +| `--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`. | +| `--cutover-date` | ISO 8601 | `create` | Pre-schedule cutover at creation time. E.g., `2030-12-31T11:59:00Z`. | +| `--date` | ISO 8601 | `cutover set` | Schedule cutover date/time. E.g., `2030-12-31T11:59:00Z`. | +| `--skip-validation` | string or int | `create` | Validation policies to skip. Accepts either comma-separated policy names (recommended) or a non-negative integer bitmask. | +| `--include-inactive` | flag | `list` | Include completed, failed, and suspended migrations. | +| `--detect` | flag | All | Auto-detect org from git remote (default: `true`). Use `--detect false` to disable. | + +## 6. Common Pitfalls + +| Pitfall | Symptom | Fix | +|---|---|---| +| **Auto-detect overrides `--org`** | Requests go to wrong host (e.g., `codedev.ms`) | Add `--detect false` or run from a non-ADO-repo directory | +| **Stale default org in config** | Requests go to old/dev URL (e.g., `codedev.ms`) | Run `az devops configure -d organization=https://dev.azure.com/` to update | +| **Resume on an active migration** | Error: "Migration is active..." | Pause first with `az devops migrations pause`, then resume | +| **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 ` | +| **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` | + +## 7. Common Errors and Fixes + +### Authentication Errors (401 / 403) + +**Symptom:** `Request failed with status 401` or `403`. + +**Fix:** + +1. Run `az login` (AAD) or `az devops login` (PAT). +1. Ensure the token/account has permission to the organization. +1. Verify `--org` points to the correct Azure DevOps org URL. + +### 404 Not Found + +**Symptom:** `Request failed with status 404`. + +**Fix:** + +1. Verify `--org` is correct (e.g., `https://dev.azure.com/myorg`). +1. Verify the `--repository-id` is a valid GUID that exists in the organization. + +### 400 Bad Request + +**Symptom:** `Request failed with status 400` or `JsonReaderException`. + +**Fix:** + +1. Check date values are valid ISO 8601 strings (e.g., `2030-12-31T11:59:00Z`). +1. Ensure `--target-repository` is a valid URL. +1. Ensure `--agent-pool` matches a pool name the service recognizes. +1. Ensure `--skip-validation` uses supported policy names or a non-negative integer bitmask. + +### skip-validation examples + +Recommended form using policy names: + +```powershell +az devops migrations create --detect false --repository-id --target-repository --target-owner-user-id --skip-validation AgentPoolExists,MaxRepoSize +``` + +Advanced form using integer bitmask: + +```powershell +az devops migrations create --detect false --repository-id --target-repository --target-owner-user-id --skip-validation 132 +``` + +Supported policy names: + +- `None` +- `ActivePullRequestCount` +- `PullRequestDeltaSize` +- `AgentPoolExists` +- `MaxFileSize` +- `MaxPullRequestSize` +- `MaxPushPackSize` +- `MaxReferenceNameLength` +- `MaxRepoSize` +- `TargetRepositoryDoesNotExist` +- `All` + +### Promote validate-only does not start + +**Symptom:** `resume --migration` does not proceed, or returns a state error. + +**Fix:** + +1. Confirm current state first: + +```powershell +az devops migrations status --detect false --repository-id -o json +``` + +1. If migration is active, pause then retry: + +```powershell +az devops migrations pause --detect false --repository-id +az devops migrations resume --detect false --repository-id --migration +``` + +1. If migration already succeeded as full migration, abandon and recreate if needed. + +### 406 Not Acceptable + +**Symptom:** `Request failed with status 406`. + +**Fix:** + +1. Verify `--org` is correct. +1. Confirm you are using the latest CLI extension version. +1. Contact your admin if it persists. + +### 500 Internal Server Error / Retries Exhausted + +**Symptom:** `Max retries exceeded with url: ... (Caused by ResponseError('too many 500 error responses'))`. + +**Fix:** + +1. Check if the requests are going to the **wrong host** (e.g., `codedev.ms` instead of your org URL). + - Run `az devops configure -l` to check your default org. + - Fix with `az devops configure -d organization=https://dev.azure.com/`. + - Or pass `--org --detect false` explicitly. +1. If the correct host is being used, the service may be temporarily unavailable — retry later or contact your admin. + +## 8. Useful Commands + +```powershell +# Check extension version +az extension show -n azure-devops --query "{name:name,version:version}" -o json + +# Set default org (so you can omit --org) +az devops configure -d organization=https://dev.azure.com/ + +# View current defaults +az devops configure -l + +# Install/update the extension from a wheel file +az extension add --source ./azure_devops-1.0.3-py2.py3-none-any.whl -y + +# Uninstall the extension +az extension remove -n azure-devops + +# Get repo GUID from ADO +az repos show --org https://dev.azure.com// --project --repository --query id -o tsv + +# List all migrations (including inactive) +az devops migrations list --include-inactive + +# Get full JSON output (instead of table) +az devops migrations status --repository-id -o json +``` + +## 9. Output Formats + +| Flag | Format | Best for | +|---|---|---| +| (default / `--output table`) | Table with key columns | Quick overview | +| `--output json` | Full JSON response from API | Scripting, debugging, seeing all fields | +| `--output tsv` | Tab-separated values | Piping to other commands | +| `--query ` | Filtered output | Extracting specific fields (e.g., `--query status`) | diff --git a/doc/getting_started.md b/doc/getting_started.md index f3b38186a..0d30c05c4 100644 --- a/doc/getting_started.md +++ b/doc/getting_started.md @@ -100,3 +100,7 @@ Global Arguments --- ---------- --------- --------- ------------- ----------------------- -------------- -------------------------- ------- 1 20190116.2 completed succeeded 1 Contoso.CI master 2019-01-16 17:29:07.497795 manual ``` + +## Enterprise live migrations + +If you are using enterprise live migrations, see the guide at [migrations.md](migrations.md). diff --git a/doc/migrations.md b/doc/migrations.md new file mode 100644 index 000000000..85d31271e --- /dev/null +++ b/doc/migrations.md @@ -0,0 +1,233 @@ +# Enterprise live migrations (ELM) + +The `az devops migrations` command group manages enterprise live migrations for repositories. + +## Prerequisites + +- Azure DevOps CLI with the Azure DevOps extension installed. +- Sign in using `az login` or `az devops login`. +- Configure a default org once to avoid repeating `--org`: + +```bash +az devops configure --defaults organization=https://dev.azure.com/myorg +``` + +- You can still override per command with `--org`. +- If your current git remote points to another org, add `--detect false` to avoid auto-detect choosing the wrong organization. + +## Migration lifecycle model + +Typical active stages: + +`queued -> validation -> synchronization -> cutover` + +Key fields in `status` output: + +- `statusRequested`: Desired state requested by the CLI (for example, `active`, `suspended`). +- `status`: Service-reported status (for example, `succeeded`, `failed`). +- `stage`: Current running stage while active. + +Use all three fields together when troubleshooting state transitions. + +## Required inputs + +- `--repository-id` is the Azure Repos repository GUID. +- `--target-repository` is the target repository URL. +- `--target-owner-user-id` is required for create. +- `--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. + +Validation enforced by the CLI: + +- `--target-repository` must start with `http://` or `https://`. +- `--repository-id` must be a valid GUID. +- `--skip-validation` policy names must be recognized values. + +### How to find `--repository-id` + +```bash +az repos show --repository MyRepo --project MyProject --query id -o tsv +``` + +## Command reference + +- `list`: List migrations for the org. Use `--include-inactive` to include completed/failed/suspended migrations. Use `--project` to filter by project name or ID. +- `status`: Show migration status for a repository GUID. +- `create`: Create a migration. Use `--validate-only` for pre-migration checks only. +- `pause`: Pause an active migration. +- `resume`: Resume a stopped (paused, failed) migration. Optional flags are `--validate-only` (resume in validate-only mode) and `--migration` (promote a succeeded validate-only migration to full migration by setting `validateOnly=false` and `statusRequested=active`). If a migration is currently active, pause it before resuming or switching mode. +- `cutover set` / `cutover cancel`: Schedule or cancel cutover. +- `abandon`: Abandon and delete a migration. + +## Status fields + +- `statusRequested`: Desired state requested by client. +- `status`: Current overall status reported by service. +- `stage`: Current active stage (for example, validation, synchronization, cutover). + +If a command is blocked, inspect all three fields from `status` output to understand whether the migration is active, terminal, or promotable. + +## Promotion semantics + +When validation succeeds in validate-only mode, promotion does not create a new migration. + +Promotion command: + +```bash +az devops migrations resume --org https://dev.azure.com/myorg \ + --repository-id 00000000-0000-0000-0000-000000000000 --migration +``` + +What this does: + +- Sends an update (PUT) to the existing migration. +- Sets `validateOnly=false`. +- Sets `statusRequested=active`. + +## Common workflows + +### List migrations + +```bash +az devops migrations list --org https://dev.azure.com/myorg +``` + +### List migrations for a project + +```bash +az devops migrations list --org https://dev.azure.com/myorg --project MyProject +``` + +### List all migrations including inactive + +```bash +az devops migrations list --org https://dev.azure.com/myorg --include-inactive +``` + +### Check migration status + +```bash +az devops migrations status --org https://dev.azure.com/myorg \ + --repository-id 00000000-0000-0000-0000-000000000000 +``` + +### Create a migration + +```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 \ + --target-owner-user-id OwnerId \ + --agent-pool MigrationPool +``` + +### Create a validate-only migration + +```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 \ + --target-owner-user-id OwnerId \ + --agent-pool MigrationPool \ + --validate-only +``` + +### Create a migration with skip-validation + +Recommended form using policy names: + +```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 \ + --target-owner-user-id OwnerId \ + --skip-validation AgentPoolExists,MaxRepoSize +``` + +Advanced form using integer bitmask: + +```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 \ + --target-owner-user-id OwnerId \ + --skip-validation 132 +``` + +### Pause and resume + +```bash +az devops migrations pause --org https://dev.azure.com/myorg \ + --repository-id 00000000-0000-0000-0000-000000000000 + +az devops migrations resume --org https://dev.azure.com/myorg \ + --repository-id 00000000-0000-0000-0000-000000000000 + +az devops migrations resume --org https://dev.azure.com/myorg \ + --repository-id 00000000-0000-0000-0000-000000000000 --validate-only + +az devops migrations resume --org https://dev.azure.com/myorg \ + --repository-id 00000000-0000-0000-0000-000000000000 --migration +``` + +### Promote a succeeded validate-only migration + +After validation succeeds, run: + +```bash +az devops migrations resume --org https://dev.azure.com/myorg \ + --repository-id 00000000-0000-0000-0000-000000000000 --migration +``` + +This promotes the same migration record (no new migration is created). + +If you receive an active-state error, pause first and retry: + +```bash +az devops migrations pause --org https://dev.azure.com/myorg \ + --repository-id 00000000-0000-0000-0000-000000000000 +``` + +### Schedule or cancel cutover + +```bash +az devops migrations cutover set --org https://dev.azure.com/myorg \ + --repository-id 00000000-0000-0000-0000-000000000000 \ + --date 2030-12-31T11:59:00Z + +az devops migrations cutover cancel --org https://dev.azure.com/myorg \ + --repository-id 00000000-0000-0000-0000-000000000000 +``` + +### Abandon a migration + +```bash +az devops migrations abandon --org https://dev.azure.com/myorg \ + --repository-id 00000000-0000-0000-0000-000000000000 +``` + +## Troubleshooting + +- Error: migration is active. + Pause first, then retry resume or mode changes. + +```bash +az devops migrations pause --org https://dev.azure.com/myorg \ + --repository-id 00000000-0000-0000-0000-000000000000 +``` + +- Error: validation already succeeded. + Use `resume --migration` to promote instead of re-running validate-only. + +- Error: `--target-repository` must be valid. + Ensure it is a fully qualified URL starting with `http://` or `https://`. + +- Error: `--skip-validation` contains unsupported policy names. + Use supported names such as `AgentPoolExists`, `MaxRepoSize`, or pass a non-negative integer bitmask. + +- Error: requests are sent to the wrong org. + Use `--org --detect false`, and verify defaults via `az devops configure -l`. + +- Error: migration already succeeded. + Use `abandon` to reset before creating a new migration.