diff --git a/azure-devops/azext_devops/dev/migration/_help.py b/azure-devops/azext_devops/dev/migration/_help.py index 649f3253..0398af29 100644 --- a/azure-devops/azext_devops/dev/migration/_help.py +++ b/azure-devops/azext_devops/dev/migration/_help.py @@ -38,7 +38,7 @@ def load_migration_help(): helps['devops migrations create'] = """ type: command short-summary: Create a migration for a repository. - long-summary: 'If --github-token is not provided, the CLI checks ELM_GITHUB_TOKEN and then runs GitHub device flow to acquire a token.' + long-summary: 'GitHub authentication uses device flow: the CLI prints a URL and a one-time code to complete sign-in interactively. No GitHub token or service connection is required.' examples: - name: Create a migration. text: | @@ -46,9 +46,6 @@ def load_migration_help(): - name: Create a validate-only migration. text: | az devops migrations create --org https://dev.azure.com/myorg --repository-id 00000000-0000-0000-0000-000000000000 --target-repository https://github.com/OrgName/RepoName --agent-pool --validate-only --skip-validation ActivePullRequestCount,PullRequestDeltaSize - - name: Create using a pre-generated GitHub token or PAT. - text: | - az devops migrations create --org https://dev.azure.com/myorg --repository-id 00000000-0000-0000-0000-000000000000 --target-repository https://github.com/OrgName/RepoName --github-token """ 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 bea0b734..f28ea0e9 100644 --- a/azure-devops/azext_devops/dev/migration/arguments.py +++ b/azure-devops/azext_devops/dev/migration/arguments.py @@ -69,13 +69,6 @@ def load_migration_arguments(self, _): context.argument('target_owner_user_id', options_list='--target-owner-user-id', help='Target repository owner user ID. Deprecated and ignored when server-side ' 'token-based owner resolution is enabled.') - context.argument('github_token', options_list='--github-token', - help='GitHub user token used for user-identity verification on the target ' - 'host. Independent of --service-endpoint-id. If omitted and ' - '--service-endpoint-id is not provided, the CLI checks ELM_GITHUB_TOKEN ' - 'and then runs GitHub device flow. When --service-endpoint-id is ' - 'provided, device flow is skipped; pass --github-token or set ' - 'ELM_GITHUB_TOKEN to supply the user token.') 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', @@ -91,11 +84,6 @@ def load_migration_arguments(self, _): 'MaxPullRequestSize, MaxPushPackSize, MaxReferenceNameLength, ' 'TargetRepositoryDoesNotExist, SourceRepositoryContainsLfsObjects, ' 'SourceRepositoryNotReadOnly, BoardsGitHubConnectionProvisioning, All.') - context.argument('service_endpoint_id', options_list='--service-endpoint-id', - help='Service endpoint ID (GUID) for the GitHub Enterprise Server connection ' - 'used to sync commits to the target. Independent of user-identity ' - 'verification: --github-token / ELM_GITHUB_TOKEN can be supplied ' - 'alongside this flag. Device flow is skipped when this flag is set.') context.argument('enable_boards_github_connection', options_list=['--enable-boards-github-connection', '--enable-boards-gh'], action='store_true', diff --git a/azure-devops/azext_devops/dev/migration/migration.py b/azure-devops/azext_devops/dev/migration/migration.py index c6cf17be..8754ae0d 100644 --- a/azure-devops/azext_devops/dev/migration/migration.py +++ b/azure-devops/azext_devops/dev/migration/migration.py @@ -39,7 +39,6 @@ PIPELINES_API_PATH_SUFFIX = '/pipelines' DEVICE_FLOW_CONFIG_API_PATH = '/_apis/migrations/deviceFlowConfig' LEGACY_DEVICE_FLOW_CONFIG_API_PATH = '/_apis/elm/migrations/deviceFlowConfig' -GITHUB_TOKEN_ENV_VAR = 'ELM_GITHUB_TOKEN' _SKIP_VALIDATION_POLICIES = { 'none': 0, 'activepullrequestcount': 1, @@ -177,15 +176,13 @@ 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, service_endpoint_id=None, github_token=None, + skip_validation=None, enable_boards_github_connection=False, enable_auto_discover_pipelines=False, pipeline_service_connection_id=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) - service_endpoint_id = _normalize_optional_text(service_endpoint_id) - github_token = _normalize_optional_text(github_token) skip_validation = _parse_skip_validation(skip_validation) if not target_repository: @@ -202,14 +199,7 @@ def create_migration(*, repository_id=None, target_repository=None, target_owner organization = _resolve_org_for_auth(organization, detect) repository_id = _resolve_repository_id(repository_id) client = _get_service_client(organization) - if not service_endpoint_id: - github_token = _resolve_github_user_token(client, organization, target_repository, github_token) - else: - # SE supplies the GitHub credential used to sync commits. User-identity - # verification (gitHubUserToken) is independent: accept an explicit - # --github-token or ELM_GITHUB_TOKEN env var, but do not trigger device - # flow here so non-interactive SE-based flows aren't broken. - github_token = github_token or _normalize_optional_text(os.getenv(GITHUB_TOKEN_ENV_VAR)) + github_token = _resolve_github_user_token(client, organization, target_repository) payload = { 'targetRepository': target_repository, @@ -225,8 +215,6 @@ def create_migration(*, repository_id=None, target_repository=None, target_owner payload['scheduledCutoverDate'] = cutover_date if skip_validation is not None: payload['skipValidation'] = skip_validation - if service_endpoint_id: - payload['serviceEndpointId'] = service_endpoint_id config_options = {} if enable_boards_github_connection: config_options['enableBoardsGitHubConnection'] = True @@ -251,15 +239,7 @@ def create_migration(*, repository_id=None, target_repository=None, target_owner raise -def _resolve_github_user_token(client, organization, target_repository, github_token=None): - token = _normalize_optional_text(github_token) - if token: - return token - - env_token = _normalize_optional_text(os.getenv(GITHUB_TOKEN_ENV_VAR)) - if env_token: - return env_token - +def _resolve_github_user_token(client, organization, target_repository): flow_config = _get_device_flow_config(client, organization, target_repository) client_id = _normalize_optional_text(flow_config.get('clientId')) enterprise_url = _normalize_optional_text(flow_config.get('enterpriseUrl')) @@ -285,7 +265,8 @@ def _get_device_flow_config(client, organization, target_repository): continue if index == 1 and first_error and 'status 404' in str(ex): raise CLIError('GitHub device-flow configuration is unavailable. ' - 'Provide --github-token or set ELM_GITHUB_TOKEN to continue.') + 'Ensure the GitHub app is installed for the target ' + 'organization, then try again.') raise if first_error: @@ -394,7 +375,7 @@ def _post_form(url, data): if ex.code in (401, 403): raise CLIError('GitHub device flow is unavailable for this organization. ' 'This can happen if the GitHub app is not installed or the service is unavailable. ' - 'Try again later, or provide --github-token (or set ELM_GITHUB_TOKEN).') + 'Try again later.') detail = '' try: content = ex.read() 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 8534a55f..db309897 100644 --- a/azure-devops/azext_devops/tests/latest/migration/test_migration.py +++ b/azure-devops/azext_devops/tests/latest/migration/test_migration.py @@ -4,7 +4,6 @@ # -------------------------------------------------------------------------------------------- import unittest -import os from urllib.error import HTTPError try: @@ -36,14 +35,15 @@ class TestMigrationCommands(unittest.TestCase): _TEST_ORG = 'https://elm.contoso.com/elmo1' def setUp(self): - self._original_env_token = os.environ.get('ELM_GITHUB_TOKEN') - os.environ['ELM_GITHUB_TOKEN'] = 'env-token-for-tests' - - def tearDown(self): - if self._original_env_token is None: - os.environ.pop('ELM_GITHUB_TOKEN', None) - else: - os.environ['ELM_GITHUB_TOKEN'] = self._original_env_token + # GitHub auth is device-flow only. Patch the resolver so create tests that + # do not exercise device flow directly get a deterministic token without + # hitting the network. Tests that verify the real device-flow path restore + # the original resolver locally via self._real_resolve_github_user_token. + self._real_resolve_github_user_token = migration_module._resolve_github_user_token + self._resolve_token_patcher = patch.object( + migration_module, '_resolve_github_user_token', return_value='device-flow-token') + self._resolve_token_patcher.start() + self.addCleanup(self._resolve_token_patcher.stop) def test_list_migrations_calls_get(self): with patch('azext_devops.dev.migration.migration.resolve_instance') as mock_resolve, \ @@ -118,7 +118,7 @@ def test_create_migration_payload_defaults_validate_only_false(self): payload = mock_send.call_args[0][3] self.assertFalse(payload['validateOnly']) - self.assertEqual(payload['gitHubUserToken'], 'env-token-for-tests') + self.assertEqual(payload['gitHubUserToken'], 'device-flow-token') def test_create_migration_fails_without_target_repository(self): with self.assertRaises(CLIError) as ctx: @@ -185,37 +185,21 @@ def test_create_migration_without_agent_pool(self): payload = mock_send.call_args[0][3] self.assertNotIn('agentPoolName', payload) - def test_create_migration_uses_parameter_token_over_environment(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='TestOwner', - github_token='param-token', - organization=self._TEST_ORG, - detect=False - ) - - payload = mock_send.call_args[0][3] - self.assertEqual(payload['gitHubUserToken'], 'param-token') - - def test_create_migration_uses_device_flow_when_no_token_provided(self): + def test_create_migration_uses_device_flow_to_resolve_token(self): + # Device flow is the only auth path: create must resolve the user token via + # the device-flow config + interactive flow, then send it as gitHubUserToken. 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, \ - patch('azext_devops.dev.migration.migration._run_device_flow') as mock_run_device_flow: + patch('azext_devops.dev.migration.migration._run_device_flow') as mock_run_device_flow, \ + patch.object(migration_module, '_resolve_github_user_token', + self._real_resolve_github_user_token): mock_resolve.return_value = self._TEST_ORG mock_send.side_effect = [ {'clientId': 'client-id-123', 'enterpriseUrl': 'https://example.ghe.com'}, {} ] mock_run_device_flow.return_value = 'device-flow-token' - os.environ.pop('ELM_GITHUB_TOKEN', None) create_migration( repository_id='00000000-0000-0000-0000-000000000000', @@ -240,7 +224,6 @@ def test_create_migration_conflict_returns_clear_message(self): create_migration( repository_id='912d0fd3-9c17-4b35-b67b-91848ce4d6bb', target_repository='https://example.ghe.com/OrgName/RepoName', - github_token='token', organization=self._TEST_ORG, detect=False ) @@ -259,143 +242,18 @@ def test_create_migration_non_conflict_error_passes_through(self): create_migration( repository_id='00000000-0000-0000-0000-000000000000', target_repository='https://example.ghe.com/OrgName/RepoName', - github_token='token', organization=self._TEST_ORG, detect=False ) self.assertIn('status 400', str(ctx.exception)) - def test_create_migration_with_service_endpoint_skips_device_flow(self): - # SE present + env var set: env var IS used for user-identity verification, - # but device flow MUST NOT run (would break non-interactive SE flows). - 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, \ - patch('azext_devops.dev.migration.migration._get_device_flow_config') as mock_flow, \ - patch('azext_devops.dev.migration.migration._run_device_flow') as mock_run_flow: - 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='TestOwner', - service_endpoint_id='1df3c9b3-666c-4033-82de-059e7759ddfe', - organization=self._TEST_ORG, - detect=False - ) - - mock_flow.assert_not_called() - mock_run_flow.assert_not_called() - self.assertEqual(mock_send.call_count, 1) - payload = mock_send.call_args[0][3] - self.assertEqual(payload['serviceEndpointId'], '1df3c9b3-666c-4033-82de-059e7759ddfe') - self.assertEqual(payload['gitHubUserToken'], 'env-token-for-tests') - - def test_create_migration_with_service_endpoint_and_token_both_sent(self): - # SE and user PAT are independent. The CLI must forward both to the server: - # SE for sync, user token for identity verification. - 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, \ - patch('azext_devops.dev.migration.migration._get_device_flow_config') as mock_flow, \ - patch('azext_devops.dev.migration.migration._run_device_flow') as mock_run_flow: - 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='TestOwner', - service_endpoint_id='1df3c9b3-666c-4033-82de-059e7759ddfe', - github_token='param-token', - organization=self._TEST_ORG, - detect=False - ) - - mock_flow.assert_not_called() - mock_run_flow.assert_not_called() - payload = mock_send.call_args[0][3] - self.assertEqual(payload['serviceEndpointId'], '1df3c9b3-666c-4033-82de-059e7759ddfe') - self.assertEqual(payload['gitHubUserToken'], 'param-token') - - def test_create_migration_with_service_endpoint_uses_env_token(self): - # ELM_GITHUB_TOKEN is set in setUp; with SE provided and no explicit - # --github-token, the env var is picked up for identity verification. - 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, \ - patch('azext_devops.dev.migration.migration._get_device_flow_config') as mock_flow: - 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', - service_endpoint_id='1df3c9b3-666c-4033-82de-059e7759ddfe', - validate_only=True, - organization=self._TEST_ORG, - detect=False - ) - - mock_flow.assert_not_called() - payload = mock_send.call_args[0][3] - self.assertEqual(payload['gitHubUserToken'], 'env-token-for-tests') - self.assertEqual(payload['serviceEndpointId'], '1df3c9b3-666c-4033-82de-059e7759ddfe') - self.assertTrue(payload['validateOnly']) - - def test_create_migration_service_endpoint_with_whitespace_github_token_not_rejected(self): - # A whitespace-only --github-token normalizes to None and must not - # trigger the mutual-exclusion error when --service-endpoint-id is set. - 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, \ - patch('azext_devops.dev.migration.migration._get_device_flow_config') as mock_flow: - 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', - service_endpoint_id='1df3c9b3-666c-4033-82de-059e7759ddfe', - github_token=' ', - organization=self._TEST_ORG, - detect=False - ) - - mock_flow.assert_not_called() - payload = mock_send.call_args[0][3] - self.assertEqual(payload['serviceEndpointId'], '1df3c9b3-666c-4033-82de-059e7759ddfe') - self.assertEqual(payload['gitHubUserToken'], 'env-token-for-tests') - - def test_create_migration_service_endpoint_conflict_returns_clear_message(self): - # Ensure the 409/TF400898 friendly message still surfaces on the - # service-endpoint code path (no GitHub token preflight to swallow it). - 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_resolve.return_value = self._TEST_ORG - mock_send.side_effect = CLIError('Request failed with status 409. TF400898: An Internal Error Occurred.') - - with self.assertRaises(CLIError) as ctx: - create_migration( - repository_id='912d0fd3-9c17-4b35-b67b-91848ce4d6bb', - target_repository='https://example.ghe.com/OrgName/RepoName', - service_endpoint_id='1df3c9b3-666c-4033-82de-059e7759ddfe', - organization=self._TEST_ORG, - detect=False - ) - - self.assertIn('An active migration already exists for repository 912d0fd3-9c17-4b35-b67b-91848ce4d6bb', - str(ctx.exception)) - - def test_create_migration_service_endpoint_with_all_optional_fields(self): - # Service endpoint path must coexist with every other optional field + def test_create_migration_with_all_optional_fields(self): + # Device-flow auth must coexist with every other optional field # (agent pool, cutover date, skip validation, target owner). 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, \ - patch('azext_devops.dev.migration.migration._get_device_flow_config') as mock_flow: + patch('azext_devops.dev.migration.migration._send_request') as mock_send: mock_send.return_value = {} mock_resolve.return_value = self._TEST_ORG @@ -403,7 +261,6 @@ def test_create_migration_service_endpoint_with_all_optional_fields(self): repository_id='00000000-0000-0000-0000-000000000000', target_repository='https://example.ghe.com/OrgName/RepoName', target_owner_user_id='TestOwner', - service_endpoint_id='1df3c9b3-666c-4033-82de-059e7759ddfe', agent_pool='TestPool', cutover_date='2026-06-01T00:00:00Z', skip_validation='AgentPoolExists', @@ -411,22 +268,22 @@ def test_create_migration_service_endpoint_with_all_optional_fields(self): detect=False ) - mock_flow.assert_not_called() payload = mock_send.call_args[0][3] - self.assertEqual(payload['serviceEndpointId'], '1df3c9b3-666c-4033-82de-059e7759ddfe') self.assertEqual(payload['targetOwnerUserId'], 'TestOwner') self.assertEqual(payload['agentPoolName'], 'TestPool') self.assertEqual(payload['scheduledCutoverDate'], '2026-06-01T00:00:00Z') self.assertEqual(payload['skipValidation'], 4) - self.assertEqual(payload['gitHubUserToken'], 'env-token-for-tests') + self.assertEqual(payload['gitHubUserToken'], 'device-flow-token') + self.assertNotIn('serviceEndpointId', payload) def test_create_migration_no_token_and_missing_device_flow_config_fields_fails(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: + patch('azext_devops.dev.migration.migration._send_request') as mock_send, \ + patch.object(migration_module, '_resolve_github_user_token', + self._real_resolve_github_user_token): mock_resolve.return_value = self._TEST_ORG mock_send.return_value = {'clientId': 'client-id-only'} - os.environ.pop('ELM_GITHUB_TOKEN', None) with self.assertRaises(CLIError) as ctx: create_migration( @@ -464,7 +321,7 @@ def test_get_device_flow_config_falls_back_to_legacy_path_on_404(self): self.assertEqual(result['clientId'], 'abc') self.assertEqual(mock_send.call_count, 2) - def test_get_device_flow_config_both_paths_404_shows_pat_guidance(self): + def test_get_device_flow_config_both_paths_404_shows_guidance(self): with patch('azext_devops.dev.migration.migration._send_request') as mock_send: mock_send.side_effect = [ CLIError("Request failed with status 404. The controller for path '/_apis/migrations/deviceFlowConfig' was not found."), @@ -478,7 +335,7 @@ def test_get_device_flow_config_both_paths_404_shows_pat_guidance(self): target_repository='https://example.ghe.com/org/repo' ) - self.assertIn('Provide --github-token or set ELM_GITHUB_TOKEN', str(ctx.exception)) + self.assertIn('GitHub device-flow configuration is unavailable', str(ctx.exception)) def test_run_device_flow_handles_access_denied(self): with patch('azext_devops.dev.migration.migration._post_form') as mock_post, \ @@ -844,83 +701,17 @@ def test_create_migration_agent_pool_always_in_payload(self): payload = mock_send.call_args[0][3] self.assertEqual(payload['agentPoolName'], 'TestPool') - def test_create_migration_service_endpoint_id_included_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._resolve_github_user_token') as mock_token, \ - patch('azext_devops.dev.migration.migration._send_request') as mock_send: - mock_send.return_value = {} - mock_resolve.return_value = self._TEST_ORG - mock_token.return_value = 'ghp_test_token' - - create_migration( - repository_id='00000000-0000-0000-0000-000000000000', - target_repository='https://example.ghe.com/OrgName/RepoName', - target_owner_user_id='TestOwner', - service_endpoint_id='12345678-1234-1234-1234-123456789012', - organization=self._TEST_ORG, - detect=False - ) - - payload = mock_send.call_args[0][3] - self.assertEqual(payload['serviceEndpointId'], '12345678-1234-1234-1234-123456789012') - # When a service connection is supplied, the server uses it for GitHub auth; - # the CLI must not resolve or send a GitHub token. - self.assertEqual(payload['gitHubUserToken'], 'env-token-for-tests') - - def test_create_migration_service_endpoint_id_skips_github_token_resolution(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._resolve_github_user_token') as mock_token, \ - patch('azext_devops.dev.migration.migration._send_request') as mock_send: - mock_send.return_value = {} - mock_resolve.return_value = self._TEST_ORG - mock_token.return_value = 'ghp_test_token' - - create_migration( - repository_id='00000000-0000-0000-0000-000000000000', - target_repository='https://example.ghe.com/OrgName/RepoName', - service_endpoint_id='12345678-1234-1234-1234-123456789012', - organization=self._TEST_ORG, - detect=False - ) - - mock_token.assert_not_called() - - def test_create_migration_service_endpoint_id_omitted_when_not_provided(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._resolve_github_user_token') as mock_token, \ - patch('azext_devops.dev.migration.migration._send_request') as mock_send: - mock_send.return_value = {} - mock_resolve.return_value = self._TEST_ORG - mock_token.return_value = 'ghp_test_token' - - create_migration( - repository_id='00000000-0000-0000-0000-000000000000', - target_repository='https://example.ghe.com/OrgName/RepoName', - target_owner_user_id='TestOwner', - organization=self._TEST_ORG, - detect=False - ) - - payload = mock_send.call_args[0][3] - self.assertNotIn('serviceEndpointId', payload) - - def test_create_migration_empty_service_endpoint_id_omitted(self): + def test_create_migration_never_sends_service_endpoint_id(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._resolve_github_user_token') as mock_token, \ patch('azext_devops.dev.migration.migration._send_request') as mock_send: mock_send.return_value = {} mock_resolve.return_value = self._TEST_ORG - mock_token.return_value = 'ghp_test_token' create_migration( repository_id='00000000-0000-0000-0000-000000000000', target_repository='https://example.ghe.com/OrgName/RepoName', target_owner_user_id='TestOwner', - service_endpoint_id=' ', organization=self._TEST_ORG, detect=False ) diff --git a/doc/elm_migrations_tsg.md b/doc/elm_migrations_tsg.md index 0fd0199b..185843e6 100644 --- a/doc/elm_migrations_tsg.md +++ b/doc/elm_migrations_tsg.md @@ -111,7 +111,7 @@ Create (validate-only) → Check status → Resume (--migration) → Monitor | 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 | -| GitHub auth token | `` | Optional: pass via `--github-token` or set `ELM_GITHUB_TOKEN` | +| GitHub sign-in | device flow | During `create` the CLI prints a URL + one-time code; complete sign-in interactively (no token needed) | | Agent pool name | `` | Ask your admin | ### 3.1 Get the source repository GUID from Azure DevOps @@ -163,16 +163,7 @@ The command returns the migration details as JSON. The migration begins immediat > **Tip:** If you're confident and want to start a full migration right away (skip validate-only), omit the `--validate-only` flag. -If `--github-token` is not provided, the CLI checks `ELM_GITHUB_TOKEN` and then runs GitHub device flow to acquire a token. If you pass `--service-endpoint-id` (a GitHub Enterprise Server service connection used to sync commits), device flow is skipped — supply `--github-token` or `ELM_GITHUB_TOKEN` only if user-identity verification is also needed. - -You can also pass a token or PAT explicitly: - -```powershell -az devops migrations create --detect false \ - --repository-id b3e18946-5b39-40ca-8e2f-d0eb683d8a85 \ - --target-repository https://example.ghe.com/OrgName/RepoName \ - --github-token -``` +GitHub authentication uses device flow: `create` prints a URL and a one-time code. Open the URL, enter the code, and complete sign-in interactively. No GitHub token or service connection is required. **Optional parameters you can add at creation time:** @@ -181,7 +172,6 @@ az devops migrations create --detect false \ | `--agent-pool` | Agent pool name used for migration work | `--agent-pool my-pool` | | `--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` | -| `--service-endpoint-id` | GitHub Enterprise Server service connection (GUID) used to sync commits; skips device flow | `--service-endpoint-id ` | | `--enable-boards-github-connection` | Provision the Azure Boards GitHub connection at cutover (off by default; requires the Boards GitHub App installed on the target org) | `--enable-boards-github-connection` | | `--enable-auto-discover-pipelines` | Auto-discover and clone pipelines that reference the source repo at cutover (off by default) | `--enable-auto-discover-pipelines` | | `--pipeline-service-connection-id` | Project-scoped GitHub service connection (GUID) attached at create time for pipeline rewiring | `--pipeline-service-connection-id ` | @@ -392,7 +382,7 @@ az devops migrations pipelines delete --detect false --repository-id --mi |---|---|---|---|---| | `list` | `--org` | `--include-all`, `--include-inactive` (deprecated), `--project`, `--detect` | GET | List migrations. By default the latest per repository. | | `status` | `--org`, `--repository-id` | `--detect` | GET | Get detailed status for one migration. | -| `create` | `--org`, `--repository-id`, `--target-repository` | `--github-token`, `--service-endpoint-id`, `--target-owner-user-id` (deprecated), `--agent-pool`, `--validate-only`, `--cutover-date`, `--skip-validation`, `--enable-boards-github-connection`, `--enable-auto-discover-pipelines`, `--pipeline-service-connection-id`, `--detect` | POST | Create a new migration. | +| `create` | `--org`, `--repository-id`, `--target-repository` | `--target-owner-user-id` (deprecated), `--agent-pool`, `--validate-only`, `--cutover-date`, `--skip-validation`, `--enable-boards-github-connection`, `--enable-auto-discover-pipelines`, `--pipeline-service-connection-id`, `--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. | | `abandon` | `--org`, `--repository-id` | `--remove-read-only`, `--detect` | DELETE | Permanently delete a migration (prompts for confirmation). | @@ -413,8 +403,6 @@ az devops migrations pipelines delete --detect false --repository-id --mi | `--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. Must start with `http://` or `https://`. | -| `--github-token` | string | `create` | GitHub token used for user-identity verification. If omitted (and `--service-endpoint-id` not set), CLI checks `ELM_GITHUB_TOKEN` then runs device flow. | -| `--service-endpoint-id` | GUID | `create` | GitHub Enterprise Server service connection used to sync commits. When set, device flow is skipped. | | `--target-owner-user-id` | string | `create` | Deprecated. Ignored when server-side token ownership resolution is enabled. | | `--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. | @@ -448,7 +436,7 @@ az devops migrations pipelines delete --detect false --repository-id --mi | **Resume while at ReviewForCutover** | Error: "Migration is waiting for cutover approval (stage: ReviewForCutover)" | Run `cutover review`, then `cutover approve`; or cancel/reschedule cutover, or abandon | | **Cancel cutover too late** | Error: "Cannot cancel cutover: the migration has already entered the Cutover stage" | Cancel only before the `Cutover` stage; if stuck, contact the ELM service team | | **Both `--validate-only` and `--migration` on resume** | Error: "Please specify only one..." | Use only one flag at a time | -| **Missing migration auth token** | Device flow prompt appears, or auth error is returned | Provide `--github-token`, set `ELM_GITHUB_TOKEN`, or complete device-flow authorization | +| **GitHub device-flow prompt appears** | The CLI prints a URL + one-time code during `create` | Expected — open the URL, enter the code, and complete sign-in. If config is unavailable, ensure the GitHub app is installed for the target org | | **Active migration already exists for repository** | Error: `An active migration already exists for repository . Delete (abandon) the existing migration before creating a new one.` | Abandon the existing migration first (`az devops migrations abandon`), then retry `create` | | **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 | @@ -500,13 +488,6 @@ Advanced form using integer bitmask: az devops migrations create --detect false --repository-id --target-repository --skip-validation 132 ``` -Token/PAT-authenticated examples: - -```powershell -az devops migrations create --detect false --repository-id --target-repository --github-token --skip-validation AgentPoolExists,MaxFileSize -az devops migrations create --detect false --repository-id --target-repository --github-token --skip-validation 132 -``` - Supported policy names: - `None` diff --git a/doc/migrations.md b/doc/migrations.md index 3cd87d18..0321d30a 100644 --- a/doc/migrations.md +++ b/doc/migrations.md @@ -34,7 +34,7 @@ Use all three fields together when troubleshooting state transitions. - `--repository-id` is the Azure Repos repository GUID. - `--target-repository` is the target repository URL. -- `--github-token` is optional for create. If not provided, the CLI checks `ELM_GITHUB_TOKEN` and then runs GitHub device flow. +- GitHub authentication uses device flow: `create` prints a URL and a one-time code to complete sign-in interactively. No GitHub token or service connection is required. - `--target-owner-user-id` is deprecated and ignored when server-side token ownership resolution is enabled. - `--agent-pool` is optional for create. - `--cutover-date` / `--date` must be ISO 8601, for example: `2030-12-31T11:59:00Z`. @@ -133,15 +133,6 @@ az devops migrations create --org https://dev.azure.com/myorg \ --validate-only ``` -### Create a migration using explicit token or PAT - -```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 \ - --github-token -``` - ### Create a migration with skip-validation Recommended form using policy names: @@ -230,8 +221,8 @@ 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: missing GitHub token or device-flow setup. - Pass `--github-token`, set `ELM_GITHUB_TOKEN`, or complete the interactive GitHub device-flow prompt shown by CLI. +- Error: GitHub device-flow configuration is unavailable. + Ensure the GitHub app is installed for the target organization, then complete the interactive device-flow prompt (URL + code) shown by the CLI. - Error: `--skip-validation` contains unsupported policy names. Use supported names such as `AgentPoolExists`, `MaxFileSize`, or pass a non-negative integer bitmask.