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