99from datetime import datetime , timedelta , timezone
1010from enum import StrEnum
1111from pathlib import Path
12- from typing import Any , Callable
12+ from typing import Any , Callable , TypeGuard
1313from urllib .parse import quote
1414
1515import requests
@@ -108,6 +108,32 @@ def _login(obj: dict[str, Any]) -> str | None:
108108 return (obj .get ("user" ) or {}).get ("login" )
109109
110110
111+ def _normalized_logins (logins : Sequence [str ]) -> set [str ]:
112+ """Lower-case GitHub logins into a set for case-insensitive membership tests.
113+
114+ GitHub logins are case-insensitive, so normalizing the allowlist once (rather
115+ than per candidate) lets repeated membership tests compare against a stable
116+ lower-cased set.
117+
118+ :param logins: The logins eligible for a gated action.
119+ """
120+ return {login .lower () for login in logins }
121+
122+
123+ def _in_allowlist (login : str | None , allowlist : set [str ]) -> TypeGuard [str ]:
124+ """Check whether a GitHub login is in a normalized allowlist.
125+
126+ ``allowlist`` must already be lower-cased (see :func:`_normalized_logins`).
127+ The login is lower-cased here before comparison, so casing differences
128+ between the configured allowlist and the API response do not cause spurious
129+ mismatches. A ``None`` login (e.g. a ghost/deleted account) is never a member.
130+
131+ :param login: The login to test, or None.
132+ :param allowlist: A lower-cased set of logins eligible for the gated action.
133+ """
134+ return login is not None and login .lower () in allowlist
135+
136+
111137# Matches the leading Conventional-Commits type token of a title, e.g. the
112138# `chore` in "chore(deps): bump x" or "feat!: ...".
113139_CONVENTIONAL_TYPE = re .compile (r"^(\w+)(\([^)]*\))?!?:" )
@@ -149,7 +175,7 @@ def _field_gate_failure(
149175 """
150176 if pr .get ("draft" ):
151177 return "it is a draft"
152- if _login (pr ) not in set (authors ):
178+ if not _in_allowlist ( _login (pr ), _normalized_logins (authors ) ):
153179 return "its author is not in the allowlist"
154180 if not _title_type_allowed (pr .get ("title" ) or "" , allowed_types ):
155181 return "its title type is not auto-merge eligible"
@@ -174,14 +200,14 @@ def _approver_review_states(
174200 :param reviews: Reviews as returned by ``get_pull_request_reviews``.
175201 :param approvers: Logins whose reviews are trusted for auto-merge.
176202 """
177- approver_set = set (approvers )
203+ approver_set = _normalized_logins (approvers )
178204 # Reviews are returned in chronological order, so a later decisive entry
179205 # overrides an earlier one for the same reviewer.
180206 latest : dict [str , str ] = {}
181207 for review in reviews :
182208 login = _login (review )
183209 state = review .get ("state" )
184- if login in approver_set and state in (
210+ if _in_allowlist ( login , approver_set ) and state in (
185211 "APPROVED" ,
186212 "CHANGES_REQUESTED" ,
187213 "DISMISSED" ,
@@ -199,9 +225,10 @@ def _marker_approved(
199225 :param approvers: Logins whose marker comments are trusted for auto-merge.
200226 :param marker: The marker substring signalling approval.
201227 """
202- approver_set = set (approvers )
228+ approver_set = _normalized_logins (approvers )
203229 return any (
204- _login (comment ) in approver_set and marker in (comment .get ("body" ) or "" )
230+ _in_allowlist (_login (comment ), approver_set )
231+ and marker in (comment .get ("body" ) or "" )
205232 for comment in comments
206233 )
207234
@@ -731,9 +758,11 @@ def auto_merge_pull_requests(
731758 # Pin the merge to the SHA the gate validated so a push that
732759 # landed in between is rejected (409) instead of merged.
733760 self .merge_pull_request (number , merge_method , sha = sha )
734- except requests . HTTPError :
761+ except Exception :
735762 logger .warning (
736- "Skipping PR #%s after a request failure." , number , exc_info = True
763+ "Skipping PR #%s after an unexpected failure." ,
764+ number ,
765+ exc_info = True ,
737766 )
738767 continue
739768 eligible .append (number )
0 commit comments