Skip to content

Commit 0431c13

Browse files
committed
Update migration changes and tests
1 parent 60b71d3 commit 0431c13

4 files changed

Lines changed: 145 additions & 13 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def load_migration_arguments(self, _):
3131
context.argument('agent_pool', options_list='--agent-pool',
3232
help='Agent pool name to use for migration work.')
3333
context.argument('skip_validation', options_list='--skip-validation',
34-
help='Comma-separated list of validation policies to skip.')
34+
help='Comma-separated list of validation policies to skip (e.g. MaxFileSize,ActivePullRequestCount).')
3535

3636
with self.argument_context('devops migrations cutover set') as context:
3737
context.argument('cutover_date', options_list='--date',

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

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,6 @@ def create_migration(repository_id=None, target_repository=None, target_owner_us
5454
validate_only=False, cutover_date=None, agent_pool=None,
5555
skip_validation=None, organization=None, detect=None):
5656
agent_pool = _normalize_optional_text(agent_pool)
57-
skip_validation = _normalize_optional_text(skip_validation)
5857
if not target_owner_user_id:
5958
raise CLIError('--target-owner-user-id must be specified.')
6059

@@ -86,20 +85,34 @@ def resume_migration(repository_id=None, validate_only=False, migration=False, o
8685
if validate_only and migration:
8786
raise CLIError('Please specify only one of --validate-only or --migration.')
8887

89-
migration_data = get_migration(repository_id=repository_id, organization=organization, detect=detect)
88+
organization = _resolve_org_for_auth(organization, detect)
89+
migration_data = get_migration(repository_id=repository_id, organization=organization, detect=None)
90+
91+
if migration and _is_validate_only_succeeded(migration_data):
92+
return _promote_to_full_migration(migration_data, repository_id, organization)
93+
9094
if _is_migration_active(migration_data):
9195
status = migration_data.get('statusRequested') or migration_data.get('status')
9296
stage = migration_data.get('stage')
9397
raise CLIError('Migration is active (statusRequested: {}, stage: {}). Pause it before resuming or changing mode.'
9498
.format(status, stage))
9599

100+
if _is_migration_terminal(migration_data):
101+
status = _normalize_state(migration_data.get('status'))
102+
is_val_only = migration_data.get('validateOnly') is True
103+
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.')
106+
if status == 'succeeded':
107+
raise CLIError('Migration already succeeded. Use abandon to reset, then create a new migration.')
108+
96109
validate_only_value = None
97110
if validate_only:
98111
validate_only_value = True
99112
elif migration:
100113
validate_only_value = False
101114

102-
return _update_migration(repository_id, organization, detect,
115+
return _update_migration(repository_id, organization, None,
103116
validate_only=validate_only_value, status_requested='active')
104117

105118

@@ -170,6 +183,41 @@ def _is_migration_active(migration):
170183
return False
171184

172185

186+
def _is_migration_terminal(migration):
187+
if not isinstance(migration, dict):
188+
return False
189+
status = _normalize_state(migration.get('status'))
190+
return status in ('succeeded', 'failed')
191+
192+
193+
def _is_validate_only_succeeded(migration):
194+
if not isinstance(migration, dict):
195+
return False
196+
return (migration.get('validateOnly') is True
197+
and _normalize_state(migration.get('status')) == 'succeeded')
198+
199+
200+
def _promote_to_full_migration(migration_data, repository_id, organization):
201+
repository_id = _resolve_repository_id(repository_id)
202+
client = _get_service_client(organization)
203+
url = _build_migration_url(organization, repository_id)
204+
205+
payload = {
206+
'targetRepository': migration_data.get('targetRepository'),
207+
'targetOwnerUserId': migration_data.get('targetOwnerUserId'),
208+
'validateOnly': False,
209+
'skipValidation': 2147483647,
210+
}
211+
agent_pool = migration_data.get('agentPoolName')
212+
if agent_pool:
213+
payload['agentPoolName'] = agent_pool
214+
cutover_date = migration_data.get('scheduledCutoverDate')
215+
if cutover_date:
216+
payload['scheduledCutoverDate'] = cutover_date
217+
218+
return _send_request(client, 'POST', url, payload)
219+
220+
173221
def _resolve_org_for_auth(organization, detect):
174222
return resolve_instance(detect=detect, organization=organization)
175223

@@ -184,6 +232,7 @@ def _build_migration_url(base_url, repository_id=None):
184232
def _get_service_client(organization):
185233
config = Configuration(base_url=None)
186234
config.add_user_agent('devOpsCli/{}'.format(VERSION))
235+
config.retry_policy.policy.status_forcelist = []
187236
connection = get_connection(organization)
188237
return ServiceClient(creds=connection._creds, config=config) # pylint: disable=protected-access
189238

@@ -196,7 +245,12 @@ def _send_request(client, method, url, content=None):
196245
}
197246
response = client.send(request=request, headers=headers, content=content)
198247
if response.status_code < 200 or response.status_code >= 300:
199-
error_detail = getattr(response, 'text', None) or getattr(response, 'content', None) or ''
248+
error_detail = ''
249+
try:
250+
body = response.json()
251+
error_detail = body.get('message') or body.get('Message') or str(body)
252+
except Exception: # pylint: disable=broad-except
253+
error_detail = getattr(response, 'text', None) or getattr(response, 'content', None) or ''
200254
raise CLIError('Request failed with status {}. {}'.format(response.status_code, error_detail))
201255

202256
content_type = response.headers.get('Content-Type') if response.headers else None

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

Lines changed: 82 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ def test_create_migration_payload_includes_optional_fields(self):
104104
validate_only=True,
105105
cutover_date='2030-12-31T11:59:00Z',
106106
agent_pool='MigrationPool',
107-
skip_validation='ActivePullRequestCount,PullRequestDeltaSize',
107+
skip_validation=2147483647,
108108
organization=self._TEST_ORG,
109109
detect=False
110110
)
@@ -113,7 +113,7 @@ def test_create_migration_payload_includes_optional_fields(self):
113113
self.assertTrue(payload['validateOnly'])
114114
self.assertEqual(payload['scheduledCutoverDate'], '2030-12-31T11:59:00Z')
115115
self.assertEqual(payload['agentPoolName'], 'MigrationPool')
116-
self.assertEqual(payload['skipValidation'], 'ActivePullRequestCount,PullRequestDeltaSize')
116+
self.assertEqual(payload['skipValidation'], 2147483647)
117117

118118
def test_create_migration_empty_agent_pool_omitted(self):
119119
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):
134134
payload = mock_send.call_args[0][3]
135135
self.assertNotIn('agentPoolName', payload)
136136

