github2gerrit 0.1.5__py3-none-any.whl → 0.1.7__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 +511 -271
- github2gerrit/commit_normalization.py +471 -0
- github2gerrit/config.py +32 -24
- github2gerrit/core.py +1092 -507
- github2gerrit/duplicate_detection.py +333 -217
- github2gerrit/external_api.py +518 -0
- github2gerrit/gerrit_rest.py +298 -0
- github2gerrit/gerrit_urls.py +353 -0
- github2gerrit/github_api.py +17 -95
- github2gerrit/gitutils.py +225 -41
- github2gerrit/models.py +3 -0
- github2gerrit/pr_content_filter.py +476 -0
- github2gerrit/similarity.py +458 -0
- github2gerrit/ssh_agent_setup.py +351 -0
- github2gerrit/ssh_common.py +244 -0
- github2gerrit/ssh_discovery.py +24 -67
- github2gerrit/utils.py +113 -0
- github2gerrit-0.1.7.dist-info/METADATA +798 -0
- github2gerrit-0.1.7.dist-info/RECORD +24 -0
- github2gerrit-0.1.5.dist-info/METADATA +0 -555
- github2gerrit-0.1.5.dist-info/RECORD +0 -15
- {github2gerrit-0.1.5.dist-info → github2gerrit-0.1.7.dist-info}/WHEEL +0 -0
- {github2gerrit-0.1.5.dist-info → github2gerrit-0.1.7.dist-info}/entry_points.txt +0 -0
- {github2gerrit-0.1.5.dist-info → github2gerrit-0.1.7.dist-info}/licenses/LICENSE +0 -0
- {github2gerrit-0.1.5.dist-info → github2gerrit-0.1.7.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,298 @@
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
2
|
+
# SPDX-FileCopyrightText: 2025 The Linux Foundation
|
3
|
+
"""
|
4
|
+
Gerrit REST helper with retry, timeout, and transient error detection.
|
5
|
+
|
6
|
+
This module provides a thin, typed wrapper for issuing Gerrit REST calls
|
7
|
+
with:
|
8
|
+
- Bounded retries using exponential backoff with jitter
|
9
|
+
- Request timeouts
|
10
|
+
- Transient error classification (HTTP 5xx/429 and common network errors)
|
11
|
+
- Centralized URL handling via GerritUrlBuilder
|
12
|
+
|
13
|
+
It prefers pygerrit2 when available and falls back to urllib otherwise.
|
14
|
+
|
15
|
+
Usage:
|
16
|
+
from github2gerrit.gerrit_rest import build_client_for_host
|
17
|
+
|
18
|
+
client = build_client_for_host("gerrit.example.org", timeout=8.0)
|
19
|
+
items = client.get("/changes/?q=project:foo limit:1&n=1&o=CURRENT_REVISION")
|
20
|
+
|
21
|
+
Design notes:
|
22
|
+
- The surface area is intentionally small and focused on JSON calls.
|
23
|
+
- Authentication (HTTP basic auth) is supported when username/password are
|
24
|
+
provided.
|
25
|
+
- Base URLs should be created via the URL builder to respect base paths.
|
26
|
+
"""
|
27
|
+
|
28
|
+
from __future__ import annotations
|
29
|
+
|
30
|
+
import base64
|
31
|
+
import json
|
32
|
+
import logging
|
33
|
+
import os
|
34
|
+
import urllib.error
|
35
|
+
import urllib.parse
|
36
|
+
import urllib.request
|
37
|
+
from dataclasses import dataclass
|
38
|
+
from typing import Any
|
39
|
+
from typing import Final
|
40
|
+
from urllib.parse import urljoin
|
41
|
+
|
42
|
+
from .external_api import ApiType
|
43
|
+
from .external_api import RetryPolicy
|
44
|
+
from .external_api import external_api_call
|
45
|
+
from .gerrit_urls import create_gerrit_url_builder
|
46
|
+
from .utils import log_exception_conditionally
|
47
|
+
|
48
|
+
|
49
|
+
log = logging.getLogger("github2gerrit.gerrit_rest")
|
50
|
+
|
51
|
+
# Optional pygerrit2 import
|
52
|
+
try: # pragma: no cover - exercised indirectly by tests that monkeypatch
|
53
|
+
from pygerrit2 import GerritRestAPI as _PygerritRestApi # type: ignore[import-not-found, unused-ignore]
|
54
|
+
from pygerrit2 import HTTPBasicAuth as _PygerritHttpAuth # type: ignore[import-not-found, unused-ignore]
|
55
|
+
except Exception: # pragma: no cover - absence path
|
56
|
+
_PygerritRestApi = None
|
57
|
+
_PygerritHttpAuth = None
|
58
|
+
|
59
|
+
|
60
|
+
_MSG_PYGERRIT2_REQUIRED_AUTH: Final[str] = "pygerrit2 is required for HTTP authentication"
|
61
|
+
|
62
|
+
_TRANSIENT_ERR_SUBSTRINGS: Final[tuple[str, ...]] = (
|
63
|
+
"timed out",
|
64
|
+
"temporarily unavailable",
|
65
|
+
"temporary failure",
|
66
|
+
"connection reset",
|
67
|
+
"connection aborted",
|
68
|
+
"broken pipe",
|
69
|
+
"connection refused",
|
70
|
+
"bad gateway",
|
71
|
+
"service unavailable",
|
72
|
+
"gateway timeout",
|
73
|
+
)
|
74
|
+
|
75
|
+
|
76
|
+
# Removed individual retry logic functions - now using centralized framework
|
77
|
+
|
78
|
+
|
79
|
+
class GerritRestError(RuntimeError):
|
80
|
+
"""Raised for non-retryable REST errors or exhausted retries."""
|
81
|
+
|
82
|
+
|
83
|
+
@dataclass(frozen=True)
|
84
|
+
class _Auth:
|
85
|
+
user: str
|
86
|
+
password: str
|
87
|
+
|
88
|
+
|
89
|
+
def _mask_secret(s: str) -> str:
|
90
|
+
if not s:
|
91
|
+
return s
|
92
|
+
if len(s) <= 4:
|
93
|
+
return "****"
|
94
|
+
return s[:2] + "*" * (len(s) - 4) + s[-2:]
|
95
|
+
|
96
|
+
|
97
|
+
class GerritRestClient:
|
98
|
+
"""
|
99
|
+
Simple JSON REST client for Gerrit with retry/timeout handling.
|
100
|
+
|
101
|
+
- If pygerrit2 is available, use it directly (preferred).
|
102
|
+
- Otherwise, use urllib with manual request construction.
|
103
|
+
"""
|
104
|
+
|
105
|
+
def __init__(
|
106
|
+
self,
|
107
|
+
*,
|
108
|
+
base_url: str,
|
109
|
+
auth: tuple[str, str] | None = None,
|
110
|
+
timeout: float = 8.0,
|
111
|
+
max_attempts: int = 5,
|
112
|
+
) -> None:
|
113
|
+
# Normalize base URL to end with '/'
|
114
|
+
base_url = base_url.rstrip("/") + "/"
|
115
|
+
self._base_url: str = base_url
|
116
|
+
self._timeout: float = float(timeout)
|
117
|
+
self._attempts: int = int(max_attempts)
|
118
|
+
self._retry_policy = RetryPolicy(
|
119
|
+
max_attempts=max_attempts,
|
120
|
+
timeout=timeout,
|
121
|
+
)
|
122
|
+
self._auth: _Auth | None = None
|
123
|
+
if auth and auth[0] and auth[1]:
|
124
|
+
self._auth = _Auth(auth[0], auth[1])
|
125
|
+
|
126
|
+
# Build pygerrit client if library is present; otherwise None
|
127
|
+
if _PygerritRestApi is not None:
|
128
|
+
if self._auth is not None:
|
129
|
+
if _PygerritHttpAuth is None:
|
130
|
+
raise GerritRestError(_MSG_PYGERRIT2_REQUIRED_AUTH)
|
131
|
+
self._client: Any = _PygerritRestApi(
|
132
|
+
url=self._base_url, auth=_PygerritHttpAuth(self._auth.user, self._auth.password)
|
133
|
+
)
|
134
|
+
else:
|
135
|
+
self._client = _PygerritRestApi(url=self._base_url)
|
136
|
+
else:
|
137
|
+
self._client = None
|
138
|
+
|
139
|
+
log.debug(
|
140
|
+
"GerritRestClient(base_url=%s, timeout=%.1fs, attempts=%d, auth_user=%s)",
|
141
|
+
self._base_url,
|
142
|
+
self._timeout,
|
143
|
+
self._attempts,
|
144
|
+
self._auth.user if self._auth else "",
|
145
|
+
)
|
146
|
+
|
147
|
+
# Public API
|
148
|
+
|
149
|
+
def get(self, path: str) -> Any:
|
150
|
+
"""HTTP GET, returning parsed JSON."""
|
151
|
+
return self._request_json_with_retry("GET", path)
|
152
|
+
|
153
|
+
def post(self, path: str, data: Any | None = None) -> Any:
|
154
|
+
"""HTTP POST with JSON payload, returning parsed JSON."""
|
155
|
+
return self._request_json_with_retry("POST", path, data=data)
|
156
|
+
|
157
|
+
def put(self, path: str, data: Any | None = None) -> Any:
|
158
|
+
"""HTTP PUT with JSON payload, returning parsed JSON."""
|
159
|
+
return self._request_json_with_retry("PUT", path, data=data)
|
160
|
+
|
161
|
+
# Internal helpers
|
162
|
+
|
163
|
+
def _request_json_with_retry(self, method: str, path: str, data: Any | None = None) -> Any:
|
164
|
+
"""Perform a JSON request with retry using external API framework."""
|
165
|
+
|
166
|
+
@external_api_call(ApiType.GERRIT_REST, f"{method.lower()}", policy=self._retry_policy)
|
167
|
+
def _do_request() -> Any:
|
168
|
+
return self._request_json(method, path, data)
|
169
|
+
|
170
|
+
return _do_request()
|
171
|
+
|
172
|
+
def _request_json(self, method: str, path: str, data: Any | None = None) -> Any:
|
173
|
+
"""Perform a JSON request (retry logic handled by decorator)."""
|
174
|
+
if not path:
|
175
|
+
msg_required = "path is required"
|
176
|
+
raise ValueError(msg_required)
|
177
|
+
|
178
|
+
# Normalize absolute vs relative path
|
179
|
+
rel = path[1:] if path.startswith("/") else path
|
180
|
+
url = urljoin(self._base_url, rel)
|
181
|
+
|
182
|
+
try:
|
183
|
+
if self._client is not None and method == "GET" and data is None:
|
184
|
+
# pygerrit2 path: only using GET to keep behavior consistent with current usage
|
185
|
+
log.debug("Gerrit REST GET via pygerrit2: %s", url)
|
186
|
+
# pygerrit2.get expects a relative path; keep 'path' argument as-is
|
187
|
+
return self._client.get("/" + rel if not path.startswith("/") else path)
|
188
|
+
|
189
|
+
# urllib path (or non-GET with pygerrit2 absent)
|
190
|
+
headers = {"Accept": "application/json"}
|
191
|
+
body_bytes: bytes | None = None
|
192
|
+
if data is not None:
|
193
|
+
headers["Content-Type"] = "application/json"
|
194
|
+
body_bytes = json.dumps(data).encode("utf-8")
|
195
|
+
|
196
|
+
if self._auth is not None:
|
197
|
+
token = base64.b64encode(f"{self._auth.user}:{self._auth.password}".encode()).decode("ascii")
|
198
|
+
headers["Authorization"] = f"Basic {token}"
|
199
|
+
scheme = urllib.parse.urlparse(url).scheme
|
200
|
+
if scheme not in ("http", "https"):
|
201
|
+
msg_scheme = f"Unsupported URL scheme for Gerrit REST: {scheme}"
|
202
|
+
raise GerritRestError(msg_scheme)
|
203
|
+
req = urllib.request.Request(url, data=body_bytes, method=method, headers=headers)
|
204
|
+
log.debug("Gerrit REST %s %s (auth_user=%s)", method, url, self._auth.user if self._auth else "")
|
205
|
+
|
206
|
+
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
207
|
+
status = getattr(resp, "status", None)
|
208
|
+
content = resp.read()
|
209
|
+
# Gerrit prepends ")]}'" in JSON responses to prevent JSON hijacking; strip if present
|
210
|
+
text = content.decode("utf-8", errors="replace")
|
211
|
+
text = _strip_xssi_guard(text)
|
212
|
+
return _json_loads(text)
|
213
|
+
|
214
|
+
except urllib.error.HTTPError as http_exc:
|
215
|
+
status = getattr(http_exc, "code", None)
|
216
|
+
msg = f"Gerrit REST {method} {url} failed with HTTP {status}"
|
217
|
+
log_exception_conditionally(log, msg)
|
218
|
+
raise GerritRestError(msg) from http_exc
|
219
|
+
|
220
|
+
except Exception as exc:
|
221
|
+
msg = f"Gerrit REST {method} {url} failed: {exc}"
|
222
|
+
log_exception_conditionally(log, msg)
|
223
|
+
raise GerritRestError(msg) from exc
|
224
|
+
|
225
|
+
def __repr__(self) -> str: # pragma: no cover - convenience
|
226
|
+
masked = ""
|
227
|
+
if self._auth is not None:
|
228
|
+
masked = f"{self._auth.user}:{_mask_secret(self._auth.password)}@"
|
229
|
+
return f"GerritRestClient(base_url='{self._base_url}', auth='{masked}')"
|
230
|
+
|
231
|
+
|
232
|
+
def _json_loads(s: str) -> Any:
|
233
|
+
try:
|
234
|
+
return json.loads(s)
|
235
|
+
except Exception as exc:
|
236
|
+
msg_parse = f"Failed to parse JSON response: {exc}"
|
237
|
+
raise GerritRestError(msg_parse) from exc
|
238
|
+
|
239
|
+
|
240
|
+
def _strip_xssi_guard(text: str) -> str:
|
241
|
+
# Gerrit typically prefixes JSON with XSSI guard ")]}'"
|
242
|
+
# Strip the guard and any trailing newline after it.
|
243
|
+
if text.startswith(")]}'"):
|
244
|
+
# Common patterns: ")]}'\n" or ")]}'\r\n"
|
245
|
+
if text[4:6] == "\r\n":
|
246
|
+
return text[6:]
|
247
|
+
if text[4:5] == "\n":
|
248
|
+
return text[5:]
|
249
|
+
return text[4:]
|
250
|
+
return text
|
251
|
+
|
252
|
+
|
253
|
+
# Removed _sleep function - using centralized retry framework
|
254
|
+
|
255
|
+
|
256
|
+
def build_client_for_host(
|
257
|
+
host: str,
|
258
|
+
*,
|
259
|
+
timeout: float = 8.0,
|
260
|
+
max_attempts: int = 5,
|
261
|
+
http_user: str | None = None,
|
262
|
+
http_password: str | None = None,
|
263
|
+
) -> GerritRestClient:
|
264
|
+
"""
|
265
|
+
Build a GerritRestClient for a given host using the centralized URL builder.
|
266
|
+
|
267
|
+
- Uses auto-discovered or environment-provided base path.
|
268
|
+
- Reads HTTP auth from arguments or environment:
|
269
|
+
GERRIT_HTTP_USER / GERRIT_HTTP_PASSWORD
|
270
|
+
If user is not provided, falls back to GERRIT_SSH_USER_G2G per project norms.
|
271
|
+
|
272
|
+
Args:
|
273
|
+
host: Gerrit hostname (no scheme)
|
274
|
+
timeout: Request timeout in seconds.
|
275
|
+
max_attempts: Max retry attempts for transient failures.
|
276
|
+
http_user: Optional HTTP user.
|
277
|
+
http_password: Optional HTTP password/token.
|
278
|
+
|
279
|
+
Returns:
|
280
|
+
Configured GerritRestClient.
|
281
|
+
"""
|
282
|
+
builder = create_gerrit_url_builder(host)
|
283
|
+
base_url = builder.api_url()
|
284
|
+
user = (
|
285
|
+
(http_user or "").strip()
|
286
|
+
or os.getenv("GERRIT_HTTP_USER", "").strip()
|
287
|
+
or os.getenv("GERRIT_SSH_USER_G2G", "").strip()
|
288
|
+
)
|
289
|
+
passwd = (http_password or "").strip() or os.getenv("GERRIT_HTTP_PASSWORD", "").strip()
|
290
|
+
auth: tuple[str, str] | None = (user, passwd) if user and passwd else None
|
291
|
+
return GerritRestClient(base_url=base_url, auth=auth, timeout=timeout, max_attempts=max_attempts)
|
292
|
+
|
293
|
+
|
294
|
+
__all__ = [
|
295
|
+
"GerritRestClient",
|
296
|
+
"GerritRestError",
|
297
|
+
"build_client_for_host",
|
298
|
+
]
|
@@ -0,0 +1,353 @@
|
|
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
|
+
import urllib.error
|
16
|
+
import urllib.parse
|
17
|
+
import urllib.request
|
18
|
+
from typing import Any
|
19
|
+
from urllib.parse import urljoin
|
20
|
+
|
21
|
+
|
22
|
+
log = logging.getLogger(__name__)
|
23
|
+
|
24
|
+
_BASE_PATH_CACHE: dict[str, str] = {}
|
25
|
+
|
26
|
+
|
27
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
28
|
+
def http_error_301(self, req: Any, fp: Any, code: int, msg: str, headers: Any) -> Any:
|
29
|
+
return fp
|
30
|
+
|
31
|
+
def http_error_302(self, req: Any, fp: Any, code: int, msg: str, headers: Any) -> Any:
|
32
|
+
return fp
|
33
|
+
|
34
|
+
def http_error_303(self, req: Any, fp: Any, code: int, msg: str, headers: Any) -> Any:
|
35
|
+
return fp
|
36
|
+
|
37
|
+
def http_error_307(self, req: Any, fp: Any, code: int, msg: str, headers: Any) -> Any:
|
38
|
+
return fp
|
39
|
+
|
40
|
+
def http_error_308(self, req: Any, fp: Any, code: int, msg: str, headers: Any) -> Any:
|
41
|
+
return fp
|
42
|
+
|
43
|
+
|
44
|
+
def _discover_base_path_for_host(host: str, timeout: float = 5.0) -> str:
|
45
|
+
"""
|
46
|
+
Discover Gerrit HTTP base path for the given host by probing redirects.
|
47
|
+
|
48
|
+
Strategy:
|
49
|
+
- Probe '/dashboard/self' and '/' without following redirects.
|
50
|
+
- If redirected, infer base path from the first non-endpoint path segment.
|
51
|
+
- If no redirect and 200 OK at '/dashboard/self', assume no base path.
|
52
|
+
- Cache discovery results per host for the process lifetime.
|
53
|
+
"""
|
54
|
+
try:
|
55
|
+
if not host:
|
56
|
+
return ""
|
57
|
+
cached = _BASE_PATH_CACHE.get(host)
|
58
|
+
if cached is not None:
|
59
|
+
return cached
|
60
|
+
|
61
|
+
opener = urllib.request.build_opener(_NoRedirect)
|
62
|
+
opener.addheaders = [("User-Agent", "github2gerrit/urls-discovery")]
|
63
|
+
probes = ["/dashboard/self", "/"]
|
64
|
+
known_endpoints = {
|
65
|
+
"changes",
|
66
|
+
"accounts",
|
67
|
+
"dashboard",
|
68
|
+
"c",
|
69
|
+
"q",
|
70
|
+
"admin",
|
71
|
+
"login",
|
72
|
+
"settings",
|
73
|
+
"plugins",
|
74
|
+
"Documentation",
|
75
|
+
}
|
76
|
+
|
77
|
+
for scheme in ("https", "http"):
|
78
|
+
for probe in probes:
|
79
|
+
url = f"{scheme}://{host}{probe}"
|
80
|
+
parsed_url = urllib.parse.urlparse(url)
|
81
|
+
if parsed_url.scheme not in ("https", "http"):
|
82
|
+
log.debug("Skipping non-HTTP(S) probe URL: %s", url)
|
83
|
+
continue
|
84
|
+
try:
|
85
|
+
resp = opener.open(url, timeout=timeout)
|
86
|
+
code = getattr(resp, "getcode", lambda: None)() or getattr(resp, "status", 0)
|
87
|
+
# If we reached the page without redirects
|
88
|
+
if code == 200:
|
89
|
+
_BASE_PATH_CACHE[host] = ""
|
90
|
+
log.info("Gerrit base path: ''")
|
91
|
+
return ""
|
92
|
+
# Handle 3xx responses when redirects are disabled (no-redirect opener)
|
93
|
+
if code in (301, 302, 303, 307, 308):
|
94
|
+
headers = getattr(resp, "headers", {}) or {}
|
95
|
+
loc = headers.get("Location") or headers.get("location") or ""
|
96
|
+
if loc:
|
97
|
+
# Normalize to absolute path
|
98
|
+
parsed = urllib.parse.urlparse(loc)
|
99
|
+
path = (
|
100
|
+
parsed.path
|
101
|
+
if parsed.scheme or parsed.netloc
|
102
|
+
else urllib.parse.urlparse(f"https://{host}{loc}").path
|
103
|
+
)
|
104
|
+
# Determine candidate base path
|
105
|
+
segs = [s for s in path.split("/") if s]
|
106
|
+
base = ""
|
107
|
+
if segs:
|
108
|
+
first = segs[0]
|
109
|
+
if first not in known_endpoints:
|
110
|
+
base = first
|
111
|
+
_BASE_PATH_CACHE[host] = base
|
112
|
+
log.info("Gerrit base path: '%s'", base)
|
113
|
+
return base
|
114
|
+
# If we get any other non-redirect response, try next probe
|
115
|
+
continue
|
116
|
+
except urllib.error.HTTPError as e:
|
117
|
+
# HTTPError doubles as the response; capture Location for redirects
|
118
|
+
code = e.code
|
119
|
+
loc = e.headers.get("Location") or e.headers.get("location") or ""
|
120
|
+
if code in (301, 302, 303, 307, 308) and loc:
|
121
|
+
# Normalize to absolute path
|
122
|
+
parsed = urllib.parse.urlparse(loc)
|
123
|
+
path = (
|
124
|
+
parsed.path
|
125
|
+
if parsed.scheme or parsed.netloc
|
126
|
+
else urllib.parse.urlparse(f"https://{host}{loc}").path
|
127
|
+
)
|
128
|
+
# Determine candidate base path
|
129
|
+
segs = [s for s in path.split("/") if s]
|
130
|
+
base = ""
|
131
|
+
if segs:
|
132
|
+
first = segs[0]
|
133
|
+
if first not in known_endpoints:
|
134
|
+
base = first
|
135
|
+
_BASE_PATH_CACHE[host] = base
|
136
|
+
log.info("Gerrit base path: '%s'", base)
|
137
|
+
return base
|
138
|
+
# Non-redirect error; try next probe
|
139
|
+
continue
|
140
|
+
except Exception as exc:
|
141
|
+
log.debug("Gerrit base path probe failed for %s%s: %s", host, probe, exc)
|
142
|
+
continue
|
143
|
+
|
144
|
+
except Exception as exc:
|
145
|
+
log.debug("Gerrit base path discovery error for %s: %s", host, exc)
|
146
|
+
return ""
|
147
|
+
# Default if nothing conclusive after exhausting all probes
|
148
|
+
_BASE_PATH_CACHE[host] = ""
|
149
|
+
log.info("Gerrit base path: ''")
|
150
|
+
return ""
|
151
|
+
|
152
|
+
|
153
|
+
class GerritUrlBuilder:
|
154
|
+
"""
|
155
|
+
Centralized builder for Gerrit URLs with consistent base path handling.
|
156
|
+
|
157
|
+
This class encapsulates all Gerrit URL construction logic, ensuring that
|
158
|
+
GERRIT_HTTP_BASE_PATH is properly handled in all contexts. It provides
|
159
|
+
methods for building different types of URLs (API, web, hooks) and handles
|
160
|
+
the common fallback patterns used throughout the application.
|
161
|
+
"""
|
162
|
+
|
163
|
+
def __init__(self, host: str, base_path: str | None = None):
|
164
|
+
"""
|
165
|
+
Initialize the URL builder for a specific Gerrit host.
|
166
|
+
|
167
|
+
Args:
|
168
|
+
host: Gerrit hostname (without protocol)
|
169
|
+
base_path: Optional base path override. If None, reads from
|
170
|
+
GERRIT_HTTP_BASE_PATH environment variable or discovers dynamically.
|
171
|
+
"""
|
172
|
+
self.host = host.strip()
|
173
|
+
|
174
|
+
# Normalize base path - remove leading/trailing slashes and whitespace
|
175
|
+
if base_path is not None:
|
176
|
+
self._base_path = base_path.strip().strip("/")
|
177
|
+
else:
|
178
|
+
env_bp = os.getenv("GERRIT_HTTP_BASE_PATH", "").strip().strip("/")
|
179
|
+
if env_bp:
|
180
|
+
self._base_path = env_bp
|
181
|
+
else:
|
182
|
+
discovered = _discover_base_path_for_host(self.host)
|
183
|
+
self._base_path = discovered.strip().strip("/")
|
184
|
+
|
185
|
+
log.debug(
|
186
|
+
"GerritUrlBuilder initialized for host=%s, base_path='%s'",
|
187
|
+
self.host,
|
188
|
+
self._base_path,
|
189
|
+
)
|
190
|
+
|
191
|
+
@property
|
192
|
+
def base_path(self) -> str:
|
193
|
+
"""Get the normalized base path."""
|
194
|
+
return self._base_path
|
195
|
+
|
196
|
+
@property
|
197
|
+
def has_base_path(self) -> bool:
|
198
|
+
"""Check if a base path is configured."""
|
199
|
+
return bool(self._base_path)
|
200
|
+
|
201
|
+
def _build_base_url(self, base_path_override: str | None = None) -> str:
|
202
|
+
"""
|
203
|
+
Build the base URL with optional base path override.
|
204
|
+
|
205
|
+
Args:
|
206
|
+
base_path_override: Optional base path to use instead of the instance default
|
207
|
+
|
208
|
+
Returns:
|
209
|
+
Base URL with trailing slash
|
210
|
+
"""
|
211
|
+
path = base_path_override if base_path_override is not None else self._base_path
|
212
|
+
if path:
|
213
|
+
return f"https://{self.host}/{path}/"
|
214
|
+
else:
|
215
|
+
return f"https://{self.host}/"
|
216
|
+
|
217
|
+
def api_url(self, endpoint: str = "", base_path_override: str | None = None) -> str:
|
218
|
+
"""
|
219
|
+
Build a Gerrit REST API URL.
|
220
|
+
|
221
|
+
Args:
|
222
|
+
endpoint: API endpoint path (e.g., "/changes/", "/accounts/self")
|
223
|
+
base_path_override: Optional base path override for fallback scenarios
|
224
|
+
|
225
|
+
Returns:
|
226
|
+
Complete API URL
|
227
|
+
"""
|
228
|
+
base_url = self._build_base_url(base_path_override)
|
229
|
+
# Ensure endpoint starts with / for proper URL joining
|
230
|
+
if endpoint and not endpoint.startswith("/"):
|
231
|
+
endpoint = "/" + endpoint
|
232
|
+
return urljoin(base_url, endpoint.lstrip("/"))
|
233
|
+
|
234
|
+
def web_url(self, path: str = "", base_path_override: str | None = None) -> str:
|
235
|
+
"""
|
236
|
+
Build a Gerrit web UI URL.
|
237
|
+
|
238
|
+
Args:
|
239
|
+
path: Web path (e.g., "c/project/+/123", "dashboard")
|
240
|
+
base_path_override: Optional base path override for fallback scenarios
|
241
|
+
|
242
|
+
Returns:
|
243
|
+
Complete web URL
|
244
|
+
"""
|
245
|
+
base_url = self._build_base_url(base_path_override)
|
246
|
+
if path:
|
247
|
+
# Remove leading slash if present to avoid double slashes
|
248
|
+
path = path.lstrip("/")
|
249
|
+
return urljoin(base_url, path)
|
250
|
+
return base_url.rstrip("/")
|
251
|
+
|
252
|
+
def change_url(
|
253
|
+
self,
|
254
|
+
project: str,
|
255
|
+
change_number: int,
|
256
|
+
base_path_override: str | None = None,
|
257
|
+
) -> str:
|
258
|
+
"""
|
259
|
+
Build a URL for a specific Gerrit change.
|
260
|
+
|
261
|
+
Args:
|
262
|
+
project: Gerrit project name
|
263
|
+
change_number: Gerrit change number
|
264
|
+
base_path_override: Optional base path override for fallback scenarios
|
265
|
+
|
266
|
+
Returns:
|
267
|
+
Complete change URL
|
268
|
+
"""
|
269
|
+
# Don't URL-encode project names - Gerrit expects them as-is (backward compatibility)
|
270
|
+
path = f"c/{project}/+/{change_number}"
|
271
|
+
return self.web_url(path, base_path_override)
|
272
|
+
|
273
|
+
def hook_url(self, hook_name: str, base_path_override: str | None = None) -> str:
|
274
|
+
"""
|
275
|
+
Build a URL for downloading Gerrit hooks.
|
276
|
+
|
277
|
+
Args:
|
278
|
+
hook_name: Name of the hook (e.g., "commit-msg")
|
279
|
+
base_path_override: Optional base path override for fallback scenarios
|
280
|
+
|
281
|
+
Returns:
|
282
|
+
Complete hook download URL
|
283
|
+
"""
|
284
|
+
path = f"tools/hooks/{hook_name}"
|
285
|
+
return self.web_url(path, base_path_override)
|
286
|
+
|
287
|
+
def get_api_url_candidates(self, endpoint: str = "") -> list[str]:
|
288
|
+
"""
|
289
|
+
Get the single API URL based on discovered/configured base path.
|
290
|
+
|
291
|
+
This method avoids hard-coded fallbacks by relying on dynamic detection
|
292
|
+
of Gerrit's HTTP base path (or explicit configuration).
|
293
|
+
|
294
|
+
Args:
|
295
|
+
endpoint: API endpoint path
|
296
|
+
|
297
|
+
Returns:
|
298
|
+
A single API URL to use
|
299
|
+
"""
|
300
|
+
return [self.api_url(endpoint)]
|
301
|
+
|
302
|
+
def get_hook_url_candidates(self, hook_name: str) -> list[str]:
|
303
|
+
"""
|
304
|
+
Get the single hook URL based on discovered/configured base path.
|
305
|
+
|
306
|
+
This method avoids hard-coded fallbacks by relying on dynamic detection
|
307
|
+
of Gerrit's HTTP base path (or explicit configuration).
|
308
|
+
|
309
|
+
Args:
|
310
|
+
hook_name: Name of the hook to download
|
311
|
+
|
312
|
+
Returns:
|
313
|
+
A single hook URL to use
|
314
|
+
"""
|
315
|
+
return [self.hook_url(hook_name)]
|
316
|
+
|
317
|
+
def get_web_base_path(self, base_path_override: str | None = None) -> str:
|
318
|
+
"""
|
319
|
+
Get the web base path for URL construction.
|
320
|
+
|
321
|
+
This is useful when you need just the path component for manual URL building.
|
322
|
+
|
323
|
+
Args:
|
324
|
+
base_path_override: Optional base path override
|
325
|
+
|
326
|
+
Returns:
|
327
|
+
Web base path with leading and trailing slashes (e.g., "/r/", "/")
|
328
|
+
"""
|
329
|
+
path = base_path_override if base_path_override is not None else self._base_path
|
330
|
+
if path:
|
331
|
+
return f"/{path}/"
|
332
|
+
else:
|
333
|
+
return "/"
|
334
|
+
|
335
|
+
def __repr__(self) -> str:
|
336
|
+
"""String representation for debugging."""
|
337
|
+
return f"GerritUrlBuilder(host='{self.host}', base_path='{self._base_path}')"
|
338
|
+
|
339
|
+
|
340
|
+
def create_gerrit_url_builder(host: str, base_path: str | None = None) -> GerritUrlBuilder:
|
341
|
+
"""
|
342
|
+
Factory function to create a GerritUrlBuilder instance.
|
343
|
+
|
344
|
+
This is the preferred way to create URL builders throughout the application.
|
345
|
+
|
346
|
+
Args:
|
347
|
+
host: Gerrit hostname
|
348
|
+
base_path: Optional base path override
|
349
|
+
|
350
|
+
Returns:
|
351
|
+
Configured GerritUrlBuilder instance
|
352
|
+
"""
|
353
|
+
return GerritUrlBuilder(host, base_path)
|