Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions packages/loopover-engine/src/scoring/pending-pr-scenarios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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.");
Expand Down Expand Up @@ -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 {
Expand Down
14 changes: 14 additions & 0 deletions test/unit/pending-pr-scenarios.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,20 @@ 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 };
expect(
classifyOpenPullRequest({ ...base, pr: pr({ number: 1, updatedAt: new Date(nowMs - staleMs).toISOString() }) }).classification,
).toBe("stale_likely_close");
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({
Expand Down