Skip to content

Commit 381e879

Browse files
[Crane: crane-migration-python-to-go-full-apm-cli-rewrite] Iteration 10: Port auth.py + token_manager.py to Go
Added internal/utils/githubhost/githubhost.go (IsValidFQDN, DefaultHost, IsAzureDevOpsHostname, IsGitHubHostname, IsGitLabHostname, SupportGHCLIHost, IsADOAuthFailureSignal, BuildAuthorizationHeaderGitEnv, BuildADOBearerGitEnv), internal/core/token_manager.go (GitHubTokenManager with GetTokenForPurpose, GetTokenWithCredentialFallback, ValidateTokens, credential resolution via git-credential-fill and gh CLI), internal/core/auth.go (HostInfo, AuthContext, AuthResolver with ClassifyHost, DetectTokenType, Resolve, resolveToken, buildGitEnv, GitLabRESTHeaders, BearerFallbackOutcome). 28 new TestParity* tests. All 164 Go tests pass. Score: 0.5397 (+0.0927). Run: https://github.com/githubnext/apm/actions/runs/26333024747 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent b84668f commit 381e879

5 files changed

Lines changed: 1206 additions & 0 deletions

File tree

internal/core/auth.go

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
// auth.go mirrors src/apm_cli/core/auth.py.
2+
// Provides AuthResolver, HostInfo, AuthContext, and BearerFallbackOutcome.
3+
package core
4+
5+
import (
6+
"os"
7+
"strings"
8+
"sync"
9+
10+
"github.com/githubnext/apm/internal/utils/githubhost"
11+
)
12+
13+
// HostInfo is an immutable description of a remote Git host.
14+
// Mirrors the HostInfo dataclass in auth.py.
15+
type HostInfo struct {
16+
Host string
17+
Kind string // "github" | "ghe_cloud" | "ghes" | "ado" | "gitlab" | "generic"
18+
HasPublicRepos bool
19+
APIBase string
20+
Port int // 0 = default port
21+
}
22+
23+
// DisplayName returns "host:port" when a non-default port is set, else bare host.
24+
func (h HostInfo) DisplayName() string {
25+
wellKnown := map[int]bool{443: true, 80: true, 22: true}
26+
if h.Port != 0 && !wellKnown[h.Port] {
27+
return h.Host + ":" + itoa(h.Port)
28+
}
29+
return h.Host
30+
}
31+
32+
func itoa(n int) string {
33+
if n == 0 {
34+
return "0"
35+
}
36+
// simple int-to-string without importing strconv at package level
37+
neg := false
38+
if n < 0 {
39+
neg = true
40+
n = -n
41+
}
42+
var buf [20]byte
43+
pos := len(buf)
44+
for n > 0 {
45+
pos--
46+
buf[pos] = byte(n%10) + '0'
47+
n /= 10
48+
}
49+
if neg {
50+
pos--
51+
buf[pos] = '-'
52+
}
53+
return string(buf[pos:])
54+
}
55+
56+
// AuthContext holds resolved authentication for a single (host, org) pair.
57+
// Mirrors the AuthContext dataclass in auth.py.
58+
type AuthContext struct {
59+
Token string // empty string = no token
60+
Source string // e.g. "GITHUB_APM_PAT", "none"
61+
TokenType string // "fine-grained", "classic", "oauth", "github-app", "unknown"
62+
HostInfo HostInfo
63+
GitEnv map[string]string
64+
AuthScheme string // "basic" | "bearer"
65+
}
66+
67+
// BearerFallbackOutcome carries the result of execute_with_bearer_fallback.
68+
type BearerFallbackOutcome struct {
69+
// Outcome is the final result (caller-defined, stored as interface{}).
70+
Outcome interface{}
71+
BearerAttempted bool
72+
}
73+
74+
// authCacheKey is the map key for the AuthResolver cache.
75+
type authCacheKey struct {
76+
host string
77+
port int
78+
org string
79+
}
80+
81+
// AuthResolver is the single source of truth for auth resolution.
82+
// Mirrors AuthResolver in auth.py.
83+
type AuthResolver struct {
84+
tokenManager *GitHubTokenManager
85+
cache map[authCacheKey]*AuthContext
86+
mu sync.Mutex
87+
88+
verboseAuthLoggedHosts map[string]bool
89+
stalePATWarnedHosts map[string]bool
90+
}
91+
92+
// NewAuthResolver constructs an AuthResolver with a default token manager.
93+
func NewAuthResolver() *AuthResolver {
94+
return &AuthResolver{
95+
tokenManager: NewGitHubTokenManager(),
96+
cache: make(map[authCacheKey]*AuthContext),
97+
verboseAuthLoggedHosts: make(map[string]bool),
98+
stalePATWarnedHosts: make(map[string]bool),
99+
}
100+
}
101+
102+
// NewAuthResolverWithManager constructs an AuthResolver with a provided token manager.
103+
func NewAuthResolverWithManager(tm *GitHubTokenManager) *AuthResolver {
104+
r := NewAuthResolver()
105+
r.tokenManager = tm
106+
return r
107+
}
108+
109+
// ClassifyHost returns a HostInfo for the given host and port.
110+
// Mirrors AuthResolver.classify_host.
111+
func ClassifyHost(host string, port int) HostInfo {
112+
h := strings.ToLower(host)
113+
114+
if h == "github.com" {
115+
return HostInfo{Host: host, Kind: "github", HasPublicRepos: true,
116+
APIBase: "https://api.github.com", Port: port}
117+
}
118+
if strings.HasSuffix(h, ".ghe.com") {
119+
return HostInfo{Host: host, Kind: "ghe_cloud", HasPublicRepos: false,
120+
APIBase: "https://" + host + "/api/v3", Port: port}
121+
}
122+
if githubhost.IsAzureDevOpsHostname(host) {
123+
return HostInfo{Host: host, Kind: "ado", HasPublicRepos: true,
124+
APIBase: "https://dev.azure.com", Port: port}
125+
}
126+
127+
// GHES: GITHUB_HOST is set to a non-github.com, non-ghe.com FQDN.
128+
ghesHost := strings.ToLower(os.Getenv("GITHUB_HOST"))
129+
if ghesHost != "" && ghesHost == h &&
130+
ghesHost != "github.com" && ghesHost != "gitlab.com" &&
131+
!strings.HasSuffix(ghesHost, ".ghe.com") &&
132+
githubhost.IsValidFQDN(ghesHost) {
133+
return HostInfo{Host: host, Kind: "ghes", HasPublicRepos: true,
134+
APIBase: "https://" + host + "/api/v3", Port: port}
135+
}
136+
137+
// GitLab (SaaS + env-configured self-managed) -- after GHES per spec.
138+
if githubhost.IsGitLabHostname(host) {
139+
apiBase := "https://gitlab.com/api/v4"
140+
if h != "gitlab.com" {
141+
apiBase = "https://" + host + "/api/v4"
142+
}
143+
return HostInfo{Host: host, Kind: "gitlab", HasPublicRepos: true,
144+
APIBase: apiBase, Port: port}
145+
}
146+
147+
// Generic FQDN.
148+
return HostInfo{Host: host, Kind: "generic", HasPublicRepos: true,
149+
APIBase: "https://" + host + "/api/v3", Port: port}
150+
}
151+
152+
// DetectTokenType classifies a token string by its prefix.
153+
// Mirrors AuthResolver.detect_token_type.
154+
func DetectTokenType(token string) string {
155+
switch {
156+
case strings.HasPrefix(token, "github_pat_"):
157+
return "fine-grained"
158+
case strings.HasPrefix(token, "ghp_"):
159+
return "classic"
160+
case strings.HasPrefix(token, "ghu_"):
161+
return "oauth"
162+
case strings.HasPrefix(token, "gho_"):
163+
return "oauth"
164+
case strings.HasPrefix(token, "ghs_"):
165+
return "github-app"
166+
case strings.HasPrefix(token, "ghr_"):
167+
return "github-app"
168+
default:
169+
return "unknown"
170+
}
171+
}
172+
173+
// Resolve resolves auth for (host, port, org). Cached and thread-safe.
174+
// Mirrors AuthResolver.resolve.
175+
func (r *AuthResolver) Resolve(host string, org string, port int) *AuthContext {
176+
hostLower := strings.ToLower(host)
177+
orgLower := strings.ToLower(org)
178+
key := authCacheKey{host: hostLower, port: port, org: orgLower}
179+
180+
r.mu.Lock()
181+
defer r.mu.Unlock()
182+
183+
if cached := r.cache[key]; cached != nil {
184+
return cached
185+
}
186+
187+
hostInfo := ClassifyHost(host, port)
188+
token, source, scheme := r.resolveToken(hostInfo, org)
189+
tokenType := "unknown"
190+
if token != "" {
191+
tokenType = DetectTokenType(token)
192+
}
193+
gitEnv := r.buildGitEnv(token, scheme, hostInfo.Kind)
194+
195+
ctx := &AuthContext{
196+
Token: token,
197+
Source: source,
198+
TokenType: tokenType,
199+
HostInfo: hostInfo,
200+
GitEnv: gitEnv,
201+
AuthScheme: scheme,
202+
}
203+
r.cache[key] = ctx
204+
return ctx
205+
}
206+
207+
// purposeForHost maps host kind to token purpose.
208+
func purposeForHost(info HostInfo) string {
209+
switch info.Kind {
210+
case "ado":
211+
return "ado_modules"
212+
case "gitlab":
213+
return "gitlab_modules"
214+
case "generic":
215+
return "generic_modules"
216+
default:
217+
return "modules"
218+
}
219+
}
220+
221+
// orgToEnvSuffix converts an org name to upper-case env-var suffix with hyphens as underscores.
222+
func orgToEnvSuffix(org string) string {
223+
return strings.ToUpper(strings.ReplaceAll(org, "-", "_"))
224+
}
225+
226+
// resolveToken walks the token resolution chain. Returns (token, source, scheme).
227+
// Mirrors AuthResolver._resolve_token.
228+
func (r *AuthResolver) resolveToken(info HostInfo, org string) (string, string, string) {
229+
// ADO: PAT -> none (bearer is fetched lazily in try_with_fallback)
230+
if info.Kind == "ado" {
231+
if pat := os.Getenv("ADO_APM_PAT"); pat != "" {
232+
return pat, "ADO_APM_PAT", "basic"
233+
}
234+
return "", "none", "basic"
235+
}
236+
237+
// 1. Per-org PAT (GitHub-class only).
238+
if org != "" && (info.Kind == "github" || info.Kind == "ghe_cloud" || info.Kind == "ghes") {
239+
envName := "GITHUB_APM_PAT_" + orgToEnvSuffix(org)
240+
if token := os.Getenv(envName); token != "" {
241+
return token, envName, "basic"
242+
}
243+
}
244+
245+
// 2. Global env vars by host class.
246+
purpose := purposeForHost(info)
247+
env := OSEnvMap()
248+
if token, ok := r.tokenManager.GetTokenForPurpose(purpose, env); ok {
249+
source := r.tokenManager.IdentifyEnvSource(purpose)
250+
return token, source, "basic"
251+
}
252+
253+
// 3. gh CLI.
254+
if token, ok := ResolveCredentialFromGHCLI(info.Host); ok {
255+
return token, "gh-auth-token", "basic"
256+
}
257+
258+
// 4. Git credential helper (not for ADO).
259+
if info.Kind != "ado" {
260+
if token, ok := ResolveCredentialFromGit(info.Host, info.Port, ""); ok {
261+
return token, "git-credential-fill", "basic"
262+
}
263+
}
264+
265+
return "", "none", "basic"
266+
}
267+
268+
// buildGitEnv constructs a process env for git subcommands.
269+
// Mirrors AuthResolver._build_git_env.
270+
func (r *AuthResolver) buildGitEnv(token, scheme, hostKind string) map[string]string {
271+
env := OSEnvMap()
272+
env["GIT_TERMINAL_PROMPT"] = "0"
273+
env["GIT_ASKPASS"] = "echo"
274+
275+
if scheme == "bearer" && token != "" && hostKind == "ado" {
276+
delete(env, "GIT_TOKEN")
277+
for k, v := range githubhost.BuildADOBearerGitEnv(token) {
278+
env[k] = v
279+
}
280+
} else if token != "" {
281+
env["GIT_TOKEN"] = token
282+
}
283+
return env
284+
}
285+
286+
// GitLabRESTHeaders builds HTTP headers for GitLab REST API v4 calls.
287+
// Mirrors AuthResolver.gitlab_rest_headers.
288+
func GitLabRESTHeaders(token string, oauthBearer bool) map[string]string {
289+
if token == "" {
290+
return map[string]string{}
291+
}
292+
if oauthBearer {
293+
return map[string]string{"Authorization": "Bearer " + token}
294+
}
295+
return map[string]string{"PRIVATE-TOKEN": token}
296+
}

0 commit comments

Comments
 (0)