Skip to content

Commit 846015f

Browse files
committed
ELM migrations: treat 'completed' equivalent to 'succeeded' for terminal status handling
1 parent e1fc264 commit 846015f

2 files changed

Lines changed: 109 additions & 9 deletions

File tree

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

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,12 @@
3131
'targetrepositorydoesnotexist': 256,
3232
'all': 2147483647,
3333
}
34+
_SUCCESS_TERMINAL_STATES = {
35+
'succeeded',
36+
'completed'
37+
}
3438
_NON_ACTIVE_STATES = {
39+
'completed',
3540
'succeeded',
3641
'failed',
3742
'suspended'
@@ -184,14 +189,14 @@ def resume_migration(repository_id=None, validate_only=False, migration=False, o
184189
.format(state_text))
185190

186191
if _is_migration_terminal(migration_data):
187-
status = _normalize_state(migration_data.get('status'))
192+
status = _get_effective_status(migration_data)
188193
is_val_only = migration_data.get('validateOnly') is True
189-
if status == 'succeeded' and is_val_only:
190-
raise CLIError('Validation already succeeded. Promote it with '
194+
if _is_success_terminal_status(status) and is_val_only:
195+
raise CLIError('Validation already completed. Promote it with '
191196
'"az devops migrations resume --repository-id <guid> --migration", '
192197
'or abandon and create a new migration.')
193-
if status == 'succeeded':
194-
raise CLIError('Migration already succeeded. Use '
198+
if _is_success_terminal_status(status):
199+
raise CLIError('Migration already completed. Use '
195200
'"az devops migrations abandon --repository-id <guid>" to reset, '
196201
'then create a new migration.')
197202

@@ -257,6 +262,20 @@ def _normalize_state(value):
257262
return normalized.replace(' ', '').replace('-', '').replace('_', '')
258263

259264

265+
def _is_success_terminal_status(status):
266+
return status in _SUCCESS_TERMINAL_STATES
267+
268+
269+
def _get_effective_status(migration):
270+
if not isinstance(migration, dict):
271+
return ''
272+
# Prefer actual migration status over requested status when both are present.
273+
status = _normalize_state(migration.get('status'))
274+
if status:
275+
return status
276+
return _normalize_state(migration.get('statusRequested'))
277+
278+
260279
def _get_migration_state_text(migration):
261280
status_requested = migration.get('statusRequested')
262281
status = migration.get('status')
@@ -277,7 +296,7 @@ def _is_migration_active(migration):
277296
if not isinstance(migration, dict):
278297
return False
279298

280-
status = _normalize_state(migration.get('statusRequested') or migration.get('status'))
299+
status = _get_effective_status(migration)
281300
if status:
282301
return status not in _NON_ACTIVE_STATES
283302

@@ -291,15 +310,15 @@ def _is_migration_active(migration):
291310
def _is_migration_terminal(migration):
292311
if not isinstance(migration, dict):
293312
return False
294-
status = _normalize_state(migration.get('status'))
295-
return status in ('succeeded', 'failed')
313+
status = _get_effective_status(migration)
314+
return _is_success_terminal_status(status) or status == 'failed'
296315

297316

298317
def _is_validate_only_succeeded(migration):
299318
if not isinstance(migration, dict):
300319
return False
301320
return (migration.get('validateOnly') is True and
302-
_normalize_state(migration.get('status')) == 'succeeded')
321+
_is_success_terminal_status(_get_effective_status(migration)))
303322

304323

305324
def _promote_to_full_migration(migration_data, repository_id, organization):

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

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,28 @@ def test_resume_migration_promotes_validate_only_succeeded(self):
479479
self.assertFalse(payload['validateOnly'])
480480
self.assertEqual(payload['statusRequested'], 'active')
481481

482+
def test_resume_migration_promotes_validate_only_completed(self):
483+
with patch('azext_devops.dev.migration.migration.get_migration') as mock_get, \
484+
patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \
485+
patch('azext_devops.dev.migration.migration._get_service_client') as mock_client, \
486+
patch('azext_devops.dev.migration.migration._send_request') as mock_send:
487+
mock_send.return_value = {}
488+
mock_get.return_value = {
489+
'status': 'completed',
490+
'validateOnly': True,
491+
}
492+
mock_resolve.return_value = self._TEST_ORG
493+
494+
resume_migration(repository_id='00000000-0000-0000-0000-000000000000',
495+
migration=True,
496+
organization=self._TEST_ORG, detect=False)
497+
498+
args = mock_send.call_args[0]
499+
self.assertEqual(args[1], 'PUT')
500+
payload = args[3]
501+
self.assertFalse(payload['validateOnly'])
502+
self.assertEqual(payload['statusRequested'], 'active')
503+
482504
def test_resume_migration_promote_uses_only_state_transition_fields(self):
483505
with patch('azext_devops.dev.migration.migration.get_migration') as mock_get, \
484506
patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \
@@ -521,6 +543,17 @@ def test_resume_succeeded_without_migration_flag_errors(self):
521543
organization=self._TEST_ORG, detect=False)
522544
self.assertIn('--migration', str(ctx.exception))
523545

546+
def test_resume_completed_without_migration_flag_errors(self):
547+
with patch('azext_devops.dev.migration.migration.get_migration') as mock_get, \
548+
patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve:
549+
mock_get.return_value = {'status': 'completed', 'validateOnly': True}
550+
mock_resolve.return_value = self._TEST_ORG
551+
552+
with self.assertRaises(CLIError) as ctx:
553+
resume_migration(repository_id='00000000-0000-0000-0000-000000000000',
554+
organization=self._TEST_ORG, detect=False)
555+
self.assertIn('--migration', str(ctx.exception))
556+
524557
def test_resume_succeeded_full_migration_errors(self):
525558
with patch('azext_devops.dev.migration.migration.get_migration') as mock_get, \
526559
patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve:
@@ -532,6 +565,54 @@ def test_resume_succeeded_full_migration_errors(self):
532565
organization=self._TEST_ORG, detect=False)
533566
self.assertIn('abandon', str(ctx.exception))
534567

