Skip to content
23 changes: 12 additions & 11 deletions pkg/cli/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (

"github.com/github/gh-aw/pkg/console"
"github.com/github/gh-aw/pkg/constants"
"github.com/github/gh-aw/pkg/errorutil"
"github.com/github/gh-aw/pkg/fileutil"
"github.com/github/gh-aw/pkg/logger"
"github.com/github/gh-aw/pkg/parser"
Expand Down Expand Up @@ -293,18 +294,18 @@ func runAuditMulti(ctx context.Context, args []string, repoFlag, outputDir strin
})
}

// isPermissionErrorStr checks if a string contains any known permission/authentication error marker.
// This is the canonical union of all auth-error substrings used across the codebase; update here
// rather than adding new inline strings.Contains checks in callers.
// isPermissionErrorStr checks if a string contains known permission/authentication markers.
// It delegates to the shared classifier and augments with gh CLI specific hints
// that are only emitted in audit command contexts.
func isPermissionErrorStr(s string) bool {
return strings.Contains(s, "authentication required") ||
strings.Contains(s, "exit status 4") ||
strings.Contains(s, "GitHub CLI authentication") ||
strings.Contains(s, "permission") ||
strings.Contains(s, "GH_TOKEN") ||
strings.Contains(s, "not logged into any GitHub hosts") ||
strings.Contains(s, "To use GitHub CLI in a GitHub Actions workflow") ||
strings.Contains(s, "gh auth login")
if errorutil.IsAuthError(s) {
return true

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] isPermissionErrorStr in audit.go now lowercases the input before matching, but the existing errorutil.IsAuthError (which it delegates to first) also lowercases internally. This is correct and harmless, but the audit-specific branch that follows uses strings.ToLower(s) on a different variable (lower). One subtle gap: the original "GitHub CLI authentication" marker is no longer present — it was removed and not mapped to any matching pattern in errorutil.IsAuthError ("authentication" would match it, but "GitHub CLI authentication" is a distinct phrase). A targeted test for this removed literal would confirm it's still covered.

💡 Suggested test
// In pkg/cli/audit_test.go
assert.True(t, isPermissionErrorStr("GitHub CLI authentication token is missing"), "legacy marker should still match via 'authentication'")

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added coverage for the legacy GitHub CLI authentication marker in aaec1b1.

}
lower := strings.ToLower(s)
return strings.Contains(lower, "exit status 4") ||
strings.Contains(lower, "permission") ||
strings.Contains(lower, "gh auth login") ||
strings.Contains(lower, "to use github cli in a github actions workflow")
}

// isPermissionError checks if an error is related to permissions/authentication.
Expand Down
5 changes: 3 additions & 2 deletions pkg/cli/download_workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/github/gh-aw/pkg/constants"

"github.com/github/gh-aw/pkg/console"
"github.com/github/gh-aw/pkg/errorutil"
"github.com/github/gh-aw/pkg/fileutil"
"github.com/github/gh-aw/pkg/gitutil"
"github.com/github/gh-aw/pkg/logger"
Expand Down Expand Up @@ -129,7 +130,7 @@ func downloadWorkflowContentViaGitClone(ctx context.Context, repo, path, ref str
}

// Check if ref is a SHA (40 hex characters)
isSHA := len(ref) == 40 && gitutil.IsHexString(ref)
isSHA := gitutil.IsValidFullSHA(ref)
downloadLog.Printf("Fetching ref via sparse checkout: is_sha=%t", isSHA)

