From 1e90bf34a44f6409f043e4ab1db1391cfb4b67f2 Mon Sep 17 00:00:00 2001 From: Bhuvan Shah Date: Wed, 25 Feb 2026 12:18:48 -0800 Subject: [PATCH 01/32] Add ELM migrations commands --- README.md | 3 + azure-devops/azext_devops/__init__.py | 6 +- .../azext_devops/dev/migration/__init__.py | 8 + .../azext_devops/dev/migration/_format.py | 46 +++++ .../azext_devops/dev/migration/_help.py | 94 +++++++++ .../azext_devops/dev/migration/arguments.py | 39 ++++ .../azext_devops/dev/migration/commands.py | 31 +++ .../azext_devops/dev/migration/migration.py | 189 ++++++++++++++++++ .../tests/latest/migration/__init__.py | 4 + .../tests/latest/migration/test_migration.py | 124 ++++++++++++ doc/getting_started.md | 4 + doc/migrations.md | 84 ++++++++ 12 files changed, 631 insertions(+), 1 deletion(-) create mode 100644 azure-devops/azext_devops/dev/migration/__init__.py create mode 100644 azure-devops/azext_devops/dev/migration/_format.py create mode 100644 azure-devops/azext_devops/dev/migration/_help.py create mode 100644 azure-devops/azext_devops/dev/migration/arguments.py create mode 100644 azure-devops/azext_devops/dev/migration/commands.py create mode 100644 azure-devops/azext_devops/dev/migration/migration.py create mode 100644 azure-devops/azext_devops/tests/latest/migration/__init__.py create mode 100644 azure-devops/azext_devops/tests/latest/migration/test_migration.py create mode 100644 doc/migrations.md 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..b4e67a54b 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 @@ -48,7 +52,7 @@ 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'))): + kwargs['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/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..87387e202 --- /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') or row.get('repositoryID') or row.get('repository') + table_row['TargetRepository'] = trim_for_display(row.get('targetRepo') or row.get('targetRepository'), + _TARGET_TRUNCATION_LENGTH) + table_row['State'] = row.get('state') + table_row['Stage'] = row.get('stage') + table_row['ValidateOnly'] = row.get('validateOnly') + table_row['CutoverDate'] = date_time_to_only_date(row.get('cutoverDate') or row.get('scheduledCutoverDate')) + table_row['CodeSyncDate'] = date_time_to_only_date(row.get('codeSyncDate')) + table_row['PrSyncDate'] = date_time_to_only_date(row.get('prSyncDate')) + 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..c6d4b3617 --- /dev/null +++ b/azure-devops/azext_devops/dev/migration/_help.py @@ -0,0 +1,94 @@ +# -------------------------------------------------------------------------------------------- +# 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. + """ + + helps['devops migrations list'] = """ + type: command + short-summary: List migrations in an organization. + examples: + - name: List migrations. + text: | + az devops migrations list --org https://codedev.ms/elmo1 + """ + + 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://codedev.ms/elmo1 --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 validation-only migration. + text: | + az devops migrations create --org https://codedev.ms/elmo1 --repository-id 00000000-0000-0000-0000-000000000000 \ + --target-repository https://microsoft.ghe.com/1ES/Gardener --target-owner-user-id GeoffCoxMSFT --validate-only + """ + + helps['devops migrations pause'] = """ + type: command + short-summary: Pause an active migration. + """ + + helps['devops migrations resume'] = """ + type: command + short-summary: Resume a paused migration. + """ + + helps['devops migrations abandon'] = """ + type: command + short-summary: Abandon and delete a migration. + """ + + helps['devops migrations set-validate-only'] = """ + type: command + short-summary: Set validate-only mode on or off. + examples: + - name: Turn validate-only on. + text: | + az devops migrations set-validate-only --org https://codedev.ms/elmo1 --repository-id 00000000-0000-0000-0000-000000000000 --on + - name: Turn validate-only off. + text: | + az devops migrations set-validate-only --org https://codedev.ms/elmo1 --repository-id 00000000-0000-0000-0000-000000000000 --off + """ + + helps['devops migrations migrate'] = """ + type: command + short-summary: Start full migration after validation. + """ + + 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://codedev.ms/elmo1 --repository-id 00000000-0000-0000-0000-000000000000 \ + --scheduled-cutover-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..6a4e841a4 --- /dev/null +++ b/azure-devops/azext_devops/dev/migration/arguments.py @@ -0,0 +1,39 @@ +# -------------------------------------------------------------------------------------------- +# 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.parameters import get_three_state_flag +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 repository (GUID).') + + with self.argument_context('devops migrations create') as context: + context.argument('target_repository', options_list='--target-repository', + help='Target GitHub repository URL. Example: https://microsoft.ghe.com/OrgName/RepoName') + 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', + help='Validate only (true/false). Defaults to true.', + arg_type=get_three_state_flag()) + context.argument('scheduled_cutover_date', options_list='--scheduled-cutover-date', + type=convert_date_string_to_iso8601, + help='Scheduled cutover date/time (ISO 8601).') + + with self.argument_context('devops migrations cutover set') as context: + context.argument('scheduled_cutover_date', options_list='--scheduled-cutover-date', + type=convert_date_string_to_iso8601, + help='Scheduled cutover date/time (ISO 8601).') + + with self.argument_context('devops migrations set-validate-only') as context: + context.argument('on', options_list='--on', action='store_true', + help='Set validate-only to true.') + context.argument('off', options_list='--off', action='store_true', + help='Set validate-only to false.') 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..25147d47b --- /dev/null +++ b/azure-devops/azext_devops/dev/migration/commands.py @@ -0,0 +1,31 @@ +# -------------------------------------------------------------------------------------------- +# 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('set-validate-only', 'set_validate_only', table_transformer=transform_migration_table_output) + g.command('migrate', 'migrate_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..bac29171f --- /dev/null +++ b/azure-devops/azext_devops/dev/migration/migration.py @@ -0,0 +1,189 @@ +# -------------------------------------------------------------------------------------------- +# 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 urlparse + +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' +BASE_URL = 'https://codedev.ms/elmo1' +MIGRATIONS_API_PATH = '/_apis/elm/migrations' +_TARGET_HOST = 'microsoft.ghe.com' +_REPO_PART_RE = re.compile(r'^[A-Za-z0-9._-]+$') + + +def list_migrations(organization=None, detect=None): + organization = _resolve_org_for_auth(organization, detect) + client = _get_service_client(organization) + url = _build_migration_url() + return _send_request(client, 'GET', url) + + +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(repository_id) + return _send_request(client, 'GET', url) + + +def create_migration(repository_id=None, target_repository=None, target_owner_user_id=None, + validate_only=None, scheduled_cutover_date=None, organization=None, detect=None): + _validate_target_repository(target_repository) + 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) + + if validate_only is None: + validate_only = True + + payload = { + 'targetRepository': target_repository, + 'targetOwnerUserId': target_owner_user_id, + 'validateOnly': bool(validate_only) + } + if scheduled_cutover_date is not None: + payload['scheduledCutoverDate'] = scheduled_cutover_date + + client = _get_service_client(organization) + url = _build_migration_url(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, organization=None, detect=None): + return _update_migration(repository_id, organization, detect, status_requested='active') + + +def schedule_cutover(repository_id=None, scheduled_cutover_date=None, organization=None, detect=None): + if not scheduled_cutover_date: + raise CLIError('--scheduled-cutover-date must be specified.') + return _update_migration(repository_id, organization, detect, scheduled_cutover_date=scheduled_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 set_validate_only(repository_id=None, on=False, off=False, organization=None, detect=None): + if on and off: + raise CLIError('Please specify only one of --on or --off.') + if not on and not off: + raise CLIError('Please specify --on or --off.') + return _update_migration(repository_id, organization, detect, validate_only=on) + + +def migrate_migration(repository_id=None, organization=None, detect=None): + return _update_migration(repository_id, organization, detect, validate_only=False, status_requested='active') + + +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(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(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 _validate_target_repository(target_repository): + if not target_repository: + raise CLIError('--target-repository must be specified.') + + parsed = urlparse(target_repository) + if parsed.scheme != 'https': + raise CLIError('--target-repository must be a https://microsoft.ghe.com/OrgName/RepoName URL.') + if parsed.netloc.lower() != _TARGET_HOST: + raise CLIError('--target-repository must be a https://microsoft.ghe.com/OrgName/RepoName URL.') + + repo_path = parsed.path.strip('/') + if not _is_owner_repo(repo_path): + raise CLIError('--target-repository must be a https://microsoft.ghe.com/OrgName/RepoName URL.') + + +def _is_owner_repo(value): + parts = value.split('/') + if len(parts) != 2: + return False + if not all(_REPO_PART_RE.match(part or '') for part in parts): + return False + return True + + +def _resolve_org_for_auth(organization, detect): + # For now, base URL is fixed for dev/test, but still validate auth. + resolve_instance(detect=detect, organization=organization or BASE_URL) + return BASE_URL + + +def _build_migration_url(repository_id=None): + url = BASE_URL + 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)) + connection = get_connection(organization) + return ServiceClient(creds=connection._creds, config=config) + + +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 = 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/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..8c21e7eda --- /dev/null +++ b/azure-devops/azext_devops/tests/latest/migration/test_migration.py @@ -0,0 +1,124 @@ +# -------------------------------------------------------------------------------------------- +# 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, + set_validate_only, + migrate_migration) + + +class TestMigrationCommands(unittest.TestCase): + + _TEST_ORG = 'https://codedev.ms/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) + + args = mock_send.call_args[0] + self.assertEqual(args[1], 'GET') + self.assertIn('/_apis/elm/migrations', args[2]) + + def test_create_migration_payload_defaults_validate_only_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://microsoft.ghe.com/1ES/Gardener', + target_owner_user_id='GeoffCoxMSFT', + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + self.assertTrue(payload['validateOnly']) + + def test_create_migration_rejects_invalid_target_repository(self): + with self.assertRaises(CLIError): + create_migration( + repository_id='00000000-0000-0000-0000-000000000000', + target_repository='microsoft/gcox-test-1', + target_owner_user_id='GeoffCoxMSFT', + organization=self._TEST_ORG, + detect=False + ) + + def test_create_migration_accepts_ghe_url(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://microsoft.ghe.com/1ES/Gardener', + target_owner_user_id='GeoffCoxMSFT', + organization=self._TEST_ORG, + detect=False + ) + + 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_set_validate_only_requires_on_or_off(self): + with self.assertRaises(CLIError): + set_validate_only(repository_id='00000000-0000-0000-0000-000000000000', + organization=self._TEST_ORG, detect=False) + + def test_migrate_sets_active(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 + + migrate_migration( + repository_id='00000000-0000-0000-0000-000000000000', + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + self.assertFalse(payload['validateOnly']) + self.assertEqual(payload['statusRequested'], 'active') + + +if __name__ == '__main__': + unittest.main() 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..6f23f8178 --- /dev/null +++ b/doc/migrations.md @@ -0,0 +1,84 @@ +# 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`. +- Use `--org` to authenticate and resolve credentials. + +## Required inputs + +- `--repository-id` is the Azure Repos repository GUID. +- `--target-repository` must be a GitHub Enterprise Server URL in this format: + `https://microsoft.ghe.com/OrgName/RepoName` +- `--target-owner-user-id` is required for create. +- `--scheduled-cutover-date` must be ISO 8601, for example: `2030-12-31T11:59:00Z`. + +## Common workflows + +### List migrations + +```bash +az devops migrations list --org https://codedev.ms/elmo1 +``` + +### Check migration status + +```bash +az devops migrations status --org https://codedev.ms/elmo1 \ + --repository-id 00000000-0000-0000-0000-000000000000 +``` + +### Create a validation-only migration + +```bash +az devops migrations create --org https://codedev.ms/elmo1 \ + --repository-id 00000000-0000-0000-0000-000000000000 \ + --target-repository https://microsoft.ghe.com/OrgName/RepoName \ + --target-owner-user-id OwnerId \ + --validate-only +``` + +### Turn validate-only off + +```bash +az devops migrations set-validate-only --org https://codedev.ms/elmo1 \ + --repository-id 00000000-0000-0000-0000-000000000000 --off +``` + +### Start full migration + +```bash +az devops migrations migrate --org https://codedev.ms/elmo1 \ + --repository-id 00000000-0000-0000-0000-000000000000 +``` + +### Pause and resume + +```bash +az devops migrations pause --org https://codedev.ms/elmo1 \ + --repository-id 00000000-0000-0000-0000-000000000000 + +az devops migrations resume --org https://codedev.ms/elmo1 \ + --repository-id 00000000-0000-0000-0000-000000000000 +``` + +### Schedule or cancel cutover + +```bash +az devops migrations cutover set --org https://codedev.ms/elmo1 \ + --repository-id 00000000-0000-0000-0000-000000000000 \ + --scheduled-cutover-date 2030-12-31T11:59:00Z + +az devops migrations cutover cancel --org https://codedev.ms/elmo1 \ + --repository-id 00000000-0000-0000-0000-000000000000 +``` + +### Abandon a migration + +```bash +az devops migrations abandon --org https://codedev.ms/elmo1 \ + --repository-id 00000000-0000-0000-0000-000000000000 +``` From 78bab25b6d68351ea367d5ab32ae3e23600c3c03 Mon Sep 17 00:00:00 2001 From: Bhuvan Shah Date: Thu, 12 Mar 2026 14:18:06 -0700 Subject: [PATCH 02/32] Add migration create options --- .../azext_devops/dev/migration/_help.py | 5 ++ .../azext_devops/dev/migration/arguments.py | 6 ++ .../azext_devops/dev/migration/migration.py | 16 ++++- .../tests/latest/migration/test_migration.py | 67 +++++++++++++++++++ 4 files changed, 93 insertions(+), 1 deletion(-) diff --git a/azure-devops/azext_devops/dev/migration/_help.py b/azure-devops/azext_devops/dev/migration/_help.py index c6d4b3617..696f79ee2 100644 --- a/azure-devops/azext_devops/dev/migration/_help.py +++ b/azure-devops/azext_devops/dev/migration/_help.py @@ -39,6 +39,11 @@ def load_migration_help(): text: | az devops migrations create --org https://codedev.ms/elmo1 --repository-id 00000000-0000-0000-0000-000000000000 \ --target-repository https://microsoft.ghe.com/1ES/Gardener --target-owner-user-id GeoffCoxMSFT --validate-only + - name: Create a migration with optional validation settings. + text: | + az devops migrations create --org https://codedev.ms/elmo1 --repository-id 00000000-0000-0000-0000-000000000000 \ + --target-repository https://microsoft.ghe.com/1ES/Gardener --target-owner-user-id GeoffCoxMSFT \ + --agent-pool-name MigrationPool --skip-validation ActivePullRequestCount,PullRequestDeltaSize """ helps['devops migrations pause'] = """ diff --git a/azure-devops/azext_devops/dev/migration/arguments.py b/azure-devops/azext_devops/dev/migration/arguments.py index 6a4e841a4..362020367 100644 --- a/azure-devops/azext_devops/dev/migration/arguments.py +++ b/azure-devops/azext_devops/dev/migration/arguments.py @@ -26,6 +26,12 @@ def load_migration_arguments(self, _): context.argument('scheduled_cutover_date', options_list='--scheduled-cutover-date', type=convert_date_string_to_iso8601, help='Scheduled cutover date/time (ISO 8601).') + context.argument('agent_pool_name', options_list='--agent-pool-name', + help='Agent pool name for migration validation.') + context.argument('skip_validation', options_list='--skip-validation', + help='Comma-separated list of validation checks to skip. ' + 'Values: None, ActivePullRequestCount, PullRequestDeltaSize, ' + 'TargetRepoMigration, All.') with self.argument_context('devops migrations cutover set') as context: context.argument('scheduled_cutover_date', options_list='--scheduled-cutover-date', diff --git a/azure-devops/azext_devops/dev/migration/migration.py b/azure-devops/azext_devops/dev/migration/migration.py index bac29171f..dfad7e8e9 100644 --- a/azure-devops/azext_devops/dev/migration/migration.py +++ b/azure-devops/azext_devops/dev/migration/migration.py @@ -30,6 +30,13 @@ def list_migrations(organization=None, detect=None): 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 get_migration(repository_id=None, organization=None, detect=None): organization = _resolve_org_for_auth(organization, detect) repository_id = _resolve_repository_id(repository_id) @@ -39,7 +46,10 @@ def get_migration(repository_id=None, organization=None, detect=None): def create_migration(repository_id=None, target_repository=None, target_owner_user_id=None, - validate_only=None, scheduled_cutover_date=None, organization=None, detect=None): + validate_only=None, scheduled_cutover_date=None, agent_pool_name=None, + skip_validation=None, organization=None, detect=None): + agent_pool_name = _normalize_optional_text(agent_pool_name) + skip_validation = _normalize_optional_text(skip_validation) _validate_target_repository(target_repository) if not target_owner_user_id: raise CLIError('--target-owner-user-id must be specified.') @@ -57,6 +67,10 @@ def create_migration(repository_id=None, target_repository=None, target_owner_us } if scheduled_cutover_date is not None: payload['scheduledCutoverDate'] = scheduled_cutover_date + if agent_pool_name is not None: + payload['agentPoolName'] = agent_pool_name + if skip_validation is not None: + payload['skipValidation'] = skip_validation client = _get_service_client(organization) url = _build_migration_url(repository_id) 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 8c21e7eda..4e55b0f82 100644 --- a/azure-devops/azext_devops/tests/latest/migration/test_migration.py +++ b/azure-devops/azext_devops/tests/latest/migration/test_migration.py @@ -56,6 +56,73 @@ def test_create_migration_payload_defaults_validate_only_true(self): payload = mock_send.call_args[0][3] self.assertTrue(payload['validateOnly']) + 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://microsoft.ghe.com/1ES/Gardener', + target_owner_user_id='GeoffCoxMSFT', + validate_only=False, + scheduled_cutover_date='2030-12-31T11:59:00Z', + agent_pool_name='MigrationPool', + skip_validation='ActivePullRequestCount,PullRequestDeltaSize', + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + self.assertFalse(payload['validateOnly']) + self.assertEqual(payload['scheduledCutoverDate'], '2030-12-31T11:59:00Z') + self.assertEqual(payload['agentPoolName'], 'MigrationPool') + self.assertEqual(payload['skipValidation'], 'ActivePullRequestCount,PullRequestDeltaSize') + + def test_create_migration_omits_empty_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://microsoft.ghe.com/1ES/Gardener', + target_owner_user_id='GeoffCoxMSFT', + agent_pool_name=' ', + skip_validation=' ', + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + self.assertNotIn('agentPoolName', payload) + self.assertNotIn('skipValidation', payload) + + def test_create_migration_trims_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://microsoft.ghe.com/1ES/Gardener', + target_owner_user_id='GeoffCoxMSFT', + agent_pool_name=' MigrationPool ', + skip_validation=' ActivePullRequestCount, PullRequestDeltaSize ', + organization=self._TEST_ORG, + detect=False + ) + + payload = mock_send.call_args[0][3] + self.assertEqual(payload['agentPoolName'], 'MigrationPool') + self.assertEqual(payload['skipValidation'], 'ActivePullRequestCount, PullRequestDeltaSize') + def test_create_migration_rejects_invalid_target_repository(self): with self.assertRaises(CLIError): create_migration( From 6c28ae00c5a317778cfa88839c80a08d27459a6c Mon Sep 17 00:00:00 2001 From: Bhuvan Shah Date: Fri, 13 Mar 2026 11:14:33 -0700 Subject: [PATCH 03/32] Fix style checks --- .flake8 | 3 +++ azure-devops/azext_devops/__init__.py | 4 ++-- azure-devops/azext_devops/dev/migration/migration.py | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) 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/azure-devops/azext_devops/__init__.py b/azure-devops/azext_devops/__init__.py index b4e67a54b..6c22154c1 100644 --- a/azure-devops/azext_devops/__init__.py +++ b/azure-devops/azext_devops/__init__.py @@ -51,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', 'migrations'))): + 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/migration/migration.py b/azure-devops/azext_devops/dev/migration/migration.py index dfad7e8e9..c0b5c18fa 100644 --- a/azure-devops/azext_devops/dev/migration/migration.py +++ b/azure-devops/azext_devops/dev/migration/migration.py @@ -183,7 +183,7 @@ def _get_service_client(organization): config = Configuration(base_url=None) config.add_user_agent('devOpsCli/{}'.format(VERSION)) connection = get_connection(organization) - return ServiceClient(creds=connection._creds, config=config) + return ServiceClient(creds=connection._creds, config=config) # pylint: disable=protected-access def _send_request(client, method, url, content=None): From a56dddd5a062c913da5bd6c2638f5aaa28174e8d Mon Sep 17 00:00:00 2001 From: Bhuvan Shah Date: Wed, 18 Mar 2026 10:27:02 -0700 Subject: [PATCH 04/32] Use org base for ELM migrations --- .../azext_devops/dev/migration/_help.py | 16 ++++++++-------- .../azext_devops/dev/migration/migration.py | 19 ++++++++----------- .../tests/latest/migration/test_migration.py | 4 +++- 3 files changed, 19 insertions(+), 20 deletions(-) diff --git a/azure-devops/azext_devops/dev/migration/_help.py b/azure-devops/azext_devops/dev/migration/_help.py index 696f79ee2..39579439c 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. + long-summary: This command group is a part of the azure-devops extension. For ELM migrations, --org should be the ELM service base URL (for example: https://elm.contoso.com/elmo1). """ helps['devops migrations list'] = """ @@ -19,7 +19,7 @@ def load_migration_help(): examples: - name: List migrations. text: | - az devops migrations list --org https://codedev.ms/elmo1 + az devops migrations list --org https://elm.contoso.com/elmo1 """ helps['devops migrations status'] = """ @@ -28,7 +28,7 @@ def load_migration_help(): examples: - name: Get migration status by repository id. text: | - az devops migrations status --org https://codedev.ms/elmo1 --repository-id 00000000-0000-0000-0000-000000000000 + az devops migrations status --org https://elm.contoso.com/elmo1 --repository-id 00000000-0000-0000-0000-000000000000 """ helps['devops migrations create'] = """ @@ -37,11 +37,11 @@ def load_migration_help(): examples: - name: Create a validation-only migration. text: | - az devops migrations create --org https://codedev.ms/elmo1 --repository-id 00000000-0000-0000-0000-000000000000 \ + az devops migrations create --org https://elm.contoso.com/elmo1 --repository-id 00000000-0000-0000-0000-000000000000 \ --target-repository https://microsoft.ghe.com/1ES/Gardener --target-owner-user-id GeoffCoxMSFT --validate-only - name: Create a migration with optional validation settings. text: | - az devops migrations create --org https://codedev.ms/elmo1 --repository-id 00000000-0000-0000-0000-000000000000 \ + az devops migrations create --org https://elm.contoso.com/elmo1 --repository-id 00000000-0000-0000-0000-000000000000 \ --target-repository https://microsoft.ghe.com/1ES/Gardener --target-owner-user-id GeoffCoxMSFT \ --agent-pool-name MigrationPool --skip-validation ActivePullRequestCount,PullRequestDeltaSize """ @@ -67,10 +67,10 @@ def load_migration_help(): examples: - name: Turn validate-only on. text: | - az devops migrations set-validate-only --org https://codedev.ms/elmo1 --repository-id 00000000-0000-0000-0000-000000000000 --on + az devops migrations set-validate-only --org https://elm.contoso.com/elmo1 --repository-id 00000000-0000-0000-0000-000000000000 --on - name: Turn validate-only off. text: | - az devops migrations set-validate-only --org https://codedev.ms/elmo1 --repository-id 00000000-0000-0000-0000-000000000000 --off + az devops migrations set-validate-only --org https://elm.contoso.com/elmo1 --repository-id 00000000-0000-0000-0000-000000000000 --off """ helps['devops migrations migrate'] = """ @@ -89,7 +89,7 @@ def load_migration_help(): examples: - name: Schedule cutover. text: | - az devops migrations cutover set --org https://codedev.ms/elmo1 --repository-id 00000000-0000-0000-0000-000000000000 \ + az devops migrations cutover set --org https://elm.contoso.com/elmo1 --repository-id 00000000-0000-0000-0000-000000000000 \ --scheduled-cutover-date 2030-12-31T11:59:00Z """ diff --git a/azure-devops/azext_devops/dev/migration/migration.py b/azure-devops/azext_devops/dev/migration/migration.py index c0b5c18fa..3447ad16d 100644 --- a/azure-devops/azext_devops/dev/migration/migration.py +++ b/azure-devops/azext_devops/dev/migration/migration.py @@ -17,7 +17,6 @@ API_VERSION = '7.2-preview' -BASE_URL = 'https://codedev.ms/elmo1' MIGRATIONS_API_PATH = '/_apis/elm/migrations' _TARGET_HOST = 'microsoft.ghe.com' _REPO_PART_RE = re.compile(r'^[A-Za-z0-9._-]+$') @@ -26,7 +25,7 @@ def list_migrations(organization=None, detect=None): organization = _resolve_org_for_auth(organization, detect) client = _get_service_client(organization) - url = _build_migration_url() + url = _build_migration_url(organization) return _send_request(client, 'GET', url) @@ -41,7 +40,7 @@ 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(repository_id) + url = _build_migration_url(organization, repository_id) return _send_request(client, 'GET', url) @@ -73,7 +72,7 @@ def create_migration(repository_id=None, target_repository=None, target_owner_us payload['skipValidation'] = skip_validation client = _get_service_client(organization) - url = _build_migration_url(repository_id) + url = _build_migration_url(organization, repository_id) return _send_request(client, 'POST', url, payload) @@ -113,7 +112,7 @@ 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(repository_id) + url = _build_migration_url(organization, repository_id) return _send_request(client, 'DELETE', url) @@ -122,7 +121,7 @@ def _update_migration(repository_id, organization, detect, validate_only=None, organization = _resolve_org_for_auth(organization, detect) repository_id = _resolve_repository_id(repository_id) client = _get_service_client(organization) - url = _build_migration_url(repository_id) + url = _build_migration_url(organization, repository_id) payload = {} if validate_only is not None: @@ -167,13 +166,11 @@ def _is_owner_repo(value): def _resolve_org_for_auth(organization, detect): - # For now, base URL is fixed for dev/test, but still validate auth. - resolve_instance(detect=detect, organization=organization or BASE_URL) - return BASE_URL + return resolve_instance(detect=detect, organization=organization) -def _build_migration_url(repository_id=None): - url = BASE_URL + MIGRATIONS_API_PATH +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 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 4e55b0f82..0ac82ae45 100644 --- a/azure-devops/azext_devops/tests/latest/migration/test_migration.py +++ b/azure-devops/azext_devops/tests/latest/migration/test_migration.py @@ -23,7 +23,7 @@ class TestMigrationCommands(unittest.TestCase): - _TEST_ORG = 'https://codedev.ms/elmo1' + _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, \ @@ -34,8 +34,10 @@ def test_list_migrations_calls_get(self): 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]) def test_create_migration_payload_defaults_validate_only_true(self): From a65cc09d1f30ad0b7c0ef89ddbeaebfcfd1b948a Mon Sep 17 00:00:00 2001 From: Bhuvan Shah Date: Wed, 18 Mar 2026 10:27:14 -0700 Subject: [PATCH 05/32] Adjust BuildWheel task for Windows paths --- .vscode/tasks.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 0db4b55e3..aeb2ba816 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -3,13 +3,13 @@ "tasks": [ { "label": "BuildWheel", + "type": "process", "command": "${command:python.interpreterPath}", "args": [ "setup.py", "sdist", "bdist_wheel" ], - "type": "shell", "options": { "cwd": "${workspaceRoot}/azure-devops/" }, From 01f61fb4d90177db2bbd9da5ce3c3247cb5f4ea6 Mon Sep 17 00:00:00 2001 From: Bhuvan Shah Date: Wed, 18 Mar 2026 11:05:39 -0700 Subject: [PATCH 06/32] Allow GitHub.com targets and fix help YAML --- .../azext_devops/dev/migration/_help.py | 6 +- .../azext_devops/dev/migration/arguments.py | 3 +- .../azext_devops/dev/migration/migration.py | 16 +++-- .../tests/latest/migration/test_migration.py | 35 ++++++++-- doc/elm_migrations_tsg.md | 69 +++++++++++++++++++ 5 files changed, 115 insertions(+), 14 deletions(-) create mode 100644 doc/elm_migrations_tsg.md diff --git a/azure-devops/azext_devops/dev/migration/_help.py b/azure-devops/azext_devops/dev/migration/_help.py index 39579439c..e230d813f 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 the ELM service base URL (for example: https://elm.contoso.com/elmo1). + long-summary: 'This command group is a part of the azure-devops extension. For ELM migrations, --org should be the ELM service base URL (for example: https://elm.contoso.com/elmo1).' """ helps['devops migrations list'] = """ @@ -38,11 +38,11 @@ def load_migration_help(): - name: Create a validation-only migration. text: | az devops migrations create --org https://elm.contoso.com/elmo1 --repository-id 00000000-0000-0000-0000-000000000000 \ - --target-repository https://microsoft.ghe.com/1ES/Gardener --target-owner-user-id GeoffCoxMSFT --validate-only + --target-repository https://github.com/OrgName/RepoName --target-owner-user-id GeoffCoxMSFT --validate-only - name: Create a migration with optional validation settings. text: | az devops migrations create --org https://elm.contoso.com/elmo1 --repository-id 00000000-0000-0000-0000-000000000000 \ - --target-repository https://microsoft.ghe.com/1ES/Gardener --target-owner-user-id GeoffCoxMSFT \ + --target-repository https://example.ghe.com/OrgName/RepoName --target-owner-user-id GeoffCoxMSFT \ --agent-pool-name MigrationPool --skip-validation ActivePullRequestCount,PullRequestDeltaSize """ diff --git a/azure-devops/azext_devops/dev/migration/arguments.py b/azure-devops/azext_devops/dev/migration/arguments.py index 362020367..41f4a64b9 100644 --- a/azure-devops/azext_devops/dev/migration/arguments.py +++ b/azure-devops/azext_devops/dev/migration/arguments.py @@ -17,7 +17,8 @@ def load_migration_arguments(self, _): with self.argument_context('devops migrations create') as context: context.argument('target_repository', options_list='--target-repository', - help='Target GitHub repository URL. Example: https://microsoft.ghe.com/OrgName/RepoName') + help='Target GitHub repository URL. Example: https://github.com/OrgName/RepoName or ' + 'https://example.ghe.com/OrgName/RepoName') 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', diff --git a/azure-devops/azext_devops/dev/migration/migration.py b/azure-devops/azext_devops/dev/migration/migration.py index 3447ad16d..bae141908 100644 --- a/azure-devops/azext_devops/dev/migration/migration.py +++ b/azure-devops/azext_devops/dev/migration/migration.py @@ -18,7 +18,8 @@ API_VERSION = '7.2-preview' MIGRATIONS_API_PATH = '/_apis/elm/migrations' -_TARGET_HOST = 'microsoft.ghe.com' +_GHE_HOST_SUFFIX = '.ghe.com' +_GITHUB_HOST = 'github.com' _REPO_PART_RE = re.compile(r'^[A-Za-z0-9._-]+$') @@ -147,13 +148,18 @@ def _validate_target_repository(target_repository): parsed = urlparse(target_repository) if parsed.scheme != 'https': - raise CLIError('--target-repository must be a https://microsoft.ghe.com/OrgName/RepoName URL.') - if parsed.netloc.lower() != _TARGET_HOST: - raise CLIError('--target-repository must be a https://microsoft.ghe.com/OrgName/RepoName URL.') + raise CLIError('--target-repository must be a https://github.com/OrgName/RepoName or ' + 'https://.ghe.com/OrgName/RepoName URL.') + + host = parsed.netloc.lower() + if not (host == _GITHUB_HOST or host.endswith(_GHE_HOST_SUFFIX)): + raise CLIError('--target-repository must be a https://github.com/OrgName/RepoName or ' + 'https://.ghe.com/OrgName/RepoName URL.') repo_path = parsed.path.strip('/') if not _is_owner_repo(repo_path): - raise CLIError('--target-repository must be a https://microsoft.ghe.com/OrgName/RepoName URL.') + raise CLIError('--target-repository must be a https://github.com/OrgName/RepoName or ' + 'https://.ghe.com/OrgName/RepoName URL.') def _is_owner_repo(value): 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 0ac82ae45..93c08d54a 100644 --- a/azure-devops/azext_devops/tests/latest/migration/test_migration.py +++ b/azure-devops/azext_devops/tests/latest/migration/test_migration.py @@ -49,7 +49,7 @@ def test_create_migration_payload_defaults_validate_only_true(self): create_migration( repository_id='00000000-0000-0000-0000-000000000000', - target_repository='https://microsoft.ghe.com/1ES/Gardener', + target_repository='https://example.ghe.com/OrgName/RepoName', target_owner_user_id='GeoffCoxMSFT', organization=self._TEST_ORG, detect=False @@ -67,7 +67,7 @@ def test_create_migration_payload_includes_optional_fields(self): create_migration( repository_id='00000000-0000-0000-0000-000000000000', - target_repository='https://microsoft.ghe.com/1ES/Gardener', + target_repository='https://example.ghe.com/OrgName/RepoName', target_owner_user_id='GeoffCoxMSFT', validate_only=False, scheduled_cutover_date='2030-12-31T11:59:00Z', @@ -92,7 +92,7 @@ def test_create_migration_omits_empty_optional_fields(self): create_migration( repository_id='00000000-0000-0000-0000-000000000000', - target_repository='https://microsoft.ghe.com/1ES/Gardener', + target_repository='https://example.ghe.com/OrgName/RepoName', target_owner_user_id='GeoffCoxMSFT', agent_pool_name=' ', skip_validation=' ', @@ -113,7 +113,7 @@ def test_create_migration_trims_optional_fields(self): create_migration( repository_id='00000000-0000-0000-0000-000000000000', - target_repository='https://microsoft.ghe.com/1ES/Gardener', + target_repository='https://example.ghe.com/OrgName/RepoName', target_owner_user_id='GeoffCoxMSFT', agent_pool_name=' MigrationPool ', skip_validation=' ActivePullRequestCount, PullRequestDeltaSize ', @@ -144,7 +144,32 @@ def test_create_migration_accepts_ghe_url(self): create_migration( repository_id='00000000-0000-0000-0000-000000000000', - target_repository='https://microsoft.ghe.com/1ES/Gardener', + target_repository='https://example.ghe.com/OrgName/RepoName', + target_owner_user_id='GeoffCoxMSFT', + organization=self._TEST_ORG, + detect=False + ) + + def test_create_migration_accepts_github_url(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://github.com/OrgName/RepoName', + target_owner_user_id='GeoffCoxMSFT', + organization=self._TEST_ORG, + detect=False + ) + + def test_create_migration_rejects_non_ghe_host(self): + with self.assertRaises(CLIError): + create_migration( + repository_id='00000000-0000-0000-0000-000000000000', + target_repository='https://example.com/OrgName/RepoName', target_owner_user_id='GeoffCoxMSFT', organization=self._TEST_ORG, detect=False diff --git a/doc/elm_migrations_tsg.md b/doc/elm_migrations_tsg.md new file mode 100644 index 000000000..3f6425462 --- /dev/null +++ b/doc/elm_migrations_tsg.md @@ -0,0 +1,69 @@ +# ELM migrations troubleshooting guide (TSG) + +## Scope +- Applies to `az devops migrations` commands in the azure-devops CLI extension. +- Migration direction: Azure DevOps (source) to GitHub (target) via the ELM service. + +## Key concepts +- `--org` for migrations is the ELM service base URL, not the ADO org. +- The ADO org is only used to look up the source repo GUID (`az repos show`). +- `--detect` defaults to true. If you run commands inside an ADO git repo, auto-detect can override `--org`. + Use `--detect false` or run from a directory that is not inside an ADO repo. + +## Quick start +1) Get source repo GUID from ADO: +```powershell +az repos show --org https://dev.azure.com// --project --repository --query id -o tsv +``` + +2) Create a validation-only migration (default is validate-only): +```powershell +az devops migrations create --org https:///elm --detect false \ + --repository-id \ + --target-repository https://// \ + --target-owner-user-id +``` + +3) Check status: +```powershell +az devops migrations status --org https:///elm --detect false --repository-id +``` + +4) Start full migration after validation passes: +```powershell +az devops migrations migrate --org https:///elm --detect false --repository-id +``` + +5) Optional cutover: +```powershell +az devops migrations cutover set --org https:///elm --detect false \ + --repository-id --scheduled-cutover-date 2030-12-31T11:59:00Z +``` + +## Common pitfalls +- **Auto-detect override**: If you are inside an ADO repo, `--detect` may override your ELM base URL. + Use `--detect false`. +- **Wrong org for migrations**: Using `https://dev.azure.com/...` with `az devops migrations` will hit ADO + instead of ELM and fail. +- **Too many parallel migrations**: Run one migration at a time unless your service owner says otherwise. + +## Common errors and fixes +- **401/403 Unauthorized**: You are not logged in or the token lacks permission. + Run `az devops login` (PAT) or `az login` (AAD) as required by your environment. +- **404 Not Found**: The ELM base URL or repo GUID is incorrect. +- **406 Not Acceptable**: The ELM service rejected the request. Verify the ELM base URL includes `/elm`, + confirm you are using the latest extension, and contact the service owner if it persists. +- **Warning: Azure DevOps Server not supported**: This can appear when using non-dev.azure.com URLs. + It is expected for ELM and can usually be ignored. +- **Target repo validation error**: The CLI validates that the target host is `github.com` or `*.ghe.com`. + If you see an error like `--target-repository must be a https://github.com/OrgName/RepoName or https://.ghe.com/OrgName/RepoName URL`, + verify the target URL host or update the validation rule for your environment. + +## Useful commands +```powershell +# Check extension version +az extension show -n azure-devops --query "{name:name,version:version}" -o json + +# Set default org to the ELM base +az devops configure -d organization=https:///elm +``` From de12e5b8002fa8433243bc960c6f4292455f8fc2 Mon Sep 17 00:00:00 2001 From: Bhuvan Shah Date: Wed, 18 Mar 2026 16:32:40 -0700 Subject: [PATCH 07/32] Simplify migration resume flow --- .../azext_devops/dev/migration/_help.py | 31 +++---- .../azext_devops/dev/migration/arguments.py | 14 ++-- .../azext_devops/dev/migration/commands.py | 2 - .../azext_devops/dev/migration/migration.py | 65 ++++++++++++++- .../tests/latest/migration/test_migration.py | 81 +++++++++++++++---- doc/elm_migrations_tsg.md | 9 ++- doc/migrations.md | 44 +++++----- 7 files changed, 174 insertions(+), 72 deletions(-) diff --git a/azure-devops/azext_devops/dev/migration/_help.py b/azure-devops/azext_devops/dev/migration/_help.py index e230d813f..701b51b84 100644 --- a/azure-devops/azext_devops/dev/migration/_help.py +++ b/azure-devops/azext_devops/dev/migration/_help.py @@ -33,7 +33,7 @@ def load_migration_help(): helps['devops migrations create'] = """ type: command - short-summary: Create a migration for a repository. + short-summary: Create a validation-only migration for a repository. examples: - name: Create a validation-only migration. text: | @@ -43,7 +43,7 @@ def load_migration_help(): text: | az devops migrations create --org https://elm.contoso.com/elmo1 --repository-id 00000000-0000-0000-0000-000000000000 \ --target-repository https://example.ghe.com/OrgName/RepoName --target-owner-user-id GeoffCoxMSFT \ - --agent-pool-name MigrationPool --skip-validation ActivePullRequestCount,PullRequestDeltaSize + --validate-only --agent-pool-name MigrationPool --skip-validation ActivePullRequestCount,PullRequestDeltaSize """ helps['devops migrations pause'] = """ @@ -53,29 +53,22 @@ def load_migration_help(): helps['devops migrations resume'] = """ type: command - short-summary: Resume a paused migration. - """ - - helps['devops migrations abandon'] = """ - type: command - short-summary: Abandon and delete a migration. - """ - - helps['devops migrations set-validate-only'] = """ - type: command - short-summary: Set validate-only mode on or off. + short-summary: Resume a non-active migration. examples: - - name: Turn validate-only on. + - name: Resume using the current mode. + text: | + az devops migrations resume --org https://elm.contoso.com/elmo1 --repository-id 00000000-0000-0000-0000-000000000000 + - name: Resume and force validate-only. text: | - az devops migrations set-validate-only --org https://elm.contoso.com/elmo1 --repository-id 00000000-0000-0000-0000-000000000000 --on - - name: Turn validate-only off. + az devops migrations resume --org https://elm.contoso.com/elmo1 --repository-id 00000000-0000-0000-0000-000000000000 --validate-only + - name: Resume and start migration. text: | - az devops migrations set-validate-only --org https://elm.contoso.com/elmo1 --repository-id 00000000-0000-0000-0000-000000000000 --off + az devops migrations resume --org https://elm.contoso.com/elmo1 --repository-id 00000000-0000-0000-0000-000000000000 --migrate """ - helps['devops migrations migrate'] = """ + helps['devops migrations abandon'] = """ type: command - short-summary: Start full migration after validation. + short-summary: Abandon and delete a migration. """ helps['devops migrations cutover'] = """ diff --git a/azure-devops/azext_devops/dev/migration/arguments.py b/azure-devops/azext_devops/dev/migration/arguments.py index 41f4a64b9..e521aa84d 100644 --- a/azure-devops/azext_devops/dev/migration/arguments.py +++ b/azure-devops/azext_devops/dev/migration/arguments.py @@ -17,8 +17,8 @@ def load_migration_arguments(self, _): with self.argument_context('devops migrations create') as context: context.argument('target_repository', options_list='--target-repository', - help='Target GitHub repository URL. Example: https://github.com/OrgName/RepoName or ' - 'https://example.ghe.com/OrgName/RepoName') + help='Target GitHub repository URL. Example: https://github.com/OrgName/RepoName or ' + 'https://example.ghe.com/OrgName/RepoName') 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', @@ -39,8 +39,8 @@ def load_migration_arguments(self, _): type=convert_date_string_to_iso8601, help='Scheduled cutover date/time (ISO 8601).') - with self.argument_context('devops migrations set-validate-only') as context: - context.argument('on', options_list='--on', action='store_true', - help='Set validate-only to true.') - context.argument('off', options_list='--off', action='store_true', - help='Set validate-only to false.') + 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('migrate', options_list='--migrate', action='store_true', + help='Resume and start migration (validate-only off).') diff --git a/azure-devops/azext_devops/dev/migration/commands.py b/azure-devops/azext_devops/dev/migration/commands.py index 25147d47b..684803f91 100644 --- a/azure-devops/azext_devops/dev/migration/commands.py +++ b/azure-devops/azext_devops/dev/migration/commands.py @@ -21,8 +21,6 @@ def load_migration_commands(self, _): 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('set-validate-only', 'set_validate_only', table_transformer=transform_migration_table_output) - g.command('migrate', 'migrate_migration', table_transformer=transform_migration_table_output) g.command('abandon', 'delete_migration', confirmation='Are you sure you want to abandon this migration?') diff --git a/azure-devops/azext_devops/dev/migration/migration.py b/azure-devops/azext_devops/dev/migration/migration.py index bae141908..4d73f14a9 100644 --- a/azure-devops/azext_devops/dev/migration/migration.py +++ b/azure-devops/azext_devops/dev/migration/migration.py @@ -20,6 +20,26 @@ MIGRATIONS_API_PATH = '/_apis/elm/migrations' _GHE_HOST_SUFFIX = '.ghe.com' _GITHUB_HOST = 'github.com' +_NON_ACTIVE_STATES = { + 'abandoned', + 'canceled', + 'cancelled', + 'complete', + 'completed', + 'failed', + 'succeeded', + 'suspended' +} +_ACTIVE_STAGES = { + 'codesync', + 'cutover', + 'migrating', + 'prsync', + 'synchronizing', + 'syncing', + 'validating', + 'validation' +} _REPO_PART_RE = re.compile(r'^[A-Za-z0-9._-]+$') @@ -59,6 +79,8 @@ def create_migration(repository_id=None, target_repository=None, target_owner_us if validate_only is None: validate_only = True + if validate_only is False: + raise CLIError('Create only supports validate-only migrations. Use resume --migrate to continue.') payload = { 'targetRepository': target_repository, @@ -81,8 +103,25 @@ 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, organization=None, detect=None): - return _update_migration(repository_id, organization, detect, status_requested='active') +def resume_migration(repository_id=None, validate_only=False, migrate=False, organization=None, detect=None): + if validate_only and migrate: + raise CLIError('Please specify only one of --validate-only or --migrate.') + + migration = get_migration(repository_id=repository_id, organization=organization, detect=detect) + if _is_migration_active(migration): + state = migration.get('state') + stage = migration.get('stage') + raise CLIError('Migration is active (state: {}, stage: {}). Pause it before resuming or changing mode.' + .format(state, stage)) + + validate_only_value = None + if validate_only: + validate_only_value = True + elif migrate: + validate_only_value = False + + return _update_migration(repository_id, organization, detect, + validate_only=validate_only_value, status_requested='active') def schedule_cutover(repository_id=None, scheduled_cutover_date=None, organization=None, detect=None): @@ -171,6 +210,28 @@ def _is_owner_repo(value): return True +def _normalize_state(value): + if value is None: + return '' + normalized = str(value).strip().lower() + return normalized.replace(' ', '').replace('-', '').replace('_', '') + + +def _is_migration_active(migration): + if not isinstance(migration, dict): + return False + + state = _normalize_state(migration.get('state')) + if state: + return state not in _NON_ACTIVE_STATES + + stage = _normalize_state(migration.get('stage')) + if stage: + return stage in _ACTIVE_STAGES + + return False + + def _resolve_org_for_auth(organization, detect): return resolve_instance(detect=detect, organization=organization) 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 93c08d54a..33451c568 100644 --- a/azure-devops/azext_devops/tests/latest/migration/test_migration.py +++ b/azure-devops/azext_devops/tests/latest/migration/test_migration.py @@ -17,8 +17,7 @@ from azext_devops.dev.migration.migration import (list_migrations, create_migration, cancel_cutover, - set_validate_only, - migrate_migration) + resume_migration) class TestMigrationCommands(unittest.TestCase): @@ -58,6 +57,17 @@ def test_create_migration_payload_defaults_validate_only_true(self): payload = mock_send.call_args[0][3] self.assertTrue(payload['validateOnly']) + def test_create_migration_rejects_validate_only_false(self): + with self.assertRaises(CLIError): + create_migration( + repository_id='00000000-0000-0000-0000-000000000000', + target_repository='https://example.ghe.com/OrgName/RepoName', + target_owner_user_id='GeoffCoxMSFT', + validate_only=False, + organization=self._TEST_ORG, + detect=False + ) + 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, \ @@ -69,7 +79,6 @@ def test_create_migration_payload_includes_optional_fields(self): repository_id='00000000-0000-0000-0000-000000000000', target_repository='https://example.ghe.com/OrgName/RepoName', target_owner_user_id='GeoffCoxMSFT', - validate_only=False, scheduled_cutover_date='2030-12-31T11:59:00Z', agent_pool_name='MigrationPool', skip_validation='ActivePullRequestCount,PullRequestDeltaSize', @@ -78,7 +87,7 @@ def test_create_migration_payload_includes_optional_fields(self): ) payload = mock_send.call_args[0][3] - self.assertFalse(payload['validateOnly']) + self.assertTrue(payload['validateOnly']) self.assertEqual(payload['scheduledCutoverDate'], '2030-12-31T11:59:00Z') self.assertEqual(payload['agentPoolName'], 'MigrationPool') self.assertEqual(payload['skipValidation'], 'ActivePullRequestCount,PullRequestDeltaSize') @@ -191,28 +200,72 @@ def test_cancel_cutover_sets_null(self): payload = mock_send.call_args[0][3] self.assertIsNone(payload['scheduledCutoverDate']) - def test_set_validate_only_requires_on_or_off(self): + def test_resume_rejects_both_flags(self): with self.assertRaises(CLIError): - set_validate_only(repository_id='00000000-0000-0000-0000-000000000000', - organization=self._TEST_ORG, detect=False) + resume_migration(repository_id='00000000-0000-0000-0000-000000000000', + validate_only=True, migrate=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 = {'state': 'active', 'stage': 'synchronizing'} + mock_resolve.return_value = self._TEST_ORG - def test_migrate_sets_active(self): - with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ + with self.assertRaises(CLIError): + resume_migration(repository_id='00000000-0000-0000-0000-000000000000', + organization=self._TEST_ORG, detect=False) + + 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 = {'state': 'succeeded'} mock_resolve.return_value = self._TEST_ORG - migrate_migration( - repository_id='00000000-0000-0000-0000-000000000000', - organization=self._TEST_ORG, - detect=False - ) + 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_migrate(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 = {'state': 'suspended'} + mock_resolve.return_value = self._TEST_ORG + + resume_migration(repository_id='00000000-0000-0000-0000-000000000000', + migrate=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 = {'state': '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') + if __name__ == '__main__': unittest.main() diff --git a/doc/elm_migrations_tsg.md b/doc/elm_migrations_tsg.md index 3f6425462..d0e3046ee 100644 --- a/doc/elm_migrations_tsg.md +++ b/doc/elm_migrations_tsg.md @@ -31,10 +31,14 @@ az devops migrations status --org https:///elm --detect false --reposi 4) Start full migration after validation passes: ```powershell -az devops migrations migrate --org https:///elm --detect false --repository-id +az devops migrations resume --org https:///elm --detect false --repository-id --migrate ``` -5) Optional cutover: +5) Re-run validation (optional): +```powershell +az devops migrations resume --org https:///elm --detect false --repository-id --validate-only +``` +6) Optional cutover: ```powershell az devops migrations cutover set --org https:///elm --detect false \ --repository-id --scheduled-cutover-date 2030-12-31T11:59:00Z @@ -46,6 +50,7 @@ az devops migrations cutover set --org https:///elm --detect false \ - **Wrong org for migrations**: Using `https://dev.azure.com/...` with `az devops migrations` will hit ADO instead of ELM and fail. - **Too many parallel migrations**: Run one migration at a time unless your service owner says otherwise. +- **Resume fails while active**: `resume` is meant for non-active states (succeeded, failed, suspended). Pause first if needed. ## Common errors and fixes - **401/403 Unauthorized**: You are not logged in or the token lacks permission. diff --git a/doc/migrations.md b/doc/migrations.md index 6f23f8178..da07b8261 100644 --- a/doc/migrations.md +++ b/doc/migrations.md @@ -6,13 +6,13 @@ The `az devops migrations` command group manages enterprise live migrations for - Azure DevOps CLI with the Azure DevOps extension installed. - Sign in using `az login` or `az devops login`. -- Use `--org` to authenticate and resolve credentials. +- Use `--org` to authenticate and resolve credentials. For ELM migrations, `--org` is the ELM service base URL. ## Required inputs - `--repository-id` is the Azure Repos repository GUID. -- `--target-repository` must be a GitHub Enterprise Server URL in this format: - `https://microsoft.ghe.com/OrgName/RepoName` +- `--target-repository` must be a GitHub URL in this format: + `https://github.com/OrgName/RepoName` or `https://example.ghe.com/OrgName/RepoName` - `--target-owner-user-id` is required for create. - `--scheduled-cutover-date` must be ISO 8601, for example: `2030-12-31T11:59:00Z`. @@ -21,64 +21,56 @@ The `az devops migrations` command group manages enterprise live migrations for ### List migrations ```bash -az devops migrations list --org https://codedev.ms/elmo1 +az devops migrations list --org https://elm.contoso.com/elmo1 ``` ### Check migration status ```bash -az devops migrations status --org https://codedev.ms/elmo1 \ +az devops migrations status --org https://elm.contoso.com/elmo1 \ --repository-id 00000000-0000-0000-0000-000000000000 ``` ### Create a validation-only migration ```bash -az devops migrations create --org https://codedev.ms/elmo1 \ +az devops migrations create --org https://elm.contoso.com/elmo1 \ --repository-id 00000000-0000-0000-0000-000000000000 \ - --target-repository https://microsoft.ghe.com/OrgName/RepoName \ + --target-repository https://github.com/OrgName/RepoName \ --target-owner-user-id OwnerId \ --validate-only ``` -### Turn validate-only off - -```bash -az devops migrations set-validate-only --org https://codedev.ms/elmo1 \ - --repository-id 00000000-0000-0000-0000-000000000000 --off -``` - -### Start full migration - -```bash -az devops migrations migrate --org https://codedev.ms/elmo1 \ - --repository-id 00000000-0000-0000-0000-000000000000 -``` - ### Pause and resume ```bash -az devops migrations pause --org https://codedev.ms/elmo1 \ +az devops migrations pause --org https://elm.contoso.com/elmo1 \ --repository-id 00000000-0000-0000-0000-000000000000 -az devops migrations resume --org https://codedev.ms/elmo1 \ +az devops migrations resume --org https://elm.contoso.com/elmo1 \ --repository-id 00000000-0000-0000-0000-000000000000 + +az devops migrations resume --org https://elm.contoso.com/elmo1 \ + --repository-id 00000000-0000-0000-0000-000000000000 --validate-only + +az devops migrations resume --org https://elm.contoso.com/elmo1 \ + --repository-id 00000000-0000-0000-0000-000000000000 --migrate ``` ### Schedule or cancel cutover ```bash -az devops migrations cutover set --org https://codedev.ms/elmo1 \ +az devops migrations cutover set --org https://elm.contoso.com/elmo1 \ --repository-id 00000000-0000-0000-0000-000000000000 \ --scheduled-cutover-date 2030-12-31T11:59:00Z -az devops migrations cutover cancel --org https://codedev.ms/elmo1 \ +az devops migrations cutover cancel --org https://elm.contoso.com/elmo1 \ --repository-id 00000000-0000-0000-0000-000000000000 ``` ### Abandon a migration ```bash -az devops migrations abandon --org https://codedev.ms/elmo1 \ +az devops migrations abandon --org https://elm.contoso.com/elmo1 \ --repository-id 00000000-0000-0000-0000-000000000000 ``` From bbd23bad82b2940bfde2d815179709be519bdbbc Mon Sep 17 00:00:00 2001 From: Bhuvan Shah Date: Wed, 18 Mar 2026 16:49:33 -0700 Subject: [PATCH 08/32] Add migrations command reference --- doc/migrations.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/doc/migrations.md b/doc/migrations.md index da07b8261..848e2f835 100644 --- a/doc/migrations.md +++ b/doc/migrations.md @@ -16,6 +16,20 @@ The `az devops migrations` command group manages enterprise live migrations for - `--target-owner-user-id` is required for create. - `--scheduled-cutover-date` must be ISO 8601, for example: `2030-12-31T11:59:00Z`. +## Command reference + +- `list`: List migrations for the ELM org. +- `status`: Show migration status for a repository GUID. +- `create`: Create a validation-only migration. `--validate-only false` is not supported. + Use `resume --migrate` to move from validation to migration. +- `pause`: Pause an active migration. +- `resume`: Resume a non-active migration. Optional flags: + - `--validate-only`: Resume and force validate-only. + - `--migrate`: Resume and start migration. + If a migration is active, pause it before resuming. +- `cutover set` / `cutover cancel`: Schedule or cancel cutover. +- `abandon`: Abandon and delete a migration. + ## Common workflows ### List migrations From 5b2e17ea26eab6bf387de460543d8853d2f3b4dc Mon Sep 17 00:00:00 2001 From: Bhuvan Shah Date: Wed, 18 Mar 2026 17:08:41 -0700 Subject: [PATCH 09/32] Fix BuildWheel task on Windows --- .vscode/tasks.json | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index aeb2ba816..63c35ff14 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -3,15 +3,18 @@ "tasks": [ { "label": "BuildWheel", - "type": "process", - "command": "${command:python.interpreterPath}", + "type": "shell", + "command": "python", "args": [ "setup.py", "sdist", "bdist_wheel" ], "options": { - "cwd": "${workspaceRoot}/azure-devops/" + "cwd": "${workspaceRoot}/azure-devops/", + "env": { + "PATH": "${workspaceRoot}\\env\\Scripts;${env:PATH}" + } }, "presentation": { "echo": true, From 3a17f545d90b22e23d5a05ab907aa8db39e004d2 Mon Sep 17 00:00:00 2001 From: Bhuvan Shah Date: Thu, 19 Mar 2026 10:01:30 -0700 Subject: [PATCH 10/32] Fix markdown lint spacing --- doc/elm_migrations_tsg.md | 13 +++++++++++++ doc/migrations.md | 4 ++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/doc/elm_migrations_tsg.md b/doc/elm_migrations_tsg.md index d0e3046ee..488030eef 100644 --- a/doc/elm_migrations_tsg.md +++ b/doc/elm_migrations_tsg.md @@ -1,22 +1,27 @@ # ELM migrations troubleshooting guide (TSG) ## Scope + - Applies to `az devops migrations` commands in the azure-devops CLI extension. - Migration direction: Azure DevOps (source) to GitHub (target) via the ELM service. ## Key concepts + - `--org` for migrations is the ELM service base URL, not the ADO org. - The ADO org is only used to look up the source repo GUID (`az repos show`). - `--detect` defaults to true. If you run commands inside an ADO git repo, auto-detect can override `--org`. Use `--detect false` or run from a directory that is not inside an ADO repo. ## Quick start + 1) Get source repo GUID from ADO: + ```powershell az repos show --org https://dev.azure.com// --project --repository --query id -o tsv ``` 2) Create a validation-only migration (default is validate-only): + ```powershell az devops migrations create --org https:///elm --detect false \ --repository-id \ @@ -25,26 +30,32 @@ az devops migrations create --org https:///elm --detect false \ ``` 3) Check status: + ```powershell az devops migrations status --org https:///elm --detect false --repository-id ``` 4) Start full migration after validation passes: + ```powershell az devops migrations resume --org https:///elm --detect false --repository-id --migrate ``` 5) Re-run validation (optional): + ```powershell az devops migrations resume --org https:///elm --detect false --repository-id --validate-only ``` + 6) Optional cutover: + ```powershell az devops migrations cutover set --org https:///elm --detect false \ --repository-id --scheduled-cutover-date 2030-12-31T11:59:00Z ``` ## Common pitfalls + - **Auto-detect override**: If you are inside an ADO repo, `--detect` may override your ELM base URL. Use `--detect false`. - **Wrong org for migrations**: Using `https://dev.azure.com/...` with `az devops migrations` will hit ADO @@ -53,6 +64,7 @@ az devops migrations cutover set --org https:///elm --detect false \ - **Resume fails while active**: `resume` is meant for non-active states (succeeded, failed, suspended). Pause first if needed. ## Common errors and fixes + - **401/403 Unauthorized**: You are not logged in or the token lacks permission. Run `az devops login` (PAT) or `az login` (AAD) as required by your environment. - **404 Not Found**: The ELM base URL or repo GUID is incorrect. @@ -65,6 +77,7 @@ az devops migrations cutover set --org https:///elm --detect false \ verify the target URL host or update the validation rule for your environment. ## Useful commands + ```powershell # Check extension version az extension show -n azure-devops --query "{name:name,version:version}" -o json diff --git a/doc/migrations.md b/doc/migrations.md index 848e2f835..0be9dc5fa 100644 --- a/doc/migrations.md +++ b/doc/migrations.md @@ -24,8 +24,8 @@ The `az devops migrations` command group manages enterprise live migrations for Use `resume --migrate` to move from validation to migration. - `pause`: Pause an active migration. - `resume`: Resume a non-active migration. Optional flags: - - `--validate-only`: Resume and force validate-only. - - `--migrate`: Resume and start migration. + - `--validate-only`: Resume and force validate-only. + - `--migrate`: Resume and start migration. If a migration is active, pause it before resuming. - `cutover set` / `cutover cancel`: Schedule or cancel cutover. - `abandon`: Abandon and delete a migration. From e2af141f09ca04af38375238fcc6dd3b64428074 Mon Sep 17 00:00:00 2001 From: Bhuvan Shah Date: Fri, 20 Mar 2026 11:47:52 -0700 Subject: [PATCH 11/32] Fix markdown lint and flake8 indentation errors --- azure-devops/azext_devops/dev/migration/arguments.py | 8 ++++++-- azure-devops/azext_devops/dev/migration/migration.py | 6 ++++-- doc/migrations.md | 4 ++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/azure-devops/azext_devops/dev/migration/arguments.py b/azure-devops/azext_devops/dev/migration/arguments.py index e521aa84d..f2c830468 100644 --- a/azure-devops/azext_devops/dev/migration/arguments.py +++ b/azure-devops/azext_devops/dev/migration/arguments.py @@ -15,10 +15,14 @@ def load_migration_arguments(self, _): context.argument('repository_id', options_list='--repository-id', help='ID of the 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.') + with self.argument_context('devops migrations create') as context: context.argument('target_repository', options_list='--target-repository', - help='Target GitHub repository URL. Example: https://github.com/OrgName/RepoName or ' - 'https://example.ghe.com/OrgName/RepoName') + help='Target GitHub repository URL. Example: https://github.com/OrgName/RepoName or ' + 'https://example.ghe.com/OrgName/RepoName') 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', diff --git a/azure-devops/azext_devops/dev/migration/migration.py b/azure-devops/azext_devops/dev/migration/migration.py index 4d73f14a9..ba4436703 100644 --- a/azure-devops/azext_devops/dev/migration/migration.py +++ b/azure-devops/azext_devops/dev/migration/migration.py @@ -43,10 +43,12 @@ _REPO_PART_RE = re.compile(r'^[A-Za-z0-9._-]+$') -def list_migrations(organization=None, detect=None): +def list_migrations(include_inactive=False, 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 += '&includeInactive=true' return _send_request(client, 'GET', url) @@ -198,7 +200,7 @@ def _validate_target_repository(target_repository): repo_path = parsed.path.strip('/') if not _is_owner_repo(repo_path): raise CLIError('--target-repository must be a https://github.com/OrgName/RepoName or ' - 'https://.ghe.com/OrgName/RepoName URL.') + 'https://.ghe.com/OrgName/RepoName URL.') def _is_owner_repo(value): diff --git a/doc/migrations.md b/doc/migrations.md index 0be9dc5fa..848e2f835 100644 --- a/doc/migrations.md +++ b/doc/migrations.md @@ -24,8 +24,8 @@ The `az devops migrations` command group manages enterprise live migrations for Use `resume --migrate` to move from validation to migration. - `pause`: Pause an active migration. - `resume`: Resume a non-active migration. Optional flags: - - `--validate-only`: Resume and force validate-only. - - `--migrate`: Resume and start migration. + - `--validate-only`: Resume and force validate-only. + - `--migrate`: Resume and start migration. If a migration is active, pause it before resuming. - `cutover set` / `cutover cancel`: Schedule or cancel cutover. - `abandon`: Abandon and delete a migration. From 667c47e7cadadce008659c252afd52d912266030 Mon Sep 17 00:00:00 2001 From: Bhuvan Shah Date: Fri, 20 Mar 2026 11:51:46 -0700 Subject: [PATCH 12/32] Add --include-inactive help example and unit test for list migrations --- azure-devops/azext_devops/dev/migration/_help.py | 3 +++ .../tests/latest/migration/test_migration.py | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/azure-devops/azext_devops/dev/migration/_help.py b/azure-devops/azext_devops/dev/migration/_help.py index 701b51b84..5652e274d 100644 --- a/azure-devops/azext_devops/dev/migration/_help.py +++ b/azure-devops/azext_devops/dev/migration/_help.py @@ -20,6 +20,9 @@ def load_migration_help(): - name: List migrations. text: | az devops migrations list --org https://elm.contoso.com/elmo1 + - name: List all migrations including inactive ones. + text: | + az devops migrations list --org https://elm.contoso.com/elmo1 --include-inactive """ helps['devops migrations status'] = """ 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 33451c568..eff08e89c 100644 --- a/azure-devops/azext_devops/tests/latest/migration/test_migration.py +++ b/azure-devops/azext_devops/tests/latest/migration/test_migration.py @@ -38,6 +38,20 @@ def test_list_migrations_calls_get(self): self.assertEqual(args[1], 'GET') self.assertTrue(args[2].startswith(self._TEST_ORG.rstrip('/'))) self.assertIn('/_apis/elm/migrations', args[2]) + self.assertNotIn('includeInactive', 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('includeInactive=true', args[2]) def test_create_migration_payload_defaults_validate_only_true(self): with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ From 13158853c467b9f53d32bfaea72e71c06cacfd35 Mon Sep 17 00:00:00 2001 From: Bhuvan Shah Date: Tue, 24 Mar 2026 13:09:29 -0700 Subject: [PATCH 13/32] Fix convert_date_string_to_iso8601 returning datetime instead of string for tz-aware inputs --- .../azext_devops/dev/common/arguments.py | 3 +- .../tests/latest/common/test_arguments.py | 97 ++++++++++++++++++- 2 files changed, 97 insertions(+), 3 deletions(-) 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/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 From 11fba3950272c05fc1106ae49e725149f100753c Mon Sep 17 00:00:00 2001 From: Bhuvan Shah Date: Fri, 27 Mar 2026 12:05:59 -0700 Subject: [PATCH 14/32] Apply dev feedback: remove URL validation, fix validate-only default, unify flag naming, generic skip-validation help --- .../azext_devops/dev/migration/_format.py | 10 +- .../azext_devops/dev/migration/_help.py | 24 ++-- .../azext_devops/dev/migration/arguments.py | 27 ++-- .../azext_devops/dev/migration/migration.py | 125 +++++------------- .../tests/latest/migration/test_migration.py | 91 ++++++++----- doc/migrations.md | 42 ++++-- 6 files changed, 142 insertions(+), 177 deletions(-) diff --git a/azure-devops/azext_devops/dev/migration/_format.py b/azure-devops/azext_devops/dev/migration/_format.py index 87387e202..e5a08e51d 100644 --- a/azure-devops/azext_devops/dev/migration/_format.py +++ b/azure-devops/azext_devops/dev/migration/_format.py @@ -34,13 +34,13 @@ def _unwrap_migration_list(result): def _transform_migration_row(row): table_row = OrderedDict() - table_row['RepositoryId'] = row.get('repositoryId') or row.get('repositoryID') or row.get('repository') - table_row['TargetRepository'] = trim_for_display(row.get('targetRepo') or row.get('targetRepository'), + table_row['RepositoryId'] = row.get('repositoryId') + table_row['TargetRepository'] = trim_for_display(row.get('targetRepository'), _TARGET_TRUNCATION_LENGTH) - table_row['State'] = row.get('state') + 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('cutoverDate') or row.get('scheduledCutoverDate')) + 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('prSyncDate')) + 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 index 5652e274d..7af47d2b4 100644 --- a/azure-devops/azext_devops/dev/migration/_help.py +++ b/azure-devops/azext_devops/dev/migration/_help.py @@ -36,17 +36,14 @@ def load_migration_help(): helps['devops migrations create'] = """ type: command - short-summary: Create a validation-only migration for a repository. + short-summary: Create a migration for a repository. examples: - - name: Create a validation-only migration. + - name: Create a migration. text: | - az devops migrations create --org https://elm.contoso.com/elmo1 --repository-id 00000000-0000-0000-0000-000000000000 \ - --target-repository https://github.com/OrgName/RepoName --target-owner-user-id GeoffCoxMSFT --validate-only - - name: Create a migration with optional validation settings. + az devops migrations create --org https://elm.contoso.com/elmo1 --repository-id 00000000-0000-0000-0000-000000000000 --target-repository https://example.ghe.com/OrgName/RepoName --target-owner-user-id GeoffCoxMSFT --agent-pool MigrationPool + - name: Create a validate-only migration. text: | - az devops migrations create --org https://elm.contoso.com/elmo1 --repository-id 00000000-0000-0000-0000-000000000000 \ - --target-repository https://example.ghe.com/OrgName/RepoName --target-owner-user-id GeoffCoxMSFT \ - --validate-only --agent-pool-name MigrationPool --skip-validation ActivePullRequestCount,PullRequestDeltaSize + az devops migrations create --org https://elm.contoso.com/elmo1 --repository-id 00000000-0000-0000-0000-000000000000 --target-repository https://example.ghe.com/OrgName/RepoName --target-owner-user-id GeoffCoxMSFT --agent-pool MigrationPool --validate-only --skip-validation ActivePullRequestCount,PullRequestDeltaSize """ helps['devops migrations pause'] = """ @@ -56,17 +53,17 @@ def load_migration_help(): helps['devops migrations resume'] = """ type: command - short-summary: Resume a non-active migration. + short-summary: Resume a stopped (paused, failed) migration. examples: - name: Resume using the current mode. text: | az devops migrations resume --org https://elm.contoso.com/elmo1 --repository-id 00000000-0000-0000-0000-000000000000 - - name: Resume and force validate-only. + - name: Resume in validate-only mode. text: | az devops migrations resume --org https://elm.contoso.com/elmo1 --repository-id 00000000-0000-0000-0000-000000000000 --validate-only - - name: Resume and start migration. + - name: Continue migration (clears validate-only mode). text: | - az devops migrations resume --org https://elm.contoso.com/elmo1 --repository-id 00000000-0000-0000-0000-000000000000 --migrate + az devops migrations resume --org https://elm.contoso.com/elmo1 --repository-id 00000000-0000-0000-0000-000000000000 --migration """ helps['devops migrations abandon'] = """ @@ -85,8 +82,7 @@ def load_migration_help(): examples: - name: Schedule cutover. text: | - az devops migrations cutover set --org https://elm.contoso.com/elmo1 --repository-id 00000000-0000-0000-0000-000000000000 \ - --scheduled-cutover-date 2030-12-31T11:59:00Z + az devops migrations cutover set --org https://elm.contoso.com/elmo1 --repository-id 00000000-0000-0000-0000-000000000000 --date 2030-12-31T11:59:00Z """ helps['devops migrations cutover cancel'] = """ diff --git a/azure-devops/azext_devops/dev/migration/arguments.py b/azure-devops/azext_devops/dev/migration/arguments.py index f2c830468..f253c10d8 100644 --- a/azure-devops/azext_devops/dev/migration/arguments.py +++ b/azure-devops/azext_devops/dev/migration/arguments.py @@ -3,7 +3,6 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -from azure.cli.core.commands.parameters import get_three_state_flag from azext_devops.dev.common.arguments import convert_date_string_to_iso8601 from azext_devops.dev.team.arguments import load_global_args @@ -21,30 +20,26 @@ def load_migration_arguments(self, _): with self.argument_context('devops migrations create') as context: context.argument('target_repository', options_list='--target-repository', - help='Target GitHub repository URL. Example: https://github.com/OrgName/RepoName or ' - 'https://example.ghe.com/OrgName/RepoName') + help='Target repository URL.') 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', - help='Validate only (true/false). Defaults to true.', - arg_type=get_three_state_flag()) - context.argument('scheduled_cutover_date', options_list='--scheduled-cutover-date', + 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_name', options_list='--agent-pool-name', - help='Agent pool name for migration validation.') + 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='Comma-separated list of validation checks to skip. ' - 'Values: None, ActivePullRequestCount, PullRequestDeltaSize, ' - 'TargetRepoMigration, All.') + help='Comma-separated list of validation policies to skip.') with self.argument_context('devops migrations cutover set') as context: - context.argument('scheduled_cutover_date', options_list='--scheduled-cutover-date', + context.argument('cutover_date', options_list='--date', type=convert_date_string_to_iso8601, - help='Scheduled cutover date/time (ISO 8601).') + 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('migrate', options_list='--migrate', action='store_true', - help='Resume and start migration (validate-only off).') + context.argument('migration', options_list='--migration', action='store_true', + help='Continue the migration (clears any validate-only mode).') diff --git a/azure-devops/azext_devops/dev/migration/migration.py b/azure-devops/azext_devops/dev/migration/migration.py index ba4436703..453da3dc3 100644 --- a/azure-devops/azext_devops/dev/migration/migration.py +++ b/azure-devops/azext_devops/dev/migration/migration.py @@ -3,9 +3,6 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -import re -from urllib.parse import urlparse - from msrest import Configuration from msrest.service_client import ServiceClient from msrest.universal_http import ClientRequest @@ -18,37 +15,23 @@ API_VERSION = '7.2-preview' MIGRATIONS_API_PATH = '/_apis/elm/migrations' -_GHE_HOST_SUFFIX = '.ghe.com' -_GITHUB_HOST = 'github.com' _NON_ACTIVE_STATES = { - 'abandoned', - 'canceled', - 'cancelled', - 'complete', - 'completed', - 'failed', 'succeeded', + 'failed', 'suspended' } _ACTIVE_STAGES = { - 'codesync', - 'cutover', - 'migrating', - 'prsync', - 'synchronizing', - 'syncing', - 'validating', - 'validation' + 'queued', + 'validation', + 'synchronization', + 'cutover' } -_REPO_PART_RE = re.compile(r'^[A-Za-z0-9._-]+$') - - def list_migrations(include_inactive=False, 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 += '&includeInactive=true' + url += '&includeInactiveMigrations=true' return _send_request(client, 'GET', url) @@ -68,31 +51,26 @@ def get_migration(repository_id=None, organization=None, detect=None): def create_migration(repository_id=None, target_repository=None, target_owner_user_id=None, - validate_only=None, scheduled_cutover_date=None, agent_pool_name=None, + validate_only=False, cutover_date=None, agent_pool=None, skip_validation=None, organization=None, detect=None): - agent_pool_name = _normalize_optional_text(agent_pool_name) + agent_pool = _normalize_optional_text(agent_pool) skip_validation = _normalize_optional_text(skip_validation) - _validate_target_repository(target_repository) if not target_owner_user_id: raise CLIError('--target-owner-user-id must be specified.') + if not agent_pool: + raise CLIError('--agent-pool must be specified.') organization = _resolve_org_for_auth(organization, detect) repository_id = _resolve_repository_id(repository_id) - if validate_only is None: - validate_only = True - if validate_only is False: - raise CLIError('Create only supports validate-only migrations. Use resume --migrate to continue.') - payload = { 'targetRepository': target_repository, 'targetOwnerUserId': target_owner_user_id, - 'validateOnly': bool(validate_only) + 'validateOnly': bool(validate_only), + 'agentPoolName': agent_pool } - if scheduled_cutover_date is not None: - payload['scheduledCutoverDate'] = scheduled_cutover_date - if agent_pool_name is not None: - payload['agentPoolName'] = agent_pool_name + if cutover_date is not None: + payload['scheduledCutoverDate'] = cutover_date if skip_validation is not None: payload['skipValidation'] = skip_validation @@ -105,31 +83,31 @@ 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, migrate=False, organization=None, detect=None): - if validate_only and migrate: - raise CLIError('Please specify only one of --validate-only or --migrate.') +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.') - migration = get_migration(repository_id=repository_id, organization=organization, detect=detect) - if _is_migration_active(migration): - state = migration.get('state') - stage = migration.get('stage') - raise CLIError('Migration is active (state: {}, stage: {}). Pause it before resuming or changing mode.' - .format(state, stage)) + migration_data = get_migration(repository_id=repository_id, organization=organization, detect=detect) + if _is_migration_active(migration_data): + status = migration_data.get('status') + stage = migration_data.get('stage') + raise CLIError('Migration is active (status: {}, stage: {}). Pause it before resuming or changing mode.' + .format(status, stage)) validate_only_value = None if validate_only: validate_only_value = True - elif migrate: + elif migration: validate_only_value = False return _update_migration(repository_id, organization, detect, validate_only=validate_only_value, status_requested='active') -def schedule_cutover(repository_id=None, scheduled_cutover_date=None, organization=None, detect=None): - if not scheduled_cutover_date: - raise CLIError('--scheduled-cutover-date must be specified.') - return _update_migration(repository_id, organization, detect, scheduled_cutover_date=scheduled_cutover_date, +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) @@ -138,18 +116,6 @@ def cancel_cutover(repository_id=None, organization=None, detect=None): include_cutover=True) -def set_validate_only(repository_id=None, on=False, off=False, organization=None, detect=None): - if on and off: - raise CLIError('Please specify only one of --on or --off.') - if not on and not off: - raise CLIError('Please specify --on or --off.') - return _update_migration(repository_id, organization, detect, validate_only=on) - - -def migrate_migration(repository_id=None, organization=None, detect=None): - return _update_migration(repository_id, organization, detect, validate_only=False, status_requested='active') - - def delete_migration(repository_id=None, organization=None, detect=None): organization = _resolve_org_for_auth(organization, detect) repository_id = _resolve_repository_id(repository_id) @@ -183,35 +149,6 @@ def _resolve_repository_id(repository_id): return repository_id -def _validate_target_repository(target_repository): - if not target_repository: - raise CLIError('--target-repository must be specified.') - - parsed = urlparse(target_repository) - if parsed.scheme != 'https': - raise CLIError('--target-repository must be a https://github.com/OrgName/RepoName or ' - 'https://.ghe.com/OrgName/RepoName URL.') - - host = parsed.netloc.lower() - if not (host == _GITHUB_HOST or host.endswith(_GHE_HOST_SUFFIX)): - raise CLIError('--target-repository must be a https://github.com/OrgName/RepoName or ' - 'https://.ghe.com/OrgName/RepoName URL.') - - repo_path = parsed.path.strip('/') - if not _is_owner_repo(repo_path): - raise CLIError('--target-repository must be a https://github.com/OrgName/RepoName or ' - 'https://.ghe.com/OrgName/RepoName URL.') - - -def _is_owner_repo(value): - parts = value.split('/') - if len(parts) != 2: - return False - if not all(_REPO_PART_RE.match(part or '') for part in parts): - return False - return True - - def _normalize_state(value): if value is None: return '' @@ -223,9 +160,9 @@ def _is_migration_active(migration): if not isinstance(migration, dict): return False - state = _normalize_state(migration.get('state')) - if state: - return state not in _NON_ACTIVE_STATES + status = _normalize_state(migration.get('status')) + if status: + return status not in _NON_ACTIVE_STATES stage = _normalize_state(migration.get('stage')) if stage: 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 eff08e89c..813f59141 100644 --- a/azure-devops/azext_devops/tests/latest/migration/test_migration.py +++ b/azure-devops/azext_devops/tests/latest/migration/test_migration.py @@ -38,7 +38,7 @@ def test_list_migrations_calls_get(self): self.assertEqual(args[1], 'GET') self.assertTrue(args[2].startswith(self._TEST_ORG.rstrip('/'))) self.assertIn('/_apis/elm/migrations', args[2]) - self.assertNotIn('includeInactive', 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, \ @@ -51,9 +51,9 @@ def test_list_migrations_include_inactive(self): args = mock_send.call_args[0] self.assertEqual(args[1], 'GET') - self.assertIn('includeInactive=true', args[2]) + self.assertIn('includeInactiveMigrations=true', args[2]) - def test_create_migration_payload_defaults_validate_only_true(self): + 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: @@ -64,23 +64,24 @@ def test_create_migration_payload_defaults_validate_only_true(self): 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.assertTrue(payload['validateOnly']) + self.assertFalse(payload['validateOnly']) - def test_create_migration_rejects_validate_only_false(self): - with self.assertRaises(CLIError): + def test_create_migration_requires_agent_pool(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', - validate_only=False, organization=self._TEST_ORG, detect=False ) + self.assertIn('--agent-pool', str(ctx.exception)) def test_create_migration_payload_includes_optional_fields(self): with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ @@ -93,8 +94,9 @@ def test_create_migration_payload_includes_optional_fields(self): repository_id='00000000-0000-0000-0000-000000000000', target_repository='https://example.ghe.com/OrgName/RepoName', target_owner_user_id='GeoffCoxMSFT', - scheduled_cutover_date='2030-12-31T11:59:00Z', - agent_pool_name='MigrationPool', + validate_only=True, + cutover_date='2030-12-31T11:59:00Z', + agent_pool='MigrationPool', skip_validation='ActivePullRequestCount,PullRequestDeltaSize', organization=self._TEST_ORG, detect=False @@ -106,7 +108,19 @@ def test_create_migration_payload_includes_optional_fields(self): self.assertEqual(payload['agentPoolName'], 'MigrationPool') self.assertEqual(payload['skipValidation'], 'ActivePullRequestCount,PullRequestDeltaSize') - def test_create_migration_omits_empty_optional_fields(self): + def test_create_migration_rejects_empty_agent_pool(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', + agent_pool=' ', + organization=self._TEST_ORG, + detect=False + ) + self.assertIn('--agent-pool', str(ctx.exception)) + + def test_create_migration_omits_empty_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: @@ -117,14 +131,13 @@ def test_create_migration_omits_empty_optional_fields(self): repository_id='00000000-0000-0000-0000-000000000000', target_repository='https://example.ghe.com/OrgName/RepoName', target_owner_user_id='GeoffCoxMSFT', - agent_pool_name=' ', + agent_pool='MigrationPool', skip_validation=' ', organization=self._TEST_ORG, detect=False ) payload = mock_send.call_args[0][3] - self.assertNotIn('agentPoolName', payload) self.assertNotIn('skipValidation', payload) def test_create_migration_trims_optional_fields(self): @@ -138,7 +151,7 @@ def test_create_migration_trims_optional_fields(self): repository_id='00000000-0000-0000-0000-000000000000', target_repository='https://example.ghe.com/OrgName/RepoName', target_owner_user_id='GeoffCoxMSFT', - agent_pool_name=' MigrationPool ', + agent_pool=' MigrationPool ', skip_validation=' ActivePullRequestCount, PullRequestDeltaSize ', organization=self._TEST_ORG, detect=False @@ -148,17 +161,26 @@ def test_create_migration_trims_optional_fields(self): self.assertEqual(payload['agentPoolName'], 'MigrationPool') self.assertEqual(payload['skipValidation'], 'ActivePullRequestCount, PullRequestDeltaSize') - def test_create_migration_rejects_invalid_target_repository(self): - with self.assertRaises(CLIError): + 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='microsoft/gcox-test-1', + target_repository='https://example.com/OrgName/RepoName', target_owner_user_id='GeoffCoxMSFT', + agent_pool='MigrationPool', organization=self._TEST_ORG, detect=False ) - def test_create_migration_accepts_ghe_url(self): + 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: @@ -169,11 +191,16 @@ def test_create_migration_accepts_ghe_url(self): 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 ) - def test_create_migration_accepts_github_url(self): + 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: @@ -182,21 +209,15 @@ def test_create_migration_accepts_github_url(self): create_migration( repository_id='00000000-0000-0000-0000-000000000000', - target_repository='https://github.com/OrgName/RepoName', + target_repository='https://example.ghe.com/OrgName/RepoName', target_owner_user_id='GeoffCoxMSFT', + agent_pool='MigrationPool', organization=self._TEST_ORG, detect=False ) - def test_create_migration_rejects_non_ghe_host(self): - with self.assertRaises(CLIError): - create_migration( - repository_id='00000000-0000-0000-0000-000000000000', - target_repository='https://example.com/OrgName/RepoName', - target_owner_user_id='GeoffCoxMSFT', - 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, \ @@ -217,13 +238,13 @@ def test_cancel_cutover_sets_null(self): def test_resume_rejects_both_flags(self): with self.assertRaises(CLIError): resume_migration(repository_id='00000000-0000-0000-0000-000000000000', - validate_only=True, migrate=True, + 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 = {'state': 'active', 'stage': 'synchronizing'} + mock_get.return_value = {'status': 'active', 'stage': 'synchronization'} mock_resolve.return_value = self._TEST_ORG with self.assertRaises(CLIError): @@ -236,7 +257,7 @@ def test_resume_sets_validate_only(self): 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 = {'state': 'succeeded'} + mock_get.return_value = {'status': 'succeeded'} mock_resolve.return_value = self._TEST_ORG resume_migration(repository_id='00000000-0000-0000-0000-000000000000', @@ -247,17 +268,17 @@ def test_resume_sets_validate_only(self): self.assertTrue(payload['validateOnly']) self.assertEqual(payload['statusRequested'], 'active') - def test_resume_sets_migrate(self): + 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 = {'state': 'suspended'} + mock_get.return_value = {'status': 'suspended'} mock_resolve.return_value = self._TEST_ORG resume_migration(repository_id='00000000-0000-0000-0000-000000000000', - migrate=True, + migration=True, organization=self._TEST_ORG, detect=False) payload = mock_send.call_args[0][3] @@ -270,7 +291,7 @@ def test_resume_without_flags_preserves_mode(self): 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 = {'state': 'failed'} + mock_get.return_value = {'status': 'failed'} mock_resolve.return_value = self._TEST_ORG resume_migration(repository_id='00000000-0000-0000-0000-000000000000', diff --git a/doc/migrations.md b/doc/migrations.md index 848e2f835..b0ca09ac7 100644 --- a/doc/migrations.md +++ b/doc/migrations.md @@ -11,21 +11,20 @@ The `az devops migrations` command group manages enterprise live migrations for ## Required inputs - `--repository-id` is the Azure Repos repository GUID. -- `--target-repository` must be a GitHub URL in this format: - `https://github.com/OrgName/RepoName` or `https://example.ghe.com/OrgName/RepoName` +- `--target-repository` is the target repository URL. - `--target-owner-user-id` is required for create. -- `--scheduled-cutover-date` must be ISO 8601, for example: `2030-12-31T11:59:00Z`. +- `--agent-pool` is required for create. +- `--cutover-date` / `--date` must be ISO 8601, for example: `2030-12-31T11:59:00Z`. ## Command reference -- `list`: List migrations for the ELM org. +- `list`: List migrations for the ELM org. Use `--include-inactive` to include completed/failed/suspended migrations. - `status`: Show migration status for a repository GUID. -- `create`: Create a validation-only migration. `--validate-only false` is not supported. - Use `resume --migrate` to move from validation to migration. +- `create`: Create a migration. Use `--validate-only` for pre-migration checks only. - `pause`: Pause an active migration. -- `resume`: Resume a non-active migration. Optional flags: - - `--validate-only`: Resume and force validate-only. - - `--migrate`: Resume and start migration. +- `resume`: Resume a stopped (paused, failed) migration. Optional flags: + - `--validate-only`: Resume in validate-only mode. + - `--migration`: Continue the migration (clears validate-only mode). If a migration is active, pause it before resuming. - `cutover set` / `cutover cancel`: Schedule or cancel cutover. - `abandon`: Abandon and delete a migration. @@ -38,6 +37,12 @@ The `az devops migrations` command group manages enterprise live migrations for az devops migrations list --org https://elm.contoso.com/elmo1 ``` +### List all migrations including inactive + +```bash +az devops migrations list --org https://elm.contoso.com/elmo1 --include-inactive +``` + ### Check migration status ```bash @@ -45,13 +50,24 @@ az devops migrations status --org https://elm.contoso.com/elmo1 \ --repository-id 00000000-0000-0000-0000-000000000000 ``` -### Create a validation-only migration +### Create a migration + +```bash +az devops migrations create --org https://elm.contoso.com/elmo1 \ + --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://elm.contoso.com/elmo1 \ --repository-id 00000000-0000-0000-0000-000000000000 \ - --target-repository https://github.com/OrgName/RepoName \ + --target-repository https://example.ghe.com/OrgName/RepoName \ --target-owner-user-id OwnerId \ + --agent-pool MigrationPool \ --validate-only ``` @@ -68,7 +84,7 @@ az devops migrations resume --org https://elm.contoso.com/elmo1 \ --repository-id 00000000-0000-0000-0000-000000000000 --validate-only az devops migrations resume --org https://elm.contoso.com/elmo1 \ - --repository-id 00000000-0000-0000-0000-000000000000 --migrate + --repository-id 00000000-0000-0000-0000-000000000000 --migration ``` ### Schedule or cancel cutover @@ -76,7 +92,7 @@ az devops migrations resume --org https://elm.contoso.com/elmo1 \ ```bash az devops migrations cutover set --org https://elm.contoso.com/elmo1 \ --repository-id 00000000-0000-0000-0000-000000000000 \ - --scheduled-cutover-date 2030-12-31T11:59:00Z + --date 2030-12-31T11:59:00Z az devops migrations cutover cancel --org https://elm.contoso.com/elmo1 \ --repository-id 00000000-0000-0000-0000-000000000000 From 010c80ed02eb554ab226f699b61e2bd793c22e54 Mon Sep 17 00:00:00 2001 From: Bhuvan Shah Date: Fri, 27 Mar 2026 12:45:39 -0700 Subject: [PATCH 15/32] Update ELM migrations TSG with current params, codedev.ms troubleshooting, output formats --- doc/elm_migrations_tsg.md | 185 +++++++++++++++++++++++++++++--------- 1 file changed, 143 insertions(+), 42 deletions(-) diff --git a/doc/elm_migrations_tsg.md b/doc/elm_migrations_tsg.md index 488030eef..6a67bc702 100644 --- a/doc/elm_migrations_tsg.md +++ b/doc/elm_migrations_tsg.md @@ -1,87 +1,188 @@ -# ELM migrations troubleshooting guide (TSG) +# ELM Migrations Troubleshooting Guide (TSG) ## Scope - Applies to `az devops migrations` commands in the azure-devops CLI extension. - Migration direction: Azure DevOps (source) to GitHub (target) via the ELM service. -## Key concepts +## Key Concepts -- `--org` for migrations is the ELM service base URL, not the ADO org. -- The ADO org is only used to look up the source repo GUID (`az repos show`). -- `--detect` defaults to true. If you run commands inside an ADO git repo, auto-detect can override `--org`. - Use `--detect false` or run from a directory that is not inside an ADO repo. +| Concept | Details | +|---|---| +| `--org` | The **ELM service base URL** (e.g., `https://elm.contoso.com/elmo1`). This is NOT your ADO org URL. | +| `--repository-id` | The Azure Repos repository **GUID**. Get it from `az repos show --query id`. | +| `--detect` | Defaults to `true`. Auto-detects org from git remote. Use `--detect false` if outside an ADO repo or to avoid override. | +| Default org | Set with `az devops configure -d organization=` so you can omit `--org` on every call. | +| `--validate-only` | Runs pre-migration checks only (no data movement). Default is `false` — omit the flag for a full migration. | -## Quick start +## Quick Start -1) Get source repo GUID from ADO: +### Step 1: Get source repo GUID from ADO ```powershell az repos show --org https://dev.azure.com// --project --repository --query id -o tsv ``` -2) Create a validation-only migration (default is validate-only): +### Step 2: Create a migration ```powershell -az devops migrations create --org https:///elm --detect false \ +az devops migrations create --org https:///elmo1 --detect false \ --repository-id \ - --target-repository https://// \ - --target-owner-user-id + --target-repository https://// \ + --target-owner-user-id \ + --agent-pool ``` -3) Check status: +To create in validate-only mode (pre-migration checks only), add `--validate-only`: ```powershell -az devops migrations status --org https:///elm --detect false --repository-id +az devops migrations create --org https:///elmo1 --detect false \ + --repository-id \ + --target-repository https://// \ + --target-owner-user-id \ + --agent-pool \ + --validate-only ``` -4) Start full migration after validation passes: +### Step 3: Check status ```powershell -az devops migrations resume --org https:///elm --detect false --repository-id --migrate +az devops migrations status --org https:///elmo1 --detect false --repository-id ``` -5) Re-run validation (optional): +### Step 4: Pause, resume, or change mode + +```powershell +# Pause an active migration +az devops migrations pause --org https:///elmo1 --detect false --repository-id + +# Resume (keeps current mode) +az devops migrations resume --org https:///elmo1 --detect false --repository-id + +# Resume and switch to validate-only mode +az devops migrations resume --org https:///elmo1 --detect false --repository-id --validate-only + +# Resume and switch to full migration mode +az devops migrations resume --org https:///elmo1 --detect false --repository-id --migration +``` + +> **Note:** You must pause an active migration before resuming with a different mode. + +### Step 5: Schedule cutover ```powershell -az devops migrations resume --org https:///elm --detect false --repository-id --validate-only +az devops migrations cutover set --org https:///elmo1 --detect false \ + --repository-id --date 2030-12-31T11:59:00Z ``` -6) Optional cutover: +### Step 6: Abandon a migration ```powershell -az devops migrations cutover set --org https:///elm --detect false \ - --repository-id --scheduled-cutover-date 2030-12-31T11:59:00Z +az devops migrations abandon --org https:///elmo1 --detect false --repository-id ``` -## Common pitfalls +> **Warning:** This permanently deletes the migration. You will be prompted to confirm. + +## Common Pitfalls + +| Pitfall | Symptom | Fix | +|---|---|---| +| **Using ADO org URL instead of ELM URL** | 404 or unexpected errors | Use the ELM service base URL for `--org`, not `https://dev.azure.com/...` | +| **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=` 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 `--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` | + +## 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). +2. Ensure the token/account has permission to the ELM service. +3. Verify `--org` points to the correct ELM URL. + +### 404 Not Found + +**Symptom:** `Request failed with status 404`. + +**Fix:** +1. Verify the ELM base URL is correct (e.g., `https://elm.contoso.com/elmo1`). +2. Verify the `--repository-id` is a valid GUID that exists in the ELM service. -- **Auto-detect override**: If you are inside an ADO repo, `--detect` may override your ELM base URL. - Use `--detect false`. -- **Wrong org for migrations**: Using `https://dev.azure.com/...` with `az devops migrations` will hit ADO - instead of ELM and fail. -- **Too many parallel migrations**: Run one migration at a time unless your service owner says otherwise. -- **Resume fails while active**: `resume` is meant for non-active states (succeeded, failed, suspended). Pause first if needed. +### 400 Bad Request -## Common errors and fixes +**Symptom:** `Request failed with status 400` or `JsonReaderException`. -- **401/403 Unauthorized**: You are not logged in or the token lacks permission. - Run `az devops login` (PAT) or `az login` (AAD) as required by your environment. -- **404 Not Found**: The ELM base URL or repo GUID is incorrect. -- **406 Not Acceptable**: The ELM service rejected the request. Verify the ELM base URL includes `/elm`, - confirm you are using the latest extension, and contact the service owner if it persists. -- **Warning: Azure DevOps Server not supported**: This can appear when using non-dev.azure.com URLs. - It is expected for ELM and can usually be ignored. -- **Target repo validation error**: The CLI validates that the target host is `github.com` or `*.ghe.com`. - If you see an error like `--target-repository must be a https://github.com/OrgName/RepoName or https://.ghe.com/OrgName/RepoName URL`, - verify the target URL host or update the validation rule for your environment. +**Fix:** +1. Check date values are valid ISO 8601 strings (e.g., `2030-12-31T11:59:00Z`). +2. Ensure `--target-repository` is a valid URL. +3. Ensure `--agent-pool` matches a pool name the service recognizes. -## Useful commands +### 406 Not Acceptable + +**Symptom:** `Request failed with status 406`. + +**Fix:** +1. Verify the ELM base URL is correct. +2. Confirm you are using the latest CLI extension version. +3. Contact the service owner 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 ELM URL). + - Run `az devops configure -l` to check your default org. + - Fix with `az devops configure -d organization=`. + - Or pass `--org --detect false` explicitly. +2. If the correct host is being used, the ELM service may be down — retry later or contact the service owner. + +### "Warning: Azure DevOps Server not supported" + +**Symptom:** Warning message appears but command may still work. + +**Fix:** This warning is expected when using non-`dev.azure.com` URLs (like ELM URLs). It can be safely ignored. + +## Useful Commands ```powershell # Check extension version az extension show -n azure-devops --query "{name:name,version:version}" -o json -# Set default org to the ELM base -az devops configure -d organization=https:///elm +# Set default org to the ELM base (so you can omit --org) +az devops configure -d organization=https:///elmo1 + +# 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 ``` + +## 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`) | From 8dc649cc962613485c0d73a26d057997e49ebce2 Mon Sep 17 00:00:00 2001 From: Bhuvan Shah Date: Fri, 27 Mar 2026 12:47:47 -0700 Subject: [PATCH 16/32] TSG: add complete command/param reference, list step, cutover cancel, all optional params --- doc/elm_migrations_tsg.md | 72 +++++++++++++++++++++++++++++++++++---- 1 file changed, 66 insertions(+), 6 deletions(-) diff --git a/doc/elm_migrations_tsg.md b/doc/elm_migrations_tsg.md index 6a67bc702..18182e09b 100644 --- a/doc/elm_migrations_tsg.md +++ b/doc/elm_migrations_tsg.md @@ -23,7 +23,19 @@ az repos show --org https://dev.azure.com// --project --repository --query id -o tsv ``` -### Step 2: Create a migration +### Step 2: List existing migrations + +```powershell +# List active migrations +az devops migrations list --org https:///elmo1 --detect false + +# List all migrations (including completed, failed, suspended) +az devops migrations list --org https:///elmo1 --detect false --include-inactive +``` + +### Step 3: Create a migration + +**Minimum required:** ```powershell az devops migrations create --org https:///elmo1 --detect false \ @@ -33,7 +45,7 @@ az devops migrations create --org https:///elmo1 --detect false \ --agent-pool ``` -To create in validate-only mode (pre-migration checks only), add `--validate-only`: +**With validate-only mode** (pre-migration checks, no data movement): ```powershell az devops migrations create --org https:///elmo1 --detect false \ @@ -44,13 +56,26 @@ az devops migrations create --org https:///elmo1 --detect false \ --validate-only ``` -### Step 3: Check status +**With all optional parameters:** + +```powershell +az devops migrations create --org https:///elmo1 --detect false \ + --repository-id \ + --target-repository https://// \ + --target-owner-user-id \ + --agent-pool \ + --validate-only \ + --cutover-date 2030-12-31T11:59:00Z \ + --skip-validation ActivePullRequestCount,PullRequestDeltaSize +``` + +### Step 4: Check status ```powershell az devops migrations status --org https:///elmo1 --detect false --repository-id ``` -### Step 4: Pause, resume, or change mode +### Step 5: Pause, resume, or change mode ```powershell # Pause an active migration @@ -68,14 +93,19 @@ az devops migrations resume --org https:///elmo1 --detect false --repo > **Note:** You must pause an active migration before resuming with a different mode. -### Step 5: Schedule cutover +### Step 6: Schedule or cancel cutover ```powershell +# Schedule a cutover date az devops migrations cutover set --org https:///elmo1 --detect false \ --repository-id --date 2030-12-31T11:59:00Z + +# Cancel a scheduled cutover +az devops migrations cutover cancel --org https:///elmo1 --detect false \ + --repository-id ``` -### Step 6: Abandon a migration +### Step 7: Abandon a migration ```powershell az devops migrations abandon --org https:///elmo1 --detect false --repository-id @@ -83,6 +113,36 @@ az devops migrations abandon --org https:///elmo1 --detect false --rep > **Warning:** This permanently deletes the migration. You will be prompted to confirm. +## 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). | + +### Parameter Details + +| Parameter | Type | Used By | Description | +|---|---|---|---| +| `--org` | URL | All | ELM service base URL (e.g., `https://elm.contoso.com/elmo1`). 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-owner-user-id` | string | `create` | Target repository owner user ID. | +| `--agent-pool` | string | `create` | Agent pool name for migration work. Required. | +| `--validate-only` | flag | `create`, `resume` | On `create`: run pre-migration checks only. On `resume`: switch to validate-only mode. | +| `--migration` | flag | `resume` | Switch to full migration mode (clears validate-only). 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 | `create` | Comma-separated list of validation policies to skip. | +| `--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. | + ## Common Pitfalls | Pitfall | Symptom | Fix | From f714a52f0dbcee1c0468c6281b26b20e3363b05f Mon Sep 17 00:00:00 2001 From: Bhuvan Shah Date: Thu, 2 Apr 2026 13:37:28 -0700 Subject: [PATCH 17/32] TSG: restructure as end-to-end guide with setup, lifecycle, walkthrough, and scenarios --- doc/elm_migrations_tsg.md | 243 +++++++++++++++++++++++++++++--------- 1 file changed, 184 insertions(+), 59 deletions(-) diff --git a/doc/elm_migrations_tsg.md b/doc/elm_migrations_tsg.md index 18182e09b..b013f8387 100644 --- a/doc/elm_migrations_tsg.md +++ b/doc/elm_migrations_tsg.md @@ -1,66 +1,135 @@ -# ELM Migrations Troubleshooting Guide (TSG) +# ELM Migrations — End-to-End Guide & Troubleshooting (TSG) -## Scope +Migrate Git repositories from Azure DevOps to GitHub using the `az devops migrations` CLI commands. -- Applies to `az devops migrations` commands in the azure-devops CLI extension. -- Migration direction: Azure DevOps (source) to GitHub (target) via the ELM service. +--- -## Key Concepts +## 1. Prerequisites & Setup -| Concept | Details | -|---|---| -| `--org` | The **ELM service base URL** (e.g., `https://elm.contoso.com/elmo1`). This is NOT your ADO org URL. | -| `--repository-id` | The Azure Repos repository **GUID**. Get it from `az repos show --query id`. | -| `--detect` | Defaults to `true`. Auto-detects org from git remote. Use `--detect false` if outside an ADO repo or to avoid override. | -| Default org | Set with `az devops configure -d organization=` so you can omit `--org` on every call. | -| `--validate-only` | Runs pre-migration checks only (no data movement). Default is `false` — omit the flag for a full migration. | +### 1.1 Install Azure CLI (if not already installed) -## Quick Start +Follow [Install the Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli). -### Step 1: Get source repo GUID from ADO +### 1.2 Install the ELM extension from the wheel file ```powershell -az repos show --org https://dev.azure.com// --project --repository --query id -o tsv +# Remove any existing version first +az extension remove -n azure-devops + +# Install from wheel +az extension add --source ./azure_devops-1.0.3-py2.py3-none-any.whl -y + +# Verify installation +az extension show -n azure-devops --query "{name:name,version:version}" -o json ``` -### Step 2: List existing migrations +### 1.3 Sign in ```powershell -# List active migrations -az devops migrations list --org https:///elmo1 --detect false +# Option A: Azure AD (recommended) +az login -# List all migrations (including completed, failed, suspended) -az devops migrations list --org https:///elmo1 --detect false --include-inactive +# Option B: Personal Access Token +az devops login ``` -### Step 3: Create a migration +### 1.4 Set your default ELM org (recommended) -**Minimum required:** +This avoids passing `--org` on every command: ```powershell -az devops migrations create --org https:///elmo1 --detect false \ - --repository-id \ - --target-repository https://// \ - --target-owner-user-id \ - --agent-pool +az devops configure -d organization=https:///elmo1 +``` + +> **Important:** `--org` is the **ELM service base URL** (e.g., `https://elm.contoso.com/elmo1`), NOT your Azure DevOps org URL (`https://dev.azure.com/...`). + +### 1.5 Verify your config + +```powershell +az devops configure -l +``` + +If you see a stale or wrong URL (e.g., `codedev.ms`), 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 → Pause → Resume (--migration) → Monitor → Schedule cutover → Done +``` + +--- + +## 3. End-to-End Walkthrough + +### What you'll need before starting + +| Item | Example | How to get it | +|---|---|---| +| ELM service URL | `https://elm.contoso.com/elmo1` | From your ELM service owner | +| Source repo GUID | `b3e18946-5b39-40ca-8e2f-d0eb683d8a85` | Step 3.1 below | +| Target repo URL | `https://example.ghe.com/OrgName/RepoName` | Create the empty target repo in GitHub first | +| Target owner user ID | `GeoffCoxMSFT` | The GitHub user ID who owns the target repo | +| Agent pool name | `MigrationPool` | From your ELM service owner | + +### 3.1 Get the source repository GUID from Azure DevOps + +```powershell +az repos show --org https://dev.azure.com// --project --repository --query id -o tsv ``` -**With validate-only mode** (pre-migration checks, no data movement): +Save this GUID — you'll use it in every command below. + +### 3.2 (Optional) Check for existing migrations ```powershell -az devops migrations create --org https:///elmo1 --detect false \ - --repository-id \ +# Active migrations only +az devops migrations list --detect false + +# 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: + +```powershell +az devops migrations create --detect false \ + --repository-id \ --target-repository https://// \ --target-owner-user-id \ --agent-pool \ --validate-only ``` -**With all optional parameters:** +> **Tip:** If you're confident and want to skip validate-only, omit the `--validate-only` flag to create a full migration directly. + +You can also set optional parameters at creation time: ```powershell -az devops migrations create --org https:///elmo1 --detect false \ - --repository-id \ +az devops migrations create --detect false \ + --repository-id \ --target-repository https://// \ --target-owner-user-id \ --agent-pool \ @@ -69,51 +138,107 @@ az devops migrations create --org https:///elmo1 --detect false \ --skip-validation ActivePullRequestCount,PullRequestDeltaSize ``` -### Step 4: Check status +### 3.4 Monitor migration status + +Check status anytime: ```powershell -az devops migrations status --org https:///elmo1 --detect false --repository-id +az devops migrations status --detect false --repository-id ``` -### Step 5: Pause, resume, or change mode +For full details (all fields from the API): ```powershell -# Pause an active migration -az devops migrations pause --org https:///elmo1 --detect false --repository-id +az devops migrations status --detect false --repository-id -o json +``` + +**What to look for:** +- `status: Active` + `stage: Validation` → validation is running +- `status: Active` + `stage: Synchronization` → data is syncing +- `status: Failed` → check the error, fix the issue, then resume +- `status: Succeeded` + `stage: Validation` → validation passed, ready to promote to migration -# Resume (keeps current mode) -az devops migrations resume --org https:///elmo1 --detect false --repository-id +### 3.5 Promote from validate-only to full migration -# Resume and switch to validate-only mode -az devops migrations resume --org https:///elmo1 --detect false --repository-id --validate-only +Once validation passes, pause and resume with `--migration` to start moving data: -# Resume and switch to full migration mode -az devops migrations resume --org https:///elmo1 --detect false --repository-id --migration +```powershell +# Step A: Pause the current validation +az devops migrations pause --detect false --repository-id + +# Step B: Resume as a full migration +az devops migrations resume --detect false --repository-id --migration ``` -> **Note:** You must pause an active migration before resuming with a different mode. +> **Important:** You **must pause first** if the migration is active. Running `resume` on an active migration gives: `Migration is active (status: ..., stage: ...). Pause it before resuming or changing mode.` + +### 3.6 Schedule cutover -### Step 6: Schedule or cancel cutover +Once synchronization is running and you're ready to finalize: ```powershell -# Schedule a cutover date -az devops migrations cutover set --org https:///elmo1 --detect false \ +az devops migrations cutover set --detect false \ --repository-id --date 2030-12-31T11:59:00Z +``` + +Changed your mind? Cancel it: -# Cancel a scheduled cutover -az devops migrations cutover cancel --org https:///elmo1 --detect false \ - --repository-id +```powershell +az devops migrations cutover cancel --detect false --repository-id ``` -### Step 7: Abandon a migration +### 3.7 Verify completion + +After cutover completes, check that the migration reached the `Migrated` stage: ```powershell -az devops migrations abandon --org https:///elmo1 --detect false --repository-id +az devops migrations status --detect false --repository-id +``` + +Expected output: `status: Succeeded`, `stage: Migrated`. + +### 3.8 (If needed) Abandon a migration + +If something went wrong and you want to start over: + +```powershell +az devops migrations abandon --detect false --repository-id ``` > **Warning:** This permanently deletes the migration. You will be prompted to confirm. -## Complete Command & Parameter Reference +--- + +## 4. Other Scenarios + +### Pause and resume without changing mode + +```powershell +# Pause +az devops migrations pause --detect false --repository-id + +# Resume (keeps whatever mode it was in) +az devops migrations resume --detect false --repository-id +``` + +### Switch back to validate-only after starting migration + +```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 can resume it (no pause needed since it's already stopped): + +```powershell +az devops migrations resume --detect false --repository-id +``` + +--- + +## 5. Complete Command & Parameter Reference | Command | Required Params | Optional Params | HTTP | Description | |---|---|---|---|---| @@ -126,7 +251,7 @@ az devops migrations abandon --org https:///elmo1 --detect false --rep | `cutover cancel` | `--org`, `--repository-id` | `--detect` | PUT | Cancel a scheduled cutover. | | `abandon` | `--org`, `--repository-id` | `--detect` | DELETE | Permanently delete a migration (prompts for confirmation). | -### Parameter Details +### 5.1 Parameter Details | Parameter | Type | Used By | Description | |---|---|---|---| @@ -143,7 +268,7 @@ az devops migrations abandon --org https:///elmo1 --detect false --rep | `--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. | -## Common Pitfalls +## 6. Common Pitfalls | Pitfall | Symptom | Fix | |---|---|---| @@ -156,7 +281,7 @@ az devops migrations abandon --org https:///elmo1 --detect false --rep | **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` | -## Common Errors and Fixes +## 7. Common Errors and Fixes ### Authentication Errors (401 / 403) @@ -210,7 +335,7 @@ az devops migrations abandon --org https:///elmo1 --detect false --rep **Fix:** This warning is expected when using non-`dev.azure.com` URLs (like ELM URLs). It can be safely ignored. -## Useful Commands +## 8. Useful Commands ```powershell # Check extension version @@ -238,7 +363,7 @@ az devops migrations list --include-inactive az devops migrations status --repository-id -o json ``` -## Output Formats +## 9. Output Formats | Flag | Format | Best for | |---|---|---| From 39228887707d07b7417f70f1207b548b8c083c86 Mon Sep 17 00:00:00 2001 From: Bhuvan Shah Date: Thu, 2 Apr 2026 13:49:15 -0700 Subject: [PATCH 18/32] TSG: add fresh-user onboarding, concrete examples, status interpretation table, shell note --- doc/elm_migrations_tsg.md | 157 ++++++++++++++++++++++++-------------- 1 file changed, 99 insertions(+), 58 deletions(-) diff --git a/doc/elm_migrations_tsg.md b/doc/elm_migrations_tsg.md index b013f8387..b065ca69e 100644 --- a/doc/elm_migrations_tsg.md +++ b/doc/elm_migrations_tsg.md @@ -2,6 +2,8 @@ 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 @@ -10,38 +12,48 @@ Migrate Git repositories from Azure DevOps to GitHub using the `az devops migrat 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 +# Remove any existing version first (ignore errors if not installed) az extension remove -n azure-devops -# Install from wheel +# 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 +# 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 (recommended) +# Option A: Azure AD / Entra ID (recommended) az login -# Option B: Personal Access Token +# Option B: Personal Access Token (needs "Full access" or at minimum Code Read/Write scope) az devops login ``` ### 1.4 Set your default ELM org (recommended) -This avoids passing `--org` on every command: +This saves you from typing `--org` on every single command: ```powershell az devops configure -d organization=https:///elmo1 ``` -> **Important:** `--org` is the **ELM service base URL** (e.g., `https://elm.contoso.com/elmo1`), NOT your Azure DevOps org URL (`https://dev.azure.com/...`). +> **Important:** `--org` is the **ELM service base URL** (e.g., `https://elm.contoso.com/elmo1`). +> This is **NOT** your Azure DevOps org URL (`https://dev.azure.com/myorg`). +> Ask your ELM service owner for the correct URL if you don't have it. ### 1.5 Verify your config @@ -49,7 +61,7 @@ az devops configure -d organization=https:///elmo1 az devops configure -l ``` -If you see a stale or wrong URL (e.g., `codedev.ms`), re-run step 1.4 with the correct URL. +You should see your ELM URL under `organization`. If you see a wrong URL (e.g., `codedev.ms` or `dev.azure.com`), re-run step 1.4 with the correct URL. --- @@ -86,22 +98,33 @@ Create (validate-only) → Check status → Pause → Resume (--migration) → M | Item | Example | How to get it | |---|---|---| -| ELM service URL | `https://elm.contoso.com/elmo1` | From your ELM service owner | -| Source repo GUID | `b3e18946-5b39-40ca-8e2f-d0eb683d8a85` | Step 3.1 below | -| Target repo URL | `https://example.ghe.com/OrgName/RepoName` | Create the empty target repo in GitHub first | +| ELM service URL | `https://elm.contoso.com/elmo1` | Ask your ELM service owner | +| Azure DevOps org URL | `https://dev.azure.com/myorg` | Your ADO org (only needed for step 3.1) | +| 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` | From your ELM service owner | +| Agent pool name | `MigrationPool` | Ask your ELM service owner | ### 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// --project --repository --query id -o tsv +az repos show --org https://dev.azure.com/myorg/ --project MyProject --repository my-repo --query id -o tsv +``` + +Example output: +``` +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 @@ -112,100 +135,109 @@ 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: +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 \ - --target-repository https://// \ - --target-owner-user-id \ - --agent-pool \ + --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 ``` -> **Tip:** If you're confident and want to skip validate-only, omit the `--validate-only` flag to create a full migration directly. +The command returns the migration details as JSON. The migration begins immediately in the background. -You can also set optional parameters at creation time: +> **Tip:** If you're confident and want to start a full migration right away (skip validate-only), omit the `--validate-only` flag. -```powershell -az devops migrations create --detect false \ - --repository-id \ - --target-repository https://// \ - --target-owner-user-id \ - --agent-pool \ - --validate-only \ - --cutover-date 2030-12-31T11:59:00Z \ - --skip-validation ActivePullRequestCount,PullRequestDeltaSize -``` +**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: +Check status anytime — run this as often as you need: ```powershell -az devops migrations status --detect false --repository-id +az devops migrations status --detect false --repository-id b3e18946-5b39-40ca-8e2f-d0eb683d8a85 ``` -For full details (all fields from the API): +For the full JSON response (useful for debugging): ```powershell -az devops migrations status --detect false --repository-id -o json +az devops migrations status --detect false --repository-id b3e18946-5b39-40ca-8e2f-d0eb683d8a85 -o json ``` -**What to look for:** -- `status: Active` + `stage: Validation` → validation is running -- `status: Active` + `stage: Synchronization` → data is syncing -- `status: Failed` → check the error, fix the issue, then resume -- `status: Succeeded` + `stage: Validation` → validation passed, ready to promote to migration +**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 -Once validation passes, pause and resume with `--migration` to start moving data: +**When to do this:** After step 3.4 shows `status: Succeeded` (validation passed). + +You need to pause first (because the migration may still be active), then resume in migration mode: ```powershell -# Step A: Pause the current validation -az devops migrations pause --detect false --repository-id +# Step A: Pause the current migration +az devops migrations pause --detect false --repository-id b3e18946-5b39-40ca-8e2f-d0eb683d8a85 -# Step B: Resume as a full migration -az devops migrations resume --detect false --repository-id --migration +# Step B: Resume as a full migration (this starts data movement) +az devops migrations resume --detect false --repository-id b3e18946-5b39-40ca-8e2f-d0eb683d8a85 --migration ``` -> **Important:** You **must pause first** if the migration is active. Running `resume` on an active migration gives: `Migration is active (status: ..., stage: ...). Pause it before resuming or changing mode.` +> **If you get:** `Migration is active (status: ..., stage: ...). Pause it before resuming or changing mode.` +> Run the pause command first (Step A), then retry Step B. + +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: +Once synchronization is running and you're ready to finalize the migration: ```powershell az devops migrations cutover set --detect false \ - --repository-id --date 2030-12-31T11:59:00Z + --repository-id b3e18946-5b39-40ca-8e2f-d0eb683d8a85 --date 2030-12-31T11:59:00Z ``` -Changed your mind? Cancel it: +> **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 +az devops migrations cutover cancel --detect false --repository-id b3e18946-5b39-40ca-8e2f-d0eb683d8a85 ``` ### 3.7 Verify completion -After cutover completes, check that the migration reached the `Migrated` stage: +After cutover completes, confirm the migration finished: ```powershell -az devops migrations status --detect false --repository-id +az devops migrations status --detect false --repository-id b3e18946-5b39-40ca-8e2f-d0eb683d8a85 ``` -Expected output: `status: Succeeded`, `stage: Migrated`. +**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 start over: +If something went wrong and you want to delete the migration entirely and start over: ```powershell -az devops migrations abandon --detect false --repository-id +az devops migrations abandon --detect false --repository-id b3e18946-5b39-40ca-8e2f-d0eb683d8a85 ``` -> **Warning:** This permanently deletes the migration. You will be prompted to confirm. +> **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. --- @@ -213,15 +245,19 @@ az devops migrations abandon --detect false --repository-id ### 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 it was in) +# 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 migration +### 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 @@ -230,10 +266,15 @@ az devops migrations resume --detect false --repository-id --validate-onl ### Resume a failed migration -If a migration fails, you can resume it (no pause needed since it's already stopped): +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 ``` --- From b17ee0ea8e64bcba62ac140e0d2ac8fba3b3c8c3 Mon Sep 17 00:00:00 2001 From: Bhuvan Shah Date: Thu, 2 Apr 2026 14:30:48 -0700 Subject: [PATCH 19/32] docs: update TSG to use ADO org URL instead of separate ELM service URL --- doc/elm_migrations_tsg.md | 48 +++++++++++++++------------------------ 1 file changed, 18 insertions(+), 30 deletions(-) diff --git a/doc/elm_migrations_tsg.md b/doc/elm_migrations_tsg.md index b065ca69e..681a32ee5 100644 --- a/doc/elm_migrations_tsg.md +++ b/doc/elm_migrations_tsg.md @@ -43,25 +43,21 @@ az login az devops login ``` -### 1.4 Set your default ELM org (recommended) +### 1.4 Set your default org (recommended) This saves you from typing `--org` on every single command: ```powershell -az devops configure -d organization=https:///elmo1 +az devops configure -d organization=https://dev.azure.com/ ``` -> **Important:** `--org` is the **ELM service base URL** (e.g., `https://elm.contoso.com/elmo1`). -> This is **NOT** your Azure DevOps org URL (`https://dev.azure.com/myorg`). -> Ask your ELM service owner for the correct URL if you don't have it. - ### 1.5 Verify your config ```powershell az devops configure -l ``` -You should see your ELM URL under `organization`. If you see a wrong URL (e.g., `codedev.ms` or `dev.azure.com`), re-run step 1.4 with the correct URL. +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. --- @@ -98,13 +94,12 @@ Create (validate-only) → Check status → Pause → Resume (--migration) → M | Item | Example | How to get it | |---|---|---| -| ELM service URL | `https://elm.contoso.com/elmo1` | Ask your ELM service owner | -| Azure DevOps org URL | `https://dev.azure.com/myorg` | Your ADO org (only needed for step 3.1) | +| 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 ELM service owner | +| Agent pool name | `MigrationPool` | Ask your admin | ### 3.1 Get the source repository GUID from Azure DevOps @@ -296,7 +291,7 @@ az devops migrations resume --detect false --repository-id --validate-onl | Parameter | Type | Used By | Description | |---|---|---|---| -| `--org` | URL | All | ELM service base URL (e.g., `https://elm.contoso.com/elmo1`). Can be set as default. | +| `--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-owner-user-id` | string | `create` | Target repository owner user ID. | @@ -313,9 +308,8 @@ az devops migrations resume --detect false --repository-id --validate-onl | Pitfall | Symptom | Fix | |---|---|---| -| **Using ADO org URL instead of ELM URL** | 404 or unexpected errors | Use the ELM service base URL for `--org`, not `https://dev.azure.com/...` | | **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=` to update | +| **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 ` | @@ -330,16 +324,16 @@ az devops migrations resume --detect false --repository-id --validate-onl **Fix:** 1. Run `az login` (AAD) or `az devops login` (PAT). -2. Ensure the token/account has permission to the ELM service. -3. Verify `--org` points to the correct ELM URL. +2. Ensure the token/account has permission to the organization. +3. Verify `--org` points to the correct Azure DevOps org URL. ### 404 Not Found **Symptom:** `Request failed with status 404`. **Fix:** -1. Verify the ELM base URL is correct (e.g., `https://elm.contoso.com/elmo1`). -2. Verify the `--repository-id` is a valid GUID that exists in the ELM service. +1. Verify `--org` is correct (e.g., `https://dev.azure.com/myorg`). +2. Verify the `--repository-id` is a valid GUID that exists in the organization. ### 400 Bad Request @@ -355,26 +349,20 @@ az devops migrations resume --detect false --repository-id --validate-onl **Symptom:** `Request failed with status 406`. **Fix:** -1. Verify the ELM base URL is correct. +1. Verify `--org` is correct. 2. Confirm you are using the latest CLI extension version. -3. Contact the service owner if it persists. +3. 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 ELM URL). +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=`. + - Fix with `az devops configure -d organization=https://dev.azure.com/`. - Or pass `--org --detect false` explicitly. -2. If the correct host is being used, the ELM service may be down — retry later or contact the service owner. - -### "Warning: Azure DevOps Server not supported" - -**Symptom:** Warning message appears but command may still work. - -**Fix:** This warning is expected when using non-`dev.azure.com` URLs (like ELM URLs). It can be safely ignored. +2. If the correct host is being used, the service may be temporarily unavailable — retry later or contact your admin. ## 8. Useful Commands @@ -382,8 +370,8 @@ az devops migrations resume --detect false --repository-id --validate-onl # Check extension version az extension show -n azure-devops --query "{name:name,version:version}" -o json -# Set default org to the ELM base (so you can omit --org) -az devops configure -d organization=https:///elmo1 +# Set default org (so you can omit --org) +az devops configure -d organization=https://dev.azure.com/ # View current defaults az devops configure -l From 30708f00c5ba370395e79eef91d1d9f7f08a22d4 Mon Sep 17 00:00:00 2001 From: Bhuvan Shah Date: Thu, 2 Apr 2026 14:55:31 -0700 Subject: [PATCH 20/32] docs: update migrations.md to use ADO org URL instead of ELM service URL --- doc/migrations.md | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/doc/migrations.md b/doc/migrations.md index b0ca09ac7..ed06d4a99 100644 --- a/doc/migrations.md +++ b/doc/migrations.md @@ -6,7 +6,7 @@ The `az devops migrations` command group manages enterprise live migrations for - Azure DevOps CLI with the Azure DevOps extension installed. - Sign in using `az login` or `az devops login`. -- Use `--org` to authenticate and resolve credentials. For ELM migrations, `--org` is the ELM service base URL. +- Use `--org` to specify your Azure DevOps org URL (e.g., `https://dev.azure.com/myorg`). ## Required inputs @@ -18,7 +18,7 @@ The `az devops migrations` command group manages enterprise live migrations for ## Command reference -- `list`: List migrations for the ELM org. Use `--include-inactive` to include completed/failed/suspended migrations. +- `list`: List migrations for the org. Use `--include-inactive` to include completed/failed/suspended migrations. - `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. @@ -34,26 +34,26 @@ The `az devops migrations` command group manages enterprise live migrations for ### List migrations ```bash -az devops migrations list --org https://elm.contoso.com/elmo1 +az devops migrations list --org https://dev.azure.com/myorg ``` ### List all migrations including inactive ```bash -az devops migrations list --org https://elm.contoso.com/elmo1 --include-inactive +az devops migrations list --org https://dev.azure.com/myorg --include-inactive ``` ### Check migration status ```bash -az devops migrations status --org https://elm.contoso.com/elmo1 \ +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://elm.contoso.com/elmo1 \ +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 \ @@ -63,7 +63,7 @@ az devops migrations create --org https://elm.contoso.com/elmo1 \ ### Create a validate-only migration ```bash -az devops migrations create --org https://elm.contoso.com/elmo1 \ +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 \ @@ -74,33 +74,33 @@ az devops migrations create --org https://elm.contoso.com/elmo1 \ ### Pause and resume ```bash -az devops migrations pause --org https://elm.contoso.com/elmo1 \ +az devops migrations pause --org https://dev.azure.com/myorg \ --repository-id 00000000-0000-0000-0000-000000000000 -az devops migrations resume --org https://elm.contoso.com/elmo1 \ +az devops migrations resume --org https://dev.azure.com/myorg \ --repository-id 00000000-0000-0000-0000-000000000000 -az devops migrations resume --org https://elm.contoso.com/elmo1 \ +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://elm.contoso.com/elmo1 \ +az devops migrations resume --org https://dev.azure.com/myorg \ --repository-id 00000000-0000-0000-0000-000000000000 --migration ``` ### Schedule or cancel cutover ```bash -az devops migrations cutover set --org https://elm.contoso.com/elmo1 \ +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://elm.contoso.com/elmo1 \ +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://elm.contoso.com/elmo1 \ +az devops migrations abandon --org https://dev.azure.com/myorg \ --repository-id 00000000-0000-0000-0000-000000000000 ``` From 70a3ddae144711fad672d2e7bd1ed673812c9735 Mon Sep 17 00:00:00 2001 From: Bhuvan Shah Date: Mon, 6 Apr 2026 12:44:52 -0700 Subject: [PATCH 21/32] Fix statusRequested field handling in pause/resume and update help URLs --- .../azext_devops/dev/migration/_help.py | 20 +++++++++---------- .../azext_devops/dev/migration/migration.py | 6 +++--- .../tests/latest/migration/test_migration.py | 10 ++++++++++ 3 files changed, 23 insertions(+), 13 deletions(-) diff --git a/azure-devops/azext_devops/dev/migration/_help.py b/azure-devops/azext_devops/dev/migration/_help.py index 7af47d2b4..96c1a5c55 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 the ELM service base URL (for example: https://elm.contoso.com/elmo1).' + 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'] = """ @@ -19,10 +19,10 @@ def load_migration_help(): examples: - name: List migrations. text: | - az devops migrations list --org https://elm.contoso.com/elmo1 + az devops migrations list --org https://dev.azure.com/myorg - name: List all migrations including inactive ones. text: | - az devops migrations list --org https://elm.contoso.com/elmo1 --include-inactive + az devops migrations list --org https://dev.azure.com/myorg --include-inactive """ helps['devops migrations status'] = """ @@ -31,7 +31,7 @@ def load_migration_help(): examples: - name: Get migration status by repository id. text: | - az devops migrations status --org https://elm.contoso.com/elmo1 --repository-id 00000000-0000-0000-0000-000000000000 + az devops migrations status --org https://dev.azure.com/myorg --repository-id 00000000-0000-0000-0000-000000000000 """ helps['devops migrations create'] = """ @@ -40,10 +40,10 @@ def load_migration_help(): examples: - name: Create a migration. text: | - az devops migrations create --org https://elm.contoso.com/elmo1 --repository-id 00000000-0000-0000-0000-000000000000 --target-repository https://example.ghe.com/OrgName/RepoName --target-owner-user-id GeoffCoxMSFT --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 --target-owner-user-id OwnerUserId --agent-pool MigrationPool - name: Create a validate-only migration. text: | - az devops migrations create --org https://elm.contoso.com/elmo1 --repository-id 00000000-0000-0000-0000-000000000000 --target-repository https://example.ghe.com/OrgName/RepoName --target-owner-user-id GeoffCoxMSFT --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 --target-owner-user-id OwnerUserId --agent-pool MigrationPool --validate-only --skip-validation ActivePullRequestCount,PullRequestDeltaSize """ helps['devops migrations pause'] = """ @@ -57,13 +57,13 @@ def load_migration_help(): examples: - name: Resume using the current mode. text: | - az devops migrations resume --org https://elm.contoso.com/elmo1 --repository-id 00000000-0000-0000-0000-000000000000 + 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://elm.contoso.com/elmo1 --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 --validate-only - name: Continue migration (clears validate-only mode). text: | - az devops migrations resume --org https://elm.contoso.com/elmo1 --repository-id 00000000-0000-0000-0000-000000000000 --migration + az devops migrations resume --org https://dev.azure.com/myorg --repository-id 00000000-0000-0000-0000-000000000000 --migration """ helps['devops migrations abandon'] = """ @@ -82,7 +82,7 @@ def load_migration_help(): examples: - name: Schedule cutover. text: | - az devops migrations cutover set --org https://elm.contoso.com/elmo1 --repository-id 00000000-0000-0000-0000-000000000000 --date 2030-12-31T11:59:00Z + 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'] = """ diff --git a/azure-devops/azext_devops/dev/migration/migration.py b/azure-devops/azext_devops/dev/migration/migration.py index 453da3dc3..65f17cbe2 100644 --- a/azure-devops/azext_devops/dev/migration/migration.py +++ b/azure-devops/azext_devops/dev/migration/migration.py @@ -89,9 +89,9 @@ def resume_migration(repository_id=None, validate_only=False, migration=False, o migration_data = get_migration(repository_id=repository_id, organization=organization, detect=detect) if _is_migration_active(migration_data): - status = migration_data.get('status') + status = migration_data.get('statusRequested') or migration_data.get('status') stage = migration_data.get('stage') - raise CLIError('Migration is active (status: {}, stage: {}). Pause it before resuming or changing mode.' + raise CLIError('Migration is active (statusRequested: {}, stage: {}). Pause it before resuming or changing mode.' .format(status, stage)) validate_only_value = None @@ -160,7 +160,7 @@ def _is_migration_active(migration): if not isinstance(migration, dict): return False - status = _normalize_state(migration.get('status')) + status = _normalize_state(migration.get('statusRequested') or migration.get('status')) if status: return status not in _NON_ACTIVE_STATES 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 813f59141..daf218770 100644 --- a/azure-devops/azext_devops/tests/latest/migration/test_migration.py +++ b/azure-devops/azext_devops/tests/latest/migration/test_migration.py @@ -251,6 +251,16 @@ def test_resume_fails_when_active(self): resume_migration(repository_id='00000000-0000-0000-0000-000000000000', organization=self._TEST_ORG, detect=False) + 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): + resume_migration(repository_id='00000000-0000-0000-0000-000000000000', + organization=self._TEST_ORG, detect=False) + 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, \ From 0e468eb3c603ed77e11fd086ca804ca03f0b2e98 Mon Sep 17 00:00:00 2001 From: Bhuvan Shah Date: Mon, 6 Apr 2026 16:22:19 -0700 Subject: [PATCH 22/32] Make --agent-pool optional; server assigns default pool - Remove required check for --agent-pool in create_migration - Conditionally include agentPoolName in payload only when provided - Server auto-assigns EnterpriseLiveMigrationPool when omitted - Update tests: replace required-agent-pool tests with optional behavior tests --- .../azext_devops/dev/migration/migration.py | 5 ++-- .../tests/latest/migration/test_migration.py | 26 ++++++++++++++----- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/azure-devops/azext_devops/dev/migration/migration.py b/azure-devops/azext_devops/dev/migration/migration.py index 65f17cbe2..921372fa2 100644 --- a/azure-devops/azext_devops/dev/migration/migration.py +++ b/azure-devops/azext_devops/dev/migration/migration.py @@ -57,8 +57,6 @@ def create_migration(repository_id=None, target_repository=None, target_owner_us skip_validation = _normalize_optional_text(skip_validation) if not target_owner_user_id: raise CLIError('--target-owner-user-id must be specified.') - if not agent_pool: - raise CLIError('--agent-pool must be specified.') organization = _resolve_org_for_auth(organization, detect) repository_id = _resolve_repository_id(repository_id) @@ -67,8 +65,9 @@ def create_migration(repository_id=None, target_repository=None, target_owner_us 'targetRepository': target_repository, 'targetOwnerUserId': target_owner_user_id, 'validateOnly': bool(validate_only), - 'agentPoolName': agent_pool } + if agent_pool: + payload['agentPoolName'] = agent_pool if cutover_date is not None: payload['scheduledCutoverDate'] = cutover_date if skip_validation is not 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 daf218770..6078f43c0 100644 --- a/azure-devops/azext_devops/tests/latest/migration/test_migration.py +++ b/azure-devops/azext_devops/tests/latest/migration/test_migration.py @@ -72,8 +72,13 @@ def test_create_migration_payload_defaults_validate_only_false(self): payload = mock_send.call_args[0][3] self.assertFalse(payload['validateOnly']) - def test_create_migration_requires_agent_pool(self): - with self.assertRaises(CLIError) as ctx: + 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', @@ -81,7 +86,9 @@ def test_create_migration_requires_agent_pool(self): organization=self._TEST_ORG, detect=False ) - self.assertIn('--agent-pool', str(ctx.exception)) + + 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, \ @@ -108,8 +115,13 @@ def test_create_migration_payload_includes_optional_fields(self): self.assertEqual(payload['agentPoolName'], 'MigrationPool') self.assertEqual(payload['skipValidation'], 'ActivePullRequestCount,PullRequestDeltaSize') - def test_create_migration_rejects_empty_agent_pool(self): - with self.assertRaises(CLIError) as ctx: + 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', @@ -118,7 +130,9 @@ def test_create_migration_rejects_empty_agent_pool(self): organization=self._TEST_ORG, detect=False ) - self.assertIn('--agent-pool', str(ctx.exception)) + + payload = mock_send.call_args[0][3] + self.assertNotIn('agentPoolName', payload) def test_create_migration_omits_empty_skip_validation(self): with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ From e28df3a9ab4305d1f295a7da5c5562b222d049d7 Mon Sep 17 00:00:00 2001 From: Bhuvan Shah Date: Fri, 10 Apr 2026 11:46:56 -0700 Subject: [PATCH 23/32] Update migration changes and tests --- .../azext_devops/dev/migration/arguments.py | 2 +- .../azext_devops/dev/migration/migration.py | 62 ++++++++++++- .../tests/latest/migration/test_migration.py | 90 +++++++++++++++++-- .../azext_devops/devops_sdk/__init__.py | 4 + 4 files changed, 145 insertions(+), 13 deletions(-) create mode 100644 azure-devops/azure_devops-1.0.3/azext_devops/devops_sdk/__init__.py diff --git a/azure-devops/azext_devops/dev/migration/arguments.py b/azure-devops/azext_devops/dev/migration/arguments.py index f253c10d8..d6bff70ee 100644 --- a/azure-devops/azext_devops/dev/migration/arguments.py +++ b/azure-devops/azext_devops/dev/migration/arguments.py @@ -31,7 +31,7 @@ def load_migration_arguments(self, _): 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='Comma-separated list of validation policies to skip.') + help='Comma-separated list of validation policies to skip (e.g. MaxFileSize,ActivePullRequestCount).') with self.argument_context('devops migrations cutover set') as context: context.argument('cutover_date', options_list='--date', diff --git a/azure-devops/azext_devops/dev/migration/migration.py b/azure-devops/azext_devops/dev/migration/migration.py index 921372fa2..4c55551ed 100644 --- a/azure-devops/azext_devops/dev/migration/migration.py +++ b/azure-devops/azext_devops/dev/migration/migration.py @@ -54,7 +54,6 @@ def create_migration(repository_id=None, target_repository=None, target_owner_us validate_only=False, cutover_date=None, agent_pool=None, skip_validation=None, organization=None, detect=None): agent_pool = _normalize_optional_text(agent_pool) - skip_validation = _normalize_optional_text(skip_validation) if not target_owner_user_id: raise CLIError('--target-owner-user-id must be specified.') @@ -86,20 +85,34 @@ def resume_migration(repository_id=None, validate_only=False, migration=False, o if validate_only and migration: raise CLIError('Please specify only one of --validate-only or --migration.') - migration_data = get_migration(repository_id=repository_id, organization=organization, detect=detect) + 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): status = migration_data.get('statusRequested') or migration_data.get('status') stage = migration_data.get('stage') raise CLIError('Migration is active (statusRequested: {}, stage: {}). Pause it before resuming or changing mode.' .format(status, stage)) + 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. Use --migration to promote to a full migration, ' + 'or abandon and create a new one.') + if status == 'succeeded': + raise CLIError('Migration already succeeded. Use abandon 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, detect, + return _update_migration(repository_id, organization, None, validate_only=validate_only_value, status_requested='active') @@ -170,6 +183,41 @@ def _is_migration_active(migration): 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): + repository_id = _resolve_repository_id(repository_id) + client = _get_service_client(organization) + url = _build_migration_url(organization, repository_id) + + payload = { + 'targetRepository': migration_data.get('targetRepository'), + 'targetOwnerUserId': migration_data.get('targetOwnerUserId'), + 'validateOnly': False, + 'skipValidation': 2147483647, + } + agent_pool = migration_data.get('agentPoolName') + if agent_pool: + payload['agentPoolName'] = agent_pool + cutover_date = migration_data.get('scheduledCutoverDate') + if cutover_date: + payload['scheduledCutoverDate'] = cutover_date + + return _send_request(client, 'POST', url, payload) + + def _resolve_org_for_auth(organization, detect): return resolve_instance(detect=detect, organization=organization) @@ -184,6 +232,7 @@ def _build_migration_url(base_url, repository_id=None): 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 @@ -196,7 +245,12 @@ def _send_request(client, method, url, content=None): } response = client.send(request=request, headers=headers, content=content) if response.status_code < 200 or response.status_code >= 300: - error_detail = getattr(response, 'text', None) or getattr(response, 'content', None) or '' + 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 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 6078f43c0..f2ee2070c 100644 --- a/azure-devops/azext_devops/tests/latest/migration/test_migration.py +++ b/azure-devops/azext_devops/tests/latest/migration/test_migration.py @@ -104,7 +104,7 @@ def test_create_migration_payload_includes_optional_fields(self): validate_only=True, cutover_date='2030-12-31T11:59:00Z', agent_pool='MigrationPool', - skip_validation='ActivePullRequestCount,PullRequestDeltaSize', + skip_validation=2147483647, organization=self._TEST_ORG, detect=False ) @@ -113,7 +113,7 @@ def test_create_migration_payload_includes_optional_fields(self): self.assertTrue(payload['validateOnly']) self.assertEqual(payload['scheduledCutoverDate'], '2030-12-31T11:59:00Z') self.assertEqual(payload['agentPoolName'], 'MigrationPool') - self.assertEqual(payload['skipValidation'], 'ActivePullRequestCount,PullRequestDeltaSize') + self.assertEqual(payload['skipValidation'], 2147483647) def test_create_migration_empty_agent_pool_omitted(self): with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ @@ -134,7 +134,7 @@ def test_create_migration_empty_agent_pool_omitted(self): payload = mock_send.call_args[0][3] self.assertNotIn('agentPoolName', payload) - def test_create_migration_omits_empty_skip_validation(self): + 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: @@ -146,7 +146,7 @@ def test_create_migration_omits_empty_skip_validation(self): target_repository='https://example.ghe.com/OrgName/RepoName', target_owner_user_id='GeoffCoxMSFT', agent_pool='MigrationPool', - skip_validation=' ', + skip_validation=None, organization=self._TEST_ORG, detect=False ) @@ -154,7 +154,7 @@ def test_create_migration_omits_empty_skip_validation(self): payload = mock_send.call_args[0][3] self.assertNotIn('skipValidation', payload) - def test_create_migration_trims_optional_fields(self): + 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: @@ -166,14 +166,14 @@ def test_create_migration_trims_optional_fields(self): target_repository='https://example.ghe.com/OrgName/RepoName', target_owner_user_id='GeoffCoxMSFT', agent_pool=' MigrationPool ', - skip_validation=' ActivePullRequestCount, PullRequestDeltaSize ', + 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'], 'ActivePullRequestCount, PullRequestDeltaSize') + 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, \ @@ -281,7 +281,7 @@ def test_resume_sets_validate_only(self): 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'} + mock_get.return_value = {'status': 'suspended'} mock_resolve.return_value = self._TEST_ORG resume_migration(repository_id='00000000-0000-0000-0000-000000000000', @@ -325,6 +325,80 @@ def test_resume_without_flags_preserves_mode(self): 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, + 'targetRepository': 'https://ghe.example.com/org/repo', + 'targetOwnerUserId': 'testuser', + 'agentPoolName': 'MyPool', + 'scheduledCutoverDate': '2030-06-01T00:00:00Z', + } + 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], 'POST') + payload = args[3] + self.assertFalse(payload['validateOnly']) + self.assertEqual(payload['skipValidation'], 2147483647) + self.assertEqual(payload['targetRepository'], 'https://ghe.example.com/org/repo') + self.assertEqual(payload['targetOwnerUserId'], 'testuser') + self.assertEqual(payload['agentPoolName'], 'MyPool') + self.assertEqual(payload['scheduledCutoverDate'], '2030-06-01T00:00:00Z') + + def test_resume_migration_promote_omits_null_optional_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', + } + 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.assertNotIn('agentPoolName', payload) + self.assertNotIn('scheduledCutoverDate', 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. +# -------------------------------------------------------------------------------------------- From 1c3c38bd0dad049df44bb7ddf11310ce0b6d7300 Mon Sep 17 00:00:00 2001 From: Bhuvan Shah Date: Mon, 13 Apr 2026 11:25:04 -0700 Subject: [PATCH 24/32] Promote validate-only migrations via PUT state update --- .../azext_devops/dev/migration/migration.py | 21 +++-------------- .../tests/latest/migration/test_migration.py | 23 ++++++++++--------- 2 files changed, 15 insertions(+), 29 deletions(-) diff --git a/azure-devops/azext_devops/dev/migration/migration.py b/azure-devops/azext_devops/dev/migration/migration.py index 4c55551ed..261bb0048 100644 --- a/azure-devops/azext_devops/dev/migration/migration.py +++ b/azure-devops/azext_devops/dev/migration/migration.py @@ -198,24 +198,9 @@ def _is_validate_only_succeeded(migration): def _promote_to_full_migration(migration_data, repository_id, organization): - repository_id = _resolve_repository_id(repository_id) - client = _get_service_client(organization) - url = _build_migration_url(organization, repository_id) - - payload = { - 'targetRepository': migration_data.get('targetRepository'), - 'targetOwnerUserId': migration_data.get('targetOwnerUserId'), - 'validateOnly': False, - 'skipValidation': 2147483647, - } - agent_pool = migration_data.get('agentPoolName') - if agent_pool: - payload['agentPoolName'] = agent_pool - cutover_date = migration_data.get('scheduledCutoverDate') - if cutover_date: - payload['scheduledCutoverDate'] = cutover_date - - return _send_request(client, 'POST', url, payload) + del migration_data + return _update_migration(repository_id, organization, detect=None, + validate_only=False, status_requested='active') def _resolve_org_for_auth(organization, detect): 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 f2ee2070c..9adba1189 100644 --- a/azure-devops/azext_devops/tests/latest/migration/test_migration.py +++ b/azure-devops/azext_devops/tests/latest/migration/test_migration.py @@ -334,10 +334,6 @@ def test_resume_migration_promotes_validate_only_succeeded(self): 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', } mock_resolve.return_value = self._TEST_ORG @@ -346,16 +342,12 @@ def test_resume_migration_promotes_validate_only_succeeded(self): organization=self._TEST_ORG, detect=False) args = mock_send.call_args[0] - self.assertEqual(args[1], 'POST') + self.assertEqual(args[1], 'PUT') payload = args[3] self.assertFalse(payload['validateOnly']) - self.assertEqual(payload['skipValidation'], 2147483647) - self.assertEqual(payload['targetRepository'], 'https://ghe.example.com/org/repo') - self.assertEqual(payload['targetOwnerUserId'], 'testuser') - self.assertEqual(payload['agentPoolName'], 'MyPool') - self.assertEqual(payload['scheduledCutoverDate'], '2030-06-01T00:00:00Z') + self.assertEqual(payload['statusRequested'], 'active') - def test_resume_migration_promote_omits_null_optional_fields(self): + 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, \ @@ -366,6 +358,9 @@ def test_resume_migration_promote_omits_null_optional_fields(self): '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 @@ -374,8 +369,14 @@ def test_resume_migration_promote_omits_null_optional_fields(self): 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, \ From 4433f87963019e6ae5d8116324127e3533ab67ae Mon Sep 17 00:00:00 2001 From: Bhuvan Shah Date: Mon, 13 Apr 2026 11:31:48 -0700 Subject: [PATCH 25/32] Improve migrations CLI UX validations and guidance --- .../azext_devops/dev/migration/arguments.py | 7 +-- .../azext_devops/dev/migration/migration.py | 45 +++++++++++++--- .../tests/latest/migration/test_migration.py | 29 +++++++++- doc/migrations.md | 54 +++++++++++++++++-- 4 files changed, 120 insertions(+), 15 deletions(-) diff --git a/azure-devops/azext_devops/dev/migration/arguments.py b/azure-devops/azext_devops/dev/migration/arguments.py index d6bff70ee..6a0eb571d 100644 --- a/azure-devops/azext_devops/dev/migration/arguments.py +++ b/azure-devops/azext_devops/dev/migration/arguments.py @@ -12,7 +12,7 @@ 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 repository (GUID).') + 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', @@ -20,7 +20,7 @@ def load_migration_arguments(self, _): with self.argument_context('devops migrations create') as context: context.argument('target_repository', options_list='--target-repository', - help='Target repository URL.') + 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', @@ -42,4 +42,5 @@ def load_migration_arguments(self, _): 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='Continue the migration (clears any validate-only mode).') + 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/migration.py b/azure-devops/azext_devops/dev/migration/migration.py index 261bb0048..d5dd775eb 100644 --- a/azure-devops/azext_devops/dev/migration/migration.py +++ b/azure-devops/azext_devops/dev/migration/migration.py @@ -3,6 +3,8 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- +import re + from msrest import Configuration from msrest.service_client import ServiceClient from msrest.universal_http import ClientRequest @@ -26,6 +28,9 @@ 'synchronization', 'cutover' } +_URL_PATTERN = re.compile(r'^https?://[^\s]+$', re.IGNORECASE) + + def list_migrations(include_inactive=False, organization=None, detect=None): organization = _resolve_org_for_auth(organization, detect) client = _get_service_client(organization) @@ -53,7 +58,14 @@ def get_migration(repository_id=None, organization=None, detect=None): def create_migration(repository_id=None, target_repository=None, target_owner_user_id=None, validate_only=False, cutover_date=None, agent_pool=None, skip_validation=None, 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) + + 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.') @@ -92,19 +104,22 @@ def resume_migration(repository_id=None, validate_only=False, migration=False, o return _promote_to_full_migration(migration_data, repository_id, organization) if _is_migration_active(migration_data): - status = migration_data.get('statusRequested') or migration_data.get('status') - stage = migration_data.get('stage') - raise CLIError('Migration is active (statusRequested: {}, stage: {}). Pause it before resuming or changing mode.' - .format(status, stage)) + 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. Use --migration to promote to a full migration, ' - 'or abandon and create a new one.') + 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 abandon to reset, then create a new migration.') + 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: @@ -168,6 +183,22 @@ def _normalize_state(value): 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 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 9adba1189..3cdca9aa3 100644 --- a/azure-devops/azext_devops/tests/latest/migration/test_migration.py +++ b/azure-devops/azext_devops/tests/latest/migration/test_migration.py @@ -72,6 +72,29 @@ def test_create_migration_payload_defaults_validate_only_false(self): 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, \ @@ -261,9 +284,10 @@ def test_resume_fails_when_active(self): mock_get.return_value = {'status': 'active', 'stage': 'synchronization'} mock_resolve.return_value = self._TEST_ORG - with self.assertRaises(CLIError): + 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, \ @@ -271,9 +295,10 @@ def test_resume_fails_when_active_via_statusRequested(self): mock_get.return_value = {'statusRequested': 'Active', 'stage': 'validation'} mock_resolve.return_value = self._TEST_ORG - with self.assertRaises(CLIError): + 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, \ diff --git a/doc/migrations.md b/doc/migrations.md index ed06d4a99..b8dba73a0 100644 --- a/doc/migrations.md +++ b/doc/migrations.md @@ -6,16 +6,28 @@ The `az devops migrations` command group manages enterprise live migrations for - Azure DevOps CLI with the Azure DevOps extension installed. - Sign in using `az login` or `az devops login`. -- Use `--org` to specify your Azure DevOps org URL (e.g., `https://dev.azure.com/myorg`). +- 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`. ## 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 required for create. +- `--agent-pool` is optional for create. - `--cutover-date` / `--date` must be ISO 8601, for example: `2030-12-31T11:59:00Z`. +### 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. @@ -24,11 +36,20 @@ The `az devops migrations` command group manages enterprise live migrations for - `pause`: Pause an active migration. - `resume`: Resume a stopped (paused, failed) migration. Optional flags: - `--validate-only`: Resume in validate-only mode. - - `--migration`: Continue the migration (clears validate-only mode). + - `--migration`: Promote a succeeded validate-only migration to full migration. + This updates the existing migration by setting `validateOnly=false` and `statusRequested=active`. If a migration is active, pause it before resuming. - `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. + ## Common workflows ### List migrations @@ -87,6 +108,17 @@ 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). + ### Schedule or cancel cutover ```bash @@ -104,3 +136,19 @@ az devops migrations cutover cancel --org https://dev.azure.com/myorg \ 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://`. From 5cba73e418a1da9da97a1d5bd987277dc268e074 Mon Sep 17 00:00:00 2001 From: Bhuvan Shah Date: Mon, 13 Apr 2026 11:35:47 -0700 Subject: [PATCH 26/32] Expand ELM migration docs and TSG guidance --- doc/elm_migrations_tsg.md | 62 +++++++++++++++++++++++++++++++-------- doc/migrations.md | 52 +++++++++++++++++++++++++++++++- 2 files changed, 100 insertions(+), 14 deletions(-) diff --git a/doc/elm_migrations_tsg.md b/doc/elm_migrations_tsg.md index 681a32ee5..37747d8d7 100644 --- a/doc/elm_migrations_tsg.md +++ b/doc/elm_migrations_tsg.md @@ -51,6 +51,8 @@ This saves you from typing `--org` on every single command: 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 @@ -83,7 +85,7 @@ And has one of these **statuses**: The safest approach is **validate first, then migrate**: ``` -Create (validate-only) → Check status → Pause → Resume (--migration) → Monitor → Schedule cutover → Done +Create (validate-only) → Check status → Resume (--migration) → Monitor → Schedule cutover → Done ``` --- @@ -180,18 +182,31 @@ az devops migrations status --detect false --repository-id b3e18946-5b39-40ca-8e **When to do this:** After step 3.4 shows `status: Succeeded` (validation passed). -You need to pause first (because the migration may still be active), then resume in migration mode: +Resume in migration mode: ```powershell -# Step A: Pause the current migration -az devops migrations pause --detect false --repository-id b3e18946-5b39-40ca-8e2f-d0eb683d8a85 - -# Step B: Resume as a full migration (this starts data movement) +# 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 ``` -> **If you get:** `Migration is active (status: ..., stage: ...). Pause it before resuming or changing mode.` -> Run the pause command first (Step A), then retry Step B. +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. @@ -280,7 +295,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`, `--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. | @@ -293,11 +308,11 @@ az devops migrations resume --detect false --repository-id --validate-onl |---|---|---|---| | `--org` | URL | All | Azure DevOps org URL (e.g., `https://dev.azure.com/myorg`). Can be set as default. | | `--repository-id` | GUID | All except `list` | Azure Repos repository GUID. Get from `az repos show --query id`. | -| `--target-repository` | URL | `create` | Target repository URL (e.g., `https://example.ghe.com/OrgName/RepoName`). Validated by the server. | +| `--target-repository` | URL | `create` | Target repository URL (e.g., `https://example.ghe.com/OrgName/RepoName`). Must start with `http://` or `https://`. | | `--target-owner-user-id` | string | `create` | Target repository owner user ID. | -| `--agent-pool` | string | `create` | Agent pool name for migration work. Required. | +| `--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` | Switch to full migration mode (clears validate-only). Mutually exclusive with `--validate-only`. | +| `--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 | `create` | Comma-separated list of validation policies to skip. | @@ -312,7 +327,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 `--target-repository` on create** | Error: "--target-repository must be specified." | Provide `--target-repository ` | +| **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` | @@ -344,6 +360,26 @@ az devops migrations resume --detect false --repository-id --validate-onl 2. Ensure `--target-repository` is a valid URL. 3. Ensure `--agent-pool` matches a pool name the service recognizes. +### 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 +``` + +2. 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 +``` + +3. If migration already succeeded as full migration, abandon and recreate if needed. + ### 406 Not Acceptable **Symptom:** `Request failed with status 406`. diff --git a/doc/migrations.md b/doc/migrations.md index b8dba73a0..5aa1a2f7f 100644 --- a/doc/migrations.md +++ b/doc/migrations.md @@ -13,6 +13,21 @@ 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 @@ -22,6 +37,11 @@ az devops configure --defaults organization=https://dev.azure.com/myorg - `--agent-pool` is optional for create. - `--cutover-date` / `--date` must be ISO 8601, for example: `2030-12-31T11:59:00Z`. +Validation enforced by the CLI: + +- `--target-repository` must start with `http://` or `https://`. +- `--repository-id` must be a valid GUID. + ### How to find `--repository-id` ```bash @@ -38,7 +58,7 @@ az repos show --repository MyRepo --project MyProject --query id -o tsv - `--validate-only`: Resume in validate-only mode. - `--migration`: Promote a succeeded validate-only migration to full migration. This updates the existing migration by setting `validateOnly=false` and `statusRequested=active`. - If a migration is active, pause it before resuming. + 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. @@ -50,6 +70,23 @@ az repos show --repository MyRepo --project MyProject --query id -o tsv 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 @@ -119,6 +156,13 @@ az devops migrations resume --org https://dev.azure.com/myorg \ 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 @@ -152,3 +196,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: 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. From acb79a0e1b8b3e7c6d949c84b47038b1614e303a Mon Sep 17 00:00:00 2001 From: Bhuvan Shah Date: Mon, 13 Apr 2026 12:31:55 -0700 Subject: [PATCH 27/32] Support skip-validation names and integer values --- .../azext_devops/dev/migration/arguments.py | 4 +- .../azext_devops/dev/migration/migration.py | 70 ++++++++++++++++ .../tests/latest/migration/test_migration.py | 81 +++++++++++++++++++ doc/elm_migrations_tsg.md | 33 +++++++- doc/migrations.md | 27 +++++++ 5 files changed, 212 insertions(+), 3 deletions(-) diff --git a/azure-devops/azext_devops/dev/migration/arguments.py b/azure-devops/azext_devops/dev/migration/arguments.py index 6a0eb571d..2e3ef552f 100644 --- a/azure-devops/azext_devops/dev/migration/arguments.py +++ b/azure-devops/azext_devops/dev/migration/arguments.py @@ -31,7 +31,9 @@ def load_migration_arguments(self, _): 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='Comma-separated list of validation policies to skip (e.g. MaxFileSize,ActivePullRequestCount).') + 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', diff --git a/azure-devops/azext_devops/dev/migration/migration.py b/azure-devops/azext_devops/dev/migration/migration.py index d5dd775eb..802fe2d12 100644 --- a/azure-devops/azext_devops/dev/migration/migration.py +++ b/azure-devops/azext_devops/dev/migration/migration.py @@ -17,6 +17,19 @@ 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', @@ -47,6 +60,62 @@ def _normalize_optional_text(value): 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) @@ -61,6 +130,7 @@ def create_migration(repository_id=None, target_repository=None, target_owner_us 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.') 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 3cdca9aa3..b3e006783 100644 --- a/azure-devops/azext_devops/tests/latest/migration/test_migration.py +++ b/azure-devops/azext_devops/tests/latest/migration/test_migration.py @@ -138,6 +138,87 @@ def test_create_migration_payload_includes_optional_fields(self): 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, \ diff --git a/doc/elm_migrations_tsg.md b/doc/elm_migrations_tsg.md index 37747d8d7..a3cdec5cc 100644 --- a/doc/elm_migrations_tsg.md +++ b/doc/elm_migrations_tsg.md @@ -308,6 +308,7 @@ az devops migrations resume --detect false --repository-id --validate-onl |---|---|---|---| | `--org` | URL | All | Azure DevOps org URL (e.g., `https://dev.azure.com/myorg`). Can be set as default. | | `--repository-id` | GUID | All except `list` | Azure Repos repository GUID. Get from `az repos show --query id`. | +| `--target-repository` | URL | `create` | Target repository URL (e.g., `https://example.ghe.com/OrgName/RepoName`). Validated by the server. | | `--target-repository` | URL | `create` | Target repository URL (e.g., `https://example.ghe.com/OrgName/RepoName`). Must start with `http://` or `https://`. | | `--target-owner-user-id` | string | `create` | Target repository owner user ID. | | `--agent-pool` | string | `create` | Agent pool name for migration work. Optional. | @@ -315,7 +316,7 @@ az devops migrations resume --detect false --repository-id --validate-onl | `--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 | `create` | Comma-separated list of validation policies to skip. | +| `--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. | @@ -327,7 +328,7 @@ 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 `--target-repository` on create** | Error: "--target-repository must be specified." | Provide `--target-repository ` | +| **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` | @@ -359,6 +360,34 @@ az devops migrations resume --detect false --repository-id --validate-onl 1. Check date values are valid ISO 8601 strings (e.g., `2030-12-31T11:59:00Z`). 2. Ensure `--target-repository` is a valid URL. 3. Ensure `--agent-pool` matches a pool name the service recognizes. +4. 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 diff --git a/doc/migrations.md b/doc/migrations.md index 5aa1a2f7f..eae3c3cc4 100644 --- a/doc/migrations.md +++ b/doc/migrations.md @@ -36,11 +36,13 @@ Use all three fields together when troubleshooting state transitions. - `--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` @@ -129,6 +131,28 @@ az devops migrations create --org https://dev.azure.com/myorg \ --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 @@ -197,6 +221,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: `--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`. From d50de9ad663dd5bed13ccbf3b3cc942a4edff741 Mon Sep 17 00:00:00 2001 From: Bhuvan Shah Date: Mon, 13 Apr 2026 14:22:20 -0700 Subject: [PATCH 28/32] Add project filter support for migrations list --- .../azext_devops/dev/migration/arguments.py | 2 ++ .../azext_devops/dev/migration/migration.py | 6 ++++- .../tests/latest/migration/test_migration.py | 26 +++++++++++++++++++ doc/elm_migrations_tsg.md | 3 +++ doc/migrations.md | 8 +++++- 5 files changed, 43 insertions(+), 2 deletions(-) diff --git a/azure-devops/azext_devops/dev/migration/arguments.py b/azure-devops/azext_devops/dev/migration/arguments.py index 2e3ef552f..4c99a92e6 100644 --- a/azure-devops/azext_devops/dev/migration/arguments.py +++ b/azure-devops/azext_devops/dev/migration/arguments.py @@ -17,6 +17,8 @@ def load_migration_arguments(self, _): 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', diff --git a/azure-devops/azext_devops/dev/migration/migration.py b/azure-devops/azext_devops/dev/migration/migration.py index 802fe2d12..7a14988dc 100644 --- a/azure-devops/azext_devops/dev/migration/migration.py +++ b/azure-devops/azext_devops/dev/migration/migration.py @@ -4,6 +4,7 @@ # -------------------------------------------------------------------------------------------- import re +from urllib.parse import quote_plus from msrest import Configuration from msrest.service_client import ServiceClient @@ -44,12 +45,15 @@ _URL_PATTERN = re.compile(r'^https?://[^\s]+$', re.IGNORECASE) -def list_migrations(include_inactive=False, organization=None, detect=None): +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) 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 b3e006783..04ce932c7 100644 --- a/azure-devops/azext_devops/tests/latest/migration/test_migration.py +++ b/azure-devops/azext_devops/tests/latest/migration/test_migration.py @@ -53,6 +53,32 @@ def test_list_migrations_include_inactive(self): 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, \ diff --git a/doc/elm_migrations_tsg.md b/doc/elm_migrations_tsg.md index a3cdec5cc..34d7a1217 100644 --- a/doc/elm_migrations_tsg.md +++ b/doc/elm_migrations_tsg.md @@ -126,6 +126,9 @@ See if any migrations already exist for your org: # 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 ``` diff --git a/doc/migrations.md b/doc/migrations.md index eae3c3cc4..1dcd6c25e 100644 --- a/doc/migrations.md +++ b/doc/migrations.md @@ -52,7 +52,7 @@ 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. +- `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. @@ -97,6 +97,12 @@ What this does: 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 From f7b4943241b93c766e6cadd066e9e08644818cf3 Mon Sep 17 00:00:00 2001 From: Bhuvan Shah Date: Tue, 14 Apr 2026 17:23:08 -0700 Subject: [PATCH 29/32] Fix flake8 W503 in migration state check --- azure-devops/azext_devops/dev/migration/migration.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/azure-devops/azext_devops/dev/migration/migration.py b/azure-devops/azext_devops/dev/migration/migration.py index 7a14988dc..e2b70545d 100644 --- a/azure-devops/azext_devops/dev/migration/migration.py +++ b/azure-devops/azext_devops/dev/migration/migration.py @@ -298,8 +298,8 @@ def _is_migration_terminal(migration): 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') + return (migration.get('validateOnly') is True and + _normalize_state(migration.get('status')) == 'succeeded') def _promote_to_full_migration(migration_data, repository_id, organization): From eb4d3a25435258d772a970e67507dd1f3a87aef2 Mon Sep 17 00:00:00 2001 From: Bhuvan Shah Date: Wed, 15 Apr 2026 09:24:44 -0700 Subject: [PATCH 30/32] Fix markdown lint spacing and list formatting --- doc/elm_migrations_tsg.md | 14 +++++++++++--- doc/migrations.md | 2 +- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/doc/elm_migrations_tsg.md b/doc/elm_migrations_tsg.md index 34d7a1217..1e0521571 100644 --- a/doc/elm_migrations_tsg.md +++ b/doc/elm_migrations_tsg.md @@ -112,7 +112,8 @@ az repos show --org https://dev.azure.com/myorg/ --project MyProject --repositor ``` Example output: -``` + +```text b3e18946-5b39-40ca-8e2f-d0eb683d8a85 ``` @@ -343,6 +344,7 @@ az devops migrations resume --detect false --repository-id --validate-onl **Symptom:** `Request failed with status 401` or `403`. **Fix:** + 1. Run `az login` (AAD) or `az devops login` (PAT). 2. Ensure the token/account has permission to the organization. 3. Verify `--org` points to the correct Azure DevOps org URL. @@ -352,6 +354,7 @@ az devops migrations resume --detect false --repository-id --validate-onl **Symptom:** `Request failed with status 404`. **Fix:** + 1. Verify `--org` is correct (e.g., `https://dev.azure.com/myorg`). 2. Verify the `--repository-id` is a valid GUID that exists in the organization. @@ -360,6 +363,7 @@ az devops migrations resume --detect false --repository-id --validate-onl **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`). 2. Ensure `--target-repository` is a valid URL. 3. Ensure `--agent-pool` matches a pool name the service recognizes. @@ -380,6 +384,7 @@ az devops migrations create --detect false --repository-id --target-repos ``` Supported policy names: + - `None` - `ActivePullRequestCount` - `PullRequestDeltaSize` @@ -397,26 +402,28 @@ Supported policy names: **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 ``` -2. If migration is active, pause then retry: +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 ``` -3. If migration already succeeded as full migration, abandon and recreate if needed. +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. 2. Confirm you are using the latest CLI extension version. 3. Contact your admin if it persists. @@ -426,6 +433,7 @@ az devops migrations resume --detect false --repository-id --migration **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/`. diff --git a/doc/migrations.md b/doc/migrations.md index 1dcd6c25e..b0382b545 100644 --- a/doc/migrations.md +++ b/doc/migrations.md @@ -60,7 +60,7 @@ az repos show --repository MyRepo --project MyProject --query id -o tsv - `--validate-only`: Resume in validate-only mode. - `--migration`: Promote a succeeded validate-only migration to full migration. This updates the existing migration by setting `validateOnly=false` and `statusRequested=active`. - If a migration is currently active, pause it before resuming or switching mode. + - 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. From 6bb3221bae79c4ea79cb77a41d990f3475a0f7b0 Mon Sep 17 00:00:00 2001 From: Bhuvan Shah Date: Wed, 15 Apr 2026 09:42:05 -0700 Subject: [PATCH 31/32] Fix remaining markdown lint issues in migration docs --- doc/elm_migrations_tsg.md | 18 +++++++++--------- doc/migrations.md | 6 +----- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/doc/elm_migrations_tsg.md b/doc/elm_migrations_tsg.md index 1e0521571..c28eb04e3 100644 --- a/doc/elm_migrations_tsg.md +++ b/doc/elm_migrations_tsg.md @@ -346,8 +346,8 @@ az devops migrations resume --detect false --repository-id --validate-onl **Fix:** 1. Run `az login` (AAD) or `az devops login` (PAT). -2. Ensure the token/account has permission to the organization. -3. Verify `--org` points to the correct Azure DevOps org URL. +1. Ensure the token/account has permission to the organization. +1. Verify `--org` points to the correct Azure DevOps org URL. ### 404 Not Found @@ -356,7 +356,7 @@ az devops migrations resume --detect false --repository-id --validate-onl **Fix:** 1. Verify `--org` is correct (e.g., `https://dev.azure.com/myorg`). -2. Verify the `--repository-id` is a valid GUID that exists in the organization. +1. Verify the `--repository-id` is a valid GUID that exists in the organization. ### 400 Bad Request @@ -365,9 +365,9 @@ az devops migrations resume --detect false --repository-id --validate-onl **Fix:** 1. Check date values are valid ISO 8601 strings (e.g., `2030-12-31T11:59:00Z`). -2. Ensure `--target-repository` is a valid URL. -3. Ensure `--agent-pool` matches a pool name the service recognizes. -4. Ensure `--skip-validation` uses supported policy names or a non-negative integer bitmask. +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 @@ -425,8 +425,8 @@ az devops migrations resume --detect false --repository-id --migration **Fix:** 1. Verify `--org` is correct. -2. Confirm you are using the latest CLI extension version. -3. Contact your admin if it persists. +1. Confirm you are using the latest CLI extension version. +1. Contact your admin if it persists. ### 500 Internal Server Error / Retries Exhausted @@ -438,7 +438,7 @@ az devops migrations resume --detect false --repository-id --migration - 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. -2. If the correct host is being used, the service may be temporarily unavailable — retry later or contact your admin. +1. If the correct host is being used, the service may be temporarily unavailable — retry later or contact your admin. ## 8. Useful Commands diff --git a/doc/migrations.md b/doc/migrations.md index b0382b545..85d31271e 100644 --- a/doc/migrations.md +++ b/doc/migrations.md @@ -56,11 +56,7 @@ az repos show --repository MyRepo --project MyProject --query id -o tsv - `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: - - `--validate-only`: Resume in validate-only mode. - - `--migration`: Promote a succeeded validate-only migration to full migration. - This updates the existing migration by setting `validateOnly=false` and `statusRequested=active`. - - If a migration is currently active, pause it before resuming or switching mode. +- `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. From 8deb6ac5147560f7f2dbd69112c003aa4b891ce8 Mon Sep 17 00:00:00 2001 From: Bhuvan Shah Date: Wed, 15 Apr 2026 13:23:41 -0700 Subject: [PATCH 32/32] Fix Run_Style_Check missing dependency on Build_Publish_Azure_CLI_Test_SDK --- .github/workflows/pr-workflow.yml | 1 + 1 file changed, 1 insertion(+) 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