github-rest-api 0.44.0__tar.gz → 0.45.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/PKG-INFO +1 -1
  2. github_rest_api-0.45.0/github_rest_api/__init__.py +19 -0
  3. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/github_rest_api/github.py +416 -4
  4. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/pyproject.toml +1 -1
  5. github_rest_api-0.45.0/tests/test_github.py +746 -0
  6. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/uv.lock +1 -1
  7. github_rest_api-0.44.0/github_rest_api/__init__.py +0 -11
  8. github_rest_api-0.44.0/tests/test_github.py +0 -257
  9. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/.devcontainer/devcontainer.json +0 -0
  10. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/.github/workflows/create_pr_to_main.yaml +0 -0
  11. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/.github/workflows/lint.yaml +0 -0
  12. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/.github/workflows/release.yaml +0 -0
  13. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/.github/workflows/remove_branch.yaml +0 -0
  14. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/.github/workflows/test.yaml +0 -0
  15. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/.gitignore +0 -0
  16. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/GEMINI.md +0 -0
  17. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/README.md +0 -0
  18. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/github_rest_api/pr_content.py +0 -0
  19. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/github_rest_api/scripts/__init__.py +0 -0
  20. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/github_rest_api/scripts/cargo/__init__.py +0 -0
  21. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/github_rest_api/scripts/cargo/benchmark.py +0 -0
  22. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/github_rest_api/scripts/cargo/profiling.py +0 -0
  23. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/github_rest_api/scripts/cargo/utils.py +0 -0
  24. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/github_rest_api/scripts/container/__init__.py +0 -0
  25. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/github_rest_api/scripts/container/build_container_images.py +0 -0
  26. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/github_rest_api/scripts/container/config_container.py +0 -0
  27. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/github_rest_api/scripts/container/update_version_containerfile.py +0 -0
  28. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/github_rest_api/scripts/github/__init__.py +0 -0
  29. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/github_rest_api/scripts/github/add_github_repo.py +0 -0
  30. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/github_rest_api/scripts/github/create_pull_request.py +0 -0
  31. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/github_rest_api/scripts/github/release_on_github.py +0 -0
  32. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/github_rest_api/scripts/github/remove_branch.py +0 -0
  33. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/github_rest_api/scripts/github/workflows/create_pr_dev_to_main.yaml +0 -0
  34. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/github_rest_api/scripts/github/workflows/create_pr_to_dev.yaml +0 -0
  35. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/github_rest_api/scripts/github/workflows/create_pr_to_main.yaml +0 -0
  36. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/github_rest_api/scripts/github/workflows/python/lint.yaml +0 -0
  37. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/github_rest_api/scripts/github/workflows/python/test.yaml +0 -0
  38. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/github_rest_api/scripts/github/workflows/remove_branch.yaml +0 -0
  39. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/github_rest_api/scripts/utils.py +0 -0
  40. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/github_rest_api/utils.py +0 -0
  41. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/memory/MEMORY.md +0 -0
  42. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/memory/feedback_test_runner.md +0 -0
  43. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/tests/__init__.py +0 -0
  44. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/tests/test_build_container_images.py +0 -0
  45. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/tests/test_create_pull_request.py +0 -0
  46. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/tests/test_pr_content.py +0 -0
  47. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/tests/test_release_on_github.py +0 -0
  48. {github_rest_api-0.44.0 → github_rest_api-0.45.0}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-rest-api
3
- Version: 0.44.0
3
+ Version: 0.45.0
4
4
  Summary: Simple wrapper of GitHub REST APIs.
5
5
  Author-email: Ben Du <longendu@yahoo.com>
6
6
  Classifier: Programming Language :: Python :: 3 :: Only
@@ -0,0 +1,19 @@
1
+ """GitHub REST APIs."""
2
+
3
+ from .github import (
4
+ MergeMethod,
5
+ Organization,
6
+ Repository,
7
+ RepositoryType,
8
+ SecretVisibility,
9
+ User,
10
+ )
11
+
12
+ __all__ = [
13
+ "MergeMethod",
14
+ "Organization",
15
+ "Repository",
16
+ "RepositoryType",
17
+ "SecretVisibility",
18
+ "User",
19
+ ]
@@ -2,12 +2,14 @@
2
2
 
3
3
  import logging
4
4
  import re
5
+ import time
5
6
  from abc import ABCMeta, abstractmethod
6
7
  from base64 import b64encode
7
8
  from collections.abc import Sequence
