github2gerrit 1.2.2__py3-none-any.whl → 1.2.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- github2gerrit/core.py +6 -2
- github2gerrit/external_api.py +17 -3
- github2gerrit/gerrit_pr_closer.py +71 -2
- github2gerrit/gerrit_query.py +15 -0
- github2gerrit/gerrit_rest.py +111 -4
- github2gerrit/gerrit_ssh.py +284 -0
- github2gerrit/orchestrator/reconciliation.py +26 -2
- github2gerrit/pr_content_filter.py +8 -1
- github2gerrit/utils.py +45 -0
- {github2gerrit-1.2.2.dist-info → github2gerrit-1.2.4.dist-info}/METADATA +4 -4
- {github2gerrit-1.2.2.dist-info → github2gerrit-1.2.4.dist-info}/RECORD +14 -13
- {github2gerrit-1.2.2.dist-info → github2gerrit-1.2.4.dist-info}/WHEEL +1 -1
- {github2gerrit-1.2.2.dist-info → github2gerrit-1.2.4.dist-info}/entry_points.txt +0 -0
- {github2gerrit-1.2.2.dist-info → github2gerrit-1.2.4.dist-info}/licenses/LICENSE +0 -0
github2gerrit/core.py
CHANGED
|
@@ -410,9 +410,13 @@ class Orchestrator:
|
|
|
410
410
|
|
|
411
411
|
# Check if client has authentication
|
|
412
412
|
if not client.is_authenticated:
|
|
413
|
+
from .gerrit_rest import warn_gerrit_credentials_unavailable
|
|
414
|
+
|
|
415
|
+
warn_gerrit_credentials_unavailable()
|
|
413
416
|
log.debug(
|
|
414
|
-
"Cannot update Gerrit change metadata: "
|
|
415
|
-
"
|
|
417
|
+
"Cannot update Gerrit change metadata for %s: "
|
|
418
|
+
"no Gerrit REST credentials available",
|
|
419
|
+
change_id,
|
|
416
420
|
)
|
|
417
421
|
return False
|
|
418
422
|
|
github2gerrit/external_api.py
CHANGED
|
@@ -380,12 +380,26 @@ def external_api_call(
|
|
|
380
380
|
reason = (
|
|
381
381
|
"final attempt" if is_final_attempt else "non-retryable"
|
|
382
382
|
)
|
|
383
|
-
|
|
384
|
-
log,
|
|
383
|
+
failure_msg = (
|
|
385
384
|
f"[{api_type.value}] {operation} failed ({reason}) "
|
|
386
385
|
f"after {attempt} attempt(s) in {duration:.2f}s: "
|
|
387
|
-
f"{target}"
|
|
386
|
+
f"{target}"
|
|
388
387
|
)
|
|
388
|
+
# Authentication/authorization failures (Gerrit REST
|
|
389
|
+
# 401/403) are surfaced once, closer to the request, as a
|
|
390
|
+
# concise warning. Avoid emitting a duplicate error/
|
|
391
|
+
# traceback for them here. Scope this strictly to Gerrit
|
|
392
|
+
# REST: other API types (e.g. GitHub) may also expose a
|
|
393
|
+
# ``.status`` attribute, and their auth/permission
|
|
394
|
+
# failures must retain error-level visibility.
|
|
395
|
+
is_gerrit_auth_failure = (
|
|
396
|
+
api_type == ApiType.GERRIT_REST
|
|
397
|
+
and getattr(exc, "status", None) in (401, 403)
|
|
398
|
+
)
|
|
399
|
+
if is_gerrit_auth_failure:
|
|
400
|
+
log.debug(failure_msg)
|
|
401
|
+
else:
|
|
402
|
+
log_exception_conditionally(log, failure_msg)
|
|
389
403
|
_update_metrics(api_type, context, success=False, exc=exc)
|
|
390
404
|
raise
|
|
391
405
|
else:
|
|
@@ -11,6 +11,7 @@ merged and close the corresponding GitHub pull request that originated it.
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
13
|
import logging
|
|
14
|
+
import os
|
|
14
15
|
import re
|
|
15
16
|
from typing import Any
|
|
16
17
|
from typing import Literal
|
|
@@ -1679,11 +1680,57 @@ def _build_gerrit_abandon_message(pr_obj: Any, pr_url: str) -> str:
|
|
|
1679
1680
|
)
|
|
1680
1681
|
|
|
1681
1682
|
|
|
1683
|
+
def _abandon_change_via_ssh_if_possible(
|
|
1684
|
+
client: Any, change_number: str, message: str
|
|
1685
|
+
) -> bool:
|
|
1686
|
+
"""Attempt to abandon a change over SSH using ambient credentials.
|
|
1687
|
+
|
|
1688
|
+
Reads the Gerrit SSH connection details from the environment
|
|
1689
|
+
(populated by the action) and the host from the REST *client*.
|
|
1690
|
+
|
|
1691
|
+
Returns ``True`` only if the change was abandoned via SSH; ``False``
|
|
1692
|
+
when SSH is not configured or the SSH abandon did not succeed (in
|
|
1693
|
+
which case the caller should fall back to REST).
|
|
1694
|
+
"""
|
|
1695
|
+
ssh_privkey = os.getenv("GERRIT_SSH_PRIVKEY_G2G", "").strip()
|
|
1696
|
+
ssh_user = os.getenv("GERRIT_SSH_USER_G2G", "").strip()
|
|
1697
|
+
if not ssh_privkey or not ssh_user:
|
|
1698
|
+
return False
|
|
1699
|
+
|
|
1700
|
+
host = os.getenv("GERRIT_SERVER", "").strip()
|
|
1701
|
+
if not host:
|
|
1702
|
+
host = getattr(client, "host", "") or ""
|
|
1703
|
+
if not host:
|
|
1704
|
+
return False
|
|
1705
|
+
|
|
1706
|
+
try:
|
|
1707
|
+
port = int(os.getenv("GERRIT_SERVER_PORT", "29418") or "29418")
|
|
1708
|
+
except ValueError:
|
|
1709
|
+
port = 29418
|
|
1710
|
+
|
|
1711
|
+
from .gerrit_ssh import abandon_change_via_ssh
|
|
1712
|
+
|
|
1713
|
+
return abandon_change_via_ssh(
|
|
1714
|
+
host=host,
|
|
1715
|
+
change_number=str(change_number),
|
|
1716
|
+
message=message,
|
|
1717
|
+
user=ssh_user,
|
|
1718
|
+
ssh_privkey=ssh_privkey,
|
|
1719
|
+
known_hosts=os.getenv("GERRIT_KNOWN_HOSTS", ""),
|
|
1720
|
+
port=port,
|
|
1721
|
+
)
|
|
1722
|
+
|
|
1723
|
+
|
|
1682
1724
|
def _abandon_gerrit_change(
|
|
1683
1725
|
client: Any, change_number: str, message: str
|
|
1684
1726
|
) -> None:
|
|
1685
1727
|
"""
|
|
1686
|
-
Abandon a Gerrit change
|
|
1728
|
+
Abandon a Gerrit change.
|
|
1729
|
+
|
|
1730
|
+
Prefers SSH (``gerrit review --abandon``) because mutating REST calls
|
|
1731
|
+
are rejected with HTTP 403 on Gerrit servers that do not allow
|
|
1732
|
+
unauthenticated REST writes and where only SSH credentials are
|
|
1733
|
+
configured. Falls back to the REST API when SSH is unavailable.
|
|
1687
1734
|
|
|
1688
1735
|
Args:
|
|
1689
1736
|
client: Gerrit REST client
|
|
@@ -1691,13 +1738,35 @@ def _abandon_gerrit_change(
|
|
|
1691
1738
|
message: Abandon message
|
|
1692
1739
|
|
|
1693
1740
|
Raises:
|
|
1694
|
-
Exception: If abandon operation fails
|
|
1741
|
+
Exception: If the abandon operation fails
|
|
1695
1742
|
"""
|
|
1743
|
+
if _abandon_change_via_ssh_if_possible(client, change_number, message):
|
|
1744
|
+
log.debug(
|
|
1745
|
+
"Successfully abandoned Gerrit change %s via SSH", change_number
|
|
1746
|
+
)
|
|
1747
|
+
return
|
|
1748
|
+
|
|
1696
1749
|
try:
|
|
1750
|
+
log.debug(
|
|
1751
|
+
"Falling back to REST abandon for Gerrit change %s", change_number
|
|
1752
|
+
)
|
|
1697
1753
|
abandon_path = f"/changes/{change_number}/abandon"
|
|
1698
1754
|
abandon_data = {"message": message}
|
|
1699
1755
|
client.post(abandon_path, data=abandon_data)
|
|
1700
1756
|
log.debug("Successfully abandoned Gerrit change %s", change_number)
|
|
1757
|
+
except GerritRestError as exc:
|
|
1758
|
+
if exc.is_auth_error:
|
|
1759
|
+
# Expected when no Gerrit REST credentials are available; the
|
|
1760
|
+
# REST layer already surfaced this once. Avoid a duplicate
|
|
1761
|
+
# error-level traceback for an authentication failure.
|
|
1762
|
+
log.debug(
|
|
1763
|
+
"REST abandon for Gerrit change %s failed (HTTP %s)",
|
|
1764
|
+
change_number,
|
|
1765
|
+
exc.status,
|
|
1766
|
+
)
|
|
1767
|
+
else:
|
|
1768
|
+
log.exception("Failed to abandon Gerrit change %s", change_number)
|
|
1769
|
+
raise
|
|
1701
1770
|
except Exception:
|
|
1702
1771
|
log.exception("Failed to abandon Gerrit change %s", change_number)
|
|
1703
1772
|
raise
|
github2gerrit/gerrit_query.py
CHANGED
|
@@ -13,6 +13,7 @@ from typing import Any
|
|
|
13
13
|
from urllib.parse import quote
|
|
14
14
|
|
|
15
15
|
from .gerrit_rest import GerritRestClient
|
|
16
|
+
from .gerrit_rest import warn_gerrit_credentials_unavailable
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
log = logging.getLogger(__name__)
|
|
@@ -148,6 +149,20 @@ def query_open_changes_by_project(
|
|
|
148
149
|
query = f'project:"{_gerrit_quote(project)}" status:open owner:self'
|
|
149
150
|
if branch:
|
|
150
151
|
query += f' branch:"{_gerrit_quote(branch)}"'
|
|
152
|
+
|
|
153
|
+
# The ``owner:self`` predicate requires an authenticated Gerrit
|
|
154
|
+
# session; an anonymous request is rejected with HTTP 403. Skip the
|
|
155
|
+
# query (warning once per run) instead of issuing a request that is
|
|
156
|
+
# guaranteed to fail and would otherwise emit error-level noise.
|
|
157
|
+
if not client.is_authenticated:
|
|
158
|
+
warn_gerrit_credentials_unavailable()
|
|
159
|
+
log.debug(
|
|
160
|
+
"Skipping owner:self query for project '%s': "
|
|
161
|
+
"no Gerrit REST credentials available",
|
|
162
|
+
project,
|
|
163
|
+
)
|
|
164
|
+
return []
|
|
165
|
+
|
|
151
166
|
log.debug("Querying Gerrit for open changes: %s", query)
|
|
152
167
|
|
|
153
168
|
try:
|
github2gerrit/gerrit_rest.py
CHANGED
|
@@ -46,6 +46,7 @@ from .gerrit_urls import create_gerrit_url_builder
|
|
|
46
46
|
from .netrc import GerritCredentials
|
|
47
47
|
from .netrc import resolve_gerrit_credentials
|
|
48
48
|
from .utils import log_exception_conditionally
|
|
49
|
+
from .utils import log_warning_once
|
|
49
50
|
|
|
50
51
|
|
|
51
52
|
log = logging.getLogger("github2gerrit.gerrit_rest")
|
|
@@ -80,12 +81,79 @@ _TRANSIENT_ERR_SUBSTRINGS: Final[tuple[str, ...]] = (
|
|
|
80
81
|
"gateway timeout",
|
|
81
82
|
)
|
|
82
83
|
|
|
84
|
+
# HTTP status codes that indicate an authentication/authorization problem
|
|
85
|
+
# rather than a transient fault or a bug. These are expected when no
|
|
86
|
+
# Gerrit REST credentials are available (or they are insufficient for the
|
|
87
|
+
# requested operation), and are surfaced as concise, default-visible
|
|
88
|
+
# warnings rather than error-level tracebacks.
|
|
89
|
+
_AUTH_ERROR_STATUSES: Final[tuple[int, ...]] = (401, 403)
|
|
90
|
+
|
|
91
|
+
# Shared dedup key so the "no Gerrit REST credentials" situation is
|
|
92
|
+
# surfaced at most once per run, regardless of how many auth-gated
|
|
93
|
+
# operations are affected.
|
|
94
|
+
_CREDENTIALS_UNAVAILABLE_KEY: Final[str] = "gerrit_rest_credentials_unavailable"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _is_auth_status(status: int | None) -> bool:
|
|
98
|
+
"""Return True if the HTTP status indicates an auth/authorization error."""
|
|
99
|
+
return status in _AUTH_ERROR_STATUSES
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _extract_http_status(exc: BaseException) -> int | None:
|
|
103
|
+
"""Best-effort extraction of an HTTP status code from an exception.
|
|
104
|
+
|
|
105
|
+
Handles both the urllib path (``urllib.error.HTTPError.code``) and the
|
|
106
|
+
pygerrit2/requests path (``HTTPError.response.status_code``).
|
|
107
|
+
"""
|
|
108
|
+
code = getattr(exc, "code", None)
|
|
109
|
+
if isinstance(code, int):
|
|
110
|
+
return code
|
|
111
|
+
response = getattr(exc, "response", None)
|
|
112
|
+
status = getattr(response, "status_code", None)
|
|
113
|
+
if isinstance(status, int):
|
|
114
|
+
return status
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def warn_gerrit_credentials_unavailable() -> None:
|
|
119
|
+
"""Warn once per run that authenticated Gerrit REST is unavailable.
|
|
120
|
+
|
|
121
|
+
Call this from auth-gated code paths before skipping an operation that
|
|
122
|
+
requires Gerrit REST credentials. The warning is emitted at most once
|
|
123
|
+
per process to avoid log spam while still making the degraded behavior
|
|
124
|
+
visible at the default log level.
|
|
125
|
+
"""
|
|
126
|
+
log_warning_once(
|
|
127
|
+
log,
|
|
128
|
+
_CREDENTIALS_UNAVAILABLE_KEY,
|
|
129
|
+
"Gerrit REST credentials are not available; authenticated Gerrit "
|
|
130
|
+
"REST operations are disabled. Fallback behavior may apply and "
|
|
131
|
+
"could degrade performance. Provide GERRIT_HTTP_USER and "
|
|
132
|
+
"GERRIT_HTTP_PASSWORD (or a .netrc entry) to enable authenticated "
|
|
133
|
+
"Gerrit REST access.",
|
|
134
|
+
)
|
|
135
|
+
|
|
83
136
|
|
|
84
137
|
# Removed individual retry logic functions - now using centralized framework
|
|
85
138
|
|
|
86
139
|
|
|
87
140
|
class GerritRestError(RuntimeError):
|
|
88
|
-
"""Raised for non-retryable REST errors or exhausted retries.
|
|
141
|
+
"""Raised for non-retryable REST errors or exhausted retries.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
status: HTTP status code associated with the failure, when known.
|
|
145
|
+
Used by callers to distinguish authentication/authorization
|
|
146
|
+
problems (401/403) from transient or unexpected errors.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
def __init__(self, *args: object, status: int | None = None) -> None:
|
|
150
|
+
super().__init__(*args)
|
|
151
|
+
self.status = status
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def is_auth_error(self) -> bool:
|
|
155
|
+
"""Return True if this error stems from an auth failure (401/403)."""
|
|
156
|
+
return _is_auth_status(self.status)
|
|
89
157
|
|
|
90
158
|
|
|
91
159
|
@dataclass(frozen=True)
|
|
@@ -163,6 +231,19 @@ class GerritRestClient:
|
|
|
163
231
|
"""Return True if client has authentication credentials."""
|
|
164
232
|
return self._auth is not None
|
|
165
233
|
|
|
234
|
+
@property
|
|
235
|
+
def host(self) -> str:
|
|
236
|
+
"""Return the Gerrit hostname derived from the base URL.
|
|
237
|
+
|
|
238
|
+
Returns an empty string when the host cannot be determined.
|
|
239
|
+
"""
|
|
240
|
+
from urllib.parse import urlparse
|
|
241
|
+
|
|
242
|
+
try:
|
|
243
|
+
return urlparse(self._base_url).hostname or ""
|
|
244
|
+
except Exception:
|
|
245
|
+
return ""
|
|
246
|
+
|
|
166
247
|
def get(self, path: str) -> Any:
|
|
167
248
|
"""HTTP GET, returning parsed JSON."""
|
|
168
249
|
return self._request_json_with_retry("GET", path)
|
|
@@ -251,13 +332,38 @@ class GerritRestClient:
|
|
|
251
332
|
except urllib.error.HTTPError as http_exc:
|
|
252
333
|
status = getattr(http_exc, "code", None)
|
|
253
334
|
msg = f"Gerrit REST {method} {url} failed with HTTP {status}"
|
|
254
|
-
|
|
255
|
-
raise GerritRestError(msg) from http_exc
|
|
335
|
+
self._log_request_failure(msg, status)
|
|
336
|
+
raise GerritRestError(msg, status=status) from http_exc
|
|
256
337
|
|
|
257
338
|
except Exception as exc:
|
|
339
|
+
status = _extract_http_status(exc)
|
|
258
340
|
msg = f"Gerrit REST {method} {url} failed: {exc}"
|
|
341
|
+
self._log_request_failure(msg, status)
|
|
342
|
+
raise GerritRestError(msg, status=status) from exc
|
|
343
|
+
|
|
344
|
+
@staticmethod
|
|
345
|
+
def _log_request_failure(msg: str, status: int | None) -> None:
|
|
346
|
+
"""Log a failed REST request at an appropriate level.
|
|
347
|
+
|
|
348
|
+
Authentication/authorization failures (401/403) are expected when
|
|
349
|
+
credentials are missing or insufficient; they are surfaced as a
|
|
350
|
+
single concise warning per run rather than an error-level traceback,
|
|
351
|
+
since they are neither transient faults nor bugs. All other failures
|
|
352
|
+
retain the existing conditional error/traceback behavior.
|
|
353
|
+
"""
|
|
354
|
+
if _is_auth_status(status):
|
|
355
|
+
log_warning_once(
|
|
356
|
+
log,
|
|
357
|
+
f"gerrit_rest_auth_{status}",
|
|
358
|
+
"Gerrit REST authentication failed (HTTP %s): the configured "
|
|
359
|
+
"credentials are missing or insufficient for this operation. "
|
|
360
|
+
"Fallback behavior may apply and could degrade performance. "
|
|
361
|
+
"Details: %s",
|
|
362
|
+
status,
|
|
363
|
+
msg,
|
|
364
|
+
)
|
|
365
|
+
else:
|
|
259
366
|
log_exception_conditionally(log, msg)
|
|
260
|
-
raise GerritRestError(msg) from exc
|
|
261
367
|
|
|
262
368
|
def __repr__(self) -> str: # pragma: no cover - convenience
|
|
263
369
|
masked = ""
|
|
@@ -372,4 +478,5 @@ __all__ = [
|
|
|
372
478
|
"GerritRestClient",
|
|
373
479
|
"GerritRestError",
|
|
374
480
|
"build_client_for_host",
|
|
481
|
+
"warn_gerrit_credentials_unavailable",
|
|
375
482
|
]
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2025 The Linux Foundation
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
SSH-based Gerrit operations.
|
|
6
|
+
|
|
7
|
+
Some Gerrit deployments (notably those fronted by a CDN/WAF, or that
|
|
8
|
+
disallow anonymous REST writes) reject mutating REST calls such as
|
|
9
|
+
``POST /changes/{id}/abandon`` with HTTP 403 unless dedicated HTTP API
|
|
10
|
+
credentials are supplied. The github2gerrit action authenticates to
|
|
11
|
+
Gerrit over SSH (the same channel used to push changes), so this module
|
|
12
|
+
performs the abandon over SSH instead, reusing the already-configured
|
|
13
|
+
SSH key and known_hosts.
|
|
14
|
+
|
|
15
|
+
The single public entry point :func:`abandon_change_via_ssh` is designed
|
|
16
|
+
to be used as the preferred path with a REST fallback: it never raises
|
|
17
|
+
and returns ``True`` only when the change was actually abandoned.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import logging
|
|
24
|
+
import os
|
|
25
|
+
import secrets
|
|
26
|
+
import shlex
|
|
27
|
+
import tempfile
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from .gitutils import CommandError
|
|
31
|
+
from .gitutils import run_cmd
|
|
32
|
+
from .ssh_common import augment_known_hosts_with_bracketed_entries
|
|
33
|
+
from .ssh_common import build_non_interactive_ssh_env
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
log = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
DEFAULT_GERRIT_SSH_PORT = 29418
|
|
39
|
+
_SSH_TIMEOUT_SECONDS = 30.0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _write_secure_file(path: Path, content: str, mode: int) -> None:
|
|
43
|
+
"""Write *content* to *path* with restrictive *mode* permissions."""
|
|
44
|
+
# Create with secure permissions from the start (avoid a window where
|
|
45
|
+
# the file is world-readable).
|
|
46
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode)
|
|
47
|
+
try:
|
|
48
|
+
handle = os.fdopen(fd, "w", encoding="utf-8")
|
|
49
|
+
except BaseException:
|
|
50
|
+
# os.fdopen did not take ownership of fd; close it to avoid a leak.
|
|
51
|
+
os.close(fd)
|
|
52
|
+
raise
|
|
53
|
+
with handle:
|
|
54
|
+
handle.write(content)
|
|
55
|
+
# Re-assert mode in case umask altered it.
|
|
56
|
+
path.chmod(mode)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _build_ssh_base_argv(
|
|
60
|
+
*,
|
|
61
|
+
key_path: Path,
|
|
62
|
+
known_hosts_path: Path | None,
|
|
63
|
+
port: int,
|
|
64
|
+
user: str,
|
|
65
|
+
host: str,
|
|
66
|
+
) -> list[str]:
|
|
67
|
+
"""Build the common ``ssh`` argv prefix for non-interactive auth."""
|
|
68
|
+
argv: list[str] = [
|
|
69
|
+
"ssh",
|
|
70
|
+
"-F",
|
|
71
|
+
"/dev/null",
|
|
72
|
+
"-i",
|
|
73
|
+
str(key_path),
|
|
74
|
+
"-o",
|
|
75
|
+
"IdentitiesOnly=yes",
|
|
76
|
+
"-o",
|
|
77
|
+
"IdentityAgent=none",
|
|
78
|
+
"-o",
|
|
79
|
+
"BatchMode=yes",
|
|
80
|
+
"-o",
|
|
81
|
+
"PreferredAuthentications=publickey",
|
|
82
|
+
"-o",
|
|
83
|
+
"PasswordAuthentication=no",
|
|
84
|
+
"-o",
|
|
85
|
+
"PubkeyAcceptedKeyTypes=+ssh-rsa",
|
|
86
|
+
"-o",
|
|
87
|
+
"ConnectTimeout=10",
|
|
88
|
+
]
|
|
89
|
+
if known_hosts_path is not None:
|
|
90
|
+
argv += [
|
|
91
|
+
"-o",
|
|
92
|
+
f"UserKnownHostsFile={known_hosts_path}",
|
|
93
|
+
"-o",
|
|
94
|
+
"StrictHostKeyChecking=yes",
|
|
95
|
+
]
|
|
96
|
+
else:
|
|
97
|
+
# No known_hosts supplied: we cannot verify the host key, but we
|
|
98
|
+
# still want a non-interactive connection. accept-new records the
|
|
99
|
+
# key on first use; pair it with an explicit (throwaway)
|
|
100
|
+
# UserKnownHostsFile so OpenSSH does not mutate the runner's
|
|
101
|
+
# default ~/.ssh/known_hosts (which may be missing/unwritable).
|
|
102
|
+
log.warning(
|
|
103
|
+
"No GERRIT_KNOWN_HOSTS available for SSH abandon; using "
|
|
104
|
+
"StrictHostKeyChecking=accept-new with a throwaway known_hosts"
|
|
105
|
+
)
|
|
106
|
+
argv += [
|
|
107
|
+
"-o",
|
|
108
|
+
"UserKnownHostsFile=/dev/null",
|
|
109
|
+
"-o",
|
|
110
|
+
"StrictHostKeyChecking=accept-new",
|
|
111
|
+
]
|
|
112
|
+
argv += ["-n", "-p", str(port), f"{user}@{host}"]
|
|
113
|
+
return argv
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _resolve_current_patchset(
|
|
117
|
+
base_argv: list[str],
|
|
118
|
+
change_number: str,
|
|
119
|
+
env: dict[str, str],
|
|
120
|
+
) -> str | None:
|
|
121
|
+
"""Return the current patch-set number for *change_number*, or None."""
|
|
122
|
+
remote_cmd = (
|
|
123
|
+
"gerrit query --format=JSON --current-patch-set "
|
|
124
|
+
f"change:{shlex.quote(change_number)}"
|
|
125
|
+
)
|
|
126
|
+
try:
|
|
127
|
+
result = run_cmd(
|
|
128
|
+
[*base_argv, remote_cmd],
|
|
129
|
+
timeout=_SSH_TIMEOUT_SECONDS,
|
|
130
|
+
env=env,
|
|
131
|
+
)
|
|
132
|
+
except CommandError as exc:
|
|
133
|
+
log.debug(
|
|
134
|
+
"Gerrit SSH query for change %s failed: %s",
|
|
135
|
+
change_number,
|
|
136
|
+
exc,
|
|
137
|
+
)
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
for raw_line in result.stdout.splitlines():
|
|
141
|
+
line = raw_line.strip()
|
|
142
|
+
if not line:
|
|
143
|
+
continue
|
|
144
|
+
try:
|
|
145
|
+
record = json.loads(line)
|
|
146
|
+
except (ValueError, TypeError):
|
|
147
|
+
continue
|
|
148
|
+
patch_set = record.get("currentPatchSet")
|
|
149
|
+
if isinstance(patch_set, dict) and patch_set.get("number") is not None:
|
|
150
|
+
return str(patch_set["number"])
|
|
151
|
+
log.debug(
|
|
152
|
+
"No currentPatchSet found in Gerrit query output for change %s",
|
|
153
|
+
change_number,
|
|
154
|
+
)
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def abandon_change_via_ssh(
|
|
159
|
+
*,
|
|
160
|
+
host: str,
|
|
161
|
+
change_number: str,
|
|
162
|
+
message: str,
|
|
163
|
+
user: str,
|
|
164
|
+
ssh_privkey: str,
|
|
165
|
+
known_hosts: str | None = None,
|
|
166
|
+
port: int = DEFAULT_GERRIT_SSH_PORT,
|
|
167
|
+
) -> bool:
|
|
168
|
+
"""Abandon a Gerrit change over SSH using ``gerrit review --abandon``.
|
|
169
|
+
|
|
170
|
+
This is the preferred abandon path because it uses the same SSH
|
|
171
|
+
credentials that the action already relies on to push changes, and
|
|
172
|
+
therefore works on Gerrit servers that reject unauthenticated REST
|
|
173
|
+
writes.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
host: Gerrit SSH hostname (no scheme).
|
|
177
|
+
change_number: Numeric Gerrit change number.
|
|
178
|
+
message: Abandon message (may be multi-line).
|
|
179
|
+
user: Gerrit SSH username.
|
|
180
|
+
ssh_privkey: SSH private key content.
|
|
181
|
+
known_hosts: Optional known_hosts content for host verification.
|
|
182
|
+
port: Gerrit SSH port (default 29418).
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
``True`` if the change was abandoned successfully; ``False`` when
|
|
186
|
+
prerequisites are missing or the SSH operation failed (so the
|
|
187
|
+
caller may fall back to another mechanism). This function never
|
|
188
|
+
raises.
|
|
189
|
+
"""
|
|
190
|
+
if not (host and user and ssh_privkey and str(change_number).strip()):
|
|
191
|
+
log.debug(
|
|
192
|
+
"SSH abandon prerequisites missing (host=%s, user=%s, "
|
|
193
|
+
"key=%s, change=%s); skipping SSH abandon",
|
|
194
|
+
bool(host),
|
|
195
|
+
bool(user),
|
|
196
|
+
bool(ssh_privkey),
|
|
197
|
+
change_number,
|
|
198
|
+
)
|
|
199
|
+
return False
|
|
200
|
+
|
|
201
|
+
change_number = str(change_number).strip()
|
|
202
|
+
try:
|
|
203
|
+
tmp_dir = Path(
|
|
204
|
+
tempfile.mkdtemp(prefix=f"g2g_abandon_{secrets.token_hex(8)}_")
|
|
205
|
+
)
|
|
206
|
+
except OSError as exc:
|
|
207
|
+
# Honor the "never raises" contract so callers can fall back to REST.
|
|
208
|
+
log.debug("Could not create temp dir for SSH abandon: %s", exc)
|
|
209
|
+
return False
|
|
210
|
+
try:
|
|
211
|
+
tmp_dir.chmod(0o700)
|
|
212
|
+
key_path = tmp_dir / "gerrit_key"
|
|
213
|
+
_write_secure_file(key_path, ssh_privkey.strip() + "\n", 0o600)
|
|
214
|
+
|
|
215
|
+
known_hosts_path: Path | None = None
|
|
216
|
+
if known_hosts and known_hosts.strip():
|
|
217
|
+
# OpenSSH looks up host keys under a bracketed "[host]:port"
|
|
218
|
+
# entry when connecting on a non-default port (Gerrit uses
|
|
219
|
+
# 29418). Augment the provided content with bracketed variants
|
|
220
|
+
# so StrictHostKeyChecking can verify the key and we don't fall
|
|
221
|
+
# back to REST unnecessarily.
|
|
222
|
+
augmented = augment_known_hosts_with_bracketed_entries(
|
|
223
|
+
known_hosts.strip(), host, port
|
|
224
|
+
)
|
|
225
|
+
known_hosts_path = tmp_dir / "known_hosts"
|
|
226
|
+
_write_secure_file(known_hosts_path, augmented, 0o644)
|
|
227
|
+
|
|
228
|
+
base_argv = _build_ssh_base_argv(
|
|
229
|
+
key_path=key_path,
|
|
230
|
+
known_hosts_path=known_hosts_path,
|
|
231
|
+
port=port,
|
|
232
|
+
user=user,
|
|
233
|
+
host=host,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# Disable any ambient SSH agent so only the provided key is used.
|
|
237
|
+
ssh_env = build_non_interactive_ssh_env()
|
|
238
|
+
|
|
239
|
+
patch_set = _resolve_current_patchset(base_argv, change_number, ssh_env)
|
|
240
|
+
if patch_set is None:
|
|
241
|
+
log.debug(
|
|
242
|
+
"Could not resolve current patch-set for change %s via SSH",
|
|
243
|
+
change_number,
|
|
244
|
+
)
|
|
245
|
+
return False
|
|
246
|
+
|
|
247
|
+
target = f"{change_number},{patch_set}"
|
|
248
|
+
remote_cmd = (
|
|
249
|
+
"gerrit review --abandon "
|
|
250
|
+
f"-m {shlex.quote(message)} {shlex.quote(target)}"
|
|
251
|
+
)
|
|
252
|
+
try:
|
|
253
|
+
run_cmd(
|
|
254
|
+
[*base_argv, remote_cmd],
|
|
255
|
+
timeout=_SSH_TIMEOUT_SECONDS,
|
|
256
|
+
env=ssh_env,
|
|
257
|
+
)
|
|
258
|
+
except CommandError as exc:
|
|
259
|
+
log.warning(
|
|
260
|
+
"SSH abandon failed for change %s: %s",
|
|
261
|
+
change_number,
|
|
262
|
+
exc,
|
|
263
|
+
)
|
|
264
|
+
return False
|
|
265
|
+
else:
|
|
266
|
+
log.debug(
|
|
267
|
+
"Successfully abandoned Gerrit change %s via SSH",
|
|
268
|
+
change_number,
|
|
269
|
+
)
|
|
270
|
+
return True
|
|
271
|
+
except Exception:
|
|
272
|
+
log.warning(
|
|
273
|
+
"Unexpected error during SSH abandon for change %s",
|
|
274
|
+
change_number,
|
|
275
|
+
exc_info=True,
|
|
276
|
+
)
|
|
277
|
+
return False
|
|
278
|
+
finally:
|
|
279
|
+
try:
|
|
280
|
+
import shutil
|
|
281
|
+
|
|
282
|
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
283
|
+
except Exception:
|
|
284
|
+
log.debug("Failed to clean up SSH temp dir %s", tmp_dir)
|
|
@@ -511,13 +511,25 @@ def _abandon_orphan_changes(
|
|
|
511
511
|
|
|
512
512
|
from github2gerrit.gerrit_rest import GerritRestError
|
|
513
513
|
from github2gerrit.gerrit_rest import build_client_for_host
|
|
514
|
+
from github2gerrit.gerrit_rest import warn_gerrit_credentials_unavailable
|
|
514
515
|
|
|
515
|
-
abandoned = []
|
|
516
|
+
abandoned: list[str] = []
|
|
516
517
|
try:
|
|
517
518
|
client = build_client_for_host(
|
|
518
519
|
gerrit.host, timeout=10.0, max_attempts=3
|
|
519
520
|
)
|
|
520
521
|
|
|
522
|
+
# Abandoning a change is a mutating REST call that requires
|
|
523
|
+
# authentication; without credentials every attempt would 403.
|
|
524
|
+
# Warn once and skip rather than emitting per-change errors.
|
|
525
|
+
if not client.is_authenticated:
|
|
526
|
+
warn_gerrit_credentials_unavailable()
|
|
527
|
+
log.debug(
|
|
528
|
+
"Skipping orphan-change abandon: "
|
|
529
|
+
"no Gerrit REST credentials available"
|
|
530
|
+
)
|
|
531
|
+
return abandoned
|
|
532
|
+
|
|
521
533
|
for change_id in orphan_ids:
|
|
522
534
|
try:
|
|
523
535
|
abandon_message = (
|
|
@@ -559,13 +571,25 @@ def _comment_orphan_changes(
|
|
|
559
571
|
|
|
560
572
|
from github2gerrit.gerrit_rest import GerritRestError
|
|
561
573
|
from github2gerrit.gerrit_rest import build_client_for_host
|
|
574
|
+
from github2gerrit.gerrit_rest import warn_gerrit_credentials_unavailable
|
|
562
575
|
|
|
563
|
-
commented = []
|
|
576
|
+
commented: list[str] = []
|
|
564
577
|
try:
|
|
565
578
|
client = build_client_for_host(
|
|
566
579
|
gerrit.host, timeout=10.0, max_attempts=3
|
|
567
580
|
)
|
|
568
581
|
|
|
582
|
+
# Posting a review comment is a mutating REST call that requires
|
|
583
|
+
# authentication; without credentials every attempt would 403.
|
|
584
|
+
# Warn once and skip rather than emitting per-change errors.
|
|
585
|
+
if not client.is_authenticated:
|
|
586
|
+
warn_gerrit_credentials_unavailable()
|
|
587
|
+
log.debug(
|
|
588
|
+
"Skipping orphan-change comment: "
|
|
589
|
+
"no Gerrit REST credentials available"
|
|
590
|
+
)
|
|
591
|
+
return commented
|
|
592
|
+
|
|
569
593
|
for change_id in orphan_ids:
|
|
570
594
|
try:
|
|
571
595
|
comment_message = (
|
|
@@ -42,6 +42,13 @@ _DANGEROUS_HTML_PATTERN = re.compile(
|
|
|
42
42
|
)
|
|
43
43
|
_MULTIPLE_NEWLINES_PATTERN = re.compile(r"\n{3,}")
|
|
44
44
|
_EMOJI_PATTERN = re.compile(r":[a-z_]+:") # GitHub emoji codes like :sparkles:
|
|
45
|
+
# Dependabot embeds compatibility-score badges proxied through GitHub's
|
|
46
|
+
# camo image host. Detected via a regex rather than a substring membership
|
|
47
|
+
# check (the latter trips CodeQL's incomplete-url-substring-sanitization
|
|
48
|
+
# heuristic and is a weaker way to match a URL).
|
|
49
|
+
_CAMO_IMAGE_URL_PATTERN = re.compile(
|
|
50
|
+
r"https://camo\.githubusercontent\.com/", re.IGNORECASE
|
|
51
|
+
)
|
|
45
52
|
|
|
46
53
|
|
|
47
54
|
@dataclass
|
|
@@ -112,7 +119,7 @@ class DependabotRule(FilterRule):
|
|
|
112
119
|
"Bumps " in title and " from " in title and " to " in title,
|
|
113
120
|
"Dependabot will resolve any conflicts" in body,
|
|
114
121
|
"<details>" in body and "<summary>" in body,
|
|
115
|
-
|
|
122
|
+
bool(_CAMO_IMAGE_URL_PATTERN.search(body)),
|
|
116
123
|
]
|
|
117
124
|
|
|
118
125
|
# Require multiple indicators for confidence
|
github2gerrit/utils.py
CHANGED
|
@@ -10,6 +10,7 @@ and ensure consistent behavior.
|
|
|
10
10
|
|
|
11
11
|
import logging
|
|
12
12
|
import os
|
|
13
|
+
import threading
|
|
13
14
|
from typing import Any
|
|
14
15
|
|
|
15
16
|
|
|
@@ -92,6 +93,50 @@ def log_exception_conditionally(
|
|
|
92
93
|
logger.error(message, *args)
|
|
93
94
|
|
|
94
95
|
|
|
96
|
+
_WARNED_ONCE_LOCK = threading.Lock()
|
|
97
|
+
_WARNED_ONCE_KEYS: set[str] = set()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def log_warning_once(
|
|
101
|
+
logger: logging.Logger, dedup_key: str, message: str, *args: Any
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Emit a warning at most once per process for a given dedup key.
|
|
104
|
+
|
|
105
|
+
Useful for expected, recurring conditions (for example, an
|
|
106
|
+
authentication-gated operation that is skipped because no
|
|
107
|
+
credentials are available) where surfacing the situation once is
|
|
108
|
+
helpful but repeating it for every affected item would be noisy.
|
|
109
|
+
|
|
110
|
+
Thread-safe: PRs may be processed concurrently (see the
|
|
111
|
+
``ThreadPoolExecutor`` in ``cli._process_bulk``), so the
|
|
112
|
+
check-and-record step is guarded by a lock to preserve the
|
|
113
|
+
warn-once guarantee. The warning itself is emitted outside the lock
|
|
114
|
+
to avoid holding it during logging I/O.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
logger: Logger instance to use.
|
|
118
|
+
dedup_key: Stable key identifying the warning; subsequent calls
|
|
119
|
+
with the same key are suppressed.
|
|
120
|
+
message: Log message format string.
|
|
121
|
+
*args: Arguments for message formatting.
|
|
122
|
+
"""
|
|
123
|
+
with _WARNED_ONCE_LOCK:
|
|
124
|
+
if dedup_key in _WARNED_ONCE_KEYS:
|
|
125
|
+
return
|
|
126
|
+
_WARNED_ONCE_KEYS.add(dedup_key)
|
|
127
|
+
logger.warning(message, *args)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def reset_warning_once() -> None:
|
|
131
|
+
"""Clear the warn-once dedup cache.
|
|
132
|
+
|
|
133
|
+
Primarily intended for tests that need to assert warn-once behavior
|
|
134
|
+
across independent cases within a single process.
|
|
135
|
+
"""
|
|
136
|
+
with _WARNED_ONCE_LOCK:
|
|
137
|
+
_WARNED_ONCE_KEYS.clear()
|
|
138
|
+
|
|
139
|
+
|
|
95
140
|
def append_github_output(outputs: dict[str, str]) -> None:
|
|
96
141
|
"""Append key-value pairs to GitHub Actions output file.
|
|
97
142
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: github2gerrit
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.4
|
|
4
4
|
Summary: Submit a GitHub pull request to a Gerrit repository.
|
|
5
5
|
Project-URL: Homepage, https://github.com/lfreleng-actions/github2gerrit-action
|
|
6
6
|
Project-URL: Repository, https://github.com/lfreleng-actions/github2gerrit-action
|
|
@@ -23,7 +23,7 @@ Classifier: Topic :: Software Development :: Version Control
|
|
|
23
23
|
Classifier: Typing :: Typed
|
|
24
24
|
Requires-Python: >=3.11
|
|
25
25
|
Requires-Dist: click>=8.1.7
|
|
26
|
-
Requires-Dist: cryptography>=
|
|
26
|
+
Requires-Dist: cryptography>=48.0.1
|
|
27
27
|
Requires-Dist: git-review>=2.5.0
|
|
28
28
|
Requires-Dist: pygerrit2>=2.0.15
|
|
29
29
|
Requires-Dist: pygithub>=2.8.1
|
|
@@ -963,7 +963,7 @@ name: github2gerrit
|
|
|
963
963
|
|
|
964
964
|
on:
|
|
965
965
|
pull_request_target:
|
|
966
|
-
types: [opened, reopened, edited, synchronize]
|
|
966
|
+
types: [opened, reopened, edited, synchronize, closed]
|
|
967
967
|
workflow_dispatch:
|
|
968
968
|
|
|
969
969
|
permissions:
|
|
@@ -1192,7 +1192,7 @@ name: github2gerrit (advanced)
|
|
|
1192
1192
|
|
|
1193
1193
|
on:
|
|
1194
1194
|
pull_request_target:
|
|
1195
|
-
types: [opened, reopened, edited, synchronize]
|
|
1195
|
+
types: [opened, reopened, edited, synchronize, closed]
|
|
1196
1196
|
workflow_dispatch:
|
|
1197
1197
|
|
|
1198
1198
|
permissions:
|
|
@@ -4,13 +4,14 @@ github2gerrit/commit_normalization.py,sha256=6FOkNfYvR_wicBAhbd4gcwVlkgnqdVFWvUF
|
|
|
4
4
|
github2gerrit/commit_rules.py,sha256=GMlCYwuZb-RcEP1SwMfI3r4i_Dy2yqK6f0RwnYFMGH4,16297
|
|
5
5
|
github2gerrit/config.py,sha256=nvSc9e_wJM2GS5whFE5R3UwmZjYPZgucaS8s6rrEbjY,26469
|
|
6
6
|
github2gerrit/constants.py,sha256=uCAx-lZiyxlVSHaGmDx6TFbCajkK3VO2ggCOej0EeXk,1306
|
|
7
|
-
github2gerrit/core.py,sha256
|
|
7
|
+
github2gerrit/core.py,sha256=-pUwvP2urkfddmNP2KxoV6UGkYXOVEXApFAnZcsMkkk,254770
|
|
8
8
|
github2gerrit/duplicate_detection.py,sha256=CTrzD3YLc-AyPmaR5bSC5b22LpBDpCcNbVxMv2X0o7A,32127
|
|
9
9
|
github2gerrit/error_codes.py,sha256=MclL3tiOSR4R61c3J642CYDyJJ10tzEQmI2YW7Wkqjw,19098
|
|
10
|
-
github2gerrit/external_api.py,sha256=
|
|
11
|
-
github2gerrit/gerrit_pr_closer.py,sha256=
|
|
12
|
-
github2gerrit/gerrit_query.py,sha256=
|
|
13
|
-
github2gerrit/gerrit_rest.py,sha256=
|
|
10
|
+
github2gerrit/external_api.py,sha256=nVZBQoPbsKKal2c9Wdsj-oWnl5wv9aNNdCG0qhyvKNA,19338
|
|
11
|
+
github2gerrit/gerrit_pr_closer.py,sha256=hNX13ekFai0mYWCdrmijdEikkzI6Xnp8CypMUuJtieQ,64019
|
|
12
|
+
github2gerrit/gerrit_query.py,sha256=2FvYcf8gt2rZ22nJLklBVKuZamp8ke9TaEzq2UKRHwY,11077
|
|
13
|
+
github2gerrit/gerrit_rest.py,sha256=69c1EsIOjUaNy_x3gL-OtQLqAmQN4fo9Bl6es4GNvVw,16858
|
|
14
|
+
github2gerrit/gerrit_ssh.py,sha256=TGpXF2REZWaya-uQPhd_jqE4kLhPWxJK-l75Bhi2hSg,9116
|
|
14
15
|
github2gerrit/gerrit_urls.py,sha256=aoPuUOCysHffVr9qkBRIPCZEUoSBMHmfviydy1U1uSQ,13843
|
|
15
16
|
github2gerrit/github_api.py,sha256=wYKWRMQsYu7zbxyRQRO7N4cTqBvE6vYf8SdN_3C6XTo,11486
|
|
16
17
|
github2gerrit/gitreview.py,sha256=I1FPsYjkPttj6DdjqHI0HkWOwsnInhmijLeGjnD658I,23464
|
|
@@ -19,7 +20,7 @@ github2gerrit/mapping_comment.py,sha256=3WAL3KQgjfPnUMZS_-aqNL7qtD0jNXr0VTK_GPEc
|
|
|
19
20
|
github2gerrit/models.py,sha256=beZ1C3S8v-qA4tD3Niq6trpoHofxLx6q3VQgvOz0WyI,4304
|
|
20
21
|
github2gerrit/netrc.py,sha256=x8l53j-t1kl-v1YZEPaDT2Mmf5qUfgP5J0YZzE7fkIY,27534
|
|
21
22
|
github2gerrit/pr_commands.py,sha256=VZNEvzK1MHnXul3SbkjjoY_HW34M9QFyt74RxcRZpQU,12440
|
|
22
|
-
github2gerrit/pr_content_filter.py,sha256=
|
|
23
|
+
github2gerrit/pr_content_filter.py,sha256=9O2jiRM0XhaP2CD9QgKU2C4UQfyQggR14uYprBjgFWE,19644
|
|
23
24
|
github2gerrit/reconcile_matcher.py,sha256=s7v3LJdv5CZmi7LvhENFoXlQ-GCxFS-CtkuOCUsD5ow,20466
|
|
24
25
|
github2gerrit/rich_display.py,sha256=Y2rtJNLP_EG9zdR29NiSqM3j_o_51gpQVeTNBqZch5o,16038
|
|
25
26
|
github2gerrit/rich_logging.py,sha256=D8yyV4NrsLf74fZH5-AFr1c-Im7MyNyx3LbJTLqepHs,10991
|
|
@@ -29,11 +30,11 @@ github2gerrit/ssh_common.py,sha256=OC20SQsX_AP3yTUJ_tqz6XAN30O5GbgtZwzwUzyX4DQ,8
|
|
|
29
30
|
github2gerrit/ssh_config_parser.py,sha256=Yd6yoh95lSfUXHRHw2mrgNlY0yizz1A7GmQod30dCT8,15523
|
|
30
31
|
github2gerrit/ssh_discovery.py,sha256=G94d129GM2kOmVVh-wZXqBybKLGr0s_CPjrJW6_j3k8,17847
|
|
31
32
|
github2gerrit/trailers.py,sha256=9w0vIxPNBNQp56sIy-MF62d22Rm6vY-msh9ao1lX0rQ,8384
|
|
32
|
-
github2gerrit/utils.py,sha256=
|
|
33
|
+
github2gerrit/utils.py,sha256=5DvwiDZmYsvyEp3VegTkLczlDX_Efoa6LogJ-pG9L9k,5344
|
|
33
34
|
github2gerrit/orchestrator/__init__.py,sha256=HAEcdCAHOFr8LsdIwAdcIcFZn_ayMbX9rdVUULp8410,864
|
|
34
|
-
github2gerrit/orchestrator/reconciliation.py,sha256
|
|
35
|
-
github2gerrit-1.2.
|
|
36
|
-
github2gerrit-1.2.
|
|
37
|
-
github2gerrit-1.2.
|
|
38
|
-
github2gerrit-1.2.
|
|
39
|
-
github2gerrit-1.2.
|
|
35
|
+
github2gerrit/orchestrator/reconciliation.py,sha256=qo5IGQu11fszzOMbVwBH12VG5zQz9jjjjIvAapLi92g,20516
|
|
36
|
+
github2gerrit-1.2.4.dist-info/METADATA,sha256=3716GdviZ62NgbN3r9BZgOZKhFeXdEOxnare-JhE480,70226
|
|
37
|
+
github2gerrit-1.2.4.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
38
|
+
github2gerrit-1.2.4.dist-info/entry_points.txt,sha256=MxN2_liIKo3-xJwtAulAeS5GcOS6JS96nvwOQIkP3W8,56
|
|
39
|
+
github2gerrit-1.2.4.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
40
|
+
github2gerrit-1.2.4.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|