137-
def test_create_migration_omits_empty_skip_validation(self):
137+
def test_create_migration_omits_none_skip_validation(self):
138138
with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \
139139
patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \
140140
patch('azext_devops.dev.migration.migration._send_request') as mock_send:
@@ -146,15 +146,15 @@ def test_create_migration_omits_empty_skip_validation(self):
146146
target_repository='https://example.ghe.com/OrgName/RepoName',
147147
target_owner_user_id='GeoffCoxMSFT',
148148
agent_pool='MigrationPool',
149-
skip_validation=' ',
149+
skip_validation=None,
150150
organization=self._TEST_ORG,
151151
detect=False
152152
)
153153

154154
payload = mock_send.call_args[0][3]
155155
self.assertNotIn('skipValidation', payload)
156156

157-
def test_create_migration_trims_optional_fields(self):
157+
def test_create_migration_trims_agent_pool(self):
158158
with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \
159159
patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \
160160
patch('azext_devops.dev.migration.migration._send_request') as mock_send:
@@ -166,14 +166,14 @@ def test_create_migration_trims_optional_fields(self):
166166
target_repository='https://example.ghe.com/OrgName/RepoName',
167167
target_owner_user_id='GeoffCoxMSFT',
168168
agent_pool=' MigrationPool ',
169-
skip_validation=' ActivePullRequestCount, PullRequestDeltaSize ',
169+
skip_validation=42,
170170
organization=self._TEST_ORG,
171171
detect=False
172172
)
173173

174174
payload = mock_send.call_args[0][3]
175175
self.assertEqual(payload['agentPoolName'], 'MigrationPool')
176-
self.assertEqual(payload['skipValidation'], 'ActivePullRequestCount, PullRequestDeltaSize')
176+
self.assertEqual(payload['skipValidation'], 42)
177177

178178
def test_create_migration_passes_target_repository_to_api(self):
179179
with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \
@@ -281,7 +281,7 @@ def test_resume_sets_validate_only(self):
281281
patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \
282282
patch('azext_devops.dev.migration.migration._send_request') as mock_send:
283283
mock_send.return_value = {}
284-
mock_get.return_value = {'status': 'succeeded'}
284+
mock_get.return_value = {'status': 'suspended'}
285285
mock_resolve.return_value = self._TEST_ORG
286286

