Skip to content

Commit c50b636

Browse files
committed
Improve migrations CLI UX validations and guidance
1 parent ae3a17d commit c50b636

4 files changed

Lines changed: 120 additions & 15 deletions

File tree

azure-devops/azext_devops/dev/migration/arguments.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,15 @@ def load_migration_arguments(self, _):
1212
with self.argument_context('devops migrations') as context:
1313
load_global_args(context)
1414
context.argument('repository_id', options_list='--repository-id',
15-
help='ID of the repository (GUID).')
15+
help='ID of the Azure Repos repository (GUID).')
1616

1717
with self.argument_context('devops migrations list') as context:
1818
context.argument('include_inactive', options_list='--include-inactive', action='store_true',
1919
help='Include inactive (completed, abandoned, failed) migrations in the results.')
2020

2121
with self.argument_context('devops migrations create') as context:
2222
context.argument('target_repository', options_list='--target-repository',
23-
help='Target repository URL.')
23+
help='Target repository URL (must start with http:// or https://).')
2424
context.argument('target_owner_user_id', options_list='--target-owner-user-id',
2525
help='Target repository owner user ID.')
2626
context.argument('validate_only', options_list='--validate-only', action='store_true',
@@ -42,4 +42,5 @@ def load_migration_arguments(self, _):
4242
context.argument('validate_only', options_list='--validate-only', action='store_true',
4343
help='Resume in validate-only mode.')
4444
context.argument('migration', options_list='--migration', action='store_true',
45-
help='Continue the migration (clears any validate-only mode).')
45+
help='Promote a succeeded validate-only migration to a full migration '
46+
'(sets validateOnly=false and statusRequested=active).')

azure-devops/azext_devops/dev/migration/migration.py

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
# Licensed under the MIT License. See License.txt in the project root for license information.
44
# --------------------------------------------------------------------------------------------
55

6+
import re
7+
68
from msrest import Configuration
79
from msrest.service_client import ServiceClient
810
from msrest.universal_http import ClientRequest
@@ -26,6 +28,9 @@
2628
'synchronization',
2729
'cutover'
2830
}
31+
_URL_PATTERN = re.compile(r'^https?://[^\s]+$', re.IGNORECASE)
32+
33+
2934
def list_migrations(include_inactive=False, organization=None, detect=None):
3035
organization = _resolve_org_for_auth(organization, detect)
3136
client = _get_service_client(organization)
@@ -53,7 +58,14 @@ def get_migration(repository_id=None, organization=None, detect=None):
5358
def create_migration(repository_id=None, target_repository=None, target_owner_user_id=None,
5459
validate_only=False, cutover_date=None, agent_pool=None,
5560
skip_validation=None, organization=None, detect=None):
61+
target_repository = _normalize_optional_text(target_repository)
62+
target_owner_user_id = _normalize_optional_text(target_owner_user_id)
5663
agent_pool = _normalize_optional_text(agent_pool)
64+
65+
if not target_repository:
66+
raise CLIError('--target-repository must be specified.')
67+
if not _URL_PATTERN.match(target_repository):
68+
raise CLIError('--target-repository must be a valid URL starting with http:// or https://.')
5769
if not target_owner_user_id:
5870
raise CLIError('--target-owner-user-id must be specified.')
5971

