github-rest-api 0.44.0__tar.gz → 0.46.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.
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/.github/workflows/lint.yaml +8 -0
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/.github/workflows/test.yaml +1 -1
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/PKG-INFO +1 -1
- github_rest_api-0.46.0/github_rest_api/__init__.py +19 -0
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/github_rest_api/github.py +416 -4
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/github_rest_api/pr_content.py +18 -7
- github_rest_api-0.46.0/github_rest_api/scripts/github/auto_merge_pull_request.py +109 -0
- github_rest_api-0.44.0/github_rest_api/scripts/github/add_github_repo.py → github_rest_api-0.46.0/github_rest_api/scripts/github/create_github_repo.py +5 -5
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/pyproject.toml +3 -2
- github_rest_api-0.46.0/tests/test_github.py +746 -0
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/tests/test_pr_content.py +48 -0
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/uv.lock +1 -1
- github_rest_api-0.44.0/github_rest_api/__init__.py +0 -11
- github_rest_api-0.44.0/tests/test_github.py +0 -257
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/.devcontainer/devcontainer.json +0 -0
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/.github/workflows/create_pr_to_main.yaml +0 -0
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/.github/workflows/release.yaml +0 -0
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/.github/workflows/remove_branch.yaml +0 -0
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/.gitignore +0 -0
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/GEMINI.md +0 -0
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/README.md +0 -0
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/github_rest_api/scripts/__init__.py +0 -0
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/github_rest_api/scripts/cargo/__init__.py +0 -0
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/github_rest_api/scripts/cargo/benchmark.py +0 -0
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/github_rest_api/scripts/cargo/profiling.py +0 -0
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/github_rest_api/scripts/cargo/utils.py +0 -0
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/github_rest_api/scripts/container/__init__.py +0 -0
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/github_rest_api/scripts/container/build_container_images.py +0 -0
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/github_rest_api/scripts/container/config_container.py +0 -0
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/github_rest_api/scripts/container/update_version_containerfile.py +0 -0
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/github_rest_api/scripts/github/__init__.py +0 -0
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/github_rest_api/scripts/github/create_pull_request.py +0 -0
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/github_rest_api/scripts/github/release_on_github.py +0 -0
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/github_rest_api/scripts/github/remove_branch.py +0 -0
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/github_rest_api/scripts/github/workflows/create_pr_dev_to_main.yaml +0 -0
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/github_rest_api/scripts/github/workflows/create_pr_to_dev.yaml +0 -0
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/github_rest_api/scripts/github/workflows/create_pr_to_main.yaml +0 -0
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/github_rest_api/scripts/github/workflows/python/lint.yaml +0 -0
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/github_rest_api/scripts/github/workflows/python/test.yaml +0 -0
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/github_rest_api/scripts/github/workflows/remove_branch.yaml +0 -0
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/github_rest_api/scripts/utils.py +0 -0
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/github_rest_api/utils.py +0 -0
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/memory/MEMORY.md +0 -0
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/memory/feedback_test_runner.md +0 -0
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/tests/__init__.py +0 -0
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/tests/test_build_container_images.py +0 -0
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/tests/test_create_pull_request.py +0 -0
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/tests/test_release_on_github.py +0 -0
- {github_rest_api-0.44.0 → github_rest_api-0.46.0}/tests/test_utils.py +0 -0
|
@@ -24,3 +24,11 @@ jobs:
|
|
|
24
24
|
run: uv run ty check
|
|
25
25
|
- name: Analyze Dependencies
|
|
26
26
|
run: uv run deptry .
|
|
27
|
+
- name: Check workflow templates use .yaml extension
|
|
28
|
+
run: |
|
|
29
|
+
yml_files=$(find github_rest_api/scripts/github/workflows/ -name '*.yml')
|
|
30
|
+
if [ -n "$yml_files" ]; then
|
|
31
|
+
echo "The following workflow templates must use the .yaml extension instead of .yml:"
|
|
32
|
+
echo "$yml_files"
|
|
33
|
+
exit 1
|
|
34
|
+
fi
|
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
@@ -83,9 +83,15 @@ def deterministic_title(compare: dict[str, Any]) -> str:
|
|
|
83
83
|
"""Derive a Conventional-Commits title from a comparison result.
|
|
84
84
|
|
|
85
85
|
The type is the most significant type present across the commits
|
|
86
|
-
(``feat`` > ``fix`` > the most frequent parsed type
|
|
87
|
-
|
|
88
|
-
|
|
86
|
+
(``feat`` > ``fix`` > the most frequent parsed type). When no commit follows
|
|
87
|
+
the Conventional-Commits grammar, the type is ``ci`` for changes confined to
|
|
88
|
+
``.github`` and ``chore`` otherwise. The scope is the common top-level
|
|
89
|
+
directory of the changed files; ``!`` is appended for breaking changes.
|
|
90
|
+
|
|
91
|
+
The description reuses a matching commit's description when available and
|
|
92
|
+
otherwise falls back to the first commit's subject line, so a genuinely
|
|
93
|
+
descriptive (if non-conventional) subject is preserved rather than replaced
|
|
94
|
+
by a generic placeholder.
|
|
89
95
|
|
|
90
96
|
:param compare: The comparison result from `Repository.compare`.
|
|
91
97
|
"""
|
|
@@ -94,17 +100,22 @@ def deterministic_title(compare: dict[str, Any]) -> str:
|
|
|
94
100
|
parsed = [parse_conventional(subject) for subject in subjects]
|
|
95
101
|
types = [item[0] for item in parsed if item]
|
|
96
102
|
scope = _common_scope(compare)
|
|
97
|
-
prefix_scope = f"({scope})" if scope else ""
|
|
98
|
-
if not subjects:
|
|
99
|
-
return f"chore{prefix_scope}: update"
|
|
100
103
|
if "feat" in types:
|
|
101
104
|
type_ = "feat"
|
|
102
105
|
elif "fix" in types:
|
|
103
106
|
type_ = "fix"
|
|
104
107
|
elif types:
|
|
105
108
|
type_ = Counter(types).most_common(1)[0][0]
|
|
109
|
+
elif scope == ".github":
|
|
110
|
+
# Changes confined to `.github` (e.g. workflow files) are conventionally
|
|
111
|
+
# `ci`; the type already conveys the location, so drop the (redundant)
|
|
112
|
+
# `.github` scope.
|
|
113
|
+
type_, scope = "ci", None
|
|
106
114
|
else:
|
|
107
115
|
type_ = "chore"
|
|
116
|
+
prefix_scope = f"({scope})" if scope else ""
|
|
117
|
+
if not subjects:
|
|
118
|
+
return f"{type_}{prefix_scope}: update"
|
|
108
119
|
breaking = any(item[2] for item in parsed if item) or any(
|
|
109
120
|
_BREAKING_CHANGE_PATTERN.search(message) for message in messages
|
|
110
121
|
)
|
|
@@ -113,7 +124,7 @@ def deterministic_title(compare: dict[str, Any]) -> str:
|
|
|
113
124
|
else:
|
|
114
125
|
description = next(
|
|
115
126
|
(item[3] for item in parsed if item and item[0] == type_),
|
|
116
|
-
|
|
127
|
+
subjects[0],
|
|
117
128
|
)
|
|
118
129
|
return f"{type_}{prefix_scope}{'!' if breaking else ''}: {description}"
|
|
119
130
|
|