package github import ( "context" "errors" "sync" "time" "golang.org/x/sync/singleflight" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/watchreset" ) // Auth method constants. const ( AuthMethodNone = "none" AuthMethodPAT = "pat" ) // defaultBranchMain and defaultBranchMaster are the conventional default branch // names sorted to the top of branch pickers. const ( defaultBranchMain = "main" defaultBranchMaster = "master" ) // reviewEventApprove is the GitHub Reviews API event value for a positive // review. Extracted because it appears in the controller validator, the // service's self-approval guard, and tests. const reviewEventApprove = "APPROVE" // prSyncFreshnessWindow is how long PR data is considered fresh (skip GitHub API). const prSyncFreshnessWindow = 30 * time.Second // cleanupFetchFailureThreshold is the number of consecutive GetPRFeedback / // GetIssueState errors a single dedup row may accumulate before the cleanup // loop logs a Warn. The previous behavior swallowed the error at Debug level // so transient outages — auth-token expiry, rate-limit exhaustion — silently // blocked cleanup forever. const cleanupFetchFailureThreshold = 5 // TaskDeleter deletes tasks by ID. Used for cleaning up merged PR tasks. // Implementations should return errors wrapping ErrTaskNotFound when the // task is already gone. type TaskDeleter interface { DeleteTask(ctx context.Context, taskID string) error } // isTaskNotFound reports whether an error from TaskDeleter signals the task // was already gone. Adapters wrap their underlying not-found error with // ErrTaskNotFound — see cmd/kandev/turn_adapters.go's taskDeleterAdapter. func isTaskNotFound(err error) bool { return err != nil && errors.Is(err, ErrTaskNotFound) } // TaskSessionChecker checks whether the user genuinely engaged with a task // (authored at least one non-auto-start message). Used by cleanup logic to // preserve tasks the user touched while sweeping auto-started-only ones. type TaskSessionChecker interface { HasUserAuthoredMessage(ctx context.Context, taskID string) (bool, error) } // SecretManager handles secret creation, update, and deletion. type SecretManager interface { Create(ctx context.Context, name, value string) (id string, err error) Update(ctx context.Context, id, value string) error Delete(ctx context.Context, id string) error } // Service coordinates GitHub integration operations. type Service struct { mu sync.Mutex client Client authMethod string secrets SecretProvider secretManager SecretManager store *Store eventBus bus.EventBus logger *logger.Logger taskDeleter TaskDeleter // cascadeTaskDeleter is the cascade-delete entry point used by the // watch reset flow. It is distinct from taskDeleter (which only deletes // a single task by ID) because reset must walk the task tree and clean // up subtasks, runs, and worktrees too. Wired post-construction via // SetCascadeTaskDeleter to avoid an import cycle with the task service. cascadeTaskDeleter watchreset.TaskDeleter taskSessionChecker TaskSessionChecker syncGroup singleflight.Group taskEventSubs []bus.Subscription searchCache *ttlCache prStatusCache *ttlCache prFeedbackCache *ttlCache mergeMethodsCache *ttlCache accessibleReposCache *ttlCache repoErrorCache *ttlCache protectionCache *branchProtectionCache rateTracker *RateTracker // cleanupFailureMu guards cleanupFailureCounts; the cleanup loop is the // only writer but the global sweep + per-watch sweep can run concurrently // in different goroutines, and the map is shared between them. cleanupFailureMu sync.Mutex cleanupFailureCounts map[string]int // inflightWorkspaceRefreshes tracks workspaces whose stale-PR background // refresh is currently running, so overlapping ListWorkspaceTaskPRs polls // from the frontend coalesce instead of stacking goroutines (each of // which fires its own batched GraphQL request). Keys are workspaceID; // presence means "a refresh is in flight". sync.Map is the right shape // here because keys are unbounded and short-lived and the hot path is a // LoadOrStore guard, not iteration. inflightWorkspaceRefreshes sync.Map // stopCtx / stopCancel / bgWG own the lifecycle of background goroutines // the service spawns lazily (currently refreshStaleWorkspaceWatches). // Stop() cancels stopCtx and waits for bgWG to drain, satisfying the // "long-running goroutine has a single owner with explicit start/stop" // convention in apps/backend/AGENTS.md. stopOnce makes Stop idempotent // so the Provide cleanup func can be invoked from any number of // shutdown paths. stopCtx context.Context stopCancel context.CancelFunc bgWG sync.WaitGroup stopOnce sync.Once } // NewService creates a new GitHub service. func NewService(client Client, authMethod string, secrets SecretProvider, store *Store, eventBus bus.EventBus, log *logger.Logger) *Service { stopCtx, stopCancel := context.WithCancel(context.Background()) return &Service{ client: client, authMethod: authMethod, secrets: secrets, store: store, eventBus: eventBus, logger: log, searchCache: newTTLCache(), prStatusCache: newTTLCache(), prFeedbackCache: newPRFeedbackCache(), mergeMethodsCache: newMergeMethodsCache(), accessibleReposCache: newAccessibleReposCache(), repoErrorCache: newRepoErrorCache(), protectionCache: newBranchProtectionCache(), rateTracker: NewRateTracker(eventBus, log), cleanupFailureCounts: make(map[string]int), stopCtx: stopCtx, stopCancel: stopCancel, } } // Stop cancels in-flight background goroutines (currently the // per-workspace stale-PR refreshes spawned by refreshStaleWorkspaceWatches) // and waits for them to drain. Idempotent: safe to call from the Provide // cleanup func and any other shutdown path. func (s *Service) Stop() { if s == nil { return } s.stopOnce.Do(func() { s.stopCancel() s.bgWG.Wait() }) } // RateTracker exposes the service's rate-limit tracker so factory callers // can wire it into individual clients. func (s *Service) RateTracker() *RateTracker { return s.rateTracker } // newPATClient builds a PATClient pre-wired with the service's shared rate // tracker. Centralizing this guards against forgetting the wiring on auth // flips (e.g. ConfigureToken), which would otherwise leave PAT calls // invisible to the rate-limit UI, health checks, and poller throttling. func (s *Service) newPATClient(token string) *PATClient { c := NewPATClient(token) attachRateTracker(c, s.rateTracker, s.logger) return c } // SetTaskDeleter sets the task deletion dependency for cleanup operations. func (s *Service) SetTaskDeleter(d TaskDeleter) { s.taskDeleter = d } // SetCascadeTaskDeleter wires the cascade-delete dependency used by the // watch reset flow (ResetIssueWatch / ResetReviewWatch). Optional — when // unset, reset returns an error so the missing wiring is surfaced instead // of silently no-op'ing. func (s *Service) SetCascadeTaskDeleter(d watchreset.TaskDeleter) { s.mu.Lock() defer s.mu.Unlock() s.cascadeTaskDeleter = d } // SetTaskSessionChecker sets the session checker for cleanup operations. func (s *Service) SetTaskSessionChecker(c TaskSessionChecker) { s.taskSessionChecker = c } // SetSecretManager sets the secret manager for token configuration operations. func (s *Service) SetSecretManager(m SecretManager) { s.secretManager = m } // Client returns the underlying GitHub client (may be nil if not authenticated). func (s *Service) Client() Client { return s.client } // TestStore returns the store for test/mock use only. func (s *Service) TestStore() *Store { return s.store } // ListTaskPRsByTaskIDs forwards to the underlying store. Exposed so other // packages (e.g. internal/office) can read PR associations without // importing internal/github/store. func (s *Service) ListTaskPRsByTaskIDs(ctx context.Context, taskIDs []string) (map[string][]*TaskPR, error) { if s.store == nil { return map[string][]*TaskPR{}, nil } return s.store.ListTaskPRsByTaskIDs(ctx, taskIDs) } // TestEventBus returns the event bus for test/mock use only. func (s *Service) TestEventBus() bus.EventBus { return s.eventBus } // IsAuthenticated returns whether the service has a working GitHub client. // Returns false when using the NoopClient fallback (authMethod == "none"). func (s *Service) IsAuthenticated() bool { s.mu.Lock() defer s.mu.Unlock() return s.client != nil && s.authMethod != AuthMethodNone } // AuthMethod returns the authentication method ("gh_cli", "pat", or "none"). func (s *Service) AuthMethod() string { return s.authMethod } // isRepoCachedAsMissing reports whether the (owner, repo) tuple is in the // 10-min negative cache. Called from SyncWatchesBatched and per-watch // probes before acquiring the gh throttle, so the storm against // missing/unauthorized repos short-circuits without burning a slot. func (s *Service) isRepoCachedAsMissing(owner, repo string) bool { if s == nil || s.repoErrorCache == nil { return false } v, ok := s.repoErrorCache.get(repoErrorCacheKey(owner, repo)) if !ok { return false } _, isErr := v.(cachedErr) return isErr } // repoErrorGenSnapshot returns the current cache generation. Callers // snapshot this BEFORE issuing the fetch that may classify a repo as // missing, and pass the snapshot to markRepoAsMissing — that way an // evictRepoNegative / ClearRepoErrorCache that fires DURING the fetch // (bumping gen) causes the post-fetch write to be dropped, and the // negative cache reflects the user's latest intent rather than the // stale classifier result. func (s *Service) repoErrorGenSnapshot() uint64 { if s == nil || s.repoErrorCache == nil { return 0 } return s.repoErrorCache.generation() } // markRepoAsMissing stores ErrRepoNotResolvable for (owner, repo) in the // negative cache. Called after a per-watch or batched probe deterministically // classifies the repo as missing/unauthorized (see isRepoNotResolvableErr). // `gen` must be a snapshot taken via repoErrorGenSnapshot BEFORE the fetch // — passing the current generation at write time would defeat the guard: // a concurrent del() / clear() that bumped gen between the fetch start and // the write would still be present at write time, so a write-time snapshot // would land in the new generation and the just-evicted entry would be // reinserted. The fetch-start snapshot turns the write into a no-op when // any eviction has fired since the fetch began. func (s *Service) markRepoAsMissing(owner, repo string, gen uint64) { if s == nil || s.repoErrorCache == nil { return } s.repoErrorCache.setIfCurrentGeneration( repoErrorCacheKey(owner, repo), cachedErr{err: ErrRepoNotResolvable}, gen) } // evictRepoNegative drops any negative-cache entry for (owner, repo). Called // from the PR watch (re)create paths and AssociatePRByURL so a freshly // linked or re-linked repository is probed immediately on the next sync, // without waiting out the 10-min TTL. func (s *Service) evictRepoNegative(owner, repo string) { if s == nil || s.repoErrorCache == nil { return } s.repoErrorCache.del(repoErrorCacheKey(owner, repo)) } // ClearRepoErrorCache drops every entry from the negative repo-resolution // cache. Called from ConfigureToken / ClearToken so a token swap or clear // invalidates classifications that were made under the prior identity — // without this, a repo that the old token couldn't see would stay // "permanent: true" for up to 10 minutes after the user fixes auth, and // the frontend retry loop would stop early on stale data. Nil-guards the // cache because tests construct Service literals without going through // NewService. func (s *Service) ClearRepoErrorCache() { if s == nil || s.repoErrorCache == nil { return } s.repoErrorCache.clear() } // ClearPRCaches drops the PR status and PR feedback caches. Called from // the token-swap path because review visibility differs across identities // (a PR readable to the old token may not be readable to the new one, and // vice versa); without this, the new identity could see stale review / // check / comment data for up to one TTL after the swap. func (s *Service) ClearPRCaches() { if s == nil { return } if s.prStatusCache != nil { s.prStatusCache.clear() } if s.prFeedbackCache != nil { s.prFeedbackCache.clear() } }