diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 7bcf60859b..9ddb3d6529 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -377,16 +377,25 @@ function resolveLinkedIssueClaimedAt( ): string | null { if (linkedIssues.length === 0) return null; if (!existing) return observedLinkedIssueClaimedAt; - if (existing.linkedIssuesJson === linkedIssuesJson) return existing.linkedIssueClaimedAt ?? observedLinkedIssueClaimedAt; - if (existing.linkedIssueClaimedAt && linkedIssuesOverlap(parseJson(existing.linkedIssuesJson, []), linkedIssues)) { - return existing.linkedIssueClaimedAt; - } + if ( + existing.linkedIssuesJson === linkedIssuesJson || + sameLinkedIssueSet(parseLinkedIssuesJson(existing.linkedIssuesJson), linkedIssues) + ) + return existing.linkedIssueClaimedAt ?? observedLinkedIssueClaimedAt; return observedLinkedIssueClaimedAt; } -function linkedIssuesOverlap(left: number[], right: number[]): boolean { - const rightIssues = new Set(right); - return left.some((issue) => rightIssues.has(issue)); +function parseLinkedIssuesJson(value: string): number[] { + const parsed = parseJson(value, []); + return Array.isArray(parsed) ? (parsed as number[]) : []; +} + +function sameLinkedIssueSet(left: number[], right: number[]): boolean { + return normalizedLinkedIssueSet(left) === normalizedLinkedIssueSet(right); +} + +function normalizedLinkedIssueSet(numbers: number[]): string { + return jsonString([...new Set(numbers)].sort((left, right) => left - right)); } export async function upsertIssueFromGitHub(env: Env, repoFullName: string, issue: GitHubIssuePayload, options: { seenOpenAt?: string } = {}): Promise { diff --git a/test/unit/db-parsers.test.ts b/test/unit/db-parsers.test.ts index 1faf0a52df..b1bd5d7192 100644 --- a/test/unit/db-parsers.test.ts +++ b/test/unit/db-parsers.test.ts @@ -92,7 +92,7 @@ describe("database row parser hardening", () => { ); }); - it("REGRESSION: adding another linked issue preserves the original shared-issue claim time", async () => { + it("REGRESSION: adding another linked issue resets the PR-level claim time", async () => { const env = createTestEnv(); vi.useFakeTimers(); @@ -136,7 +136,23 @@ describe("database row parser hardening", () => { expect(expanded).toMatchObject({ title: "Expanded claim", linkedIssues: [1, 2], - linkedIssueClaimedAt: first?.linkedIssueClaimedAt, + linkedIssueClaimedAt: "2026-06-29T10:05:00.000Z", + }); + + vi.setSystemTime(new Date("2026-06-29T10:07:00.000Z")); + await upsertPullRequestFromGitHub(env, "owner/repo", { + number: 11, + title: "Reordered expanded claim", + state: "open", + user: { login: "alice" }, + labels: [], + body: "Fixes #2\nFixes #1", + }); + const reordered = (await listPullRequests(env, "owner/repo")).find((p) => p.number === 11); + expect(reordered).toMatchObject({ + title: "Reordered expanded claim", + linkedIssues: [2, 1], + linkedIssueClaimedAt: expanded?.linkedIssueClaimedAt, }); vi.setSystemTime(new Date("2026-06-29T10:10:00.000Z")); @@ -203,6 +219,41 @@ describe("database row parser hardening", () => { }); }); + it("repairs sparse non-array linked issue cache rows without throwing", async () => { + const env = createTestEnv(); + + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-29T11:10:00.000Z")); + await upsertPullRequestFromGitHub(env, "owner/repo", { + number: 13, + title: "Sparse cached claim", + state: "open", + user: { login: "alice" }, + labels: [], + body: "Fixes #8", + }); + await env.DB.prepare("UPDATE pull_requests SET linked_issues_json = ? WHERE repo_full_name = ? AND number = ?") + .bind("{}", "owner/repo", 13) + .run(); + + vi.setSystemTime(new Date("2026-06-29T11:12:00.000Z")); + await upsertPullRequestFromGitHub(env, "owner/repo", { + number: 13, + title: "Sparse cached claim repaired", + state: "open", + user: { login: "alice" }, + labels: [], + body: "Fixes #8", + }); + + const repaired = (await listPullRequests(env, "owner/repo")).find((p) => p.number === 13); + expect(repaired).toMatchObject({ + title: "Sparse cached claim repaired", + linkedIssues: [8], + linkedIssueClaimedAt: "2026-06-29T11:12:00.000Z", + }); + }); + it("markPullRequestRegated stamps the internal last_regated_at marker (sweep convergence #audit-sweep-converge)", async () => { const env = createTestEnv(); await upsertPullRequestFromGitHub(env, "owner/repo", { number: 5, title: "Stale PR", state: "open", user: { login: "alice" }, labels: [] });