package github import ( "context" "errors" "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) { return s.createPRWatch(ctx, "", sessionID, taskID, repositoryID, owner, repo, prNumber, branch) } func (s *Service) CreatePRWatchForWorkspace( ctx context.Context, workspaceID, sessionID, taskID, repositoryID, owner, repo string, prNumber int, branch string, ) (*PRWatch, error) { if strings.TrimSpace(workspaceID) == "" { return nil, ErrGitHubWorkspaceRequired } if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil { return nil, err } return s.createPRWatch(ctx, workspaceID, sessionID, taskID, repositoryID, owner, repo, prNumber, branch) } func (s *Service) createPRWatch( ctx context.Context, workspaceID, 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) } taskID = s.resolveEffectiveAssociationTaskIDForSession(ctx, sessionID, taskID, repositoryID) w := &PRWatch{ WorkspaceID: workspaceID, 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 } // GetPRWatch returns one PR watch after authorizing its stored workspace. func (s *Service) GetPRWatch(ctx context.Context, id string) (*PRWatch, error) { watch, err := s.store.GetPRWatch(ctx, id) if err != nil || watch == nil { return watch, err } if err := s.authorizeWorkspaceAccess(ctx, watch.WorkspaceID); err != nil { return nil, err } return watch, nil } // 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) } // ListActivePRWatchesForWorkspace returns active PR watches for one authorized // workspace. The all-workspaces variant is reserved for identity-less pollers. func (s *Service) ListActivePRWatchesForWorkspace(ctx context.Context, workspaceID string) ([]*PRWatch, error) { if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil { return nil, err } return s.store.ListActivePRWatchesForWorkspace(ctx, workspaceID) } // DeletePRWatch deletes a PR watch by ID. func (s *Service) DeletePRWatch(ctx context.Context, id string) error { watch, err := s.store.GetPRWatch(ctx, id) if err != nil { return err } if watch != nil { if err := s.authorizeWorkspaceAccess(ctx, watch.WorkspaceID); err != nil { return err } } 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) } // UpdatePRWatchRepository updates a watch's provider repository identity after // PR discovery. This keeps future status polling on the repository that owns // the PR rather than the repository used to discover the branch. func (s *Service) UpdatePRWatchRepository(ctx context.Context, id, owner, repo string) error { return s.store.UpdatePRWatchRepository(ctx, id, owner, repo) } func (s *Service) rebindPRWatchRepository(ctx context.Context, watch *PRWatch, pr *PR) error { if watch == nil || pr == nil || strings.TrimSpace(pr.RepoOwner) == "" || strings.TrimSpace(pr.RepoName) == "" { return nil } if sameRepositoryIdentity(watch.Owner, watch.Repo, pr.RepoOwner, pr.RepoName) { return nil } if err := s.store.UpdatePRWatchRepository(ctx, watch.ID, pr.RepoOwner, pr.RepoName); err != nil { return fmt.Errorf("update PR watch repository: %w", err) } watch.Owner = pr.RepoOwner watch.Repo = pr.RepoName return nil } // 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") } return s.checkPRWatchWithClient(ctx, s.client, watch) } func (s *Service) CheckPRWatchForWorkspace(ctx context.Context, watch *PRWatch) (*PRStatus, bool, error) { if watch == nil || strings.TrimSpace(watch.WorkspaceID) == "" { return nil, false, ErrGitHubWorkspaceRequired } resolved, err := s.resolveAutomationClient(ctx, watch.WorkspaceID, watch.Owner, watch.Repo) if err != nil { return nil, false, err } return s.checkPRWatchWithClient(ctx, resolved.Client, watch) } func (s *Service) checkPRWatchWithClient( ctx context.Context, client Client, watch *PRWatch, ) (*PRStatus, bool, error) { status, err := 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 commentAt := prWatchFeedbackWatermark(watch, status) hasNew = hasNew || prWatchFeedbackUpdatedSinceWatch(watch, status) // Update watch timestamps now := time.Now().UTC() if err := s.store.UpdatePRWatchTimestamps(ctx, watch.ID, now, commentAt, 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 } func prWatchFeedbackUpdatedSinceWatch(watch *PRWatch, status *PRStatus) bool { if watch == nil || status == nil || status.PR == nil || status.PR.UpdatedAt.IsZero() { return false } return watch.LastCommentAt == nil || status.PR.UpdatedAt.After(*watch.LastCommentAt) } func prWatchFeedbackWatermark(watch *PRWatch, status *PRStatus) *time.Time { if status != nil && status.PR != nil && !status.PR.UpdatedAt.IsZero() { updatedAt := status.PR.UpdatedAt if watch != nil && watch.LastCommentAt != nil && watch.LastCommentAt.After(updatedAt) { return watch.LastCommentAt } return &updatedAt } if watch == nil { return nil } return watch.LastCommentAt } // 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) { return s.ensurePRWatch(ctx, "", sessionID, taskID, repositoryID, owner, repo, branch) } func (s *Service) EnsurePRWatchForWorkspace( ctx context.Context, workspaceID, sessionID, taskID, repositoryID, owner, repo, branch string, ) (*PRWatch, error) { if strings.TrimSpace(workspaceID) == "" { return nil, ErrGitHubWorkspaceRequired } return s.ensurePRWatch(ctx, workspaceID, sessionID, taskID, repositoryID, owner, repo, branch) } func (s *Service) ensurePRWatch( ctx context.Context, workspaceID, 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 } taskID = s.resolveEffectiveAssociationTaskIDForSession(ctx, sessionID, taskID, repositoryID) w := &PRWatch{ WorkspaceID: workspaceID, 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 repositories.ID — the repository_id COLUMN // value stored on the task's task_repositories row, NOT that row's own id. // Passing the row id instead lets two writers disagree on repository_id for // the same task+PR, so both pass the UNIQUE(task_id, repository_id, // pr_number) constraint and the PR is associated twice. 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) { // pr here comes from branch-search discovery (poller.detectPRForWatch) or // a batched status result of unknown populated-ness — never assert // outcomeFieldsPopulated for this wrapper's callers (AC-11). return s.associatePRWithTask(ctx, "", taskID, repositoryID, pr, false, false, TaskPRSourceWatch) } // AssociatePRWithTaskForWorkspace is the workspace-scoped variant of // AssociatePRWithTask; `repositoryID` is repositories.ID, same as there. func (s *Service) AssociatePRWithTaskForWorkspace( ctx context.Context, workspaceID, taskID, repositoryID string, pr *PR, ) (*TaskPR, error) { if strings.TrimSpace(workspaceID) == "" { return nil, ErrGitHubWorkspaceRequired } return s.associatePRWithTask(ctx, workspaceID, taskID, repositoryID, pr, false, false, TaskPRSourceWatch) } // resolveEffectiveAssociationTaskID redirects a watch-sourced association // write to the workspace-group owner task, independently of whatever // producer called in. It exists as defense-in-depth alongside // internal/orchestrator's resolveEffectivePushTaskID: that helper redirects // every watch/association call site the orchestrator package controls, but // Review Round 2 (F4) found a fifth orchestrator producer it had missed, and // F5 found that redirect alone does nothing for github_pr_watches rows that // already carry a member task_id from before the fix existed — those stale // watches keep feeding poller.go's AssociatePRWithTask(watch.TaskID, ...) // forever, since nothing ever rewrites a watch's task_id. Applying the same // redirect again at this single private write funnel closes both gaps at // once: it does not matter which orchestrator producer supplied taskID, or // whether the watch driving it predates this fix, because every watch-sourced // write is re-checked here before it lands. // // Only applied to source == TaskPRSourceWatch. TaskPRSourceURLLink // (AssociateExistingPRByURL/ForWorkspace) is a deliberate cross-task action — // the user explicitly chose to link a specific PR to the task whose card they // were viewing, independent of worktree sharing — and must never be // silently rerouted to a different task. // // Falls back to taskID unchanged (matching the orchestrator resolver's // fallback semantics) whenever the redirect can't be safely applied: no // resolver wired, taskID empty, no group/no distinct owner, the lookup // errors, the owner task can't be loaded or is archived, or the owner // doesn't hold repositoryID itself (redirecting would just make // validateTaskRepositoryID reject the owner and drop the association, which // is worse than leaving it under the observing task — the same reasoning // Round 2 applied to resolveEffectivePushTaskID's own repository check). func (s *Service) resolveEffectiveAssociationTaskIDForSession( ctx context.Context, sessionID, taskID, repositoryID string, ) string { if taskID == "" { return taskID } resolver := s.workspaceGroupOwnerResolver if resolver == nil { return taskID } var ownerTaskID string var err error if sessionID != "" { if sessionResolver, ok := any(resolver).(WorkspaceGroupOwnerSessionResolver); ok { ownerTaskID, err = sessionResolver.GetWorkspaceGroupOwnerTaskIDForSession(ctx, taskID, sessionID) } else { // Keep compatibility with older adapters and focused test doubles. ownerTaskID, err = resolver.GetWorkspaceGroupOwnerTaskID(ctx, taskID) } } else { ownerTaskID, err = resolver.GetWorkspaceGroupOwnerTaskID(ctx, taskID) } if err != nil || ownerTaskID == "" || ownerTaskID == taskID { return taskID } store := s.getTaskIssueStore() if store == nil { return taskID } ownerTask, err := store.GetTask(ctx, ownerTaskID) if err != nil || ownerTask == nil || ownerTask.ArchivedAt != nil { return taskID } if repositoryID != "" && !s.associationOwnerHoldsRepository(ctx, store, ownerTaskID, repositoryID) { return taskID } s.logger.Info("redirecting watch-sourced PR association to workspace group owner", zap.String("from_task_id", taskID), zap.String("to_task_id", ownerTaskID), zap.String("repository_id", repositoryID)) return ownerTaskID } // associationOwnerHoldsRepository reports whether taskID has a // task_repositories row for repositoryID. Used by // resolveEffectiveAssociationTaskID to avoid redirecting into a write that // validateTaskRepositoryID would reject and associatePRWithTask would then // silently drop. func (s *Service) associationOwnerHoldsRepository(ctx context.Context, store TaskIssueStore, taskID, repositoryID string) bool { taskRepos, err := store.ListTaskRepositories(ctx, taskID) if err != nil { return false } for _, tr := range taskRepos { if tr != nil && tr.RepositoryID == repositoryID { return true } } return false } // reconcileTaskPROwnership repairs an active member-owned row before a // watch-sourced sync writes the effective owner. The store operation is // atomic and does not publish an intermediate member event, so one upstream // observation produces one owner-scoped update. func (s *Service) reconcileTaskPROwnership( ctx context.Context, sessionID, memberTaskID, repositoryID string, prNumber int, ) string { ownerTaskID := s.resolveEffectiveAssociationTaskIDForSession(ctx, sessionID, memberTaskID, repositoryID) if ownerTaskID == "" || ownerTaskID == memberTaskID || prNumber == 0 || s.store == nil { return ownerTaskID } reconciler, ok := any(s.store).(TaskPROwnershipReconciler) if !ok { return ownerTaskID } if _, err := reconciler.ReconcileTaskPROwnership( ctx, memberTaskID, ownerTaskID, repositoryID, prNumber, ); err != nil { if s.logger != nil { s.logger.Warn("failed to reconcile legacy member task PR", zap.String("member_task_id", memberTaskID), zap.String("owner_task_id", ownerTaskID), zap.String("repository_id", repositoryID), zap.Int("pr_number", prNumber), zap.Error(err)) } } return ownerTaskID } // associatePRWithTask creates the task-PR association. outcomeFieldsPopulated // tells it whether pr came from a full single-PR fetch (AC-10) — only the // direct GetPR-based callers in this file set it true; the two public // wrappers above always pass false, preserving their existing callers' // behavior exactly, because a branch-search or batched-status pr may not // carry real observations for these fields (AC-11). source is only applied // when this call creates a brand-new row (see TaskPRSourceWatch / // TaskPRSourceURLLink) — an existing or restored row keeps its original // source classification (no backfill). taskID is redirected to the // workspace-group owner before anything else runs when source is // TaskPRSourceWatch — see resolveEffectiveAssociationTaskID. func (s *Service) associatePRWithTask( ctx context.Context, workspaceID, taskID, repositoryID string, pr *PR, restoreDetached, outcomeFieldsPopulated bool, source string, ) (*TaskPR, error) { return s.associatePRWithTaskForSession(ctx, workspaceID, "", taskID, repositoryID, pr, restoreDetached, outcomeFieldsPopulated, source) } func (s *Service) associatePRWithTaskForSession( ctx context.Context, workspaceID, sessionID, taskID, repositoryID string, pr *PR, restoreDetached, outcomeFieldsPopulated bool, source string, ) (*TaskPR, error) { originalTaskID := taskID if source == TaskPRSourceWatch { taskID = s.resolveEffectiveAssociationTaskIDForSession(ctx, sessionID, taskID, repositoryID) if taskID != originalTaskID { s.reconcileTaskPROwnership(ctx, sessionID, originalTaskID, repositoryID, pr.Number) } } if err := s.validateTaskRepositoryID(ctx, taskID, repositoryID); err != nil { return nil, err } // 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.GetTaskPRByRepoAndNumberIncludingDetached(ctx, taskID, repositoryID, pr.Number) if err != nil { return nil, err } if existing != nil { if existing.DetachedAt != nil && restoreDetached { // RestoreTaskPR resolves the five outcome-attribution columns // itself, inside its own transaction, against the row it is // about to overwrite (AC-43, AC-43a) — this caller must not // pre-resolve them via resolveTaskPROutcomeFields, since a // value resolved here would be stale by the time the store's // statement executes. restored, restoreErr := s.store.RestoreTaskPR( ctx, taskID, repositoryID, &PRStatus{PR: pr, OutcomeFieldsPopulated: outcomeFieldsPopulated}, ) if restoreErr != nil || restored == nil { return restored, restoreErr } if s.eventBus != nil { event := bus.NewEvent(events.GitHubTaskPRUpdated, "github", restored) if err := s.eventBus.Publish(ctx, events.GitHubTaskPRUpdated, event); err != nil { s.logger.Debug("failed to publish restored task PR event", zap.Error(err)) } } s.reconcileComparisonTarget(ctx, taskID, pr) return restored, nil } s.reconcileComparisonTarget(ctx, taskID, pr) return existing, nil } tp := &TaskPR{ WorkspaceID: workspaceID, 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, HeadSHA: pr.HeadSHA, AuthorLogin: pr.AuthorLogin, State: pr.State, Additions: pr.Additions, Deletions: pr.Deletions, CreatedAt: pr.CreatedAt, MergedAt: pr.MergedAt, ClosedAt: pr.ClosedAt, Source: source, } // ReplaceTaskPR upserts the row matching (task, repository, pr_number) // and resolves the five outcome-attribution columns itself, inside its // own transaction, against the row it is about to overwrite (AC-43, // AC-43a) — this caller must not pre-resolve them. 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. replaced, err := s.store.ReplaceTaskPR(ctx, tp, &PRStatus{PR: pr, OutcomeFieldsPopulated: outcomeFieldsPopulated}) if err != nil { return nil, fmt.Errorf("replace task PR: %w", err) } tp = replaced // 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.reconcileComparisonTarget(ctx, taskID, pr) 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. `repositoryID` is repositories.ID, same id space // as AssociatePRWithTask. 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) } if err := s.validateTaskRepositoryID(ctx, taskID, repositoryID); err != nil { return nil, 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, true, true, TaskPRSourceURLLink) if err != nil { return nil, fmt.Errorf("associate PR with task: %w", err) } return tp, nil } // AssociateExistingPRByURLForWorkspace is the workspace-scoped variant of // AssociateExistingPRByURL; `repositoryID` is repositories.ID, same as there. func (s *Service) AssociateExistingPRByURLForWorkspace( ctx context.Context, workspaceID, userID, taskID, repositoryID, prURL string, ) (*TaskPR, error) { owner, repo, prNumber, err := parsePRURL(prURL) if err != nil { return nil, fmt.Errorf("%w: %w", ErrInvalidPRURL, err) } if err := s.validateTaskRepositoryID(ctx, taskID, repositoryID); err != nil { return nil, err } if err := s.ensureRepositoryInWorkspaceScope(ctx, workspaceID, owner, repo); err != nil { return nil, err } resolved, err := s.resolvePersonalReadClient(ctx, workspaceID, userID, owner, repo) if err != nil { return nil, err } pr, err := resolved.Client.GetPR(ctx, owner, repo, prNumber) if err != nil { return nil, fmt.Errorf("fetch PR: %w", err) } tp, err := s.associatePRWithTask(ctx, workspaceID, taskID, repositoryID, pr, true, true, TaskPRSourceURLLink) 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 } if err := s.validateTaskRepositoryID(ctx, taskID, repositoryID); err != nil { s.logger.Error("invalid task repository for PR association", zap.String("task_id", taskID), zap.String("repository_id", repositoryID), 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 } // Resolved once and reused for both writes below: the watch's task_id is // its only sync handle (get-or-create, never re-pointed on a hit), so it // must agree with the association's task_id or the redirected owner's // row never syncs. effectiveTaskID := s.resolveEffectiveAssociationTaskIDForSession(ctx, sessionID, taskID, repositoryID) if _, watchErr := s.CreatePRWatch(ctx, sessionID, effectiveTaskID, 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.associatePRWithTaskForSession(ctx, "", sessionID, taskID, repositoryID, pr, true, true, TaskPRSourceWatch); assocErr != nil { s.logger.Error("failed to associate PR with task after creation", zap.String("task_id", effectiveTaskID), zap.Error(assocErr)) } } func (s *Service) AssociatePRByURLForWorkspace( ctx context.Context, workspaceID, userID, sessionID, taskID, repositoryID, prURL, branch string, ) error { owner, repo, prNumber, err := parsePRURL(prURL) if err != nil { return fmt.Errorf("%w: %w", ErrInvalidPRURL, err) } if err := s.validateTaskRepositoryID(ctx, taskID, repositoryID); err != nil { return err } if err := s.ensureRepositoryInWorkspaceScope(ctx, workspaceID, owner, repo); err != nil { return err } resolved, err := s.resolvePersonalReadClient(ctx, workspaceID, userID, owner, repo) if err != nil { return err } pr, err := resolved.Client.GetPR(ctx, owner, repo, prNumber) if err != nil { return fmt.Errorf("fetch PR: %w", err) } if branch == "" { branch = pr.HeadBranch } // Resolved once and reused for both writes below: the watch's task_id is // its only sync handle (get-or-create, never re-pointed on a hit), so it // must agree with the association's task_id or the redirected owner's // row never syncs. effectiveTaskID := s.resolveEffectiveAssociationTaskIDForSession(ctx, sessionID, taskID, repositoryID) if _, err := s.CreatePRWatchForWorkspace( ctx, workspaceID, sessionID, effectiveTaskID, repositoryID, owner, repo, prNumber, branch, ); err != nil { return fmt.Errorf("create PR watch: %w", err) } if _, err := s.associatePRWithTaskForSession(ctx, workspaceID, sessionID, taskID, repositoryID, pr, true, true, TaskPRSourceWatch); err != nil { return fmt.Errorf("associate PR with task: %w", err) } return nil } // 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) } if number <= 0 { return "", "", 0, fmt.Errorf("invalid PR number in URL %s: must be greater than zero", prURL) } // 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) } // GetTaskPRByOwnerRepoNumber returns the task PR row matching a PR feedback event. func (s *Service) GetTaskPRByOwnerRepoNumber(ctx context.Context, taskID, owner, repo string, prNumber int) (*TaskPR, error) { prs, err := s.store.ListTaskPRsByTask(ctx, taskID) if err != nil { return nil, err } for _, pr := range prs { if pr.Owner == owner && pr.Repo == repo && pr.PRNumber == prNumber { return pr, nil } } return nil, nil } // 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) } // FindTaskIDsByPRNumber returns the IDs of tasks in a workspace associated with // the given PR number. Used by task search to surface a task by its PR number. func (s *Service) FindTaskIDsByPRNumber(ctx context.Context, workspaceID string, prNumber int) ([]string, error) { return s.store.ListTaskIDsByPRNumber(ctx, workspaceID, prNumber) } // 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() allWatches, unwatched := s.collectStaleWorkspaceSyncTargets(syncCtx, staleTasks) // PRs left behind by a branch handover have no watch to fan in, so // they would stay stale on every workspace load. Reconcile them in // one batched call for the whole workspace; once a row reaches a // terminal state it drops out of this set permanently. if len(unwatched) > 0 { s.syncUnwatchedTaskPRs(syncCtx, unwatched, workspaceID) } if len(allWatches) == 0 { return } if _, err := s.SyncWorkspaceWatchesBatched(syncCtx, workspaceID, 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 } type taskPRSyncState struct { checksTotal, checksPassing int unresolved, reviewCount, pendingReviewCount int requiredReviews *int baseBranch, mergeableState, state string mergeQueueState string mergeQueuePosition, mergeQueueEstimate *int mergeQueueEntryID, mergeQueueEntryHeadSHA string mergeQueueLastRemovalID, mergeQueueLastRemovalReason string mergeQueueLastRemovalBeforeSHA string mergeQueueLastRemovedAt *time.Time headSHA string mergedAt, closedAt *time.Time isDraft *bool changedFiles *int mergedByLogin, closedByLogin *string autoMergeObservedAt *time.Time } type taskPRMergeQueueState struct { state, entryID, entryHeadSHA string position, estimate *int lastRemovalID, lastRemovalReason, lastRemovalBeforeSHA string lastRemovedAt *time.Time } func resolveTaskPRMergeQueueState(tp *TaskPR, status *PRStatus) taskPRMergeQueueState { var queue taskPRMergeQueueState if tp != nil { queue.state = tp.MergeQueueState queue.position = tp.MergeQueuePosition queue.estimate = tp.MergeQueueEstimatedTimeToMergeSeconds queue.entryID = tp.MergeQueueEntryID queue.entryHeadSHA = tp.MergeQueueEntryHeadSHA queue.lastRemovalID = tp.MergeQueueLastRemovalID queue.lastRemovedAt = tp.MergeQueueLastRemovedAt queue.lastRemovalReason = tp.MergeQueueLastRemovalReason queue.lastRemovalBeforeSHA = tp.MergeQueueLastRemovalBeforeSHA } if status == nil || status.PR == nil { return queue } if strings.EqualFold(status.PR.State, prStateMerged) || strings.EqualFold(status.PR.State, prStateClosed) { queue.state, queue.position, queue.estimate = "", nil, nil queue.entryID, queue.entryHeadSHA = "", "" } else if status.mergeQueuePopulated { queue.state = normalizeMergeQueueState(status.MergeQueueState) queue.position = positiveIntPtr(status.MergeQueuePosition) queue.estimate = nonNegativeIntPtr(status.MergeQueueEstimatedTimeToMergeSeconds) queue.entryID = status.MergeQueueEntryID queue.entryHeadSHA = status.MergeQueueEntryHeadSHA } else { // REST and gh CLI status reads do not expose merge-queue fields. They // are still authoritative for the absence of an active entry once the // PR itself was fetched, so do not let an old GraphQL entry identifier // keep auto-merge blocked indefinitely. queue.state, queue.position, queue.estimate = "", nil, nil queue.entryID, queue.entryHeadSHA = "", "" } if status.mergeQueueRecoveryPopulated && shouldApplyTaskPRMergeQueueRemoval(queue, status) { queue.lastRemovalID = status.MergeQueueLastRemovalID queue.lastRemovedAt = status.MergeQueueLastRemovedAt queue.lastRemovalReason = status.MergeQueueLastRemovalReason queue.lastRemovalBeforeSHA = status.MergeQueueLastRemovalBeforeSHA } return queue } func shouldApplyTaskPRMergeQueueRemoval(current taskPRMergeQueueState, status *PRStatus) bool { if status == nil || status.MergeQueueLastRemovalID == "" || current.lastRemovalID == status.MergeQueueLastRemovalID { return false } if current.lastRemovalID == "" || current.lastRemovedAt == nil { return true } if status.MergeQueueLastRemovedAt == nil { return false } return status.MergeQueueLastRemovedAt.After(*current.lastRemovedAt) } // resolveTaskPROutcomeFields applies the populated/preserve dance for the // five outcome-attribution columns, split out of prepareTaskPRSyncState to // keep that function's complexity within the repo's lint limits (see the // cyclomatic-complexity suppression on SyncTaskPR below). // // - is_draft / changed_files each follow their own presence flag // (status.PR.IsDraftObserved / status.PR.ChangedFilesObserved) rather // than the whole-group status.OutcomeFieldsPopulated: a group marked // populated is not a promise that every field in it arrived, so a // populated sync whose upstream response omitted or nulled one of these // two writes NULL for that column specifically, not the decoder's zero // value (AC-12a). merged_by_login stays keyed off the group flag alone // (nil merged_by_login when upstream reports no merger, never ""). // An unpopulated group preserves all three stored values verbatim, // including NULL (AC-13). // - closed_by_login follows status.ClosureAttributionPopulated the same // way (AC-14, AC-15) — only the GraphQL path ever sets it. // - auto_merge_observed_at is a latch: set once, on the first populating // sync that observes auto-merge armed while the stored value is NULL, // and never cleared or overwritten afterwards (AC-16, AC-17). func resolveTaskPROutcomeFields(tp *TaskPR, status *PRStatus) ( isDraft *bool, changedFiles *int, mergedByLogin, closedByLogin *string, autoMergeObservedAt *time.Time, ) { isDraft, changedFiles, mergedByLogin = tp.IsDraft, tp.ChangedFiles, tp.MergedByLogin if status.OutcomeFieldsPopulated { isDraft = nil if status.PR.IsDraftObserved { draft := status.PR.Draft isDraft = &draft } changedFiles = nil if status.PR.ChangedFilesObserved { files := status.PR.ChangedFiles changedFiles = &files } mergedByLogin = nil if status.PR.MergedByLogin != "" { login := status.PR.MergedByLogin mergedByLogin = &login } } closedByLogin = tp.ClosedByLogin if status.ClosureAttributionPopulated { login := status.ClosedByLogin closedByLogin = &login } autoMergeObservedAt = tp.AutoMergeObservedAt if status.OutcomeFieldsPopulated && status.PR.AutoMergeEnabled && tp.AutoMergeObservedAt == nil { now := time.Now().UTC() autoMergeObservedAt = &now } return isDraft, changedFiles, mergedByLogin, closedByLogin, autoMergeObservedAt } // resolveTerminalMergeState keeps a row that already reached "merged" there. // GitHub cannot un-merge a PR, so a live read reporting anything else for a // merged row is stale — an eventually-consistent REST reply, or a fetch that // started before a concurrent writer observed the merge. Without this, two // writers racing the same PR could flip its badge back to green. // // Only "merged" is guarded. The PR detail panel used to apply a full // merged > closed > open rank client-side; that is deliberately not ported, // because closed -> open is a real transition (a PR can be reopened on GitHub) // and ranking it as a regression would pin every reopened PR closed forever. func resolveTerminalMergeState(tp *TaskPR, pr *PR) (string, *time.Time, *time.Time) { if tp.State == prStateMerged && pr.State != prStateMerged { return tp.State, tp.MergedAt, tp.ClosedAt } return pr.State, pr.MergedAt, pr.ClosedAt } func (s *Service) prepareTaskPRSyncState(ctx context.Context, tp *TaskPR, status *PRStatus) taskPRSyncState { // 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.fetchRequiredReviewsForTaskPR(ctx, tp, nextBaseBranch); fetched != nil { nextRequiredReviews = fetched } // GitHub reports draft status separately from mergeStateStatus and can // return CLEAN for a draft PR. Persist the effective blocker so every // TaskPR consumer agrees that drafts are not ready to merge. nextMergeableState := status.MergeableState if status.PR.Draft { nextMergeableState = "draft" } queue := resolveTaskPRMergeQueueState(tp, status) nextState, nextMergedAt, nextClosedAt := resolveTerminalMergeState(tp, status.PR) nextIsDraft, nextChangedFiles, nextMergedByLogin, nextClosedByLogin, nextAutoMergeObservedAt := resolveTaskPROutcomeFields(tp, status) nextHeadSHA := tp.HeadSHA if status.PR.HeadSHA != "" { nextHeadSHA = status.PR.HeadSHA } return taskPRSyncState{ checksTotal: nextChecksTotal, checksPassing: nextChecksPassing, unresolved: nextUnresolved, reviewCount: nextReviewCount, pendingReviewCount: nextPendingReviewCount, requiredReviews: nextRequiredReviews, baseBranch: nextBaseBranch, mergeableState: nextMergeableState, mergeQueueState: queue.state, mergeQueuePosition: queue.position, mergeQueueEstimate: queue.estimate, mergeQueueEntryID: queue.entryID, mergeQueueEntryHeadSHA: queue.entryHeadSHA, mergeQueueLastRemovalID: queue.lastRemovalID, mergeQueueLastRemovalReason: queue.lastRemovalReason, mergeQueueLastRemovalBeforeSHA: queue.lastRemovalBeforeSHA, mergeQueueLastRemovedAt: queue.lastRemovedAt, headSHA: nextHeadSHA, state: nextState, mergedAt: nextMergedAt, closedAt: nextClosedAt, isDraft: nextIsDraft, changedFiles: nextChangedFiles, mergedByLogin: nextMergedByLogin, closedByLogin: nextClosedByLogin, autoMergeObservedAt: nextAutoMergeObservedAt, } } func taskPRChangedFields(tp *TaskPR, status *PRStatus, next taskPRSyncState) []string { if tp == nil || status == nil || status.PR == nil { return nil } changed := make([]string, 0, 16) changed = appendChangedField(changed, "head_sha", tp.HeadSHA != next.headSHA) changed = appendChangedField(changed, "state", tp.State != next.state) changed = appendChangedField(changed, "pr_title", tp.PRTitle != status.PR.Title) changed = appendChangedField(changed, "additions", tp.Additions != status.PR.Additions) changed = appendChangedField(changed, "deletions", tp.Deletions != status.PR.Deletions) changed = appendChangedField(changed, "review_state", tp.ReviewState != status.ReviewState) changed = appendChangedField(changed, "checks_state", tp.ChecksState != status.ChecksState) changed = appendChangedField(changed, "mergeable_state", tp.MergeableState != next.mergeableState) changed = appendChangedField(changed, "merge_queue_state", tp.MergeQueueState != next.mergeQueueState) changed = appendChangedField(changed, "merge_queue_position", !intPtrEqual(tp.MergeQueuePosition, next.mergeQueuePosition)) changed = appendChangedField(changed, "merge_queue_entry_id", tp.MergeQueueEntryID != next.mergeQueueEntryID) changed = appendChangedField(changed, "merge_queue_entry_head_sha", tp.MergeQueueEntryHeadSHA != next.mergeQueueEntryHeadSHA) changed = appendChangedField(changed, "merge_queue_estimated_time_to_merge_seconds", !intPtrEqual(tp.MergeQueueEstimatedTimeToMergeSeconds, next.mergeQueueEstimate)) changed = appendChangedField(changed, "merge_queue_last_removal_id", tp.MergeQueueLastRemovalID != next.mergeQueueLastRemovalID) changed = appendChangedField(changed, "merge_queue_last_removed_at", !timeEqual(tp.MergeQueueLastRemovedAt, next.mergeQueueLastRemovedAt)) changed = appendChangedField(changed, "merge_queue_last_removal_reason", tp.MergeQueueLastRemovalReason != next.mergeQueueLastRemovalReason) changed = appendChangedField(changed, "merge_queue_last_removal_before_sha", tp.MergeQueueLastRemovalBeforeSHA != next.mergeQueueLastRemovalBeforeSHA) changed = appendChangedField(changed, "review_count", tp.ReviewCount != next.reviewCount) changed = appendChangedField( changed, "pending_review_count", tp.PendingReviewCount != next.pendingReviewCount, ) changed = appendChangedField( changed, "required_reviews", !intPtrEqual(tp.RequiredReviews, next.requiredReviews), ) changed = appendChangedField(changed, "checks_total", tp.ChecksTotal != next.checksTotal) changed = appendChangedField(changed, "checks_passing", tp.ChecksPassing != next.checksPassing) changed = appendChangedField( changed, "unresolved_review_threads", tp.UnresolvedReviewThreads != next.unresolved, ) changed = appendChangedField(changed, "base_branch", tp.BaseBranch != next.baseBranch) changed = appendChangedField(changed, "merged_at", !timeEqual(tp.MergedAt, next.mergedAt)) changed = appendChangedField(changed, "closed_at", !timeEqual(tp.ClosedAt, next.closedAt)) changed = appendChangedField(changed, "is_draft", !boolPtrEqual(tp.IsDraft, next.isDraft)) changed = appendChangedField(changed, "changed_files", !intPtrEqual(tp.ChangedFiles, next.changedFiles)) changed = appendChangedField(changed, "merged_by_login", !stringPtrEqual(tp.MergedByLogin, next.mergedByLogin)) changed = appendChangedField(changed, "closed_by_login", !stringPtrEqual(tp.ClosedByLogin, next.closedByLogin)) changed = appendChangedField( changed, "auto_merge_observed_at", !timeEqual(tp.AutoMergeObservedAt, next.autoMergeObservedAt), ) return changed } func appendChangedField(changed []string, field string, isChanged bool) []string { if !isChanged { return changed } return append(changed, field) } // 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 reconciliation keeps update intent clear 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 } next := s.prepareTaskPRSyncState(ctx, tp, status) baseBranchChanged := tp.BaseBranch != next.baseBranch && strings.TrimSpace(next.baseBranch) != "" changedFields := taskPRChangedFields(tp, status, next) changed := len(changedFields) > 0 tp.State = next.state tp.HeadSHA = next.headSHA tp.PRTitle = status.PR.Title tp.Additions = status.PR.Additions tp.Deletions = status.PR.Deletions tp.MergedAt = next.mergedAt tp.ClosedAt = next.closedAt tp.ReviewState = status.ReviewState tp.ChecksState = status.ChecksState tp.MergeableState = next.mergeableState tp.MergeQueueState = next.mergeQueueState tp.MergeQueuePosition = next.mergeQueuePosition tp.MergeQueueEntryID = next.mergeQueueEntryID tp.MergeQueueEntryHeadSHA = next.mergeQueueEntryHeadSHA tp.MergeQueueEstimatedTimeToMergeSeconds = next.mergeQueueEstimate tp.MergeQueueLastRemovalID = next.mergeQueueLastRemovalID tp.MergeQueueLastRemovedAt = next.mergeQueueLastRemovedAt tp.MergeQueueLastRemovalReason = next.mergeQueueLastRemovalReason tp.MergeQueueLastRemovalBeforeSHA = next.mergeQueueLastRemovalBeforeSHA tp.ReviewCount = next.reviewCount tp.PendingReviewCount = next.pendingReviewCount tp.RequiredReviews = next.requiredReviews tp.ChecksTotal = next.checksTotal tp.ChecksPassing = next.checksPassing tp.UnresolvedReviewThreads = next.unresolved tp.BaseBranch = next.baseBranch tp.IsDraft = next.isDraft tp.ChangedFiles = next.changedFiles tp.MergedByLogin = next.mergedByLogin tp.ClosedByLogin = next.closedByLogin tp.AutoMergeObservedAt = next.autoMergeObservedAt // CommentCount is no longer updated from polling -- only refreshed on-demand now := time.Now().UTC() tp.LastSyncedAt = &now if len(changedFields) > 0 && s.logger != nil { s.logger.Debug("task PR semantic state changed", zap.String("task_id", taskID), zap.String("pr_owner", tp.Owner), zap.String("pr_repo", tp.Repo), zap.Int("pr_number", tp.PRNumber), zap.Strings("changed_fields", changedFields), ) } return s.persistAndPublishTaskPRSync( ctx, taskID, status.PR, tp, changed, status.OutcomeFieldsPopulated, baseBranchChanged, ) } func (s *Service) propagateTaskRepositoryBaseBranch(ctx context.Context, tp *TaskPR, changed bool) { if !changed || s.taskRepositoryUpdater == nil || tp == nil { return } if err := s.taskRepositoryUpdater.UpdateTaskRepositoryBaseBranch( ctx, tp.TaskID, tp.RepositoryID, tp.HeadBranch, tp.BaseBranch, ); err != nil && s.logger != nil { s.logger.Warn("failed to propagate pull request base branch to task repository", zap.String("task_id", tp.TaskID), zap.String("repository_id", tp.RepositoryID), zap.Int("pr_number", tp.PRNumber), zap.String("base_branch", tp.BaseBranch), zap.Error(err)) } } // persistAndPublishTaskPRSync writes the reconciled sync state and, on // change, publishes github.task_pr.updated. It re-reads the row after the // write rather than publishing tp directly: tp.AutoMergeObservedAt is // computed from THIS call's own (possibly stale-by-the-time-of-write) read // at the top of SyncTaskPR, but UpdateTaskPR persists it through // COALESCE(auto_merge_observed_at, ?), so a concurrent sync that lands its // own write in the gap between this call's read and its write can leave the // column holding a DIFFERENT, earlier timestamp than tp carries in memory. // Publishing tp unmodified would then broadcast a value that never matches // what a subsequent read of the row returns (codex [P2]). Split out of // SyncTaskPR to keep that function within the repo's complexity limits and // to make the re-read-before-publish behavior directly testable. func (s *Service) persistAndPublishTaskPRSync( ctx context.Context, taskID string, pr *PR, tp *TaskPR, changed, outcomeFieldsPopulated, baseBranchChanged bool, ) error { // AC-38/AC-18c: the counter fires at the populated-ness decision point, // before the write is attempted, and survives write failure — it // measures what the sync observed, not whether the store call happened // to succeed. incTaskPROutcomeSync(outcomeFieldsPopulated) if err := s.store.UpdateTaskPR(ctx, tp); err != nil { return fmt.Errorf("update task PR: %w", err) } s.propagateTaskRepositoryBaseBranch(ctx, tp, baseBranchChanged) // Provider payloads carry the authoritative head/base repository identity // and branch. Reconcile after the TaskPR write so a malformed or // unmatchable payload never prevents the review association from persisting. s.reconcileComparisonTargetFromSync(ctx, taskID, pr) if changed && s.eventBus != nil { published, err := s.store.GetTaskPRByID(ctx, tp.ID) if err != nil { s.logger.Debug("failed to re-read task PR before publishing", zap.Error(err)) published = tp } event := bus.NewEvent(events.GitHubTaskPRUpdated, "github", published) 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 } func (s *Service) fetchRequiredReviewsForTaskPR(ctx context.Context, tp *TaskPR, branch string) *int { if tp == nil { return nil } if tp.WorkspaceID == "" { return s.fetchRequiredReviews(ctx, tp.Owner, tp.Repo, branch) } return s.fetchRequiredReviewsForWorkspace(ctx, tp.WorkspaceID, tp.Owner, tp.Repo, branch) } // 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 — the TaskPRs that already exist (e.g. PRs imported via // task-create-from-PR-URL where the watch is optional) have no other // sync path at all, so reconcile them here rather than returning the // stored snapshot verbatim. Empty slice if none. permanent=false (the // task can still acquire a watch later via push detection). existing, listErr := s.reconcileTaskUnwatchedPRs(ctx, taskID, nil) if listErr != nil { return nil, false, listErr } return existing, false, nil } prs, syncErr := s.runBatchedOrPerWatchSync(ctx, taskID, watches) // PRs the task linked from an earlier branch are no longer covered by any // watch — the loop above cannot see them, and without this they keep their // last-observed state (open, green, mergeable) forever. if reconciled, listErr := s.reconcileTaskUnwatchedPRs(ctx, taskID, watches); listErr != nil { s.logger.Debug("unwatched task PR reconciliation failed", zap.String("task_id", taskID), zap.Error(listErr)) } else { prs = reconciled } return prs, s.areAllWatchesPermanentlyMissing(ctx, 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) { workspaceID := watches[0].WorkspaceID if _, batchErr := s.SyncWorkspaceWatchesBatched(ctx, workspaceID, 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(ctx context.Context, watches []*PRWatch) bool { if len(watches) == 0 { return false } for _, w := range watches { resolved, err := s.resolveAutomationClient(ctx, w.WorkspaceID, w.Owner, w.Repo) if err != nil || !s.isRepoCachedAsMissingForScope(resolved.CacheScope, 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)) var syncErrs []error 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)) syncErrs = append(syncErrs, fmt.Errorf("%s/%s#%d: %w", w.Owner, w.Repo, w.PRNumber, syncErr)) continue } if tp != nil { results = append(results, tp) } } if len(syncErrs) == 0 { return results, nil } err := errors.Join(syncErrs...) if len(results) > 0 { return results, &PartialPRSyncError{Err: err} } return results, err } // PartialPRSyncError reports that some PR watches failed while others synced. type PartialPRSyncError struct { Err error } func (e *PartialPRSyncError) Error() string { if e == nil || e.Err == nil { return "partial PR sync failure" } return e.Err.Error() } func (e *PartialPRSyncError) Unwrap() error { if e == nil { return nil } return e.Err } func (s *Service) triggerPRDetection(ctx context.Context, watch *PRWatch, taskID string) (*TaskPR, error) { if watch == nil || strings.TrimSpace(watch.WorkspaceID) == "" { return nil, ErrGitHubWorkspaceRequired } resolved, err := s.resolveAutomationClient(ctx, watch.WorkspaceID, watch.Owner, watch.Repo) if err != nil { return nil, err } // 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 := scopedCacheKey(resolved.CacheScope, "pr-detect:"+watch.ID) v, err, _ := s.syncGroup.Do(key, func() (interface{}, error) { return s.detectPRForWatchOnce(ctx, resolved, 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, resolved *resolvedServiceClient, 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.isRepoCachedAsMissingForScope(resolved.CacheScope, 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 { effectiveTaskID := s.reconcileTaskPROwnership(ctx, watch.SessionID, watch.TaskID, watch.RepositoryID, watch.PRNumber) return s.store.GetTaskPRByRepository(ctx, effectiveTaskID, 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.findPRByBranchInForkNetwork( ctx, resolved.Client, resolved.CacheScope, watch.Owner, watch.Repo, watch.Branch, ) if err != nil && isRepoNotResolvableErr(err) { s.markRepoAsMissingForScope(resolved.CacheScope, 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 rebindErr := s.rebindPRWatchRepository(ctx, watch, pr); rebindErr != nil { return nil, rebindErr } 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.associatePRWithTaskForSession( ctx, watch.WorkspaceID, watch.SessionID, watch.TaskID, watch.RepositoryID, pr, false, false, TaskPRSourceWatch, ); assocErr != nil { s.logger.Error("failed to associate PR with task during sync", zap.String("task_id", watch.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) { if watch == nil || strings.TrimSpace(watch.WorkspaceID) == "" { return nil, ErrGitHubWorkspaceRequired } // taskID is derived from the watch (callers fetch watches by task_id), so // a numbered watch that found its PR before this fix existed still carries // the observing member's task_id — watches are never re-pointed once they // have a PR (see apps/backend/CLAUDE.md's "PR status sync coverage"). // Resolve the effective owner the same way associatePRWithTask does for // the association write, so an on-demand sync keeps landing on the // owner's github_task_prs row instead of silently updating nothing. rawTaskID := taskID if watch.TaskID != "" { rawTaskID = watch.TaskID } taskID = s.reconcileTaskPROwnership(ctx, watch.SessionID, rawTaskID, watch.RepositoryID, watch.PRNumber) resolved, err := s.resolveAutomationClient(ctx, watch.WorkspaceID, watch.Owner, watch.Repo) if err != nil { return nil, err } // Freshness check: skip GitHub API if this exact PR row was recently // synced. Multi-branch tasks can have multiple PRs in the same repo, so a // repo-only lookup would let a fresh sibling suppress this PR's sync. loadTaskPR := func(c context.Context) (*TaskPR, error) { tp, err := s.store.GetTaskPRByRepoAndNumber(c, taskID, watch.RepositoryID, watch.PRNumber) 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. if watch.RepositoryID != "" { return nil, nil } 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.isRepoCachedAsMissingForScope(resolved.CacheScope, watch.Owner, watch.Repo) { return nil, ErrRepoNotResolvable } // Coalesce concurrent syncs for the same PR key := scopedCacheKey(resolved.CacheScope, 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.checkPRWatchWithClient(bgCtx, resolved.Client, watch) if checkErr != nil { if isRepoNotResolvableErr(checkErr) { s.markRepoAsMissingForScope(resolved.CacheScope, watch.Owner, watch.Repo, repoErrGen) return nil, fmt.Errorf("%w: %w", ErrRepoNotResolvable, checkErr) } return nil, checkErr } if status == nil { return loadTaskPR(bgCtx) } if existing, loadErr := loadTaskPR(bgCtx); loadErr != nil { return nil, loadErr } else if existing == nil && status.PR != nil { // Gap-fill a numbered watch whose exact task_pr row was never // created. AssociatePRWithTask publishes the creation event; the // following SyncTaskPR may publish again if status fields changed. // That double event is harmless because clients re-fetch state. if _, assocErr := s.associatePRWithTaskForSession( bgCtx, watch.WorkspaceID, watch.SessionID, rawTaskID, watch.RepositoryID, status.PR, false, false, TaskPRSourceWatch, ); assocErr != nil { return nil, assocErr } } 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 }