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
5 changes: 4 additions & 1 deletion src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6190,7 +6190,10 @@ export async function listRecentSignalSnapshotsForTargets(
): Promise<Map<string, SignalSnapshotRecord[]>> {
const result = new Map<string, SignalSnapshotRecord[]>();
if (targetKeys.length === 0) return result;
const perTargetLimit = Math.max(1, Math.min(maxPerTarget, 100));
// #10020 / #9699: allow caps large enough for multi-week daily snapshot series (e.g. QUEUE_TREND_SNAPSHOT_LIMIT
// = 140). The previous hard 100 matched listSignalSnapshots' latest-row backstop and re-truncated time-bounded
// history that needed more than 100 in-window rows.
const perTargetLimit = Math.max(1, Math.min(maxPerTarget, 500));
// The time bound (#9699) is applied INSIDE the windowed subquery so row_number() ranks over the in-window
// set, not the whole table — otherwise a repo with many recent snapshots could rank its cap entirely within
// the last few days and never surface the older weeks the trend card needs. maxPerTarget stays the backstop.
Expand Down
18 changes: 15 additions & 3 deletions src/queue/signal-snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,18 @@ import {
listRecentMergedPullRequests,
listRepoGithubTotalsSnapshotHistory,
listRepoLabels,
listRecentSignalSnapshotsForTargets,
listRepositories,
listSignalSnapshots,
persistSignalSnapshot,
replaceCollisionEdges,
upsertRepoQueueTrendSnapshot,
} from "../db/repositories";
import { computeRepoOutcomePatterns, REPO_OUTCOME_PATTERNS_SIGNAL } from "../services/repo-outcome-patterns";
import { buildQueueTrendReport, QUEUE_TREND_HISTORY_DAYS } from "../services/queue-trends";
import {
buildQueueTrendReport,
QUEUE_TREND_HISTORY_DAYS,
QUEUE_TREND_SNAPSHOT_LIMIT,
} from "../services/queue-trends";
import {
buildCollisionEdges,
buildCollisionReport,
Expand Down Expand Up @@ -119,7 +123,15 @@ async function generateSignalSnapshotForRepo(
sinceIso: trendSince,
limit: 120,
}),
listSignalSnapshots(env, "queue-health", repo.fullName),
// #10020: same time window as the totals half — listSignalSnapshots' hard 100-row cap otherwise keeps
// only ~25 days at 4 rows/day and the 30-day trend window never resolves a queue-health baseline.
listRecentSignalSnapshotsForTargets(
env,
"queue-health",
[repo.fullName],
QUEUE_TREND_SNAPSHOT_LIMIT,
trendSince,
).then((byTarget) => byTarget.get(repo.fullName) ?? []),
]);
const collisions = buildCollisionReport(
repo.fullName,
Expand Down
3 changes: 3 additions & 0 deletions src/services/queue-trends.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { nowIso } from "../utils/json";

const QUEUE_TREND_WINDOWS_DAYS = [7, 14, 30] as const;
export const QUEUE_TREND_HISTORY_DAYS = 35;
/** Per-repo row backstop for the queue-health history read (#10020). Sized for ≥4 snapshots/day across
* `QUEUE_TREND_HISTORY_DAYS` so the time bound (`trendSince`) is the primary constraint. */
export const QUEUE_TREND_SNAPSHOT_LIMIT = QUEUE_TREND_HISTORY_DAYS * 4;

export type QueueTrendWindow = {
windowDays: 7 | 14 | 30;
Expand Down
8 changes: 7 additions & 1 deletion test/unit/queue-trends.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,20 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import * as repositoriesModule from "../../src/db/repositories";
import { getRepoQueueTrendSnapshot, persistRepoGithubTotalsSnapshot, persistSignalSnapshot, upsertPullRequestFromGitHub, upsertRepositoryFromGitHub } from "../../src/db/repositories";
import { generateSignalSnapshots } from "../../src/queue/processors";
import { buildQueueTrendReport, buildUnavailableQueueTrendReport, type QueueTrendReport } from "../../src/services/queue-trends";
import { buildQueueTrendReport, buildUnavailableQueueTrendReport, QUEUE_TREND_HISTORY_DAYS, QUEUE_TREND_SNAPSHOT_LIMIT, type QueueTrendReport } from "../../src/services/queue-trends";
import type { RepoGithubTotalsSnapshotRecord } from "../../src/types";
import { createTestEnv } from "../helpers/d1";

describe("queue trend windows", () => {
afterEach(() => {
vi.restoreAllMocks();
});

it("keeps QUEUE_TREND_SNAPSHOT_LIMIT sized for four rows/day across the history window (#10020)", () => {
expect(QUEUE_TREND_HISTORY_DAYS).toBeGreaterThanOrEqual(30);
expect(QUEUE_TREND_SNAPSHOT_LIMIT).toBeGreaterThanOrEqual(QUEUE_TREND_HISTORY_DAYS * 4);
});

it("builds deterministic 7/14/30-day queue pressure and review velocity windows", () => {
const report = buildQueueTrendReport({
repoFullName: "owner/repo",
Expand Down
137 changes: 137 additions & 0 deletions test/unit/signal-snapshot-queue-trend-history.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
getRepoQueueTrendSnapshot,
persistRepoGithubTotalsSnapshot,
persistSignalSnapshot,
upsertPullRequestFromGitHub,
upsertRepositoryFromGitHub,
} from "../../src/db/repositories";
import { generateSignalSnapshots } from "../../src/queue/processors";
import type { QueueTrendReport } from "../../src/services/queue-trends";
import type { RepoGithubTotalsSnapshotRecord } from "../../src/types";
import { createTestEnv } from "../helpers/d1";

const REPO = "owner/trend-history";
const FIXTURE_NOW_MS = Date.parse("2026-07-31T12:00:00.000Z");

describe("signal-snapshot queue-trend history window (#10020)", () => {
beforeEach(() => {
vi.useFakeTimers({ now: FIXTURE_NOW_MS });
});

afterEach(() => {
vi.useRealTimers();
});

it("REGRESSION: time-bounded queue-health history lets the 30-day window resolve duplicate and stale deltas", async () => {
const env = createTestEnv();
await upsertRepositoryFromGitHub(
env,
{ name: "trend-history", full_name: REPO, private: false, owner: { login: "owner" }, default_branch: "main" },
801,
);
await env.DB.prepare("update repositories set is_registered = 1 where full_name = ?").bind(REPO).run();
await upsertPullRequestFromGitHub(env, REPO, {
number: 1,
title: "Open fix",
state: "open",
user: { login: "miner" },
author_association: "NONE",
labels: [],
body: "Fixes #1",
created_at: atDaysAgo(40),
updated_at: atDaysAgo(0),
});

// 130 queue-health rows across ~33 days at four/day — under the old listSignalSnapshots(limit 100)
// only ~25 days remained and the 30-day baseline stayed null.
let id = 0;
for (let day = 32; day >= 0; day -= 1) {
for (let slot = 0; slot < 4; slot += 1) {
if (id >= 130) break;
const daysAgo = day + slot / 4;
await persistSignalSnapshot(env, {
id: `qh-${id}`,
signalType: "queue-health",
targetKey: REPO,
repoFullName: REPO,
generatedAt: atDaysAgo(daysAgo),
payload: {
signals: {
openPullRequests: 10 + Math.floor(daysAgo),
stalePullRequests: 1 + Math.floor(daysAgo / 10),
collisionClusters: 1 + Math.floor((32 - day) / 8),
},
},
});
id += 1;
}
}

for (const daysAgo of [33, 30, 14, 7, 0]) {
await persistRepoGithubTotalsSnapshot(env, totals(daysAgo, {
openIssues: 10 + daysAgo,
openPrs: 4 + Math.floor(daysAgo / 5),
merged: 20 - Math.floor(daysAgo / 3),
closed: 5,
}));
}

await generateSignalSnapshots(env, REPO);

const snapshot = await getRepoQueueTrendSnapshot(env, REPO);
const report = snapshot?.payload as unknown as QueueTrendReport;
const window30 = report?.windows.find((window) => window.windowDays === 30);
expect(window30).toMatchObject({
status: "ready",
duplicateTrend: expect.any(Number),
stalePullRequestRateDelta: expect.any(Number),
});
expect(window30?.duplicateTrend).not.toBeNull();
expect(window30?.stalePullRequestRateDelta).not.toBeNull();
});

it("a repo with no queue-health history still persists a trend (map-miss ?? [] arm) with unavailable windows when totals are missing", async () => {
const env = createTestEnv();
await upsertRepositoryFromGitHub(
env,
{ name: "empty-history", full_name: "owner/empty-history", private: false, owner: { login: "owner" }, default_branch: "main" },
802,
);

await generateSignalSnapshots(env, "owner/empty-history");

const snapshot = await getRepoQueueTrendSnapshot(env, "owner/empty-history");
const report = snapshot?.payload as unknown as QueueTrendReport;
expect(report).toMatchObject({
status: "unavailable",
windows: [
expect.objectContaining({ windowDays: 7, status: "unavailable" }),
expect.objectContaining({ windowDays: 14, status: "unavailable" }),
expect.objectContaining({ windowDays: 30, status: "unavailable" }),
],
});
});
});

function totals(
daysAgo: number,
values: { openIssues: number; openPrs: number; merged: number; closed: number },
): RepoGithubTotalsSnapshotRecord {
return {
id: `totals-${daysAgo}-${REPO}`,
repoFullName: REPO,
openIssuesTotal: values.openIssues,
openPullRequestsTotal: values.openPrs,
mergedPullRequestsTotal: values.merged,
closedUnmergedPullRequestsTotal: values.closed,
labelsTotal: 0,
sourceKind: "test",
fetchedAt: atDaysAgo(daysAgo),
payload: {},
};
}

function atDaysAgo(daysAgo: number): string {
return new Date(FIXTURE_NOW_MS - daysAgo * 24 * 60 * 60 * 1000).toISOString();
}