github2gerrit 0.1.10__py3-none-any.whl → 0.1.12__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,316 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2025 The Linux Foundation
3
+
4
+ """
5
+ Rich-aware logging infrastructure to prevent interference with Rich displays.
6
+
7
+ This module provides a centralized logging system that detects when Rich
8
+ displays are active and routes log messages appropriately to avoid breaking
9
+ clean terminal output.
10
+
11
+ Key features:
12
+ - Automatic detection of Rich display contexts
13
+ - Clean routing of ERROR/WARNING logs through Rich console when available
14
+ - Background-only logging for DEBUG/INFO when Rich is active
15
+ - Seamless fallback to normal logging when Rich is not available
16
+ - Thread-safe operation for concurrent logging
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import threading
23
+ from typing import Any
24
+ from typing import ClassVar
25
+
26
+
27
+ try:
28
+ from rich.console import Console
29
+ from rich.logging import RichHandler
30
+
31
+ RICH_AVAILABLE = True
32
+ except ImportError:
33
+ RICH_AVAILABLE = False
34
+ Console = None
35
+ RichHandler = None
36
+
37
+
38
+ class RichAwareLogger:
39
+ """
40
+ Rich-aware logging coordinator that manages logging behavior based on
41
+ whether Rich displays are currently active.
42
+
43
+ This class provides a global registry of active Rich display contexts
44
+ and routes logging calls appropriately to prevent interference.
45
+ """
46
+
47
+ _instance: ClassVar[RichAwareLogger | None] = None
48
+ _lock: ClassVar[threading.Lock] = threading.Lock()
49
+
50
+ def __init__(self) -> None:
51
+ """Initialize the Rich-aware logger."""
52
+ self._active_rich_contexts: set[str] = set()
53
+ self._rich_console: Console | None = None
54
+ self._original_handlers: dict[str, list[logging.Handler]] = {}
55
+ self._rich_handlers_installed = False
56
+ self._context_lock = threading.Lock()
57
+
58
+ if RICH_AVAILABLE:
59
+ # Create a dedicated Rich console for logging
60
+ self._rich_console = Console(stderr=True, markup=False)
61
+
62
+ @classmethod
63
+ def get_instance(cls) -> RichAwareLogger:
64
+ """Get the singleton instance of RichAwareLogger."""
65
+ if cls._instance is None:
66
+ with cls._lock:
67
+ if cls._instance is None:
68
+ cls._instance = cls()
69
+ return cls._instance
70
+
71
+ def register_rich_context(self, context_id: str) -> None:
72
+ """
73
+ Register an active Rich display context.
74
+
75
+ Args:
76
+ context_id: Unique identifier for the Rich display context
77
+ """
78
+ with self._context_lock:
79
+ was_empty = len(self._active_rich_contexts) == 0
80
+ self._active_rich_contexts.add(context_id)
81
+
82
+ # If this is the first Rich context, install Rich handlers
83
+ if was_empty and RICH_AVAILABLE:
84
+ self._install_rich_handlers()
85
+
86
+ def unregister_rich_context(self, context_id: str) -> None:
87
+ """
88
+ Unregister a Rich display context.
89
+
90
+ Args:
91
+ context_id: Unique identifier for the Rich display context
92
+ """
93
+ with self._context_lock:
94
+ self._active_rich_contexts.discard(context_id)
95
+
96
+ # If no more Rich contexts, restore original handlers
97
+ if len(self._active_rich_contexts) == 0:
98
+ self._restore_original_handlers()
99
+
100
+ def is_rich_active(self) -> bool:
101
+ """Check if any Rich display contexts are currently active."""
102
+ with self._context_lock:
103
+ return len(self._active_rich_contexts) > 0 and RICH_AVAILABLE
104
+
105
+ def _install_rich_handlers(self) -> None:
106
+ """Install Rich-aware logging handlers."""
107
+ if not RICH_AVAILABLE or self._rich_handlers_installed:
108
+ return
109
+
110
+ # Get the root logger and key module loggers
111
+ loggers_to_modify = [
112
+ logging.getLogger(), # Root logger
113
+ logging.getLogger("github2gerrit"),
114
+ logging.getLogger("github2gerrit.cli"),
115
+ logging.getLogger("github2gerrit.core"),
116
+ logging.getLogger("github2gerrit.duplicate_detection"),
117
+ logging.getLogger("github2gerrit.external_api"),
118
+ logging.getLogger("github2gerrit.gerrit_rest"),
119
+ logging.getLogger("github2gerrit.gitutils"),
120
+ ]
121
+
122
+ for logger in loggers_to_modify:
123
+ logger_name = logger.name or "root"
124
+
125
+ # Store original handlers
126
+ self._original_handlers[logger_name] = logger.handlers.copy()
127
+
128
+ # Remove existing handlers
129
+ for handler in logger.handlers[:]:
130
+ logger.removeHandler(handler)
131
+
132
+ # Add Rich-aware handler for ERROR and WARNING levels
133
+ rich_handler = RichAwareHandler(self._rich_console)
134
+ rich_handler.setLevel(
135
+ logging.WARNING
136
+ ) # Only handle WARNING and ERROR
137
+ logger.addHandler(rich_handler)
138
+
139
+ # Add silent handler for INFO and DEBUG (logs to file but not
140
+ # console)
141
+ silent_handler = SilentHandler()
142
+ silent_handler.setLevel(logging.DEBUG)
143
+ logger.addHandler(silent_handler)
144
+
145
+ self._rich_handlers_installed = True
146
+
147
+ def _restore_original_handlers(self) -> None:
148
+ """Restore original logging handlers."""
149
+ if not self._rich_handlers_installed:
150
+ return
151
+
152
+ for logger_name, original_handlers in self._original_handlers.items():
153
+ logger = logging.getLogger(
154
+ logger_name if logger_name != "root" else None
155
+ )
156
+
157
+ # Remove Rich handlers
158
+ for handler in logger.handlers[:]:
159
+ logger.removeHandler(handler)
160
+
161
+ # Restore original handlers
162
+ for handler in original_handlers:
163
+ logger.addHandler(handler)
164
+
165
+ self._original_handlers.clear()
166
+ self._rich_handlers_installed = False
167
+
168
+
169
+ class RichAwareHandler(logging.Handler):
170
+ """
171
+ Logging handler that routes ERROR/WARNING messages through Rich console
172
+ when Rich displays are active.
173
+ """
174
+
175
+ def __init__(self, rich_console: Console | None = None) -> None:
176
+ """Initialize the Rich-aware handler."""
177
+ super().__init__()
178
+ self._rich_console = rich_console
179
+
180
+ # Set up formatting
181
+ formatter = logging.Formatter("%(levelname)s: %(message)s")
182
+ self.setFormatter(formatter)
183
+
184
+ def emit(self, record: logging.LogRecord) -> None:
185
+ """Emit a log record through Rich console."""
186
+ if not self._rich_console:
187
+ return
188
+
189
+ try:
190
+ msg = self.format(record)
191
+
192
+ # Choose style based on log level
193
+ if record.levelno >= logging.ERROR:
194
+ style = "red"
195
+ prefix = "❌"
196
+ elif record.levelno >= logging.WARNING:
197
+ style = "yellow"
198
+ prefix = "⚠️"
199
+ else:
200
+ style = "white"
201
+ prefix = "i"
202
+
203
+ # Print through Rich console
204
+ self._rich_console.print(f"{prefix} {msg}", style=style)
205
+
206
+ except Exception:
207
+ # Fallback to handleError if Rich printing fails
208
+ self.handleError(record)
209
+
210
+
211
+ class SilentHandler(logging.Handler):
212
+ """
213
+ Handler that silently discards log messages.
214
+
215
+ This is used for INFO and DEBUG messages when Rich is active,
216
+ to prevent them from interfering with Rich displays while still
217
+ allowing them to be captured by file handlers if configured.
218
+ """
219
+
220
+ def emit(self, record: logging.LogRecord) -> None:
221
+ """Silently discard the log record."""
222
+ # Do nothing - this prevents console output
223
+
224
+
225
+ class RichDisplayContext:
226
+ """
227
+ Context manager for Rich display contexts.
228
+
229
+ This should be used around any Rich display operations to ensure
230
+ that logging doesn't interfere with clean Rich output.
231
+
232
+ Example:
233
+ with RichDisplayContext("progress_tracker"):
234
+ # Rich display operations
235
+ console.print(table)
236
+ # Logging here won't interfere with Rich display
237
+ """
238
+
239
+ def __init__(self, context_id: str) -> None:
240
+ """
241
+ Initialize Rich display context.
242
+
243
+ Args:
244
+ context_id: Unique identifier for this display context
245
+ """
246
+ self.context_id = context_id
247
+ self._logger = RichAwareLogger.get_instance()
248
+
249
+ def __enter__(self) -> RichDisplayContext:
250
+ """Enter the Rich display context."""
251
+ self._logger.register_rich_context(self.context_id)
252
+ return self
253
+
254
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
255
+ """Exit the Rich display context."""
256
+ self._logger.unregister_rich_context(self.context_id)
257
+
258
+
259
+ def setup_rich_aware_logging() -> None:
260
+ """
261
+ Initialize Rich-aware logging for the entire application.
262
+
263
+ This should be called early in the application lifecycle,
264
+ typically in the main CLI entry point.
265
+ """
266
+ # Just ensure the singleton is created - actual setup happens
267
+ # when Rich contexts are registered
268
+ RichAwareLogger.get_instance()
269
+
270
+
271
+ def is_rich_logging_active() -> bool:
272
+ """
273
+ Check if Rich-aware logging is currently active.
274
+
275
+ Returns:
276
+ True if Rich displays are active and logging is being routed
277
+ through Rich console, False otherwise.
278
+ """
279
+ return RichAwareLogger.get_instance().is_rich_active()
280
+
281
+
282
+ # Convenience functions for common logging patterns that are Rich-aware
283
+ def rich_error(message: str, *args: Any) -> None:
284
+ """Log an error message in a Rich-aware manner."""
285
+ logger = logging.getLogger("github2gerrit")
286
+ logger.error(message, *args)
287
+
288
+
289
+ def rich_warning(message: str, *args: Any) -> None:
290
+ """Log a warning message in a Rich-aware manner."""
291
+ logger = logging.getLogger("github2gerrit")
292
+ logger.warning(message, *args)
293
+
294
+
295
+ def rich_info(message: str, *args: Any) -> None:
296
+ """Log an info message in a Rich-aware manner."""
297
+ logger = logging.getLogger("github2gerrit")
298
+ logger.info(message, *args)
299
+
300
+
301
+ def rich_debug(message: str, *args: Any) -> None:
302
+ """Log a debug message in a Rich-aware manner."""
303
+ logger = logging.getLogger("github2gerrit")
304
+ logger.debug(message, *args)
305
+
306
+
307
+ __all__ = [
308
+ "RichAwareLogger",
309
+ "RichDisplayContext",
310
+ "is_rich_logging_active",
311
+ "rich_debug",
312
+ "rich_error",
313
+ "rich_info",
314
+ "rich_warning",
315
+ "setup_rich_aware_logging",
316
+ ]
@@ -16,8 +16,10 @@ Design goals:
16
16
  - Explainability: each scorer returns both a score and human-readable reasons.
17
17
 
18
18
  Implementation notes:
19
- - Most functions are fully implemented with robust normalization and scoring logic.
20
- - The similarity scoring system supports exact matches, fuzzy matching, and automation-aware detection.
19
+ - Most functions are fully implemented with robust normalization and scoring
20
+ logic.
21
+ - The similarity scoring system supports exact matches, fuzzy matching, and
22
+ automation-aware detection.
21
23
  """
