github2gerrit 0.1.10__py3-none-any.whl → 0.1.11__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/cli.py +793 -198
- github2gerrit/commit_normalization.py +44 -15
- github2gerrit/config.py +76 -30
- github2gerrit/core.py +1571 -267
- github2gerrit/duplicate_detection.py +222 -98
- github2gerrit/external_api.py +76 -25
- github2gerrit/gerrit_query.py +286 -0
- github2gerrit/gerrit_rest.py +53 -18
- github2gerrit/gerrit_urls.py +90 -33
- github2gerrit/github_api.py +19 -6
- github2gerrit/gitutils.py +43 -14
- github2gerrit/mapping_comment.py +345 -0
- github2gerrit/models.py +15 -1
- github2gerrit/orchestrator/__init__.py +25 -0
- github2gerrit/orchestrator/reconciliation.py +589 -0
- github2gerrit/pr_content_filter.py +65 -17
- github2gerrit/reconcile_matcher.py +595 -0
- github2gerrit/rich_display.py +502 -0
- github2gerrit/rich_logging.py +316 -0
- github2gerrit/similarity.py +65 -19
- github2gerrit/ssh_agent_setup.py +59 -22
- github2gerrit/ssh_common.py +30 -11
- github2gerrit/ssh_discovery.py +67 -20
- github2gerrit/trailers.py +340 -0
- github2gerrit/utils.py +6 -2
- {github2gerrit-0.1.10.dist-info → github2gerrit-0.1.11.dist-info}/METADATA +76 -24
- github2gerrit-0.1.11.dist-info/RECORD +31 -0
- {github2gerrit-0.1.10.dist-info → github2gerrit-0.1.11.dist-info}/WHEEL +1 -2
- github2gerrit-0.1.10.dist-info/RECORD +0 -24
- github2gerrit-0.1.10.dist-info/top_level.txt +0 -1
- {github2gerrit-0.1.10.dist-info → github2gerrit-0.1.11.dist-info}/entry_points.txt +0 -0
- {github2gerrit-0.1.10.dist-info → github2gerrit-0.1.11.dist-info}/licenses/LICENSE +0 -0
@@ -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
|
+
]
|
github2gerrit/similarity.py
CHANGED
@@ -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
|
20
|
-
|
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(
|
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
|
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(
|
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 =
|
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}
|
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(
|
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 =
|
381
|
-
|
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(
|
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(
|
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 =
|
401
|
-
|
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(
|
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()
|
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
|
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 =
|
451
|
-
w_sum = float(
|
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 = (
|
github2gerrit/ssh_agent_setup.py
CHANGED
@@ -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 =
|
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
|
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(
|
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 =
|
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
|
-
|
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
|
-
|
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
|
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(
|
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(
|
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 =
|
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(
|
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(
|
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.
|
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)
|