Skip to content
22 changes: 12 additions & 10 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { and, desc, eq, gte, inArray, not, or, sql, type SQL } from "drizzle-orm";

Check warning on line 1 in src/db/repositories.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Items reference the same linked issue #746.

Check notice on line 1 in src/db/repositories.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Open PR work references issue #746.

Check notice on line 1 in src/db/repositories.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.
import { getDb } from "./client";
import {
advisories,
Expand Down Expand Up @@ -1312,20 +1312,24 @@
}

/** Subscribe a miner to a repo's new grabbable issues; idempotent on (login, repo) — re-subscribing just
* updates the label filter. `labels` ([]=any) are lowercased for case-insensitive matching at delivery. */
* updates the label filter. `login`, `repoFullName`, and `labels` ([]=any) are all lowercased so matching
* is case-insensitive: GitHub repo names are case-insensitive, and the delivery lookup keys off the
* webhook's canonical `repository.full_name`, so a watch stored under a different casing must still match. */
export async function upsertIssueWatchSubscription(env: Env, input: { login: string; repoFullName: string; labels?: string[] | undefined }): Promise<IssueWatchSubscription> {
const db = getDb(env.DB);
const login = input.login.toLowerCase();
const repoFullName = input.repoFullName.toLowerCase();
const labels = [...new Set((input.labels ?? []).map((label) => label.toLowerCase().trim()).filter(Boolean))];
await db
.insert(issueWatchSubscriptions)
.values({ id: crypto.randomUUID(), login, repoFullName: input.repoFullName, labelsJson: jsonString(labels), updatedAt: nowIso() })
.values({ id: crypto.randomUUID(), login, repoFullName, labelsJson: jsonString(labels), updatedAt: nowIso() })
.onConflictDoUpdate({ target: [issueWatchSubscriptions.login, issueWatchSubscriptions.repoFullName], set: { labelsJson: jsonString(labels), updatedAt: nowIso() } });
const [row] = await db
.select()
.from(issueWatchSubscriptions)
.where(and(eq(issueWatchSubscriptions.login, login), eq(issueWatchSubscriptions.repoFullName, input.repoFullName)));
return row ? toIssueWatchSubscription(row) : { login, repoFullName: input.repoFullName, labels };
.where(and(eq(issueWatchSubscriptions.login, login), eq(issueWatchSubscriptions.repoFullName, repoFullName)));
/* v8 ignore next -- the row always exists immediately after the upsert above; the literal is a type-safety fallback. */
return row ? toIssueWatchSubscription(row) : { login, repoFullName, labels };
}

export async function listIssueWatchSubscriptionsForLogin(env: Env, login: string): Promise<IssueWatchSubscription[]> {
Expand All @@ -1337,19 +1341,17 @@
/** Returns whether a watch existed and was removed (so the caller can report it accurately). */
export async function deleteIssueWatchSubscription(env: Env, login: string, repoFullName: string): Promise<boolean> {
const db = getDb(env.DB);
const existing = await db
.select({ id: issueWatchSubscriptions.id })
.from(issueWatchSubscriptions)
.where(and(eq(issueWatchSubscriptions.login, login.toLowerCase()), eq(issueWatchSubscriptions.repoFullName, repoFullName)));
const where = and(eq(issueWatchSubscriptions.login, login.toLowerCase()), eq(issueWatchSubscriptions.repoFullName, repoFullName.toLowerCase()));
const existing = await db.select({ id: issueWatchSubscriptions.id }).from(issueWatchSubscriptions).where(where);
if (existing.length === 0) return false;
await db.delete(issueWatchSubscriptions).where(and(eq(issueWatchSubscriptions.login, login.toLowerCase()), eq(issueWatchSubscriptions.repoFullName, repoFullName)));
await db.delete(issueWatchSubscriptions).where(where);
return true;
}

/** All miners watching a repo — the candidate recipients when a new grabbable issue opens there. */
export async function listIssueWatchersForRepo(env: Env, repoFullName: string): Promise<IssueWatchSubscription[]> {
const db = getDb(env.DB);
const rows = await db.select().from(issueWatchSubscriptions).where(eq(issueWatchSubscriptions.repoFullName, repoFullName)).limit(5000);
const rows = await db.select().from(issueWatchSubscriptions).where(eq(issueWatchSubscriptions.repoFullName, repoFullName.toLowerCase())).limit(5000);
return rows.map(toIssueWatchSubscription);
}

Expand Down
21 changes: 21 additions & 0 deletions test/unit/issue-watch.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";

Check warning on line 1 in test/unit/issue-watch.test.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Items reference the same linked issue #746.

Check notice on line 1 in test/unit/issue-watch.test.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Open PR work references issue #746.

Check notice on line 1 in test/unit/issue-watch.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.
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { describe, expect, it } from "vitest";
import { GittensoryMcp } from "../../src/mcp/server";
Expand Down Expand Up @@ -50,6 +50,27 @@
expect(await deleteIssueWatchSubscription(env, "miner", "owner/repo")).toBe(false); // already gone
expect(await listIssueWatchSubscriptionsForLogin(env, "miner")).toHaveLength(0);
});

it("matches the watched repo case-insensitively (GitHub repo names are case-insensitive)", async () => {
const env = createTestEnv();
// owner/repo is a tracked PUBLIC repo, so the visibility-aware fan-out gate (#742) admits any watcher.
await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" }, default_branch: "main" }, 100);
// Subscribe with non-canonical casing — the webhook delivers the canonical `repository.full_name`,
// so the stored repo must still match it (and be normalized for display + idempotency).
await upsertIssueWatchSubscription(env, { login: "alice", repoFullName: "Owner/Repo" });
expect((await listIssueWatchSubscriptionsForLogin(env, "alice"))[0]!.repoFullName).toBe("owner/repo");

// Delivery-side lookup uses the canonical webhook casing and still finds the watcher.
expect(await listIssueWatchersForRepo(env, "owner/repo")).toHaveLength(1);
const events = await detectIssueWatchEvents(env, "owner/repo", issue({ number: 21, authorLogin: "maintainer" }));
expect(events.map((e) => e.recipientLogin)).toEqual(["alice"]);

// Re-subscribing under yet another casing is idempotent (no duplicate row), and unwatch is case-insensitive.
await upsertIssueWatchSubscription(env, { login: "alice", repoFullName: "OWNER/REPO", labels: ["bug"] });
expect(await listIssueWatchSubscriptionsForLogin(env, "alice")).toHaveLength(1);
expect(await deleteIssueWatchSubscription(env, "alice", "owner/REPO")).toBe(true);
expect(await listIssueWatchersForRepo(env, "owner/repo")).toHaveLength(0);
});
});

describe("detectIssueWatchEvents", () => {
Expand Down
Loading