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
28 changes: 22 additions & 6 deletions src/registry/sync.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { and, desc, eq, notInArray } from "drizzle-orm";
import { and, desc, eq, inArray } from "drizzle-orm";

Check warning on line 1 in src/registry/sync.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Items reference the same linked issue #603.

Check notice on line 1 in src/registry/sync.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Open PR work references issue #603.

Check notice on line 1 in src/registry/sync.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.

Check notice on line 1 in src/registry/sync.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Open PR queue is busy

This repo has a busy open PR queue in the local Gittensory cache.
import { getDb } from "../db/client";
import { registrySnapshots, repositories, syncRuns } from "../db/schema";
import type { RegistrySnapshot } from "../types";
Expand Down Expand Up @@ -91,12 +91,23 @@
payloadJson: jsonString(snapshot as unknown as Record<string, unknown>),
});

// repositories.fullName is a case-sensitive primary key, but repo names arrive from multiple sources
// (the upstream registry vs GitHub-canonical webhook/API casing) and the rest of the system resolves
// repos case-insensitively (getRepository). Resolve each snapshot repo to an existing row by lowercased
// name so a casing variant updates that row instead of inserting a duplicate primary key.
const existingFullNames = (await db.select({ fullName: repositories.fullName }).from(repositories)).map((row) => row.fullName);
const canonicalByLower = new Map(existingFullNames.map((name) => [name.toLowerCase(), name]));

for (const repo of snapshot.repositories) {
const parts = repoParts(repo.repo);
const fullName = canonicalByLower.get(repo.repo.toLowerCase()) ?? repo.repo;
// Record the resolved name so a later case-variant of the same repo within this snapshot maps to
// the same row (upsert) instead of inserting a second case-only-different primary key.
canonicalByLower.set(repo.repo.toLowerCase(), fullName);
const parts = repoParts(fullName);
await db
.insert(repositories)
.values({
fullName: repo.repo,
fullName,
owner: parts.owner,
name: parts.name,
isRegistered: true,
Expand All @@ -123,8 +134,13 @@
});
}

const registeredFullNames = snapshot.repositories.map((repo) => repo.repo);
if (registeredFullNames.length > 0) {
// De-register case-insensitively: only existing rows whose lowercased name is absent from the snapshot,
// so a casing variant of a still-registered repo is never wrongly de-registered.
// Never de-register on an empty snapshot (e.g. a failed/empty registry fetch) -- that would wipe every
// registration. Only de-register when the snapshot actually lists repos and some stored row is absent.
const registeredLower = new Set(snapshot.repositories.map((repo) => repo.repo.toLowerCase()));
const staleFullNames = existingFullNames.filter((name) => !registeredLower.has(name.toLowerCase()));
if (snapshot.repositories.length > 0 && staleFullNames.length > 0) {
await db
.update(repositories)
.set({
Expand All @@ -136,7 +152,7 @@
labelMultipliersJson: "{}",
updatedAt: nowIso(),
})
.where(and(eq(repositories.isRegistered, true), notInArray(repositories.fullName, registeredFullNames)));
.where(and(eq(repositories.isRegistered, true), inArray(repositories.fullName, staleFullNames)));
}
}

Expand Down
70 changes: 69 additions & 1 deletion test/unit/registry.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";

Check warning on line 1 in test/unit/registry.test.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Items reference the same linked issue #603.

Check notice on line 1 in test/unit/registry.test.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Open PR work references issue #603.

Check notice on line 1 in test/unit/registry.test.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.

Check notice on line 1 in test/unit/registry.test.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Open PR queue is busy

This repo has a busy open PR queue in the local Gittensory cache.
import { getRepository } from "../../src/db/repositories";
import { getRepository, upsertRepositoryFromGitHub } from "../../src/db/repositories";
import { normalizeRegistryPayload } from "../../src/registry/normalize";
import { getLatestRegistrySnapshot, persistRegistrySnapshot, refreshRegistry } from "../../src/registry/sync";
import { createTestEnv } from "../helpers/d1";
Expand Down Expand Up @@ -147,6 +147,74 @@
});
});

