github2gerrit 0.1.9__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 +796 -200
- github2gerrit/commit_normalization.py +44 -15
- github2gerrit/config.py +77 -30
- github2gerrit/core.py +1576 -260
- github2gerrit/duplicate_detection.py +224 -100
- 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 +66 -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.9.dist-info → github2gerrit-0.1.11.dist-info}/METADATA +99 -25
- github2gerrit-0.1.11.dist-info/RECORD +31 -0
- {github2gerrit-0.1.9.dist-info → github2gerrit-0.1.11.dist-info}/WHEEL +1 -2
- github2gerrit-0.1.9.dist-info/RECORD +0 -24
- github2gerrit-0.1.9.dist-info/top_level.txt +0 -1
- {github2gerrit-0.1.9.dist-info → github2gerrit-0.1.11.dist-info}/entry_points.txt +0 -0
- {github2gerrit-0.1.9.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
|
"""
|
@@ -163,6 +169,7 @@ def remove_commit_trailers(message: str) -> str:
|
|
163
169
|
- Signed-off-by: Name <email>
|
164
170
|
- Issue-ID: ABC-123
|
165
171
|
- GitHub-Hash: deadbeefcafebabe
|
172
|
+
- GitHub-PR: https://github.com/org/repo/pull/123
|
166
173
|
- Co-authored-by: ...
|
167
174
|
|
168
175
|
Args:
|
@@ -173,7 +180,9 @@ def remove_commit_trailers(message: str) -> str:
|
|
173
180
|
"""
|
174
181
|
lines = (message or "").splitlines()
|
175
182
|
out: list[str] = []
|
176
|
-
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
|
+
)
|
177
186
|
for ln in lines:
|
178
187
|
if trailer_re.match(ln.strip()):
|
179
188
|
continue
|
@@ -266,7 +275,11 @@ def classify_automation_context(
|
|
266
275
|
signals.append("dependabot")
|
267
276
|
if "pre-commit" in text or ".pre-commit-config.yaml" in text:
|
268
277
|
signals.append("pre-commit")
|
269
|
-
if
|
278
|
+
if (
|
279
|
+
"github actions" in text
|
280
|
+
or ".github/workflows" in text
|
281
|
+
or "uses:" in text
|
282
|
+
):
|
270
283
|
signals.append("github-actions")
|
271
284
|
# Deduplicate while preserving order
|
272
285
|
seen: set[str] = set()
|
@@ -307,7 +320,9 @@ def score_subjects(
|
|
307
320
|
pkg_src = extract_dependency_package_from_subject(src)
|
308
321
|
pkg_cand = extract_dependency_package_from_subject(candidate_subject)
|
309
322
|
if pkg_src and pkg_cand and pkg_src == pkg_cand:
|
310
|
-
return ScoreResult(
|
323
|
+
return ScoreResult(
|
324
|
+
score=1.0, reasons=[f"Same dependency package: {pkg_src}"]
|
325
|
+
)
|
311
326
|
r = sequence_ratio(src_norm, cand_norm)
|
312
327
|
if r > best_ratio:
|
313
328
|
best_ratio = r
|
@@ -322,7 +337,7 @@ def score_files(
|
|
322
337
|
source_files: Sequence[str],
|
323
338
|
candidate_files: Sequence[str],
|
324
339
|
*,
|
325
|
-
workflow_min_floor: float =
|
340
|
+
workflow_min_floor: float | None = None,
|
326
341
|
) -> ScoreResult:
|
327
342
|
"""
|
328
343
|
Score similarity based on changed file paths.
|
@@ -333,6 +348,8 @@ def score_files(
|
|
333
348
|
- If both sides include one or more files under .github/workflows/,
|
334
349
|
floor the score to workflow_min_floor.
|
335
350
|
"""
|
351
|
+
if workflow_min_floor is None:
|
352
|
+
workflow_min_floor = ScoringConfig.workflow_min_floor
|
336
353
|
|
337
354
|
def _nf(p: str) -> str:
|
338
355
|
q = (p or "").strip().lower()
|
@@ -353,7 +370,8 @@ def score_files(
|
|
353
370
|
reasons.append("Both modify workflow files (.github/workflows/*)")
|
354
371
|
if src_set or cand_set:
|
355
372
|
reasons.append(
|
356
|
-
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)})"
|
357
375
|
)
|
358
376
|
return ScoreResult(score=score, reasons=reasons)
|
359
377
|
|
@@ -370,18 +388,28 @@ def score_bodies(
|
|
370
388
|
# Very short bodies: exact match or zero
|
371
389
|
if len(source_body.strip()) < 50 or len(candidate_body.strip()) < 50:
|
372
390
|
if normalize_body(source_body) == normalize_body(candidate_body):
|
373
|
-
return ScoreResult(
|
391
|
+
return ScoreResult(
|
392
|
+
score=1.0, reasons=["Short bodies exactly match"]
|
393
|
+
)
|
374
394
|
return ScoreResult(score=0.0, reasons=[])
|
375
395
|
reasons: list[str] = []
|
376
396
|
# Automation-aware checks
|
377
397
|
src_text = source_body or ""
|
378
398
|
cand_text = candidate_body or ""
|
379
|
-
src_is_dep =
|
380
|
-
|
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
|
+
)
|
381
407
|
if src_is_dep and cand_is_dep:
|
382
408
|
pkg1 = ""
|
383
409
|
pkg2 = ""
|
384
|
-
m1 = re.search(
|
410
|
+
m1 = re.search(
|
411
|
+
r"dependency-name:\s*([^\s\n]+)", src_text, flags=re.IGNORECASE
|
412
|
+
)
|
385
413
|
m2 = re.search(
|
386
414
|
r"dependency-name:\s*([^\s\n]+)",
|
387
415
|
cand_text,
|
@@ -392,16 +420,28 @@ def score_bodies(
|
|
392
420
|
if m2:
|
393
421
|
pkg2 = m2.group(1).strip()
|
394
422
|
if pkg1 and pkg2 and pkg1 == pkg2:
|
395
|
-
return ScoreResult(
|
423
|
+
return ScoreResult(
|
424
|
+
score=0.95, reasons=[f"Dependabot package match: {pkg1}"]
|
425
|
+
)
|
396
426
|
# Different packages -> slight similarity for being both dependabot
|
397
427
|
reasons.append("Both look like Dependabot bodies")
|
398
428
|
# do not return yet; fall through to normalized ratio
|
399
|
-
src_is_pc =
|
400
|
-
|
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
|
+
)
|
401
437
|
if src_is_pc and cand_is_pc:
|
402
|
-
return ScoreResult(
|
438
|
+
return ScoreResult(
|
439
|
+
score=0.9, reasons=["Both look like pre-commit updates"]
|
440
|
+
)
|
403
441
|
src_is_actions = (
|
404
|
-
"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()
|
405
445
|
)
|
406
446
|
cand_is_actions = (
|
407
447
|
"github actions" in cand_text.lower()
|
@@ -411,7 +451,12 @@ def score_bodies(
|
|
411
451
|
if src_is_actions and cand_is_actions:
|
412
452
|
a1 = re.search(r"uses:\s*([^@\s]+)", src_text, flags=re.IGNORECASE)
|
413
453
|
a2 = re.search(r"uses:\s*([^@\s]+)", cand_text, flags=re.IGNORECASE)
|
414
|
-
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
|
+
):
|
415
460
|
return ScoreResult(
|
416
461
|
score=0.9,
|
417
462
|
reasons=[f"Same GitHub Action: {a1.group(1).strip()}"],
|
@@ -446,8 +491,10 @@ def aggregate_scores(
|
|
446
491
|
Weighted average in [0,1].
|
447
492
|
"""
|
448
493
|
if config is None:
|
449
|
-
config =
|
450
|
-
w_sum = float(
|
494
|
+
config = _DEFAULT_SCORING_CONFIG
|
495
|
+
w_sum = float(
|
496
|
+
config.subject_weight + config.files_weight + config.body_weight
|
497
|
+
)
|
451
498
|
if w_sum <= 0:
|
452
499
|
return 0.0
|
453
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)
|