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