@@ -92,19 +104,22 @@ def resume_migration(repository_id=None, validate_only=False, migration=False, o
92104
return _promote_to_full_migration(migration_data, repository_id, organization)
93105

94106
if _is_migration_active(migration_data):
95-
status = migration_data.get('statusRequested') or migration_data.get('status')
96-
stage = migration_data.get('stage')
97-
raise CLIError('Migration is active (statusRequested: {}, stage: {}). Pause it before resuming or changing mode.'
98-
.format(status, stage))
107+
state_text = _get_migration_state_text(migration_data)
108+
raise CLIError('Migration is currently active ({}). Pause it first using '
109+
'"az devops migrations pause --repository-id <guid>" before resuming or changing mode.'
110+
.format(state_text))
99111

100112
if _is_migration_terminal(migration_data):
101113
status = _normalize_state(migration_data.get('status'))
102114
is_val_only = migration_data.get('validateOnly') is True
103115
if status == 'succeeded' and is_val_only:
104-
raise CLIError('Validation already succeeded. Use --migration to promote to a full migration, '
105-
'or abandon and create a new one.')
116+
raise CLIError('Validation already succeeded. Promote it with '
117+
'"az devops migrations resume --repository-id <guid> --migration", '
118+
'or abandon and create a new migration.')
106119
if status == 'succeeded':
107-
raise CLIError('Migration already succeeded. Use abandon to reset, then create a new migration.')
120+
raise CLIError('Migration already succeeded. Use '
121+
'"az devops migrations abandon --repository-id <guid>" to reset, '
122+
'then create a new migration.')
108123

109124
validate_only_value = None
110125
if validate_only:
@@ -168,6 +183,22 @@ def _normalize_state(value):
168183
return normalized.replace(' ', '').replace('-', '').replace('_', '')
169184

170185

186+
def _get_migration_state_text(migration):
187+
status_requested = migration.get('statusRequested')
188+
status = migration.get('status')
189+
stage = migration.get('stage')
190+
191+
parts = []
192+
if status_requested:
193+
parts.append('statusRequested: {}'.format(status_requested))
194+
if status:
195+
parts.append('status: {}'.format(status))
196+
if stage:
197+
parts.append('stage: {}'.format(stage))
198+
199+
return ', '.join(parts) if parts else 'state unknown'
200+
201+
171202
def _is_migration_active(migration):
172203
if not isinstance(migration, dict):
173204
return False

azure-devops/azext_devops/tests/latest/migration/test_migration.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,29 @@ def test_create_migration_payload_defaults_validate_only_false(self):
7272
payload = mock_send.call_args[0][3]
7373
self.assertFalse(payload['validateOnly'])
7474

75+
def test_create_migration_fails_without_target_repository(self):
76+
with self.assertRaises(CLIError) as ctx:
77+
create_migration(
78+
repository_id='00000000-0000-0000-0000-000000000000',
79+
target_owner_user_id='GeoffCoxMSFT',
80+
agent_pool='MigrationPool',
81+
organization=self._TEST_ORG,
82+
detect=False
83+
)
84+
self.assertIn('--target-repository must be specified', str(ctx.exception))
85+
86+
def test_create_migration_fails_with_invalid_target_repository_url(self):
87+
with self.assertRaises(CLIError) as ctx:
88+
create_migration(
89+
repository_id='00000000-0000-0000-0000-000000000000',
90+
target_repository='ghe.example.com/OrgName/RepoName',
91+
target_owner_user_id='GeoffCoxMSFT',
92+
agent_pool='MigrationPool',
93+
organization=self._TEST_ORG,
94+
detect=False
95+
)
96+
self.assertIn('must be a valid URL', str(ctx.exception))
97+
7598
def test_create_migration_without_agent_pool(self):
7699
with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \
77100
patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \
@@ -261,19 +284,21 @@ def test_resume_fails_when_active(self):
261284
mock_get.return_value = {'status': 'active', 'stage': 'synchronization'}
262285
mock_resolve.return_value = self._TEST_ORG
263286

264-
with self.assertRaises(CLIError):
287+
with self.assertRaises(CLIError) as ctx:
265288
resume_migration(repository_id='00000000-0000-0000-0000-000000000000',
266289
organization=self._TEST_ORG, detect=False)
290+
self.assertIn('az devops migrations pause', str(ctx.exception))
267291

268292
def test_resume_fails_when_active_via_statusRequested(self):
269293
with patch('azext_devops.dev.migration.migration.get_migration') as mock_get, \
270294
patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve:
271295
mock_get.return_value = {'statusRequested': 'Active', 'stage': 'validation'}
272296
mock_resolve.return_value = self._TEST_ORG
273297

274-
with self.assertRaises(CLIError):
298+
with self.assertRaises(CLIError) as ctx:
275299
resume_migration(repository_id='00000000-0000-0000-0000-000000000000',
276300
organization=self._TEST_ORG, detect=False)
301+
self.assertIn('statusRequested: Active', str(ctx.exception))
277302

278303
def test_resume_sets_validate_only(self):
279304
with patch('azext_devops.dev.migration.migration.get_migration') as mock_get, \

doc/migrations.md

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,28 @@ The `az devops migrations` command group manages enterprise live migrations for
66

77
- Azure DevOps CLI with the Azure DevOps extension installed.
88
- Sign in using `az login` or `az devops login`.
9-
- Use `--org` to specify your Azure DevOps org URL (e.g., `https://dev.azure.com/myorg`).
9+
- Configure a default org once to avoid repeating `--org`:
10+
11+
```bash
12+
az devops configure --defaults organization=https://dev.azure.com/myorg
13+
```
14+
15+
- You can still override per command with `--org`.
1016

1117
## Required inputs
1218

1319
- `--repository-id` is the Azure Repos repository GUID.
1420
- `--target-repository` is the target repository URL.
1521
- `--target-owner-user-id` is required for create.
16-
- `--agent-pool` is required for create.
22+
- `--agent-pool` is optional for create.
1723
- `--cutover-date` / `--date` must be ISO 8601, for example: `2030-12-31T11:59:00Z`.
1824

25+
### How to find `--repository-id`
26+
27+
```bash
28+
az repos show --repository MyRepo --project MyProject --query id -o tsv
29+
```
30+
1931
## Command reference
2032

2133
- `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
2436
- `pause`: Pause an active migration.
2537
- `resume`: Resume a stopped (paused, failed) migration. Optional flags:
2638
- `--validate-only`: Resume in validate-only mode.
27-
- `--migration`: Continue the migration (clears validate-only mode).
39+
- `--migration`: Promote a succeeded validate-only migration to full migration.
40+
This updates the existing migration by setting `validateOnly=false` and `statusRequested=active`.
2841
If a migration is active, pause it before resuming.
2942
- `cutover set` / `cutover cancel`: Schedule or cancel cutover.
3043
- `abandon`: Abandon and delete a migration.
3144

45+
## Status fields
46+
47+
- `statusRequested`: Desired state requested by client.
48+
- `status`: Current overall status reported by service.
49+
- `stage`: Current active stage (for example, validation, synchronization, cutover).
50+
51+
If a command is blocked, inspect all three fields from `status` output to understand whether the migration is active, terminal, or promotable.
52+
3253
## Common workflows
3354

3455
### List migrations
@@ -87,6 +108,17 @@ az devops migrations resume --org https://dev.azure.com/myorg \
87108
--repository-id 00000000-0000-0000-0000-000000000000 --migration
88109
```
89110

111+
### Promote a succeeded validate-only migration
112+
113+
After validation succeeds, run:
114+
115+
```bash
116+
az devops migrations resume --org https://dev.azure.com/myorg \
117+
--repository-id 00000000-0000-0000-0000-000000000000 --migration
118+
```
119+
120+
This promotes the same migration record (no new migration is created).
121+
90122
### Schedule or cancel cutover
91123

92124
```bash
@@ -104,3 +136,19 @@ az devops migrations cutover cancel --org https://dev.azure.com/myorg \
104136
az devops migrations abandon --org https://dev.azure.com/myorg \
105137
--repository-id 00000000-0000-0000-0000-000000000000
106138
```
139+
140+
## Troubleshooting
141+
142+
- Error: migration is active.
143+
Pause first, then retry resume or mode changes.
144+
145+
```bash
146+
az devops migrations pause --org https://dev.azure.com/myorg \
147+
--repository-id 00000000-0000-0000-0000-000000000000
148+
```
149+
150+
- Error: validation already succeeded.
151+
Use `resume --migration` to promote instead of re-running validate-only.
152+
153+
- Error: `--target-repository` must be valid.
154+
Ensure it is a fully qualified URL starting with `http://` or `https://`.

0 commit comments

Comments
 (0)