diff --git a/packages/das/src/entities/PullRequest.entity.ts b/packages/das/src/entities/PullRequest.entity.ts index 905ba2f..9ee2cf9 100644 --- a/packages/das/src/entities/PullRequest.entity.ts +++ b/packages/das/src/entities/PullRequest.entity.ts @@ -35,8 +35,9 @@ export class PullRequest { @Column({ name: "merged_at", type: "timestamptz", nullable: true }) mergedAt: string; + // ISO string on write, Date on read (TypeORM hydrates timestamptz). @Column({ name: "updated_at", type: "timestamptz", nullable: true }) - updatedAt: string | null; + updatedAt: Date | string | null; @Column({ name: "last_edited_at", type: "timestamptz", nullable: true }) lastEditedAt: string | null; diff --git a/packages/das/src/webhook/incremental-backfill.spec.ts b/packages/das/src/webhook/incremental-backfill.spec.ts index 1751913..6d12376 100644 --- a/packages/das/src/webhook/incremental-backfill.spec.ts +++ b/packages/das/src/webhook/incremental-backfill.spec.ts @@ -69,4 +69,41 @@ describe("needsMetadataRefresh", () => { it("re-fetches when GitHub returned a null updatedAt", () => { expect(needsMetadataRefresh(stored(), null)).toBe(true); }); + + // Stored side is a hydrated Date, incoming is GitHub's ISO string. + it("skips when a hydrated Date equals GitHub's ISO string (same instant)", () => { + expect( + needsMetadataRefresh( + stored({ updatedAt: new Date("2026-06-01T00:00:00Z") }), + "2026-06-01T00:00:00Z", + ), + ).toBe(false); + }); + + it("skips across timezone-equivalent representations of the same instant", () => { + expect( + needsMetadataRefresh( + stored({ updatedAt: new Date("2026-06-01T00:00:00Z") }), + "2026-05-31T19:00:00-05:00", + ), + ).toBe(false); + }); + + it("re-fetches when a hydrated Date is a different instant", () => { + expect( + needsMetadataRefresh( + stored({ updatedAt: new Date("2026-06-01T00:00:00Z") }), + "2026-06-02T00:00:00Z", + ), + ).toBe(true); + }); + + it("re-fetches (fails safe) on an unparseable stored value", () => { + expect( + needsMetadataRefresh( + stored({ updatedAt: "not-a-date" }), + "2026-06-01T00:00:00Z", + ), + ).toBe(true); + }); }); diff --git a/packages/das/src/webhook/incremental-backfill.ts b/packages/das/src/webhook/incremental-backfill.ts index defdf2b..54f9ca3 100644 --- a/packages/das/src/webhook/incremental-backfill.ts +++ b/packages/das/src/webhook/incremental-backfill.ts @@ -5,7 +5,8 @@ export interface StoredPrState { headSha: string | null; baseSha: string | null; - updatedAt: string | null; + // Date from TypeORM (timestamptz), string in tests. + updatedAt: Date | string | null; scoringDataStored: boolean; } @@ -23,16 +24,20 @@ export function needsContentRefresh( ); } -// updatedAt bumps on edits, state changes, merges, closes and link changes, so -// skip the PR_METADATA fetch only when it matches the stored value. +// Normalise a Date or ISO string to an epoch-ms instant; null if unparseable. +function toEpochMs(value: Date | string | null): number | null { + if (value == null) return null; + const ms = + value instanceof Date ? value.getTime() : new Date(value).getTime(); + return Number.isNaN(ms) ? null : ms; +} + +// Skip the PR_METADATA fetch only when GitHub's updatedAt instant is unchanged. export function needsMetadataRefresh( stored: StoredPrState | null | undefined, updatedAt: string | null, ): boolean { - return !( - stored != null && - stored.updatedAt != null && - updatedAt != null && - stored.updatedAt === updatedAt - ); + const storedMs = stored ? toEpochMs(stored.updatedAt) : null; + const incomingMs = toEpochMs(updatedAt); + return !(storedMs != null && incomingMs != null && storedMs === incomingMs); }