diff --git a/packages/loopover-engine/src/scoring/pending-pr-scenarios.ts b/packages/loopover-engine/src/scoring/pending-pr-scenarios.ts index c5f8e3f039..d2c7e70fae 100644 --- a/packages/loopover-engine/src/scoring/pending-pr-scenarios.ts +++ b/packages/loopover-engine/src/scoring/pending-pr-scenarios.ts @@ -139,6 +139,8 @@ export function classifyOpenPullRequest(args: { checks: CheckSummaryRecord[]; duplicateProne?: boolean | undefined; missingTests?: boolean | undefined; + // #8872: injected wall-clock for a deterministic staleness boundary; defaults to Date.now() in daysSince. + nowMs?: number | undefined; }): ClassifiedOpenPullRequest { const reasons: string[] = []; if (args.roleContext.maintainerLane) { @@ -159,7 +161,7 @@ export function classifyOpenPullRequest(args: { const checkFailureCount = args.checks.filter( (check) => check.conclusion === "failure" || check.conclusion === "timed_out" || check.conclusion === "cancelled", ).length; - const ageDays = daysSince(args.pr.updatedAt ?? args.pr.createdAt); + const ageDays = daysSince(args.pr.updatedAt ?? args.pr.createdAt, args.nowMs); if (args.duplicateProne) reasons.push("Overlapping open work detected in the same repo (possible duplicate or WIP collision)."); if (args.missingTests) reasons.push("Cached file list shows code changes without matching test files."); @@ -210,11 +212,14 @@ function sameLogin(value: string | null | undefined, login: string): boolean { return Boolean(value && value.toLowerCase() === login.toLowerCase()); } -function daysSince(value: string | null | undefined): number { +// #8872: nowMs is injectable (defaulting only here, at the call boundary) so classifyOpenPullRequest's +// staleness split is deterministic and testable at exact day boundaries, matching this module's own +// "pure classification/detection logic" contract and the nowMs injection its opportunity-freshness sibling uses. +function daysSince(value: string | null | undefined, nowMs = Date.now()): number { if (!value) return Number.POSITIVE_INFINITY; const parsed = Date.parse(value); if (!Number.isFinite(parsed)) return Number.POSITIVE_INFINITY; - return Math.max(0, (Date.now() - parsed) / 86_400_000); + return Math.max(0, (nowMs - parsed) / 86_400_000); } function nonNegative(value: number | undefined): number { diff --git a/test/unit/pending-pr-scenarios.test.ts b/test/unit/pending-pr-scenarios.test.ts index 6127d4fbab..788219d971 100644 --- a/test/unit/pending-pr-scenarios.test.ts +++ b/test/unit/pending-pr-scenarios.test.ts @@ -99,6 +99,22 @@ describe("pending PR scenario detection", () => { expect(classified.reasons.join(" ")).toMatch(/failing or cancelled check/i); }); + // #8872: with an injected nowMs the staleness split is deterministic and testable at the exact 14-day + // boundary — an approved, passing-checks PR flips from merge_ready to stale_likely_close at ageDays >= 14. + it("splits merge_ready vs stale_likely_close at the exact 14-day boundary with an injected nowMs (#8872)", () => { + const nowMs = Date.parse("2026-07-01T00:00:00.000Z"); + const staleMs = 14 * 86_400_000; + const base = { roleContext: outsideContributorRole, reviews: [approvedReview(1)], checks: [], nowMs }; + // exactly 14 days old → ageDays >= STALE_DAYS → stale_likely_close. + expect( + classifyOpenPullRequest({ ...base, pr: pr({ number: 1, updatedAt: new Date(nowMs - staleMs).toISOString() }) }).classification, + ).toBe("stale_likely_close"); + // one millisecond short of 14 days → still merge_ready. + expect( + classifyOpenPullRequest({ ...base, pr: pr({ number: 1, updatedAt: new Date(nowMs - staleMs + 1).toISOString() }) }).classification, + ).toBe("merge_ready"); + }); + it("counts stale approved PRs as pending closes and projects the post-cleanup open count", () => { const staleDate = new Date(Date.now() - 30 * 86_400_000).toISOString(); const detection = detectPendingPrScenario({