9
+ from datetime import datetime, timedelta, timezone
8
10
  from enum import StrEnum
9
11
  from pathlib import Path
10
- from typing import Any, Callable
12
+ from typing import Any, Callable, TypeGuard
11
13
  from urllib.parse import quote
12
14
 
13
15
  import requests
@@ -26,6 +28,18 @@ URL_API = "https://api.github.com"
26
28
  # Default LiteLLM 'provider/model' used to generate PR titles and descriptions.
27
29
  DEFAULT_PR_MODEL = "anthropic/claude-haiku-4-5-20251001"
28
30
 
31
+ # Defaults for automatic pull request merging (see Repository.auto_merge_pull_requests).
32
+ # A marker comment from an allowlisted approver is accepted as an approval signal
33
+ # for AI reviewers that only comment instead of submitting a formal review.
34
+ DEFAULT_AUTO_MERGE_MARKER = "<!-- auto-merge: approved -->"
35
+ # Conventional-commit title types eligible for auto-merge.
36
+ DEFAULT_AUTO_MERGE_TYPES = ("chore", "docs", "deps")
37
+ # A PR is only auto-merged once its head commit is at least this old, an anti-race
38
+ # guard so a PR that momentarily reads `clean` before CI registers is not merged.
39
+ # A just-created PR is simply deferred to the next run, never dropped, so a small
40
+ # margin over the CI-registration latency suffices; set to 0 to disable the guard.
41
+ DEFAULT_MIN_AGE_MINUTES = 2
42
+
29
43
 
30
44
  def _validate_secret_name(name: str) -> None:
