github2gerrit 0.1.4__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)
@@ -30,6 +30,12 @@ from typing import TypeVar
30
30
  from typing import cast
31
31
 
32
32
 
33
+ # Error message constants to comply with TRY003
34
+ _MSG_PYGITHUB_REQUIRED = "PyGithub required"
35
+ _MSG_MISSING_GITHUB_TOKEN = "missing GITHUB_TOKEN" # noqa: S105
36
+ _MSG_BAD_GITHUB_REPOSITORY = "bad GITHUB_REPOSITORY"
37
+
38
+
33
39
  class GithubExceptionType(Exception):
34
40
  pass
35
41
 
@@ -38,9 +44,7 @@ class RateLimitExceededExceptionType(GithubExceptionType):
38
44
  pass
39
45
 
40
46
 
41
- def _load_github_classes() -> tuple[
42
- Any | None, type[BaseException], type[BaseException]
43
- ]:
47
+ def _load_github_classes() -> tuple[Any | None, type[BaseException], type[BaseException]]:
44
48
  try:
45
49
  exc_mod = import_module("github.GithubException")
46
50
  ge = exc_mod.GithubException
@@ -65,7 +69,7 @@ else:
65
69
 
66
70
  class Github: # type: ignore[no-redef]
67
71
  def __init__(self, *args: Any, **kwargs: Any) -> None:
68
- raise RuntimeError("PyGithub required") # noqa: TRY003
72
+ raise RuntimeError(_MSG_PYGITHUB_REQUIRED)
69
73
 
70
74
 
71
75
  class GhIssueComment(Protocol):
