Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions cli/azd/pkg/templates/gh_errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package templates

import (
"errors"

"github.com/azure/azure-dev/cli/azd/internal"
"github.com/azure/azure-dev/cli/azd/pkg/errorhandler"
"github.com/azure/azure-dev/cli/azd/pkg/tools/github"
)

// withGitHubSuggestion wraps the supplied error in an *internal.ErrorWithSuggestion
// when the error is a recognized GitHub failure (typed *github.ApiError or
// *RepoNotAccessibleError). The wrapping carries actionable guidance inline
// in the error chain, which means:
//
// - The core CLI ErrorMiddleware renders it directly without consulting the
// YAML rules pipeline (ErrorMiddleware short-circuits on
// *ErrorWithSuggestion).
// - Callers that don't have access to the YAML pipeline (e.g., extensions
// receiving stringified errors over gRPC) still see the suggestion as part
// of the wrapped error, because *ApiError keeps formatting itself with
// status + message.
//
// Returns the original error unchanged when no specific suggestion applies, so
// other typed-error pipelines (auth, unknown gh failures) behave as before.
func withGitHubSuggestion(err error) error {
if err == nil {
return nil
}

if apiErr, ok := errors.AsType[*github.ApiError](err); ok {
if s := suggestionForApiError(apiErr); s != nil {
s.Err = err
return s
}
}

if repoErr, ok := errors.AsType[*RepoNotAccessibleError](err); ok {
s := suggestionForRepoNotAccessible(repoErr)
s.Err = err
return s
}
Comment thread
JeffreyCA marked this conversation as resolved.

return err
}

func suggestionForApiError(apiErr *github.ApiError) *internal.ErrorWithSuggestion {
switch apiErr.Kind {
case github.KindSAMLBlocked:
return &internal.ErrorWithSuggestion{
Message: "The GitHub organization that owns this repository requires SAML SSO " +
"authorization for your token before it can be used.",
Suggestion: "If you signed in with `gh auth login`, run `gh auth refresh` and " +
"complete the SSO authorization in the browser when prompted (use " +
"`gh auth refresh -h <host>` for non-default hosts). If you're using a " +
"personal access token, open your GitHub token settings, locate the " +
"token, click 'Configure SSO', and authorize the organization that owns " +
"this repository.",
Links: []errorhandler.ErrorLink{
{
URL: "https://docs.github.com/enterprise-cloud@latest/authentication/" +
"authenticating-with-single-sign-on/" +
"about-authentication-with-single-sign-on",
Title: "About authentication with single sign-on",
},
{
URL: "https://docs.github.com/enterprise-cloud@latest/authentication/" +
"authenticating-with-single-sign-on/" +
"authorizing-a-personal-access-token-for-use-with-single-sign-on",
Title: "Authorizing a personal access token for use with single sign-on",
},
},
}
case github.KindRateLimited:
return &internal.ErrorWithSuggestion{
Message: "GitHub API rate limit exceeded.",
Suggestion: "Authenticated requests have a much higher limit than anonymous ones. " +
"Run `gh auth login` (or set GITHUB_TOKEN / GH_TOKEN) and retry. " +
"If you're already authenticated, wait for the rate-limit window to reset " +
"(typically up to one hour).",
Links: []errorhandler.ErrorLink{
{
URL: "https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api",
Title: "Rate limits for the REST API",
},
},
}
case github.KindUnauthorized:
return &internal.ErrorWithSuggestion{
Message: "GitHub rejected the request as unauthenticated (HTTP 401).",
Suggestion: "Run `gh auth login` to sign in, or refresh an expired token with " +
"`gh auth refresh`. If you're using GITHUB_TOKEN / GH_TOKEN, regenerate the " +
"token and ensure it has the required scopes.",
}
case github.KindForbidden:
return &internal.ErrorWithSuggestion{
Message: "GitHub denied access to the requested resource (HTTP 403). The " +
"repository may be private, your token may be missing required scopes, or " +
"your account may not have permission.",
Suggestion: "Verify you can access the repository in a browser while signed in " +
"as the same GitHub account. If you're using a personal access token, ensure " +
"it includes the 'repo' scope. Run `gh auth status` to confirm which account " +
"gh is using.",
}
case github.KindServerError:
return &internal.ErrorWithSuggestion{
Message: "GitHub returned a server error (HTTP 5xx). This usually indicates a " +
"transient issue on GitHub's side rather than a problem with your request.",
Suggestion: "Wait a few minutes and try again. If the problem persists, check " +
"https://www.githubstatus.com/ for ongoing incidents.",
Links: []errorhandler.ErrorLink{
{
URL: "https://www.githubstatus.com/",
Title: "GitHub Status",
},
},
}
}
return nil
}

