Files
kandev/apps/backend/internal/github/service_tokens.go
T

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
}