@@ -171,8 +175,7 @@ def _retry_on_github(
171
175
  raise
172
176
  delay = _backoff_delay(attempt)
173
177
  log.warning(
174
- "GitHub call failed (attempt %d): %s; retrying in "
175
- "%.2fs",
178
+ "GitHub call failed (attempt %d): %s; retrying in %.2fs",
176
179
  attempt,
177
180
  exc,
178
181
  delay,
@@ -201,7 +204,7 @@ def build_client(token: str | None = None) -> GhClient:
201
204
  """
202
205
  tok = token or _getenv_str("GITHUB_TOKEN")
203
206
  if not tok:
204
- raise ValueError("missing GITHUB_TOKEN") # noqa: TRY003
207
+ raise ValueError(_MSG_MISSING_GITHUB_TOKEN)
205
208
  # per_page improves pagination; adjust as needed.
206
209
  base_url = _getenv_str("GITHUB_API_URL")
207
210
  if not base_url:
@@ -215,23 +218,17 @@ def build_client(token: str | None = None) -> GhClient:
215
218
  if auth_factory is not None and hasattr(auth_factory, "Token"):
216
219
  auth_obj = auth_factory.Token(tok)
217
220
  if base_url:
218
- client_any = Github(
219
- auth=auth_obj, per_page=100, base_url=base_url
220
- )
221
+ client_any = Github(auth=auth_obj, per_page=100, base_url=base_url)
221
222
  else:
222
223
  client_any = Github(auth=auth_obj, per_page=100)
223
224
  else:
224
225
  if base_url:
225
- client_any = Github(
226
- login_or_token=tok, per_page=100, base_url=base_url
227
- )
226
+ client_any = Github(login_or_token=tok, per_page=100, base_url=base_url)
228
227
  else:
229
228
  client_any = Github(login_or_token=tok, per_page=100)
230
229
  except Exception:
231
230
  if base_url:
232
- client_any = Github(
233
- login_or_token=tok, per_page=100, base_url=base_url
234
- )
231
+ client_any = Github(login_or_token=tok, per_page=100, base_url=base_url)
235
232
  else:
236
233
  client_any = Github(login_or_token=tok, per_page=100)
237
234
  return cast(GhClient, client_any)
@@ -242,7 +239,7 @@ def get_repo_from_env(client: GhClient) -> GhRepository:
242
239
  """Return the repository object based on GITHUB_REPOSITORY."""
243
240
  full = _getenv_str("GITHUB_REPOSITORY")
244
241
  if not full or "/" not in full:
245
- raise ValueError("bad GITHUB_REPOSITORY") # noqa: TRY003
242
+ raise ValueError(_MSG_BAD_GITHUB_REPOSITORY)
246
243
  repo = client.get_repo(full)
247
244
  return repo
248
245
 
@@ -327,7 +324,5 @@ def close_pr(pr: GhPullRequest, *, comment: str | None = None) -> None:
327
324
  try:
328
325
  create_pr_comment(pr, comment)
329
326
  except Exception as exc:
330
- log.warning(
331
- "Failed to add close comment to PR #%s: %s", pr.number, exc
332
- )
327
+ log.warning("Failed to add close comment to PR #%s: %s", pr.number, exc)
333
328
  pr.edit(state="closed")
github2gerrit/gitutils.py CHANGED
@@ -20,6 +20,20 @@ from collections.abc import Mapping
20
20
  from collections.abc import Sequence
21
21
  from dataclasses import dataclass
22
22
  from pathlib import Path
23
+ from typing import Any
24
+
25
+
26
+ def _is_verbose_mode() -> bool:
27
+ """Check if verbose mode is enabled via environment variable."""
28
+ return os.getenv("G2G_VERBOSE", "").lower() in ("true", "1", "yes")
29
+
30
+
31
+ def _log_exception_conditionally(logger: logging.Logger, message: str, *args: Any) -> None:
32
+ """Log exception with traceback only if verbose mode is enabled."""
33
+ if _is_verbose_mode():
34
+ logger.exception(message, *args)
35
+ else:
36
+ logger.error(message, *args)
23
37
 
24
38
 
25
39
  __all__ = [
@@ -41,6 +55,9 @@ __all__ = [
41
55
  "run_cmd_with_retries",
42
56
  ]
43
57
 
58
+ # Error message constants to comply with TRY003
59
+ _MSG_COMMIT_NO_MESSAGE = "Either message or message_file must be provided"
60
+
44
61
 
45
62
  _LOGGER_NAME = "github2gerrit.git"
46
63
  log = logging.getLogger(_LOGGER_NAME)
@@ -48,10 +65,7 @@ if not log.handlers:
48
65
  # Provide a minimal default if the app has not configured logging.
49
66
  level_name = os.getenv("G2G_LOG_LEVEL", "INFO").upper()
50
67
  level = getattr(logging, level_name, logging.INFO)
51
- fmt = (
52
- "%(asctime)s %(levelname)-8s %(name)s "
53
- "%(filename)s:%(lineno)d | %(message)s"
54
- )
68
+ fmt = "%(asctime)s %(levelname)-8s %(name)s %(filename)s:%(lineno)d | %(message)s"
55
69
  logging.basicConfig(level=level, format=fmt)
56
70
 
57
71
 
@@ -170,6 +184,7 @@ def run_cmd(
170
184
  - Raises CommandError on failure when check=True.
171
185
  """
172
186
  masks = list(masks or [])
187
+ env = env or non_interactive_env()
173
188
  env_full = _merge_env(None, env)
174
189
 
175
190
  log.debug("Executing: %s", _format_cmd_for_log(cmd, masks))
@@ -187,7 +202,7 @@ def run_cmd(
187
202
  )
188
203
  except subprocess.TimeoutExpired as exc:
189
204
  msg = f"Command timed out: {cmd!r}"
190
- log.exception(msg)
205
+ _log_exception_conditionally(log, msg)
191
206
  # TimeoutExpired carries 'output' and 'stderr' attributes,
192
207
  # which may be bytes depending on invocation context.
193
208
  out = getattr(exc, "output", None)
@@ -201,7 +216,7 @@ def run_cmd(
201
216
  ) from exc
202
217
  except OSError as exc:
203
218
  msg = f"Failed to execute command: {cmd!r} ({exc})"
204
- log.exception(msg)
219
+ _log_exception_conditionally(log, msg)
205
220
  raise CommandError(msg, cmd=cmd) from exc
206
221
 
207
222
  result = CommandResult(
@@ -235,6 +250,29 @@ def run_cmd(
235
250
  return result
236
251
 
237
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
+
238
276
  def run_cmd_with_retries(
239
277
  cmd: Sequence[str],
240
278
  *,
@@ -252,6 +290,7 @@ def run_cmd_with_retries(
252
290
  The default retry predicate considers common transient git errors.
253
291
  """
254
292
  masks = list(masks or [])
293
+ env = env or non_interactive_env()
255
294
 
256
295
  def _default_retry_on(res: CommandResult) -> bool:
257
296
  return res.returncode != 0 and _is_transient_git_error(res.stderr)
@@ -289,8 +328,7 @@ def run_cmd_with_retries(
289
328
  if predicate(res):
290
329
  delay = _backoff_delay(attempt)
291
330
  log.warning(
292
- "Retrying (attempt %d) after transient error; delay %.1fs. "
293
- "cmd=%s",
331
+ "Retrying (attempt %d) after transient error; delay %.1fs. cmd=%s",
294
332
  attempt,
295
333
  delay,
296
334
  _format_cmd_for_log(cmd, masks),
@@ -351,7 +389,7 @@ def git_quiet(
351
389
  return run_cmd(
352
390
  cmd,
353
391
  cwd=cwd,
354
- env=env,
392
+ env=env or non_interactive_env(),
355
393
  timeout=timeout,
356
394
  check=False,
357
395
  )
@@ -471,7 +509,7 @@ def git_commit_new(
471
509
  ) -> None:
472
510
  """Create a new commit using message or message_file."""
473
511
  if not message and not message_file:
474
- raise ValueError("Either message or message_file must be provided") # noqa: TRY003
512
+ raise ValueError(_MSG_COMMIT_NO_MESSAGE)
475
513
 
476
514
  args: list[str] = ["commit"]
477
515
  if signoff:
@@ -599,9 +637,7 @@ def git_config_get_all(
599
637
  try:
600
638
  res = git_quiet(args, cwd=None)
601
639
  if res.returncode == 0:
602
- values = [
603
- ln.strip() for ln in res.stdout.splitlines() if ln.strip()
604
- ]
640
+ values = [ln.strip() for ln in res.stdout.splitlines() if ln.strip()]
605
641
  return values
606
642
  else:
607
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)