github2gerrit 0.1.3__py3-none-any.whl → 0.1.5__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
@@ -38,14 +38,6 @@ from dataclasses import dataclass
38
38
  from pathlib import Path
39
39
  from typing import Any
40
40
 
41
-
42
- try:
43
- from pygerrit2 import GerritRestAPI
44
- from pygerrit2 import HTTPBasicAuth
45
- except ImportError:
46
- GerritRestAPI = None
47
- HTTPBasicAuth = None
48
-
49
41
  from .github_api import build_client
50
42
  from .github_api import close_pr
51
43
  from .github_api import create_pr_comment
@@ -67,9 +59,52 @@ from .models import GitHubContext
67
59
  from .models import Inputs
68
60
 
69
61
 
62
+ try:
63
+ from pygerrit2 import GerritRestAPI
64
+ from pygerrit2 import HTTPBasicAuth
65
+ except ImportError:
66
+ GerritRestAPI = None
67
+ HTTPBasicAuth = None
68
+
69
+ try:
70
+ from .ssh_discovery import SSHDiscoveryError
71
+ from .ssh_discovery import auto_discover_gerrit_host_keys
72
+ except ImportError:
73
+ # Fallback if ssh_discovery module is not available
74
+ auto_discover_gerrit_host_keys = None # type: ignore[assignment]
75
+ SSHDiscoveryError = Exception # type: ignore[misc,assignment]
76
+
77
+
78
+ def _is_verbose_mode() -> bool:
79
+ """Check if verbose mode is enabled via environment variable."""
80
+ return os.getenv("G2G_VERBOSE", "").lower() in ("true", "1", "yes")
81
+
82
+
83
+ def _log_exception_conditionally(
84
+ logger: logging.Logger, message: str, *args: Any
85
+ ) -> None:
86
+ """Log exception with traceback only if verbose mode is enabled."""
87
+ if _is_verbose_mode():
88
+ logger.exception(message, *args)
89
+ else:
90
+ logger.error(message, *args)
91
+
92
+
70
93
  log = logging.getLogger("github2gerrit.core")
71
94
 
72
95
 
96
+ # Error message constants to comply with TRY003
97
+ _MSG_ISSUE_ID_MULTILINE = "Issue ID must be single line"
98
+ _MSG_MISSING_PR_CONTEXT = "missing PR context"
99
+ _MSG_BAD_REPOSITORY_CONTEXT = "bad repository context"
100
+ _MSG_MISSING_GERRIT_SERVER = "missing GERRIT_SERVER"
101
+ _MSG_MISSING_GERRIT_PROJECT = "missing GERRIT_PROJECT"
102
+ _MSG_PYGERRIT2_REQUIRED_REST = "pygerrit2 is required to query Gerrit REST API"
103
+ _MSG_PYGERRIT2_REQUIRED_AUTH = "pygerrit2 is required for HTTP authentication"
104
+ _MSG_PYGERRIT2_MISSING = "pygerrit2 missing"
105
+ _MSG_PYGERRIT2_AUTH_MISSING = "pygerrit2 auth missing"
106
+
107
+
73
108
  def _insert_issue_id_into_commit_message(message: str, issue_id: str) -> str:
