diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 501a25edd5..9534cb3d21 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -1279,6 +1279,13 @@ export async function getOpenUpstreamDriftReportByFingerprint(env: Env, fingerpr return row ? toUpstreamDriftReportRecord(row) : null; } +/** Lookup a drift report by its stable fingerprint regardless of status (resolved reports included). */ +export async function getUpstreamDriftReportByFingerprint(env: Env, fingerprint: string): Promise { + const db = getDb(env.DB); + const [row] = await db.select().from(upstreamDriftReports).where(eq(upstreamDriftReports.fingerprint, fingerprint)).limit(1); + return row ? toUpstreamDriftReportRecord(row) : null; +} + export async function persistScorePreview(env: Env, preview: ScorePreviewRecord): Promise { const db = getDb(env.DB); await db.insert(scorePreviews).values({ diff --git a/src/upstream/unmodeled-scoring-drift.ts b/src/upstream/unmodeled-scoring-drift.ts index 71005c8fac..b9c52f7576 100644 --- a/src/upstream/unmodeled-scoring-drift.ts +++ b/src/upstream/unmodeled-scoring-drift.ts @@ -1,6 +1,6 @@ import { getLatestUpstreamRulesetSnapshot, - listUpstreamDriftReports, + getUpstreamDriftReportByFingerprint, upsertUpstreamDriftReport, } from "../db/repositories"; import type { UpstreamDriftArea, UpstreamDriftReportRecord, UpstreamDriftSeverity } from "../types"; @@ -23,7 +23,10 @@ export async function syncUnmodeledScoringConstantDrift( }, ): Promise { const fingerprint = await unmodeledScoringConstantsFingerprint(); - const existing = (await listUpstreamDriftReports(env, 50)).find((report) => report.fingerprint === fingerprint) ?? null; + // Key by fingerprint directly — `listUpstreamDriftReports` is capped and ordered by `updatedAt`, so an older + // unmodeled-constants report can fall off the newest-50 window and be treated as missing (duplicate open reports, + // lost issue metadata, resolve no-ops). + const existing = (await getUpstreamDriftReportByFingerprint(env, fingerprint)) ?? null; const now = nowIso(); if (args.unmodeledConstants.length === 0) { diff --git a/test/unit/unmodeled-scoring-drift.test.ts b/test/unit/unmodeled-scoring-drift.test.ts index 8d24e5977e..d3a5f6dd40 100644 --- a/test/unit/unmodeled-scoring-drift.test.ts +++ b/test/unit/unmodeled-scoring-drift.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { listUpstreamDriftReports, updateUpstreamDriftReportIssue } from "../../src/db/repositories"; +import { listUpstreamDriftReports, updateUpstreamDriftReportIssue, upsertUpstreamDriftReport } from "../../src/db/repositories"; import { syncUnmodeledScoringConstantDrift, unmodeledScoringConstantsFingerprint } from "../../src/upstream/unmodeled-scoring-drift"; import { createTestEnv } from "../helpers/d1"; @@ -102,4 +102,53 @@ describe("unmodeled scoring constant drift", () => { expect(report?.summary).toMatch(/, …$/); expect(report?.severity).toBe("high"); }); + + it("looks up the stable unmodeled-constants fingerprint even when it falls off the newest-50 drift list", async () => { + const env = createTestEnv(); + const fingerprint = await unmodeledScoringConstantsFingerprint(); + const opened = await syncUnmodeledScoringConstantDrift(env, { unmodeledConstants: ["ALPHA"] }); + expect(opened?.fingerprint).toBe(fingerprint); + + // Push the unmodeled report out of `listUpstreamDriftReports(env, 50)`'s recency window and seed linked-issue + // metadata directly on the row — do NOT call `updateUpstreamDriftReportIssue` here; that helper rewrites + // `updatedAt` to now and would put the row back inside the capped list, defeating the regression. + await upsertUpstreamDriftReport(env, { + ...opened!, + updatedAt: "2020-01-01T00:00:00.000Z", + issueNumber: 811, + issueUrl: "https://github.com/JSONbored/gittensory/issues/811", + }); + for (let index = 0; index < 51; index++) { + await upsertUpstreamDriftReport(env, { + id: `newer-${index}`, + fingerprint: `newer-drift-${index}`, + severity: "low", + status: "open", + summary: `newer drift ${index}`, + affectedAreas: ["registry"], + previousRulesetId: null, + currentRulesetId: null, + issueNumber: null, + issueUrl: null, + payload: { changes: ["noop"] }, + generatedAt: "2026-06-30T00:00:00.000Z", + updatedAt: `2026-06-30T${String(index).padStart(2, "0")}:00:00.000Z`, + }); + } + expect((await listUpstreamDriftReports(env, 50)).some((report) => report.fingerprint === fingerprint)).toBe(false); + + const updated = await syncUnmodeledScoringConstantDrift(env, { unmodeledConstants: ["ALPHA", "BETA"] }); + expect(updated).toMatchObject({ + id: opened!.id, + fingerprint, + status: "open", + issueNumber: 811, + issueUrl: "https://github.com/JSONbored/gittensory/issues/811", + payload: expect.objectContaining({ unmodeledUpstreamConstants: ["ALPHA", "BETA"] }), + }); + + await upsertUpstreamDriftReport(env, { ...updated!, updatedAt: "2020-01-01T00:00:00.000Z" }); + const resolved = await syncUnmodeledScoringConstantDrift(env, { unmodeledConstants: [] }); + expect(resolved).toMatchObject({ id: opened!.id, fingerprint, status: "resolved" }); + }); });