func suggestionForRepoNotAccessible(_ *RepoNotAccessibleError) *internal.ErrorWithSuggestion {
// Leave Message empty so the renderer falls back to RepoNotAccessibleError.Error(),
// which is already user-friendly and includes the repo slug — avoids duplication.
Comment thread
JeffreyCA marked this conversation as resolved.
return &internal.ErrorWithSuggestion{
Suggestion: "Confirm the repository URL is correct and that the active gh account " +
"can see it. Run `gh auth status` to check which account is active. For Enterprise " +
"Managed Users (EMU), make sure the active account is the EMU account that owns " +
"this repository — a github.com URL may need to target a different host.",
}
}
157 changes: 157 additions & 0 deletions cli/azd/pkg/templates/gh_errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package templates

import (
"errors"
"strings"
"testing"

"github.com/azure/azure-dev/cli/azd/internal"
"github.com/azure/azure-dev/cli/azd/pkg/tools/github"
"github.com/stretchr/testify/require"
)

// TestSuggestionForApiError_PerKind verifies that every classified
// ApiErrorKind that is meant to surface user guidance produces a non-nil
// *internal.ErrorWithSuggestion containing the substrings users rely on
// (the suggestion text and the relevant doc link). Kinds that intentionally
// return nil (NotFound, Other, Unknown) are also asserted to ensure we
// don't silently start emitting suggestions where none are expected.
func TestSuggestionForApiError_PerKind(t *testing.T) {
t.Parallel()
cases := []struct {
name string
kind github.ApiErrorKind
wantNil bool
wantSnippet string // substring required in the rendered suggestion text
wantLink string // substring required in at least one Links[].URL (empty = skip)
}{
{"SAMLBlocked", github.KindSAMLBlocked, false,
"SAML SSO", "authenticating-with-single-sign-on"},
{"RateLimited", github.KindRateLimited, false,
"rate limit", "rate-limits-for-the-rest-api"},
{"Unauthorized", github.KindUnauthorized, false,
"gh auth login", ""},
{"Forbidden", github.KindForbidden, false,
"gh auth status", ""},
{"ServerError", github.KindServerError, false,
"server error", "githubstatus.com"},
{"NotFound returns nil — RepoNotAccessibleError handles that path", github.KindNotFound, true, "", ""},
{"Other returns nil — falls through to typed *ApiError.Error()", github.KindOther, true, "", ""},
{"Unknown returns nil — surfaces underlying error", github.KindUnknown, true, "", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
apiErr := &github.ApiError{
URL: "https://api.github.com/repos/o/r",
Kind: tc.kind,
StatusCode: 500, // arbitrary, not asserted
}
got := suggestionForApiError(apiErr)
if tc.wantNil {
require.Nil(t, got, "expected nil suggestion for %s", tc.kind)
return
}
require.NotNil(t, got, "expected non-nil suggestion for %s", tc.kind)
combined := got.Message + " " + got.Suggestion
require.Contains(t, combined, tc.wantSnippet)
if tc.wantLink != "" {
var found bool
for _, l := range got.Links {
if strings.Contains(l.URL, tc.wantLink) {
found = true
break
}
}
require.True(t, found, "expected a Links[] URL containing %q, got %+v", tc.wantLink, got.Links)
}
})
}
}

