@@ -66,6 +66,35 @@ def init_schema(self) -> None:
6666 except sqlite3 .OperationalError as e :
6767 if "duplicate column" not in str (e ).lower ():
6868 raise
69+ # Case-insensitive github_user: normalized column for uniqueness and lookups.
70+ try :
71+ conn .execute ("ALTER TABLE identity_links ADD COLUMN github_user_normalized TEXT" )
72+ except sqlite3 .OperationalError as e :
73+ if "duplicate column" not in str (e ).lower ():
74+ raise
75+ conn .execute (
76+ "UPDATE identity_links SET github_user_normalized = lower(github_user) WHERE github_user_normalized IS NULL"
77+ )
78+ # De-duplicate: keep one row per (discord_user_id, github_user_normalized), prefer verified then newest.
79+ conn .execute (
80+ """
81+ DELETE FROM identity_links
82+ WHERE rowid IN (
83+ SELECT rowid FROM (
84+ SELECT rowid,
85+ ROW_NUMBER() OVER (
86+ PARTITION BY discord_user_id, COALESCE(github_user_normalized, '')
87+ ORDER BY verified DESC, created_at DESC
88+ ) AS rn
89+ FROM identity_links
90+ ) WHERE rn > 1
91+ )
92+ """
93+ )
94+ conn .execute (
95+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_identity_links_discord_github_norm "
96+ "ON identity_links (discord_user_id, github_user_normalized)"
97+ )
6998 # Issue requests: contributor requests for assignment, mentor reviews
7099 conn .executescript (
71100 """
@@ -309,15 +338,16 @@ def create_identity_claim(
309338 """
310339 now = datetime .now (timezone .utc )
311340 expires_utc = _ensure_utc (expires_at )
341+ gh_norm = github_user .strip ().lower ()
312342 with self ._connect () as conn :
313- # Verified github_user owned by someone else -> reject
343+ # Verified github_user (case-insensitive) owned by someone else -> reject
314344 row = conn .execute (
315345 """
316346 SELECT discord_user_id
317347 FROM identity_links
318- WHERE github_user = ? AND verified = 1
348+ WHERE github_user_normalized = ? AND verified = 1
319349 """ ,
320- (github_user ,),
350+ (gh_norm ,),
321351 ).fetchone ()
322352 if row and row ["discord_user_id" ] != discord_user_id :
323353 raise ValueError ("github_user is already verified for another Discord user" )
@@ -327,9 +357,9 @@ def create_identity_claim(
327357 """
328358 SELECT discord_user_id, expires_at
329359 FROM identity_links
330- WHERE github_user = ? AND verified = 0 AND discord_user_id != ?
360+ WHERE github_user_normalized = ? AND verified = 0 AND discord_user_id != ?
331361 """ ,
332- (github_user , discord_user_id ),
362+ (gh_norm , discord_user_id ),
333363 ).fetchall ()
334364 for row in rows :
335365 existing_expires = row ["expires_at" ]
@@ -341,20 +371,20 @@ def create_identity_claim(
341371 conn .execute (
342372 """
343373 DELETE FROM identity_links
344- WHERE github_user = ? AND verified = 0 AND (expires_at IS NULL OR expires_at <= ?)
374+ WHERE github_user_normalized = ? AND verified = 0 AND (expires_at IS NULL OR expires_at <= ?)
345375 """ ,
346- (github_user , now .isoformat ()),
376+ (gh_norm , now .isoformat ()),
347377 )
348378
349379 # Enforce one verified mapping per discord user (prevent accidental multi-link)
350380 # Exception: allow refresh if verified identity is stale
351381 row = conn .execute (
352382 """
353- SELECT github_user, verified, verified_at
383+ SELECT github_user, github_user_normalized, verified, verified_at
354384 FROM identity_links
355- WHERE discord_user_id = ? AND github_user = ?
385+ WHERE discord_user_id = ? AND github_user_normalized = ?
356386 """ ,
357- (discord_user_id , github_user ),
387+ (discord_user_id , gh_norm ),
358388 ).fetchone ()
359389 if row and int (row ["verified" ] or 0 ) == 1 :
360390 # Check if stale
@@ -371,23 +401,24 @@ def create_identity_claim(
371401
372402 row = conn .execute (
373403 """
374- SELECT github_user
404+ SELECT github_user_normalized
375405 FROM identity_links
376406 WHERE discord_user_id = ? AND verified = 1
377407 """ ,
378408 (discord_user_id ,),
379409 ).fetchone ()
380- if row and row ["github_user " ] != github_user :
410+ if row and row ["github_user_normalized " ] != gh_norm :
381411 raise ValueError ("discord_user_id is already verified for another GitHub user" )
382412
383413 conn .execute (
384414 """
385415 INSERT INTO identity_links (
386- discord_user_id, github_user, verified, verification_code, expires_at, created_at, verified_at
416+ discord_user_id, github_user, github_user_normalized, verified, verification_code, expires_at, created_at, verified_at
387417 )
388- VALUES (?, ?, 0, ?, ?, ?, NULL)
389- ON CONFLICT(discord_user_id, github_user )
418+ VALUES (?, ?, ?, 0, ?, ?, ?, NULL)
419+ ON CONFLICT(discord_user_id, github_user_normalized )
390420 DO UPDATE SET
421+ github_user = excluded.github_user,
391422 verified = 0,
392423 verification_code = excluded.verification_code,
393424 expires_at = excluded.expires_at,
@@ -397,6 +428,7 @@ def create_identity_claim(
397428 (
398429 discord_user_id ,
399430 github_user ,
431+ gh_norm ,
400432 verification_code ,
401433 expires_utc .isoformat (),
402434 now .isoformat (),
@@ -405,20 +437,22 @@ def create_identity_claim(
405437
406438 def get_identity_link (self , discord_user_id : str , github_user : str ) -> dict | None :
407439 self .init_schema ()
440+ gh_norm = github_user .strip ().lower ()
408441 with self ._connect () as conn :
409442 row = conn .execute (
410443 """
411444 SELECT discord_user_id, github_user, verified, verification_code,
412445 expires_at, created_at, verified_at, unlinked_at
413446 FROM identity_links
414- WHERE discord_user_id = ? AND github_user = ?
447+ WHERE discord_user_id = ? AND github_user_normalized = ?
415448 """ ,
416- (discord_user_id , github_user ),
449+ (discord_user_id , gh_norm ),
417450 ).fetchone ()
418451 return dict (row ) if row else None
419452
420453 def mark_identity_verified (self , discord_user_id : str , github_user : str ) -> None :
421454 now = datetime .now (timezone .utc ).isoformat ()
455+ gh_norm = github_user .strip ().lower ()
422456 with self ._connect () as conn :
423457 conn .execute (
424458 """
@@ -427,9 +461,9 @@ def mark_identity_verified(self, discord_user_id: str, github_user: str) -> None
427461 verified_at = ?,
428462 verification_code = NULL,
429463 expires_at = NULL
430- WHERE discord_user_id = ? AND github_user = ?
464+ WHERE discord_user_id = ? AND github_user_normalized = ?
431465 """ ,
432- (now , discord_user_id , github_user ),
466+ (now , discord_user_id , gh_norm ),
433467 )
434468
435469 def unlink_identity (
0 commit comments