74
109
  """
75
110
  Insert Issue ID into commit message after the first line.
@@ -87,10 +122,13 @@ def _insert_issue_id_into_commit_message(message: str, issue_id: str) -> str:
87
122
  # Validate that Issue ID is a single line string
88
123
  cleaned_issue_id = issue_id.strip()
89
124
  if "\n" in cleaned_issue_id or "\r" in cleaned_issue_id:
90
- raise ValueError("Issue ID must be single line") # noqa: TRY003
125
+ raise ValueError(_MSG_ISSUE_ID_MULTILINE)
91
126
 
92
- # Use the cleaned issue ID for insertion
93
- issue_line = cleaned_issue_id
127
+ # Format as proper Issue-ID trailer
128
+ if cleaned_issue_id.startswith("Issue-ID:"):
129
+ issue_line = cleaned_issue_id
130
+ else:
131
+ issue_line = f"Issue-ID: {cleaned_issue_id}"
94
132
 
95
133
  lines = message.splitlines()
96
134
  if not lines:
@@ -232,7 +270,7 @@ class Orchestrator:
232
270
  return SubmissionResult(
233
271
  change_urls=[], change_numbers=[], commit_shas=[]
234
272
  )
235
- self._setup_ssh(inputs)
273
+ self._setup_ssh(inputs, gerrit)
236
274
 
237
275
  if inputs.submit_single_commits:
238
276
  prep = self._prepare_single_commits(inputs, gh, gerrit)
@@ -278,7 +316,7 @@ class Orchestrator:
278
316
 
279
317
  def _guard_pull_request_context(self, gh: GitHubContext) -> None:
280
318
  if gh.pr_number is None:
281
- raise OrchestratorError("missing PR context") # noqa: TRY003
319
+ raise OrchestratorError(_MSG_MISSING_PR_CONTEXT)
282
320
  log.debug("PR context OK: #%s", gh.pr_number)
283
321
 
284
322
  def _parse_gitreview_text(self, text: str) -> GerritInfo | None:
@@ -443,7 +481,7 @@ class Orchestrator:
443
481
  # Fallback: use the repository name portion only.
444
482
  repo_full = gh.repository
445
483
  if not repo_full or "/" not in repo_full:
446
- raise OrchestratorError("bad repository context") # noqa: TRY003
484
+ raise OrchestratorError(_MSG_BAD_REPOSITORY_CONTEXT)
447
485
  owner, name = repo_full.split("/", 1)
448
486
  # Fallback: map all '-' to '/' for Gerrit path (e.g., 'my/repo/name')
449
487
  gerrit_name = name.replace("-", "/")
@@ -463,7 +501,7 @@ class Orchestrator:
463
501
 
464
502
  host = inputs.gerrit_server.strip()
465
503
  if not host:
466
- raise OrchestratorError("missing GERRIT_SERVER") # noqa: TRY003
504
+ raise OrchestratorError(_MSG_MISSING_GERRIT_SERVER)
467
505
  port_s = inputs.gerrit_server_port.strip() or "29418"
468
506
  try:
469
507
  port = int(port_s)
@@ -483,13 +521,13 @@ class Orchestrator:
483
521
  project,
484
522
  )
485
523
  else:
486
- raise OrchestratorError("missing GERRIT_PROJECT") # noqa: TRY003
524
+ raise OrchestratorError(_MSG_MISSING_GERRIT_PROJECT)
487
525
 
488
526
  info = GerritInfo(host=host, port=port, project=project)
489
527
  log.debug("Resolved Gerrit info: %s", info)
490
528
  return info
491
529
 
492
- def _setup_ssh(self, inputs: Inputs) -> None:
530
+ def _setup_ssh(self, inputs: Inputs, gerrit: GerritInfo) -> None:
493
531
  """Set up temporary SSH configuration for Gerrit access.
494
532
 
495
533
  This method creates tool-specific SSH files in the workspace without
@@ -503,8 +541,46 @@ class Orchestrator:
503
541
 
504
542
  Does not modify user files.
