252 lines
7.3 KiB
Go
252 lines
7.3 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// ExhaustedRateLimit is a tiny DTO returned to the health package — the
|
|
// health package can't import github (cycle), so it consumes a structural
|
|
// shape via interface assertion.
|
|
type ExhaustedRateLimit struct {
|
|
Resource string
|
|
ResetAt time.Time
|
|
}
|
|
|
|
// ExhaustedRateLimits lists every resource bucket currently out of quota.
|
|
// Returns an empty slice when none are exhausted, so callers can safely
|
|
// `len()` the result.
|
|
func (s *Service) ExhaustedRateLimits() []ExhaustedRateLimit {
|
|
if s.rateTracker == nil {
|
|
return nil
|
|
}
|
|
out := []ExhaustedRateLimit{}
|
|
for resource, snap := range s.rateTracker.All() {
|
|
if snap.Exhausted() {
|
|
out = append(out, ExhaustedRateLimit{
|
|
Resource: string(resource),
|
|
ResetAt: snap.ResetAt,
|
|
})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// rateLimitInfo materializes the tracker's snapshots into the DTO shape
|
|
// returned by GetStatus and the rate-limit WS notification. Returns nil
|
|
// when no buckets are known yet so the field stays omitted.
|
|
func (s *Service) rateLimitInfo() *GitHubRateLimitInfo {
|
|
if s.rateTracker == nil {
|
|
return nil
|
|
}
|
|
all := s.rateTracker.All()
|
|
if len(all) == 0 {
|
|
return nil
|
|
}
|
|
info := &GitHubRateLimitInfo{}
|
|
if snap, ok := all[ResourceCore]; ok {
|
|
v := snap
|
|
info.Core = &v
|
|
}
|
|
if snap, ok := all[ResourceGraphQL]; ok {
|
|
v := snap
|
|
info.GraphQL = &v
|
|
}
|
|
if snap, ok := all[ResourceSearch]; ok {
|
|
v := snap
|
|
info.Search = &v
|
|
}
|
|
return info
|
|
}
|
|
|
|
// RequiredGitHubScopes lists the GitHub token scopes needed for full functionality.
|
|
var RequiredGitHubScopes = []string{"repo", "read:org"}
|
|
|
|
// GetStatus returns the current GitHub connection status.
|
|
// If not authenticated, it retries client creation to pick up auth changes
|
|
// (e.g. GITHUB_TOKEN secret added after startup).
|
|
func (s *Service) GetStatus(ctx context.Context) (*GitHubStatus, error) {
|
|
if !s.IsAuthenticated() {
|
|
s.retryClientCreation(ctx)
|
|
}
|
|
|
|
s.mu.Lock()
|
|
client := s.client
|
|
authMethod := s.authMethod
|
|
s.mu.Unlock()
|
|
|
|
status := &GitHubStatus{
|
|
AuthMethod: authMethod,
|
|
RequiredScopes: RequiredGitHubScopes,
|
|
RateLimit: s.rateLimitInfo(),
|
|
}
|
|
|
|
// Check if a GITHUB_TOKEN secret is configured
|
|
tokenSecretID, hasToken := s.findGitHubTokenSecret(ctx)
|
|
status.TokenConfigured = hasToken
|
|
status.TokenSecretID = tokenSecretID
|
|
|
|
if client == nil {
|
|
status.Diagnostics = runGHDiagnostics(ctx)
|
|
return status, nil
|
|
}
|
|
ok, err := client.IsAuthenticated(ctx)
|
|
if err != nil {
|
|
return status, nil
|
|
}
|
|
status.Authenticated = ok
|
|
if ok {
|
|
user, err := client.GetAuthenticatedUser(ctx)
|
|
if err == nil {
|
|
status.Username = user
|
|
}
|
|
} else {
|
|
status.Diagnostics = runGHDiagnostics(ctx)
|
|
}
|
|
return status, nil
|
|
}
|
|
|
|
// findGitHubTokenSecret checks if a GITHUB_TOKEN secret exists in the secrets store.
|
|
// Returns the secret ID and whether a token is configured.
|
|
func (s *Service) findGitHubTokenSecret(ctx context.Context) (string, bool) {
|
|
if s.secrets == nil {
|
|
return "", false
|
|
}
|
|
items, err := s.secrets.List(ctx)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
for _, item := range items {
|
|
if !item.HasValue {
|
|
continue
|
|
}
|
|
if item.Name == "GITHUB_TOKEN" || item.Name == "github_token" {
|
|
return item.ID, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// retryClientCreation attempts to create a GitHub client when not authenticated.
|
|
// This picks up auth changes made after startup (secrets added, env vars set).
|
|
func (s *Service) retryClientCreation(ctx context.Context) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.authMethod != AuthMethodNone {
|
|
return // already authenticated
|
|
}
|
|
client, authMethod, err := NewClient(ctx, s.secrets, s.logger)
|
|
if err != nil {
|
|
s.logger.Debug("GitHub client retry failed", zap.Error(err))
|
|
return
|
|
}
|
|
attachRateTracker(client, s.rateTracker, s.logger)
|
|
s.client = client
|
|
s.authMethod = authMethod
|
|
s.logger.Info("GitHub client recovered after retry",
|
|
zap.String("auth_method", authMethod))
|
|
}
|
|
|
|
// runGHDiagnostics runs gh auth status if the gh CLI is available.
|
|
func runGHDiagnostics(ctx context.Context) *AuthDiagnostics {
|
|
if !GHAvailable() {
|
|
return &AuthDiagnostics{
|
|
Command: "gh auth status",
|
|
Output: "gh CLI is not installed. Install it from https://cli.github.com",
|
|
ExitCode: -1,
|
|
}
|
|
}
|
|
return NewGHClient().RunAuthDiagnostics(ctx)
|
|
}
|
|
|
|
// ConfigureToken saves or updates a GitHub Personal Access Token in the secrets store.
|
|
// It validates the token by making a test API call before saving.
|
|
func (s *Service) ConfigureToken(ctx context.Context, token string) error {
|
|
if s.secretManager == nil {
|
|
return fmt.Errorf("secret manager not configured")
|
|
}
|
|
|
|
// Validate the token by testing authentication. Wire the rate tracker
|
|
// onto the test client up front so the validation request seeds the
|
|
// shared quota and subsequent PAT-backed calls keep feeding it.
|
|
testClient := s.newPATClient(token)
|
|
user, err := testClient.GetAuthenticatedUser(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: %w", ErrInvalidToken, err)
|
|
}
|
|
s.logger.Info("validated GitHub token", zap.String("user", user))
|
|
|
|
// Check if a GITHUB_TOKEN secret already exists
|
|
existingID, exists := s.findGitHubTokenSecret(ctx)
|
|
if exists {
|
|
// Update existing secret
|
|
if err := s.secretManager.Update(ctx, existingID, token); err != nil {
|
|
return fmt.Errorf("update token: %w", err)
|
|
}
|
|
s.logger.Info("updated GitHub token secret", zap.String("id", existingID))
|
|
} else {
|
|
// Create new secret
|
|
newID, err := s.secretManager.Create(ctx, "GITHUB_TOKEN", token)
|
|
if err != nil {
|
|
return fmt.Errorf("create token: %w", err)
|
|
}
|
|
s.logger.Info("created GitHub token secret", zap.String("id", newID))
|
|
}
|
|
|
|
// Swap the client AND drop identity-scoped caches atomically under
|
|
// s.mu. Clearing OUTSIDE the lock (either before or after the swap)
|
|
// opens a race window: clear-before-swap lets an old-identity fetch
|
|
// completing in the gap repopulate the just-cleared cache; clear-
|
|
// after-swap lets a new-identity caller hit the still-stale cache
|
|
// in the gap. Holding the same lock for both publishes a coherent
|
|
// (new client, empty caches) state to any goroutine that takes
|
|
// s.mu after this. The cache.clear() calls take their own locks
|
|
// (not s.mu), so this is fine to nest. The generation bump inside
|
|
// each clear() also catches any in-flight fetch that started under
|
|
// the old client and tries to write after the lock is released.
|
|
s.mu.Lock()
|
|
s.client = testClient
|
|
s.authMethod = AuthMethodPAT
|
|
s.ClearAccessibleReposCaches()
|
|
s.ClearRepoErrorCache()
|
|
s.ClearPRCaches()
|
|
s.mu.Unlock()
|
|
|
|
return nil
|
|
}
|
|
|
|
// ClearToken removes the configured GitHub token from the secrets store.
|
|
func (s *Service) ClearToken(ctx context.Context) error {
|
|
if s.secretManager == nil {
|
|
return fmt.Errorf("secret manager not configured")
|
|
}
|
|
|
|
existingID, exists := s.findGitHubTokenSecret(ctx)
|
|
if !exists {
|
|
return nil // No token to clear
|
|
}
|
|
|
|
if err := s.secretManager.Delete(ctx, existingID); err != nil {
|
|
return fmt.Errorf("delete token: %w", err)
|
|
}
|
|
s.logger.Info("cleared GitHub token secret", zap.String("id", existingID))
|
|
|
|
// Reset client + drop identity-scoped caches atomically under s.mu;
|
|
// see ConfigureToken for the rationale.
|
|
s.mu.Lock()
|
|
s.client = nil
|
|
s.authMethod = AuthMethodNone
|
|
s.ClearAccessibleReposCaches()
|
|
s.ClearRepoErrorCache()
|
|
s.ClearPRCaches()
|
|
s.mu.Unlock()
|
|
|
|
// Try to re-establish connection via other methods
|
|
s.retryClientCreation(ctx)
|
|
|
|
return nil
|
|
}
|