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.
@@ -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)
@@ -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 []
github2gerrit/models.py CHANGED
@@ -52,6 +52,7 @@ class Inputs:
52
52
  gerrit_project: str
53
53
  issue_id: str
54
54
  allow_duplicates: bool
55
+ duplicates_filter: str = "open"
55
56
 
56
57
 
57
58
  @dataclass(frozen=True)