505
543
  """
506
- if not inputs.gerrit_ssh_privkey_g2g or not inputs.gerrit_known_hosts:
507
- log.debug("SSH key or known hosts not provided, skipping SSH setup")
544
+ if not inputs.gerrit_ssh_privkey_g2g:
545
+ log.debug("SSH private key not provided, skipping SSH setup")
546
+ return
547
+
548
+ # Auto-discover host keys if not provided
549
+ effective_known_hosts = inputs.gerrit_known_hosts
550
+ if (
551
+ not effective_known_hosts
552
+ and auto_discover_gerrit_host_keys is not None
553
+ ):
554
+ log.info(
555
+ "GERRIT_KNOWN_HOSTS not provided, attempting auto-discovery..."
556
+ )
557
+ try:
558
+ discovered_keys = auto_discover_gerrit_host_keys(
559
+ gerrit_hostname=gerrit.host,
560
+ gerrit_port=gerrit.port,
561
+ organization=inputs.organization,
562
+ save_to_config=True,
563
+ )
564
+ if discovered_keys:
565
+ effective_known_hosts = discovered_keys
566
+ log.info(
567
+ "Successfully auto-discovered SSH host keys for %s:%d",
568
+ gerrit.host,
569
+ gerrit.port,
570
+ )
571
+ else:
572
+ log.warning(
573
+ "Auto-discovery failed, SSH host key verification may "
574
+ "fail"
575
+ )
576
+ except Exception as exc:
577
+ log.warning("SSH host key auto-discovery failed: %s", exc)
578
+
579
+ if not effective_known_hosts:
580
+ log.debug(
581
+ "No SSH host keys available (manual or auto-discovered), "
582
+ "skipping SSH setup"
583
+ )
508
584
  return
509
585
 
510
586
  log.info("Setting up temporary SSH configuration for Gerrit")
@@ -526,7 +602,7 @@ class Orchestrator:
526
602
  # Write known hosts to tool-specific location
527
603
  known_hosts_path = tool_ssh_dir / "known_hosts"
528
604
  with open(known_hosts_path, "w", encoding="utf-8") as f:
529
- f.write(inputs.gerrit_known_hosts.strip() + "\n")
605
+ f.write(effective_known_hosts.strip() + "\n")
530
606
  known_hosts_path.chmod(0o644)
531
607
  log.debug("Known hosts written to %s", known_hosts_path)
532
608
  log.debug("Using isolated known_hosts to prevent user conflicts")
@@ -1081,6 +1157,7 @@ class Orchestrator:
1081
1157
  repo.project_gerrit,
1082
1158
  branch,
1083
1159
  )
1160
+ log.debug("Starting git review push operation...")
1084
1161
  if single_commits:
1085
1162
  tmp_branch = os.getenv("G2G_TMP_BRANCH", "tmp_branch")
1086
1163
  run_cmd(["git", "checkout", tmp_branch], cwd=self.workspace)
@@ -1113,9 +1190,19 @@ class Orchestrator:
1113
1190
  if self._git_ssh_command
1114
1191
  else None
1115
1192
  )
1193
+ log.debug("Executing git review command: %s", " ".join(args))
1116
1194
  run_cmd(args, cwd=self.workspace, env=env)
1195
+ log.info("Successfully pushed changes to Gerrit")
1117
1196
  except CommandError as exc:
1118
- msg = f"Failed to push changes to Gerrit with git-review: {exc}"
1197
+ # Analyze the specific failure reason from git review output
1198
+ error_details = self._analyze_gerrit_push_failure(exc)
1199
+ _log_exception_conditionally(
1200
+ log, "Gerrit push failed: %s", error_details
1201
+ )
1202
+ msg = (
1203
+ f"Failed to push changes to Gerrit with git-review: "
1204
+ f"{error_details}"
1205
+ )
1119
1206
  raise OrchestratorError(msg) from exc
1120
1207
  # Cleanup temporary branch used during preparation
1121
1208
  tmp_branch = (os.getenv("G2G_TMP_BRANCH", "") or "").strip()
@@ -1132,6 +1219,108 @@ class Orchestrator:
1132
1219
  cwd=self.workspace,
1133
1220
  )
1134
1221
 
1222
+ def _analyze_gerrit_push_failure(self, exc: CommandError) -> str:
1223
+ """Analyze git review failure and provide helpful error message."""
1224
+ stdout = exc.stdout or ""
1225
+ stderr = exc.stderr or ""
1226
+ combined_output = f"{stdout}\n{stderr}"
1227
+ combined_lower = combined_output.lower()
1228
+
1229
+ # Check for SSH host key verification failures first
1230
+ if (
1231
+ "host key verification failed" in combined_lower
1232
+ or "no ed25519 host key is known" in combined_lower
1233
+ or "no rsa host key is known" in combined_lower
1234
+ or "no ecdsa host key is known" in combined_lower
1235
+ ):
1236
+ return (
1237
+ "SSH host key verification failed. The GERRIT_KNOWN_HOSTS "
1238
+ "value is missing or contains an outdated host key for the "
1239
+ "Gerrit server. The tool will attempt to auto-discover "
1240
+ "host keys "
1241
+ "on the next run, or you can manually run "
1242
+ "'ssh-keyscan -p 29418 <gerrit-host>' "
1243
+ "to get the current host keys."
1244
+ )
1245
+ elif (
1246
+ "authenticity of host" in combined_lower
1247
+ and "can't be established" in combined_lower
1248
+ ):
1249
+ return (
1250
+ "SSH host key unknown. The GERRIT_KNOWN_HOSTS value does not "
1251
+ "contain the host key for the Gerrit server. "
1252
+ "The tool will attempt "
1253
+ "to auto-discover host keys on the next run, or you can "
1254
+ "manually run "
1255
+ "'ssh-keyscan -p 29418 <gerrit-host>' to get the host keys."
1256
+ )
1257
+ # Check for specific SSH key issues before general permission denied
1258
+ elif (
1259
+ "key_load_public" in combined_lower
1260
+ and "invalid format" in combined_lower
1261
+ ):
1262
+ return (
1263
+ "SSH key format is invalid. Check that the SSH private key "
1264
+ "is properly formatted."
1265
+ )
1266
+ elif "no matching host key type found" in combined_lower:
1267
+ return (
1268
+ "SSH key type not supported by server. The server may not "
1269
+ "accept this SSH key algorithm."
1270
+ )
1271
+ elif "authentication failed" in combined_lower:
1272
+ return (
1273
+ "SSH authentication failed - check SSH key, username, and "
1274
+ "server configuration"
1275
+ )
1276
+ # Check for connection timeout/refused before "could not read" check
1277
+ elif (
1278
+ "connection timed out" in combined_lower
1279
+ or "connection refused" in combined_lower
1280
+ ):
1281
+ return (
1282
+ "Connection failed - check network connectivity and "
1283
+ "Gerrit server availability"
1284
+ )
1285
+ # Check for specific SSH publickey-only authentication failures
1286
+ elif "permission denied (publickey)" in combined_lower and not any(
1287
+ auth_method in combined_lower
1288
+ for auth_method in ["gssapi", "password", "keyboard"]
1289
+ ):
1290
+ return (
1291
+ "SSH public key authentication failed. The SSH key may be "
1292
+ "invalid, not authorized for this user, or the wrong key type."
1293
+ )
1294
+ # Check for general SSH permission issues
1295
+ elif "permission denied" in combined_lower:
1296
+ return "SSH permission denied - check SSH key and user permissions"
1297
+ elif "could not read from remote repository" in combined_lower:
1298
+ return (
1299
+ "Could not read from remote repository - check SSH "
1300
+ "authentication and repository access permissions"
1301
+ )
1302
+ # Check for Gerrit-specific issues
1303
+ elif "missing issue-id" in combined_lower:
1304
+ return "Missing Issue-ID in commit message."
1305
+ elif "commit not associated to any issue" in combined_lower:
1306
+ return "Commit not associated to any issue."
1307
+ elif (
1308
+ "remote rejected" in combined_lower
1309
+ and "refs/for/" in combined_lower
1310
+ ):
1311
+ # Extract specific rejection reason from output
1312
+ lines = combined_output.split("\n")
1313
+ for line in lines:
1314
+ if "! [remote rejected]" in line:
1315
+ # Extract the reason in parentheses
1316
+ if "(" in line and ")" in line:
1317
+ reason = line[line.find("(") + 1 : line.find(")")]
1318
+ return f"Gerrit rejected the push: {reason}"
1319
+ return f"Gerrit rejected the push: {line.strip()}"
1320
+ return "Gerrit rejected the push for an unknown reason"
1321
+ else:
1322
+ return f"Unknown error: {exc}"
1323
+
1135
1324
  def _query_gerrit_for_results(
1136
1325
  self,
1137
1326
  *,
@@ -1154,14 +1343,10 @@ class Orchestrator:
1154
1343
  )
1155
1344
  http_pass = os.getenv("GERRIT_HTTP_PASSWORD", "").strip()
1156
1345
  if GerritRestAPI is None:
1157
- raise OrchestratorError( # noqa: TRY003
1158
- "pygerrit2 is required to query Gerrit REST API"
1159
- )
1346
+ raise OrchestratorError(_MSG_PYGERRIT2_REQUIRED_REST)
1160
1347
  if http_user and http_pass:
1161
1348
  if HTTPBasicAuth is None:
1162
- raise OrchestratorError( # noqa: TRY003
1163
- "pygerrit2 is required for HTTP authentication"
1164
- )
1349
+ raise OrchestratorError(_MSG_PYGERRIT2_REQUIRED_AUTH)
1165
1350
  rest = GerritRestAPI(
1166
1351
  url=base_url, auth=HTTPBasicAuth(http_user, http_pass)
1167
1352
  )
@@ -1352,6 +1537,19 @@ class Orchestrator:
1352
1537
  if not commit_shas:
1353
1538
  log.warning("No commit shas to comment on in Gerrit")
1354
1539
  return
1540
+
1541
+ # Check if back-reference comments are disabled
1542
+ if os.getenv("G2G_SKIP_GERRIT_COMMENTS", "").lower() in (
1543
+ "true",
1544
+ "1",
1545
+ "yes",
1546
+ ):
1547
+ log.info(
1548
+ "Skipping back-reference comments "
1549
+ "(G2G_SKIP_GERRIT_COMMENTS=true)"
1550
+ )
1551
+ return
1552
+
1355
1553
  log.info("Adding back-reference comment in Gerrit")
1356
1554
  user = os.getenv("GERRIT_SSH_USER_G2G", "")
1357
1555
  server = gerrit.host
@@ -1368,12 +1566,24 @@ class Orchestrator:
1368
1566
  continue
1369
1567
  try:
1370
1568
  log.debug("Executing SSH command for commit %s", csha)
1371
- run_cmd(
1569
+ # Build SSH command with our configured SSH options
1570
+ ssh_cmd = ["ssh", "-n", "-p", str(gerrit.port)]
1571
+
1572
+ # Add our SSH options if we have custom SSH config
1573
+ if self._git_ssh_command:
1574
+ # Extract SSH options from GIT_SSH_COMMAND
1575
+ # Format: "ssh -i /path/to/key -o Option=value ..."
1576
+ git_ssh_parts = self._git_ssh_command.split()
1577
+ if len(git_ssh_parts) > 1: # Skip the "ssh" part
1578
+ ssh_options = git_ssh_parts[1:]
1579
+ log.debug("Adding SSH options: %s", ssh_options)
1580
+ ssh_cmd.extend(ssh_options)
1581
+ else:
1582
+ log.debug("No custom SSH config, using default SSH options")
1583
+
1584
+ # Add the target and gerrit command
1585
+ ssh_cmd.extend(
1372
1586
  [
1373
- "ssh",
1374
- "-n",
1375
- "-p",
1376
- str(gerrit.port),
1377
1587
  f"{user}@{server}",
1378
1588
  "gerrit",
1379
1589
  "review",
@@ -1386,14 +1596,40 @@ class Orchestrator:
1386
1596
  csha,
1387
1597
  ]
1388
1598
  )
1599
+
1600
+ log.debug("Final SSH command: %s", " ".join(ssh_cmd))
1601
+ run_cmd(ssh_cmd, cwd=self.workspace)
1389
1602
  log.info(
1390
1603
  "Successfully added back-reference comment for %s: %s",
1391
1604
  csha,
1392
1605
  message,
1393
1606
  )
1394
- except Exception:
1395
- log.exception(
1396
- "Failed to add back-reference comment for %s", csha
1607
+ except CommandError as exc:
1608
+ log.warning(
1609
+ "Failed to add back-reference comment for %s "
1610
+ "(non-fatal): %s",
1611
+ csha,
1612
+ exc,
1613
+ )
1614
+ if exc.stderr:
1615
+ log.debug("SSH stderr: %s", exc.stderr)
1616
+ if exc.stdout:
1617
+ log.debug("SSH stdout: %s", exc.stdout)
1618
+ log.info(
1619
+ "Back-reference comment failed but change was successfully "
1620
+ "submitted. You can set G2G_SKIP_GERRIT_COMMENTS=true to "
1621
+ "disable these comments."
1622
+ )
1623
+ # Continue processing - this is not a fatal error
1624
+ except Exception as exc:
1625
+ log.warning(
1626
+ "Failed to add back-reference comment for %s "
1627
+ "(non-fatal): %s",
1628
+ csha,
1629
+ exc,
1630
+ )
1631
+ log.debug(
1632
+ "Back-reference comment failure details:", exc_info=True
1397
1633
  )
1398
1634
  # Continue processing - this is not a fatal error
1399
1635
 
@@ -1591,15 +1827,15 @@ class Orchestrator:
1591
1827
  def _build_client(url: str) -> Any:
1592
1828
  if http_user and http_pass:
1593
1829
  if GerritRestAPI is None:
1594
- raise OrchestratorError("pygerrit2 missing") # noqa: TRY003
1830
+ raise OrchestratorError(_MSG_PYGERRIT2_MISSING)
1595
1831
  if HTTPBasicAuth is None:
1596
- raise OrchestratorError("pygerrit2 auth missing") # noqa: TRY003
1832
+ raise OrchestratorError(_MSG_PYGERRIT2_AUTH_MISSING)
1597
1833
  return GerritRestAPI(
1598
1834
  url=url, auth=HTTPBasicAuth(http_user, http_pass)
1599
1835
  )
1600
1836
  else:
1601
1837
  if GerritRestAPI is None:
1602
- raise OrchestratorError("pygerrit2 missing") # noqa: TRY003
1838
+ raise OrchestratorError(_MSG_PYGERRIT2_MISSING)
1603
1839
  return GerritRestAPI(url=url)
1604
1840
 
1605
1841
  def _probe(url: str) -> None:
@@ -1,5 +1,5 @@
1
- # SPDX-FileCopyrightText: 2024 Matthew Watkins
2
1
  # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2025 The Linux Foundation
3
3
 
4
4
  """