287287
resume_migration(repository_id='00000000-0000-0000-0000-000000000000',
@@ -325,6 +325,80 @@ def test_resume_without_flags_preserves_mode(self):
325325
self.assertNotIn('validateOnly', payload)
326326
self.assertEqual(payload['statusRequested'], 'active')
327327

328+
def test_resume_migration_promotes_validate_only_succeeded(self):
329+
with patch('azext_devops.dev.migration.migration.get_migration') as mock_get, \
330+
patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \
331+
patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \
332+
patch('azext_devops.dev.migration.migration._send_request') as mock_send:
333+
mock_send.return_value = {}
334+
mock_get.return_value = {
335+
'status': 'succeeded',
336+
'validateOnly': True,
337+
'targetRepository': 'https://ghe.example.com/org/repo',
338+
'targetOwnerUserId': 'testuser',
339+
'agentPoolName': 'MyPool',
340+
'scheduledCutoverDate': '2030-06-01T00:00:00Z',
341+
}
342+
mock_resolve.return_value = self._TEST_ORG
343+
344+
resume_migration(repository_id='00000000-0000-0000-0000-000000000000',
345+
migration=True,
346+
organization=self._TEST_ORG, detect=False)
347+
348+
args = mock_send.call_args[0]
349+
self.assertEqual(args[1], 'POST')
350+
payload = args[3]
351+
self.assertFalse(payload['validateOnly'])
352+
self.assertEqual(payload['skipValidation'], 2147483647)
353+
self.assertEqual(payload['targetRepository'], 'https://ghe.example.com/org/repo')
354+
self.assertEqual(payload['targetOwnerUserId'], 'testuser')
355+
self.assertEqual(payload['agentPoolName'], 'MyPool')
356+
self.assertEqual(payload['scheduledCutoverDate'], '2030-06-01T00:00:00Z')
357+
358+
def test_resume_migration_promote_omits_null_optional_fields(self):
359+
with patch('azext_devops.dev.migration.migration.get_migration') as mock_get, \
360+
patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \
361+
patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \
362+
patch('azext_devops.dev.migration.migration._send_request') as mock_send:
363+
mock_send.return_value = {}
364+
mock_get.return_value = {
365+
'status': 'succeeded',
366+
'validateOnly': True,
367+
'targetRepository': 'https://ghe.example.com/org/repo',
368+
'targetOwnerUserId': 'testuser',
369+
}
370+
mock_resolve.return_value = self._TEST_ORG
371+
372+
resume_migration(repository_id='00000000-0000-0000-0000-000000000000',
373+
migration=True,
374+
organization=self._TEST_ORG, detect=False)
375+
376+
payload = mock_send.call_args[0][3]
377+
self.assertNotIn('agentPoolName', payload)
378+
self.assertNotIn('scheduledCutoverDate', payload)
379+
380+
def test_resume_succeeded_without_migration_flag_errors(self):
381+
with patch('azext_devops.dev.migration.migration.get_migration') as mock_get, \
382+
patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve:
383+
mock_get.return_value = {'status': 'succeeded', 'validateOnly': True}
384+
mock_resolve.return_value = self._TEST_ORG
385+
386+
with self.assertRaises(CLIError) as ctx:
387+
resume_migration(repository_id='00000000-0000-0000-0000-000000000000',
388+
organization=self._TEST_ORG, detect=False)
389+
self.assertIn('--migration', str(ctx.exception))
390+
391+
def test_resume_succeeded_full_migration_errors(self):
392+
with patch('azext_devops.dev.migration.migration.get_migration') as mock_get, \
393+
patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve:
394+
mock_get.return_value = {'status': 'succeeded', 'validateOnly': False}
395+
mock_resolve.return_value = self._TEST_ORG
396+
397+
with self.assertRaises(CLIError) as ctx:
398+
resume_migration(repository_id='00000000-0000-0000-0000-000000000000',
399+
organization=self._TEST_ORG, detect=False)
400+
self.assertIn('abandon', str(ctx.exception))
401+
328402

329403
if __name__ == '__main__':
330404
unittest.main()
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# --------------------------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License. See License.txt in the project root for license information.
4+
# --------------------------------------------------------------------------------------------

0 commit comments

Comments
 (0)