From 9d01186791b7bcc9b18dc5f33355c9a73903ad81 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 6 Jul 2026 05:30:46 -0700 Subject: [PATCH] fix(review): elect duplicate-cluster winner by true PR creation time (#3816) linkedIssueClaimedAt is gittensory's own sync-observation time, not GitHub's real pull_request.created_at. When processing falls out of creation order (a stalled sweep catching up on a backlog, backfill reordering, delayed webhook delivery), the winner election could crown whichever PR gittensory happened to observe first instead of whoever actually opened their PR first, silently mis-crediting a later contributor over an earlier one. Thread GitHub's true creation time (already persisted in payloadJson, already surfaced by toPullRequestRecordFromRow, just never wired into the election) into isDuplicateClusterWinnerByClaim, preferring it over claim time whenever both sides of a comparison have a valid one and falling back to the legacy claim-time comparison unchanged otherwise. Also name the actual winning PR number in a loser's close comment (#3817) instead of the generic "duplicate of another open PR" wording, so a closed contributor can verify their work wasn't silently discarded. --- src/db/repositories.ts | 5 + src/queue/processors.ts | 43 +++++++- src/settings/agent-actions.ts | 11 ++- src/signals/duplicate-winner.ts | 65 ++++++++++-- src/types.ts | 7 +- test/unit/agent-actions.test.ts | 13 +++ test/unit/duplicate-winner.test.ts | 154 ++++++++++++++++++++++++++++- 7 files changed, 279 insertions(+), 19 deletions(-) diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 7fe6d345a4..2ecf070cb5 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -5256,6 +5256,11 @@ function toPullRequestRecord(repoFullName: string, pr: GitHubPullRequestPayload) mergeableState: pr.mergeable_state ?? pr.mergeableState ?? mergeableBooleanState(pr.mergeable), reviewDecision: pr.reviewDecision, body: pr.body, + // GitHub's true PR-creation time (#dup-winner true-creation-time). Already persisted into payloadJson via + // compactGitHubPayload below and re-surfaced correctly by toPullRequestRecordFromRow on any later read — this + // populates it on the IMMEDIATE upsert return too, so a caller acting on this same call's result (not a + // subsequent DB round-trip) sees the same value instead of `undefined`. + createdAt: pr.created_at, labels: (pr.labels ?? []).flatMap((label) => (label.name ? [label.name] : [])), linkedIssues: extractLinkedIssueNumbers(pr.body ?? ""), }; diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 6ac7c2f40b..794e80f760 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -334,7 +334,7 @@ import { PR_PANEL_RETRIGGER_MARKER, type ContributorProfile, } from "../signals/engine"; -import { isDuplicateClusterWinnerByClaim } from "../signals/duplicate-winner"; +import { isDuplicateClusterWinnerByClaim, resolveDuplicateClusterWinnerNumber } from "../signals/duplicate-winner"; import { buildUnifiedReviewDiff, totalAddedLineCount } from "../review/review-diff"; import { estimateReviewEffort } from "../review/review-effort"; import { buildUnifiedCommentBody } from "../review/unified-comment-bridge"; @@ -2768,6 +2768,8 @@ async function runAgentMaintenancePlanAndExecute( const approvalsSatisfied = autoMaintain.requireApprovals === 0 || (liveReviewDecision ?? pr.reviewDecision) === "APPROVED"; + const duplicateWinnerEnabled = env.GITTENSORY_DUPLICATE_WINNER === "true"; + const openDuplicateSiblings = linkedIssueDuplicatePullRequestRecordsForGate(pr, otherOpenPullRequests); const planned = planAgentMaintenanceActions({ conclusion: gate.conclusion, blockerTitles: gate.blockers.map((blocker) => blocker.title), @@ -2822,10 +2824,21 @@ async function runAgentMaintenancePlanAndExecute( // (it can still close on its own merits — CI/conflict/blockers). Flag-OFF short-circuits ⇒ the real // count is used (byte-identical). Sparse legacy rows fail closed so duplicate evidence remains visible. linkedDuplicateCount: dupWinnerLinkedDuplicateCount( - linkedIssueDuplicatePullRequestRecordsForGate(pr, otherOpenPullRequests), + openDuplicateSiblings, pr.number, pr.linkedIssueClaimedAt, - env.GITTENSORY_DUPLICATE_WINNER === "true", + duplicateWinnerEnabled, + pr.createdAt, + ), + // #dup-winner-credit: name the cluster's actual winner in a loser's close comment instead of a generic + // "duplicate of another open PR". `null` (flag off, this PR IS the winner, or an ambiguous election) + // falls back to the pre-existing generic wording in agent-actions.ts, byte-identical to before this existed. + linkedDuplicateWinnerNumber: dupWinnerLinkedDuplicateWinnerNumber( + openDuplicateSiblings, + pr.number, + pr.linkedIssueClaimedAt, + duplicateWinnerEnabled, + pr.createdAt, ), headSha: pr.headSha, mergeBlockedSha: pr.mergeBlockedSha, @@ -7322,19 +7335,39 @@ export async function runAiSlopForAdvisory( * when count > 0). Flag-OFF (default) returns the real sibling count — byte-identical to today. */ export function dupWinnerLinkedDuplicateCount( - openSiblings: Pick[], + openSiblings: Pick[], prNumber: number, linkedIssueClaimedAt: string | null | undefined, duplicateWinnerEnabled: boolean, + createdAt?: string | null | undefined, ): number { if ( duplicateWinnerEnabled && - isDuplicateClusterWinnerByClaim({ number: prNumber, linkedIssueClaimedAt }, openSiblings) + isDuplicateClusterWinnerByClaim({ number: prNumber, linkedIssueClaimedAt, createdAt }, openSiblings) ) return 0; return openSiblings.length; } +/** + * Duplicate-winner adjudication (#dup-winner-credit) seam for naming the cluster's actual winner in a loser's + * close comment. Returns `null` (generic "duplicate of another open PR" wording, byte-identical to before this + * existed) when the flag is off, this PR IS the winner (nothing to name — its close reason omits the cause + * entirely via {@link dupWinnerLinkedDuplicateCount}), or the election is too ambiguous to name a specific + * winner ({@link resolveDuplicateClusterWinnerNumber}'s fail-closed `null`). + */ +export function dupWinnerLinkedDuplicateWinnerNumber( + openSiblings: Pick[], + prNumber: number, + linkedIssueClaimedAt: string | null | undefined, + duplicateWinnerEnabled: boolean, + createdAt?: string | null | undefined, +): number | null { + if (!duplicateWinnerEnabled) return null; + const winner = resolveDuplicateClusterWinnerNumber({ number: prNumber, linkedIssueClaimedAt, createdAt }, openSiblings); + return winner === null || winner === prNumber ? null : winner; +} + /** * Live-reconcile the duplicate cluster's open siblings before the winner is elected (#dup-winner / audit #15). * diff --git a/src/settings/agent-actions.ts b/src/settings/agent-actions.ts index 69dbb457fe..eabb73c6e1 100644 --- a/src/settings/agent-actions.ts +++ b/src/settings/agent-actions.ts @@ -328,6 +328,10 @@ export type AgentActionPlanInput = { slopRisk?: number | null | undefined; labels: string[]; linkedDuplicateCount?: number | undefined; + // #dup-winner-credit: the elected winner's PR number, when the election is confident enough to name one (see + // dupWinnerLinkedDuplicateWinnerNumber). Only read below when linkedDuplicateCount > 0; null/absent falls + // back to the pre-existing generic "duplicate of another open PR" wording. + linkedDuplicateWinnerNumber?: number | null | undefined; // RC3 terminal-fail merges: the live head SHA + the SHA at which a prior merge was terminally blocked // (perms/required-check/conflict). When they match, the merge can't complete for this commit → suppress it. headSha?: string | null | undefined; @@ -1085,7 +1089,12 @@ export function planAgentMaintenanceActions(input: AgentActionPlanInput): Planne if (isConflict) closeReasons.push("conflicts with the base branch — resolve and open a fresh PR"); for (const blockerTitle of input.blockerTitles) closeReasons.push(blockerTitle); if (input.pr.slopRisk != null && input.pr.slopRisk >= slopGateMinScore) closeReasons.push(`slop score ${input.pr.slopRisk} ≥ ${slopGateMinScore}`); - if ((input.pr.linkedDuplicateCount ?? 0) > 0) closeReasons.push("duplicate of another open PR"); + if ((input.pr.linkedDuplicateCount ?? 0) > 0) + closeReasons.push( + input.pr.linkedDuplicateWinnerNumber != null + ? `duplicate of open PR #${input.pr.linkedDuplicateWinnerNumber}` + : "duplicate of another open PR", + ); if (closeReasons.length === 0) closeReasons.push("the review gate is not satisfied"); // Tagged "heuristic": a verdict-driven close (gate-verdict / duplicate / slop / CI). The close-precision // breaker downgrades this to a hold when close precision has dropped — UNLESS it is also backed by concrete, diff --git a/src/signals/duplicate-winner.ts b/src/signals/duplicate-winner.ts index 5c5e128868..9de8f9f496 100644 --- a/src/signals/duplicate-winner.ts +++ b/src/signals/duplicate-winner.ts @@ -3,14 +3,23 @@ * * When several OPEN PRs link the same issue (a duplicate cluster), the legacy behavior gate-blocks + * auto-closes EVERY sibling as a duplicate — no winner survives. With the flag ON, exactly ONE winner is - * spared: the earliest observed linked-issue claimant. Sparse legacy rows that do not yet have claim timing - * fail closed so unknown ordering cannot arbitrarily suppress duplicate evidence. Only the LOSERS are - * blocked/closed; the winner still must pass CI / conflict / gate / linked-issue / slop on its OWN merits. + * spared: the earliest claimant. Sparse legacy rows that do not yet have claim timing fail closed so unknown + * ordering cannot arbitrarily suppress duplicate evidence. Only the LOSERS are blocked/closed; the winner + * still must pass CI / conflict / gate / linked-issue / slop on its OWN merits. * * This module is PURE — no IO, no Date, no random — so the same inputs always yield the same verdict and the * caller can compute the winner ONCE per review run and thread the result boolean consistently into every * surface (advisory finding, close reason, slop, panels), so they agree by construction. * + * ELECTION ORDER (#dup-winner true-creation-time): prefer each PR's true GitHub `pull_request.created_at` — + * the real order contributors opened their PRs in — over `linkedIssueClaimedAt` (gittensory's own sync-time, + * i.e. whenever a webhook/sweep/backfill pass happened to OBSERVE the linked issue). Sync order and creation + * order diverge whenever processing isn't strictly FIFO (a stalled sweep catching up on a backlog, backfill + * reordering, webhook delivery delay), under the old claim-time-only rule, that divergence could crown a + * LATER contributor the winner and close the PR of whoever actually opened first. `createdAt` is compared + * only when BOTH sides of a given comparison have a valid one; otherwise this falls back to the legacy + * claim-time comparison unchanged, so sparse/legacy rows keep their existing fail-closed behavior exactly. + * * INVARIANT (the caller MUST honor it): {@link openSiblingNumbers} carries OPEN-only sibling PR numbers. The * existing sources already exclude closed/merged PRs. Once the winner closes (e.g. red CI), it leaves the open * set and the next-earliest OPEN claimant becomes the winner on re-eval — no permanently-orphaned cluster. @@ -19,6 +28,8 @@ export type DuplicateClaimMember = { number: number; linkedIssueClaimedAt?: string | null | undefined; + /** GitHub's true PR creation time. See the module doc's "ELECTION ORDER" note. */ + createdAt?: string | null | undefined; }; /** @@ -37,20 +48,54 @@ export function isDuplicateClusterWinner(prNumber: number, openSiblingNumbers: n } /** - * True iff `pr` is the earliest known linked-issue claimant in the open duplicate cluster. Sparse legacy rows - * fail closed; ties between known claim times use PR number. + * True iff `pr` is the earliest-elected claimant in the open duplicate cluster (see the module doc's + * "ELECTION ORDER" note for the createdAt-vs-claim-time precedence). Sparse legacy rows fail closed; ties + * between equally-ordered members use PR number. */ export function isDuplicateClusterWinnerByClaim(pr: DuplicateClaimMember, openSiblings: DuplicateClaimMember[]): boolean { if (openSiblings.length === 0) return true; + for (const sibling of openSiblings) { + if (!prPrecedesSibling(pr, sibling)) return false; + } + return true; +} + +/** + * True iff `pr` is ordered at or ahead of `sibling` for cluster-winner purposes. Prefers `createdAt` when BOTH + * sides have a valid one (the true creation-time order); otherwise falls back to the legacy `linkedIssueClaimedAt` + * comparison unchanged (including its fail-closed-on-missing/invalid-timestamp behavior), so a mixed + * legacy/modern cluster never silently guesses using two different clocks for the two sides of one comparison. + */ +function prPrecedesSibling(pr: DuplicateClaimMember, sibling: DuplicateClaimMember): boolean { + const prCreated = claimTimeMs(pr.createdAt); + const siblingCreated = claimTimeMs(sibling.createdAt); + if (prCreated !== null && siblingCreated !== null) { + if (prCreated !== siblingCreated) return prCreated < siblingCreated; + return pr.number <= sibling.number; + } const prClaim = claimTimeMs(pr.linkedIssueClaimedAt); if (prClaim === null) return false; + const siblingClaim = claimTimeMs(sibling.linkedIssueClaimedAt); + if (siblingClaim === null) return false; + if (siblingClaim < prClaim) return false; + if (siblingClaim === prClaim && sibling.number < pr.number) return false; + return true; +} + +/** + * The winning PR number among `pr` and its open duplicate siblings, or `null` when the election is not + * determinable (mirrors {@link isDuplicateClusterWinnerByClaim}'s fail-closed semantics — this never guesses a + * specific winner when the ordering data is too sparse/ambiguous to be sure). Used only for DISPLAY (naming the + * winner in a loser's close comment, #dup-winner-credit) — the close/hold decision for any given PR is still + * driven directly by {@link isDuplicateClusterWinnerByClaim}, not by this function's return value. + */ +export function resolveDuplicateClusterWinnerNumber(pr: DuplicateClaimMember, openSiblings: DuplicateClaimMember[]): number | null { + if (isDuplicateClusterWinnerByClaim(pr, openSiblings)) return pr.number; for (const sibling of openSiblings) { - const siblingClaim = claimTimeMs(sibling.linkedIssueClaimedAt); - if (siblingClaim === null) return false; - if (siblingClaim < prClaim) return false; - if (siblingClaim === prClaim && sibling.number < pr.number) return false; + const rest = openSiblings.filter((other) => other.number !== sibling.number); + if (isDuplicateClusterWinnerByClaim(sibling, [pr, ...rest])) return sibling.number; } - return true; + return null; } function claimTimeMs(value: string | null | undefined): number | null { diff --git a/src/types.ts b/src/types.ts index 96f6060905..8eb7824418 100644 --- a/src/types.ts +++ b/src/types.ts @@ -476,11 +476,16 @@ export type PullRequestRecord = { mergeableState?: string | null | undefined; reviewDecision?: string | null | undefined; body?: string | null | undefined; + /** GitHub's own PR creation time (`pull_request.created_at`) — the ground-truth order contributors actually + * opened their PRs in, independent of when gittensory's own webhook/sweep pipeline happened to observe or + * process this PR. NOT the same as {@link linkedIssueClaimedAt} (gittensory's own sync-time). Preferred for + * duplicate-cluster winner election when present on both sides being compared (#dup-winner). */ createdAt?: string | null | undefined; updatedAt?: string | null | undefined; closedAt?: string | null | undefined; /** First time Gittensory observed this PR claiming one or more linked issues. Used to elect same-issue - * duplicate winners by claim order instead of PR number. */ + * duplicate winners by claim order instead of PR number ONLY when {@link createdAt} is unavailable on either + * side of a comparison. */ linkedIssueClaimedAt?: string | null | undefined; labels: string[]; linkedIssues: number[]; diff --git a/test/unit/agent-actions.test.ts b/test/unit/agent-actions.test.ts index d5f9d46af8..cddb63f216 100644 --- a/test/unit/agent-actions.test.ts +++ b/test/unit/agent-actions.test.ts @@ -359,6 +359,19 @@ describe("planAgentMaintenanceActions (#778)", () => { expect(winnerClose.reason).not.toContain("duplicate of another open PR"); }); + it("#dup-winner-credit: names the actual winning PR in the close reason when the election is confident enough", () => { + const named = planAgentMaintenanceActions(input({ conclusion: "failure", autonomy: { close: "auto" }, blockerTitles: ["x"], pr: { labels: [], linkedDuplicateCount: 1, linkedDuplicateWinnerNumber: 99 } })); + const namedClose = named.find((a) => a.actionClass === "close")!; + expect(namedClose.reason).toContain("duplicate of open PR #99"); + expect(namedClose.reason).not.toContain("duplicate of another open PR"); + }); + + it("#dup-winner-credit: falls back to the generic wording when no winner number is known (null, the nullish arm)", () => { + const generic = planAgentMaintenanceActions(input({ conclusion: "failure", autonomy: { close: "auto" }, blockerTitles: ["x"], pr: { labels: [], linkedDuplicateCount: 1, linkedDuplicateWinnerNumber: null } })); + const genericClose = generic.find((a) => a.actionClass === "close")!; + expect(genericClose.reason).toContain("duplicate of another open PR"); + }); + it("keeps every close cause as a structured closeReasons list for historical audit accuracy", () => { const plan = planAgentMaintenanceActions( input({ diff --git a/test/unit/duplicate-winner.test.ts b/test/unit/duplicate-winner.test.ts index 5cedb4b121..452e786d05 100644 --- a/test/unit/duplicate-winner.test.ts +++ b/test/unit/duplicate-winner.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { isDuplicateClusterWinner, isDuplicateClusterWinnerByClaim } from "../../src/signals/duplicate-winner"; -import { dupWinnerLinkedDuplicateCount, linkedIssueDuplicatePullRequestsForGate } from "../../src/queue/processors"; +import { isDuplicateClusterWinner, isDuplicateClusterWinnerByClaim, resolveDuplicateClusterWinnerNumber } from "../../src/signals/duplicate-winner"; +import { dupWinnerLinkedDuplicateCount, dupWinnerLinkedDuplicateWinnerNumber, linkedIssueDuplicatePullRequestsForGate } from "../../src/queue/processors"; import type { PullRequestRecord } from "../../src/types"; import { listOtherOpenPullRequests, upsertPullRequestFromGitHub } from "../../src/db/repositories"; import { createTestEnv } from "../helpers/d1"; @@ -92,6 +92,89 @@ describe("isDuplicateClusterWinnerByClaim (#dup-winner claim election)", () => { }); }); +describe("isDuplicateClusterWinnerByClaim createdAt precedence (#dup-winner true-creation-time)", () => { + const member = (number: number, createdAt: string | null, linkedIssueClaimedAt: string | null) => ({ number, createdAt, linkedIssueClaimedAt }); + + it("REGRESSION: elects the PR that GitHub says opened first, even when gittensory OBSERVED (claimed) the later-opened sibling first", () => { + // PR 13 truly opened first (10:00) but gittensory's stalled sweep only got around to syncing/claiming it at + // 11:00. PR 14 opened later (10:05) but was claimed immediately (10:06) because the sweep happened to reach + // it first. Under the old claim-time-only rule, 14 would wrongly win and 13 (the real first mover) would be + // closed as the "duplicate." createdAt must override that. + expect( + isDuplicateClusterWinnerByClaim( + member(13, "2026-06-29T10:00:00.000Z", "2026-06-29T11:00:00.000Z"), + [member(14, "2026-06-29T10:05:00.000Z", "2026-06-29T10:06:00.000Z")], + ), + ).toBe(true); + // And symmetrically, the later-created PR no longer wins just because it was claimed first. + expect( + isDuplicateClusterWinnerByClaim( + member(14, "2026-06-29T10:05:00.000Z", "2026-06-29T10:06:00.000Z"), + [member(13, "2026-06-29T10:00:00.000Z", "2026-06-29T11:00:00.000Z")], + ), + ).toBe(false); + }); + + it("falls back to claim-time comparison when only ONE side has a valid createdAt (mixed legacy/modern cluster)", () => { + // pr has createdAt; sibling (a legacy row) does not — never mix clocks across the two sides of one + // comparison. pr's claim (10:00) is earlier than sibling's claim (10:05) ⇒ pr still wins via the fallback. + expect( + isDuplicateClusterWinnerByClaim( + { number: 12, createdAt: "2026-06-29T09:00:00.000Z", linkedIssueClaimedAt: "2026-06-29T10:00:00.000Z" }, + [{ number: 13, createdAt: null, linkedIssueClaimedAt: "2026-06-29T10:05:00.000Z" }], + ), + ).toBe(true); + // Same mixed case, but pr's own claim is later than the sibling's ⇒ pr loses via the fallback. + expect( + isDuplicateClusterWinnerByClaim( + { number: 12, createdAt: "2026-06-29T09:00:00.000Z", linkedIssueClaimedAt: "2026-06-29T10:05:00.000Z" }, + [{ number: 13, createdAt: null, linkedIssueClaimedAt: "2026-06-29T10:00:00.000Z" }], + ), + ).toBe(false); + }); + + it("falls back to claim-time comparison when a createdAt value is present but unparseable", () => { + expect( + isDuplicateClusterWinnerByClaim( + member(12, "not-a-date", "2026-06-29T10:00:00.000Z"), + [member(13, "2026-06-29T09:00:00.000Z", "2026-06-29T10:05:00.000Z")], + ), + ).toBe(true); + }); + + it("tie-breaks equal createdAt values by PR number, mirroring the claim-time tie-break", () => { + expect(isDuplicateClusterWinnerByClaim(member(12, "2026-06-29T10:00:00.000Z", null), [member(13, "2026-06-29T10:00:00.000Z", null)])).toBe(true); + expect(isDuplicateClusterWinnerByClaim(member(13, "2026-06-29T10:00:00.000Z", null), [member(12, "2026-06-29T10:00:00.000Z", null)])).toBe(false); + }); + + it("createdAt-based cases are unaffected by (and do not require) a claim timestamp at all", () => { + expect(isDuplicateClusterWinnerByClaim(member(12, "2026-06-29T10:00:00.000Z", null), [member(13, "2026-06-29T10:05:00.000Z", null)])).toBe(true); + }); +}); + +describe("resolveDuplicateClusterWinnerNumber (#dup-winner-credit)", () => { + it("returns this PR's own number when it is the winner", () => { + expect(resolveDuplicateClusterWinnerNumber({ number: 12, createdAt: "2026-06-29T10:00:00.000Z" }, [{ number: 13, createdAt: "2026-06-29T10:05:00.000Z" }])).toBe(12); + }); + + it("returns the actual winning sibling's number when this PR is a loser, even with multiple siblings", () => { + expect( + resolveDuplicateClusterWinnerNumber({ number: 14, createdAt: "2026-06-29T10:10:00.000Z" }, [ + { number: 13, createdAt: "2026-06-29T10:00:00.000Z" }, + { number: 15, createdAt: "2026-06-29T10:05:00.000Z" }, + ]), + ).toBe(13); + }); + + it("an empty sibling list ⇒ this PR wins by default", () => { + expect(resolveDuplicateClusterWinnerNumber({ number: 12 }, [])).toBe(12); + }); + + it("returns null when the election is too ambiguous to name a specific winner (fully sparse legacy cluster)", () => { + expect(resolveDuplicateClusterWinnerNumber({ number: 12, createdAt: null, linkedIssueClaimedAt: null }, [{ number: 13, createdAt: null, linkedIssueClaimedAt: null }])).toBeNull(); + }); +}); + describe("dupWinnerLinkedDuplicateCount (#dup-winner close-reason seam)", () => { it("winner + flag ON ⇒ 0 (close reason omits the duplicate cause)", () => { expect( @@ -139,6 +222,38 @@ describe("dupWinnerLinkedDuplicateCount (#dup-winner close-reason seam)", () => expect(dupWinnerLinkedDuplicateCount([], 12, "2026-06-29T10:00:00.000Z", true)).toBe(0); expect(dupWinnerLinkedDuplicateCount([], 12, "2026-06-29T10:00:00.000Z", false)).toBe(0); }); + + it("REGRESSION (#dup-winner true-creation-time): createdAt overrides a claim-time-only verdict when passed through", () => { + // By claim time alone this PR (12) would lose to sibling 13 (claimed earlier, 10:00 vs 10:05). But 12's true + // createdAt (09:00) precedes 13's (09:30), so passing createdAt flips the verdict to a win (count 0). + expect( + dupWinnerLinkedDuplicateCount( + [{ number: 13, linkedIssueClaimedAt: "2026-06-29T10:00:00.000Z", createdAt: "2026-06-29T09:30:00.000Z" }], + 12, + "2026-06-29T10:05:00.000Z", + true, + "2026-06-29T09:00:00.000Z", + ), + ).toBe(0); + }); +}); + +describe("dupWinnerLinkedDuplicateWinnerNumber (#dup-winner-credit close-reason naming seam)", () => { + it("flag OFF ⇒ null regardless of who would win (generic wording, byte-identical to before this existed)", () => { + expect(dupWinnerLinkedDuplicateWinnerNumber([{ number: 13, createdAt: "2026-06-29T10:05:00.000Z" }], 12, undefined, false, "2026-06-29T10:00:00.000Z")).toBeNull(); + }); + + it("winner + flag ON ⇒ null (nothing to name — its own close reason omits the duplicate cause entirely)", () => { + expect(dupWinnerLinkedDuplicateWinnerNumber([{ number: 13, createdAt: "2026-06-29T10:05:00.000Z" }], 12, undefined, true, "2026-06-29T10:00:00.000Z")).toBeNull(); + }); + + it("loser + flag ON ⇒ the actual winning sibling's number", () => { + expect(dupWinnerLinkedDuplicateWinnerNumber([{ number: 12, createdAt: "2026-06-29T10:00:00.000Z" }], 14, undefined, true, "2026-06-29T10:10:00.000Z")).toBe(12); + }); + + it("loser + flag ON, but the election is too ambiguous ⇒ null (falls back to generic wording)", () => { + expect(dupWinnerLinkedDuplicateWinnerNumber([{ number: 13, createdAt: null, linkedIssueClaimedAt: null }], 12, null, true, null)).toBeNull(); + }); }); describe("linkedIssueDuplicatePullRequestsForGate (#dup-winner open-sibling source)", () => { @@ -189,3 +304,38 @@ describe("listOtherOpenPullRequests ordering (#audit-3.9)", () => { expect(siblingNumbers).not.toContain(102); // the lowest 100 (1..100) are returned, not the first-inserted 100 }); }); + +describe("upsertPullRequestFromGitHub createdAt threading (#dup-winner true-creation-time)", () => { + it("populates createdAt from GitHub's true pull_request.created_at on the IMMEDIATE upsert return, not just on a later DB round-trip", async () => { + const env = createTestEnv(); + const record = await upsertPullRequestFromGitHub(env, "owner/repo", { + number: 42, + title: "PR 42", + state: "open", + user: { login: "c" }, + head: { sha: "s42" }, + labels: [], + body: "x", + created_at: "2026-06-29T09:00:00.000Z", + }); + expect(record.createdAt).toBe("2026-06-29T09:00:00.000Z"); + + const rehydrated = await listOtherOpenPullRequests(env, "owner/repo", 999); + expect(rehydrated).toHaveLength(1); + expect(rehydrated[0]?.createdAt).toBe("2026-06-29T09:00:00.000Z"); + }); + + it("createdAt is absent (undefined) when the GitHub payload doesn't carry one (the false ternary/nullish arm)", async () => { + const env = createTestEnv(); + const record = await upsertPullRequestFromGitHub(env, "owner/repo", { + number: 43, + title: "PR 43", + state: "open", + user: { login: "c" }, + head: { sha: "s43" }, + labels: [], + body: "x", + }); + expect(record.createdAt).toBeUndefined(); + }); +});