5
5
  Duplicate change detection for github2gerrit.
@@ -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
 
@@ -65,7 +71,7 @@ else:
65
71
 
66
72
  class Github: # type: ignore[no-redef]
67
73
  def __init__(self, *args: Any, **kwargs: Any) -> None:
68
- raise RuntimeError("PyGithub required") # noqa: TRY003
74
+ raise RuntimeError(_MSG_PYGITHUB_REQUIRED)
69
75
 
70
76
 
71
77
  class GhIssueComment(Protocol):
@@ -123,6 +129,8 @@ def _getenv_str(name: str) -> str:
123
129
  def _backoff_delay(attempt: int, base: float = 0.5, cap: float = 6.0) -> float:
124
130
  # Exponential backoff with jitter; cap prevents unbounded waits.
125
131
  delay: float = float(min(base * (2 ** max(0, attempt - 1)), cap))
132
+ # Using random.uniform for jitter is appropriate here - we only need
133
+ # pseudorandom distribution to avoid thundering herd, not crypto security
126
134
  jitter: float = float(random.uniform(0.0, delay / 2.0)) # noqa: S311
127
135
  return float(delay + jitter)
128
136
 
@@ -199,7 +207,7 @@ def build_client(token: str | None = None) -> GhClient:
199
207
  """
200
208
  tok = token or _getenv_str("GITHUB_TOKEN")
201
209
  if not tok:
202
- raise ValueError("missing GITHUB_TOKEN") # noqa: TRY003
210
+ raise ValueError(_MSG_MISSING_GITHUB_TOKEN)
203
211
  # per_page improves pagination; adjust as needed.
204
212
  base_url = _getenv_str("GITHUB_API_URL")
205
213
  if not base_url:
@@ -240,7 +248,7 @@ def get_repo_from_env(client: GhClient) -> GhRepository:
240
248
  """Return the repository object based on GITHUB_REPOSITORY."""
241
249
  full = _getenv_str("GITHUB_REPOSITORY")
242
250
  if not full or "/" not in full:
243
- raise ValueError("bad GITHUB_REPOSITORY") # noqa: TRY003
251
+ raise ValueError(_MSG_BAD_GITHUB_REPOSITORY)
244
252
  repo = client.get_repo(full)
245
253
  return repo
246
254
 
github2gerrit/gitutils.py CHANGED
@@ -20,6 +20,22 @@ 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(
32
+ logger: logging.Logger, message: str, *args: Any
33
+ ) -> None:
34
+ """Log exception with traceback only if verbose mode is enabled."""
35
+ if _is_verbose_mode():
36
+ logger.exception(message, *args)
37
+ else:
38
+ logger.error(message, *args)
23
39
 
24
40
 
25
41
  __all__ = [
@@ -41,6 +57,9 @@ __all__ = [
41
57
  "run_cmd_with_retries",
42
58
  ]
43
59
 
60
+ # Error message constants to comply with TRY003
61
+ _MSG_COMMIT_NO_MESSAGE = "Either message or message_file must be provided"
62
+
44
63
 
45
64
  _LOGGER_NAME = "github2gerrit.git"
46
65
  log = logging.getLogger(_LOGGER_NAME)
@@ -187,7 +206,7 @@ def run_cmd(
187
206
  )
188
207
  except subprocess.TimeoutExpired as exc:
189
208
  msg = f"Command timed out: {cmd!r}"
190
- log.exception(msg)
209
+ _log_exception_conditionally(log, msg)
191
210
  # TimeoutExpired carries 'output' and 'stderr' attributes,
192
211
  # which may be bytes depending on invocation context.
193
212
  out = getattr(exc, "output", None)
@@ -201,7 +220,7 @@ def run_cmd(
201
220
  ) from exc
202
221
  except OSError as exc:
203
222
  msg = f"Failed to execute command: {cmd!r} ({exc})"
204
- log.exception(msg)
223
+ _log_exception_conditionally(log, msg)
205
224
  raise CommandError(msg, cmd=cmd) from exc
206
225
 
207
226
  result = CommandResult(
@@ -258,7 +277,6 @@ def run_cmd_with_retries(
258
277
 
259
278
  predicate = retry_on or _default_retry_on
260
279
  attempt = 0
261
- # removed unused variable 'last_res'
262
280
 
263
281
  while True:
264
282
  attempt += 1
@@ -472,7 +490,7 @@ def git_commit_new(
472
490
  ) -> None:
473
491
  """Create a new commit using message or message_file."""
474
492
  if not message and not message_file:
475
- raise ValueError("Either message or message_file must be provided") # noqa: TRY003
493
+ raise ValueError(_MSG_COMMIT_NO_MESSAGE)
476
494
 
477
495
  args: list[str] = ["commit"]
478
496
  if signoff: