Skip to content

Commit 53175a1

Browse files
authored
Refactor shared value formatting and unify GitHub error/SHA classification paths (#53018)
1 parent b29c796 commit 53175a1

25 files changed

Lines changed: 313 additions & 369 deletions
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# ADR-53018: Centralize GitHub Error Classifiers and Value Formatting
2+
3+
**Date**: 2026-08-16
4+
**Status**: Draft
5+
**Deciders**: Unknown
6+
7+
---
8+
9+
### Context
10+
11+
The codebase had duplicate implementations of two cross-cutting concerns scattered across multiple packages. `IsAuthError` and `IsRateLimitError` were defined in `pkg/gitutil` but called by `pkg/cli` and `pkg/parser` — packages that have no semantic dependency on git operations. Placing error-classification logic in a git utility package created an inappropriate coupling: callers that only needed to classify GitHub API responses had to import git infrastructure. Independently, `marshalEnvValue` in `pkg/workflow` contained inlined JSON/reflect normalization that duplicated the same logic already present in `importinpututil.FormatResolvedValue`, creating a split-brain risk where the two serialization paths could diverge silently. Additionally, full-SHA validation was written inline as `len(x)==40 && gitutil.IsHexString(x)` at seven separate call sites rather than using the already-exported `gitutil.IsValidFullSHA` predicate.
12+
13+
### Decision
14+
15+
We will move `IsAuthError` and `IsRateLimitError` out of `pkg/gitutil` and into `pkg/errorutil` as the canonical shared API for GitHub error classification. We will update all callers across `pkg/cli` and `pkg/parser` to import from `errorutil`. We will replace `marshalEnvValue`'s inlined JSON/reflect normalization with a delegation to `importinpututil.FormatResolvedValue`, keeping only a `fmt.Sprint` scalar fallback and a `nil``""` guard. We will replace all inline `len(x)==40 && IsHexString(x)` predicates with `gitutil.IsValidFullSHA`.
16+
17+
### Alternatives Considered
18+
19+
#### Alternative 1: Keep classifiers in `gitutil`, add re-export shims in `errorutil`
20+
21+
Re-export `gitutil.IsAuthError` and `gitutil.IsRateLimitError` from `errorutil` without moving the implementation. Callers can import from either package. This avoids touching the implementation and keeps `gitutil` as the authority, but it creates two public APIs for the same function, does not fix the semantic mismatch (error classification is not a git concern), and leaves the underlying coupling intact. It was rejected because it trades a clean break for ongoing confusion about which package owns the behavior.
22+
23+
#### Alternative 2: Inline error-classification logic at each call site
24+
25+
Remove shared classifiers entirely and duplicate the substring checks wherever they are needed. This eliminates the package-dependency question but defeats the goal of a single source of truth, making future changes to classification phrases error-prone and requiring updates across many files. It was rejected because the problem that motivated `gitutil.IsAuthError` in the first place — avoiding scattered inline checks — would recur immediately.
26+
27+
### Consequences
28+
29+
#### Positive
30+
- `pkg/gitutil` scope is now narrowly defined as git repository operations and SHA/ref validation, eliminating an inappropriate coupling to GitHub API error semantics.
31+
- `pkg/errorutil` becomes the single authoritative location for GitHub error classification, so future phrase changes need to be made in exactly one place.
32+
- `marshalEnvValue` and `importinpututil.FormatResolvedValue` are guaranteed to produce identical serialization for arrays and maps, eliminating the risk of silent divergence between the two code paths.
33+
- Inline SHA predicates are replaced by a named, tested, regex-backed function, reducing the chance of off-by-one errors (e.g., accepting mixed-case or 64-character SHAs).
34+
35+
#### Negative
36+
- The change touches 21 files across `pkg/cli`, `pkg/parser`, `pkg/workflow`, `pkg/gitutil`, and `pkg/errorutil`, making it a wide-surface refactor that carries merge-conflict risk for any concurrent branches importing `gitutil.IsAuthError`.
37+
- Removing `IsRateLimitError` and `IsAuthError` from `pkg/gitutil`'s public API is a breaking change for any external consumers that imported those symbols directly (though this appears to be an internal-only codebase).
38+
39+
#### Neutral
40+
- `isPermissionErrorStr` in `pkg/cli/audit.go` now delegates to `errorutil.IsAuthError` and augments with audit-specific markers (`exit status 4`, `permission`, `gh auth login`, workflow guidance) rather than maintaining its own canonical union — this preserves audit-command-specific behavior without duplicating shared logic.
41+
- Tests for the moved functions are migrated from `pkg/gitutil` to `pkg/errorutil`, and spec tests are updated to reflect the new package ownership.
42+
43+
---
44+
45+
*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*

pkg/cli/audit.go

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313

1414
"github.com/github/gh-aw/pkg/console"
1515
"github.com/github/gh-aw/pkg/constants"
16+
"github.com/github/gh-aw/pkg/errorutil"
1617
"github.com/github/gh-aw/pkg/fileutil"
1718
"github.com/github/gh-aw/pkg/logger"
1819
"github.com/github/gh-aw/pkg/parser"
@@ -293,18 +294,18 @@ func runAuditMulti(ctx context.Context, args []string, repoFlag, outputDir strin
293294
})
294295
}
295296

