fix(github): heal legacy PR watches so single-repo tasks don't dupe PRs (#785)

After the multi-repo migration, pre-existing github_pr_watches rows kept
repository_id='', so the orchestrator's reconciler (keyed on
(session_id, repository_id)) inserted a second watch under the resolved
id. Both watches polled independently and each created its own
github_task_prs row, surfacing as a "+2 PR" badge on legacy single-repo
tasks. Add a startup backfill mirroring backfillTaskPRsRepositoryID:
dedup the legacy '' rows where a per-repo row exists, then stamp the
remainder from task_repositories.

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
João Salavisa
2026-05-02 22:36:58 +01:00
committed by GitHub
parent 1394b7103f
commit d8a9bb79d5
2 changed files with 165 additions and 0 deletions
+57
View File
@@ -169,6 +169,9 @@ func (s *Store) initSchema() error {
if err := s.backfillTaskPRsRepositoryID(); err != nil {
return fmt.Errorf("backfill github_task_prs.repository_id: %w", err)
}
if err := s.backfillPRWatchesRepositoryID(); err != nil {
return fmt.Errorf("backfill github_pr_watches.repository_id: %w", err)
}
return nil
}
@@ -233,6 +236,60 @@ func (s *Store) backfillTaskPRsRepositoryID() error {
return nil
}
// backfillPRWatchesRepositoryID heals github_pr_watches rows that pre-date
// the per-repo schema (repository_id = ”). Same two-pass shape as
// backfillTaskPRsRepositoryID — without this the orchestrator's reconciler
// (which keys its existence-check by (session_id, repository_id)) would see
// the legacy `(sess, ”)` row as foreign and insert a SECOND watch row
// under the resolved repository_id. Two watches → two AssociatePRWithTask
// calls when the user opens a PR → two github_task_prs rows for the same
// PR, which is the "PR appears twice on a single-repo task" symptom we hit
// after the multi-repo rollout.
//
// 1. Dedup: drop legacy `”` rows whose session already has a non-empty
// row — the reconciler-inserted row supersedes the legacy one.
//
// 2. Backfill: stamp the remaining `”` rows with the task's primary
// repository_id from `task_repositories`. Skipped silently when the
// table is absent (unit tests that init only this package's schema).
//
// Idempotent: re-running on a healed db is a no-op.
func (s *Store) backfillPRWatchesRepositoryID() error {
if _, err := s.db.Exec(`
DELETE FROM github_pr_watches
WHERE repository_id = ''
AND EXISTS (
SELECT 1 FROM github_pr_watches other
WHERE other.session_id = github_pr_watches.session_id
AND other.repository_id != ''
)
`); err != nil {
return fmt.Errorf("dedup legacy PR watch rows: %w", err)
}
if !s.tableExists("task_repositories") {
return nil
}
_, err := s.db.Exec(`
UPDATE github_pr_watches
SET repository_id = (
SELECT tr.repository_id
FROM task_repositories tr
WHERE tr.task_id = github_pr_watches.task_id
ORDER BY tr.position
LIMIT 1
)
WHERE repository_id = ''
AND EXISTS (
SELECT 1 FROM task_repositories tr
WHERE tr.task_id = github_pr_watches.task_id
)
`)
if err != nil {
return fmt.Errorf("backfill PR watch repository_id: %w", err)
}
return nil
}
// tableExists returns true when the named table is present in sqlite_master.
// Used by the multi-repo backfill to skip cross-package healing in unit
// tests that don't bring up the task schema.