package github import ( "context" "fmt" "strconv" "strings" "sync" "time" "go.uber.org/zap" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" ) // --- PR Watch operations --- // CreatePRWatch creates a new PR watch for a (session, repository, branch) // triple. `repositoryID` may be empty for legacy single-repo callers; // multi-repo callers must pass the per-task repository_id so each repo gets // its own watch row. Multi-branch tasks store one watch per branch so a // secondary branch's push isn't lost behind the primary's existing watch. func (s *Service) CreatePRWatch(ctx context.Context, sessionID, taskID, repositoryID, owner, repo string, prNumber int, branch string) (*PRWatch, error) { // Evict any negative-cache entry up front. Caller intent on either // branch (creating a new watch or re-finding an existing one) is "I // want this repo watched", which means a stale "missing" verdict // from a prior auth failure should be re-probed immediately rather // than held for the rest of the 10-min TTL. s.evictRepoNegative(owner, repo) existing, err := s.store.GetPRWatchBySessionRepoAndBranch(ctx, sessionID, repositoryID, branch) if err != nil { return nil, err } if existing != nil { return existing, nil // already watching this (session, repo, branch) } w := &PRWatch{ SessionID: sessionID, TaskID: taskID, RepositoryID: repositoryID, Owner: owner, Repo: repo, PRNumber: prNumber, Branch: branch, } if err := s.store.CreatePRWatch(ctx, w); err != nil { return nil, fmt.Errorf("create PR watch: %w", err) } s.logger.Info("created PR watch", zap.String("session_id", sessionID), zap.String("repository_id", repositoryID), zap.String("branch", branch), zap.Int("pr_number", prNumber)) return w, nil } // GetPRWatchBySession returns the first PR watch for a session. Multi-repo // callers should prefer GetPRWatchBySessionAndRepo to avoid landing on the // wrong repo's watch. func (s *Service) GetPRWatchBySession(ctx context.Context, sessionID string) (*PRWatch, error) { return s.store.GetPRWatchBySession(ctx, sessionID) } // GetPRWatchBySessionAndRepo returns the PR watch for a (session, repo) pair. func (s *Service) GetPRWatchBySessionAndRepo(ctx context.Context, sessionID, repositoryID string) (*PRWatch, error) { return s.store.GetPRWatchBySessionAndRepo(ctx, sessionID, repositoryID) } // GetPRWatchBySessionRepoAndBranch returns the PR watch for a precise // (session, repository, branch) triple — used by push detection in // multi-branch tasks so each branch's push lands on its own watch row. func (s *Service) GetPRWatchBySessionRepoAndBranch(ctx context.Context, sessionID, repositoryID, branch string) (*PRWatch, error) { return s.store.GetPRWatchBySessionRepoAndBranch(ctx, sessionID, repositoryID, branch) } // ListPRWatchesBySession returns every PR watch for a session. func (s *Service) ListPRWatchesBySession(ctx context.Context, sessionID string) ([]*PRWatch, error) { return s.store.ListPRWatchesBySession(ctx, sessionID) } // ListPRWatchesByTask returns every PR watch for a task. func (s *Service) ListPRWatchesByTask(ctx context.Context, taskID string) ([]*PRWatch, error) { return s.store.ListPRWatchesByTask(ctx, taskID) } // ListActivePRWatches returns all active PR watches. func (s *Service) ListActivePRWatches(ctx context.Context) ([]*PRWatch, error) { return s.store.ListActivePRWatches(ctx) } // DeletePRWatch deletes a PR watch by ID. func (s *Service) DeletePRWatch(ctx context.Context, id string) error { return s.store.DeletePRWatch(ctx, id) } // UpdatePRWatchBranchIfSearching atomically updates branch only when pr_number = 0. func (s *Service) UpdatePRWatchBranchIfSearching(ctx context.Context, id, branch string) error { return s.store.UpdatePRWatchBranchIfSearching(ctx, id, branch) } // UpdatePRWatchPRNumber updates a PR watch's PR number after discovery. func (s *Service) UpdatePRWatchPRNumber(ctx context.Context, id string, prNumber int) error { return s.store.UpdatePRWatchPRNumber(ctx, id, prNumber) } // ResetPRWatch atomically resets a watch's branch and clears its pr_number so // the poller re-searches for a PR on the new branch. See Store.ResetPRWatch. func (s *Service) ResetPRWatch(ctx context.Context, id, branch string) error { return s.store.ResetPRWatch(ctx, id, branch) } // CheckPRWatch fetches lightweight PR status for a watch and determines if there are changes. func (s *Service) CheckPRWatch(ctx context.Context, watch *PRWatch) (*PRStatus, bool, error) { if s.client == nil { return nil, false, fmt.Errorf("github client not available") } status, err := s.client.GetPRStatus(ctx, watch.Owner, watch.Repo, watch.PRNumber) if err != nil { return nil, false, err } // Check for check status or review state changes hasNew := status.ChecksState != watch.LastCheckStatus || status.ReviewState != watch.LastReviewState // Update watch timestamps now := time.Now().UTC() if err := s.store.UpdatePRWatchTimestamps(ctx, watch.ID, now, nil, status.ChecksState, status.ReviewState); err != nil { s.logger.Error("failed to update PR watch timestamps", zap.String("id", watch.ID), zap.Error(err)) } return status, hasNew, nil } // EnsurePRWatch creates a PRWatch with pr_number=0 for a // (session, repo, branch) triple if one doesn't already exist. The poller // will detect the PR by searching for the branch on GitHub. `repositoryID` // is empty for legacy single-repo callers; multi-repo / multi-branch // callers MUST pass the per-task repository_id and the worktree's branch // so each branch gets its own watch — keying on (session, repo) alone // drops secondary branches' watches behind the primary's existing row. func (s *Service) EnsurePRWatch(ctx context.Context, sessionID, taskID, repositoryID, owner, repo, branch string) (*PRWatch, error) { // Same up-front eviction as CreatePRWatch — see that function for the // rationale. The existing-watch early-return path must not inherit a // stale "missing" verdict from a prior incarnation. s.evictRepoNegative(owner, repo) existing, err := s.store.GetPRWatchBySessionRepoAndBranch(ctx, sessionID, repositoryID, branch) if err != nil { return nil, err } if existing != nil { return existing, nil } w := &PRWatch{ SessionID: sessionID, TaskID: taskID, RepositoryID: repositoryID, Owner: owner, Repo: repo, PRNumber: 0, Branch: branch, } if err := s.store.CreatePRWatch(ctx, w); err != nil { return nil, fmt.Errorf("ensure PR watch: %w", err) } s.logger.Info("created PR watch for session (will search for PR)", zap.String("session_id", sessionID), zap.String("repository_id", repositoryID), zap.String("branch", branch)) return w, nil } // --- Task-PR association --- // AssociatePRWithTask creates a task-PR association scoped to a specific // repository. `repositoryID` is the per-task repository_id (from // task_repositories); empty preserves legacy single-repo behavior. Multi-repo // callers MUST pass it — empty causes ReplaceTaskPR to wipe the entire task's // PR rows (legacy "delete all" branch), which is what older code relied on. func (s *Service) AssociatePRWithTask(ctx context.Context, taskID, repositoryID string, pr *PR) (*TaskPR, error) { // Multi-branch: scope the "already-current" short-circuit by exact // pr_number too. A task can hold multiple PR rows per (task, repo) on // different branches; the legacy by-repo lookup returns whichever row // was most recently updated, which would make Associate think the // secondary PR is already there (wrong PR number) and skip the insert // — or worse, fall through to ReplaceTaskPR which used to delete the // sibling row. existing, err := s.store.GetTaskPRByRepoAndNumber(ctx, taskID, repositoryID, pr.Number) if err != nil { return nil, err } if existing != nil { return existing, nil } tp := &TaskPR{ TaskID: taskID, RepositoryID: repositoryID, Owner: pr.RepoOwner, Repo: pr.RepoName, PRNumber: pr.Number, PRURL: pr.HTMLURL, PRTitle: pr.Title, HeadBranch: pr.HeadBranch, BaseBranch: pr.BaseBranch, AuthorLogin: pr.AuthorLogin, State: pr.State, Additions: pr.Additions, Deletions: pr.Deletions, CreatedAt: pr.CreatedAt, MergedAt: pr.MergedAt, ClosedAt: pr.ClosedAt, } // ReplaceTaskPR upserts the row matching (task, repository, pr_number). // Multi-branch tasks may already hold sibling rows for the SAME // (task, repository) on different PR numbers — ReplaceTaskPR no longer // touches them. The early-return above guarantees we only reach this // line when no row for the exact pr_number exists yet, so this is a // straight insert in steady state; the delete-then-insert form is // retained so a retry that races a partial write resolves cleanly. if err := s.store.ReplaceTaskPR(ctx, tp); err != nil { return nil, fmt.Errorf("replace task PR: %w", err) } // Publish event for UI if s.eventBus != nil { event := bus.NewEvent(events.GitHubTaskPRUpdated, "github", tp) if err := s.eventBus.Publish(ctx, events.GitHubTaskPRUpdated, event); err != nil { s.logger.Debug("failed to publish task PR updated event", zap.Error(err)) } } s.logger.Info("associated PR with task", zap.String("task_id", taskID), zap.String("repository_id", repositoryID), zap.Int("pr_number", pr.Number)) return tp, nil } // AssociateExistingPRByURL parses a GitHub PR URL, fetches the PR data, and // associates it with the given task. No PR watch is created — this is used // when the caller already knows the PR (e.g. user clicked "+ Task" on a PR // in the GitHub page), so branch-based discovery is unnecessary. The watch // for ongoing status sync is still created later when the agent session // starts (see ensureSessionPRWatch). // // Returns the persisted TaskPR row so callers can confirm the association // and react to errors synchronously, in contrast to AssociatePRByURL's // fire-and-forget logging. func (s *Service) AssociateExistingPRByURL(ctx context.Context, taskID, repositoryID, prURL string) (*TaskPR, error) { if s.client == nil { return nil, fmt.Errorf("github client not available") } owner, repo, prNumber, err := parsePRURL(prURL) if err != nil { return nil, fmt.Errorf("%w: %w", ErrInvalidPRURL, err) } pr, err := s.client.GetPR(ctx, owner, repo, prNumber) if err != nil { return nil, fmt.Errorf("fetch PR: %w", err) } tp, err := s.AssociatePRWithTask(ctx, taskID, repositoryID, pr) if err != nil { return nil, fmt.Errorf("associate PR with task: %w", err) } return tp, nil } // AssociatePRByURL parses a GitHub PR URL, fetches the PR data, creates a PR // watch, and associates it with the given task. Called after the user // creates a PR from the UI. `repositoryID` scopes the watch + association to // a specific per-task repository (multi-repo tasks); empty preserves the // legacy single-repo behavior. Without this, the second repo's UI-initiated // PR would overwrite the first's TaskPR row. func (s *Service) AssociatePRByURL(ctx context.Context, sessionID, taskID, repositoryID, prURL, branch string) { if s.client == nil { return } owner, repo, prNumber, err := parsePRURL(prURL) if err != nil { s.logger.Error("failed to parse PR URL", zap.String("url", prURL), zap.Error(err)) return } pr, err := s.client.GetPR(ctx, owner, repo, prNumber) if err != nil { s.logger.Error("failed to fetch PR after creation", zap.String("url", prURL), zap.Error(err)) return } // Create PR watch for ongoing monitoring if branch == "" { branch = pr.HeadBranch } if _, watchErr := s.CreatePRWatch(ctx, sessionID, taskID, repositoryID, owner, repo, prNumber, branch); watchErr != nil { s.logger.Error("failed to create PR watch after PR creation", zap.String("session_id", sessionID), zap.Error(watchErr)) } // Associate PR with task (persists + publishes WS event) if _, assocErr := s.AssociatePRWithTask(ctx, taskID, repositoryID, pr); assocErr != nil { s.logger.Error("failed to associate PR with task after creation", zap.String("task_id", taskID), zap.Error(assocErr)) } } // parsePRURL extracts owner, repo, and PR number from a GitHub PR URL. // Expected format: https://github.com/{owner}/{repo}/pull/{number} // Handles trailing slashes, query parameters, and URL fragments. func parsePRURL(prURL string) (owner, repo string, number int, err error) { // Strip trailing whitespace/newlines prURL = strings.TrimSpace(prURL) // Find the /pull/ segment idx := strings.Index(prURL, "/pull/") if idx < 0 { return "", "", 0, fmt.Errorf("URL does not contain /pull/: %s", prURL) } // Parse PR number after /pull/, stripping query params, fragments, and trailing slashes numStr := prURL[idx+len("/pull/"):] if i := strings.IndexAny(numStr, "?#"); i >= 0 { numStr = numStr[:i] } numStr = strings.TrimRight(numStr, "/") number, err = strconv.Atoi(numStr) if err != nil { return "", "", 0, fmt.Errorf("invalid PR number in URL %s: %w", prURL, err) } // Parse owner/repo from path before /pull/ pathBefore := prURL[:idx] // Remove scheme+host prefix (find last two path segments) parts := strings.Split(strings.TrimRight(pathBefore, "/"), "/") if len(parts) < 2 { return "", "", 0, fmt.Errorf("cannot extract owner/repo from URL: %s", prURL) } repo = parts[len(parts)-1] owner = parts[len(parts)-2] if owner == "" || repo == "" { return "", "", 0, fmt.Errorf("empty owner or repo in URL: %s", prURL) } return owner, repo, number, nil } // GetTaskPR returns the PR association for a task. func (s *Service) GetTaskPR(ctx context.Context, taskID string) (*TaskPR, error) { return s.store.GetTaskPR(ctx, taskID) } // ListTaskPRs returns PR associations for multiple tasks, grouped by task_id. // Multi-repo tasks may have more than one PR per task. func (s *Service) ListTaskPRs(ctx context.Context, taskIDs []string) (map[string][]*TaskPR, error) { return s.store.ListTaskPRsByTaskIDs(ctx, taskIDs) } // ListWorkspaceTaskPRs returns all PR associations for a workspace, grouped by // task_id. Multi-repo tasks may have more than one PR per task. It returns // cached data immediately and triggers background refresh for stale entries. func (s *Service) ListWorkspaceTaskPRs(ctx context.Context, workspaceID string) (map[string][]*TaskPR, error) { result, err := s.store.ListTaskPRsByWorkspaceID(ctx, workspaceID) if err != nil { return nil, err } // Collect stale task IDs for background refresh. A task is considered stale // if any of its PRs are stale. staleTasks := make(map[string]struct{}) for taskID, prs := range result { for _, tp := range prs { if tp.LastSyncedAt == nil || time.Since(*tp.LastSyncedAt) >= prSyncFreshnessWindow { staleTasks[taskID] = struct{}{} break } } } if len(staleTasks) > 0 { s.refreshStaleWorkspaceWatches(workspaceID, staleTasks) } return result, nil } // refreshStaleWorkspaceWatches fans in all stale watches across the stale // tasks and runs ONE batched GraphQL sync, so a 40-watch workspace fires // ~2 gh subprocess calls instead of 40 (one per_task * five concurrent). // Best-effort: errors are logged at Debug and the cached result is still // returned to the caller. Background goroutine so the WS handler returns // immediately. // // Coalesces overlapping refreshes for the same workspace: if a refresh is // already in flight, subsequent calls drop their stale set on the floor // and let the running goroutine finish. Without this, the frontend's 5s // poll (or a burst of workspace events) would stack goroutines that each // fire a batched GraphQL request, defeating the per-process throttle // once the cap is small enough to start queueing. func (s *Service) refreshStaleWorkspaceWatches(workspaceID string, staleTasks map[string]struct{}) { if _, inflight := s.inflightWorkspaceRefreshes.LoadOrStore(workspaceID, struct{}{}); inflight { return } // Track the goroutine on the service WaitGroup so Stop() drains it, // and derive syncCtx from s.stopCtx so shutdown cancels the in-flight // sync instead of letting it run past process teardown. See // apps/backend/AGENTS.md "Goroutine ownership and leak testing". s.bgWG.Add(1) go func() { defer s.bgWG.Done() defer s.inflightWorkspaceRefreshes.Delete(workspaceID) syncCtx, cancel := context.WithTimeout(s.stopCtx, 60*time.Second) defer cancel() var allWatches []*PRWatch for taskID := range staleTasks { watches, err := s.store.ListPRWatchesByTask(syncCtx, taskID) if err != nil { s.logger.Debug("list PR watches for refresh failed", zap.String("task_id", taskID), zap.Error(err)) continue } allWatches = append(allWatches, watches...) } if len(allWatches) == 0 { return } if _, err := s.SyncWatchesBatched(syncCtx, allWatches); err != nil { // Batched fetch failed (noop client, auth blip, GraphQL error). // Fall back to per-task sync with bounded concurrency so we // don't spawn one gh per watch in lockstep. s.logger.Debug("batched workspace PR sync failed; falling back per-task", zap.Int("watches", len(allWatches)), zap.Error(err)) s.refreshStaleTasksPerTask(syncCtx, staleTasks) } }() } // refreshStaleTasksPerTask is the legacy bounded-concurrency fallback for // when SyncWatchesBatched can't be used (e.g. NoopClient). Kept small — // the global gh subprocess semaphore in gh_throttle.go is what actually // caps fan-out now, the worker pool here just bounds goroutine count. // // Blocks until every spawned goroutine returns. The caller relies on this // to keep `inflightWorkspaceRefreshes` from being cleared while // TriggerPRSyncAll is still writing PR-watch state — otherwise a follow-up // refresh for the same workspace could race the still-running fallback. func (s *Service) refreshStaleTasksPerTask(ctx context.Context, staleTasks map[string]struct{}) { sem := make(chan struct{}, 5) var wg sync.WaitGroup for taskID := range staleTasks { // Honor ctx so shutdown / parent-timeout stops queuing new tasks // instead of waiting on a busy semaphore slot. select { case sem <- struct{}{}: case <-ctx.Done(): wg.Wait() return } wg.Add(1) go func(id string) { defer wg.Done() defer func() { <-sem }() if _, syncErr := s.TriggerPRSyncAll(ctx, id); syncErr != nil { s.logger.Debug("background PR sync failed", zap.String("task_id", id), zap.Error(syncErr)) } }(taskID) } wg.Wait() } // findTaskPRForStatus locates the TaskPR row matching the (task, owner, repo, // pr_number) tuple from a poll result. Multi-repo tasks can have multiple // rows for the same task — narrowing by (owner, repo, pr_number) ensures the // caller updates the right one. Returns nil (no error) when no row exists, // matching the prior GetTaskPR semantics. func (s *Service) findTaskPRForStatus(ctx context.Context, taskID string, pr *PR) (*TaskPR, error) { rows, err := s.store.ListTaskPRsByTask(ctx, taskID) if err != nil { return nil, err } for _, tp := range rows { if tp.Owner == pr.RepoOwner && tp.Repo == pr.RepoName && tp.PRNumber == pr.Number { return tp, nil } } return nil, nil } // SyncTaskPR updates a TaskPR record with the latest PR status. Multi-repo: // the row is found by (task_id, owner, repo, pr_number) since the same // task can have several PRs; the legacy GetTaskPR(taskID) "first match" // would cross repos and silently update the wrong row. // It only publishes a github.task_pr.updated event when data actually changed, // preventing feedback loops with frontend sync handlers. // //nolint:cyclop // sequential field-by-field "populated/preserve" reconciliation; splitting helpers hides intent func (s *Service) SyncTaskPR(ctx context.Context, taskID string, status *PRStatus) error { if status == nil || status.PR == nil { return fmt.Errorf("sync task PR: missing PR data for task %s", taskID) } tp, err := s.findTaskPRForStatus(ctx, taskID, status.PR) if err != nil || tp == nil { return err } // Some sync paths (notably the batched GraphQL poller) don't populate // ChecksTotal / ChecksPassing — they only carry the rollup state. The // caller sets status.ChecksPopulated=true when it actually counted // checks; otherwise we preserve the persisted values so the popover // doesn't flap to "0/0" between a rich REST sync and a lightweight // GraphQL one. When the populated counter says 0/0 it really is 0/0 // (e.g. all workflows were removed from the PR), so we honor it. nextChecksTotal, nextChecksPassing := tp.ChecksTotal, tp.ChecksPassing if status.ChecksPopulated { nextChecksTotal = status.ChecksTotal nextChecksPassing = status.ChecksPassing } // Same Populated/preserve dance for unresolved review threads — the // REST path doesn't fetch them, so blindly writing status.UnresolvedReviewThreads // would clobber the non-zero value set by the GraphQL path on every poll. nextUnresolved := tp.UnresolvedReviewThreads if status.UnresolvedReviewThreadsPopulated { nextUnresolved = status.UnresolvedReviewThreads } // Review counts: only overwrite when the caller actually computed them. // Both REST and GraphQL paths now populate these, but a partial sync // path that doesn't would otherwise reset the popover's "Approved (N)" // to zero. nextReviewCount, nextPendingReviewCount := tp.ReviewCount, tp.PendingReviewCount if status.ReviewCountsPopulated { nextReviewCount = status.ReviewCount nextPendingReviewCount = status.PendingReviewCount } // PRs can be retargeted to a different base branch; pick up the new // branch from status.PR before resolving branch-protection so we don't // indefinitely surface the wrong rule. nextBaseBranch := tp.BaseBranch if status.PR.BaseBranch != "" && status.PR.BaseBranch != tp.BaseBranch { nextBaseBranch = status.PR.BaseBranch } // RequiredReviews comes from branch protection, fetched separately. // Treat nil as "unknown — don't touch"; only write when the caller has it // or our cache resolves the rule for this base branch. nextRequiredReviews := tp.RequiredReviews if status.RequiredReviews != nil { nextRequiredReviews = status.RequiredReviews } else if fetched := s.fetchRequiredReviews(ctx, tp.Owner, tp.Repo, nextBaseBranch); fetched != nil { nextRequiredReviews = fetched } changed := tp.State != status.PR.State || tp.PRTitle != status.PR.Title || tp.Additions != status.PR.Additions || tp.Deletions != status.PR.Deletions || tp.ReviewState != status.ReviewState || tp.ChecksState != status.ChecksState || tp.MergeableState != status.MergeableState || tp.ReviewCount != nextReviewCount || tp.PendingReviewCount != nextPendingReviewCount || !intPtrEqual(tp.RequiredReviews, nextRequiredReviews) || tp.ChecksTotal != nextChecksTotal || tp.ChecksPassing != nextChecksPassing || tp.UnresolvedReviewThreads != nextUnresolved || tp.BaseBranch != nextBaseBranch || !timeEqual(tp.MergedAt, status.PR.MergedAt) || !timeEqual(tp.ClosedAt, status.PR.ClosedAt) tp.State = status.PR.State tp.PRTitle = status.PR.Title tp.Additions = status.PR.Additions tp.Deletions = status.PR.Deletions tp.MergedAt = status.PR.MergedAt tp.ClosedAt = status.PR.ClosedAt tp.ReviewState = status.ReviewState tp.ChecksState = status.ChecksState tp.MergeableState = status.MergeableState tp.ReviewCount = nextReviewCount tp.PendingReviewCount = nextPendingReviewCount tp.RequiredReviews = nextRequiredReviews tp.ChecksTotal = nextChecksTotal tp.ChecksPassing = nextChecksPassing tp.UnresolvedReviewThreads = nextUnresolved tp.BaseBranch = nextBaseBranch // CommentCount is no longer updated from polling -- only refreshed on-demand now := time.Now().UTC() tp.LastSyncedAt = &now if err := s.store.UpdateTaskPR(ctx, tp); err != nil { return fmt.Errorf("update task PR: %w", err) } if changed && s.eventBus != nil { event := bus.NewEvent(events.GitHubTaskPRUpdated, "github", tp) if err := s.eventBus.Publish(ctx, events.GitHubTaskPRUpdated, event); err != nil { s.logger.Debug("failed to publish task PR updated event", zap.Error(err)) } } return nil } // TriggerPRSync performs an immediate PR status sync for a task. Single-repo // callers see the same single-PR contract as before. Multi-repo callers get // the primary repo's PR back; they should use TriggerPRSyncAll to refresh // every repo's PR in one round-trip. func (s *Service) TriggerPRSync(ctx context.Context, taskID string) (*TaskPR, error) { watch, err := s.store.GetPRWatchByTask(ctx, taskID) if err != nil { return nil, fmt.Errorf("get PR watch: %w", err) } if watch == nil { // No watch — just return existing TaskPR if any return s.store.GetTaskPR(ctx, taskID) } if watch.PRNumber == 0 { return s.triggerPRDetection(ctx, watch, taskID) } return s.triggerPRStatusSync(ctx, watch, taskID) } // TriggerPRSyncAll performs an immediate PR status sync for every PR watch // associated with the task and returns every resulting TaskPR. For // multi-repo tasks this is the right entry point — TriggerPRSync only // touches the most recently updated watch and silently leaves the other // repos' PRs stale. Returns an empty slice (not nil) when the task has no // watches. // // When the client supports GraphQL (the production path) all of the task's // watches are fetched in 1-2 batched gh subprocess calls. The per-watch // fallback below is reached only for the NoopClient (auth disabled) or // when the batched fetch itself fails; in those cases we fan out one // subprocess per watch as before. func (s *Service) TriggerPRSyncAll(ctx context.Context, taskID string) ([]*TaskPR, error) { prs, _, err := s.triggerPRSyncAllPermanent(ctx, taskID) return prs, err } // TriggerPRSyncAllPermanent extends TriggerPRSyncAll with a "permanent" // flag: true iff the task has at least one PR watch and every one of // those watches is currently in the 10-min negative cache (or its repo // is otherwise classified as unresolvable). The WS handler uses this to // tell the frontend to stop the 5s sync-retry interval, so a task // pointing at a deleted repo doesn't keep hammering the gh throttle for // the lifetime of the task. func (s *Service) TriggerPRSyncAllPermanent(ctx context.Context, taskID string) ([]*TaskPR, bool, error) { return s.triggerPRSyncAllPermanent(ctx, taskID) } func (s *Service) triggerPRSyncAllPermanent(ctx context.Context, taskID string) ([]*TaskPR, bool, error) { watches, err := s.store.ListPRWatchesByTask(ctx, taskID) if err != nil { return nil, false, fmt.Errorf("list PR watches: %w", err) } if len(watches) == 0 { // No watches — fall back to whatever TaskPRs already exist (e.g. // PRs imported via task-create-from-PR-URL where the watch is // optional). Empty slice if none. permanent=false (the task can // still acquire a watch later via push detection). existing, listErr := s.store.ListTaskPRsByTask(ctx, taskID) if listErr != nil { return nil, false, fmt.Errorf("list task PRs: %w", listErr) } return existing, false, nil } prs, syncErr := s.runBatchedOrPerWatchSync(ctx, taskID, watches) return prs, s.areAllWatchesPermanentlyMissing(watches), syncErr } // runBatchedOrPerWatchSync is the shared "try batched, fall back to // per-watch" body of triggerPRSyncAllPermanent. Split out so the // permanent-flag computation can wrap the result without duplicating // the batched / fallback branches inline. func (s *Service) runBatchedOrPerWatchSync(ctx context.Context, taskID string, watches []*PRWatch) ([]*TaskPR, error) { if _, batchErr := s.SyncWatchesBatched(ctx, watches); batchErr != nil { s.logger.Debug("batched PR sync failed; falling back to per-watch", zap.String("task_id", taskID), zap.Error(batchErr)) return s.triggerPRSyncAllPerWatch(ctx, taskID, watches) } // Batched path applied all DB updates inline; reload so the WS caller // sees the freshest TaskPR rows. return s.store.ListTaskPRsByTask(ctx, taskID) } // areAllWatchesPermanentlyMissing reports whether every supplied watch's // (owner, repo) is currently in the 10-min negative cache. Used to set // the permanent flag on wsSyncTaskPR's response so the frontend stops // retrying when a task points only at deleted/inaccessible repositories. // Returns false when the input is empty so an empty task doesn't get // classified as permanently missing. func (s *Service) areAllWatchesPermanentlyMissing(watches []*PRWatch) bool { if len(watches) == 0 { return false } for _, w := range watches { if !s.isRepoCachedAsMissing(w.Owner, w.Repo) { return false } } return true } // triggerPRSyncAllPerWatch is the legacy fan-out path: one gh subprocess // per watch. Kept as a fallback for the NoopClient and for the rare case // where the batched GraphQL call fails (auth glitch, network blip) so a // single bad cycle doesn't leave the UI staring at stale data. func (s *Service) triggerPRSyncAllPerWatch(ctx context.Context, taskID string, watches []*PRWatch) ([]*TaskPR, error) { results := make([]*TaskPR, 0, len(watches)) for _, w := range watches { var tp *TaskPR var syncErr error if w.PRNumber == 0 { tp, syncErr = s.triggerPRDetection(ctx, w, taskID) } else { tp, syncErr = s.triggerPRStatusSync(ctx, w, taskID) } if syncErr != nil { // Debug, not Warn: this is best-effort background reconciliation and // the most common failure — a branch with no PR yet, or an // unresolvable repo — is an expected steady state, not an // operational warning. The background poller logs the same failure // at Debug (see poller.detectPRForWatch / checkSinglePRWatch); the // WS handler still returns the error to the caller. s.logger.Debug("per-repo PR sync failed", zap.String("task_id", taskID), zap.String("repository_id", w.RepositoryID), zap.Int("pr_number", w.PRNumber), zap.Error(syncErr)) continue } if tp != nil { results = append(results, tp) } } return results, nil } func (s *Service) triggerPRDetection(ctx context.Context, watch *PRWatch, taskID string) (*TaskPR, error) { if s.client == nil { return nil, nil } // Coalesce concurrent detection probes for the same watch. Without this the // freshness check below is racy: parallel callers (e.g. the 5s frontend // retry racing the workspace background refresh) can all read a stale // last_checked_at and probe GitHub simultaneously, defeating the throttle. // Keyed distinctly from triggerPRStatusSync's owner/repo/number key so the // two never collide. singleflight only blocks same-key calls, so the nested // triggerPRStatusSync (different key) below cannot deadlock. key := "pr-detect:" + watch.ID v, err, _ := s.syncGroup.Do(key, func() (interface{}, error) { return s.detectPRForWatchOnce(ctx, watch, taskID) }) if err != nil { return nil, err } if v == nil { return nil, nil } return v.(*TaskPR), nil } // detectPRForWatchOnce performs a single branch-detection probe for a // searching (pr_number=0) watch. It runs inside triggerPRDetection's // singleflight so only one probe per watch is in flight at a time. func (s *Service) detectPRForWatchOnce(ctx context.Context, watch *PRWatch, taskID string) (*TaskPR, error) { // Short-circuit when this repo is already in the 10-min negative // cache. Without this, an unresolvable repo gets a fresh per-watch // probe every 5s (the frontend retry cadence) until the freshness // timestamp below stamps in — multiplied by every watch the task has. if s.isRepoCachedAsMissing(watch.Owner, watch.Repo) { return nil, ErrRepoNotResolvable } // Throttle branch-detection probes. A watch still searching for its PR // (pr_number=0) has no TaskPR row yet, so triggerPRStatusSync's freshness // check can't gate it — we gate on the watch's own last_checked_at instead. // Without this, a branch whose PR never appears (e.g. an unresolvable repo) // re-hits `gh` on every on-demand sync, and the frontend re-syncs every 5s // while no PR is found — flooding the logs with identical failures. if watch.LastCheckedAt != nil && time.Since(*watch.LastCheckedAt) < prSyncFreshnessWindow { return s.store.GetTaskPRByRepository(ctx, taskID, watch.RepositoryID) } // Snapshot the negative-cache generation BEFORE the fetch so an // eviction (CreatePRWatch / EnsurePRWatch / explicit clear) that // fires WHILE FindPRByBranch is running wins over our post-fetch // classification — see Service.markRepoAsMissing for the rationale. repoErrGen := s.repoErrorGenSnapshot() pr, err := s.client.FindPRByBranch(ctx, watch.Owner, watch.Repo, watch.Branch) if err != nil && isRepoNotResolvableErr(err) { s.markRepoAsMissing(watch.Owner, watch.Repo, repoErrGen) // Wrap so wsSyncTaskPR can errors.Is(err, ErrRepoNotResolvable) // to flag the WS response as permanent without re-running the // classifier on the raw upstream error string. err = fmt.Errorf("%w: %w", ErrRepoNotResolvable, err) } // Stamp last_checked_at regardless of outcome — including on error. The // flood this fixes IS the error path (an unresolvable repo makes `gh` exit // non-zero), so stamping only on success would leave the bug unfixed. The // tradeoff: a transient GitHub error throttles the next on-demand probe for // prSyncFreshnessWindow, which is fine — it stops hammering a failing // endpoint, and the 60s background poller still retries. This diverges // intentionally from poller.detectPRForWatch, which stamps only on success. // The empty-string check/review args clear last_check_status / // last_review_state. That's harmless here — this path only runs for // pr_number=0 (searching) watches, which never carry those fields — and // matches the poller's detection stamp. Only call this on searching // watches; for a resolved PR use the status-sync path instead. now := time.Now().UTC() if tsErr := s.store.UpdatePRWatchTimestamps(ctx, watch.ID, now, nil, "", ""); tsErr != nil { s.logger.Debug("failed to stamp PR watch after detection probe", zap.String("watch_id", watch.ID), zap.Error(tsErr)) } if err != nil || pr == nil { return nil, err } if err := s.store.UpdatePRWatchPRNumber(ctx, watch.ID, pr.Number); err != nil { s.logger.Error("failed to update PR watch number during sync", zap.String("watch_id", watch.ID), zap.Int("pr_number", pr.Number), zap.Error(err)) return nil, fmt.Errorf("update PR watch: %w", err) } if _, assocErr := s.AssociatePRWithTask(ctx, taskID, watch.RepositoryID, pr); assocErr != nil { s.logger.Error("failed to associate PR with task during sync", zap.String("task_id", taskID), zap.Int("pr_number", pr.Number), zap.Error(assocErr)) return nil, fmt.Errorf("associate PR: %w", assocErr) } // Also fetch status so the first response includes review/check state watch.PRNumber = pr.Number return s.triggerPRStatusSync(ctx, watch, taskID) } func (s *Service) triggerPRStatusSync(ctx context.Context, watch *PRWatch, taskID string) (*TaskPR, error) { // Freshness check: skip GitHub API if recently synced. Look up by the // watch's own (task, repo) — the legacy GetTaskPR(ctx, taskID) returned // "first match" which for multi-repo tasks would mistakenly hit the // other repo's row and skip the sync that this watch actually needs. loadTaskPR := func(c context.Context) (*TaskPR, error) { tp, err := s.store.GetTaskPRByRepository(c, taskID, watch.RepositoryID) if err != nil { return nil, err } if tp != nil { return tp, nil } // Fall back to the legacy untagged row for single-repo tasks that // haven't been re-associated under the multi-repo schema yet. return s.store.GetTaskPR(c, taskID) } if tp, _ := loadTaskPR(ctx); tp != nil && tp.LastSyncedAt != nil { if time.Since(*tp.LastSyncedAt) < prSyncFreshnessWindow { return tp, nil } } // Short-circuit on negative cache so a 5s frontend retry against a // dead repo doesn't burn the gh throttle. Eviction happens on watch // (re)create paths, so a freshly linked repo is probed immediately. if s.isRepoCachedAsMissing(watch.Owner, watch.Repo) { return nil, ErrRepoNotResolvable } // Coalesce concurrent syncs for the same PR key := fmt.Sprintf("%s/%s/%d", watch.Owner, watch.Repo, watch.PRNumber) v, err, _ := s.syncGroup.Do(key, func() (interface{}, error) { bgCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() // Snapshot cache generation BEFORE the per-watch probe so a // concurrent eviction wins; see Service.markRepoAsMissing. repoErrGen := s.repoErrorGenSnapshot() status, _, checkErr := s.CheckPRWatch(bgCtx, watch) if checkErr != nil { if isRepoNotResolvableErr(checkErr) { s.markRepoAsMissing(watch.Owner, watch.Repo, repoErrGen) return nil, fmt.Errorf("%w: %w", ErrRepoNotResolvable, checkErr) } return nil, checkErr } if status == nil { return loadTaskPR(bgCtx) } if syncErr := s.SyncTaskPR(bgCtx, taskID, status); syncErr != nil { return nil, syncErr } return loadTaskPR(bgCtx) }) if err != nil { return nil, err } if v == nil { return nil, nil } return v.(*TaskPR), nil }