296-
// isPermissionErrorStr checks if a string contains any known permission/authentication error marker.
297-
// This is the canonical union of all auth-error substrings used across the codebase; update here
298-
// rather than adding new inline strings.Contains checks in callers.
297+
// isPermissionErrorStr checks if a string contains known permission/authentication markers.
298+
// It delegates to the shared classifier and augments with gh CLI specific hints
299+
// that are only emitted in audit command contexts.
299300
func isPermissionErrorStr(s string) bool {
300-
return strings.Contains(s, "authentication required") ||
301-
strings.Contains(s, "exit status 4") ||
302-
strings.Contains(s, "GitHub CLI authentication") ||
303-
strings.Contains(s, "permission") ||
304-
strings.Contains(s, "GH_TOKEN") ||
305-
strings.Contains(s, "not logged into any GitHub hosts") ||
306-
strings.Contains(s, "To use GitHub CLI in a GitHub Actions workflow") ||
307-
strings.Contains(s, "gh auth login")
301+
if errorutil.IsAuthError(s) {
302+
return true
303+
}
304+
lower := strings.ToLower(s)
305+
return strings.Contains(lower, "exit status 4") ||
306+
strings.Contains(lower, "permission") ||
307+
strings.Contains(lower, "gh auth login") ||
308+
strings.Contains(lower, "to use github cli in a github actions workflow")
308309
}
309310

310311
// isPermissionError checks if an error is related to permissions/authentication.