31
45
  """Validate a secret name against GitHub's naming rules.
@@ -83,6 +97,174 @@ def _is_rust(file: str) -> bool:
83
97
  return False
84
98
 
85
99
 
100
+ def _login(obj: dict[str, Any]) -> str | None:
101
+ """Return the author login of a GitHub object (PR, review, comment), or None.
102
+
103
+ The ``user`` field can be absent or null (e.g. a ghost/deleted account), so
104
+ it is guarded before reading ``login``.
105
+
106
+ :param obj: A GitHub object carrying an optional ``user`` sub-object.
107
+ """
108
+ return (obj.get("user") or {}).get("login")
109
+
110
+
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
+
137
+ # Matches the leading Conventional-Commits type token of a title, e.g. the
138
+ # `chore` in "chore(deps): bump x" or "feat!: ...".
139
+ _CONVENTIONAL_TYPE = re.compile(r"^(\w+)(\([^)]*\))?!?:")
140
+
141
+
142
+ def _conventional_type(title: str) -> str | None:
143
+ """Extract the lower-cased Conventional-Commits type token from a PR title.
144
+
145
+ :param title: The pull request title.
146
+ :return: The type token (e.g. ``chore``) lower-cased, or None when the title
147
+ does not start with a Conventional-Commits type prefix.
148
+ """
149
+ match = _CONVENTIONAL_TYPE.match(title)
150
+ return match.group(1).lower() if match else None
151
+
152
+
153
+ def _title_type_allowed(title: str, allowed_types: Sequence[str]) -> bool:
154
+ """Check whether a PR title's Conventional-Commits type is auto-merge eligible.
155
+
156
+ :param title: The pull request title.
157
+ :param allowed_types: The Conventional-Commits types eligible for auto-merge.
158
+ """
159
+ type_ = _conventional_type(title)
160
+ return type_ is not None and type_ in allowed_types
161
+
162
+
163
+ def _field_gate_failure(
164
+ pr: dict[str, Any], authors: Sequence[str], allowed_types: Sequence[str]
165
+ ) -> str | None:
166
+ """Return why a PR fails the field-only auto-merge gates, or None if it passes.
167
+
168
+ These gates (not a draft, trusted author, eligible title type) read only
169
+ fields carried by both the list and single-PR payloads, so they can filter
170
+ list items before the more expensive single-PR detail is fetched.
171
+
172
+ :param pr: A PR payload (a list item or full detail).
173
+ :param authors: Logins whose PRs are eligible for auto-merge.
174
+ :param allowed_types: Conventional-Commits title types eligible for auto-merge.
175
+ """
176
+ if pr.get("draft"):
177
+ return "it is a draft"
178
+ if not _in_allowlist(_login(pr), _normalized_logins(authors)):
179
+ return "its author is not in the allowlist"
180
+ if not _title_type_allowed(pr.get("title") or "", allowed_types):
181
+ return "its title type is not auto-merge eligible"
182
+ return None
183
+
184
+
185
+ def _approver_review_states(
186
+ reviews: Sequence[dict[str, Any]], approvers: Sequence[str]
187
+ ) -> set[str]:
188
+ """Return the set of latest decisive review states among allowlisted reviewers.
189
+
190
+ Only the latest decisive review per reviewer is kept. ``APPROVED``,
191
+ ``CHANGES_REQUESTED`` and ``DISMISSED`` are decisive (so a later
192
+ ``DISMISSED`` clears an earlier approval), while ``COMMENTED`` and
193
+ ``PENDING`` are ignored (a comment does not revoke an approval).
194
+
195
+ Returning the set (rather than a single approved/not-approved boolean) lets
196
+ the caller treat ``CHANGES_REQUESTED`` as a veto independently of how
197
+ approval is granted, so a marker comment cannot override a standing change
198
+ request.
199
+
200
+ :param reviews: Reviews as returned by ``get_pull_request_reviews``.
201
+ :param approvers: Logins whose reviews are trusted for auto-merge.
202
+ """
203
+ approver_set = _normalized_logins(approvers)
204
+ # Reviews are returned in chronological order, so a later decisive entry
205
+ # overrides an earlier one for the same reviewer.
206
+ latest: dict[str, str] = {}
207
+ for review in reviews:
208
+ login = _login(review)
209
+ state = review.get("state")
210
+ if _in_allowlist(login, approver_set) and state in (
211
+ "APPROVED",
212
+ "CHANGES_REQUESTED",
213
+ "DISMISSED",
214
+ ):
215
+ latest[login.lower()] = state
216
+ return set(latest.values())
217
+
218
+
219
+ def _marker_approved(
220
+ comments: Sequence[dict[str, Any]],
221
+ approvers: Sequence[str],
222
+ marker: str,
223
+ since: datetime,
224
+ ) -> bool:
225
+ """Check whether an allowlisted approver marked the current head for merge.
226
+
227
+ Only comments created after ``since`` (the head commit's timestamp) count: a
228
+ marker left before the current head commit approved an older revision, so
229
+ honouring it would auto-merge newer, unreviewed changes.
230
+
231
+ :param comments: Issue comments as returned by ``get_issue_comments``.
232
+ :param approvers: Logins whose marker comments are trusted for auto-merge.
233
+ :param marker: The marker substring signalling approval.
234
+ :param since: The head commit's timestamp; earlier comments are ignored.
235
+ """
236
+ approver_set = _normalized_logins(approvers)
237
+ return any(
238
+ _in_allowlist(_login(comment), approver_set)
239
+ and marker in (comment.get("body") or "")
240
+ and _parse_iso(comment["created_at"]) > since
241
+ for comment in comments
242
+ )
243
+
244
+
245
+ def _parse_iso(timestamp: str) -> datetime:
246
+ """Parse an ISO-8601 timestamp into a timezone-aware ``datetime``.
247
+
248
+ A naive timestamp (no offset) is assumed to be UTC.
249
+
250
+ :param timestamp: An ISO-8601 timestamp (e.g. ``2026-06-27T12:00:00Z``).
251
+ """
252
+ parsed = datetime.fromisoformat(timestamp)
253
+ if parsed.tzinfo is None:
254
+ parsed = parsed.replace(tzinfo=timezone.utc)
255
+ return parsed
256
+
257
+
258
+ def _old_enough(commit_iso: str, min_age_minutes: int) -> bool:
259
+ """Check whether a commit timestamp is at least ``min_age_minutes`` old.
260
+
261
+ :param commit_iso: An ISO-8601 UTC timestamp (e.g. ``2026-06-27T12:00:00Z``).
262
+ :param min_age_minutes: The minimum age in minutes.
263
+ """
264
+ committed = _parse_iso(commit_iso)
265
+ return datetime.now(timezone.utc) - committed >= timedelta(minutes=min_age_minutes)
266
+
267
+
86
268
  class GitHub:
87
269
  def __init__(self, token: str):
88
270
  self._token = token
@@ -209,6 +391,12 @@ class IssueState(StrEnum):
209
391
  ALL = "all"
210
392
 
211
393
 
394
+ class MergeMethod(StrEnum):
395
+ MERGE = "merge"
396
+ SQUASH = "squash"
397
+ REBASE = "rebase"
398
+
399
+
212
400
  class Repository(GitHub):
213
401
  """Abstraction of a GitHub repository."""
214
402
 
@@ -288,6 +476,28 @@ class Repository(GitHub):
288
476
  """List pull requests in this repository."""
289
477
  return self._extract_all(url=self._url_pull, n=n)
290
478
 
479
+ def get_pull_request(self, pr_number: int) -> dict[str, Any]:
480
+ """Get the full detail of a single pull request.
481
+
482
+ Unlike ``get_pull_requests``, this returns fields computed per-PR such as
483
+ ``mergeable`` and ``mergeable_state``. These are computed asynchronously
484
+ by GitHub, so ``mergeable_state`` may be ``unknown`` immediately after a
485
+ push; callers that need it should re-fetch until it settles.
486
+
487
+ :param pr_number: The number of the pull request.
488
+ """
489
+ return self._get(url=f"{self._url_pull}/{pr_number}").json()
490
+
491
+ def get_pull_request_reviews(
492
+ self, pr_number: int, n: int = 0
493
+ ) -> list[dict[str, Any]]:
494
+ """List reviews on a pull request.
495
+
496
+ :param pr_number: The number of the pull request.
497
+ :param n: The maximum number of reviews to return (0 means all).
498
+ """
499
+ return self._extract_all(url=f"{self._url_pull}/{pr_number}/reviews", n=n)
500
+
291
501
  def get_issues(
292
502
  self, state: IssueState = IssueState.OPEN, n: int = 0
293
503
  ) -> list[dict[str, Any]]:
@@ -378,13 +588,29 @@ class Repository(GitHub):
378
588
  resp.raise_for_status()
379
589
  return resp.json()
380
590
 
381
- def merge_pull_request(self, pr_number: int) -> dict[str, Any]:
591
+ def merge_pull_request(
592
+ self,
593
+ pr_number: int,
594
+ merge_method: MergeMethod | str = MergeMethod.MERGE,
595
+ sha: str = "",
596
+ ) -> dict[str, Any]:
382
597
  """Merge a pull request in this repository.
383
598
  :param pr_number: The number of the pull quest to be merged.
599
+ :param merge_method: The merge method to use (``merge``, ``squash`` or
600
+ ``rebase``). Defaults to ``merge``.
601
+ :param sha: When non-empty, the SHA that the PR head must still match for
602
+ the merge to proceed (GitHub rejects the merge with 409 if the head
603
+ has moved). Use it to pin a merge to a previously validated commit.
384
604
  """
385
- return self._put(
605
+ body: dict[str, Any] = {"merge_method": str(merge_method)}
606
+ if sha:
607
+ body["sha"] = sha
608
+ resp = self._put(
386
609
  url=f"{self._url_pull}/{pr_number}/merge",
387
- ).json()
610
+ json=body,
611
+ )
612
+ resp.raise_for_status()
613
+ return resp.json()
388
614
 
389
615
  def update_branch(self, update: str, upstream: str) -> dict[str, Any] | None:
390
616
  """Update a branch by creating a PR from upstream and then merge it.
@@ -405,6 +631,167 @@ class Repository(GitHub):
405
631
  return
406
632
  return self.merge_pull_request(pr["number"])
407
633
 
634
+ def _settle_mergeable_state(
635
+ self, pr: dict[str, Any], attempts: int = 3, delay: float = 2.0
636
+ ) -> dict[str, Any]:
637
+ """Re-fetch a PR until GitHub finishes computing ``mergeable_state``.
638
+
639
+ GitHub computes ``mergeable_state`` asynchronously, returning ``unknown``
640
+ until it settles. The PR is re-fetched (up to ``attempts`` times, waiting
641
+ ``delay`` seconds between tries) until the state is no longer ``unknown``.
642
+
643
+ :param pr: A PR detail dict as returned by ``get_pull_request``.
644
+ :param attempts: The maximum number of fetches before giving up.
645
+ :param delay: The delay in seconds between fetches.
646
+ """
647
+ for _ in range(attempts):
648
+ if pr.get("mergeable_state") not in (None, "unknown"):
649
+ break
650
+ time.sleep(delay)
651
+ pr = self.get_pull_request(pr["number"])
652
+ return pr
653
+
654
+ def should_auto_merge(
655
+ self,
656
+ pr_number: int,
657
+ *,
658
+ authors: Sequence[str],
659
+ approvers: Sequence[str],
660
+ allowed_types: Sequence[str] = DEFAULT_AUTO_MERGE_TYPES,
661
+ min_age_minutes: int = DEFAULT_MIN_AGE_MINUTES,
662
+ marker: str = DEFAULT_AUTO_MERGE_MARKER,
663
+ ) -> str | None:
664
+ """Decide whether a pull request is eligible for automatic merging.
665
+
666
+ All of the following must hold: the PR is not a draft; its author is in
667
+ ``authors``; its Conventional-Commits title type is in ``allowed_types``;
668
+ its ``mergeable_state`` is ``clean`` (no merge conflict and no failing or
669
+ pending status checks); its head commit is at least ``min_age_minutes``
670
+ old; and it is approved either by an ``APPROVED`` review or by a
671
+ ``marker`` comment posted after the head commit by an approver in
672
+ ``approvers`` (with no outstanding ``CHANGES_REQUESTED``).
673
+
674
+ The age check guards against the race where a brand-new PR momentarily
675
+ reads ``clean`` before its checks register; it relies on the head
676
+ commit's timestamp, which is trustworthy given that ``authors`` is an
677
+ allowlist of trusted automation.
678
+
679
+ Checks run cheapest-first, returning ``None`` (with a logged reason) on
680
+ the first failure so extra requests are avoided.
681
+
682
+ :param pr_number: The number of the pull request.
683
+ :param authors: Logins whose PRs are eligible for auto-merge.
684
+ :param approvers: Logins whose reviews/marker comments grant approval.
685
+ :param allowed_types: Conventional-Commits title types eligible for auto-merge.
686
+ :param min_age_minutes: The minimum head-commit age in minutes.
687
+ :param marker: The marker substring an approver may comment to approve.
688
+ :return: The validated head SHA when the PR is eligible, else ``None``.
689
+ Passing the SHA to ``merge_pull_request`` pins the merge to the exact
690
+ commit checked here, so a push landing between this gate and the merge
691
+ is rejected rather than silently merged.
692
+ """
693
+ pr = self.get_pull_request(pr_number)
694
+
695
+ def skip(reason: str) -> None:
696
+ logger.info("Skipping auto-merge of PR #%s: %s.", pr_number, reason)
697
+ return None
698
+
699
+ # Field-only gates first; these need no extra requests.
700
+ field_failure = _field_gate_failure(pr, authors, allowed_types)
701
+ if field_failure:
702
+ return skip(field_failure)
703
+ # Check age before mergeable_state: one commit-date fetch is cheaper than
704
+ # settling mergeable_state, and mergeable_state is only trustworthy once
705
+ # the PR is old enough for its checks to have registered.
706
+ committed = self._head_commit_date(pr["head"]["sha"])
707
+ if not _old_enough(committed, min_age_minutes):
708
+ return skip(f"its head commit is newer than {min_age_minutes} minutes")
709
+ pr = self._settle_mergeable_state(pr)
710
+ if pr.get("mergeable_state") != "clean":
711
+ return skip(f"its mergeable_state is {pr.get('mergeable_state')!r}")
712
+ # The head SHA is read after settling so it matches the mergeable_state
713
+ # just validated, and is returned so the caller can pin the merge to it.
714
+ head_sha = pr["head"]["sha"]
715
+ reviews = self.get_pull_request_reviews(pr_number)
716
+ review_states = _approver_review_states(reviews, approvers)
717
+ # A standing change request vetoes the merge regardless of how approval
718
+ # would otherwise be granted, so it is checked before the marker path.
719
+ if "CHANGES_REQUESTED" in review_states:
720
+ return skip("an approver has requested changes")
721
+ if "APPROVED" in review_states:
722
+ return head_sha
723
+ comments = self.get_issue_comments(pr_number)
724
+ if _marker_approved(comments, approvers, marker, _parse_iso(committed)):
725
+ return head_sha
726
+ return skip("it lacks an approving review or marker comment")
727
+
728
+ def auto_merge_pull_requests(
729
+ self,
730
+ *,
731
+ authors: Sequence[str],
732
+ approvers: Sequence[str],
733
+ allowed_types: Sequence[str] = DEFAULT_AUTO_MERGE_TYPES,
734
+ min_age_minutes: int = DEFAULT_MIN_AGE_MINUTES,
735
+ marker: str = DEFAULT_AUTO_MERGE_MARKER,
736
+ merge_method: MergeMethod | str = MergeMethod.MERGE,
737
+ dry_run: bool = False,
738
+ ) -> list[int]:
739
+ """Auto-merge every open pull request that passes ``should_auto_merge``.
740
+
741
+ :param authors: Logins whose PRs are eligible for auto-merge. An empty
742
+ allowlist makes nothing eligible (fail-safe).
743
+ :param approvers: Logins whose reviews/marker comments grant approval.
744
+ :param allowed_types: Conventional-Commits title types eligible for auto-merge.
745
+ :param min_age_minutes: The minimum head-commit age in minutes.
746
+ :param marker: The marker substring an approver may comment to approve.
747
+ :param merge_method: The merge method to use (``merge``, ``squash`` or
748
+ ``rebase``).
749
+ :param dry_run: When True, eligible PRs are logged and returned but not
750
+ merged.
751
+ :return: The numbers of the PRs that were merged (or, under ``dry_run``,
752
+ that would have been merged).
753
+ """
754
+ eligible = []
755
+ for pr in self.get_pull_requests():
756
+ number = pr["number"]
757
+ # Pre-filter on the list payload so PRs failing a field-only gate are
758
+ # dropped without fetching their (more expensive) single-PR detail.
759
+ field_failure = _field_gate_failure(pr, authors, allowed_types)
760
+ if field_failure:
761
+ logger.info("Skipping auto-merge of PR #%s: %s.", number, field_failure)
762
+ continue
763
+ # Both the eligibility checks and the merge issue requests that can
764
+ # fail for a single PR (a force-pushed/deleted head, a PR that became
765
+ # unmergeable, or a transient error). Isolate each PR so one failure
766
+ # skips that PR without aborting the rest of the batch.
767
+ try:
768
+ sha = self.should_auto_merge(
769
+ number,
770
+ authors=authors,
771
+ approvers=approvers,
772
+ allowed_types=allowed_types,
773
+ min_age_minutes=min_age_minutes,
774
+ marker=marker,
775
+ )
776
+ if sha is None:
777
+ continue
778
+ if dry_run:
779
+ logger.info("[dry-run] PR #%s is eligible for auto-merge.", number)
780
+ else:
781
+ logger.info("Auto-merging PR #%s.", number)
782
+ # Pin the merge to the SHA the gate validated so a push that
783
+ # landed in between is rejected (409) instead of merged.
784
+ self.merge_pull_request(number, merge_method, sha=sha)
785
+ except Exception:
786
+ logger.warning(
787
+ "Skipping PR #%s after an unexpected failure.",
788
+ number,
789
+ exc_info=True,
790
+ )
791
+ continue
792
+ eligible.append(number)
793
+ return eligible
794
+
408
795
  def get_pull_request_files(
409
796
  self, pr_number: int, n: int = 0
410
797
  ) -> list[dict[str, Any]]:
@@ -429,6 +816,31 @@ class Repository(GitHub):
429
816
  basehead = f"{quote(base, safe='')}...{quote(head, safe='')}"
430
817
  return self._get(url=f"{self._url_compare}/{basehead}").json()
431
818
 
819
+ def _head_commit_date(self, sha: str) -> str:
820
+ """Return the committer date of ``sha`` without fetching its file diff.
821
+
822
+ The single-commit endpoint (``GET /commits/{sha}``) embeds the commit's
823
+ full file diff, which is wasteful when only the timestamp is needed. The
824
+ list-commits endpoint omits per-commit files, so requesting a single
825
+ commit starting at ``sha`` stays cheap even for a large diff.
826
+
827
+ :param sha: The head commit SHA.
828
+ """
829
+ resp = self._get(
830
+ url=f"{self._url_repo}/commits",
831
+ params={"sha": sha, "per_page": 1},
832
+ )
833
+ resp.raise_for_status()
834
+ commits = resp.json()
835
+ if not isinstance(commits, list) or not commits:
836
+ raise ValueError(f"No commits found for SHA {sha}")
837
+ try:
838
+ return commits[0]["commit"]["committer"]["date"]
839
+ except (KeyError, TypeError) as e:
840
+ raise ValueError(
841
+ f"Unexpected commit payload structure for SHA {sha}"
842
+ ) from e
843
+
432
844
  def get_branches(self, n: int = 0) -> list[dict[str, Any]]:
433
845
  """List branches in this repository."""
434
846
  return self._extract_all(url=self._url_branches, n=n)
@@ -4,7 +4,7 @@ requires = [ "hatchling" ]
4
4
 
5
5
  [project]
6
6
  name = "github-rest-api"
7
- version = "0.44.0"
7
+ version = "0.45.0"
8
8
  description = "Simple wrapper of GitHub REST APIs."
9
9
  readme = "README.md"
10
10
  authors = [ { name = "Ben Du", email = "longendu@yahoo.com" } ]