Files
kandev/apps/backend/internal/github/service_pr_watch.go
T
João Salavisa fd79d6170f feat(tasks): multi-branch tasks — N (repo, branch) pairs per task (#1226)
* feat(tasks): multi-branch tasks — N (repo, branch) pairs per task

Extends multi-repo into multi-branch so a single task can hold multiple
worktrees on the same repo (one per branch). Sibling worktrees layout at
<task>/<repo>/ + <task>/<repo>-<slug>/, dedicated PRWatch per branch,
live agentctl tracker rescan + frontend store updates so the UI splits
PRs / files / commits per branch without a session restart. MCP
add_branch_to_task_kandev tool is shielded to the worktree executor.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(web): unify multi-branch repo label as <repo> · <branch> across panels

PR Changes and Commits panels rendered repo labels differently for multi-branch
tasks — Commits showed "kandev · feature/x" while PR Changes leaked the raw
"kandev-feature-x" agentctl tracker tag. Reformat tracker-tagged repo names via
formatRepoLabel using the longest known-repo prefix so kandev-cli-* doesn't
collapse to kandev · cli-*.

* fix(tasks): address multi-branch PR review — data races, slug collisions, MCP error codes

Backend
- agentctl process.Manager: add repoTrackersMu RWMutex guarding repoTrackers
  + workspaceTracker. Reads snapshot under RLock; RescanRepositories builds
  trackers first, takes Lock for swap/append. Validates newWorkDir via
  os.Stat before publishing cfg.WorkDir.
- worktree.Manager: reject invalid explicit BranchSlug up-front; cache key
  uses sanitized wt.BranchSlug so read/write paths are symmetric.
- worktree.store: narrow isDuplicateColumnError to "duplicate column name".
- task service AddBranchToTask: slug-collision guard rejects feat/a vs feat-a.
- orchestrator resolvePushRepo: two-pass exact-name match before subdir
  prefix fallback so "backend" doesn't swallow pushes for "backend-admin".
- executor buildRepoSpecs: duplicate repos always get a non-empty BranchSlug
  (falls back to branch-N when both branch fields sanitize to empty).
- MCP handler: classifyAddBranchError maps service errors to NOT_FOUND /
  CONFLICT / VALIDATION_ERROR; add ErrorCodeConflict constant.
- MCP server: extract registerAddBranchToTaskTool, register in task mode
  only (external mode contract: config tools + create_task_kandev).
- workspace_rescan handler: reject malformed JSON with 400 instead of
  silently rescanning the current root.
- branch_materializer: split MaterializeBranch into prepare + finalize
  helpers to satisfy the 80-line cap.

Frontend
- existingPrUrl returns undefined when a task has >1 PR so per-repo View PR
  buttons take over (no silent linking to a sibling).
- formatRepoLabel checks exact known-repo match before prefix splitting so
  "kandev-cli" isn't mislabeled as "kandev · cli".
- Repo chip key now includes base_branch so siblings differing only on
  base render as distinct chips.

Docs
- multi-branch spec uniqueness wording aligned with the canonical 4-column
  key (task_id, repository_id, base_branch, checkout_branch).
- worktree.CreateRequest.BranchSlug doc describes the sibling layout
  (<repo>-<slug>) instead of the obsolete nested form.

* fix(tasks): scope slug-collision check to repo only + use errors.Is for ErrTaskNotFound

- service_branches: drop base_branch from the slug-collision check. Worktree
  path is (repo, slug); two rows on the same repo cut from different bases
  still collide on disk.
- service_branches: prepareBranchAdd wraps taskrepo.ErrTaskNotFound via
  fmt.Errorf with %w so classifiers don't have to substring-match the
  formatted task UUID.
- mcp handlers: classifyAddBranchError uses errors.Is(err, ErrTaskNotFound).

* fix(agentctl): sanitize rescan work_dir before os.Stat to clear CodeQL path-injection

CodeQL flagged the rescan endpoint as a path-injection sink (the
HTTP-supplied work_dir flowed straight into os.Stat and downstream
scanRepositorySubdirs). Add validateRescanPath which:
- requires the input to be a clean absolute path with no ".." segments
- rejects anything that isn't equal to or an ancestor of the current
  cfg.WorkDir — the only legitimate use case is the branch materializer
  promoting <task>/<repo>/ to <task>/

Defense in depth: agentctl binds to localhost, but any colocated process
could otherwise direct it at /etc or the user's home by POSTing
/api/v1/workspace/rescan.

* fix(agentctl): tighten validateRescanPath, close TOCTOU on existingTrackers, add tests

- Drop the strings.Contains(clean, "..") guard. filepath.Clean already
  resolves traversal; the substring check rejects legitimate names like
  /home/u/..hidden without adding defense (the ancestor check via
  filepath.Rel is the real boundary).
- Drop the rel == "." branch in the ancestor check — early return for
  clean == currentClean already covers identity.
- Read existingTrackers inside the write-lock section (not under RLock
  then re-Lock) so two concurrent rescans can't both see 0 trackers and
  double-bootstrap the multi-repo set.
- Add table-driven TestValidateRescanPath covering rejection paths
  (relative, descendant, sibling, traversal-cleaned, empty input),
  legitimate ancestor promotion, exact match, no-anchor first-launch,
  and a /home/u/..hidden dotted-segment case.

* fix(agentctl): break taint flow into os.Stat — derive rescan path from cfg, not HTTP body

CodeQL's go/path-injection trace still flagged manager_rescan.go after the
prior sanitizer because the user-supplied newWorkDir flowed (after
validation) into os.Stat and then into cfg.WorkDir → tracker paths →
workspace_monitor os.Stat sinks.

Replace validateRescanPath with resolveRescanPath: it returns a path
DERIVED from currentWorkDir (no-op = currentClean, promotion =
filepath.Dir(currentClean)) and uses newPath only for comparison, never
as a source for the returned string. os.Stat now receives a path whose
provenance is trusted manager config.

Functionally the legitimate caller (branch materializer promoting
<task>/<repo>/ to <task>/) is unchanged.

* test(agentctl): make TestResolveRescanPath windows-portable

The Linux Backend Tests job passed but the Backend (windows) job rejected
every "absolute" case because filepath.IsAbs("/task/repo") is false on
Windows (no volume letter).

Build inputs via filepath.Join against absRoot() (filepath.Abs of "/" /
"\\") so the same table covers POSIX (`/...`) and Windows (`C:\...`)
without per-OS branches. Keep the traversal-after-Clean case POSIX-only
since filepath.Clean is the shared primitive and that input form needs a
volume to be considered absolute on Windows anyway.

* fix(agentctl): serialize RescanRepositories + re-check trackers under write lock

Two concurrent RescanRepositories calls could both observe an empty
tracker set (between the snapshot read and the bootstrap branch) and both
enter transitionToMultiRepoMode, leaking a duplicate bare-root tracker
and duplicate per-repo trackers. Same race in appendNewRepoTrackers would
double-append the same new child across both callers.

Fix:
- Add rescanMu sync.Mutex on Manager. Acquired for the full body of
  RescanRepositories so the snapshot → branch → mutate sequence is
  serialized. repoTrackersMu still guards individual field reads from
  unrelated goroutines (subscribe/unsubscribe/poll-mode).
- appendNewRepoTrackers re-checks membership inside the write-lock
  before appending and drops already-present trackers (Stop + detach
  outside the lock). Defense in depth.
- Test: add `dotted segment is legal as promotion` case so the
  ..hidden-segment promotion path stays exercised.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 19:36:20 +01:00

717 lines
28 KiB
Go

package github
import (
"context"
"fmt"
"strconv"
"strings"
"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) {
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) {
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; the sync is per-task so we only need to
// queue each task once.
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
}
}
}
staleTaskIDs := make([]string, 0, len(staleTasks))
for id := range staleTasks {
staleTaskIDs = append(staleTaskIDs, id)
}
// Background refresh with bounded concurrency
if len(staleTaskIDs) > 0 {
go func() {
sem := make(chan struct{}, 5)
for _, taskID := range staleTaskIDs {
sem <- struct{}{}
go func(id string) {
defer func() { <-sem }()
syncCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if _, syncErr := s.TriggerPRSyncAll(syncCtx, id); syncErr != nil {
s.logger.Debug("background PR sync failed", zap.String("task_id", id), zap.Error(syncErr))
}
}(taskID)
}
}()
}
return result, nil
}
// 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.
func (s *Service) TriggerPRSyncAll(ctx context.Context, taskID string) ([]*TaskPR, error) {
watches, err := s.store.ListPRWatchesByTask(ctx, taskID)
if err != nil {
return nil, 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.
existing, listErr := s.store.ListTaskPRsByTask(ctx, taskID)
if listErr != nil {
return nil, fmt.Errorf("list task PRs: %w", listErr)
}
return existing, nil
}
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) {
// 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)
}
pr, err := s.client.FindPRByBranch(ctx, watch.Owner, watch.Repo, watch.Branch)
// 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
}
}
// 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()
status, _, checkErr := s.CheckPRWatch(bgCtx, watch)
if checkErr != nil {
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
}