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(); + }); +});