github2gerrit 1.2.2__py3-none-any.whl → 1.2.4__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/core.py CHANGED
@@ -410,9 +410,13 @@ class Orchestrator:
410
410
 
411
411
  # Check if client has authentication
412
412
  if not client.is_authenticated:
413
+ from .gerrit_rest import warn_gerrit_credentials_unavailable
414
+
415
+ warn_gerrit_credentials_unavailable()
413
416
  log.debug(
414
- "Cannot update Gerrit change metadata: "
415
- "No credentials found (check .netrc or environment)"
417
+ "Cannot update Gerrit change metadata for %s: "
418
+ "no Gerrit REST credentials available",
419
+ change_id,
416
420
  )
417
421
  return False
418
422
 
@@ -380,12 +380,26 @@ def external_api_call(
380
380
  reason = (
381
381
  "final attempt" if is_final_attempt else "non-retryable"
382
382
  )
383
- log_exception_conditionally(
384
- log,
383
+ failure_msg = (
385
384
  f"[{api_type.value}] {operation} failed ({reason}) "
386
385
  f"after {attempt} attempt(s) in {duration:.2f}s: "
387
- f"{target}",
386
+ f"{target}"
388
387
  )
388
+ # Authentication/authorization failures (Gerrit REST
389
+ # 401/403) are surfaced once, closer to the request, as a
390
+ # concise warning. Avoid emitting a duplicate error/
391
+ # traceback for them here. Scope this strictly to Gerrit
392
+ # REST: other API types (e.g. GitHub) may also expose a
393
+ # ``.status`` attribute, and their auth/permission
394
+ # failures must retain error-level visibility.
395
+ is_gerrit_auth_failure = (
396
+ api_type == ApiType.GERRIT_REST
397
+ and getattr(exc, "status", None) in (401, 403)
398
+ )
399
+ if is_gerrit_auth_failure:
400
+ log.debug(failure_msg)
401
+ else:
402
+ log_exception_conditionally(log, failure_msg)
389
403
  _update_metrics(api_type, context, success=False, exc=exc)
390
404
  raise
391
405
  else:
@@ -11,6 +11,7 @@ merged and close the corresponding GitHub pull request that originated it.
11
11
  from __future__ import annotations
12
12
 
13
13
  import logging
14
+ import os
14
15
  import re
15
16
  from typing import Any
16
17
  from typing import Literal
@@ -1679,11 +1680,57 @@ def _build_gerrit_abandon_message(pr_obj: Any, pr_url: str) -> str:
1679
1680
  )
1680
1681
 
1681
1682
 
1683
+ def _abandon_change_via_ssh_if_possible(
1684
+ client: Any, change_number: str, message: str
1685
+ ) -> bool:
1686
+ """Attempt to abandon a change over SSH using ambient credentials.
1687
+
1688
+ Reads the Gerrit SSH connection details from the environment
1689
+ (populated by the action) and the host from the REST *client*.
1690
+
1691
+ Returns ``True`` only if the change was abandoned via SSH; ``False``
1692
+ when SSH is not configured or the SSH abandon did not succeed (in
1693
+ which case the caller should fall back to REST).
1694
+ """
1695
+ ssh_privkey = os.getenv("GERRIT_SSH_PRIVKEY_G2G", "").strip()
1696
+ ssh_user = os.getenv("GERRIT_SSH_USER_G2G", "").strip()
1697
+ if not ssh_privkey or not ssh_user:
1698
+ return False
1699
+
1700
+ host = os.getenv("GERRIT_SERVER", "").strip()
1701
+ if not host:
1702
+ host = getattr(client, "host", "") or ""
1703
+ if not host:
1704
+ return False
1705
+
1706
+ try:
1707
+ port = int(os.getenv("GERRIT_SERVER_PORT", "29418") or "29418")
1708
+ except ValueError:
1709
+ port = 29418
1710
+
1711
+ from .gerrit_ssh import abandon_change_via_ssh
1712
+
1713
+ return abandon_change_via_ssh(
1714
+ host=host,
1715
+ change_number=str(change_number),
1716
+ message=message,
1717
+ user=ssh_user,
1718
+ ssh_privkey=ssh_privkey,
1719
+ known_hosts=os.getenv("GERRIT_KNOWN_HOSTS", ""),
1720
+ port=port,
1721
+ )
1722
+
1723
+
1682
1724
  def _abandon_gerrit_change(
1683
1725
  client: Any, change_number: str, message: str
1684
1726
  ) -> None:
1685
1727
  """
1686
- Abandon a Gerrit change via REST API.
1728
+ Abandon a Gerrit change.
1729
+
1730
+ Prefers SSH (``gerrit review --abandon``) because mutating REST calls
1731
+ are rejected with HTTP 403 on Gerrit servers that do not allow
1732
+ unauthenticated REST writes and where only SSH credentials are
1733
+ configured. Falls back to the REST API when SSH is unavailable.
1687
1734
 
1688
1735
  Args:
1689
1736
  client: Gerrit REST client
@@ -1691,13 +1738,35 @@ def _abandon_gerrit_change(
1691
1738
  message: Abandon message
1692
1739
 
1693
1740
  Raises:
1694
- Exception: If abandon operation fails
1741
+ Exception: If the abandon operation fails
1695
1742
  """
1743
+ if _abandon_change_via_ssh_if_possible(client, change_number, message):
1744
+ log.debug(
1745
+ "Successfully abandoned Gerrit change %s via SSH", change_number
1746
+ )
1747
+ return
1748
+
1696
1749
  try:
1750
+ log.debug(
1751
+ "Falling back to REST abandon for Gerrit change %s", change_number
1752
+ )
1697
1753
  abandon_path = f"/changes/{change_number}/abandon"
1698
1754
  abandon_data = {"message": message}
1699
1755
  client.post(abandon_path, data=abandon_data)
1700
1756
  log.debug("Successfully abandoned Gerrit change %s", change_number)
1757
+ except GerritRestError as exc:
1758
+ if exc.is_auth_error:
1759
+ # Expected when no Gerrit REST credentials are available; the
1760
+ # REST layer already surfaced this once. Avoid a duplicate
1761
+ # error-level traceback for an authentication failure.
1762
+ log.debug(
1763
+ "REST abandon for Gerrit change %s failed (HTTP %s)",
1764
+ change_number,
1765
+ exc.status,
1766
+ )
1767
+ else:
1768
+ log.exception("Failed to abandon Gerrit change %s", change_number)
1769
+ raise
1701
1770
  except Exception:
1702
1771
  log.exception("Failed to abandon Gerrit change %s", change_number)
1703
1772
  raise
@@ -13,6 +13,7 @@ from typing import Any
13
13
  from urllib.parse import quote
14
14
 
15
15
  from .gerrit_rest import GerritRestClient
16
+ from .gerrit_rest import warn_gerrit_credentials_unavailable
16
17
 
17
18
 
18
19
  log = logging.getLogger(__name__)
@@ -148,6 +149,20 @@ def query_open_changes_by_project(
148
149
  query = f'project:"{_gerrit_quote(project)}" status:open owner:self'
149
150
  if branch:
150
151
  query += f' branch:"{_gerrit_quote(branch)}"'
152
+
153
+ # The ``owner:self`` predicate requires an authenticated Gerrit
154
+ # session; an anonymous request is rejected with HTTP 403. Skip the
155
+ # query (warning once per run) instead of issuing a request that is
156
+ # guaranteed to fail and would otherwise emit error-level noise.
157
+ if not client.is_authenticated:
158
+ warn_gerrit_credentials_unavailable()
159
+ log.debug(
160
+ "Skipping owner:self query for project '%s': "
161
+ "no Gerrit REST credentials available",
162
+ project,
163
+ )
164
+ return []
165
+
151
166
  log.debug("Querying Gerrit for open changes: %s", query)
152
167
 
153
168
  try:
@@ -46,6 +46,7 @@ from .gerrit_urls import create_gerrit_url_builder
46
46
  from .netrc import GerritCredentials
47
47
  from .netrc import resolve_gerrit_credentials
48
48
  from .utils import log_exception_conditionally
49
+ from .utils import log_warning_once
49
50
 
50
51
 
51
52
  log = logging.getLogger("github2gerrit.gerrit_rest")
@@ -80,12 +81,79 @@ _TRANSIENT_ERR_SUBSTRINGS: Final[tuple[str, ...]] = (
80
81
  "gateway timeout",
81
82
  )
82
83
 
84
+ # HTTP status codes that indicate an authentication/authorization problem
85
+ # rather than a transient fault or a bug. These are expected when no
86
+ # Gerrit REST credentials are available (or they are insufficient for the
87
+ # requested operation), and are surfaced as concise, default-visible
88
+ # warnings rather than error-level tracebacks.
89
+ _AUTH_ERROR_STATUSES: Final[tuple[int, ...]] = (401, 403)
90
+
91
+ # Shared dedup key so the "no Gerrit REST credentials" situation is
92
+ # surfaced at most once per run, regardless of how many auth-gated
93
+ # operations are affected.
94
+ _CREDENTIALS_UNAVAILABLE_KEY: Final[str] = "gerrit_rest_credentials_unavailable"
95
+
96
+
97
+ def _is_auth_status(status: int | None) -> bool:
98
+ """Return True if the HTTP status indicates an auth/authorization error."""
99
+ return status in _AUTH_ERROR_STATUSES
100
+
101
+
102
+ def _extract_http_status(exc: BaseException) -> int | None:
103
+ """Best-effort extraction of an HTTP status code from an exception.
104
+
105
+ Handles both the urllib path (``urllib.error.HTTPError.code``) and the
106
+ pygerrit2/requests path (``HTTPError.response.status_code``).
107
+ """
108
+ code = getattr(exc, "code", None)
109
+ if isinstance(code, int):
110
+ return code
111
+ response = getattr(exc, "response", None)
112
+ status = getattr(response, "status_code", None)
113
+ if isinstance(status, int):
114
+ return status
115
+ return None
116
+
117
+
118
+ def warn_gerrit_credentials_unavailable() -> None:
119
+ """Warn once per run that authenticated Gerrit REST is unavailable.
120
+
121
+ Call this from auth-gated code paths before skipping an operation that
122
+ requires Gerrit REST credentials. The warning is emitted at most once
123
+ per process to avoid log spam while still making the degraded behavior
124
+ visible at the default log level.
125
+ """
126
+ log_warning_once(
127
+ log,
128
+ _CREDENTIALS_UNAVAILABLE_KEY,
129
+ "Gerrit REST credentials are not available; authenticated Gerrit "
130
+ "REST operations are disabled. Fallback behavior may apply and "
131
+ "could degrade performance. Provide GERRIT_HTTP_USER and "
132
+ "GERRIT_HTTP_PASSWORD (or a .netrc entry) to enable authenticated "
133
+ "Gerrit REST access.",
134
+ )
135
+
83
136
 
84
137
  # Removed individual retry logic functions - now using centralized framework
85
138
 
86
139
 
87
140
  class GerritRestError(RuntimeError):
88
- """Raised for non-retryable REST errors or exhausted retries."""
141
+ """Raised for non-retryable REST errors or exhausted retries.
142
+
143
+ Args:
144
+ status: HTTP status code associated with the failure, when known.
145
+ Used by callers to distinguish authentication/authorization
146
+ problems (401/403) from transient or unexpected errors.
147
+ """
148
+
149
+ def __init__(self, *args: object, status: int | None = None) -> None:
150
+ super().__init__(*args)
151
+ self.status = status
152
+
153
+ @property
154
+ def is_auth_error(self) -> bool:
155
+ """Return True if this error stems from an auth failure (401/403)."""
156
+ return _is_auth_status(self.status)
89
157
 
90
158
 
91
159
  @dataclass(frozen=True)
@@ -163,6 +231,19 @@ class GerritRestClient:
163
231
  """Return True if client has authentication credentials."""
164
232
  return self._auth is not None
165
233
 
234
+ @property
235
+ def host(self) -> str:
236
+ """Return the Gerrit hostname derived from the base URL.
237
+
238
+ Returns an empty string when the host cannot be determined.
239
+ """
240
+ from urllib.parse import urlparse
241
+
242
+ try:
243
+ return urlparse(self._base_url).hostname or ""
244
+ except Exception:
245
+ return ""
246
+
166
247
  def get(self, path: str) -> Any:
167
248
  """HTTP GET, returning parsed JSON."""
168
249
  return self._request_json_with_retry("GET", path)
@@ -251,13 +332,38 @@ class GerritRestClient:
251
332
  except urllib.error.HTTPError as http_exc:
252
333
  status = getattr(http_exc, "code", None)
253
334
  msg = f"Gerrit REST {method} {url} failed with HTTP {status}"
254
- log_exception_conditionally(log, msg)
255
- raise GerritRestError(msg) from http_exc
335
+ self._log_request_failure(msg, status)
336
+ raise GerritRestError(msg, status=status) from http_exc
256
337
 
257
338
  except Exception as exc:
339
+ status = _extract_http_status(exc)
258
340
  msg = f"Gerrit REST {method} {url} failed: {exc}"
341
+ self._log_request_failure(msg, status)
342
+ raise GerritRestError(msg, status=status) from exc
343
+
344
+ @staticmethod
345
+ def _log_request_failure(msg: str, status: int | None) -> None:
346
+ """Log a failed REST request at an appropriate level.
347
+
348
+ Authentication/authorization failures (401/403) are expected when
349
+ credentials are missing or insufficient; they are surfaced as a
350
+ single concise warning per run rather than an error-level traceback,
351
+ since they are neither transient faults nor bugs. All other failures
352
+ retain the existing conditional error/traceback behavior.
353
+ """
354
+ if _is_auth_status(status):
355
+ log_warning_once(
356
+ log,
357
+ f"gerrit_rest_auth_{status}",
358
+ "Gerrit REST authentication failed (HTTP %s): the configured "
359
+ "credentials are missing or insufficient for this operation. "
360
+ "Fallback behavior may apply and could degrade performance. "
361
+ "Details: %s",
362
+ status,
363
+ msg,
364
+ )
365
+ else:
259
366
  log_exception_conditionally(log, msg)
260
- raise GerritRestError(msg) from exc
261
367
 
262
368
  def __repr__(self) -> str: # pragma: no cover - convenience
263
369
  masked = ""
@@ -372,4 +478,5 @@ __all__ = [
372
478
  "GerritRestClient",
373
479
  "GerritRestError",
374
480
  "build_client_for_host",
481
+ "warn_gerrit_credentials_unavailable",
375
482
  ]
@@ -0,0 +1,284 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2025 The Linux Foundation
3
+
4
+ """
5
+ SSH-based Gerrit operations.
6
+
7
+ Some Gerrit deployments (notably those fronted by a CDN/WAF, or that
8
+ disallow anonymous REST writes) reject mutating REST calls such as
9
+ ``POST /changes/{id}/abandon`` with HTTP 403 unless dedicated HTTP API
10
+ credentials are supplied. The github2gerrit action authenticates to
11
+ Gerrit over SSH (the same channel used to push changes), so this module
12
+ performs the abandon over SSH instead, reusing the already-configured
13
+ SSH key and known_hosts.
14
+
15
+ The single public entry point :func:`abandon_change_via_ssh` is designed
16
+ to be used as the preferred path with a REST fallback: it never raises
17
+ and returns ``True`` only when the change was actually abandoned.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import logging
24
+ import os
25
+ import secrets
26
+ import shlex
27
+ import tempfile
28
+ from pathlib import Path
29
+
30
+ from .gitutils import CommandError
31
+ from .gitutils import run_cmd
32
+ from .ssh_common import augment_known_hosts_with_bracketed_entries
33
+ from .ssh_common import build_non_interactive_ssh_env
34
+
35
+
36
+ log = logging.getLogger(__name__)
37
+
38
+ DEFAULT_GERRIT_SSH_PORT = 29418
39
+ _SSH_TIMEOUT_SECONDS = 30.0
40
+
41
+
42
+ def _write_secure_file(path: Path, content: str, mode: int) -> None:
43
+ """Write *content* to *path* with restrictive *mode* permissions."""
44
+ # Create with secure permissions from the start (avoid a window where
45
+ # the file is world-readable).
46
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode)
47
+ try:
48
+ handle = os.fdopen(fd, "w", encoding="utf-8")
49
+ except BaseException:
50
+ # os.fdopen did not take ownership of fd; close it to avoid a leak.
51
+ os.close(fd)
52
+ raise
53
+ with handle:
54
+ handle.write(content)
55
+ # Re-assert mode in case umask altered it.
56
+ path.chmod(mode)
57
+
58
+
59
+ def _build_ssh_base_argv(
60
+ *,
61
+ key_path: Path,
62
+ known_hosts_path: Path | None,
63
+ port: int,
64
+ user: str,
65
+ host: str,
66
+ ) -> list[str]:
67
+ """Build the common ``ssh`` argv prefix for non-interactive auth."""
68
+ argv: list[str] = [
69
+ "ssh",
70
+ "-F",
71
+ "/dev/null",
72
+ "-i",
73
+ str(key_path),
74
+ "-o",
75
+ "IdentitiesOnly=yes",
76
+ "-o",
77
+ "IdentityAgent=none",
78
+ "-o",
79
+ "BatchMode=yes",
80
+ "-o",
81
+ "PreferredAuthentications=publickey",
82
+ "-o",
83
+ "PasswordAuthentication=no",
84
+ "-o",
85
+ "PubkeyAcceptedKeyTypes=+ssh-rsa",
86
+ "-o",
87
+ "ConnectTimeout=10",
88
+ ]
89
+ if known_hosts_path is not None:
90
+ argv += [
91
+ "-o",
92
+ f"UserKnownHostsFile={known_hosts_path}",
93
+ "-o",
94
+ "StrictHostKeyChecking=yes",
95
+ ]
96
+ else:
97
+ # No known_hosts supplied: we cannot verify the host key, but we
98
+ # still want a non-interactive connection. accept-new records the
99
+ # key on first use; pair it with an explicit (throwaway)
100
+ # UserKnownHostsFile so OpenSSH does not mutate the runner's
101
+ # default ~/.ssh/known_hosts (which may be missing/unwritable).
102
+ log.warning(
103
+ "No GERRIT_KNOWN_HOSTS available for SSH abandon; using "
104
+ "StrictHostKeyChecking=accept-new with a throwaway known_hosts"
105
+ )
106
+ argv += [
107
+ "-o",
108
+ "UserKnownHostsFile=/dev/null",
109
+ "-o",
110
+ "StrictHostKeyChecking=accept-new",
111
+ ]
112
+ argv += ["-n", "-p", str(port), f"{user}@{host}"]
113
+ return argv
114
+
115
+
116
+ def _resolve_current_patchset(
117
+ base_argv: list[str],
118
+ change_number: str,
119
+ env: dict[str, str],
120
+ ) -> str | None:
121
+ """Return the current patch-set number for *change_number*, or None."""
122
+ remote_cmd = (
123
+ "gerrit query --format=JSON --current-patch-set "
124
+ f"change:{shlex.quote(change_number)}"
125
+ )
126
+ try:
127
+ result = run_cmd(
128
+ [*base_argv, remote_cmd],
129
+ timeout=_SSH_TIMEOUT_SECONDS,
130
+ env=env,
131
+ )
132
+ except CommandError as exc:
133
+ log.debug(
134
+ "Gerrit SSH query for change %s failed: %s",
135
+ change_number,
136
+ exc,
137
+ )
138
+ return None
139
+
140
+ for raw_line in result.stdout.splitlines():
141
+ line = raw_line.strip()
142
+ if not line:
143
+ continue
144
+ try:
145
+ record = json.loads(line)
146
+ except (ValueError, TypeError):
147
+ continue
148
+ patch_set = record.get("currentPatchSet")
149
+ if isinstance(patch_set, dict) and patch_set.get("number") is not None:
150
+ return str(patch_set["number"])
151
+ log.debug(
152
+ "No currentPatchSet found in Gerrit query output for change %s",
153
+ change_number,
154
+ )
155
+ return None
156
+
157
+
158
+ def abandon_change_via_ssh(
159
+ *,
160
+ host: str,
161
+ change_number: str,
162
+ message: str,
163
+ user: str,
164
+ ssh_privkey: str,
165
+ known_hosts: str | None = None,
166
+ port: int = DEFAULT_GERRIT_SSH_PORT,
167
+ ) -> bool:
168
+ """Abandon a Gerrit change over SSH using ``gerrit review --abandon``.
169
+
170
+ This is the preferred abandon path because it uses the same SSH
171
+ credentials that the action already relies on to push changes, and
172
+ therefore works on Gerrit servers that reject unauthenticated REST
173
+ writes.
174
+
175
+ Args:
176
+ host: Gerrit SSH hostname (no scheme).
177
+ change_number: Numeric Gerrit change number.
178
+ message: Abandon message (may be multi-line).
179
+ user: Gerrit SSH username.
180
+ ssh_privkey: SSH private key content.
181
+ known_hosts: Optional known_hosts content for host verification.
182
+ port: Gerrit SSH port (default 29418).
183
+
184
+ Returns:
185
+ ``True`` if the change was abandoned successfully; ``False`` when
186
+ prerequisites are missing or the SSH operation failed (so the
187
+ caller may fall back to another mechanism). This function never
188
+ raises.
189
+ """
190
+ if not (host and user and ssh_privkey and str(change_number).strip()):
191
+ log.debug(
192
+ "SSH abandon prerequisites missing (host=%s, user=%s, "
193
+ "key=%s, change=%s); skipping SSH abandon",
194
+ bool(host),
195
+ bool(user),
196
+ bool(ssh_privkey),
197
+ change_number,
198
+ )
199
+ return False
200
+
201
+ change_number = str(change_number).strip()
202
+ try:
203
+ tmp_dir = Path(
204
+ tempfile.mkdtemp(prefix=f"g2g_abandon_{secrets.token_hex(8)}_")
205
+ )
206
+ except OSError as exc:
207
+ # Honor the "never raises" contract so callers can fall back to REST.
208
+ log.debug("Could not create temp dir for SSH abandon: %s", exc)
209
+ return False
210
+ try:
211
+ tmp_dir.chmod(0o700)
212
+ key_path = tmp_dir / "gerrit_key"
213
+ _write_secure_file(key_path, ssh_privkey.strip() + "\n", 0o600)
214
+
215
+ known_hosts_path: Path | None = None
216
+ if known_hosts and known_hosts.strip():
217
+ # OpenSSH looks up host keys under a bracketed "[host]:port"
218
+ # entry when connecting on a non-default port (Gerrit uses
219
+ # 29418). Augment the provided content with bracketed variants
220
+ # so StrictHostKeyChecking can verify the key and we don't fall
221
+ # back to REST unnecessarily.
222
+ augmented = augment_known_hosts_with_bracketed_entries(
223
+ known_hosts.strip(), host, port
224
+ )
225
+ known_hosts_path = tmp_dir / "known_hosts"
226
+ _write_secure_file(known_hosts_path, augmented, 0o644)
227
+
228
+ base_argv = _build_ssh_base_argv(
229
+ key_path=key_path,
230
+ known_hosts_path=known_hosts_path,
231
+ port=port,
232
+ user=user,
233
+ host=host,
234
+ )
235
+
236
+ # Disable any ambient SSH agent so only the provided key is used.
237
+ ssh_env = build_non_interactive_ssh_env()
238
+
239
+ patch_set = _resolve_current_patchset(base_argv, change_number, ssh_env)
240
+ if patch_set is None:
241
+ log.debug(
242
+ "Could not resolve current patch-set for change %s via SSH",
243
+ change_number,
244
+ )
245
+ return False
246
+
247
+ target = f"{change_number},{patch_set}"
248
+ remote_cmd = (
249
+ "gerrit review --abandon "
250
+ f"-m {shlex.quote(message)} {shlex.quote(target)}"
251
+ )
252
+ try:
253
+ run_cmd(
254
+ [*base_argv, remote_cmd],
255
+ timeout=_SSH_TIMEOUT_SECONDS,
256
+ env=ssh_env,
257
+ )
258
+ except CommandError as exc:
259
+ log.warning(
260
+ "SSH abandon failed for change %s: %s",
261
+ change_number,
262
+ exc,
263
+ )
264
+ return False
265
+ else:
266
+ log.debug(
267
+ "Successfully abandoned Gerrit change %s via SSH",
268
+ change_number,
269
+ )
270
+ return True
271
+ except Exception:
272
+ log.warning(
273
+ "Unexpected error during SSH abandon for change %s",
274
+ change_number,
275
+ exc_info=True,
276
+ )
277
+ return False
278
+ finally:
279
+ try:
280
+ import shutil
281
+
282
+ shutil.rmtree(tmp_dir, ignore_errors=True)
283
+ except Exception:
284
+ log.debug("Failed to clean up SSH temp dir %s", tmp_dir)
@@ -511,13 +511,25 @@ def _abandon_orphan_changes(
511
511
 
512
512
  from github2gerrit.gerrit_rest import GerritRestError
513
513
  from github2gerrit.gerrit_rest import build_client_for_host
514
+ from github2gerrit.gerrit_rest import warn_gerrit_credentials_unavailable
514
515
 
515
- abandoned = []
516
+ abandoned: list[str] = []
516
517
  try:
517
518
  client = build_client_for_host(
518
519
  gerrit.host, timeout=10.0, max_attempts=3
519
520
  )
520
521
 
522
+ # Abandoning a change is a mutating REST call that requires
523
+ # authentication; without credentials every attempt would 403.
524
+ # Warn once and skip rather than emitting per-change errors.
525
+ if not client.is_authenticated:
526
+ warn_gerrit_credentials_unavailable()
527
+ log.debug(
528
+ "Skipping orphan-change abandon: "
529
+ "no Gerrit REST credentials available"
530
+ )
531
+ return abandoned
532
+
521
533
  for change_id in orphan_ids:
522
534
  try:
523
535
  abandon_message = (
@@ -559,13 +571,25 @@ def _comment_orphan_changes(
559
571
 
560
572
  from github2gerrit.gerrit_rest import GerritRestError
561
573
  from github2gerrit.gerrit_rest import build_client_for_host
574
+ from github2gerrit.gerrit_rest import warn_gerrit_credentials_unavailable
562
575
 
563
- commented = []
576
+ commented: list[str] = []
564
577
  try:
565
578
  client = build_client_for_host(
566
579
  gerrit.host, timeout=10.0, max_attempts=3
567
580
  )
568
581
 
582
+ # Posting a review comment is a mutating REST call that requires
583
+ # authentication; without credentials every attempt would 403.
584
+ # Warn once and skip rather than emitting per-change errors.
585
+ if not client.is_authenticated:
586
+ warn_gerrit_credentials_unavailable()
587
+ log.debug(
588
+ "Skipping orphan-change comment: "
589
+ "no Gerrit REST credentials available"
590
+ )
591
+ return commented
592
+
569
593
  for change_id in orphan_ids:
570
594
  try:
571
595
  comment_message = (
@@ -42,6 +42,13 @@ _DANGEROUS_HTML_PATTERN = re.compile(
42
42
  )
43
43
  _MULTIPLE_NEWLINES_PATTERN = re.compile(r"\n{3,}")
44
44
  _EMOJI_PATTERN = re.compile(r":[a-z_]+:") # GitHub emoji codes like :sparkles:
45
+ # Dependabot embeds compatibility-score badges proxied through GitHub's
46
+ # camo image host. Detected via a regex rather than a substring membership
47
+ # check (the latter trips CodeQL's incomplete-url-substring-sanitization
48
+ # heuristic and is a weaker way to match a URL).
49
+ _CAMO_IMAGE_URL_PATTERN = re.compile(
50
+ r"https://camo\.githubusercontent\.com/", re.IGNORECASE
51
+ )
45
52
 
46
53
 
47
54
  @dataclass
@@ -112,7 +119,7 @@ class DependabotRule(FilterRule):
112
119
  "Bumps " in title and " from " in title and " to " in title,
113
120
  "Dependabot will resolve any conflicts" in body,
114
121
  "<details>" in body and "<summary>" in body,
115
- "https://camo.githubusercontent.com/" in body,
122
+ bool(_CAMO_IMAGE_URL_PATTERN.search(body)),
116
123
  ]
117
124
 
118
125
  # Require multiple indicators for confidence
github2gerrit/utils.py CHANGED
@@ -10,6 +10,7 @@ and ensure consistent behavior.
10
10
 
11
11
  import logging
12
12
  import os
13
+ import threading
13
14
  from typing import Any
14
15
 
15
16
 
@@ -92,6 +93,50 @@ def log_exception_conditionally(
92
93
  logger.error(message, *args)
93
94
 
94
95
 
96
+ _WARNED_ONCE_LOCK = threading.Lock()
97
+ _WARNED_ONCE_KEYS: set[str] = set()
98
+
99
+
100
+ def log_warning_once(
101
+ logger: logging.Logger, dedup_key: str, message: str, *args: Any
102
+ ) -> None:
103
+ """Emit a warning at most once per process for a given dedup key.
104
+
105
+ Useful for expected, recurring conditions (for example, an
106
+ authentication-gated operation that is skipped because no
107
+ credentials are available) where surfacing the situation once is
108
+ helpful but repeating it for every affected item would be noisy.
109
+
110
+ Thread-safe: PRs may be processed concurrently (see the
111
+ ``ThreadPoolExecutor`` in ``cli._process_bulk``), so the
112
+ check-and-record step is guarded by a lock to preserve the
113
+ warn-once guarantee. The warning itself is emitted outside the lock
114
+ to avoid holding it during logging I/O.
115
+
116
+ Args:
117
+ logger: Logger instance to use.
118
+ dedup_key: Stable key identifying the warning; subsequent calls
119
+ with the same key are suppressed.
120
+ message: Log message format string.
121
+ *args: Arguments for message formatting.
122
+ """
123
+ with _WARNED_ONCE_LOCK:
124
+ if dedup_key in _WARNED_ONCE_KEYS:
125
+ return
126
+ _WARNED_ONCE_KEYS.add(dedup_key)
127
+ logger.warning(message, *args)
128
+
129
+
130
+ def reset_warning_once() -> None:
131
+ """Clear the warn-once dedup cache.
132
+
133
+ Primarily intended for tests that need to assert warn-once behavior
134
+ across independent cases within a single process.
135
+ """
136
+ with _WARNED_ONCE_LOCK:
137
+ _WARNED_ONCE_KEYS.clear()
138
+
139
+
95
140
  def append_github_output(outputs: dict[str, str]) -> None:
96
141
  """Append key-value pairs to GitHub Actions output file.
97
142
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github2gerrit
3
- Version: 1.2.2
3
+ Version: 1.2.4
4
4
  Summary: Submit a GitHub pull request to a Gerrit repository.
5
5
  Project-URL: Homepage, https://github.com/lfreleng-actions/github2gerrit-action
6
6
  Project-URL: Repository, https://github.com/lfreleng-actions/github2gerrit-action
@@ -23,7 +23,7 @@ Classifier: Topic :: Software Development :: Version Control
23
23
  Classifier: Typing :: Typed
24
24
  Requires-Python: >=3.11
25
25
  Requires-Dist: click>=8.1.7
26
- Requires-Dist: cryptography>=46.0.5
26
+ Requires-Dist: cryptography>=48.0.1
27
27
  Requires-Dist: git-review>=2.5.0
28
28
  Requires-Dist: pygerrit2>=2.0.15
29
29
  Requires-Dist: pygithub>=2.8.1
@@ -963,7 +963,7 @@ name: github2gerrit
963
963
 
964
964
  on:
965
965
  pull_request_target:
966
- types: [opened, reopened, edited, synchronize]
966
+ types: [opened, reopened, edited, synchronize, closed]
967
967
  workflow_dispatch:
968
968
 
969
969
  permissions:
@@ -1192,7 +1192,7 @@ name: github2gerrit (advanced)
1192
1192
 
1193
1193
  on:
1194
1194
  pull_request_target:
1195
- types: [opened, reopened, edited, synchronize]
1195
+ types: [opened, reopened, edited, synchronize, closed]
1196
1196
  workflow_dispatch:
1197
1197
 
1198
1198
  permissions:
@@ -4,13 +4,14 @@ github2gerrit/commit_normalization.py,sha256=6FOkNfYvR_wicBAhbd4gcwVlkgnqdVFWvUF
4
4
  github2gerrit/commit_rules.py,sha256=GMlCYwuZb-RcEP1SwMfI3r4i_Dy2yqK6f0RwnYFMGH4,16297
5
5
  github2gerrit/config.py,sha256=nvSc9e_wJM2GS5whFE5R3UwmZjYPZgucaS8s6rrEbjY,26469
6
6
  github2gerrit/constants.py,sha256=uCAx-lZiyxlVSHaGmDx6TFbCajkK3VO2ggCOej0EeXk,1306
7
- github2gerrit/core.py,sha256=W08u18H9Uhsy1_0cm7hiXZmuXljr6AwGI03ybNcUO-Y,254613
7
+ github2gerrit/core.py,sha256=-pUwvP2urkfddmNP2KxoV6UGkYXOVEXApFAnZcsMkkk,254770
8
8
  github2gerrit/duplicate_detection.py,sha256=CTrzD3YLc-AyPmaR5bSC5b22LpBDpCcNbVxMv2X0o7A,32127
9
9
  github2gerrit/error_codes.py,sha256=MclL3tiOSR4R61c3J642CYDyJJ10tzEQmI2YW7Wkqjw,19098
10
- github2gerrit/external_api.py,sha256=9483kkgIs1ECOl_f0lcGb8GrJQF9IfYmWfBQwUJT9hk,18480
11
- github2gerrit/gerrit_pr_closer.py,sha256=irsAI4eNJWi66P39q1gRibpV5SdhiVKv11u-G8yD9a0,61591
12
- github2gerrit/gerrit_query.py,sha256=pn44IN46tgKPq-s48XnGXpS6kfeQyiECQP75CnJSOr8,10472
13
- github2gerrit/gerrit_rest.py,sha256=FyQAMCSuZmJ7ClLuPRgkwapXbJnhW41TYdgkB6UGKQY,12612
10
+ github2gerrit/external_api.py,sha256=nVZBQoPbsKKal2c9Wdsj-oWnl5wv9aNNdCG0qhyvKNA,19338
11
+ github2gerrit/gerrit_pr_closer.py,sha256=hNX13ekFai0mYWCdrmijdEikkzI6Xnp8CypMUuJtieQ,64019
12
+ github2gerrit/gerrit_query.py,sha256=2FvYcf8gt2rZ22nJLklBVKuZamp8ke9TaEzq2UKRHwY,11077
13
+ github2gerrit/gerrit_rest.py,sha256=69c1EsIOjUaNy_x3gL-OtQLqAmQN4fo9Bl6es4GNvVw,16858
14
+ github2gerrit/gerrit_ssh.py,sha256=TGpXF2REZWaya-uQPhd_jqE4kLhPWxJK-l75Bhi2hSg,9116
14
15
  github2gerrit/gerrit_urls.py,sha256=aoPuUOCysHffVr9qkBRIPCZEUoSBMHmfviydy1U1uSQ,13843
15
16
  github2gerrit/github_api.py,sha256=wYKWRMQsYu7zbxyRQRO7N4cTqBvE6vYf8SdN_3C6XTo,11486
16
17
  github2gerrit/gitreview.py,sha256=I1FPsYjkPttj6DdjqHI0HkWOwsnInhmijLeGjnD658I,23464
@@ -19,7 +20,7 @@ github2gerrit/mapping_comment.py,sha256=3WAL3KQgjfPnUMZS_-aqNL7qtD0jNXr0VTK_GPEc
19
20
  github2gerrit/models.py,sha256=beZ1C3S8v-qA4tD3Niq6trpoHofxLx6q3VQgvOz0WyI,4304
20
21
  github2gerrit/netrc.py,sha256=x8l53j-t1kl-v1YZEPaDT2Mmf5qUfgP5J0YZzE7fkIY,27534
21
22
  github2gerrit/pr_commands.py,sha256=VZNEvzK1MHnXul3SbkjjoY_HW34M9QFyt74RxcRZpQU,12440
22
- github2gerrit/pr_content_filter.py,sha256=WUeYiTTYbIgz4Kb0u8M5e3vEoNU5L2WQ9h27xV1iNwE,19278
23
+ github2gerrit/pr_content_filter.py,sha256=9O2jiRM0XhaP2CD9QgKU2C4UQfyQggR14uYprBjgFWE,19644
23
24
  github2gerrit/reconcile_matcher.py,sha256=s7v3LJdv5CZmi7LvhENFoXlQ-GCxFS-CtkuOCUsD5ow,20466
24
25
  github2gerrit/rich_display.py,sha256=Y2rtJNLP_EG9zdR29NiSqM3j_o_51gpQVeTNBqZch5o,16038
25
26
  github2gerrit/rich_logging.py,sha256=D8yyV4NrsLf74fZH5-AFr1c-Im7MyNyx3LbJTLqepHs,10991
@@ -29,11 +30,11 @@ github2gerrit/ssh_common.py,sha256=OC20SQsX_AP3yTUJ_tqz6XAN30O5GbgtZwzwUzyX4DQ,8
29
30
  github2gerrit/ssh_config_parser.py,sha256=Yd6yoh95lSfUXHRHw2mrgNlY0yizz1A7GmQod30dCT8,15523
30
31
  github2gerrit/ssh_discovery.py,sha256=G94d129GM2kOmVVh-wZXqBybKLGr0s_CPjrJW6_j3k8,17847
31
32
  github2gerrit/trailers.py,sha256=9w0vIxPNBNQp56sIy-MF62d22Rm6vY-msh9ao1lX0rQ,8384
32
- github2gerrit/utils.py,sha256=zNH1qcxyOYdhIn4Ku0o5p9pbwYWdQfboLNqzADDts8A,3804
33
+ github2gerrit/utils.py,sha256=5DvwiDZmYsvyEp3VegTkLczlDX_Efoa6LogJ-pG9L9k,5344
33
34
  github2gerrit/orchestrator/__init__.py,sha256=HAEcdCAHOFr8LsdIwAdcIcFZn_ayMbX9rdVUULp8410,864
34
- github2gerrit/orchestrator/reconciliation.py,sha256=-JQre_PUx6aZeX8Qs6uHqzujKAXXCj9wNv7Cy-543Z8,19391
35
- github2gerrit-1.2.2.dist-info/METADATA,sha256=JUrvlCF0qFREPgEa7h_tgro6fp9VT0FO3zUF5c0Jjjs,70210
36
- github2gerrit-1.2.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
37
- github2gerrit-1.2.2.dist-info/entry_points.txt,sha256=MxN2_liIKo3-xJwtAulAeS5GcOS6JS96nvwOQIkP3W8,56
38
- github2gerrit-1.2.2.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
39
- github2gerrit-1.2.2.dist-info/RECORD,,
35
+ github2gerrit/orchestrator/reconciliation.py,sha256=qo5IGQu11fszzOMbVwBH12VG5zQz9jjjjIvAapLi92g,20516
36
+ github2gerrit-1.2.4.dist-info/METADATA,sha256=3716GdviZ62NgbN3r9BZgOZKhFeXdEOxnare-JhE480,70226
37
+ github2gerrit-1.2.4.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
38
+ github2gerrit-1.2.4.dist-info/entry_points.txt,sha256=MxN2_liIKo3-xJwtAulAeS5GcOS6JS96nvwOQIkP3W8,56
39
+ github2gerrit-1.2.4.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
40
+ github2gerrit-1.2.4.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.29.0
2
+ Generator: hatchling 1.30.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any