if isSHA {
Expand Down Expand Up @@ -197,7 +198,7 @@ func downloadWorkflowContent(ctx context.Context, repo, path, ref string, verbos
if err != nil {
// Check if this is an authentication error
outputStr := string(output)
if gitutil.IsAuthError(outputStr) || gitutil.IsAuthError(err.Error()) {
if errorutil.IsAuthError(outputStr) || errorutil.IsAuthError(err.Error()) {
downloadLog.Printf("GitHub API authentication failed, attempting git fallback for %s/%s@%s", repo, path, ref)
// Try fallback using git commands
content, gitErr := downloadWorkflowContentViaGit(ctx, repo, path, ref, verbose)
Expand Down
4 changes: 2 additions & 2 deletions pkg/cli/forecast_compute.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import (

"github.com/github/gh-aw/pkg/console"
"github.com/github/gh-aw/pkg/constants"
"github.com/github/gh-aw/pkg/errorutil"
"github.com/github/gh-aw/pkg/fileutil"
"github.com/github/gh-aw/pkg/gitutil"
"github.com/github/gh-aw/pkg/workflow"
)

Expand Down Expand Up @@ -76,7 +76,7 @@ func forecastWorkflow(ctx context.Context, workflowName, startDate string, confi

runs, _, err := listRunsWithBackoff(ctx, opts, result.WorkflowID)
if err != nil {
if gitutil.IsRateLimitError(err.Error()) {
if errorutil.IsRateLimitError(err.Error()) {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(
fmt.Sprintf("Skipping %s: GitHub API rate limit exceeded", result.WorkflowID)))
return result, nil
Expand Down
6 changes: 3 additions & 3 deletions pkg/cli/forecast_resolution.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (
"time"

"github.com/github/gh-aw/pkg/console"
"github.com/github/gh-aw/pkg/gitutil"
"github.com/github/gh-aw/pkg/errorutil"
"github.com/github/gh-aw/pkg/logger"
"github.com/github/gh-aw/pkg/workflow"
)
Expand Down Expand Up @@ -116,7 +116,7 @@ func fetchWorkflowsWithBackoff(ctx context.Context, ids []string, repoOverride s
if err == nil {
return githubWorkflows, nil
}
if !gitutil.IsRateLimitError(err.Error()) {
if !errorutil.IsRateLimitError(err.Error()) {
return nil, err
}

Expand Down Expand Up @@ -160,7 +160,7 @@ func listRunsWithBackoff(ctx context.Context, opts ListWorkflowRunsOptions, work
if err == nil {
return runs, total, nil
}
if !gitutil.IsRateLimitError(err.Error()) {
if !errorutil.IsRateLimitError(err.Error()) {
return nil, 0, err
}

Expand Down
4 changes: 2 additions & 2 deletions pkg/cli/health_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (

"github.com/github/gh-aw/pkg/console"
"github.com/github/gh-aw/pkg/constants"
"github.com/github/gh-aw/pkg/gitutil"
"github.com/github/gh-aw/pkg/errorutil"
"github.com/github/gh-aw/pkg/logger"
"github.com/github/gh-aw/pkg/workflow"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -133,7 +133,7 @@ func RunHealth(config HealthConfig) error {
// Fetch workflow runs from GitHub
runs, err := fetchWorkflowRuns(workflowAPIName, startDate, config.RepoOverride, config.Verbose)
if err != nil {
if gitutil.IsRateLimitError(err.Error()) {
if errorutil.IsRateLimitError(err.Error()) {
// Rate limiting is a transient infrastructure condition, not a code error.
// Warn and exit cleanly so CI jobs are not marked as failed.
fmt.Fprintln(os.Stderr, console.FormatWarningMessage("Skipping health check: GitHub API rate limit exceeded"))
Expand Down
3 changes: 2 additions & 1 deletion pkg/cli/update_actions_release.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"time"

"github.com/github/gh-aw/pkg/console"
"github.com/github/gh-aw/pkg/errorutil"
"github.com/github/gh-aw/pkg/gitutil"
"github.com/github/gh-aw/pkg/semverutil"
"github.com/github/gh-aw/pkg/workflow"
Expand All @@ -39,7 +40,7 @@ func getLatestActionReleaseWithDeps(ctx context.Context, deps actionUpdateDeps,
if err != nil {
// Check if this is an authentication error
outputStr := string(output)
if gitutil.IsAuthError(outputStr) || gitutil.IsAuthError(err.Error()) {
if errorutil.IsAuthError(outputStr) || errorutil.IsAuthError(err.Error()) {
updateLog.Printf("GitHub API authentication failed, attempting git ls-remote fallback for %s", repo)
// Try fallback using git ls-remote
latestRelease, latestSHA, gitErr := deps.getLatestReleaseViaGit(ctx, repo, currentVersion, allowMajor, verbose)
Expand Down
8 changes: 4 additions & 4 deletions pkg/cli/update_display.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import (
"strings"

"github.com/github/gh-aw/pkg/console"
"github.com/github/gh-aw/pkg/gitutil"
"github.com/github/gh-aw/pkg/errorutil"
"github.com/github/gh-aw/pkg/logger"
)

Expand Down Expand Up @@ -68,11 +68,11 @@ func groupUpdateFailures(failures []updateFailure) []updateFailureGroup {

func compactUpdateFailureReason(message string) string {
switch {
case gitutil.IsAuthError(message) && gitutil.IsRateLimitError(message):
case errorutil.IsAuthError(message) && errorutil.IsRateLimitError(message):
return "SAML-restricted authenticated access; anonymous GitHub API fallback is rate-limited"
case gitutil.IsAuthError(message):
case errorutil.IsAuthError(message):
return "GitHub API access is restricted by authentication or SAML"
case gitutil.IsRateLimitError(message):
case errorutil.IsRateLimitError(message):
return "GitHub API rate limit exceeded"
}

Expand Down
13 changes: 6 additions & 7 deletions pkg/cli/update_workflows.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,9 @@ import (
"sync"
"time"

"github.com/github/gh-aw/pkg/constants"

"github.com/github/gh-aw/pkg/console"
"github.com/github/gh-aw/pkg/gitutil"
"github.com/github/gh-aw/pkg/constants"
"github.com/github/gh-aw/pkg/errorutil"
"github.com/github/gh-aw/pkg/parser"
"github.com/github/gh-aw/pkg/semverutil"
"github.com/github/gh-aw/pkg/workflow"
Expand Down Expand Up @@ -187,7 +186,7 @@ func UpdateWorkflows(ctx context.Context, opts UpdateWorkflowsOptions) error {
// (non-fatal) from genuine update failures (fatal).
func allFailuresAreRateLimited(failures []updateFailure) bool {
for _, f := range failures {
if !gitutil.IsRateLimitError(f.Error) {
if !errorutil.IsRateLimitError(f.Error) {
return false
}
}
Expand Down Expand Up @@ -472,7 +471,7 @@ func fetchPublicReleaseTagsPaginated(ctx context.Context, repo string) ([]string
// getRepoDefaultBranch fetches the default branch name for a repository.
func getRepoDefaultBranch(ctx context.Context, repo string) (string, error) {
output, err := workflow.RunGHContext(ctx, "Fetching repo info...", "api", "/repos/"+repo, "--jq", ".default_branch")
if err != nil && gitutil.IsAuthError(err.Error()) {
if err != nil && errorutil.IsAuthError(err.Error()) {
updateLog.Printf("GitHub API auth failed for %s, retrying without token", repo)
body, fallbackErr := fetchPublicGitHubAPI(ctx, "/repos/"+repo)
if fallbackErr != nil {
Expand Down Expand Up @@ -506,7 +505,7 @@ func getLatestBranchCommitInfo(ctx context.Context, repo, branch string) (latest
// URL-encode the branch name since it may contain slashes (e.g. "feature/foo")
endpoint := fmt.Sprintf("/repos/%s/commits/%s", repo, url.PathEscape(branch))
output, err := workflow.RunGHContext(ctx, "Fetching commit info...", "api", endpoint)
if err != nil && gitutil.IsAuthError(err.Error()) {
if err != nil && errorutil.IsAuthError(err.Error()) {
updateLog.Printf("GitHub API auth failed for branch %s of %s, retrying without token", branch, repo)
body, fallbackErr := fetchPublicGitHubAPI(ctx, endpoint)
if fallbackErr != nil {
Expand Down Expand Up @@ -564,7 +563,7 @@ func defaultWorkflowUpdateDeps() workflowUpdateDeps {
runReleasesAPI: func(ctx context.Context, repo string) ([]byte, error) {
endpoint := fmt.Sprintf("/repos/%s/releases", repo)
output, err := workflow.RunGHContext(ctx, "Fetching releases...", "api", "--paginate", endpoint, "--jq", ".[].tag_name")
if err != nil && gitutil.IsAuthError(err.Error()) {
if err != nil && errorutil.IsAuthError(err.Error()) {
updateLog.Printf("GitHub API auth failed for releases of %s, retrying without token", repo)
tags, fallbackErr := fetchPublicReleaseTagsPaginated(ctx, repo)
if fallbackErr != nil {
Expand Down
13 changes: 12 additions & 1 deletion pkg/errorutil/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ The `errorutil` package provides shared helpers for classifying and inspecting e

## Overview

This package currently exposes focused helpers for identifying common error categories used across `pkg/cli` and `pkg/parser`, including "not found" (`404`), "forbidden" (`403`), and "gone" (`410`) responses.
This package currently exposes focused helpers for identifying common error categories used across `pkg/cli` and `pkg/parser`, including "not found" (`404`), "forbidden" (`403`), "gone" (`410`), rate-limit, and authentication/authorization responses.

## Public API

Expand All @@ -15,6 +15,8 @@ This package currently exposes focused helpers for identifying common error cate
| `IsNotFoundError` | `func(err error) bool` | Returns `true` when `err` indicates a "not found" condition by matching case-insensitive `404` or `not found` text; returns `false` for `nil` and non-matching errors |
| `IsForbiddenError` | `func(err error) bool` | Returns `true` when `err` indicates an HTTP-style `403`/"forbidden" response by matching case-insensitive patterns like `HTTP 403` or `403 Forbidden`; returns `false` for `nil` and non-matching errors |
| `IsGoneError` | `func(err error) bool` | Returns `true` when `err` indicates an HTTP-style `410`/"gone" response by matching case-insensitive patterns like `HTTP 410` or `410 Gone`; returns `false` for `nil` and non-matching errors |
| `IsRateLimitError` | `func(output string) bool` | Returns `true` when `output` indicates GitHub API rate limiting by matching case-insensitive `api rate limit exceeded`, `rate limit exceeded`, or `secondary rate limit` text |
| `IsAuthError` | `func(output string) bool` | Returns `true` when `output` indicates authentication or authorization failures by matching case-insensitive markers including `GH_TOKEN`, `GITHUB_TOKEN`, `authentication`, `not logged into`, `unauthorized`, `forbidden`, `permission denied`, or `SAML enforcement` |

## Usage Examples

Expand All @@ -32,6 +34,14 @@ if errorutil.IsForbiddenError(err) {
if errorutil.IsGoneError(err) {
// Handle expired or deleted resource
}

if errorutil.IsRateLimitError(output) {
// Back off and retry
}

if errorutil.IsAuthError(output) {
// Surface credential guidance
}
```

## Dependencies
Expand All @@ -45,6 +55,7 @@ if errorutil.IsGoneError(err) {
## Design Notes

- `IsNotFoundError`, `IsForbiddenError`, and `IsGoneError` intentionally accept multiple message formats to cover errors produced by GitHub API responses, `gh` CLI output, and `go-gh` wrappers.
- `IsRateLimitError` and `IsAuthError` provide shared case-insensitive string classifiers for GitHub API and `gh` CLI output so callers avoid duplicating inline substring checks.
- `IsForbiddenError` and `IsGoneError` intentionally require HTTP-style status context so unrelated phrases like `forbidden character` or `gone away` are not misclassified.

---
Expand Down
32 changes: 32 additions & 0 deletions pkg/errorutil/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,38 @@ func IsGoneError(err error) bool {
return matched
}

// IsRateLimitError reports whether output indicates a GitHub API rate-limit error.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/improve-codebase-architecture] IsRateLimitError is documented as matching "api rate limit exceeded", "rate limit exceeded", or "secondary rate limit". The implementation uses containsSubstring which matches "rate limit exceeded" — this is a substring of "api rate limit exceeded", so the two patterns collapse into one. The "api rate limit exceeded" entry in errors_test.go passes because "rate limit exceeded" matches it too.

This redundancy isn't harmful, but it means the first documented phrase has no independent coverage. Consider either removing the redundant literal or documenting that "rate limit exceeded" is the canonical form.

💡 Why this matters

If someone later adds a string-specific exclusion or a more restrictive matcher, the documented phrases should be independently testable. A deduplication comment in errors.go would make the intent explicit:

// "rate limit exceeded" is a suffix of "api rate limit exceeded" — one pattern covers both.

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The redundant API-specific phrase remains covered by the canonical rate limit exceeded substring; the documentation now lists that canonical matcher in aaec1b1.

// The check is case-insensitive and matches known API phrases.
func IsRateLimitError(output string) bool {
matched := containsSubstring(output,
"rate limit exceeded",
"secondary rate limit",
)
if matched {
errorutilLog.Printf("Classified output as rate-limit related: %s", output)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/codebase-design] Debug log may leak sensitive content: the full output string is written to the log on a positive match, but that string can contain credential fragments from CLI error messages. The original gitutil.IsAuthError logged only a fixed sentinel — not the payload.

💡 Suggested fix

Log a bounded indicator instead of the raw value:

errorutilLog.Printf("Classified output as rate-limit related (len=%d)", len(output))

This is consistent with how gitutil.IsAuthError handled it and avoids echoing potentially sensitive error payloads into debug logs.

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated both shared classifiers to log only the output length in aaec1b1.

}
return matched
}

// IsAuthError reports whether output indicates an authentication or
// authorization issue from the GitHub API or gh CLI.
func IsAuthError(output string) bool {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IsAuthError now treats every 403 Forbidden as an authentication problem, which will misroute ordinary authorization failures into auth/git-fallback paths and hide the real cause from users.

💡 Why this blocks merge

The old audit helper had extra command-specific heuristics, but the shared classifier is now used in parser and CLI fallback logic to decide whether to retry with git or unauthenticated API calls. Broadening that shared predicate to match bare forbidden means any non-auth 403 — for example repo policy restrictions, disabled endpoints, or feature gating — gets mislabeled as an auth failure.

That changes control flow, not just messaging: callers will take fallback branches intended only for missing/invalid credentials, and the final error becomes misleading when the fallback also fails.

Please tighten the classifier so it only matches credential-specific markers, or require stronger context than a generic forbidden substring before triggering auth recovery.

matched := containsSubstring(output,
"gh_token",
"github_token",
"authentication",
"not logged into",
"unauthorized",
"forbidden",
"permission denied",
"saml enforcement",
)
if matched {
errorutilLog.Printf("Classified output as auth-related: %s", output)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/codebase-design] Same sensitive-log concern applies to IsAuthError: the full output (which often contains the exact credential error message) is logged verbatim when the function returns true. Consider logging only len(output) or a fixed marker here too.

💡 Suggested fix
if matched {
    errorutilLog.Printf("Classified output as auth-related (len=%d)", len(output))
}

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated both shared classifiers to log only the output length in aaec1b1.

}
return matched
}

// containsErrorSubstring reports whether err contains any of the provided
// substrings after lowercasing the full error message for case-insensitive
// matching.
Expand Down
44 changes: 44 additions & 0 deletions pkg/errorutil/errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,47 @@ func TestIsGoneError(t *testing.T) {
})
}
}

func TestIsRateLimitError(t *testing.T) {
tests := []struct {
name string
output string
want bool
}{
{name: "api rate limit exceeded", output: "API rate limit exceeded", want: true},
{name: "rate limit exceeded", output: "rate limit exceeded", want: true},
{name: "secondary rate limit", output: "secondary rate limit triggered", want: true},
{name: "case-insensitive", output: "API RATE LIMIT EXCEEDED", want: true},
{name: "non-rate-limit error", output: "HTTP 404: Not Found", want: false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, errorutil.IsRateLimitError(tt.output))
})
}
}

func TestIsAuthError(t *testing.T) {
tests := []struct {
name string
output string
want bool
}{
{name: "gh_token", output: "GH_TOKEN is not set", want: true},
{name: "github_token", output: "GITHUB_TOKEN is invalid", want: true},
{name: "authentication", output: "authentication required", want: true},
{name: "not logged into", output: "not logged into any GitHub hosts", want: true},
{name: "unauthorized", output: "HTTP 401: Unauthorized", want: true},
{name: "forbidden", output: "HTTP 403: Forbidden", want: true},
{name: "permission denied", output: "permission denied: insufficient scope", want: true},
{name: "saml enforcement", output: "Resource protected by organization SAML enforcement", want: true},
{name: "non-auth error", output: "API rate limit exceeded for installation", want: false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, errorutil.IsAuthError(tt.output))
})
}
}
Loading
Loading