diff --git a/internal/cli/mint.go b/internal/cli/mint.go index afab56d3c4..f8f2989718 100644 --- a/internal/cli/mint.go +++ b/internal/cli/mint.go @@ -155,7 +155,9 @@ var githubAPIBaseURL = "https://api.github.com" var githubHTTPClient = &http.Client{Timeout: 30 * time.Second} // lookupAppID fetches the numeric app ID for a public GitHub App by slug. -// It makes an unauthenticated GET request to the GitHub API. +// When GH_TOKEN or GITHUB_TOKEN is set in the environment, the request is +// authenticated (5,000 requests/hour). Otherwise it falls back to an +// unauthenticated request (60 requests/hour, shared by source IP). func lookupAppID(ctx context.Context, slug string) (int, error) { url := githubAPIBaseURL + "/apps/" + url.PathEscape(slug) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) @@ -164,6 +166,16 @@ func lookupAppID(ctx context.Context, slug string) (int, error) { } req.Header.Set("Accept", "application/vnd.github+json") + // Authenticate if a token is available, lifting the rate limit from + // 60/hour (unauthenticated, shared by IP) to 5,000/hour. + token := os.Getenv("GH_TOKEN") + if token == "" { + token = os.Getenv("GITHUB_TOKEN") + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := githubHTTPClient.Do(req) if err != nil { return 0, fmt.Errorf("looking up app %s: %w", slug, err) @@ -177,7 +189,10 @@ func lookupAppID(ctx context.Context, slug string) (int, error) { return 0, fmt.Errorf("GitHub App %q not found — ensure the app exists and is publicly visible", slug) } if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests { - return 0, fmt.Errorf("GitHub API rate limit exceeded for app %s — unauthenticated requests are limited to 60/hour; try again later", slug) + if token != "" { + return 0, fmt.Errorf("GitHub API rate limit exceeded for app %s — try again later", slug) + } + return 0, fmt.Errorf("GitHub API rate limit exceeded for app %s — unauthenticated requests are limited to 60/hour; set GH_TOKEN or GITHUB_TOKEN and try again", slug) } if resp.StatusCode != http.StatusOK { return 0, fmt.Errorf("GitHub API returned %d for app %s", resp.StatusCode, slug) diff --git a/internal/cli/mint_test.go b/internal/cli/mint_test.go index c461283c18..5c0dfb229d 100644 --- a/internal/cli/mint_test.go +++ b/internal/cli/mint_test.go @@ -2286,8 +2286,12 @@ func TestMintDeployCmd_NoWarningForCorrectPlatformFlags(t *testing.T) { // --- lookupAppID tests --- func TestLookupAppID_Success(t *testing.T) { + t.Setenv("GH_TOKEN", "") + t.Setenv("GITHUB_TOKEN", "") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/apps/fullsend-ai-coder", r.URL.Path) + assert.Empty(t, r.Header.Get("Authorization"), "unauthenticated request should have no Authorization header") w.Header().Set("Content-Type", "application/json") fmt.Fprintln(w, `{"id": 12345, "slug": "fullsend-ai-coder", "client_id": "Iv1.abc123"}`) })) @@ -2358,6 +2362,9 @@ func TestLookupAppID_RateLimit(t *testing.T) { {"TooManyRequests", http.StatusTooManyRequests}, } { t.Run(tc.name, func(t *testing.T) { + t.Setenv("GH_TOKEN", "") + t.Setenv("GITHUB_TOKEN", "") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(tc.code) })) @@ -2370,10 +2377,92 @@ func TestLookupAppID_RateLimit(t *testing.T) { _, err := lookupAppID(context.Background(), "some-app") require.Error(t, err) assert.Contains(t, err.Error(), "rate limit") + assert.Contains(t, err.Error(), "set GH_TOKEN or GITHUB_TOKEN") }) } } +func TestLookupAppID_AuthenticatedWithGHToken(t *testing.T) { + t.Setenv("GH_TOKEN", "ghp_test_token_123") + t.Setenv("GITHUB_TOKEN", "") + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "Bearer ghp_test_token_123", r.Header.Get("Authorization")) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintln(w, `{"id": 99}`) + })) + defer srv.Close() + + orig := githubAPIBaseURL + githubAPIBaseURL = srv.URL + defer func() { githubAPIBaseURL = orig }() + + appID, err := lookupAppID(context.Background(), "test-app") + require.NoError(t, err) + assert.Equal(t, 99, appID) +} + +func TestLookupAppID_AuthenticatedWithGITHUBToken(t *testing.T) { + t.Setenv("GH_TOKEN", "") + t.Setenv("GITHUB_TOKEN", "ghs_fallback_token") + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "Bearer ghs_fallback_token", r.Header.Get("Authorization")) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintln(w, `{"id": 77}`) + })) + defer srv.Close() + + orig := githubAPIBaseURL + githubAPIBaseURL = srv.URL + defer func() { githubAPIBaseURL = orig }() + + appID, err := lookupAppID(context.Background(), "test-app") + require.NoError(t, err) + assert.Equal(t, 77, appID) +} + +func TestLookupAppID_GHTokenTakesPrecedence(t *testing.T) { + t.Setenv("GH_TOKEN", "ghp_primary") + t.Setenv("GITHUB_TOKEN", "ghs_secondary") + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "Bearer ghp_primary", r.Header.Get("Authorization"), + "GH_TOKEN should take precedence over GITHUB_TOKEN") + w.Header().Set("Content-Type", "application/json") + fmt.Fprintln(w, `{"id": 55}`) + })) + defer srv.Close() + + orig := githubAPIBaseURL + githubAPIBaseURL = srv.URL + defer func() { githubAPIBaseURL = orig }() + + appID, err := lookupAppID(context.Background(), "test-app") + require.NoError(t, err) + assert.Equal(t, 55, appID) +} + +func TestLookupAppID_RateLimitAuthenticated(t *testing.T) { + t.Setenv("GH_TOKEN", "ghp_some_token") + t.Setenv("GITHUB_TOKEN", "") + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusTooManyRequests) + })) + defer srv.Close() + + orig := githubAPIBaseURL + githubAPIBaseURL = srv.URL + defer func() { githubAPIBaseURL = orig }() + + _, err := lookupAppID(context.Background(), "some-app") + require.Error(t, err) + assert.Contains(t, err.Error(), "rate limit") + assert.NotContains(t, err.Error(), "set GH_TOKEN or GITHUB_TOKEN", + "authenticated rate limit error should not suggest setting a token") +} + // --- verifyPEMMatchesApp tests --- func TestVerifyPEMMatchesApp_Success(t *testing.T) {