it("updates an existing case-variant repo row instead of inserting a duplicate", async () => {
const env = createTestEnv();
// A GitHub-sourced row already exists under canonical casing.
await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } });

// The registry supplies the same repo with different casing.
await persistRegistrySnapshot(
env,
normalizeRegistryPayload(
{ "jsonbored/gittensory": { emission_share: 0.02, issue_discovery_share: 0 } },
{ kind: "raw-github", url: "fixture://registry" },
"2026-05-22T00:00:00.000Z",
),
);

// The existing canonical row is updated to registered -- no duplicate primary-key row.
await expect(getRepository(env, "JSONbored/gittensory")).resolves.toMatchObject({ isRegistered: true });
const rows = await env.DB.prepare("SELECT full_name FROM repositories WHERE lower(full_name) = ?").bind("jsonbored/gittensory").all();
expect(rows.results).toHaveLength(1);
expect((rows.results[0] as { full_name: string }).full_name).toBe("JSONbored/gittensory");
});

it("does not de-register a repo whose snapshot casing differs from the stored row", async () => {
const env = createTestEnv();
await persistRegistrySnapshot(
env,
normalizeRegistryPayload({ "JSONbored/gittensory": { emission_share: 0.02, issue_discovery_share: 0 } }, { kind: "raw-github", url: "fixture://old" }, "2026-05-22T00:00:00.000Z"),
);
// The next snapshot uses different casing for the same repo.
await persistRegistrySnapshot(
env,
normalizeRegistryPayload({ "jsonbored/gittensory": { emission_share: 0.02, issue_discovery_share: 0 } }, { kind: "raw-github", url: "fixture://new" }, "2026-05-23T00:00:00.000Z"),
);

await expect(getRepository(env, "JSONbored/gittensory")).resolves.toMatchObject({ isRegistered: true });
const rows = await env.DB.prepare("SELECT full_name FROM repositories WHERE lower(full_name) = ?").bind("jsonbored/gittensory").all();
expect(rows.results).toHaveLength(1);
});

it("does not de-register existing repos when the snapshot is empty", async () => {
const env = createTestEnv();
await persistRegistrySnapshot(
env,
normalizeRegistryPayload({ "JSONbored/gittensory": { emission_share: 0.02, issue_discovery_share: 0 } }, { kind: "raw-github", url: "fixture://seed" }, "2026-05-22T00:00:00.000Z"),
);
// An empty snapshot (e.g. a failed/empty registry fetch) must preserve registrations, not wipe them.
await persistRegistrySnapshot(env, normalizeRegistryPayload({}, { kind: "raw-github", url: "fixture://empty" }, "2026-05-23T00:00:00.000Z"));
await expect(getRepository(env, "JSONbored/gittensory")).resolves.toMatchObject({ isRegistered: true });
});

it("collapses case-variant duplicates within a single snapshot to one row", async () => {
const env = createTestEnv();
await persistRegistrySnapshot(
env,
normalizeRegistryPayload(
{
"JSONbored/gittensory": { emission_share: 0.02, issue_discovery_share: 0 },
"jsonbored/gittensory": { emission_share: 0.03, issue_discovery_share: 0 },
},
{ kind: "raw-github", url: "fixture://dup-casing" },
"2026-05-22T00:00:00.000Z",
),
);
const rows = await env.DB.prepare("SELECT full_name FROM repositories WHERE lower(full_name) = ?").bind("jsonbored/gittensory").all();
expect(rows.results).toHaveLength(1);
await expect(getRepository(env, "JSONbored/gittensory")).resolves.toMatchObject({ isRegistered: true });
});

it("falls back to raw GitHub when registry API probes fail", async () => {
vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {
const url = input.toString();
Expand Down