pkg/cli/audit_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,11 @@ func TestIsPermissionErrorStr(t *testing.T) {
105105
s: "Run gh auth login to proceed",
106106
expected: true,
107107
},
108+
{
109+
name: "GitHub CLI authentication marker",
110+
s: "GitHub CLI authentication token is missing",
111+
expected: true,
112+
},
108113
{
109114
name: "Empty string",
110115
s: "",

pkg/cli/download_workflow.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/github/gh-aw/pkg/constants"
1313

1414
"github.com/github/gh-aw/pkg/console"
15+
"github.com/github/gh-aw/pkg/errorutil"
1516
"github.com/github/gh-aw/pkg/fileutil"
1617
"github.com/github/gh-aw/pkg/gitutil"
1718
"github.com/github/gh-aw/pkg/logger"
@@ -129,7 +130,7 @@ func downloadWorkflowContentViaGitClone(ctx context.Context, repo, path, ref str
129130
}
130131

131132
// Check if ref is a SHA (40 hex characters)
132-
isSHA := len(ref) == 40 && gitutil.IsHexString(ref)
133+
isSHA := gitutil.IsValidFullSHACaseInsensitive(ref)
133134
downloadLog.Printf("Fetching ref via sparse checkout: is_sha=%t", isSHA)
134135

135136
if isSHA {
@@ -197,7 +198,7 @@ func downloadWorkflowContent(ctx context.Context, repo, path, ref string, verbos
197198
if err != nil {
198199
// Check if this is an authentication error
199200
outputStr := string(output)
200-
if gitutil.IsAuthError(outputStr) || gitutil.IsAuthError(err.Error()) {
201+
if errorutil.IsAuthError(outputStr) || errorutil.IsAuthError(err.Error()) {
201202
downloadLog.Printf("GitHub API authentication failed, attempting git fallback for %s/%s@%s", repo, path, ref)
202203
// Try fallback using git commands
203204
content, gitErr := downloadWorkflowContentViaGit(ctx, repo, path, ref, verbose)

pkg/cli/forecast_compute.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ import (
1515

1616
"github.com/github/gh-aw/pkg/console"
1717
"github.com/github/gh-aw/pkg/constants"
18+
"github.com/github/gh-aw/pkg/errorutil"
1819
"github.com/github/gh-aw/pkg/fileutil"
19-
"github.com/github/gh-aw/pkg/gitutil"
2020
"github.com/github/gh-aw/pkg/workflow"
2121
)
2222

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

7777
runs, _, err := listRunsWithBackoff(ctx, opts, result.WorkflowID)
7878
if err != nil {
79-
if gitutil.IsRateLimitError(err.Error()) {
79+
if errorutil.IsRateLimitError(err.Error()) {
8080
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(
8181
fmt.Sprintf("Skipping %s: GitHub API rate limit exceeded", result.WorkflowID)))
8282
return result, nil

pkg/cli/forecast_resolution.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import (
99
"time"
1010

1111
"github.com/github/gh-aw/pkg/console"
12-
"github.com/github/gh-aw/pkg/gitutil"
12+
"github.com/github/gh-aw/pkg/errorutil"
1313
"github.com/github/gh-aw/pkg/logger"
1414
"github.com/github/gh-aw/pkg/workflow"
1515
)
@@ -116,7 +116,7 @@ func fetchWorkflowsWithBackoff(ctx context.Context, ids []string, repoOverride s
116116
if err == nil {
117117
return githubWorkflows, nil
118118
}
119-
if !gitutil.IsRateLimitError(err.Error()) {
119+
if !errorutil.IsRateLimitError(err.Error()) {
120120
return nil, err
121121
}
122122

@@ -160,7 +160,7 @@ func listRunsWithBackoff(ctx context.Context, opts ListWorkflowRunsOptions, work
160160
if err == nil {
161161
return runs, total, nil
162162
}
163-
if !gitutil.IsRateLimitError(err.Error()) {
163+
if !errorutil.IsRateLimitError(err.Error()) {
164164
return nil, 0, err
165165
}
166166

pkg/cli/health_command.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import (
1010

1111
"github.com/github/gh-aw/pkg/console"
1212
"github.com/github/gh-aw/pkg/constants"
13-
"github.com/github/gh-aw/pkg/gitutil"
13+
"github.com/github/gh-aw/pkg/errorutil"
1414
"github.com/github/gh-aw/pkg/logger"
1515
"github.com/github/gh-aw/pkg/workflow"
1616
"github.com/spf13/cobra"
@@ -133,7 +133,7 @@ func RunHealth(config HealthConfig) error {
133133
// Fetch workflow runs from GitHub
134134
runs, err := fetchWorkflowRuns(workflowAPIName, startDate, config.RepoOverride, config.Verbose)
135135
if err != nil {
136-
if gitutil.IsRateLimitError(err.Error()) {
136+
if errorutil.IsRateLimitError(err.Error()) {
137137
// Rate limiting is a transient infrastructure condition, not a code error.
138138
// Warn and exit cleanly so CI jobs are not marked as failed.
139139
fmt.Fprintln(os.Stderr, console.FormatWarningMessage("Skipping health check: GitHub API rate limit exceeded"))

pkg/cli/update_actions_release.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"time"
1717

1818
"github.com/github/gh-aw/pkg/console"
19+
"github.com/github/gh-aw/pkg/errorutil"
1920
"github.com/github/gh-aw/pkg/gitutil"
2021
"github.com/github/gh-aw/pkg/semverutil"
2122
"github.com/github/gh-aw/pkg/workflow"
@@ -39,7 +40,7 @@ func getLatestActionReleaseWithDeps(ctx context.Context, deps actionUpdateDeps,
3940
if err != nil {
4041
// Check if this is an authentication error
4142
outputStr := string(output)
42-
if gitutil.IsAuthError(outputStr) || gitutil.IsAuthError(err.Error()) {
43+
if errorutil.IsAuthError(outputStr) || errorutil.IsAuthError(err.Error()) {
4344
updateLog.Printf("GitHub API authentication failed, attempting git ls-remote fallback for %s", repo)
4445
// Try fallback using git ls-remote
4546
latestRelease, latestSHA, gitErr := deps.getLatestReleaseViaGit(ctx, repo, currentVersion, allowMajor, verbose)

pkg/cli/update_display.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import (
77
"strings"
88

99
"github.com/github/gh-aw/pkg/console"
10-
"github.com/github/gh-aw/pkg/gitutil"
10+
"github.com/github/gh-aw/pkg/errorutil"
1111
"github.com/github/gh-aw/pkg/logger"
1212
)
1313

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

6969
func compactUpdateFailureReason(message string) string {
7070
switch {
71-
case gitutil.IsAuthError(message) && gitutil.IsRateLimitError(message):
71+
case errorutil.IsAuthError(message) && errorutil.IsRateLimitError(message):
7272
return "SAML-restricted authenticated access; anonymous GitHub API fallback is rate-limited"
73-
case gitutil.IsAuthError(message):
73+
case errorutil.IsAuthError(message):
7474
return "GitHub API access is restricted by authentication or SAML"
75-
case gitutil.IsRateLimitError(message):
75+
case errorutil.IsRateLimitError(message):
7676
return "GitHub API rate limit exceeded"
7777
}
7878

pkg/cli/update_workflows.go

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,9 @@ import (
1414
"sync"
1515
"time"
1616

17-
"github.com/github/gh-aw/pkg/constants"
18-
1917
"github.com/github/gh-aw/pkg/console"
20-
"github.com/github/gh-aw/pkg/gitutil"
18+
"github.com/github/gh-aw/pkg/constants"
19+
"github.com/github/gh-aw/pkg/errorutil"
2120
"github.com/github/gh-aw/pkg/parser"
2221
"github.com/github/gh-aw/pkg/semverutil"
2322
"github.com/github/gh-aw/pkg/workflow"
@@ -187,7 +186,7 @@ func UpdateWorkflows(ctx context.Context, opts UpdateWorkflowsOptions) error {
187186
// (non-fatal) from genuine update failures (fatal).
188187
func allFailuresAreRateLimited(failures []updateFailure) bool {
189188
for _, f := range failures {
190-
if !gitutil.IsRateLimitError(f.Error) {
189+
if !errorutil.IsRateLimitError(f.Error) {
191190
return false
192191
}
193192
}
@@ -472,7 +471,7 @@ func fetchPublicReleaseTagsPaginated(ctx context.Context, repo string) ([]string
472471
// getRepoDefaultBranch fetches the default branch name for a repository.
473472
func getRepoDefaultBranch(ctx context.Context, repo string) (string, error) {
474473
output, err := workflow.RunGHContext(ctx, "Fetching repo info...", "api", "/repos/"+repo, "--jq", ".default_branch")
475-
if err != nil && gitutil.IsAuthError(err.Error()) {
474+
if err != nil && errorutil.IsAuthError(err.Error()) {
476475
updateLog.Printf("GitHub API auth failed for %s, retrying without token", repo)
477476
body, fallbackErr := fetchPublicGitHubAPI(ctx, "/repos/"+repo)
478477
if fallbackErr != nil {
@@ -506,7 +505,7 @@ func getLatestBranchCommitInfo(ctx context.Context, repo, branch string) (latest
506505
// URL-encode the branch name since it may contain slashes (e.g. "feature/foo")
507506
endpoint := fmt.Sprintf("/repos/%s/commits/%s", repo, url.PathEscape(branch))
508507
output, err := workflow.RunGHContext(ctx, "Fetching commit info...", "api", endpoint)
509-
if err != nil && gitutil.IsAuthError(err.Error()) {
508+
if err != nil && errorutil.IsAuthError(err.Error()) {
510509
updateLog.Printf("GitHub API auth failed for branch %s of %s, retrying without token", branch, repo)
511510
body, fallbackErr := fetchPublicGitHubAPI(ctx, endpoint)
512511
if fallbackErr != nil {
@@ -564,7 +563,7 @@ func defaultWorkflowUpdateDeps() workflowUpdateDeps {
564563
runReleasesAPI: func(ctx context.Context, repo string) ([]byte, error) {
565564
endpoint := fmt.Sprintf("/repos/%s/releases", repo)
566565
output, err := workflow.RunGHContext(ctx, "Fetching releases...", "api", "--paginate", endpoint, "--jq", ".[].tag_name")
567-
if err != nil && gitutil.IsAuthError(err.Error()) {
566+
if err != nil && errorutil.IsAuthError(err.Error()) {
568567
updateLog.Printf("GitHub API auth failed for releases of %s, retrying without token", repo)
569568
tags, fallbackErr := fetchPublicReleaseTagsPaginated(ctx, repo)
570569
if fallbackErr != nil {

0 commit comments

Comments
 (0)