// TestSuggestionForRepoNotAccessible verifies the dedicated
// RepoNotAccessibleError suggestion is emitted with EMU/private-repo guidance
// and a non-empty Suggestion field — Message is intentionally empty so the
// renderer falls back to RepoNotAccessibleError.Error() (which already
// contains the repo slug) and we don't duplicate the same text twice.
func TestSuggestionForRepoNotAccessible(t *testing.T) {
t.Parallel()
got := suggestionForRepoNotAccessible(&RepoNotAccessibleError{
Hostname: "github.com",
RepoSlug: "owner/repo",
})
require.NotNil(t, got)
require.Empty(t, got.Message, "Message must be empty so renderer uses RepoNotAccessibleError.Error()")
require.Contains(t, got.Suggestion, "gh auth status")
require.Contains(t, got.Suggestion, "EMU")
}

// TestWithGitHubSuggestion_Dispatch verifies the wrapper:
// - returns nil for nil input
// - wraps *github.ApiError with a classified suggestion (preserving the
// original error in ErrorWithSuggestion.Err so the chain still unwraps
// to the typed *ApiError)
// - wraps *RepoNotAccessibleError into a suggestion (always — never nil,
// because RepoNotAccessibleError is itself a strong signal)
// - returns the original error unchanged for unrecognized types
// - returns the original error unchanged when the typed error has a kind
// that intentionally has no suggestion (e.g., KindNotFound on its own —
// RepoNotAccessibleError is the surface for the "repo invisible" case)
func TestWithGitHubSuggestion_Dispatch(t *testing.T) {
t.Parallel()

t.Run("nil in nil out", func(t *testing.T) {
t.Parallel()
require.NoError(t, withGitHubSuggestion(nil))
})

t.Run("ApiError with suggestion-bearing kind is wrapped", func(t *testing.T) {
t.Parallel()
original := &github.ApiError{
URL: "https://api.github.com/repos/o/r/branches/main",
Kind: github.KindSAMLBlocked,
StatusCode: 403,
}
wrapped := withGitHubSuggestion(original)
ews, ok := errors.AsType[*internal.ErrorWithSuggestion](wrapped)
require.True(t, ok, "expected *internal.ErrorWithSuggestion, got %T", wrapped)
// The ErrorWithSuggestion wraps the original so error chain still
// unwraps to the typed ApiError for downstream consumers.
got, ok := errors.AsType[*github.ApiError](ews)
require.True(t, ok, "ErrorWithSuggestion must preserve *ApiError in chain")
require.Same(t, original, got)
})

t.Run("ApiError without suggestion (KindNotFound) returned unchanged", func(t *testing.T) {
t.Parallel()
original := &github.ApiError{
URL: "https://api.github.com/repos/o/r/branches/main",
Kind: github.KindNotFound,
StatusCode: 404,
}
got := withGitHubSuggestion(original)
require.Same(t, original, got, "no suggestion → return original error untouched")
})

t.Run("RepoNotAccessibleError is always wrapped", func(t *testing.T) {
t.Parallel()
original := &RepoNotAccessibleError{Hostname: "github.com", RepoSlug: "owner/repo"}
wrapped := withGitHubSuggestion(original)
ews, ok := errors.AsType[*internal.ErrorWithSuggestion](wrapped)
require.True(t, ok)
// Chain still unwraps to the typed RepoNotAccessibleError.
got, ok := errors.AsType[*RepoNotAccessibleError](ews)
require.True(t, ok)
require.Same(t, original, got)
})

t.Run("unrecognized error returned unchanged", func(t *testing.T) {
t.Parallel()
original := errors.New("some random error")
got := withGitHubSuggestion(original)
require.Same(t, original, got)
})
}
Loading
Loading