From 43c5843d203ffa482f1a448052167d21ead57879 Mon Sep 17 00:00:00 2001 From: Chad Oakenfold Date: Thu, 16 Apr 2026 22:04:32 -0400 Subject: [PATCH 1/3] fix(auth): provide ADO-specific authentication error message for dev.azure.com --- src/apm_cli/core/auth.py | 17 ++++++--- tests/unit/test_auth.py | 75 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 5 deletions(-) diff --git a/src/apm_cli/core/auth.py b/src/apm_cli/core/auth.py index fc18d4ca8..7a6423aae 100644 --- a/src/apm_cli/core/auth.py +++ b/src/apm_cli/core/auth.py @@ -336,12 +336,19 @@ def build_error_context( "authorize your token at https://github.com/settings/tokens" ) else: - lines.append("No token available.") - lines.append( - "Set GITHUB_APM_PAT or GITHUB_TOKEN, or run 'gh auth login'." - ) + host_info = self.classify_host(host) + if host_info.kind == "ado": + lines.append("Azure DevOps authentication required.") + lines.append( + "Set the ADO_APM_PAT environment variable with a PAT that has Code (Read) scope." + ) + else: + lines.append("No token available.") + lines.append( + "Set GITHUB_APM_PAT or GITHUB_TOKEN, or run 'gh auth login'." + ) - if org: + if org and self.classify_host(host).kind != "ado": lines.append( f"If packages span multiple organizations, set per-org tokens: " f"GITHUB_APM_PAT_{_org_to_env_suffix(org)}" diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index ab63b3b2f..8cf3477d5 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -461,3 +461,78 @@ def test_token_present_shows_source(self): msg = resolver.build_error_context("github.com", "clone") assert "GITHUB_APM_PAT" in msg assert "SAML SSO" in msg + + +# --------------------------------------------------------------------------- +# TestBuildErrorContextADO +# --------------------------------------------------------------------------- + +class TestBuildErrorContextADO: + """build_error_context must give ADO-specific guidance for dev.azure.com hosts. + + Issue #625: missing ADO_APM_PAT is described with a generic GitHub error + message instead of pointing the user at ADO_APM_PAT and Code (Read) scope. + """ + + def test_ado_no_token_mentions_ado_pat(self): + """No ADO_APM_PAT → error message must mention ADO_APM_PAT.""" + with patch.dict(os.environ, {}, clear=True): + with patch.object( + GitHubTokenManager, "resolve_credential_from_git", return_value=None + ): + resolver = AuthResolver() + msg = resolver.build_error_context("dev.azure.com", "clone", org="myorg") + assert "ADO_APM_PAT" in msg, ( + f"Expected 'ADO_APM_PAT' in error message, got:\n{msg}" + ) + + def test_ado_no_token_does_not_suggest_github_remediation(self): + """ADO error must not suggest GitHub-specific remediation steps.""" + with patch.dict(os.environ, {}, clear=True): + with patch.object( + GitHubTokenManager, "resolve_credential_from_git", return_value=None + ): + resolver = AuthResolver() + msg = resolver.build_error_context("dev.azure.com", "clone", org="myorg") + assert "gh auth login" not in msg, ( + f"ADO error message should not mention 'gh auth login', got:\n{msg}" + ) + assert "GITHUB_TOKEN" not in msg, ( + f"ADO error message should not mention 'GITHUB_TOKEN', got:\n{msg}" + ) + + def test_ado_no_token_mentions_code_read_scope(self): + """ADO error must mention Code (Read) scope so user knows what PAT scope to set.""" + with patch.dict(os.environ, {}, clear=True): + with patch.object( + GitHubTokenManager, "resolve_credential_from_git", return_value=None + ): + resolver = AuthResolver() + msg = resolver.build_error_context("dev.azure.com", "clone", org="myorg") + assert "Code" in msg or "read" in msg.lower(), ( + f"Expected Code (Read) scope guidance in error message, got:\n{msg}" + ) + + def test_ado_no_org_no_token_mentions_ado_pat(self): + """No org argument, no ADO_APM_PAT → error message must still mention ADO_APM_PAT.""" + with patch.dict(os.environ, {}, clear=True): + with patch.object( + GitHubTokenManager, "resolve_credential_from_git", return_value=None + ): + resolver = AuthResolver() + msg = resolver.build_error_context("dev.azure.com", "clone") + assert "ADO_APM_PAT" in msg, ( + f"Expected 'ADO_APM_PAT' in error message, got:\n{msg}" + ) + + def test_ado_with_token_still_shows_source(self): + """When an ADO token IS present but clone fails, source info is shown.""" + with patch.dict(os.environ, {"ADO_APM_PAT": "mypat"}, clear=True): + with patch.object( + GitHubTokenManager, "resolve_credential_from_git", return_value=None + ): + resolver = AuthResolver() + msg = resolver.build_error_context("dev.azure.com", "clone", org="myorg") + assert "ADO_APM_PAT" in msg, ( + f"Expected token source 'ADO_APM_PAT' in error message, got:\n{msg}" + ) From 0672bcc2f588159e182bcc6e7fd502f837b4fddb Mon Sep 17 00:00:00 2001 From: Chad Oakenfold Date: Thu, 16 Apr 2026 22:22:54 -0400 Subject: [PATCH 2/3] fix(auth): provide ADO-specific authentication error message for dev.azure.com PR Copilot suggestions --- src/apm_cli/core/auth.py | 9 ++++++--- tests/unit/test_auth.py | 31 +++++++++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/apm_cli/core/auth.py b/src/apm_cli/core/auth.py index 7a6423aae..1ab109a0e 100644 --- a/src/apm_cli/core/auth.py +++ b/src/apm_cli/core/auth.py @@ -315,15 +315,19 @@ def build_error_context( auth_ctx = self.resolve(host, org) lines: list[str] = [f"Authentication failed for {operation} on {host}."] + host_info = auth_ctx.host_info if auth_ctx.token: lines.append(f"Token was provided (source: {auth_ctx.source}, type: {auth_ctx.token_type}).") - host_info = self.classify_host(host) if host_info.kind == "ghe_cloud": lines.append( "GHE Cloud Data Residency hosts (*.ghe.com) require " "enterprise-scoped tokens. Ensure your PAT is authorized " "for this enterprise." ) + elif host_info.kind == "ado": + lines.append( + "Verify your ADO_APM_PAT is valid and has Code (Read) scope." + ) elif host.lower() == "github.com": lines.append( "If your organization uses SAML SSO or is an EMU org, " @@ -336,7 +340,6 @@ def build_error_context( "authorize your token at https://github.com/settings/tokens" ) else: - host_info = self.classify_host(host) if host_info.kind == "ado": lines.append("Azure DevOps authentication required.") lines.append( @@ -348,7 +351,7 @@ def build_error_context( "Set GITHUB_APM_PAT or GITHUB_TOKEN, or run 'gh auth login'." ) - if org and self.classify_host(host).kind != "ado": + if org and host_info.kind != "ado": lines.append( f"If packages span multiple organizations, set per-org tokens: " f"GITHUB_APM_PAT_{_org_to_env_suffix(org)}" diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index 8cf3477d5..5d97a1b45 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -475,7 +475,7 @@ class TestBuildErrorContextADO: """ def test_ado_no_token_mentions_ado_pat(self): - """No ADO_APM_PAT → error message must mention ADO_APM_PAT.""" + """No ADO_APM_PAT -> error message must mention ADO_APM_PAT.""" with patch.dict(os.environ, {}, clear=True): with patch.object( GitHubTokenManager, "resolve_credential_from_git", return_value=None @@ -514,7 +514,7 @@ def test_ado_no_token_mentions_code_read_scope(self): ) def test_ado_no_org_no_token_mentions_ado_pat(self): - """No org argument, no ADO_APM_PAT → error message must still mention ADO_APM_PAT.""" + """No org argument, no ADO_APM_PAT -> error message must still mention ADO_APM_PAT.""" with patch.dict(os.environ, {}, clear=True): with patch.object( GitHubTokenManager, "resolve_credential_from_git", return_value=None @@ -536,3 +536,30 @@ def test_ado_with_token_still_shows_source(self): assert "ADO_APM_PAT" in msg, ( f"Expected token source 'ADO_APM_PAT' in error message, got:\n{msg}" ) + + def test_ado_with_token_mentions_scope_guidance(self): + """When an ADO token is present but auth fails, PAT validity/scope hint is shown.""" + with patch.dict(os.environ, {"ADO_APM_PAT": "mypat"}, clear=True): + with patch.object( + GitHubTokenManager, "resolve_credential_from_git", return_value=None + ): + resolver = AuthResolver() + msg = resolver.build_error_context("dev.azure.com", "clone", org="myorg") + assert "Code (Read)" in msg, ( + f"Expected Code (Read) scope guidance in error message, got:\n{msg}" + ) + + def test_ado_with_token_does_not_suggest_github_remediation(self): + """When an ADO token is present but auth fails, GitHub SAML guidance must not appear.""" + with patch.dict(os.environ, {"ADO_APM_PAT": "mypat"}, clear=True): + with patch.object( + GitHubTokenManager, "resolve_credential_from_git", return_value=None + ): + resolver = AuthResolver() + msg = resolver.build_error_context("dev.azure.com", "clone", org="myorg") + assert "SAML" not in msg, ( + f"ADO error should not mention SAML, got:\n{msg}" + ) + assert "github.com/settings/tokens" not in msg, ( + f"ADO error should not mention github.com/settings/tokens, got:\n{msg}" + ) From 51718f2a219a20573406274305c9ab5c8a225b6e Mon Sep 17 00:00:00 2001 From: Chad Oakenfold Date: Thu, 16 Apr 2026 22:37:27 -0400 Subject: [PATCH 3/3] fix(auth): provide ADO-specific authentication error message for dev.azure.com PR.2 Implementing Copilot suggestions --- tests/unit/test_auth.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index 5d97a1b45..f4a9390e6 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -500,6 +500,10 @@ def test_ado_no_token_does_not_suggest_github_remediation(self): assert "GITHUB_TOKEN" not in msg, ( f"ADO error message should not mention 'GITHUB_TOKEN', got:\n{msg}" ) + assert "GITHUB_APM_PAT_MYORG" not in msg, ( + "ADO error message should not mention per-org GitHub PAT hint " + f"'GITHUB_APM_PAT_MYORG', got:\n{msg}" + ) def test_ado_no_token_mentions_code_read_scope(self): """ADO error must mention Code (Read) scope so user knows what PAT scope to set.""" @@ -563,3 +567,21 @@ def test_ado_with_token_does_not_suggest_github_remediation(self): assert "github.com/settings/tokens" not in msg, ( f"ADO error should not mention github.com/settings/tokens, got:\n{msg}" ) + + def test_visualstudio_com_gets_ado_remediation(self): + """Legacy *.visualstudio.com hosts are also ADO and must get ADO-specific guidance.""" + with patch.dict(os.environ, {}, clear=True): + with patch.object( + GitHubTokenManager, "resolve_credential_from_git", return_value=None + ): + resolver = AuthResolver() + msg = resolver.build_error_context("myorg.visualstudio.com", "clone") + assert "ADO_APM_PAT" in msg, ( + f"Expected 'ADO_APM_PAT' in error message, got:\n{msg}" + ) + assert "gh auth login" not in msg, ( + f"ADO error should not mention 'gh auth login', got:\n{msg}" + ) + assert "SAML" not in msg, ( + f"ADO error should not mention SAML, got:\n{msg}" + )