22
24
 
23
25
  from __future__ import annotations
@@ -68,6 +70,10 @@ class ScoringConfig:
68
70
  workflow_min_floor: float = 0.50
69
71
 
70
72
 
73
+ # Default scoring configuration singleton
74
+ _DEFAULT_SCORING_CONFIG = ScoringConfig()
75
+
76
+
71
77
  @dataclass(frozen=True)
72
78
  class ScoreResult:
73
79
  """
@@ -174,7 +180,9 @@ def remove_commit_trailers(message: str) -> str:
174
180
  """
175
181
  lines = (message or "").splitlines()
176
182
  out: list[str] = []
177
- trailer_re = re.compile(r"(?i)^(change-id|signed-off-by|issue-id|github-hash|github-pr|co-authored-by):")
183
+ trailer_re = re.compile(
184
+ r"(?i)^(change-id|signed-off-by|issue-id|github-hash|github-pr|co-authored-by):"
185
+ )
178
186
  for ln in lines:
179
187
  if trailer_re.match(ln.strip()):
180
188
  continue
@@ -267,7 +275,11 @@ def classify_automation_context(
267
275
  signals.append("dependabot")
268
276
  if "pre-commit" in text or ".pre-commit-config.yaml" in text:
269
277
  signals.append("pre-commit")
270
- if "github actions" in text or ".github/workflows" in text or "uses:" in text:
278
+ if (
279
+ "github actions" in text
280
+ or ".github/workflows" in text
281
+ or "uses:" in text
282
+ ):
271
283
  signals.append("github-actions")
272
284
  # Deduplicate while preserving order
273
285
  seen: set[str] = set()
@@ -308,7 +320,9 @@ def score_subjects(
308
320
  pkg_src = extract_dependency_package_from_subject(src)
309
321
  pkg_cand = extract_dependency_package_from_subject(candidate_subject)
310
322
  if pkg_src and pkg_cand and pkg_src == pkg_cand:
311
- return ScoreResult(score=1.0, reasons=[f"Same dependency package: {pkg_src}"])
323
+ return ScoreResult(
324
+ score=1.0, reasons=[f"Same dependency package: {pkg_src}"]
325
+ )
312
326
  r = sequence_ratio(src_norm, cand_norm)
313
327
  if r > best_ratio:
314
328
  best_ratio = r
@@ -323,7 +337,7 @@ def score_files(
323
337
  source_files: Sequence[str],
324
338
  candidate_files: Sequence[str],
325
339
  *,
326
- workflow_min_floor: float = ScoringConfig.workflow_min_floor,
340
+ workflow_min_floor: float | None = None,
327
341
  ) -> ScoreResult:
328
342
  """
329
343
  Score similarity based on changed file paths.
@@ -334,6 +348,8 @@ def score_files(
334
348
  - If both sides include one or more files under .github/workflows/,
335
349
  floor the score to workflow_min_floor.
336
350
  """
351
+ if workflow_min_floor is None:
352
+ workflow_min_floor = ScoringConfig.workflow_min_floor
337
353
 
338
354
  def _nf(p: str) -> str:
339
355
  q = (p or "").strip().lower()
@@ -354,7 +370,8 @@ def score_files(
354
370
  reasons.append("Both modify workflow files (.github/workflows/*)")
355
371
  if src_set or cand_set:
356
372
  reasons.append(
357
- f"File overlap Jaccard: {score:.2f} (|n|={len(src_set & cand_set)}, |U|={len(src_set | cand_set)})"
373
+ f"File overlap Jaccard: {score:.2f} "
374
+ f"(|n|={len(src_set & cand_set)}, |U|={len(src_set | cand_set)})"
358
375
  )
359
376
  return ScoreResult(score=score, reasons=reasons)
360
377
 
@@ -371,18 +388,28 @@ def score_bodies(
371
388
  # Very short bodies: exact match or zero
372
389
  if len(source_body.strip()) < 50 or len(candidate_body.strip()) < 50:
373
390
  if normalize_body(source_body) == normalize_body(candidate_body):
374
- return ScoreResult(score=1.0, reasons=["Short bodies exactly match"])
391
+ return ScoreResult(
392
+ score=1.0, reasons=["Short bodies exactly match"]
393
+ )
375
394
  return ScoreResult(score=0.0, reasons=[])
376
395
  reasons: list[str] = []
377
396
  # Automation-aware checks
378
397
  src_text = source_body or ""
379
398
  cand_text = candidate_body or ""
380
- src_is_dep = "dependabot" in src_text.lower() or "dependency-name:" in src_text.lower()
381
- cand_is_dep = "dependabot" in cand_text.lower() or "dependency-name:" in cand_text.lower()
399
+ src_is_dep = (
400
+ "dependabot" in src_text.lower()
401
+ or "dependency-name:" in src_text.lower()
402
+ )
403
+ cand_is_dep = (
404
+ "dependabot" in cand_text.lower()
405
+ or "dependency-name:" in cand_text.lower()
406
+ )
382
407
  if src_is_dep and cand_is_dep:
383
408
  pkg1 = ""
384
409
  pkg2 = ""
385
- m1 = re.search(r"dependency-name:\s*([^\s\n]+)", src_text, flags=re.IGNORECASE)
410
+ m1 = re.search(
411
+ r"dependency-name:\s*([^\s\n]+)", src_text, flags=re.IGNORECASE
412
+ )
386
413
  m2 = re.search(
387
414
  r"dependency-name:\s*([^\s\n]+)",
388
415
  cand_text,
@@ -393,16 +420,28 @@ def score_bodies(
393
420
  if m2:
394
421
  pkg2 = m2.group(1).strip()
395
422
  if pkg1 and pkg2 and pkg1 == pkg2:
396
- return ScoreResult(score=0.95, reasons=[f"Dependabot package match: {pkg1}"])
423
+ return ScoreResult(
424
+ score=0.95, reasons=[f"Dependabot package match: {pkg1}"]
425
+ )
397
426
  # Different packages -> slight similarity for being both dependabot
398
427
  reasons.append("Both look like Dependabot bodies")
399
428
  # do not return yet; fall through to normalized ratio
400
- src_is_pc = "pre-commit" in src_text.lower() or ".pre-commit-config.yaml" in src_text.lower()
401
- cand_is_pc = "pre-commit" in cand_text.lower() or ".pre-commit-config.yaml" in cand_text.lower()
429
+ src_is_pc = (
430
+ "pre-commit" in src_text.lower()
431
+ or ".pre-commit-config.yaml" in src_text.lower()
432
+ )
433
+ cand_is_pc = (
434
+ "pre-commit" in cand_text.lower()
435
+ or ".pre-commit-config.yaml" in cand_text.lower()
436
+ )
402
437
  if src_is_pc and cand_is_pc:
403
- return ScoreResult(score=0.9, reasons=["Both look like pre-commit updates"])
438
+ return ScoreResult(
439
+ score=0.9, reasons=["Both look like pre-commit updates"]
440
+ )
404
441
  src_is_actions = (
405
- "github actions" in src_text.lower() or ".github/workflows" in src_text.lower() or "uses:" in src_text.lower()
442
+ "github actions" in src_text.lower()
443
+ or ".github/workflows" in src_text.lower()
444
+ or "uses:" in src_text.lower()
406
445
  )
407
446
  cand_is_actions = (
408
447
  "github actions" in cand_text.lower()
@@ -412,7 +451,12 @@ def score_bodies(
412
451
  if src_is_actions and cand_is_actions:
413
452
  a1 = re.search(r"uses:\s*([^@\s]+)", src_text, flags=re.IGNORECASE)
414
453
  a2 = re.search(r"uses:\s*([^@\s]+)", cand_text, flags=re.IGNORECASE)
415
- if a1 and a2 and a1.group(1).strip() and a1.group(1).strip() == a2.group(1).strip():
454
+ if (
455
+ a1
456
+ and a2
457
+ and a1.group(1).strip()
458
+ and a1.group(1).strip() == a2.group(1).strip()
459
+ ):
416
460
  return ScoreResult(
417
461
  score=0.9,
418
462
  reasons=[f"Same GitHub Action: {a1.group(1).strip()}"],
@@ -447,8 +491,10 @@ def aggregate_scores(
447
491
  Weighted average in [0,1].
448
492
  """
449
493
  if config is None:
450
- config = ScoringConfig()
451
- w_sum = float(config.subject_weight + config.files_weight + config.body_weight)
494
+ config = _DEFAULT_SCORING_CONFIG
495
+ w_sum = float(
496
+ config.subject_weight + config.files_weight + config.body_weight
497
+ )
452
498
  if w_sum <= 0:
453
499
  return 0.0
454
500
  total = (
@@ -16,6 +16,7 @@ import os
16
16
  import shutil
17
17
  import subprocess
18
18
  from pathlib import Path
19
+ from typing import cast
19
20
 
20
21
  from .gitutils import CommandError
21
22
  from .gitutils import run_cmd
@@ -42,6 +43,7 @@ _MSG_LIST_FAILED = "Failed to list keys: {error}"
42
43
  _MSG_NO_KEYS_LOADED = "No keys were loaded into SSH agent"
43
44
  _MSG_SSH_AGENT_NOT_FOUND = "ssh-agent not found in PATH"
44
45
  _MSG_SSH_ADD_NOT_FOUND = "ssh-add not found in PATH"
46
+ _MSG_TOOL_NOT_FOUND = "Required tool '{tool_name}' not found in PATH"
45
47
 
46
48
 
47
49
  class SSHAgentManager:
@@ -63,10 +65,7 @@ class SSHAgentManager:
63
65
  """Start a new SSH agent process."""
64
66
  try:
65
67
  # Locate ssh-agent executable
66
- ssh_agent_path = shutil.which("ssh-agent")
67
- if not ssh_agent_path:
68
- _raise_ssh_agent_not_found()
69
- assert ssh_agent_path is not None # for mypy # noqa: S101
68
+ ssh_agent_path = _ensure_tool_available("ssh-agent")
70
69
 
71
70
  # Start ssh-agent and capture its output
72
71
  result = run_cmd([ssh_agent_path, "-s"], timeout=10)
@@ -74,7 +73,8 @@ class SSHAgentManager:
74
73
  # Parse the ssh-agent output to get environment variables
75
74
  for line in result.stdout.strip().split("\n"):
76
75
  if line.startswith("SSH_AUTH_SOCK="):
77
- # Format: SSH_AUTH_SOCK=/path/to/socket; export SSH_AUTH_SOCK;
76
+ # Format: SSH_AUTH_SOCK=/path/to/socket; export
77
+ # SSH_AUTH_SOCK;
78
78
  value = line.split("=", 1)[1].split(";")[0].strip()
79
79
  self.auth_sock = value
80
80
  elif line.startswith("SSH_AGENT_PID="):
@@ -97,7 +97,11 @@ class SSHAgentManager:
97
97
  if self.agent_pid:
98
98
  os.environ["SSH_AGENT_PID"] = str(self.agent_pid)
99
99
 
100
- log.debug("Started SSH agent with PID %d, socket %s", self.agent_pid, self.auth_sock)
100
+ log.debug(
101
+ "Started SSH agent with PID %d, socket %s",
102
+ self.agent_pid,
103
+ self.auth_sock,
104
+ )
101
105
 
102
106
  except Exception as exc:
103
107
  raise SSHAgentError(_MSG_START_FAILED.format(error=exc)) from exc
@@ -112,20 +116,20 @@ class SSHAgentManager:
112
116
  raise SSHAgentError(_MSG_NOT_STARTED)
113
117
 
114
118
  # Locate ssh-add executable
115
- ssh_add_path = shutil.which("ssh-add")
116
- if not ssh_add_path:
117
- _raise_ssh_add_not_found()
118
- assert ssh_add_path is not None # for mypy # noqa: S101
119
+ ssh_add_path = _ensure_tool_available("ssh-add")
119
120
 
120
121
  process = None
121
122
  try:
122
123
  # Use ssh-add with stdin to add the key
123
- process = subprocess.Popen( # noqa: S603
124
+ # Security: ssh_add_path is validated by _ensure_tool_available()
125
+ # which uses shutil.which() to find the actual ssh-add binary
126
+ process = subprocess.Popen( # noqa: S603 # ssh_add_path validated by _ensure_tool_available via shutil.which
124
127
  [ssh_add_path, "-"],
125
128
  stdin=subprocess.PIPE,
126
129
  stdout=subprocess.PIPE,
127
130
  stderr=subprocess.PIPE,
128
131
  text=True,
132
+ shell=False, # Explicitly disable shell for security
129
133
  env={
130
134
  **os.environ,
131
135
  "SSH_AUTH_SOCK": self.auth_sock,
@@ -133,7 +137,9 @@ class SSHAgentManager:
133
137
  },
134
138
  )
135
139
 
136
- stdout, stderr = process.communicate(input=private_key_content.strip() + "\n", timeout=10)
140
+ _stdout, stderr = process.communicate(
141
+ input=private_key_content.strip() + "\n", timeout=10
142
+ )
137
143
 
138
144
  if process.returncode != 0:
139
145
  _raise_add_key_error(stderr)
@@ -158,7 +164,8 @@ class SSHAgentManager:
158
164
  tool_ssh_dir = self.workspace / ".ssh-g2g"
159
165
  tool_ssh_dir.mkdir(mode=0o700, exist_ok=True)
160
166
 
161
- # Write known hosts file (normalize/augment with [host]:port entries)
167
+ # Write known hosts file (normalize/augment with [host]:port
168
+ # entries)
162
169
  self.known_hosts_path = tool_ssh_dir / "known_hosts"
163
170
  host = (os.getenv("GERRIT_SERVER") or "").strip()
164
171
  port = (os.getenv("GERRIT_SERVER_PORT") or "29418").strip()
@@ -168,7 +175,9 @@ class SSHAgentManager:
168
175
  port_int = 29418
169
176
 
170
177
  # Use centralized augmentation logic
171
- augmented_content = augment_known_hosts_with_bracketed_entries(known_hosts_content, host, port_int)
178
+ augmented_content = augment_known_hosts_with_bracketed_entries(
179
+ known_hosts_content, host, port_int
180
+ )
172
181
 
173
182
  with open(self.known_hosts_path, "w", encoding="utf-8") as f:
174
183
  f.write(augmented_content)
@@ -177,7 +186,9 @@ class SSHAgentManager:
177
186
  log.debug("Known hosts written to %s", self.known_hosts_path)
178
187
 
179
188
  except Exception as exc:
180
- raise SSHAgentError(_MSG_SETUP_HOSTS_FAILED.format(error=exc)) from exc
189
+ raise SSHAgentError(
190
+ _MSG_SETUP_HOSTS_FAILED.format(error=exc)
191
+ ) from exc
181
192
 
182
193
  def get_git_ssh_command(self) -> str:
183
194
  """Generate GIT_SSH_COMMAND for SSH agent-based authentication.
@@ -227,10 +238,7 @@ class SSHAgentManager:
227
238
 
228
239
  try:
229
240
  # Locate ssh-add executable
230
- ssh_add_path = shutil.which("ssh-add")
231
- if not ssh_add_path:
232
- _raise_ssh_add_not_found()
233
- assert ssh_add_path is not None # for mypy # noqa: S101
241
+ ssh_add_path = _ensure_tool_available("ssh-add")
234
242
 
235
243
  result = run_cmd(
236
244
  [ssh_add_path, "-l"],
@@ -274,7 +282,9 @@ class SSHAgentManager:
274
282
  import shutil
275
283
 
276
284
  shutil.rmtree(tool_ssh_dir)
277
- log.debug("Cleaned up temporary SSH directory: %s", tool_ssh_dir)
285
+ log.debug(
286
+ "Cleaned up temporary SSH directory: %s", tool_ssh_dir
287
+ )
278
288
 
279
289
  except Exception as exc:
280
290
  log.warning("Failed to clean up SSH agent: %s", exc)
@@ -284,7 +294,9 @@ class SSHAgentManager:
284
294
  self.known_hosts_path = None
285
295
 
286
296
 
287
- def setup_ssh_agent_auth(workspace: Path, private_key_content: str, known_hosts_content: str) -> SSHAgentManager:
297
+ def setup_ssh_agent_auth(
298
+ workspace: Path, private_key_content: str, known_hosts_content: str
299
+ ) -> SSHAgentManager:
288
300
  """Setup SSH agent-based authentication.
289
301
 
290
302
  Args:
@@ -315,7 +327,7 @@ def setup_ssh_agent_auth(workspace: Path, private_key_content: str, known_hosts_
315
327
  if "No keys loaded" in keys_list:
316
328
  _raise_no_keys_error()
317
329
 
318
- log.info("SSH agent authentication configured successfully")
330
+ log.debug("SSH agent authentication configured successfully")
319
331
  log.debug("Loaded keys: %s", keys_list)
320
332
 
321
333
  except Exception:
@@ -336,6 +348,31 @@ def _raise_add_key_error(stderr: str) -> None:
336
348
  raise SSHAgentError(_MSG_ADD_FAILED.format(error=stderr))
337
349
 
338
350
 
351
+ def _ensure_tool_available(tool_name: str) -> str:
352
+ """Ensure a required tool is available and return its path.
353
+
354
+ Args:
355
+ tool_name: Name of the tool to locate
356
+
357
+ Returns:
358
+ Path to the tool executable
359
+
360
+ Raises:
361
+ SSHAgentError: If the tool is not found
362
+ """
363
+ tool_path = shutil.which(tool_name)
364
+ if not tool_path:
365
+ if tool_name == "ssh-agent":
366
+ _raise_ssh_agent_not_found()
367
+ elif tool_name == "ssh-add":
368
+ _raise_ssh_add_not_found()
369
+ else:
370
+ raise SSHAgentError(_MSG_TOOL_NOT_FOUND.format(tool_name=tool_name))
371
+ # At this point, tool_path is guaranteed not to be None
372
+ # (the above conditions raise exceptions if it was None)
373
+ return cast(str, tool_path)
374
+
375
+
339
376
  def _raise_ssh_agent_not_found() -> None:
340
377
  """Raise SSH agent not found error."""
341
378
  raise SSHAgentError(_MSG_SSH_AGENT_NOT_FOUND)