568+
def test_resume_completed_full_migration_errors(self):
569+
with patch('azext_devops.dev.migration.migration.get_migration') as mock_get, \
570+
patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve:
571+
mock_get.return_value = {'status': 'completed', 'validateOnly': False}
572+
mock_resolve.return_value = self._TEST_ORG
573+
574+
with self.assertRaises(CLIError) as ctx:
575+
resume_migration(repository_id='00000000-0000-0000-0000-000000000000',
576+
organization=self._TEST_ORG, detect=False)
577+
self.assertIn('abandon', str(ctx.exception))
578+
579+
def test_resume_completed_status_takes_precedence_over_active_status_requested(self):
580+
with patch('azext_devops.dev.migration.migration.get_migration') as mock_get, \
581+
patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve:
582+
mock_get.return_value = {
583+
'status': 'completed',
584+
'statusRequested': 'active',
585+
'validateOnly': True,
586+
}
587+
mock_resolve.return_value = self._TEST_ORG
588+
589+
with self.assertRaises(CLIError) as ctx:
590+
resume_migration(repository_id='00000000-0000-0000-0000-000000000000',
591+
organization=self._TEST_ORG, detect=False)
592+
self.assertIn('--migration', str(ctx.exception))
593+
594+
def test_resume_completed_status_requested_without_status_is_terminal(self):
595+
with patch('azext_devops.dev.migration.migration.get_migration') as mock_get, \
596+
patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve:
597+
mock_get.return_value = {'statusRequested': 'completed', 'validateOnly': False}
598+
mock_resolve.return_value = self._TEST_ORG
599+
600+
with self.assertRaises(CLIError) as ctx:
601+
resume_migration(repository_id='00000000-0000-0000-0000-000000000000',
602+
organization=self._TEST_ORG, detect=False)
603+
self.assertIn('abandon', str(ctx.exception))
604+
605+
def test_resume_completed_case_variants_are_treated_as_terminal(self):
606+
with patch('azext_devops.dev.migration.migration.get_migration') as mock_get, \
607+
patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve:
608+
mock_get.return_value = {'status': 'Com_PleTed', 'validateOnly': False}
609+
mock_resolve.return_value = self._TEST_ORG
610+
611+
with self.assertRaises(CLIError) as ctx:
612+
resume_migration(repository_id='00000000-0000-0000-0000-000000000000',
613+
organization=self._TEST_ORG, detect=False)
614+
self.assertIn('abandon', str(ctx.exception))
615+
535616

536617
if __name__ == '__main__':
537618
unittest.main()

0 commit comments

Comments
 (0)