github2gerrit 0.1.4__py3-none-any.whl → 0.1.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,365 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2025 The Linux Foundation
3
+
4
+ """
5
+ SSH host key auto-discovery for github2gerrit.
6
+
7
+ This module provides functionality to automatically discover and fetch SSH
8
+ host keys for Gerrit servers, eliminating the need for manual
9
+ GERRIT_KNOWN_HOSTS configuration.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import os
16
+ import socket
17
+ from pathlib import Path
18
+
19
+ from .gitutils import CommandError
20
+ from .gitutils import run_cmd
21
+
22
+
23
+ log = logging.getLogger(__name__)
24
+
25
+
26
+ class SSHDiscoveryError(Exception):
27
+ """Raised when SSH host key discovery fails."""
28
+
29
+
30
+ # Error message constants to comply with TRY003
31
+ _MSG_HOST_UNREACHABLE = "Host {hostname}:{port} is not reachable. Check network connectivity and server availability."
32
+ _MSG_NO_KEYS_FOUND = (
33
+ "No SSH host keys found for {hostname}:{port}. The server may not be running SSH or may be blocking connections."
34
+ )
35
+ _MSG_NO_VALID_KEYS = (
36
+ "No valid SSH host keys found for {hostname}:{port}. The ssh-keyscan output was empty or malformed."
37
+ )
38
+ _MSG_CONNECTION_FAILED = "Failed to connect to {hostname}:{port} for SSH key discovery. Error: {error}"
39
+ _MSG_KEYSCAN_FAILED = "ssh-keyscan failed with return code {returncode}: {error}"
40
+ _MSG_UNEXPECTED_ERROR = "Unexpected error during SSH key discovery for {hostname}:{port}: {error}"
41
+ _MSG_SAVE_FAILED = "Failed to save host keys to configuration file {config_file}: {error}"
42
+
43
+
44
+ def is_host_reachable(hostname: str, port: int, timeout: int = 5) -> bool:
45
+ """Check if a host and port are reachable via TCP."""
46
+ try:
47
+ with socket.create_connection((hostname, port), timeout=timeout):
48
+ return True
49
+ except OSError:
50
+ return False
51
+
52
+
53
+ def fetch_ssh_host_keys(hostname: str, port: int = 22, timeout: int = 10) -> str:
54
+ """
55
+ Fetch SSH host keys for a given hostname and port using ssh-keyscan.
56
+
57
+ Args:
58
+ hostname: The hostname to scan
59
+ port: The SSH port (default: 22)
60
+ timeout: Connection timeout in seconds (default: 10)
61
+
62
+ Returns:
63
+ A string containing the host keys in known_hosts format
64
+
65
+ Raises:
66
+ SSHDiscoveryError: If the host keys cannot be fetched
67
+ """
68
+ log.debug("Fetching SSH host keys for %s:%d", hostname, port)
69
+
70
+ # First check if the host is reachable
71
+ if not is_host_reachable(hostname, port, timeout=5):
72
+ raise SSHDiscoveryError(_MSG_HOST_UNREACHABLE.format(hostname=hostname, port=port))
73
+
74
+ try:
75
+ # Use ssh-keyscan to fetch all available key types
76
+ cmd = [
77
+ "ssh-keyscan",
78
+ "-p",
79
+ str(port),
80
+ "-T",
81
+ str(timeout),
82
+ "-t",
83
+ "rsa,ecdsa,ed25519",
84
+ hostname,
85
+ ]
86
+
87
+ result = run_cmd(cmd, timeout=timeout + 5)
88
+
89
+ if not result.stdout or not result.stdout.strip():
90
+ raise SSHDiscoveryError( # noqa: TRY301
91
+ _MSG_NO_KEYS_FOUND.format(hostname=hostname, port=port)
92
+ )
93
+
94
+ # Validate that we got proper known_hosts format
95
+ lines = result.stdout.strip().split("\n")
96
+ valid_lines = []
97
+
98
+ for line in lines:
99
+ stripped_line = line.strip()
100
+ if not stripped_line or stripped_line.startswith("#"):
101
+ continue
102
+
103
+ # Basic validation: should have hostname, key type, and key
104
+ parts = stripped_line.split()
105
+ if len(parts) >= 3:
106
+ valid_lines.append(stripped_line)
107
+
108
+ if not valid_lines:
109
+ raise SSHDiscoveryError( # noqa: TRY301
110
+ _MSG_NO_VALID_KEYS.format(hostname=hostname, port=port)
111
+ )
112
+
113
+ discovered_keys = "\n".join(valid_lines)
114
+ log.info(
115
+ "Successfully discovered %d SSH host key(s) for %s:%d",
116
+ len(valid_lines),
117
+ hostname,
118
+ port,
119
+ )
120
+ log.debug("Discovered keys:\n%s", discovered_keys)
121
+
122
+ except CommandError as exc:
123
+ if exc.returncode == 1:
124
+ # ssh-keyscan returns 1 when it can't connect
125
+ error_msg = exc.stderr or exc.stdout or "Connection failed"
126
+ raise SSHDiscoveryError(
127
+ _MSG_CONNECTION_FAILED.format(hostname=hostname, port=port, error=error_msg)
128
+ ) from exc
129
+ else:
130
+ error_msg = exc.stderr or exc.stdout or "Unknown error"
131
+ raise SSHDiscoveryError(_MSG_KEYSCAN_FAILED.format(returncode=exc.returncode, error=error_msg)) from exc
132
+ except Exception as exc:
133
+ raise SSHDiscoveryError(_MSG_UNEXPECTED_ERROR.format(hostname=hostname, port=port, error=exc)) from exc
134
+ else:
135
+ return discovered_keys
136
+
137
+
138
+ def extract_gerrit_info_from_gitreview(content: str) -> tuple[str, int] | None:
139
+ """
140
+ Extract Gerrit hostname and port from .gitreview file content.
141
+
142
+ Args:
143
+ content: The content of a .gitreview file
144
+
145
+ Returns:
146
+ A tuple of (hostname, port) or None if not found
147
+ """
148
+ hostname = None
149
+ port = 29418 # Default Gerrit SSH port
150
+
151
+ for line in content.split("\n"):
152
+ stripped_line = line.strip()
153
+ if "=" not in stripped_line:
154
+ continue
155
+
156
+ key, value = stripped_line.split("=", 1)
157
+ key = key.strip().lower()
158
+ value = value.strip()
159
+
160
+ if key == "host":
161
+ hostname = value
162
+ elif key == "port":
163
+ try:
164
+ port = int(value)
165
+ except ValueError:
166
+ log.warning("Invalid port in .gitreview: %s", value)
167
+
168
+ return (hostname, port) if hostname else None
169
+
170
+
171
+ def discover_and_save_host_keys(hostname: str, port: int, organization: str, config_path: str | None = None) -> str:
172
+ """
173
+ Discover SSH host keys and save them to the organization's configuration.
174
+
175
+ Args:
176
+ hostname: Gerrit hostname
177
+ port: Gerrit SSH port
178
+ organization: GitHub organization name for config section
179
+ config_path: Path to config file (optional, uses default if not
180
+ provided)
181
+
182
+ Returns:
183
+ The discovered host keys string
184
+
185
+ Raises:
186
+ SSHDiscoveryError: If discovery or saving fails
187
+ """
188
+ # Discover the host keys
189
+ host_keys = fetch_ssh_host_keys(hostname, port)
190
+
191
+ # Save to configuration file
192
+ save_host_keys_to_config(host_keys, organization, config_path)
193
+
194
+ return host_keys
195
+
196
+
197
+ def save_host_keys_to_config(host_keys: str, organization: str, config_path: str | None = None) -> None:
198
+ """
199
+ Save SSH host keys to the organization's configuration file.
200
+
201
+ Args:
202
+ host_keys: The host keys in known_hosts format
203
+ organization: GitHub organization name for config section
204
+ config_path: Path to config file (optional, uses default if not
205
+ provided)
206
+
207
+ Raises:
208
+ SSHDiscoveryError: If saving fails
209
+ """
210
+ from .config import DEFAULT_CONFIG_PATH
211
+
212
+ if config_path is None:
213
+ config_path = os.getenv("G2G_CONFIG_PATH", "").strip() or DEFAULT_CONFIG_PATH
214
+
215
+ config_file = Path(config_path).expanduser()
216
+
217
+ try:
218
+ # Ensure the directory exists
219
+ config_file.parent.mkdir(parents=True, exist_ok=True)
220
+
221
+ # Read existing configuration
222
+ existing_content = ""
223
+ if config_file.exists():
224
+ existing_content = config_file.read_text(encoding="utf-8")
225
+
226
+ # Parse existing content to find the organization section
227
+ lines = existing_content.split("\n")
228
+ new_lines = []
229
+ in_org_section = False
230
+ org_section_found = False
231
+ gerrit_known_hosts_updated = False
232
+
233
+ for line in lines:
234
+ stripped = line.strip()
235
+
236
+ # Check for section headers
237
+ if stripped.startswith("[") and stripped.endswith("]"):
238
+ section_name = stripped[1:-1].strip().lower()
239
+ in_org_section = section_name == organization.lower()
240
+ if in_org_section:
241
+ org_section_found = True
242
+
243
+ # If we're in the org section and find GERRIT_KNOWN_HOSTS, replace
244
+ elif in_org_section and "=" in line:
245
+ key = line.split("=", 1)[0].strip().upper()
246
+ if key == "GERRIT_KNOWN_HOSTS":
247
+ # Replace with new host keys (properly escaped for INI)
248
+ escaped_keys = host_keys.replace("\n", "\\n")
249
+ new_lines.append(f'GERRIT_KNOWN_HOSTS = "{escaped_keys}"')
250
+ gerrit_known_hosts_updated = True
251
+ continue
252
+
253
+ new_lines.append(line)
254
+
255
+ # If organization section wasn't found, add it
256
+ if not org_section_found:
257
+ if new_lines and new_lines[-1].strip():
258
+ new_lines.append("") # Add blank line before new section
259
+ new_lines.append(f"[{organization}]")
260
+ escaped_keys = host_keys.replace("\n", "\\n")
261
+ new_lines.append(f'GERRIT_KNOWN_HOSTS = "{escaped_keys}"')
262
+ gerrit_known_hosts_updated = True
263
+
264
+ # If section existed but didn't have GERRIT_KNOWN_HOSTS, add it
265
+ elif not gerrit_known_hosts_updated:
266
+ # Find the end of the organization section and add the key there
267
+ section_end = len(new_lines)
268
+ for i, line in enumerate(new_lines):
269
+ stripped = line.strip()
270
+ if stripped.startswith("[") and stripped.endswith("]"):
271
+ section_name = stripped[1:-1].strip().lower()
272
+ if section_name == organization.lower():
273
+ # Find the end of this section
274
+ for j in range(i + 1, len(new_lines)):
275
+ if new_lines[j].strip().startswith("["):
276
+ section_end = j
277
+ break
278
+ break
279
+
280
+ # Insert the GERRIT_KNOWN_HOSTS entry
281
+ escaped_keys = host_keys.replace("\n", "\\n")
282
+ new_lines.insert(section_end, f'GERRIT_KNOWN_HOSTS = "{escaped_keys}"')
283
+
284
+ # Write the updated configuration
285
+ config_file.write_text("\n".join(new_lines), encoding="utf-8")
286
+
287
+ log.info(
288
+ "Successfully saved SSH host keys to configuration file: %s [%s]",
289
+ config_file,
290
+ organization,
291
+ )
292
+
293
+ except Exception as exc:
294
+ raise SSHDiscoveryError(_MSG_SAVE_FAILED.format(config_file=config_file, error=exc)) from exc
295
+
296
+
297
+ def auto_discover_gerrit_host_keys(
298
+ gerrit_hostname: str | None = None,
299
+ gerrit_port: int | None = None,
300
+ organization: str | None = None,
301
+ save_to_config: bool = True,
302
+ ) -> str | None:
303
+ """
304
+ Automatically discover Gerrit SSH host keys and optionally save to config.
305
+
306
+ This is the main entry point for auto-discovery functionality.
307
+
308
+ Args:
309
+ gerrit_hostname: Gerrit hostname (if not provided, tries to detect
310
+ from context)
311
+ gerrit_port: Gerrit SSH port (defaults to 29418)
312
+ organization: GitHub organization (if not provided, tries to detect
313
+ from env)
314
+ save_to_config: Whether to save discovered keys to config file
315
+
316
+ Returns:
317
+ The discovered host keys string, or None if discovery failed
318
+ """
319
+ try:
320
+ # Set defaults
321
+ if gerrit_port is None:
322
+ gerrit_port = 29418
323
+
324
+ if organization is None:
325
+ organization = (os.getenv("ORGANIZATION") or os.getenv("GITHUB_REPOSITORY_OWNER") or "").strip()
326
+
327
+ if not gerrit_hostname:
328
+ log.debug("No Gerrit hostname provided for auto-discovery")
329
+ return None
330
+
331
+ if not organization:
332
+ log.warning("No organization specified for SSH host key auto-discovery. Cannot save to configuration file.")
333
+ save_to_config = False
334
+
335
+ log.info(
336
+ "Attempting to auto-discover SSH host keys for %s:%d",
337
+ gerrit_hostname,
338
+ gerrit_port,
339
+ )
340
+
341
+ # Discover the host keys
342
+ host_keys = fetch_ssh_host_keys(gerrit_hostname, gerrit_port)
343
+
344
+ # Save to configuration if requested and possible
345
+ if save_to_config and organization:
346
+ save_host_keys_to_config(host_keys, organization)
347
+ log.info(
348
+ "SSH host keys automatically discovered and saved to config "
349
+ "for organization '%s'. Future runs will use the cached keys.",
350
+ organization,
351
+ )
352
+ else:
353
+ log.info(
354
+ "SSH host keys discovered but not saved to configuration. "
355
+ "Set ORGANIZATION environment variable to enable auto-saving."
356
+ )
357
+
358
+ except SSHDiscoveryError as exc:
359
+ log.warning("SSH host key auto-discovery failed: %s", exc)
360
+ return None
361
+ except Exception as exc:
362
+ log.warning("Unexpected error during SSH host key auto-discovery: %s", exc)
363
+ return None
364
+ else:
365
+ return host_keys
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github2gerrit
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: Submit a GitHub pull request to a Gerrit repository.
5
5
  Author-email: Matthew Watkins <mwatkins@linuxfoundation.org>
6
6
  License-Expression: Apache-2.0
@@ -34,6 +34,8 @@ Requires-Dist: ruff>=0.6.3; extra == "dev"
34
34
  Requires-Dist: black>=24.8.0; extra == "dev"
35
35
  Requires-Dist: mypy>=1.11.2; extra == "dev"
36
36
  Requires-Dist: pytest-mock>=3.14.0; extra == "dev"
37
+ Requires-Dist: types-requests>=2.31.0; extra == "dev"
38
+ Requires-Dist: types-click>=7.1.8; extra == "dev"
37
39
  Dynamic: license-file
38
40
 
39
41
  <!--
@@ -103,16 +105,25 @@ stabilize coverage reporting for parallel/xdist runs.
103
105
 
104
106
  ## Duplicate detection
105
107
 
106
- By default, the tool checks for duplicate changes to prevent spam
107
- submissions from automated tools like Dependabot. It compares PR titles,
108
- content, and files changed against recent PRs (last 7 days) and will
109
- exit with an error when it finds duplicates.
108
+ Duplicate detection uses a scoring-based approach. Instead of relying on a hash
109
+ added by this action, the detector compares the first line of the commit message
110
+ (subject/PR title), analyzes the body text and the set of files changed, and
111
+ computes a similarity score. When the score meets or exceeds a configurable
112
+ threshold (default 0.8), the tool treats the change as a duplicate and blocks
113
+ submission. This approach aims to remain robust even when similar changes
114
+ appeared outside this pipeline.
110
115
 
111
116
  ### Examples of detected duplicates
112
117
 
113
- - Identical Dependabot PRs: "Bump package from 1.0 to 1.1"
114
- - Sequential dependency updates: "Bump package 1.01.1", "Bump package 1.11.2"
115
- - Similar bug fixes with slightly different wording
118
+ - Dependency bumps for the same package across close versions
119
+ (e.g., "Bump foo from 1.0 to 1.1" vs "Bump foo from 1.1 to 1.2")
120
+ with overlapping files high score
121
+ - Pre-commit autoupdates that change .pre-commit-config.yaml and hook versions —
122
+ high score
123
+ - GitHub Actions version bumps that update .github/workflows/* uses lines —
124
+ medium to high score
125
+ - Similar bug fixes with the same subject and significant file overlap —
126
+ strong match
116
127
 
117
128
  ### Allowing duplicates
118
129
 
@@ -288,19 +299,7 @@ jobs:
288
299
  submit-to-gerrit:
289
300
  runs-on: ubuntu-latest
290
301
  steps:
291
- - name: Install SSH key and custom SSH config
292
- <!-- markdownlint-disable-next-line MD013 -->
293
- uses: shimataro/ssh-key-action@d4fffb50872869abe2d9a9098a6d9c5aa7d16be4 # v2.7.0
294
- with:
295
- key: ${{ secrets.GERRIT_SSH_PRIVKEY_G2G }}
296
- name: "id_rsa"
297
- known_hosts: ${{ vars.GERRIT_KNOWN_HOSTS }}
298
- config: |
299
- Host ${{ vars.GERRIT_SERVER }}
300
- User ${{ vars.GERRIT_SSH_USER_G2G }}
301
- Port ${{ vars.GERRIT_SERVER_PORT }}
302
- PubkeyAcceptedKeyTypes +ssh-rsa
303
- IdentityFile ~/.ssh/id_rsa
302
+
304
303
 
305
304
  - name: Submit PR to Gerrit (with explicit overrides)
306
305
  id: g2g
@@ -334,10 +333,10 @@ jobs:
334
333
 
335
334
  Notes:
336
335
 
337
- - If both this step and the action define SSH configuration, the last
338
- configuration applied in the runner wins.
339
- - For most users, you can rely on the action’s built-in SSH setup. Use this
340
- advanced configuration when you need custom SSH behavior or hosts.
336
+ - The action configures SSH internally using the provided inputs (key,
337
+ known_hosts) and does not use the runner’s SSH agent or ~/.ssh/config.
338
+ - Do not add external steps to install SSH keys or edit SSH config; they’re
339
+ unnecessary and may conflict with the action.
341
340
 
342
341
  ## GitHub Enterprise support
343
342
 
@@ -0,0 +1,17 @@
1
+ github2gerrit/__init__.py,sha256=N1Vj1HJ28LKCJLAynQdm5jFGQQAz9YSMzZhEfvbBgow,886
2
+ github2gerrit/cli.py,sha256=86Xvjk9xsSN76ewXjBbA0yWIncnkyIy3VFb2pUN7HAM,34637
3
+ github2gerrit/config.py,sha256=sTiujlOjCsJbaA-Ftr5XR--9nxs7XH8uEWD-IbGqLYk,17370
4
+ github2gerrit/core.py,sha256=Tg1Qxg7tvGCxAfC0cSFnkexeN2JVIVkv0zqvov1S-RY,79296
5
+ github2gerrit/duplicate_detection.py,sha256=ODPzCm6Gv5NZsxkPm1vy4CBsWQ7hIhD7U95TiXWwcts,29986
6
+ github2gerrit/gerrit_urls.py,sha256=A-bi6JbicnqR5WAg-cLpWzDQczkWMoahCVqNl21wARo,8395
7
+ github2gerrit/github_api.py,sha256=nteSZjMiFP64pUJc9tqtvUgiaGlKtaH62zJWfGbR19I,10528
8
+ github2gerrit/gitutils.py,sha256=4-UXLX3L_nUJbXbcYx1FvpikUdsr5BwnnlI-GwbeXu0,19410
9
+ github2gerrit/models.py,sha256=-iBFJXyWEW1mFNn_wkkHjMenxoKB5SNNzJHhZ2IkxFA,1827
10
+ github2gerrit/similarity.py,sha256=gcz9wSK6xZjff0MD_VNHLpxJUWH7Z0ZAYT25t46qpgY,15228
11
+ github2gerrit/ssh_discovery.py,sha256=XSV01p2nMMKBbTWAAmrWr-VMDdBZMxcMt5Nu04pDdH4,12825
12
+ github2gerrit-0.1.6.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
13
+ github2gerrit-0.1.6.dist-info/METADATA,sha256=QNKgaz_XAC42GVQuNaHoZYTOia2ncvlDaOHatXaarsY,21471
14
+ github2gerrit-0.1.6.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
15
+ github2gerrit-0.1.6.dist-info/entry_points.txt,sha256=MxN2_liIKo3-xJwtAulAeS5GcOS6JS96nvwOQIkP3W8,56
16
+ github2gerrit-0.1.6.dist-info/top_level.txt,sha256=bWTYXjvuu4sSU90KLT1JlnjD7xV_iXZ-vKoulpjLTy8,14
17
+ github2gerrit-0.1.6.dist-info/RECORD,,
@@ -1,14 +0,0 @@
1
- github2gerrit/__init__.py,sha256=N1Vj1HJ28LKCJLAynQdm5jFGQQAz9YSMzZhEfvbBgow,886
2
- github2gerrit/cli.py,sha256=gvgyoKvNzOdh5H_BaBAkAFXvJEQLsSa4ACqYg_9QdyA,29768
3
- github2gerrit/config.py,sha256=_r5BAowI3x5vRKSGcZsJn6NGJqkiPF8hAmfqT1id3I8,10282
4
- github2gerrit/core.py,sha256=qatoJ_M6I8kiQeAA9kFT32uuw5Xo7pnUUWht0RL24io,69593
5
- github2gerrit/duplicate_detection.py,sha256=J6a8t3ih-ebr6FEhWsaKnXYPQCzwcnFEWhdstmtjnMo,19475
6
- github2gerrit/github_api.py,sha256=mgiz55GrTgAVozmoOKSLrnUcX59YxV3p2Llch2COmyE,10523
7
- github2gerrit/gitutils.py,sha256=1KmBACvvVDIte0WiuR-AlgswbWEm69G0J2OpmAgPn7Y,18058
8
- github2gerrit/models.py,sha256=DAm0pEWvAexOInnxTVrvTnKWhLMd86TfSqT78UohOCo,1791
9
- github2gerrit-0.1.4.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
10
- github2gerrit-0.1.4.dist-info/METADATA,sha256=jJml8yKtMJgtQSfZ1F-UE5dhQmtdEQeo1kyGoBjf17w,21441
11
- github2gerrit-0.1.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
12
- github2gerrit-0.1.4.dist-info/entry_points.txt,sha256=MxN2_liIKo3-xJwtAulAeS5GcOS6JS96nvwOQIkP3W8,56
13
- github2gerrit-0.1.4.dist-info/top_level.txt,sha256=bWTYXjvuu4sSU90KLT1JlnjD7xV_iXZ-vKoulpjLTy8,14
14
- github2gerrit-0.1.4.dist-info/RECORD,,