Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 16 additions & 7 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number[]>(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<unknown>(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<IssueRecord> {
Expand Down
55 changes: 53 additions & 2 deletions test/unit/db-parsers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -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: [] });
Expand Down
Loading