Skip to content

Commit 17fbd4a

Browse files
Update github_rest_api/github.py
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent c8f734b commit 17fbd4a

2 files changed

Lines changed: 86 additions & 9 deletions

File tree

github_rest_api/github.py

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from datetime import datetime, timedelta, timezone
1010
from enum import StrEnum
1111
from pathlib import Path
12-
from typing import Any, Callable
12+
from typing import Any, Callable, TypeGuard
1313
from urllib.parse import quote
1414

1515
import 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

@@ -212,7 +239,7 @@ def _old_enough(commit_iso: str, min_age_minutes: int) -> bool:
212239
:param commit_iso: An ISO-8601 UTC timestamp (e.g. ``2026-06-27T12:00:00Z``).
213240
:param min_age_minutes: The minimum age in minutes.
214241
"""
215-
committed = datetime.fromisoformat(commit_iso.replace("Z", "+00:00"))
242+
committed = datetime.fromisoformat(commit_iso)
216243
return datetime.now(timezone.utc) - committed >= timedelta(minutes=min_age_minutes)
217244

218245

@@ -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)

tests/test_github.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,54 @@ def test_should_auto_merge_changes_requested_vetoes_marker_comment():
536536
assert _run_should_auto_merge(repo, patches) is None
537537

538538

539+
def test_settle_mergeable_state_retries_until_settled():
540+
# GitHub returns "unknown" until it finishes computing mergeable_state; the
541+
# PR is re-fetched (sleeping between tries) until the state settles.
542+
repo = Repository("token", "owner/name")
543+
pr = {"number": 1, "mergeable_state": "unknown"}
544+
fetched = [
545+
{"number": 1, "mergeable_state": "unknown"},
546+
{"number": 1, "mergeable_state": "clean"},
547+
]
548+
with (
549+
patch("github_rest_api.github.time.sleep") as mock_sleep,
550+
patch.object(repo, "get_pull_request", side_effect=fetched) as mock_get,
551+
):
552+
settled = repo._settle_mergeable_state(pr)
553+
assert settled["mergeable_state"] == "clean"
554+
assert mock_get.call_count == 2
555+
assert mock_sleep.call_count == 2
556+
557+
558+
def test_settle_mergeable_state_returns_immediately_when_settled():
559+
# An already-settled state needs no re-fetch and no sleep.
560+
repo = Repository("token", "owner/name")
561+
pr = {"number": 1, "mergeable_state": "clean"}
562+
with (
563+
patch("github_rest_api.github.time.sleep") as mock_sleep,
564+
patch.object(repo, "get_pull_request") as mock_get,
565+
):
566+
settled = repo._settle_mergeable_state(pr)
567+
assert settled is pr
568+
mock_get.assert_not_called()
569+
mock_sleep.assert_not_called()
570+
571+
572+
def test_settle_mergeable_state_gives_up_after_attempts():
573+
# When the state never settles, it gives up after ``attempts`` fetches and
574+
# returns the last (still-unknown) PR rather than looping forever.
575+
repo = Repository("token", "owner/name")
576+
pr = {"number": 1, "mergeable_state": "unknown"}
577+
with (
578+
patch("github_rest_api.github.time.sleep") as mock_sleep,
579+
patch.object(repo, "get_pull_request", return_value=dict(pr)) as mock_get,
580+
):
581+
settled = repo._settle_mergeable_state(pr, attempts=3)
582+
assert settled["mergeable_state"] == "unknown"
583+
assert mock_get.call_count == 3
584+
assert mock_sleep.call_count == 3
585+
586+
539587
def _pr_item(number):
540588
"""A list-endpoint PR payload that passes the field-only pre-filter gate."""
541589
return {

0 commit comments

Comments
 (0)