github2gerrit 0.1.5__py3-none-any.whl → 0.1.6__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/cli.py +86 -117
- github2gerrit/config.py +32 -24
- github2gerrit/core.py +425 -417
- github2gerrit/duplicate_detection.py +375 -193
- github2gerrit/gerrit_urls.py +256 -0
- github2gerrit/github_api.py +6 -17
- github2gerrit/gitutils.py +30 -13
- github2gerrit/models.py +1 -0
- github2gerrit/similarity.py +458 -0
- github2gerrit/ssh_discovery.py +20 -67
- {github2gerrit-0.1.5.dist-info → github2gerrit-0.1.6.dist-info}/METADATA +22 -25
- github2gerrit-0.1.6.dist-info/RECORD +17 -0
- github2gerrit-0.1.5.dist-info/RECORD +0 -15
- {github2gerrit-0.1.5.dist-info → github2gerrit-0.1.6.dist-info}/WHEEL +0 -0
- {github2gerrit-0.1.5.dist-info → github2gerrit-0.1.6.dist-info}/entry_points.txt +0 -0
- {github2gerrit-0.1.5.dist-info → github2gerrit-0.1.6.dist-info}/licenses/LICENSE +0 -0
- {github2gerrit-0.1.5.dist-info → github2gerrit-0.1.6.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,256 @@
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
2
|
+
# SPDX-FileCopyrightText: 2025 The Linux Foundation
|
3
|
+
"""
|
4
|
+
Centralized Gerrit URL construction utilities.
|
5
|
+
|
6
|
+
This module provides a unified way to construct Gerrit URLs, ensuring
|
7
|
+
consistent handling of GERRIT_HTTP_BASE_PATH and eliminating the need
|
8
|
+
for manual URL construction throughout the codebase.
|
9
|
+
"""
|
10
|
+
|
11
|
+
from __future__ import annotations
|
12
|
+
|
13
|
+
import logging
|
14
|
+
import os
|
15
|
+
from urllib.parse import urljoin
|
16
|
+
|
17
|
+
|
18
|
+
log = logging.getLogger(__name__)
|
19
|
+
|
20
|
+
|
21
|
+
class GerritUrlBuilder:
|
22
|
+
"""
|
23
|
+
Centralized builder for Gerrit URLs with consistent base path handling.
|
24
|
+
|
25
|
+
This class encapsulates all Gerrit URL construction logic, ensuring that
|
26
|
+
GERRIT_HTTP_BASE_PATH is properly handled in all contexts. It provides
|
27
|
+
methods for building different types of URLs (API, web, hooks) and handles
|
28
|
+
the common fallback patterns used throughout the application.
|
29
|
+
"""
|
30
|
+
|
31
|
+
def __init__(self, host: str, base_path: str | None = None):
|
32
|
+
"""
|
33
|
+
Initialize the URL builder for a specific Gerrit host.
|
34
|
+
|
35
|
+
Args:
|
36
|
+
host: Gerrit hostname (without protocol)
|
37
|
+
base_path: Optional base path override. If None, reads from
|
38
|
+
GERRIT_HTTP_BASE_PATH environment variable.
|
39
|
+
"""
|
40
|
+
self.host = host.strip()
|
41
|
+
|
42
|
+
# Normalize base path - remove leading/trailing slashes and whitespace
|
43
|
+
if base_path is not None:
|
44
|
+
self._base_path = base_path.strip().strip("/")
|
45
|
+
else:
|
46
|
+
self._base_path = os.getenv("GERRIT_HTTP_BASE_PATH", "").strip().strip("/")
|
47
|
+
|
48
|
+
log.debug(
|
49
|
+
"GerritUrlBuilder initialized for host=%s, base_path='%s'",
|
50
|
+
self.host,
|
51
|
+
self._base_path,
|
52
|
+
)
|
53
|
+
|
54
|
+
@property
|
55
|
+
def base_path(self) -> str:
|
56
|
+
"""Get the normalized base path."""
|
57
|
+
return self._base_path
|
58
|
+
|
59
|
+
@property
|
60
|
+
def has_base_path(self) -> bool:
|
61
|
+
"""Check if a base path is configured."""
|
62
|
+
return bool(self._base_path)
|
63
|
+
|
64
|
+
def _build_base_url(self, base_path_override: str | None = None) -> str:
|
65
|
+
"""
|
66
|
+
Build the base URL with optional base path override.
|
67
|
+
|
68
|
+
Args:
|
69
|
+
base_path_override: Optional base path to use instead of the instance default
|
70
|
+
|
71
|
+
Returns:
|
72
|
+
Base URL with trailing slash
|
73
|
+
"""
|
74
|
+
path = base_path_override if base_path_override is not None else self._base_path
|
75
|
+
if path:
|
76
|
+
return f"https://{self.host}/{path}/"
|
77
|
+
else:
|
78
|
+
return f"https://{self.host}/"
|
79
|
+
|
80
|
+
def api_url(self, endpoint: str = "", base_path_override: str | None = None) -> str:
|
81
|
+
"""
|
82
|
+
Build a Gerrit REST API URL.
|
83
|
+
|
84
|
+
Args:
|
85
|
+
endpoint: API endpoint path (e.g., "/changes/", "/accounts/self")
|
86
|
+
base_path_override: Optional base path override for fallback scenarios
|
87
|
+
|
88
|
+
Returns:
|
89
|
+
Complete API URL
|
90
|
+
"""
|
91
|
+
base_url = self._build_base_url(base_path_override)
|
92
|
+
# Ensure endpoint starts with / for proper URL joining
|
93
|
+
if endpoint and not endpoint.startswith("/"):
|
94
|
+
endpoint = "/" + endpoint
|
95
|
+
return urljoin(base_url, endpoint.lstrip("/"))
|
96
|
+
|
97
|
+
def web_url(self, path: str = "", base_path_override: str | None = None) -> str:
|
98
|
+
"""
|
99
|
+
Build a Gerrit web UI URL.
|
100
|
+
|
101
|
+
Args:
|
102
|
+
path: Web path (e.g., "c/project/+/123", "dashboard")
|
103
|
+
base_path_override: Optional base path override for fallback scenarios
|
104
|
+
|
105
|
+
Returns:
|
106
|
+
Complete web URL
|
107
|
+
"""
|
108
|
+
base_url = self._build_base_url(base_path_override)
|
109
|
+
if path:
|
110
|
+
# Remove leading slash if present to avoid double slashes
|
111
|
+
path = path.lstrip("/")
|
112
|
+
return urljoin(base_url, path)
|
113
|
+
return base_url.rstrip("/")
|
114
|
+
|
115
|
+
def change_url(
|
116
|
+
self,
|
117
|
+
project: str,
|
118
|
+
change_number: int,
|
119
|
+
base_path_override: str | None = None,
|
120
|
+
) -> str:
|
121
|
+
"""
|
122
|
+
Build a URL for a specific Gerrit change.
|
123
|
+
|
124
|
+
Args:
|
125
|
+
project: Gerrit project name
|
126
|
+
change_number: Gerrit change number
|
127
|
+
base_path_override: Optional base path override for fallback scenarios
|
128
|
+
|
129
|
+
Returns:
|
130
|
+
Complete change URL
|
131
|
+
"""
|
132
|
+
# Don't URL-encode project names - Gerrit expects them as-is (backward compatibility)
|
133
|
+
path = f"c/{project}/+/{change_number}"
|
134
|
+
return self.web_url(path, base_path_override)
|
135
|
+
|
136
|
+
def hook_url(self, hook_name: str, base_path_override: str | None = None) -> str:
|
137
|
+
"""
|
138
|
+
Build a URL for downloading Gerrit hooks.
|
139
|
+
|
140
|
+
Args:
|
141
|
+
hook_name: Name of the hook (e.g., "commit-msg")
|
142
|
+
base_path_override: Optional base path override for fallback scenarios
|
143
|
+
|
144
|
+
Returns:
|
145
|
+
Complete hook download URL
|
146
|
+
"""
|
147
|
+
path = f"tools/hooks/{hook_name}"
|
148
|
+
return self.web_url(path, base_path_override)
|
149
|
+
|
150
|
+
def get_api_url_candidates(self, endpoint: str = "") -> list[str]:
|
151
|
+
"""
|
152
|
+
Get a list of candidate API URLs for fallback scenarios.
|
153
|
+
|
154
|
+
This method returns URLs in order of preference:
|
155
|
+
1. URL with configured base path (if any)
|
156
|
+
2. URL with /r/ base path (common fallback)
|
157
|
+
3. URL with no base path (root)
|
158
|
+
|
159
|
+
Args:
|
160
|
+
endpoint: API endpoint path
|
161
|
+
|
162
|
+
Returns:
|
163
|
+
List of candidate URLs to try
|
164
|
+
"""
|
165
|
+
candidates = []
|
166
|
+
|
167
|
+
# Primary URL with configured base path
|
168
|
+
if self.has_base_path:
|
169
|
+
candidates.append(self.api_url(endpoint))
|
170
|
+
|
171
|
+
# Common fallback: /r/ base path
|
172
|
+
if self._base_path != "r":
|
173
|
+
candidates.append(self.api_url(endpoint, base_path_override="r"))
|
174
|
+
|
175
|
+
# Final fallback: no base path
|
176
|
+
if self.has_base_path:
|
177
|
+
candidates.append(self.api_url(endpoint, base_path_override=""))
|
178
|
+
|
179
|
+
# If no base path was configured, add the primary URL
|
180
|
+
if not self.has_base_path:
|
181
|
+
candidates.append(self.api_url(endpoint))
|
182
|
+
|
183
|
+
return candidates
|
184
|
+
|
185
|
+
def get_hook_url_candidates(self, hook_name: str) -> list[str]:
|
186
|
+
"""
|
187
|
+
Get a list of candidate hook URLs for fallback scenarios.
|
188
|
+
|
189
|
+
This method returns URLs in order of preference for downloading hooks:
|
190
|
+
1. URL with configured base path (if any)
|
191
|
+
2. URL with /r/ base path (common for hooks)
|
192
|
+
3. URL with no base path (root)
|
193
|
+
|
194
|
+
Args:
|
195
|
+
hook_name: Name of the hook to download
|
196
|
+
|
197
|
+
Returns:
|
198
|
+
List of candidate URLs to try
|
199
|
+
"""
|
200
|
+
candidates = []
|
201
|
+
|
202
|
+
# Primary URL with configured base path
|
203
|
+
if self.has_base_path:
|
204
|
+
candidates.append(self.hook_url(hook_name))
|
205
|
+
|
206
|
+
# Common fallback: /r/ base path (very common for hooks)
|
207
|
+
if self._base_path != "r":
|
208
|
+
candidates.append(self.hook_url(hook_name, base_path_override="r"))
|
209
|
+
|
210
|
+
# Final fallback: no base path
|
211
|
+
if self.has_base_path:
|
212
|
+
candidates.append(self.hook_url(hook_name, base_path_override=""))
|
213
|
+
|
214
|
+
# If no base path was configured, add the primary URL
|
215
|
+
if not self.has_base_path:
|
216
|
+
candidates.append(self.hook_url(hook_name))
|
217
|
+
|
218
|
+
return candidates
|
219
|
+
|
220
|
+
def get_web_base_path(self, base_path_override: str | None = None) -> str:
|
221
|
+
"""
|
222
|
+
Get the web base path for URL construction.
|
223
|
+
|
224
|
+
This is useful when you need just the path component for manual URL building.
|
225
|
+
|
226
|
+
Args:
|
227
|
+
base_path_override: Optional base path override
|
228
|
+
|
229
|
+
Returns:
|
230
|
+
Web base path with leading and trailing slashes (e.g., "/r/", "/")
|
231
|
+
"""
|
232
|
+
path = base_path_override if base_path_override is not None else self._base_path
|
233
|
+
if path:
|
234
|
+
return f"/{path}/"
|
235
|
+
else:
|
236
|
+
return "/"
|
237
|
+
|
238
|
+
def __repr__(self) -> str:
|
239
|
+
"""String representation for debugging."""
|
240
|
+
return f"GerritUrlBuilder(host='{self.host}', base_path='{self._base_path}')"
|
241
|
+
|
242
|
+
|
243
|
+
def create_gerrit_url_builder(host: str, base_path: str | None = None) -> GerritUrlBuilder:
|
244
|
+
"""
|
245
|
+
Factory function to create a GerritUrlBuilder instance.
|
246
|
+
|
247
|
+
This is the preferred way to create URL builders throughout the application.
|
248
|
+
|
249
|
+
Args:
|
250
|
+
host: Gerrit hostname
|
251
|
+
base_path: Optional base path override
|
252
|
+
|
253
|
+
Returns:
|
254
|
+
Configured GerritUrlBuilder instance
|
255
|
+
"""
|
256
|
+
return GerritUrlBuilder(host, base_path)
|
github2gerrit/github_api.py
CHANGED
@@ -44,9 +44,7 @@ class RateLimitExceededExceptionType(GithubExceptionType):
|
|
44
44
|
pass
|
45
45
|
|
46
46
|
|
47
|
-
def _load_github_classes() -> tuple[
|
48
|
-
Any | None, type[BaseException], type[BaseException]
|
49
|
-
]:
|
47
|
+
def _load_github_classes() -> tuple[Any | None, type[BaseException], type[BaseException]]:
|
50
48
|
try:
|
51
49
|
exc_mod = import_module("github.GithubException")
|
52
50
|
ge = exc_mod.GithubException
|
@@ -177,8 +175,7 @@ def _retry_on_github(
|
|
177
175
|
raise
|
178
176
|
delay = _backoff_delay(attempt)
|
179
177
|
log.warning(
|
180
|
-
"GitHub call failed (attempt %d): %s; retrying in "
|
181
|
-
"%.2fs",
|
178
|
+
"GitHub call failed (attempt %d): %s; retrying in %.2fs",
|
182
179
|
attempt,
|
183
180
|
exc,
|
184
181
|
delay,
|
@@ -221,23 +218,17 @@ def build_client(token: str | None = None) -> GhClient:
|
|
221
218
|
if auth_factory is not None and hasattr(auth_factory, "Token"):
|
222
219
|
auth_obj = auth_factory.Token(tok)
|
223
220
|
if base_url:
|
224
|
-
client_any = Github(
|
225
|
-
auth=auth_obj, per_page=100, base_url=base_url
|
226
|
-
)
|
221
|
+
client_any = Github(auth=auth_obj, per_page=100, base_url=base_url)
|
227
222
|
else:
|
228
223
|
client_any = Github(auth=auth_obj, per_page=100)
|
229
224
|
else:
|
230
225
|
if base_url:
|
231
|
-
client_any = Github(
|
232
|
-
login_or_token=tok, per_page=100, base_url=base_url
|
233
|
-
)
|
226
|
+
client_any = Github(login_or_token=tok, per_page=100, base_url=base_url)
|
234
227
|
else:
|
235
228
|
client_any = Github(login_or_token=tok, per_page=100)
|
236
229
|
except Exception:
|
237
230
|
if base_url:
|
238
|
-
client_any = Github(
|
239
|
-
login_or_token=tok, per_page=100, base_url=base_url
|
240
|
-
)
|
231
|
+
client_any = Github(login_or_token=tok, per_page=100, base_url=base_url)
|
241
232
|
else:
|
242
233
|
client_any = Github(login_or_token=tok, per_page=100)
|
243
234
|
return cast(GhClient, client_any)
|
@@ -333,7 +324,5 @@ def close_pr(pr: GhPullRequest, *, comment: str | None = None) -> None:
|
|
333
324
|
try:
|
334
325
|
create_pr_comment(pr, comment)
|
335
326
|
except Exception as exc:
|
336
|
-
log.warning(
|
337
|
-
"Failed to add close comment to PR #%s: %s", pr.number, exc
|
338
|
-
)
|
327
|
+
log.warning("Failed to add close comment to PR #%s: %s", pr.number, exc)
|
339
328
|
pr.edit(state="closed")
|
github2gerrit/gitutils.py
CHANGED
@@ -28,9 +28,7 @@ def _is_verbose_mode() -> bool:
|
|
28
28
|
return os.getenv("G2G_VERBOSE", "").lower() in ("true", "1", "yes")
|
29
29
|
|
30
30
|
|
31
|
-
def _log_exception_conditionally(
|
32
|
-
logger: logging.Logger, message: str, *args: Any
|
33
|
-
) -> None:
|
31
|
+
def _log_exception_conditionally(logger: logging.Logger, message: str, *args: Any) -> None:
|
34
32
|
"""Log exception with traceback only if verbose mode is enabled."""
|
35
33
|
if _is_verbose_mode():
|
36
34
|
logger.exception(message, *args)
|
@@ -67,10 +65,7 @@ if not log.handlers:
|
|
67
65
|
# Provide a minimal default if the app has not configured logging.
|
68
66
|
level_name = os.getenv("G2G_LOG_LEVEL", "INFO").upper()
|
69
67
|
level = getattr(logging, level_name, logging.INFO)
|
70
|
-
fmt = (
|
71
|
-
"%(asctime)s %(levelname)-8s %(name)s "
|
72
|
-
"%(filename)s:%(lineno)d | %(message)s"
|
73
|
-
)
|
68
|
+
fmt = "%(asctime)s %(levelname)-8s %(name)s %(filename)s:%(lineno)d | %(message)s"
|
74
69
|
logging.basicConfig(level=level, format=fmt)
|
75
70
|
|
76
71
|
|
@@ -189,6 +184,7 @@ def run_cmd(
|
|
189
184
|
- Raises CommandError on failure when check=True.
|
190
185
|
"""
|
191
186
|
masks = list(masks or [])
|
187
|
+
env = env or non_interactive_env()
|
192
188
|
env_full = _merge_env(None, env)
|
193
189
|
|
194
190
|
log.debug("Executing: %s", _format_cmd_for_log(cmd, masks))
|
@@ -254,6 +250,29 @@ def run_cmd(
|
|
254
250
|
return result
|
255
251
|
|
256
252
|
|
253
|
+
def non_interactive_env() -> dict[str, str]:
|
254
|
+
"""Return a non-interactive SSH/Git environment to bypass local
|
255
|
+
agents/keychains."""
|
256
|
+
return {
|
257
|
+
"GIT_SSH_COMMAND": (
|
258
|
+
"ssh -F /dev/null "
|
259
|
+
"-o IdentitiesOnly=yes "
|
260
|
+
"-o IdentityAgent=none "
|
261
|
+
"-o BatchMode=yes "
|
262
|
+
"-o PreferredAuthentications=publickey "
|
263
|
+
"-o StrictHostKeyChecking=yes "
|
264
|
+
"-o PasswordAuthentication=no "
|
265
|
+
"-o PubkeyAcceptedKeyTypes=+ssh-rsa "
|
266
|
+
"-o ConnectTimeout=10"
|
267
|
+
),
|
268
|
+
"SSH_AUTH_SOCK": "",
|
269
|
+
"SSH_AGENT_PID": "",
|
270
|
+
"SSH_ASKPASS": "/usr/bin/false",
|
271
|
+
"DISPLAY": "",
|
272
|
+
"SSH_ASKPASS_REQUIRE": "never",
|
273
|
+
}
|
274
|
+
|
275
|
+
|
257
276
|
def run_cmd_with_retries(
|
258
277
|
cmd: Sequence[str],
|
259
278
|
*,
|
@@ -271,6 +290,7 @@ def run_cmd_with_retries(
|
|
271
290
|
The default retry predicate considers common transient git errors.
|
272
291
|
"""
|
273
292
|
masks = list(masks or [])
|
293
|
+
env = env or non_interactive_env()
|
274
294
|
|
275
295
|
def _default_retry_on(res: CommandResult) -> bool:
|
276
296
|
return res.returncode != 0 and _is_transient_git_error(res.stderr)
|
@@ -308,8 +328,7 @@ def run_cmd_with_retries(
|
|
308
328
|
if predicate(res):
|
309
329
|
delay = _backoff_delay(attempt)
|
310
330
|
log.warning(
|
311
|
-
"Retrying (attempt %d) after transient error; delay %.1fs. "
|
312
|
-
"cmd=%s",
|
331
|
+
"Retrying (attempt %d) after transient error; delay %.1fs. cmd=%s",
|
313
332
|
attempt,
|
314
333
|
delay,
|
315
334
|
_format_cmd_for_log(cmd, masks),
|
@@ -370,7 +389,7 @@ def git_quiet(
|
|
370
389
|
return run_cmd(
|
371
390
|
cmd,
|
372
391
|
cwd=cwd,
|
373
|
-
env=env,
|
392
|
+
env=env or non_interactive_env(),
|
374
393
|
timeout=timeout,
|
375
394
|
check=False,
|
376
395
|
)
|
@@ -618,9 +637,7 @@ def git_config_get_all(
|
|
618
637
|
try:
|
619
638
|
res = git_quiet(args, cwd=None)
|
620
639
|
if res.returncode == 0:
|
621
|
-
values = [
|
622
|
-
ln.strip() for ln in res.stdout.splitlines() if ln.strip()
|
623
|
-
]
|
640
|
+
values = [ln.strip() for ln in res.stdout.splitlines() if ln.strip()]
|
624
641
|
return values
|
625
642
|
else:
|
626
643
|
return []
|