From b30d9fcbc51673c915fe5676bed9f071de4210e8 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:40:19 -0700 Subject: [PATCH] feat(selfhost): give sweep-watchdog and PR-reconciliation a per-repo opt-out Both crons only had a global LOOPOVER_SWEEP_WATCHDOG / LOOPOVER_PR_RECONCILIATION kill-switch with no repo-level control. Add review.sweepWatchdog and review.prReconciliation (bool | null, force-off-only, mirroring review.selftune's established shape) so an operator can exclude one repo from either scan via .loopover.yml without touching the global flag. A manifest-load error fails open (the repo stays watched), matching each scan's existing settings-blip fail-safe. --- .loopover.yml.example | 14 +++++ config/examples/loopover.full.yml | 14 +++++ .../loopover-engine/src/focus-manifest.ts | 41 +++++++++++-- src/review/pr-reconciliation.ts | 17 +++++- src/review/sweep-watchdog.ts | 17 +++++- test/unit/focus-manifest.test.ts | 4 +- test/unit/pr-reconciliation.test.ts | 55 +++++++++++++++++ test/unit/sweep-watchdog.test.ts | 60 +++++++++++++++++++ 8 files changed, 213 insertions(+), 9 deletions(-) diff --git a/.loopover.yml.example b/.loopover.yml.example index 722c5068f4..2e48ec9367 100644 --- a/.loopover.yml.example +++ b/.loopover.yml.example @@ -509,6 +509,20 @@ review: # null/true -- no change to today's agent-configured-repos-only behavior. # selftune: false + # Per-repo FORCE-OFF for the sweep-liveness watchdog cron pass (#6275) -- `false` excludes this repo from the + # watchdog's scan even though it's otherwise watched (convergence-allowlisted, or agent-configured with a real + # installation) and the operator's LOOPOVER_SWEEP_WATCHDOG flag is on. Deliberately FORCE-OFF-ONLY (no `true` + # override), mirroring `selftune` above: forcing a repo the watchdog wouldn't otherwise watch INTO the scan + # would bypass that separate repo-selection boundary. Bool or null. Default: null/true -- no change to today's + # watched-repo-set behavior. + # sweepWatchdog: false + + # Per-repo FORCE-OFF for the fast open-PR reconciliation cron pass (#6275) -- `false` excludes this repo from + # the reconciliation scan even though it's otherwise watched and the operator's LOOPOVER_PR_RECONCILIATION + # flag is on. Deliberately FORCE-OFF-ONLY (no `true` override), mirroring `selftune`/`sweepWatchdog` above. + # Bool or null. Default: null/true -- no change to today's watched-repo-set behavior. + # prReconciliation: false + # Repeat-false-positive suppression (#2179, part of #1964). Bool | null. Default: null/false — byte-identical # (no suppression-store read, no matching). Also requires the operator's LOOPOVER_REVIEW_MEMORY env flag to # be on -- this manifest field alone cannot enable it. When both are on, an advisory (non-blocking) AI finding diff --git a/config/examples/loopover.full.yml b/config/examples/loopover.full.yml index 09608d9545..8f6763555f 100644 --- a/config/examples/loopover.full.yml +++ b/config/examples/loopover.full.yml @@ -523,6 +523,20 @@ review: # null/true -- no change to today's agent-configured-repos-only behavior. # selftune: false + # Per-repo FORCE-OFF for the sweep-liveness watchdog cron pass (#6275) -- `false` excludes this repo from the + # watchdog's scan even though it's otherwise watched (convergence-allowlisted, or agent-configured with a real + # installation) and the operator's LOOPOVER_SWEEP_WATCHDOG flag is on. Deliberately FORCE-OFF-ONLY (no `true` + # override), mirroring `selftune` above: forcing a repo the watchdog wouldn't otherwise watch INTO the scan + # would bypass that separate repo-selection boundary. Bool or null. Default: null/true -- no change to today's + # watched-repo-set behavior. + # sweepWatchdog: false + + # Per-repo FORCE-OFF for the fast open-PR reconciliation cron pass (#6275) -- `false` excludes this repo from + # the reconciliation scan even though it's otherwise watched and the operator's LOOPOVER_PR_RECONCILIATION + # flag is on. Deliberately FORCE-OFF-ONLY (no `true` override), mirroring `selftune`/`sweepWatchdog` above. + # Bool or null. Default: null/true -- no change to today's watched-repo-set behavior. + # prReconciliation: false + # Repeat-false-positive suppression (#2179, part of #1964). Bool | null. Default: null/false — byte-identical # (no suppression-store read, no matching). Also requires the operator's LOOPOVER_REVIEW_MEMORY env flag to # be on -- this manifest field alone cannot enable it. When both are on, an advisory (non-blocking) AI finding diff --git a/packages/loopover-engine/src/focus-manifest.ts b/packages/loopover-engine/src/focus-manifest.ts index ce6cb93600..e0bdb12594 100644 --- a/packages/loopover-engine/src/focus-manifest.ts +++ b/packages/loopover-engine/src/focus-manifest.ts @@ -242,7 +242,11 @@ export type CopycatGateMode = "off" | "warn" | "label" | "block"; // ALSO deliberately lives outside this block, as its own top-level `review.selftune` field below — it has no // `LOOPOVER_REVIEW_REPOS` allowlist to fall back to (its own repo scoping is `isAgentConfigured`, a // different consent boundary), so it doesn't fit this resolver's env-kill-switch → override → allowlist- -// default shape; see `selfTuneRepos` in `review/selftune-wire.ts`. `e2eTests` (#4190, part of the #4189 +// default shape; see `selfTuneRepos` in `review/selftune-wire.ts`. `sweepWatchdog` and `prReconciliation` +// (#6275) live outside this block for the identical reason: each is a FORCE-OFF-ONLY per-repo override on a +// cron pass whose own repo-selection is the convergence allowlist union `isAgentConfigured` (mirroring +// `selftune`'s shape one-for-one, just two separate cron passes instead of one) — see `watchedRepos` in +// `review/sweep-watchdog.ts` and `review/pr-reconciliation.ts` respectively. `e2eTests` (#4190, part of the #4189 // E2E-test-generation epic) fits this shape exactly as a plain symmetric override — unlike `safety`/ // `grounding` it has no force-on-only or force-off-only floor/ceiling, since AI-generated test content // carries no security-hardening or full-file-fetch rationale to protect from a repo-controlled override. @@ -583,6 +587,23 @@ export type FocusManifestReviewConfig = { * `features:` block/`resolveConvergedFeature` (see `CONVERGED_FEATURE_KEYS`'s own comment). null/true * (default, absent) ⇒ no change to today's agent-configured-repos-only behavior. */ selftune: boolean | null; + /** `review.sweepWatchdog` (#6275): explicit per-repo FORCE-OFF for the sweep-liveness watchdog cron pass + * (`runSweepLivenessWatchdog`, `src/review/sweep-watchdog.ts`) — `false` excludes this repo from the + * watchdog's scan even though it's otherwise watched (convergence-allowlisted, or agent-configured with a + * real installation) and the global `LOOPOVER_SWEEP_WATCHDOG` kill-switch is on. Deliberately + * FORCE-OFF-ONLY (no `true` override), mirroring `selftune` immediately above: forcing a repo the watchdog + * wouldn't otherwise watch INTO the scan would bypass that separate repo-selection boundary (the + * convergence allowlist / `isAgentConfigured` consent), which this key must not touch. A manifest-load + * error fails OPEN (the repo stays watched), matching the surrounding scan's existing settings-blip + * fail-safe. null/true (default, absent) ⇒ no change to today's watched-repo-set behavior. */ + sweepWatchdog: boolean | null; + /** `review.prReconciliation` (#6275): explicit per-repo FORCE-OFF for the fast open-PR reconciliation cron + * pass (`runOpenPrReconciliation`, `src/review/pr-reconciliation.ts`) — `false` excludes this repo from + * the reconciliation scan even though it's otherwise watched and the global `LOOPOVER_PR_RECONCILIATION` + * kill-switch is on. Deliberately FORCE-OFF-ONLY (no `true` override), mirroring `selftune`/`sweepWatchdog` + * above, for the identical reason. A manifest-load error fails OPEN (the repo stays watched). null/true + * (default, absent) ⇒ no change to today's watched-repo-set behavior. */ + prReconciliation: boolean | null; /** `review.memory` (#2179, config slice of #1964): when true, gates repeat-false-positive SUPPRESSION — * before an advisory (non-blocking) AI finding is surfaced in the unified review comment, it is matched * against this repo's stored `review_suppression` signals (a maintainer's own past false-positive @@ -1084,7 +1105,7 @@ const EMPTY_MANIFEST: FocusManifest = { publicNotes: [], gate: { ...EMPTY_GATE_CONFIG }, settings: {}, - review: { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, fixHandoff: null, autoMergeSummary: null, suggestions: null, changedFilesSummary: null, effortScore: null, impactMap: null, cultureProfile: null, selftune: null, reviewMemory: null, findingCategories: null, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { ...EMPTY_MAX_FINDINGS_CONFIG }, commentVerbosity: null, e2eTestDelivery: null, e2eTestAutoTrigger: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null, sharedConfigSource: null }, + review: { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, fixHandoff: null, autoMergeSummary: null, suggestions: null, changedFilesSummary: null, effortScore: null, impactMap: null, cultureProfile: null, selftune: null, sweepWatchdog: null, prReconciliation: null, reviewMemory: null, findingCategories: null, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { ...EMPTY_MAX_FINDINGS_CONFIG }, commentVerbosity: null, e2eTestDelivery: null, e2eTestAutoTrigger: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null, sharedConfigSource: null }, features: { ...EMPTY_FEATURES_CONFIG }, experimental: { ...EMPTY_EXPERIMENTAL_CONFIG }, contentLane: { ...EMPTY_CONTENT_LANE_CONFIG }, @@ -1116,7 +1137,7 @@ function emptyManifest(source: FocusManifestSource, warnings: string[] = []): Fo warnings, gate: { ...EMPTY_GATE_CONFIG }, settings: {}, - review: { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, fixHandoff: null, autoMergeSummary: null, suggestions: null, changedFilesSummary: null, effortScore: null, impactMap: null, cultureProfile: null, selftune: null, reviewMemory: null, findingCategories: null, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { ...EMPTY_MAX_FINDINGS_CONFIG }, commentVerbosity: null, e2eTestDelivery: null, e2eTestAutoTrigger: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null, sharedConfigSource: null }, + review: { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, fixHandoff: null, autoMergeSummary: null, suggestions: null, changedFilesSummary: null, effortScore: null, impactMap: null, cultureProfile: null, selftune: null, sweepWatchdog: null, prReconciliation: null, reviewMemory: null, findingCategories: null, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { ...EMPTY_MAX_FINDINGS_CONFIG }, commentVerbosity: null, e2eTestDelivery: null, e2eTestAutoTrigger: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null, sharedConfigSource: null }, features: { ...EMPTY_FEATURES_CONFIG }, experimental: { ...EMPTY_EXPERIMENTAL_CONFIG }, contentLane: { ...EMPTY_CONTENT_LANE_CONFIG }, @@ -2309,7 +2330,7 @@ function parsePublicSafeText(value: JsonValue | undefined, field: string, warnin * throws; invalid/unsafe values are dropped with warnings. */ function parseReviewConfig(value: JsonValue | undefined, warnings: string[]): FocusManifestReviewConfig { - const empty: FocusManifestReviewConfig = { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, fixHandoff: null, autoMergeSummary: null, suggestions: null, changedFilesSummary: null, effortScore: null, impactMap: null, cultureProfile: null, selftune: null, reviewMemory: null, findingCategories: null, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { ...EMPTY_MAX_FINDINGS_CONFIG }, commentVerbosity: null, e2eTestDelivery: null, e2eTestAutoTrigger: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null, sharedConfigSource: null }; + const empty: FocusManifestReviewConfig = { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, fixHandoff: null, autoMergeSummary: null, suggestions: null, changedFilesSummary: null, effortScore: null, impactMap: null, cultureProfile: null, selftune: null, sweepWatchdog: null, prReconciliation: null, reviewMemory: null, findingCategories: null, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { ...EMPTY_MAX_FINDINGS_CONFIG }, commentVerbosity: null, e2eTestDelivery: null, e2eTestAutoTrigger: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null, sharedConfigSource: null }; if (value === undefined || value === null) return empty; if (typeof value !== "object" || Array.isArray(value)) { warnings.push(`Manifest field "review" must be a mapping; ignoring it.`); @@ -2354,6 +2375,8 @@ function parseReviewConfig(value: JsonValue | undefined, warnings: string[]): Fo const impactMap = normalizeOptionalBoolean(r.impact_map, "review.impact_map", warnings); const cultureProfile = normalizeOptionalBoolean(r.culture_profile, "review.culture_profile", warnings); const selftune = normalizeOptionalBoolean(r.selftune, "review.selftune", warnings); + const sweepWatchdog = normalizeOptionalBoolean(r.sweepWatchdog, "review.sweepWatchdog", warnings); + const prReconciliation = normalizeOptionalBoolean(r.prReconciliation, "review.prReconciliation", warnings); const reviewMemory = normalizeOptionalBoolean(r.memory, "review.memory", warnings); const findingCategories = normalizeOptionalBoolean(r.finding_categories, "review.finding_categories", warnings); const inlineCommentsPerCategory = normalizeOptionalNonNegativeInt( @@ -2396,6 +2419,8 @@ function parseReviewConfig(value: JsonValue | undefined, warnings: string[]): Fo impactMap !== null || cultureProfile !== null || selftune !== null || + sweepWatchdog !== null || + prReconciliation !== null || reviewMemory !== null || findingCategories !== null || inlineCommentsPerCategory !== null || @@ -2435,6 +2460,8 @@ function parseReviewConfig(value: JsonValue | undefined, warnings: string[]): Fo impactMap, cultureProfile, selftune, + sweepWatchdog, + prReconciliation, reviewMemory, findingCategories, inlineCommentsPerCategory, @@ -2534,6 +2561,8 @@ function computeReviewConfigPresent(review: Omit> { const repositoriesByKey = new Map((await listRepositories(env)).map((repo) => [repo.fullName.toLowerCase(), repo])); const byKey = new Map(); @@ -49,7 +58,11 @@ async function watchedRepos(env: Env): Promise null); + if (manifest?.review.prReconciliation === false) continue; // explicit per-repo opt-out (#6275) + configured.push(repo); } catch { /* a settings blip on one repo must not abort the whole reconciliation scan */ } diff --git a/src/review/sweep-watchdog.ts b/src/review/sweep-watchdog.ts index 7b451f9a46..882df93104 100644 --- a/src/review/sweep-watchdog.ts +++ b/src/review/sweep-watchdog.ts @@ -14,6 +14,7 @@ import { countOpenPullRequests, getLatestRegatedAt, listRepositories } from "../db/repositories"; import { isAgentConfigured } from "../settings/autonomy"; import { resolveRepositorySettings } from "../settings/repository-settings"; +import { loadRepoFocusManifest } from "../signals/focus-manifest-loader"; import type { JobMessage } from "../types"; import { errorMessage, nowIso } from "../utils/json"; import { isConvergenceRepoAllowed, listConvergenceRepos } from "./cutover-gate"; @@ -40,7 +41,15 @@ export function isSweepStale(input: { openPullRequestCount: number; lastRegatedA /** The same acting-autonomy repo set fanOutAgentRegateSweepJobs sweeps: the convergence allowlist * (LOOPOVER_REVIEW_REPOS) union the webhook-registered repos with acting autonomy, deduped case-insensitively. * Deliberately mirrors that function's own selection so the watchdog can never watch a DIFFERENT set of repos - * than the sweep actually covers. */ + * than the sweep actually covers. + * + * Per-repo opt-out (#6275): mirrors `selftune-wire.ts`'s `selfTuneRepos` FORCE-OFF-ONLY shape exactly -- an + * explicit per-repo `.loopover.yml` `review.sweepWatchdog: false` excludes that one repo from the watchdog + * scan even though it's otherwise watched. There is no `true` override: forcing a repo the scan wouldn't + * otherwise watch INTO it would bypass the separate convergence-allowlist / acting-autonomy consent boundary + * above, which this key must not touch. Unset (the default) changes nothing. A manifest-load error fails OPEN + * (the repo stays watched), matching the surrounding settings-blip fail-safe below -- a config-read failure + * must never silently exclude a repo from monitoring. */ async function watchedRepos(env: Env): Promise> { const repositoriesByKey = new Map((await listRepositories(env)).map((repo) => [repo.fullName.toLowerCase(), repo])); const byKey = new Map(); @@ -61,7 +70,11 @@ async function watchedRepos(env: Env): Promise null); + if (manifest?.review.sweepWatchdog === false) continue; // explicit per-repo opt-out (#6275) + configured.push(repo); } catch { /* a settings blip on one repo must not abort the whole watchdog scan */ } diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index 7a03389de7..32772f3084 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -389,6 +389,8 @@ describe(".loopover.yml.example field-exhaustiveness (#1670)", () => { impactMap: "impact_map:", cultureProfile: "culture_profile:", selftune: "selftune:", + sweepWatchdog: "sweepWatchdog:", + prReconciliation: "prReconciliation:", reviewMemory: "memory:", findingCategories: "finding_categories:", inlineCommentsPerCategory: "inline_comments_per_category:", @@ -887,7 +889,7 @@ describe("compileFocusManifestPolicy", () => { publicNotes: ["Keep PRs focused.", "Maximize your reward payout"], gate: { present: false, enabled: null, checkMode: null, pack: null, linkedIssue: null, duplicates: null, readinessMode: null, readinessMinScore: null, slopMode: null, slopMinScore: null, slopAiAdvisory: null, sizeMode: null, sizeMaxFiles: null, sizeMaxLines: null, lockfileIntegrityMode: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, aiReviewAllAuthors: null, aiReviewCloseConfidence: null, aiReviewLowConfidenceDisposition: null, aiReviewCombine: null, aiReviewOnMerge: null, aiReviewReviewers: null, mergeReadiness: null, selfAuthoredLinkedIssue: null, linkedIssueSatisfaction: null, manifestPolicy: null, dryRun: null, firstTimeContributorGrace: null, premergeContentRecheck: null, requireFreshRebaseWindowMinutes: null, claMode: null, claConsentPhrase: null, claCheckRunName: null, claCheckRunAppSlug: null, expectedCiContexts: null, advisoryCheckRuns: null, aiJudgmentBlockersMode: null, copycatMode: null, copycatMinScore: null }, settings: {}, - review: { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, fixHandoff: null, autoMergeSummary: null, suggestions: null, changedFilesSummary: null, effortScore: null, impactMap: null, cultureProfile: null, selftune: null, reviewMemory: null, findingCategories: null, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { blockers: null, nits: null }, commentVerbosity: null, e2eTestDelivery: null, e2eTestAutoTrigger: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null, sharedConfigSource: null }, + review: { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, fixHandoff: null, autoMergeSummary: null, suggestions: null, changedFilesSummary: null, effortScore: null, impactMap: null, cultureProfile: null, selftune: null, sweepWatchdog: null, prReconciliation: null, reviewMemory: null, findingCategories: null, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { blockers: null, nits: null }, commentVerbosity: null, e2eTestDelivery: null, e2eTestAutoTrigger: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null, sharedConfigSource: null }, features: { present: false, rag: null, reputation: null, safety: null, grounding: null, e2eTests: null, screenshots: null, improvementSignal: null }, experimental: { present: false, gittensor: null }, contentLane: { present: false, entryFileGlob: null, providerFileGlob: null, artifactGlob: null, collectionField: null, maxAppendedEntries: null, duplicateKeyFields: [], validatorId: null }, diff --git a/test/unit/pr-reconciliation.test.ts b/test/unit/pr-reconciliation.test.ts index 27b7ab4800..c0ec929fd7 100644 --- a/test/unit/pr-reconciliation.test.ts +++ b/test/unit/pr-reconciliation.test.ts @@ -4,6 +4,8 @@ import { getPullRequest, upsertRepositoryFromGitHub, upsertRepositorySettings } import * as backfillModule from "../../src/github/backfill"; import * as repositoriesModule from "../../src/db/repositories"; import { counterValue, resetMetrics } from "../../src/selfhost/metrics"; +import { upsertRepoFocusManifest } from "../../src/signals/focus-manifest-loader"; +import * as focusManifestLoaderModule from "../../src/signals/focus-manifest-loader"; import { createTestEnv } from "../helpers/d1"; describe("isPrReconciliationEnabled — default OFF, truthy convention", () => { @@ -155,3 +157,56 @@ describe("runOpenPrReconciliation (#audit-open-pr-reconciliation)", () => { expect(errors.mock.calls.some((call) => String(call[0]).includes("open_pr_reconciliation_catch_up_failed") && String(call[0]).includes("owner/send-fails"))).toBe(true); }); }); + +describe("watchedRepos — per-repo review.prReconciliation FORCE-OFF (#6275)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("REGRESSION: an explicit review.prReconciliation: false excludes an otherwise-watched repo from the scan entirely", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "opted-out", full_name: "owner/opted-out", private: false, owner: { login: "owner" } }, 9410); + await upsertRepositorySettings(env, { repoFullName: "owner/opted-out", autonomy: { merge: "auto" } }); + await upsertRepoFocusManifest(env, "owner/opted-out", { review: { prReconciliation: false } }); + const reconcileSpy = vi.spyOn(backfillModule, "reconcileOpenPullRequests"); + + const found = await runOpenPrReconciliation(env); + + expect(found).toEqual([]); + expect(reconcileSpy).not.toHaveBeenCalled(); + }); + + it("an explicit review.prReconciliation: true is a no-op — the repo is watched exactly as when unset", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "opted-in", full_name: "owner/opted-in", private: false, owner: { login: "owner" } }, 9411); + await upsertRepositorySettings(env, { repoFullName: "owner/opted-in", autonomy: { merge: "auto" } }); + await upsertRepoFocusManifest(env, "owner/opted-in", { review: { prReconciliation: true } }); + const reconcileSpy = vi.spyOn(backfillModule, "reconcileOpenPullRequests").mockResolvedValueOnce({ repoFullName: "owner/opted-in", remoteOpenCount: 0, localOpenCount: 0, missingNumbers: [] }); + + await runOpenPrReconciliation(env); + + expect(reconcileSpy).toHaveBeenCalledWith(env, "owner/opted-in"); + }); + + it("fails OPEN on a manifest-load error — the repo stays watched (a config-read failure must never silently exclude it from monitoring)", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "manifest-errors", full_name: "owner/manifest-errors", private: false, owner: { login: "owner" } }, 9412); + await upsertRepositorySettings(env, { repoFullName: "owner/manifest-errors", autonomy: { merge: "auto" } }); + // resolveRepositorySettings ALSO loads the manifest internally (settings resolution needs it too) -- let + // that first call succeed normally so this test isolates the SECOND, opt-out-check call (watchedRepos's + // own explicit loadRepoFocusManifest) as the one that fails. + const original = focusManifestLoaderModule.loadRepoFocusManifest; + let callCount = 0; + vi.spyOn(focusManifestLoaderModule, "loadRepoFocusManifest").mockImplementation(async (...args: Parameters) => { + callCount += 1; + if (callCount === 1) return original(...args); + throw new Error("manifest fetch failed"); + }); + const reconcileSpy = vi.spyOn(backfillModule, "reconcileOpenPullRequests").mockResolvedValueOnce({ repoFullName: "owner/manifest-errors", remoteOpenCount: 0, localOpenCount: 0, missingNumbers: [] }); + + await runOpenPrReconciliation(env); + + expect(callCount).toBeGreaterThanOrEqual(2); // both the settings-resolution load AND the opt-out-check load ran + expect(reconcileSpy).toHaveBeenCalledWith(env, "owner/manifest-errors"); + }); +}); diff --git a/test/unit/sweep-watchdog.test.ts b/test/unit/sweep-watchdog.test.ts index 8d81b820c0..e9fc19508c 100644 --- a/test/unit/sweep-watchdog.test.ts +++ b/test/unit/sweep-watchdog.test.ts @@ -2,6 +2,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { isSweepStale, isSweepWatchdogEnabled, runSweepLivenessWatchdog, SWEEP_STALENESS_THRESHOLD_MS } from "../../src/review/sweep-watchdog"; import { markPullRequestsRegated, upsertPullRequestFromGitHub, upsertRepositoryFromGitHub, upsertRepositorySettings } from "../../src/db/repositories"; import * as repositoriesModule from "../../src/db/repositories"; +import { upsertRepoFocusManifest } from "../../src/signals/focus-manifest-loader"; +import * as focusManifestLoaderModule from "../../src/signals/focus-manifest-loader"; import { createTestEnv } from "../helpers/d1"; describe("isSweepWatchdogEnabled — default OFF, truthy convention", () => { @@ -188,3 +190,61 @@ describe("runSweepLivenessWatchdog (#audit-sweep-fanout-isolation follow-up)", ( expect(errors.mock.calls.some((call) => String(call[0]).includes("sweep_liveness_reenqueue_failed") && String(call[0]).includes("owner/send-fails"))).toBe(true); }); }); + +describe("watchedRepos — per-repo review.sweepWatchdog FORCE-OFF (#6275)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("REGRESSION: an explicit review.sweepWatchdog: false excludes an otherwise-watched, stale repo from the scan entirely", async () => { + const sent: import("../../src/types").JobMessage[] = []; + const env = createTestEnv({ JOBS: { async send(m: import("../../src/types").JobMessage) { sent.push(m); } } as unknown as Queue }); + await upsertRepositoryFromGitHub(env, { name: "opted-out", full_name: "owner/opted-out", private: false, owner: { login: "owner" } }, 9309); + await upsertRepositorySettings(env, { repoFullName: "owner/opted-out", autonomy: { merge: "auto" } }); + await upsertPullRequestFromGitHub(env, "owner/opted-out", { number: 1, title: "PR1", state: "open", user: { login: "c" }, head: { sha: "a1" }, labels: [], body: "" }); + await upsertRepoFocusManifest(env, "owner/opted-out", { review: { sweepWatchdog: false } }); + + const found = await runSweepLivenessWatchdog(env); + + expect(found).toEqual([]); + expect(sent).toEqual([]); + }); + + it("an explicit review.sweepWatchdog: true is a no-op — the repo is watched exactly as when unset", async () => { + const sent: import("../../src/types").JobMessage[] = []; + const env = createTestEnv({ JOBS: { async send(m: import("../../src/types").JobMessage) { sent.push(m); } } as unknown as Queue }); + await upsertRepositoryFromGitHub(env, { name: "opted-in", full_name: "owner/opted-in", private: false, owner: { login: "owner" } }, 9310); + await upsertRepositorySettings(env, { repoFullName: "owner/opted-in", autonomy: { merge: "auto" } }); + await upsertPullRequestFromGitHub(env, "owner/opted-in", { number: 1, title: "PR1", state: "open", user: { login: "c" }, head: { sha: "a1" }, labels: [], body: "" }); + await upsertRepoFocusManifest(env, "owner/opted-in", { review: { sweepWatchdog: true } }); + + const found = await runSweepLivenessWatchdog(env); + + expect(found).toEqual([expect.objectContaining({ repoFullName: "owner/opted-in" })]); + expect(sent).toEqual([expect.objectContaining({ repoFullName: "owner/opted-in" })]); + }); + + it("fails OPEN on a manifest-load error — the repo stays watched (a config-read failure must never silently exclude it from monitoring)", async () => { + const sent: import("../../src/types").JobMessage[] = []; + const env = createTestEnv({ JOBS: { async send(m: import("../../src/types").JobMessage) { sent.push(m); } } as unknown as Queue }); + await upsertRepositoryFromGitHub(env, { name: "manifest-errors", full_name: "owner/manifest-errors", private: false, owner: { login: "owner" } }, 9311); + await upsertRepositorySettings(env, { repoFullName: "owner/manifest-errors", autonomy: { merge: "auto" } }); + await upsertPullRequestFromGitHub(env, "owner/manifest-errors", { number: 1, title: "PR1", state: "open", user: { login: "c" }, head: { sha: "a1" }, labels: [], body: "" }); + // resolveRepositorySettings ALSO loads the manifest internally (settings resolution needs it too) -- let + // that first call succeed normally so this test isolates the SECOND, opt-out-check call (watchedRepos's + // own explicit loadRepoFocusManifest) as the one that fails. + const original = focusManifestLoaderModule.loadRepoFocusManifest; + let callCount = 0; + vi.spyOn(focusManifestLoaderModule, "loadRepoFocusManifest").mockImplementation(async (...args: Parameters) => { + callCount += 1; + if (callCount === 1) return original(...args); + throw new Error("manifest fetch failed"); + }); + + const found = await runSweepLivenessWatchdog(env); + + expect(callCount).toBeGreaterThanOrEqual(2); // both the settings-resolution load AND the opt-out-check load ran + expect(found).toEqual([expect.objectContaining({ repoFullName: "owner/manifest-errors" })]); + expect(sent).toEqual([expect.objectContaining({ repoFullName: "owner/manifest-errors" })]); + }); +});