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

890 lines
36 KiB
Go

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
}