[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.
[Bug]: issue-watch subscriptions match
repoFullNamecase-sensitively → watchers silently miss notificationsSummary
The new issue-watch feature (#735,
gittensory_watch_issues) stores and matchesthe watched
repoFullNamecase-sensitively, while the rest of the codebasetreats repo full names case-insensitively (
getRepositoryresolves vialower(fullName) = lower(input); the registry sync was explicitly fixed for thisin #604). The watch functions deliberately lowercase
loginandlabels"forcase-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/gittensoryinsteadof
JSONbored/gittensory— trivially common, since GitHub URLs arecase-insensitive) gets a stored subscription that the webhook's canonical-cased
lookup can never match. They see their watch in
list, but silently neverreceive 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
getRepositorycanonicalization, schema is justz.string()):Match side — the webhook looks up watchers with GitHub's canonical casing via an
exact, case-sensitive
eq:deleteIssueWatchSubscriptionhas the same case-sensitiveeq, sounwatchalsofails to remove a row stored under a different casing.
The established invariant elsewhere (for contrast):
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
repoFullNameonly, 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_issueswithaction: "watch"and any non-canonical repo casing→ subscription stored mis-cased → every subsequent
issues.openedwebhook on thatrepo calls
listIssueWatchersForRepo(canonicalCasing)→ no match → nonotification. No special setup; just a casing difference between what the user
typed and GitHub's canonical
full_name.Suggested fix
Normalize
repoFullNameto a canonical form on both store and lookup, mirroringthe existing
login/labelslowercasing in these same functions: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
repoFullNameis normalized, aslogin/labelsalready are.(Alternative: resolve the canonical
repo.fullNameviagetRepositoryin the MCPtool 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.tsdeliberately subscribes withlogin: "Miner"to assert login lowercasing — but uses the already-lowercase"owner/repo"for every repo, so the repo-casing asymmetry is never exercisedand the bug ships green. A regression test should subscribe with
repoFullName: "Owner/Repo"and assertlistIssueWatchersForRepo("owner/repo")(and
detectIssueWatchEventswith 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.