github2gerrit 0.1.0__py3-none-any.whl → 0.1.3__py3-none-any.whl
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.
- github2gerrit/__init__.py +29 -0
- github2gerrit/cli.py +865 -0
- github2gerrit/config.py +311 -0
- github2gerrit/core.py +1750 -0
- github2gerrit/duplicate_detection.py +542 -0
- github2gerrit/github_api.py +331 -0
- github2gerrit/gitutils.py +655 -0
- github2gerrit/models.py +81 -0
- {github2gerrit-0.1.0.dist-info → github2gerrit-0.1.3.dist-info}/METADATA +5 -4
- github2gerrit-0.1.3.dist-info/RECORD +12 -0
- github2gerrit-0.1.0.dist-info/RECORD +0 -4
- {github2gerrit-0.1.0.dist-info → github2gerrit-0.1.3.dist-info}/WHEEL +0 -0
- {github2gerrit-0.1.0.dist-info → github2gerrit-0.1.3.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,331 @@
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
2
|
+
# SPDX-FileCopyrightText: 2025 The Linux Foundation
|
3
|
+
#
|
4
|
+
# GitHub API wrapper using PyGithub with retries/backoff.
|
5
|
+
# - Centralized construction of the client
|
6
|
+
# - Helpers for common PR operations used by github2gerrit
|
7
|
+
# - Deterministic, typed interfaces with strict typing
|
8
|
+
# - Basic exponential backoff with jitter for transient failures
|
9
|
+
#
|
10
|
+
# Notes:
|
11
|
+
# - This module intentionally limits its surface area to the needs of the
|
12
|
+
# orchestration flow: PR discovery, metadata, comments, and closing PRs.
|
13
|
+
# - Rate limit handling is best-effort. For heavy usage, consider honoring
|
14
|
+
# the reset timestamp exposed by the API. Here we implement a capped
|
15
|
+
# exponential backoff with jitter for simplicity.
|
16
|
+
|
17
|
+
from __future__ import annotations
|
18
|
+
|
19
|
+
import logging
|
20
|
+
import os
|
21
|
+
import random
|
22
|
+
import re
|
23
|
+
import time
|
24
|
+
from collections.abc import Callable
|
25
|
+
from collections.abc import Iterable
|
26
|
+
from importlib import import_module
|
27
|
+
from typing import Any
|
28
|
+
from typing import Protocol
|
29
|
+
from typing import TypeVar
|
30
|
+
from typing import cast
|
31
|
+
|
32
|
+
|
33
|
+
class GithubExceptionType(Exception):
|
34
|
+
pass
|
35
|
+
|
36
|
+
|
37
|
+
class RateLimitExceededExceptionType(GithubExceptionType):
|
38
|
+
pass
|
39
|
+
|
40
|
+
|
41
|
+
def _load_github_classes() -> tuple[
|
42
|
+
Any | None, type[BaseException], type[BaseException]
|
43
|
+
]:
|
44
|
+
try:
|
45
|
+
exc_mod = import_module("github.GithubException")
|
46
|
+
ge = exc_mod.GithubException
|
47
|
+
rle = exc_mod.RateLimitExceededException
|
48
|
+
except Exception:
|
49
|
+
ge = GithubExceptionType
|
50
|
+
rle = RateLimitExceededExceptionType
|
51
|
+
try:
|
52
|
+
gh_mod = import_module("github")
|
53
|
+
gh_cls = gh_mod.Github
|
54
|
+
except Exception:
|
55
|
+
gh_cls = None
|
56
|
+
return gh_cls, ge, rle
|
57
|
+
|
58
|
+
|
59
|
+
_GITHUB_CLASS, _GITHUB_EXCEPTION, _RATE_LIMIT_EXC = _load_github_classes()
|
60
|
+
# Expose a public Github alias for tests and callers.
|
61
|
+
# If PyGithub is not available, provide a placeholder that raises.
|
62
|
+
if _GITHUB_CLASS is not None:
|
63
|
+
Github = _GITHUB_CLASS
|
64
|
+
else:
|
65
|
+
|
66
|
+
class Github: # type: ignore[no-redef]
|
67
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
68
|
+
raise RuntimeError("PyGithub required") # noqa: TRY003
|
69
|
+
|
70
|
+
|
71
|
+
class GhIssueComment(Protocol):
|
72
|
+
body: str | None
|
73
|
+
|
74
|
+
|
75
|
+
class GhIssue(Protocol):
|
76
|
+
def get_comments(self) -> Iterable[GhIssueComment]: ...
|
77
|
+
def create_comment(self, body: str) -> None: ...
|
78
|
+
|
79
|
+
|
80
|
+
class GhPullRequest(Protocol):
|
81
|
+
number: int
|
82
|
+
title: str | None
|
83
|
+
body: str | None
|
84
|
+
|
85
|
+
def as_issue(self) -> GhIssue: ...
|
86
|
+
def edit(self, *, state: str) -> None: ...
|
87
|
+
|
88
|
+
|
89
|
+
class GhRepository(Protocol):
|
90
|
+
def get_pull(self, number: int) -> GhPullRequest: ...
|
91
|
+
def get_pulls(self, state: str) -> Iterable[GhPullRequest]: ...
|
92
|
+
|
93
|
+
|
94
|
+
class GhClient(Protocol):
|
95
|
+
def get_repo(self, full: str) -> GhRepository: ...
|
96
|
+
|
97
|
+
|
98
|
+
__all__ = [
|
99
|
+
"Github",
|
100
|
+
"GithubExceptionType",
|
101
|
+
"RateLimitExceededExceptionType",
|
102
|
+
"build_client",
|
103
|
+
"close_pr",
|
104
|
+
"create_pr_comment",
|
105
|
+
"get_pr_title_body",
|
106
|
+
"get_pull",
|
107
|
+
"get_recent_change_ids_from_comments",
|
108
|
+
"get_repo_from_env",
|
109
|
+
"iter_open_pulls",
|
110
|
+
"time",
|
111
|
+
]
|
112
|
+
|
113
|
+
log = logging.getLogger("github2gerrit.github_api")
|
114
|
+
|
115
|
+
_T = TypeVar("_T")
|
116
|
+
|
117
|
+
|
118
|
+
def _getenv_str(name: str) -> str:
|
119
|
+
val = os.getenv(name, "")
|
120
|
+
return val.strip()
|
121
|
+
|
122
|
+
|
123
|
+
def _backoff_delay(attempt: int, base: float = 0.5, cap: float = 6.0) -> float:
|
124
|
+
# Exponential backoff with jitter; cap prevents unbounded waits.
|
125
|
+
delay: float = float(min(base * (2 ** max(0, attempt - 1)), cap))
|
126
|
+
jitter: float = float(random.uniform(0.0, delay / 2.0)) # noqa: S311
|
127
|
+
return float(delay + jitter)
|
128
|
+
|
129
|
+
|
130
|
+
def _should_retry(exc: BaseException) -> bool:
|
131
|
+
# Retry on common transient conditions:
|
132
|
+
# - RateLimitExceededException
|
133
|
+
# - GithubException with 5xx codes
|
134
|
+
# - GithubException with 403 and rate limit hints
|
135
|
+
if isinstance(exc, _RATE_LIMIT_EXC | RateLimitExceededExceptionType):
|
136
|
+
return True
|
137
|
+
if isinstance(exc, _GITHUB_EXCEPTION | GithubExceptionType):
|
138
|
+
status = getattr(exc, "status", None)
|
139
|
+
if isinstance(status, int) and 500 <= status <= 599:
|
140
|
+
return True
|
141
|
+
data = getattr(exc, "data", "")
|
142
|
+
if status == 403 and isinstance(data, str | bytes):
|
143
|
+
try:
|
144
|
+
text = data.decode("utf-8") if isinstance(data, bytes) else data
|
145
|
+
except Exception:
|
146
|
+
text = str(data)
|
147
|
+
if "rate limit" in text.lower():
|
148
|
+
return True
|
149
|
+
return False
|
150
|
+
|
151
|
+
|
152
|
+
def _retry_on_github(
|
153
|
+
attempts: int = 5,
|
154
|
+
) -> Callable[[Callable[..., _T]], Callable[..., _T]]:
|
155
|
+
def decorator(func: Callable[..., _T]) -> Callable[..., _T]:
|
156
|
+
def wrapper(*args: Any, **kwargs: Any) -> _T:
|
157
|
+
last_exc: BaseException | None = None
|
158
|
+
for attempt in range(1, attempts + 1):
|
159
|
+
try:
|
160
|
+
return func(*args, **kwargs)
|
161
|
+
except BaseException as exc:
|
162
|
+
last_exc = exc
|
163
|
+
if not _should_retry(exc) or attempt == attempts:
|
164
|
+
log.debug(
|
165
|
+
"GitHub call failed (no retry) at attempt %d: %s",
|
166
|
+
attempt,
|
167
|
+
exc,
|
168
|
+
)
|
169
|
+
raise
|
170
|
+
delay = _backoff_delay(attempt)
|
171
|
+
log.warning(
|
172
|
+
"GitHub call failed (attempt %d): %s; retrying in "
|
173
|
+
"%.2fs",
|
174
|
+
attempt,
|
175
|
+
exc,
|
176
|
+
delay,
|
177
|
+
)
|
178
|
+
time.sleep(delay)
|
179
|
+
# Should not reach here, but raise if it does.
|
180
|
+
if last_exc is None:
|
181
|
+
raise RuntimeError("unreachable")
|
182
|
+
raise last_exc
|
183
|
+
|
184
|
+
return wrapper
|
185
|
+
|
186
|
+
return decorator
|
187
|
+
|
188
|
+
|
189
|
+
@_retry_on_github()
|
190
|
+
def build_client(token: str | None = None) -> GhClient:
|
191
|
+
"""Construct a PyGithub client from a token or environment.
|
192
|
+
|
193
|
+
Order of precedence:
|
194
|
+
- Provided 'token' argument
|
195
|
+
- GITHUB_TOKEN environment variable
|
196
|
+
|
197
|
+
Returns:
|
198
|
+
Github client with sane defaults.
|
199
|
+
"""
|
200
|
+
tok = token or _getenv_str("GITHUB_TOKEN")
|
201
|
+
if not tok:
|
202
|
+
raise ValueError("missing GITHUB_TOKEN") # noqa: TRY003
|
203
|
+
# per_page improves pagination; adjust as needed.
|
204
|
+
base_url = _getenv_str("GITHUB_API_URL")
|
205
|
+
if not base_url:
|
206
|
+
server_url = _getenv_str("GITHUB_SERVER_URL")
|
207
|
+
if server_url:
|
208
|
+
base_url = server_url.rstrip("/") + "/api/v3"
|
209
|
+
client_any: Any
|
210
|
+
try:
|
211
|
+
gh_mod = import_module("github")
|
212
|
+
auth_factory = getattr(gh_mod, "Auth", None)
|
213
|
+
if auth_factory is not None and hasattr(auth_factory, "Token"):
|
214
|
+
auth_obj = auth_factory.Token(tok)
|
215
|
+
if base_url:
|
216
|
+
client_any = Github(
|
217
|
+
auth=auth_obj, per_page=100, base_url=base_url
|
218
|
+
)
|
219
|
+
else:
|
220
|
+
client_any = Github(auth=auth_obj, per_page=100)
|
221
|
+
else:
|
222
|
+
if base_url:
|
223
|
+
client_any = Github(
|
224
|
+
login_or_token=tok, per_page=100, base_url=base_url
|
225
|
+
)
|
226
|
+
else:
|
227
|
+
client_any = Github(login_or_token=tok, per_page=100)
|
228
|
+
except Exception:
|
229
|
+
if base_url:
|
230
|
+
client_any = Github(
|
231
|
+
login_or_token=tok, per_page=100, base_url=base_url
|
232
|
+
)
|
233
|
+
else:
|
234
|
+
client_any = Github(login_or_token=tok, per_page=100)
|
235
|
+
return cast(GhClient, client_any)
|
236
|
+
|
237
|
+
|
238
|
+
@_retry_on_github()
|
239
|
+
def get_repo_from_env(client: GhClient) -> GhRepository:
|
240
|
+
"""Return the repository object based on GITHUB_REPOSITORY."""
|
241
|
+
full = _getenv_str("GITHUB_REPOSITORY")
|
242
|
+
if not full or "/" not in full:
|
243
|
+
raise ValueError("bad GITHUB_REPOSITORY") # noqa: TRY003
|
244
|
+
repo = client.get_repo(full)
|
245
|
+
return repo
|
246
|
+
|
247
|
+
|
248
|
+
@_retry_on_github()
|
249
|
+
def get_pull(repo: GhRepository, number: int) -> GhPullRequest:
|
250
|
+
"""Fetch a pull request by number."""
|
251
|
+
pr = repo.get_pull(number)
|
252
|
+
return pr
|
253
|
+
|
254
|
+
|
255
|
+
def iter_open_pulls(repo: GhRepository) -> Iterable[GhPullRequest]:
|
256
|
+
"""Yield open pull requests in this repository."""
|
257
|
+
yield from repo.get_pulls(state="open")
|
258
|
+
|
259
|
+
|
260
|
+
def get_pr_title_body(pr: GhPullRequest) -> tuple[str, str]:
|
261
|
+
"""Return PR title and body, replacing None with empty strings."""
|
262
|
+
title = getattr(pr, "title", "") or ""
|
263
|
+
body = getattr(pr, "body", "") or ""
|
264
|
+
return str(title), str(body)
|
265
|
+
|
266
|
+
|
267
|
+
_CHANGE_ID_RE: re.Pattern[str] = re.compile(r"Change-Id:\s*([A-Za-z0-9._-]+)")
|
268
|
+
|
269
|
+
|
270
|
+
@_retry_on_github()
|
271
|
+
def _get_issue(pr: GhPullRequest) -> GhIssue:
|
272
|
+
"""Return the issue object corresponding to a pull request."""
|
273
|
+
issue = pr.as_issue()
|
274
|
+
return issue
|
275
|
+
|
276
|
+
|
277
|
+
@_retry_on_github()
|
278
|
+
def get_recent_change_ids_from_comments(
|
279
|
+
pr: GhPullRequest,
|
280
|
+
*,
|
281
|
+
max_comments: int = 50,
|
282
|
+
) -> list[str]:
|
283
|
+
"""Scan recent PR comments for Change-Id trailers.
|
284
|
+
|
285
|
+
Args:
|
286
|
+
pr: Pull request.
|
287
|
+
max_comments: Max number of most recent comments to scan.
|
288
|
+
|
289
|
+
Returns:
|
290
|
+
List of Change-Id values in order of appearance (oldest to newest)
|
291
|
+
within the scanned window. Duplicates are preserved.
|
292
|
+
"""
|
293
|
+
issue = _get_issue(pr)
|
294
|
+
comments: Iterable[GhIssueComment] = issue.get_comments()
|
295
|
+
# Collect last 'max_comments' by buffering and slicing at the end.
|
296
|
+
buf: list[GhIssueComment] = []
|
297
|
+
for c in comments:
|
298
|
+
buf.append(c)
|
299
|
+
# No early stop; PaginatedList can be large, we'll truncate after.
|
300
|
+
# Truncate to the most recent 'max_comments'
|
301
|
+
recent = buf[-max_comments:] if max_comments > 0 else buf
|
302
|
+
found: list[str] = []
|
303
|
+
for c in recent:
|
304
|
+
body = getattr(c, "body", "") or ""
|
305
|
+
for m in _CHANGE_ID_RE.finditer(body):
|
306
|
+
cid = m.group(1).strip()
|
307
|
+
if cid:
|
308
|
+
found.append(cid)
|
309
|
+
return found
|
310
|
+
|
311
|
+
|
312
|
+
@_retry_on_github()
|
313
|
+
def create_pr_comment(pr: GhPullRequest, body: str) -> None:
|
314
|
+
"""Create a new comment on the pull request."""
|
315
|
+
if not body.strip():
|
316
|
+
return
|
317
|
+
issue = _get_issue(pr)
|
318
|
+
issue.create_comment(body)
|
319
|
+
|
320
|
+
|
321
|
+
@_retry_on_github()
|
322
|
+
def close_pr(pr: GhPullRequest, *, comment: str | None = None) -> None:
|
323
|
+
"""Close a pull request, optionally posting a comment first."""
|
324
|
+
if comment and comment.strip():
|
325
|
+
try:
|
326
|
+
create_pr_comment(pr, comment)
|
327
|
+
except Exception as exc:
|
328
|
+
log.warning(
|
329
|
+
"Failed to add close comment to PR #%s: %s", pr.number, exc
|
330
|
+
)
|
331
|
+
pr.edit(state="closed")
|