Skip to content

[Bug]: issue-watch subscriptions match repoFullName case-sensitively #746

Description

@philluiz2323

[Bug]: issue-watch subscriptions match repoFullName case-sensitively → watchers silently miss notifications

Summary

The new issue-watch feature (#735, gittensory_watch_issues) stores and matches
the watched repoFullName case-sensitively, while the rest of the codebase
treats repo full names case-insensitively (getRepository resolves via
lower(fullName) = lower(input); the registry sync was explicitly fixed for this
in #604). The watch functions deliberately lowercase login and labels "for
case-insensitive matching" — but not repoFullName.

Consequence: a contributor who subscribes via the MCP tool with a repo name whose
casing differs from GitHub's canonical casing (e.g. jsonbored/gittensory instead
of JSONbored/gittensory — trivially common, since GitHub URLs are
case-insensitive) gets a stored subscription that the webhook's canonical-cased
lookup can never match. They see their watch in list, but silently never
receive any issue-watch notification
— the entire point of the feature fails for
them, with no error.

Evidence

Store side — the MCP tool passes the user's raw casing straight through (no
getRepository canonicalization, schema is just z.string()):

// src/mcp/server.ts — watchIssues
await upsertIssueWatchSubscription(this.env, { login: input.login, repoFullName: input.repoFullName, labels: input.labels });
// watchIssuesShape: repoFullName: z.string().min(3).max(200).optional()  — no normalization
// src/db/repositories.ts — login + labels lowercased, repoFullName stored RAW
export async function upsertIssueWatchSubscription(env, input) {
  const login = input.login.toLowerCase();
  const labels = [...].map((label) => label.toLowerCase().trim())...;
  await db.insert(issueWatchSubscriptions)
    .values({ ..., login, repoFullName: input.repoFullName, ... })   // ← raw casing stored
    .onConflictDoUpdate({ target: [issueWatchSubscriptions.login, issueWatchSubscriptions.repoFullName], ... });
  ...
}

Match side — the webhook looks up watchers with GitHub's canonical casing via an
exact, case-sensitive eq:

// src/queue/processors.ts
if (payload.action === "opened") issueWatchEvents = await detectIssueWatchEvents(env, payload.repository.full_name, issue);

// src/notifications/service.ts
const watchers = await listIssueWatchersForRepo(env, repoFullName);

// src/db/repositories.ts — case-sensitive match
export async function listIssueWatchersForRepo(env, repoFullName) {
  const rows = await db.select().from(issueWatchSubscriptions)
    .where(eq(issueWatchSubscriptions.repoFullName, repoFullName)).limit(5000);   // ← exact casing
  ...
}

deleteIssueWatchSubscription has the same case-sensitive eq, so unwatch also
fails to remove a row stored under a different casing.

The established invariant elsewhere (for contrast):

// src/db/repositories.ts — getRepository resolves repo names case-insensitively
... where(sql`lower(${repositories.fullName}) = lower(${fullName})`) ...

Why it's wrong

GitHub repo full names are case-insensitive for access (github.com/JSONbored/Gittensory
resolves), so users and agents routinely supply non-canonical casing. The whole
codebase already normalizes for this (getRepository, registry sync #604,
contributor logins, the watch feature's own labels/login). The watch feature broke
the invariant for repoFullName only, turning a casing typo into a permanent,
silent loss of every notification the feature is supposed to deliver. It also
allows duplicate rows for the same logical (login, repo) across casings, since the
UNIQUE(login, repo_full_name) index is case-sensitive.

Reachability

Primary intended path: a contributor (or their agent) calls
gittensory_watch_issues with action: "watch" and any non-canonical repo casing
→ subscription stored mis-cased → every subsequent issues.opened webhook on that
repo calls listIssueWatchersForRepo(canonicalCasing) → no match → no
notification. No special setup; just a casing difference between what the user
typed and GitHub's canonical full_name.

Suggested fix

Normalize repoFullName to a canonical form on both store and lookup, mirroring
the existing login/labels lowercasing in these same functions:

// upsertIssueWatchSubscription
const repoFullName = input.repoFullName.toLowerCase();
// ...use repoFullName in .values() and the select-back where clause

// listIssueWatchersForRepo
.where(eq(issueWatchSubscriptions.repoFullName, repoFullName.toLowerCase()))

// deleteIssueWatchSubscription
.where(and(eq(..., login.toLowerCase()), eq(issueWatchSubscriptions.repoFullName, repoFullName.toLowerCase())))

Lowercasing both sides makes matching symmetric and lets the UNIQUE(login, repo)
index dedupe across casings (the stored value is always lowercase). Update the
docstring to note repoFullName is normalized, as login/labels already are.
(Alternative: resolve the canonical repo.fullName via getRepository in the MCP
tool before storing — preserves display casing but only works for repos already
known to the DB; lowercasing is simpler and unconditional.)

Test status

Not covered. test/unit/issue-watch.test.ts deliberately subscribes with
login: "Miner" to assert login lowercasing — but uses the already-lowercase
"owner/repo" for every repo, so the repo-casing asymmetry is never exercised
and the bug ships green. A regression test should subscribe with
repoFullName: "Owner/Repo" and assert listIssueWatchersForRepo("owner/repo")
(and detectIssueWatchEvents with the canonical casing) still finds the watcher.

Confidence note

High. Concrete, reachable on the feature's primary path, with a clear
user-visible failure (silent missed notifications) and a contract the codebase
already enforces everywhere else (#604, getRepository). The only nuance is that it
fails silently rather than erroring, so it's a correctness/usability bug, not a
security issue.

Distinct from prior reports

New feature (#735); no existing report covers it. Same class as the registry
casing fix (#604) but a different, newly-introduced surface (issue-watch
subscriptions). Unrelated to the validate-linked-issue allowlist, predicted-gate,
or gate-403 work.

Metadata

Metadata

Assignees

No one assigned

    Labels

    slopAI slop and/or attempts to game additional points via manipulation or alt profiles.

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions