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
github2gerrit/external_api.py
CHANGED
@@ -106,7 +106,9 @@ class ApiCallContext:
|
|
106
106
|
|
107
107
|
# Global metrics storage - in production this could be replaced with
|
108
108
|
# proper metrics collection system (Prometheus, etc.)
|
109
|
-
_METRICS: dict[ApiType, ApiMetrics] = {
|
109
|
+
_METRICS: dict[ApiType, ApiMetrics] = {
|
110
|
+
api_type: ApiMetrics() for api_type in ApiType
|
111
|
+
}
|
110
112
|
|
111
113
|
|
112
114
|
def get_api_metrics(api_type: ApiType) -> ApiMetrics:
|
@@ -159,7 +161,10 @@ def _is_transient_error(exc: BaseException, api_type: ApiType) -> bool:
|
|
159
161
|
reason = getattr(exc, "reason", None)
|
160
162
|
if isinstance(
|
161
163
|
reason,
|
162
|
-
socket.timeout
|
164
|
+
socket.timeout
|
165
|
+
| TimeoutError
|
166
|
+
| ConnectionResetError
|
167
|
+
| ConnectionAbortedError,
|
163
168
|
):
|
164
169
|
return True
|
165
170
|
|
@@ -175,11 +180,14 @@ def _is_transient_error(exc: BaseException, api_type: ApiType) -> bool:
|
|
175
180
|
|
176
181
|
# Check by class name or isinstance for mock/test exceptions
|
177
182
|
exc_name = exc.__class__.__name__
|
178
|
-
if exc_name in (
|
179
|
-
|
180
|
-
|
183
|
+
if exc_name in (
|
184
|
+
"RateLimitExceededException",
|
185
|
+
"RateLimitExceededExceptionType",
|
186
|
+
) or isinstance(exc, RateLimitExceededExceptionType):
|
181
187
|
return True
|
182
|
-
if exc_name in ("GithubException", "GithubExceptionType") or isinstance(
|
188
|
+
if exc_name in ("GithubException", "GithubExceptionType") or isinstance(
|
189
|
+
exc, GithubExceptionType
|
190
|
+
):
|
183
191
|
status = getattr(exc, "status", None)
|
184
192
|
if isinstance(status, int) and 500 <= status <= 599:
|
185
193
|
return True
|
@@ -187,12 +195,19 @@ def _is_transient_error(exc: BaseException, api_type: ApiType) -> bool:
|
|
187
195
|
data = getattr(exc, "data", "")
|
188
196
|
if isinstance(data, str | bytes):
|
189
197
|
try:
|
190
|
-
text =
|
198
|
+
text = (
|
199
|
+
data.decode("utf-8")
|
200
|
+
if isinstance(data, bytes)
|
201
|
+
else data
|
202
|
+
)
|
191
203
|
if "rate limit" in text.lower():
|
192
204
|
return True
|
193
205
|
except Exception:
|
194
206
|
# Ignore decode errors when checking for rate limit text
|
195
|
-
log.debug(
|
207
|
+
log.debug(
|
208
|
+
"Failed to decode GitHub API error data for rate "
|
209
|
+
"limit check"
|
210
|
+
)
|
196
211
|
return False
|
197
212
|
|
198
213
|
# Gerrit REST specific errors - check for wrapped HTTP errors
|
@@ -201,9 +216,13 @@ def _is_transient_error(exc: BaseException, api_type: ApiType) -> bool:
|
|
201
216
|
if "HTTP 5" in str(exc) or "HTTP 429" in str(exc):
|
202
217
|
return True
|
203
218
|
# Also check for original HTTP errors that caused the GerritRestError
|
204
|
-
if hasattr(exc, "__cause__") and isinstance(
|
219
|
+
if hasattr(exc, "__cause__") and isinstance(
|
220
|
+
exc.__cause__, urllib.error.HTTPError
|
221
|
+
):
|
205
222
|
status = getattr(exc.__cause__, "code", None)
|
206
|
-
return (
|
223
|
+
return (
|
224
|
+
(500 <= status <= 599) or (status == 429) if status else False
|
225
|
+
)
|
207
226
|
|
208
227
|
# SSH/Git command errors - check stderr for common transient messages
|
209
228
|
if api_type == ApiType.SSH:
|
@@ -321,7 +340,9 @@ def external_api_call(
|
|
321
340
|
attempt,
|
322
341
|
policy.max_attempts,
|
323
342
|
target,
|
324
|
-
f"(timeout={policy.timeout}s)"
|
343
|
+
f"(timeout={policy.timeout}s)"
|
344
|
+
if policy.timeout
|
345
|
+
else "",
|
325
346
|
)
|
326
347
|
|
327
348
|
# Call the actual function
|
@@ -343,7 +364,8 @@ def external_api_call(
|
|
343
364
|
policy.jitter_factor,
|
344
365
|
)
|
345
366
|
log.warning(
|
346
|
-
"[%s] %s attempt %d/%d failed (%.2fs): %s;
|
367
|
+
"[%s] %s attempt %d/%d failed (%.2fs): %s; "
|
368
|
+
"retrying in %.2fs",
|
347
369
|
api_type.value,
|
348
370
|
operation,
|
349
371
|
attempt,
|
@@ -355,11 +377,14 @@ def external_api_call(
|
|
355
377
|
time.sleep(delay)
|
356
378
|
continue
|
357
379
|
# Final failure - log and re-raise
|
358
|
-
reason =
|
380
|
+
reason = (
|
381
|
+
"final attempt" if is_final_attempt else "non-retryable"
|
382
|
+
)
|
359
383
|
log_exception_conditionally(
|
360
384
|
log,
|
361
385
|
f"[{api_type.value}] {operation} failed ({reason}) "
|
362
|
-
f"after {attempt} attempt(s) in {duration:.2f}s:
|
386
|
+
f"after {attempt} attempt(s) in {duration:.2f}s: "
|
387
|
+
f"{target}",
|
363
388
|
)
|
364
389
|
_update_metrics(api_type, context, success=False, exc=exc)
|
365
390
|
raise
|
@@ -394,17 +419,26 @@ def external_api_call(
|
|
394
419
|
|
395
420
|
def log_api_metrics_summary() -> None:
|
396
421
|
"""Log a summary of all API metrics."""
|
397
|
-
log.
|
422
|
+
log.debug("=== External API Metrics Summary ===")
|
398
423
|
for api_type in ApiType:
|
399
424
|
metrics = _METRICS[api_type]
|
400
425
|
if metrics.total_calls == 0:
|
401
426
|
continue
|
402
427
|
|
403
|
-
success_rate = (
|
404
|
-
|
428
|
+
success_rate = (
|
429
|
+
(metrics.successful_calls / metrics.total_calls * 100)
|
430
|
+
if metrics.total_calls > 0
|
431
|
+
else 0.0
|
432
|
+
)
|
433
|
+
avg_duration = (
|
434
|
+
metrics.total_duration / metrics.total_calls
|
435
|
+
if metrics.total_calls > 0
|
436
|
+
else 0.0
|
437
|
+
)
|
405
438
|
|
406
|
-
log.
|
407
|
-
"[%s] Calls: %d, Success: %.1f%%, Avg Duration: %.2fs,
|
439
|
+
log.debug(
|
440
|
+
"[%s] Calls: %d, Success: %.1f%%, Avg Duration: %.2fs, "
|
441
|
+
"Retries: %d, Timeouts: %d, Transient Errors: %d",
|
408
442
|
api_type.value,
|
409
443
|
metrics.total_calls,
|
410
444
|
success_rate,
|
@@ -447,7 +481,9 @@ def curl_download(
|
|
447
481
|
if policy is None:
|
448
482
|
policy = RetryPolicy(max_attempts=3, timeout=timeout)
|
449
483
|
|
450
|
-
@external_api_call(
|
484
|
+
@external_api_call(
|
485
|
+
ApiType.HTTP_DOWNLOAD, "curl_download", target=url, policy=policy
|
486
|
+
)
|
451
487
|
def _do_curl() -> tuple[int, str]:
|
452
488
|
cmd = ["curl"]
|
453
489
|
|
@@ -473,16 +509,28 @@ def curl_download(
|
|
473
509
|
|
474
510
|
# Helper functions for raising errors to comply with TRY301
|
475
511
|
def _raise_curl_failed(returncode: int, error_msg: str) -> None:
|
476
|
-
raise RuntimeError(
|
512
|
+
raise RuntimeError(
|
513
|
+
_MSG_CURL_FAILED_WITH_RC.format(
|
514
|
+
_MSG_CURL_FAILED, returncode, error_msg
|
515
|
+
)
|
516
|
+
)
|
477
517
|
|
478
518
|
def _raise_no_output() -> None:
|
479
519
|
raise RuntimeError(_MSG_CURL_NO_OUTPUT)
|
480
520
|
|
481
521
|
def _raise_timeout(timeout_val: float) -> None:
|
482
|
-
raise TimeoutError(
|
522
|
+
raise TimeoutError(
|
523
|
+
_MSG_CURL_TIMEOUT_WITH_TIME.format(
|
524
|
+
_MSG_CURL_TIMEOUT, timeout_val
|
525
|
+
)
|
526
|
+
)
|
483
527
|
|
484
528
|
def _raise_download_failed(exc: Exception) -> None:
|
485
|
-
raise RuntimeError(
|
529
|
+
raise RuntimeError(
|
530
|
+
_MSG_CURL_DOWNLOAD_FAILED_WITH_EXC.format(
|
531
|
+
_MSG_CURL_DOWNLOAD_FAILED, exc
|
532
|
+
)
|
533
|
+
) from exc
|
486
534
|
|
487
535
|
# Initialize variables
|
488
536
|
result = None
|
@@ -493,7 +541,8 @@ def curl_download(
|
|
493
541
|
cmd,
|
494
542
|
capture_output=True,
|
495
543
|
text=True,
|
496
|
-
timeout=timeout
|
544
|
+
timeout=timeout
|
545
|
+
+ 5, # Give subprocess a bit more time than curl
|
497
546
|
check=False,
|
498
547
|
)
|
499
548
|
|
@@ -501,7 +550,9 @@ def curl_download(
|
|
501
550
|
http_status = result.stdout.strip() if result.stdout else "unknown"
|
502
551
|
|
503
552
|
if result.returncode != 0:
|
504
|
-
error_msg =
|
553
|
+
error_msg = (
|
554
|
+
result.stderr.strip() if result.stderr else _MSG_CURL_FAILED
|
555
|
+
)
|
505
556
|
_raise_curl_failed(result.returncode, error_msg)
|
506
557
|
|
507
558
|
# Verify file was created
|
@@ -0,0 +1,286 @@
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
2
|
+
# SPDX-FileCopyrightText: 2025 The Linux Foundation
|
3
|
+
"""
|
4
|
+
Gerrit query utilities for topic-based change discovery.
|
5
|
+
|
6
|
+
This module provides functions to query Gerrit REST API for changes
|
7
|
+
based on topics, with support for pagination and safe parsing.
|
8
|
+
"""
|
9
|
+
|
10
|
+
import logging
|
11
|
+
from dataclasses import dataclass
|
12
|
+
from typing import Any
|
13
|
+
|
14
|
+
from .gerrit_rest import GerritRestClient
|
15
|
+
|
16
|
+
|
17
|
+
log = logging.getLogger(__name__)
|
18
|
+
|
19
|
+
|
20
|
+
@dataclass
|
21
|
+
class GerritChange:
|
22
|
+
"""Represents a Gerrit change from query results."""
|
23
|
+
|
24
|
+
change_id: str
|
25
|
+
number: str
|
26
|
+
subject: str
|
27
|
+
status: str
|
28
|
+
current_revision: str
|
29
|
+
files: list[str]
|
30
|
+
commit_message: str
|
31
|
+
topic: str | None = None
|
32
|
+
|
33
|
+
@classmethod
|
34
|
+
def from_dict(cls, data: dict[str, Any]) -> "GerritChange":
|
35
|
+
"""Create GerritChange from Gerrit REST API response."""
|
36
|
+
# Extract files from current revision
|
37
|
+
files = []
|
38
|
+
current_revision = data.get("current_revision", "")
|
39
|
+
if current_revision and "revisions" in data:
|
40
|
+
revision_data = data["revisions"].get(current_revision, {})
|
41
|
+
files = list(revision_data.get("files", {}).keys())
|
42
|
+
|
43
|
+
# Extract commit message
|
44
|
+
commit_message = ""
|
45
|
+
if current_revision and "revisions" in data:
|
46
|
+
revision_data = data["revisions"].get(current_revision, {})
|
47
|
+
commit_message = revision_data.get("commit", {}).get("message", "")
|
48
|
+
|
49
|
+
return cls(
|
50
|
+
change_id=data.get("change_id", ""),
|
51
|
+
number=str(data.get("_number", "")),
|
52
|
+
subject=data.get("subject", ""),
|
53
|
+
status=data.get("status", ""),
|
54
|
+
current_revision=current_revision,
|
55
|
+
files=files,
|
56
|
+
commit_message=commit_message,
|
57
|
+
topic=data.get("topic"),
|
58
|
+
)
|
59
|
+
|
60
|
+
|
61
|
+
def query_changes_by_topic(
|
62
|
+
client: GerritRestClient,
|
63
|
+
topic: str,
|
64
|
+
*,
|
65
|
+
statuses: list[str] | None = None,
|
66
|
+
max_results: int = 100,
|
67
|
+
) -> list[GerritChange]:
|
68
|
+
"""
|
69
|
+
Query Gerrit for changes matching the given topic.
|
70
|
+
|
71
|
+
Args:
|
72
|
+
client: Gerrit REST client
|
73
|
+
topic: Topic name to search for
|
74
|
+
statuses: List of change statuses to include (default: ["NEW"])
|
75
|
+
max_results: Maximum number of results to return
|
76
|
+
|
77
|
+
Returns:
|
78
|
+
List of GerritChange objects
|
79
|
+
"""
|
80
|
+
if statuses is None:
|
81
|
+
statuses = ["NEW"]
|
82
|
+
|
83
|
+
# Build query string
|
84
|
+
status_query = " OR ".join(f"status:{status}" for status in statuses)
|
85
|
+
query = f"topic:{topic} AND ({status_query})"
|
86
|
+
|
87
|
+
log.debug("Querying Gerrit for changes: %s", query)
|
88
|
+
|
89
|
+
try:
|
90
|
+
changes = _execute_query_with_pagination(
|
91
|
+
client, query, max_results=max_results
|
92
|
+
)
|
93
|
+
log.debug(
|
94
|
+
"Found %d changes for topic '%s' with statuses %s",
|
95
|
+
len(changes),
|
96
|
+
topic,
|
97
|
+
statuses,
|
98
|
+
)
|
99
|
+
except Exception as exc:
|
100
|
+
log.warning(
|
101
|
+
"Failed to query Gerrit changes for topic '%s': %s", topic, exc
|
102
|
+
)
|
103
|
+
return []
|
104
|
+
else:
|
105
|
+
return changes
|
106
|
+
|
107
|
+
|
108
|
+
def _execute_query_with_pagination(
|
109
|
+
client: GerritRestClient,
|
110
|
+
query: str,
|
111
|
+
*,
|
112
|
+
max_results: int = 100,
|
113
|
+
page_size: int = 25,
|
114
|
+
) -> list[GerritChange]:
|
115
|
+
"""
|
116
|
+
Execute Gerrit query with pagination support.
|
117
|
+
|
118
|
+
Args:
|
119
|
+
client: Gerrit REST client
|
120
|
+
query: Gerrit query string
|
121
|
+
max_results: Maximum total results to return
|
122
|
+
page_size: Results per page
|
123
|
+
|
124
|
+
Returns:
|
125
|
+
List of GerritChange objects
|
126
|
+
"""
|
127
|
+
all_changes: list[GerritChange] = []
|
128
|
+
start = 0
|
129
|
+
|
130
|
+
while len(all_changes) < max_results:
|
131
|
+
remaining = max_results - len(all_changes)
|
132
|
+
current_limit = min(page_size, remaining)
|
133
|
+
|
134
|
+
try:
|
135
|
+
# Build query URL with parameters
|
136
|
+
# Gerrit REST API: /changes/?q=query&n=limit&S=skip&o=options
|
137
|
+
query_params = [
|
138
|
+
f"q={query}",
|
139
|
+
f"n={current_limit}",
|
140
|
+
f"S={start}",
|
141
|
+
"o=CURRENT_REVISION",
|
142
|
+
"o=CURRENT_FILES",
|
143
|
+
"o=CURRENT_COMMIT",
|
144
|
+
]
|
145
|
+
query_path = f"/changes/?{'&'.join(query_params)}"
|
146
|
+
|
147
|
+
response = client.get(query_path)
|
148
|
+
|
149
|
+
if not response:
|
150
|
+
break
|
151
|
+
|
152
|
+
# Gerrit REST API returns a list of change objects
|
153
|
+
if not isinstance(response, list):
|
154
|
+
log.warning(
|
155
|
+
"Unexpected Gerrit query response format: %s",
|
156
|
+
type(response),
|
157
|
+
)
|
158
|
+
break
|
159
|
+
|
160
|
+
page_changes = []
|
161
|
+
for change_data in response:
|
162
|
+
try:
|
163
|
+
change = GerritChange.from_dict(change_data)
|
164
|
+
page_changes.append(change)
|
165
|
+
except Exception as exc:
|
166
|
+
log.debug("Skipping malformed change data: %s", exc)
|
167
|
+
continue
|
168
|
+
|
169
|
+
all_changes.extend(page_changes)
|
170
|
+
|
171
|
+
# If we got fewer results than requested, we've reached the end
|
172
|
+
if len(page_changes) < current_limit:
|
173
|
+
break
|
174
|
+
|
175
|
+
start += len(page_changes)
|
176
|
+
|
177
|
+
except Exception as exc:
|
178
|
+
log.warning(
|
179
|
+
"Failed to fetch Gerrit changes page (start=%d, limit=%d): %s",
|
180
|
+
start,
|
181
|
+
current_limit,
|
182
|
+
exc,
|
183
|
+
)
|
184
|
+
break
|
185
|
+
|
186
|
+
return all_changes[:max_results]
|
187
|
+
|
188
|
+
|
189
|
+
def extract_pr_metadata_from_commit_message(
|
190
|
+
commit_message: str,
|
191
|
+
) -> dict[str, str]:
|
192
|
+
"""
|
193
|
+
Extract GitHub PR metadata trailers from a commit message.
|
194
|
+
|
195
|
+
Args:
|
196
|
+
commit_message: Full commit message text
|
197
|
+
|
198
|
+
Returns:
|
199
|
+
Dictionary with extracted metadata (GitHub-PR, GitHub-Hash, etc.)
|
200
|
+
"""
|
201
|
+
metadata = {}
|
202
|
+
|
203
|
+
# Look for trailer-style metadata at the end of the commit message
|
204
|
+
lines = commit_message.strip().split("\n")
|
205
|
+
|
206
|
+
# Find the start of trailers (after the last blank line)
|
207
|
+
trailer_start = 0
|
208
|
+
for i in range(len(lines) - 1, -1, -1):
|
209
|
+
if not lines[i].strip():
|
210
|
+
trailer_start = i + 1
|
211
|
+
break
|
212
|
+
|
213
|
+
# Parse trailer lines
|
214
|
+
for raw_line in lines[trailer_start:]:
|
215
|
+
line = raw_line.strip()
|
216
|
+
if ":" in line:
|
217
|
+
key, value = line.split(":", 1)
|
218
|
+
key = key.strip()
|
219
|
+
value = value.strip()
|
220
|
+
if key.startswith("GitHub-"):
|
221
|
+
metadata[key] = value
|
222
|
+
|
223
|
+
return metadata
|
224
|
+
|
225
|
+
|
226
|
+
def validate_pr_metadata_match(
|
227
|
+
gerrit_changes: list[GerritChange],
|
228
|
+
expected_pr_url: str,
|
229
|
+
expected_github_hash: str,
|
230
|
+
) -> list[GerritChange]:
|
231
|
+
"""
|
232
|
+
Filter Gerrit changes to only those matching the expected PR metadata.
|
233
|
+
|
234
|
+
This prevents cross-PR contamination by ensuring changes belong to
|
235
|
+
the same GitHub PR based on trailer metadata.
|
236
|
+
|
237
|
+
Args:
|
238
|
+
gerrit_changes: List of changes from Gerrit query
|
239
|
+
expected_pr_url: Expected GitHub PR URL
|
240
|
+
expected_github_hash: Expected GitHub-Hash trailer value
|
241
|
+
|
242
|
+
Returns:
|
243
|
+
Filtered list of changes matching the PR metadata
|
244
|
+
"""
|
245
|
+
validated_changes = []
|
246
|
+
|
247
|
+
for change in gerrit_changes:
|
248
|
+
metadata = extract_pr_metadata_from_commit_message(
|
249
|
+
change.commit_message
|
250
|
+
)
|
251
|
+
|
252
|
+
# Check GitHub-PR URL match
|
253
|
+
pr_url = metadata.get("GitHub-PR", "")
|
254
|
+
if pr_url and pr_url != expected_pr_url:
|
255
|
+
log.debug(
|
256
|
+
"Excluding change %s: PR URL mismatch (expected=%s, found=%s)",
|
257
|
+
change.change_id,
|
258
|
+
expected_pr_url,
|
259
|
+
pr_url,
|
260
|
+
)
|
261
|
+
continue
|
262
|
+
|
263
|
+
# Check GitHub-Hash match
|
264
|
+
github_hash = metadata.get("GitHub-Hash", "")
|
265
|
+
if github_hash and github_hash != expected_github_hash:
|
266
|
+
log.debug(
|
267
|
+
"Excluding change %s: GitHub-Hash mismatch "
|
268
|
+
"(expected=%s, found=%s)",
|
269
|
+
change.change_id,
|
270
|
+
expected_github_hash,
|
271
|
+
github_hash,
|
272
|
+
)
|
273
|
+
continue
|
274
|
+
|
275
|
+
validated_changes.append(change)
|
276
|
+
|
277
|
+
if len(validated_changes) != len(gerrit_changes):
|
278
|
+
log.info(
|
279
|
+
"Filtered Gerrit changes: %d -> %d "
|
280
|
+
"(excluded %d due to metadata mismatch)",
|
281
|
+
len(gerrit_changes),
|
282
|
+
len(validated_changes),
|
283
|
+
len(gerrit_changes) - len(validated_changes),
|
284
|
+
)
|
285
|
+
|
286
|
+
return validated_changes
|
github2gerrit/gerrit_rest.py
CHANGED
@@ -50,14 +50,20 @@ log = logging.getLogger("github2gerrit.gerrit_rest")
|
|
50
50
|
|
51
51
|
# Optional pygerrit2 import
|
52
52
|
try: # pragma: no cover - exercised indirectly by tests that monkeypatch
|
53
|
-
from pygerrit2 import
|
54
|
-
|
53
|
+
from pygerrit2 import (
|
54
|
+
GerritRestAPI as _PygerritRestApi, # type: ignore[import-not-found, unused-ignore]
|
55
|
+
)
|
56
|
+
from pygerrit2 import (
|
57
|
+
HTTPBasicAuth as _PygerritHttpAuth, # type: ignore[import-not-found, unused-ignore]
|
58
|
+
)
|
55
59
|
except Exception: # pragma: no cover - absence path
|
56
60
|
_PygerritRestApi = None
|
57
61
|
_PygerritHttpAuth = None
|
58
62
|
|
59
63
|
|
60
|
-
_MSG_PYGERRIT2_REQUIRED_AUTH: Final[str] =
|
64
|
+
_MSG_PYGERRIT2_REQUIRED_AUTH: Final[str] = (
|
65
|
+
"pygerrit2 is required for HTTP authentication"
|
66
|
+
)
|
61
67
|
|
62
68
|
_TRANSIENT_ERR_SUBSTRINGS: Final[tuple[str, ...]] = (
|
63
69
|
"timed out",
|
@@ -129,7 +135,10 @@ class GerritRestClient:
|
|
129
135
|
if _PygerritHttpAuth is None:
|
130
136
|
raise GerritRestError(_MSG_PYGERRIT2_REQUIRED_AUTH)
|
131
137
|
self._client: Any = _PygerritRestApi(
|
132
|
-
url=self._base_url,
|
138
|
+
url=self._base_url,
|
139
|
+
auth=_PygerritHttpAuth(
|
140
|
+
self._auth.user, self._auth.password
|
141
|
+
),
|
133
142
|
)
|
134
143
|
else:
|
135
144
|
self._client = _PygerritRestApi(url=self._base_url)
|
@@ -137,7 +146,8 @@ class GerritRestClient:
|
|
137
146
|
self._client = None
|
138
147
|
|
139
148
|
log.debug(
|
140
|
-
"GerritRestClient(base_url=%s, timeout=%.1fs, attempts=%d,
|
149
|
+
"GerritRestClient(base_url=%s, timeout=%.1fs, attempts=%d, "
|
150
|
+
"auth_user=%s)",
|
141
151
|
self._base_url,
|
142
152
|
self._timeout,
|
143
153
|
self._attempts,
|
@@ -160,16 +170,22 @@ class GerritRestClient:
|
|
160
170
|
|
161
171
|
# Internal helpers
|
162
172
|
|
163
|
-
def _request_json_with_retry(
|
173
|
+
def _request_json_with_retry(
|
174
|
+
self, method: str, path: str, data: Any | None = None
|
175
|
+
) -> Any:
|
164
176
|
"""Perform a JSON request with retry using external API framework."""
|
165
177
|
|
166
|
-
@external_api_call(
|
178
|
+
@external_api_call(
|
179
|
+
ApiType.GERRIT_REST, f"{method.lower()}", policy=self._retry_policy
|
180
|
+
)
|
167
181
|
def _do_request() -> Any:
|
168
182
|
return self._request_json(method, path, data)
|
169
183
|
|
170
184
|
return _do_request()
|
171
185
|
|
172
|
-
def _request_json(
|
186
|
+
def _request_json(
|
187
|
+
self, method: str, path: str, data: Any | None = None
|
188
|
+
) -> Any:
|
173
189
|
"""Perform a JSON request (retry logic handled by decorator)."""
|
174
190
|
if not path:
|
175
191
|
msg_required = "path is required"
|
@@ -181,10 +197,14 @@ class GerritRestClient:
|
|
181
197
|
|
182
198
|
try:
|
183
199
|
if self._client is not None and method == "GET" and data is None:
|
184
|
-
# pygerrit2 path: only using GET to keep behavior consistent
|
200
|
+
# pygerrit2 path: only using GET to keep behavior consistent
|
201
|
+
# with current usage
|
185
202
|
log.debug("Gerrit REST GET via pygerrit2: %s", url)
|
186
|
-
# pygerrit2.get expects a relative path; keep 'path' argument
|
187
|
-
|
203
|
+
# pygerrit2.get expects a relative path; keep 'path' argument
|
204
|
+
# as-is
|
205
|
+
return self._client.get(
|
206
|
+
"/" + rel if not path.startswith("/") else path
|
207
|
+
)
|
188
208
|
|
189
209
|
# urllib path (or non-GET with pygerrit2 absent)
|
190
210
|
headers = {"Accept": "application/json"}
|
@@ -194,19 +214,29 @@ class GerritRestClient:
|
|
194
214
|
body_bytes = json.dumps(data).encode("utf-8")
|
195
215
|
|
196
216
|
if self._auth is not None:
|
197
|
-
token = base64.b64encode(
|
217
|
+
token = base64.b64encode(
|
218
|
+
f"{self._auth.user}:{self._auth.password}".encode()
|
219
|
+
).decode("ascii")
|
198
220
|
headers["Authorization"] = f"Basic {token}"
|
199
221
|
scheme = urllib.parse.urlparse(url).scheme
|
200
222
|
if scheme not in ("http", "https"):
|
201
223
|
msg_scheme = f"Unsupported URL scheme for Gerrit REST: {scheme}"
|
202
224
|
raise GerritRestError(msg_scheme)
|
203
|
-
req = urllib.request.Request(
|
204
|
-
|
225
|
+
req = urllib.request.Request(
|
226
|
+
url, data=body_bytes, method=method, headers=headers
|
227
|
+
)
|
228
|
+
log.debug(
|
229
|
+
"Gerrit REST %s %s (auth_user=%s)",
|
230
|
+
method,
|
231
|
+
url,
|
232
|
+
self._auth.user if self._auth else "",
|
233
|
+
)
|
205
234
|
|
206
235
|
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
207
236
|
status = getattr(resp, "status", None)
|
208
237
|
content = resp.read()
|
209
|
-
# Gerrit prepends ")]}'" in JSON responses to prevent JSON
|
238
|
+
# Gerrit prepends ")]}'" in JSON responses to prevent JSON
|
239
|
+
# hijacking; strip if present
|
210
240
|
text = content.decode("utf-8", errors="replace")
|
211
241
|
text = _strip_xssi_guard(text)
|
212
242
|
return _json_loads(text)
|
@@ -267,7 +297,8 @@ def build_client_for_host(
|
|
267
297
|
- Uses auto-discovered or environment-provided base path.
|
268
298
|
- Reads HTTP auth from arguments or environment:
|
269
299
|
GERRIT_HTTP_USER / GERRIT_HTTP_PASSWORD
|
270
|
-
If user is not provided, falls back to GERRIT_SSH_USER_G2G per project
|
300
|
+
If user is not provided, falls back to GERRIT_SSH_USER_G2G per project
|
301
|
+
norms.
|
271
302
|
|
272
303
|
Args:
|
273
304
|
host: Gerrit hostname (no scheme)
|
@@ -286,9 +317,13 @@ def build_client_for_host(
|
|
286
317
|
or os.getenv("GERRIT_HTTP_USER", "").strip()
|
287
318
|
or os.getenv("GERRIT_SSH_USER_G2G", "").strip()
|
288
319
|
)
|
289
|
-
passwd = (http_password or "").strip() or os.getenv(
|
320
|
+
passwd = (http_password or "").strip() or os.getenv(
|
321
|
+
"GERRIT_HTTP_PASSWORD", ""
|
322
|
+
).strip()
|
290
323
|
auth: tuple[str, str] | None = (user, passwd) if user and passwd else None
|
291
|
-
return GerritRestClient(
|
324
|
+
return GerritRestClient(
|
325
|
+
base_url=base_url, auth=auth, timeout=timeout, max_attempts=max_attempts
|
326
|
+
)
|
292
327
|
|
293
328
|
|
294
329
|
__all__ = [
|