github2gerrit 0.1.8__tar.gz → 0.1.9__tar.gz
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-0.1.8 → github2gerrit-0.1.9}/.pre-commit-config.yaml +6 -5
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/PKG-INFO +5 -5
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/README.md +4 -4
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/REUSE.toml +1 -0
- github2gerrit-0.1.9/docs/github2gerrit_token_permissions_classic.png +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/src/github2gerrit/cli.py +20 -9
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/src/github2gerrit/config.py +78 -1
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/src/github2gerrit/core.py +10 -3
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/src/github2gerrit/duplicate_detection.py +2 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/src/github2gerrit/external_api.py +3 -2
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/src/github2gerrit/github_api.py +2 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/src/github2gerrit/gitutils.py +12 -4
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/src/github2gerrit.egg-info/PKG-INFO +5 -5
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/src/github2gerrit.egg-info/SOURCES.txt +1 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/tests/test_duplicate_detection.py +4 -1
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/tests/test_external_api_framework.py +2 -1
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/tests/test_gerrit_rest_client.py +4 -1
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/tests/test_gitutils_helpers.py +2 -1
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/tests/test_url_parser.py +1 -1
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/.editorconfig +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/.github/actionlint.yaml +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/.github/dependabot.yml +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/.github/release-drafter.yml +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/.github/workflows/build-test-release.yaml +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/.github/workflows/build-test.yaml +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/.github/workflows/dependencies.yaml +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/.github/workflows/github2gerrit.yaml +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/.github/workflows/release-drafter.yaml +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/.github/workflows/semantic-pull-request.yaml +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/.github/workflows/sha-pinned-actions.yaml +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/.github/workflows/testing.yaml +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/.gitignore +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/.gitlint +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/.markdownlint.yaml +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/.readthedocs.yml +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/.yamllint +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/LICENSE +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/LICENSES/Apache-2.0.txt +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/action.yaml +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/pyproject.toml +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/setup.cfg +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/sitecustomize.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/src/github2gerrit/__init__.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/src/github2gerrit/commit_normalization.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/src/github2gerrit/gerrit_rest.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/src/github2gerrit/gerrit_urls.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/src/github2gerrit/models.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/src/github2gerrit/pr_content_filter.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/src/github2gerrit/similarity.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/src/github2gerrit/ssh_agent_setup.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/src/github2gerrit/ssh_common.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/src/github2gerrit/ssh_discovery.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/src/github2gerrit/utils.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/src/github2gerrit.egg-info/dependency_links.txt +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/src/github2gerrit.egg-info/entry_points.txt +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/src/github2gerrit.egg-info/requires.txt +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/src/github2gerrit.egg-info/top_level.txt +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/tests/conftest.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/tests/fixtures/__init__.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/tests/fixtures/make_repo.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/tests/test_change_id_deduplication.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/tests/test_cli.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/tests/test_cli_helpers.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/tests/test_cli_outputs_file.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/tests/test_cli_url_and_dryrun.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/tests/test_commit_normalization.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/tests/test_config_and_reviewers.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/tests/test_config_helpers.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/tests/test_core_close_pr_policy.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/tests/test_core_config_and_errors.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/tests/test_core_gerrit_backref_comment.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/tests/test_core_gerrit_push_errors.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/tests/test_core_gerrit_rest_results.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/tests/test_core_integration_fixture_repo.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/tests/test_core_prepare_commits.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/tests/test_core_ssh_setup.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/tests/test_gerrit_change_id_footer.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/tests/test_gerrit_urls.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/tests/test_gerrit_urls_more.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/tests/test_ghe_and_gitreview_args.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/tests/test_github_api_helpers.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/tests/test_github_api_retry_and_helpers.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/tests/test_misc_small_coverage.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/tests/test_pr_content_filter.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/tests/test_pr_content_filter_integration.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/tests/test_ssh_agent.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/tests/test_ssh_common.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/tests/test_ssh_discovery.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/tests/test_utils.py +0 -0
- {github2gerrit-0.1.8 → github2gerrit-0.1.9}/uv.lock +0 -0
@@ -74,11 +74,12 @@ repos:
|
|
74
74
|
- types-PyYAML
|
75
75
|
- types-requests
|
76
76
|
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
77
|
+
# Doesn't seem to run reliably inside pre-commit.ci
|
78
|
+
# - repo: https://github.com/RobertCraigie/pyright-python
|
79
|
+
# rev: d393df1703a808473b84bd14a2702f4793014031 # frozen: v1.1.404
|
80
|
+
# hooks:
|
81
|
+
# - id: pyright
|
82
|
+
# files: ^(src|scripts|tests)/.+\.py$
|
82
83
|
|
83
84
|
- repo: https://github.com/btford/write-good
|
84
85
|
rev: ab66ce10136dfad5146e69e70f82a3efac8842c1 # frozen: v1.0.8
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: github2gerrit
|
3
|
-
Version: 0.1.
|
3
|
+
Version: 0.1.9
|
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
|
@@ -297,7 +297,7 @@ jobs:
|
|
297
297
|
steps:
|
298
298
|
- name: Submit PR to Gerrit
|
299
299
|
id: g2g
|
300
|
-
uses:
|
300
|
+
uses: lfreleng-actions/github2gerrit-action@main
|
301
301
|
with:
|
302
302
|
SUBMIT_SINGLE_COMMITS: "false"
|
303
303
|
USE_PR_AS_COMMIT: "false"
|
@@ -343,7 +343,7 @@ uv run github2gerrit
|
|
343
343
|
uvx github2gerrit https://github.com/owner/repo/pull/123
|
344
344
|
|
345
345
|
# Install from specific version/source
|
346
|
-
uvx --from git+https://github.com/
|
346
|
+
uvx --from git+https://github.com/lfreleng-actions/github2gerrit-action@main github2gerrit https://github.com/owner/repo/pull/123
|
347
347
|
```
|
348
348
|
|
349
349
|
### Available Options
|
@@ -524,7 +524,7 @@ jobs:
|
|
524
524
|
|
525
525
|
- name: Submit PR to Gerrit (with explicit overrides)
|
526
526
|
id: g2g
|
527
|
-
uses:
|
527
|
+
uses: lfreleng-actions/github2gerrit-action@main
|
528
528
|
with:
|
529
529
|
# Behavior
|
530
530
|
SUBMIT_SINGLE_COMMITS: "false"
|
@@ -777,7 +777,7 @@ For CI/CD pipelines (like GitHub Actions), use `uvx` to install and run without
|
|
777
777
|
uvx github2gerrit <PR_URL> --dry-run
|
778
778
|
|
779
779
|
# Install from a specific version or source
|
780
|
-
uvx --from git+https://github.com/
|
780
|
+
uvx --from git+https://github.com/lfreleng-actions/github2gerrit-action@main github2gerrit <PR_URL>
|
781
781
|
|
782
782
|
# Run with specific Python version
|
783
783
|
uvx --python 3.11 github2gerrit <PR_URL>
|
@@ -257,7 +257,7 @@ jobs:
|
|
257
257
|
steps:
|
258
258
|
- name: Submit PR to Gerrit
|
259
259
|
id: g2g
|
260
|
-
uses:
|
260
|
+
uses: lfreleng-actions/github2gerrit-action@main
|
261
261
|
with:
|
262
262
|
SUBMIT_SINGLE_COMMITS: "false"
|
263
263
|
USE_PR_AS_COMMIT: "false"
|
@@ -303,7 +303,7 @@ uv run github2gerrit
|
|
303
303
|
uvx github2gerrit https://github.com/owner/repo/pull/123
|
304
304
|
|
305
305
|
# Install from specific version/source
|
306
|
-
uvx --from git+https://github.com/
|
306
|
+
uvx --from git+https://github.com/lfreleng-actions/github2gerrit-action@main github2gerrit https://github.com/owner/repo/pull/123
|
307
307
|
```
|
308
308
|
|
309
309
|
### Available Options
|
@@ -484,7 +484,7 @@ jobs:
|
|
484
484
|
|
485
485
|
- name: Submit PR to Gerrit (with explicit overrides)
|
486
486
|
id: g2g
|
487
|
-
uses:
|
487
|
+
uses: lfreleng-actions/github2gerrit-action@main
|
488
488
|
with:
|
489
489
|
# Behavior
|
490
490
|
SUBMIT_SINGLE_COMMITS: "false"
|
@@ -737,7 +737,7 @@ For CI/CD pipelines (like GitHub Actions), use `uvx` to install and run without
|
|
737
737
|
uvx github2gerrit <PR_URL> --dry-run
|
738
738
|
|
739
739
|
# Install from a specific version or source
|
740
|
-
uvx --from git+https://github.com/
|
740
|
+
uvx --from git+https://github.com/lfreleng-actions/github2gerrit-action@main github2gerrit <PR_URL>
|
741
741
|
|
742
742
|
# Run with specific Python version
|
743
743
|
uvx --python 3.11 github2gerrit <PR_URL>
|
Binary file
|
@@ -3,13 +3,10 @@
|
|
3
3
|
|
4
4
|
from __future__ import annotations
|
5
5
|
|
6
|
-
import io
|
7
6
|
import json
|
8
7
|
import logging
|
9
8
|
import os
|
10
|
-
import shutil
|
11
9
|
import tempfile
|
12
|
-
import zipfile
|
13
10
|
from collections.abc import Callable
|
14
11
|
from concurrent.futures import ThreadPoolExecutor
|
15
12
|
from concurrent.futures import as_completed
|
@@ -20,8 +17,6 @@ from typing import Protocol
|
|
20
17
|
from typing import TypeVar
|
21
18
|
from typing import cast
|
22
19
|
from urllib.parse import urlparse
|
23
|
-
from urllib.request import Request
|
24
|
-
from urllib.request import urlopen
|
25
20
|
|
26
21
|
import click
|
27
22
|
import typer
|
@@ -295,11 +290,14 @@ def main(
|
|
295
290
|
"""
|
296
291
|
Tool to convert GitHub pull requests into Gerrit changes
|
297
292
|
|
298
|
-
- Providing a URL to a pull request: converts that pull request
|
293
|
+
- Providing a URL to a pull request: converts that pull request
|
294
|
+
into a Gerrit change
|
299
295
|
|
300
|
-
- Providing a URL to a GitHub repository converts all open pull
|
296
|
+
- Providing a URL to a GitHub repository converts all open pull
|
297
|
+
requests into Gerrit changes
|
301
298
|
|
302
|
-
- No arguments for CI/CD environment; reads parameters from
|
299
|
+
- No arguments for CI/CD environment; reads parameters from
|
300
|
+
environment variables
|
303
301
|
"""
|
304
302
|
# Override boolean parameters with properly parsed environment variables
|
305
303
|
# This ensures that string "false" from GitHub Actions is handled correctly
|
@@ -361,15 +359,21 @@ def main(
|
|
361
359
|
# URL mode handling
|
362
360
|
if target_url:
|
363
361
|
org, repo, pr = _parse_github_target(target_url)
|
362
|
+
log.debug("Parsed GitHub URL: org=%s, repo=%s, pr=%s", org, repo, pr)
|
364
363
|
if org:
|
365
364
|
os.environ["ORGANIZATION"] = org
|
365
|
+
log.debug("Set ORGANIZATION=%s", org)
|
366
366
|
if org and repo:
|
367
|
-
|
367
|
+
github_repo = f"{org}/{repo}"
|
368
|
+
os.environ["GITHUB_REPOSITORY"] = github_repo
|
369
|
+
log.debug("Set GITHUB_REPOSITORY=%s", github_repo)
|
368
370
|
if pr:
|
369
371
|
os.environ["PR_NUMBER"] = str(pr)
|
370
372
|
os.environ["SYNC_ALL_OPEN_PRS"] = "false"
|
373
|
+
log.debug("Set PR_NUMBER=%s", pr)
|
371
374
|
else:
|
372
375
|
os.environ["SYNC_ALL_OPEN_PRS"] = "true"
|
376
|
+
log.debug("Set SYNC_ALL_OPEN_PRS=true")
|
373
377
|
os.environ["G2G_TARGET_URL"] = "1"
|
374
378
|
# Debug: Show environment at CLI startup
|
375
379
|
log.debug("CLI startup environment check:")
|
@@ -748,6 +752,13 @@ def _prepare_local_checkout(workspace: Path, gh: GitHubContext, data: Inputs) ->
|
|
748
752
|
|
749
753
|
def _fallback_to_api_archive(workspace: Path, gh: GitHubContext, data: Inputs, pr_num_str: str) -> None:
|
750
754
|
"""Fallback to GitHub API archive download for private repos."""
|
755
|
+
import io
|
756
|
+
import json
|
757
|
+
import shutil
|
758
|
+
import zipfile
|
759
|
+
from urllib.request import Request
|
760
|
+
from urllib.request import urlopen
|
761
|
+
|
751
762
|
log.info("Attempting API archive fallback for PR #%s", pr_num_str)
|
752
763
|
|
753
764
|
# Get GitHub token for authenticated requests
|
@@ -131,6 +131,22 @@ def _coerce_value(raw: str) -> str:
|
|
131
131
|
# like SSH keys or known_hosts entries can be specified inline using
|
132
132
|
# '\n' or '\r\n' in configuration files.
|
133
133
|
normalized_newlines = unquoted.replace("\\r\\n", "\n").replace("\\n", "\n").replace("\r\n", "\n")
|
134
|
+
|
135
|
+
# Additional sanitization for SSH private keys
|
136
|
+
if ("-----BEGIN" in normalized_newlines and "PRIVATE KEY-----" in normalized_newlines) or (
|
137
|
+
"ssh-" in normalized_newlines.lower() and "key" in normalized_newlines.lower()
|
138
|
+
):
|
139
|
+
# Clean up SSH key formatting: remove extra whitespace, normalize line endings
|
140
|
+
lines = normalized_newlines.split("\n")
|
141
|
+
sanitized_lines = []
|
142
|
+
for line in lines:
|
143
|
+
cleaned = line.strip()
|
144
|
+
if cleaned:
|
145
|
+
# Remove any stray quotes that might have been embedded in the key content
|
146
|
+
cleaned = cleaned.replace('"', "").replace("'", "")
|
147
|
+
sanitized_lines.append(cleaned)
|
148
|
+
normalized_newlines = "\n".join(sanitized_lines)
|
149
|
+
|
134
150
|
b = _normalize_bool_like(normalized_newlines)
|
135
151
|
return b if b is not None else normalized_newlines
|
136
152
|
|
@@ -149,7 +165,7 @@ def _select_section(
|
|
149
165
|
|
150
166
|
def _load_ini(path: Path) -> configparser.RawConfigParser:
|
151
167
|
cp = configparser.RawConfigParser()
|
152
|
-
# Preserve option case; mypy requires a cast for attribute
|
168
|
+
# Preserve option case; mypy requires a cast for attribute requirement
|
153
169
|
cast(Any, cp).optionxform = str
|
154
170
|
try:
|
155
171
|
with path.open("r", encoding="utf-8") as fh:
|
@@ -162,6 +178,9 @@ def _load_ini(path: Path) -> configparser.RawConfigParser:
|
|
162
178
|
# We collapse these into a single line with '\n' escapes so that
|
163
179
|
# configparser can ingest them reliably; later, _coerce_value()
|
164
180
|
# converts the escapes back to real newlines.
|
181
|
+
#
|
182
|
+
# We also handle SSH private keys and other multi-line values that
|
183
|
+
# might have formatting inconsistencies by sanitizing them.
|
165
184
|
lines = raw_text.splitlines()
|
166
185
|
out_lines: list[str] = []
|
167
186
|
i = 0
|
@@ -171,6 +190,8 @@ def _load_ini(path: Path) -> configparser.RawConfigParser:
|
|
171
190
|
if eq_idx != -1:
|
172
191
|
left = line[: eq_idx + 1]
|
173
192
|
rhs = line[eq_idx + 1 :].strip()
|
193
|
+
|
194
|
+
# Handle standard multi-line quoted values: key = "
|
174
195
|
if rhs == '"':
|
175
196
|
i += 1
|
176
197
|
block: list[str] = []
|
@@ -187,10 +208,64 @@ def _load_ini(path: Path) -> configparser.RawConfigParser:
|
|
187
208
|
else:
|
188
209
|
# No closing quote found; fall through
|
189
210
|
# and keep original line
|
211
|
+
log.debug("Multi-line quote not properly closed for line: %s", line[:50])
|
190
212
|
out_lines.append(line)
|
191
213
|
continue
|
214
|
+
|
215
|
+
# Handle SSH private keys and other values that start with a quote
|
216
|
+
# but contain embedded content that might confuse configparser
|
217
|
+
elif rhs.startswith('"') and not rhs.endswith('"'):
|
218
|
+
# This looks like a multi-line value that starts on the same line
|
219
|
+
# Collect all content until we find a line ending with a quote
|
220
|
+
content_lines = [rhs[1:]] # Remove opening quote
|
221
|
+
i += 1
|
222
|
+
|
223
|
+
while i < len(lines):
|
224
|
+
current_line = lines[i]
|
225
|
+
if current_line.strip().endswith('"') and not current_line.strip().endswith('\\"'):
|
226
|
+
# Found closing quote - remove it and add final line
|
227
|
+
final_content = current_line.rstrip()
|
228
|
+
if final_content.endswith('"'):
|
229
|
+
final_content = final_content[:-1]
|
230
|
+
if final_content: # Only add if there's content after removing quote
|
231
|
+
content_lines.append(final_content)
|
232
|
+
break
|
233
|
+
else:
|
234
|
+
content_lines.append(current_line)
|
235
|
+
i += 1
|
236
|
+
|
237
|
+
# Join all content and sanitize for SSH keys
|
238
|
+
full_content = "\\n".join(content_lines)
|
239
|
+
|
240
|
+
# Special handling for SSH private keys - remove extra whitespace and line breaks
|
241
|
+
key_name = left.split("=")[0].strip().upper()
|
242
|
+
if "SSH" in key_name and "KEY" in key_name:
|
243
|
+
# For SSH keys, clean up base64 content by removing whitespace within lines
|
244
|
+
sanitized_lines = []
|
245
|
+
for content_line in content_lines:
|
246
|
+
cleaned = content_line.strip()
|
247
|
+
# Preserve SSH key headers/footers but clean base64 content
|
248
|
+
if cleaned.startswith("-----") or not cleaned:
|
249
|
+
sanitized_lines.append(cleaned)
|
250
|
+
else:
|
251
|
+
# Remove any embedded quotes and whitespace from base64 content
|
252
|
+
cleaned = cleaned.replace('"', "").replace("'", "").strip()
|
253
|
+
if cleaned:
|
254
|
+
sanitized_lines.append(cleaned)
|
255
|
+
full_content = "\\n".join(sanitized_lines)
|
256
|
+
|
257
|
+
log.debug(
|
258
|
+
"Processed multi-line value for key %s (length: %d)",
|
259
|
+
left.split("=")[0].strip(),
|
260
|
+
len(full_content),
|
261
|
+
)
|
262
|
+
out_lines.append(f'{left} "{full_content}"')
|
263
|
+
i += 1
|
264
|
+
continue
|
265
|
+
|
192
266
|
out_lines.append(line)
|
193
267
|
i += 1
|
268
|
+
|
194
269
|
preprocessed = "\n".join(out_lines) + ("\n" if out_lines else "")
|
195
270
|
cp.read_string(preprocessed)
|
196
271
|
except FileNotFoundError:
|
@@ -272,6 +347,8 @@ def load_org_config(
|
|
272
347
|
|
273
348
|
# Report unknown configuration keys to help users catch typos
|
274
349
|
unknown_keys = set(normalized.keys()) - KNOWN_KEYS
|
350
|
+
log.debug("All parsed keys from config: %s", sorted(normalized.keys()))
|
351
|
+
log.debug("Known keys: %s", sorted(KNOWN_KEYS))
|
275
352
|
if unknown_keys:
|
276
353
|
log.warning(
|
277
354
|
"Unknown configuration keys found in [%s]: %s. "
|
@@ -30,10 +30,7 @@ import logging
|
|
30
30
|
import os
|
31
31
|
import re
|
32
32
|
import shlex
|
33
|
-
import shutil
|
34
|
-
import socket
|
35
33
|
import stat
|
36
|
-
import tempfile
|
37
34
|
import urllib.parse
|
38
35
|
import urllib.request
|
39
36
|
from collections.abc import Iterable
|
@@ -789,6 +786,8 @@ class Orchestrator:
|
|
789
786
|
|
790
787
|
def _strategy_open_fchmod(self, key_path: Path, key_content: str) -> None:
|
791
788
|
"""Strategy: open with os.open and specific flags, then fchmod."""
|
789
|
+
import os
|
790
|
+
import stat
|
792
791
|
|
793
792
|
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
794
793
|
mode = stat.S_IRUSR | stat.S_IWUSR # 0o600
|
@@ -802,6 +801,7 @@ class Orchestrator:
|
|
802
801
|
|
803
802
|
def _strategy_umask_open(self, key_path: Path, key_content: str) -> None:
|
804
803
|
"""Strategy: set umask, create file, restore umask."""
|
804
|
+
import os
|
805
805
|
|
806
806
|
original_umask = os.umask(0o077) # Only owner can read/write
|
807
807
|
try:
|
@@ -813,6 +813,8 @@ class Orchestrator:
|
|
813
813
|
|
814
814
|
def _strategy_stat_constants(self, key_path: Path, key_content: str) -> None:
|
815
815
|
"""Strategy: use stat constants for permission setting."""
|
816
|
+
import os
|
817
|
+
import stat
|
816
818
|
|
817
819
|
with open(key_path, "w", encoding="utf-8") as f:
|
818
820
|
f.write(key_content)
|
@@ -824,9 +826,13 @@ class Orchestrator:
|
|
824
826
|
|
825
827
|
def _create_key_in_memory_fs(self, key_path: Path, key_content: str) -> bool:
|
826
828
|
"""Fallback: try to create key in memory filesystem."""
|
829
|
+
import shutil
|
830
|
+
import tempfile
|
831
|
+
|
827
832
|
try:
|
828
833
|
# Try to create in memory filesystem if available
|
829
834
|
# Use secure temporary directories
|
835
|
+
import tempfile
|
830
836
|
|
831
837
|
temp_dir = tempfile.gettempdir()
|
832
838
|
memory_dirs = [temp_dir]
|
@@ -2337,6 +2343,7 @@ class Orchestrator:
|
|
2337
2343
|
- Verify GitHub token by fetching repository and PR metadata
|
2338
2344
|
- Do NOT perform any write operations
|
2339
2345
|
"""
|
2346
|
+
import socket
|
2340
2347
|
|
2341
2348
|
log.info("Dry-run: starting preflight checks")
|
2342
2349
|
if os.getenv("G2G_DRYRUN_DISABLE_NETWORK", "").strip().lower() in (
|
@@ -289,6 +289,8 @@ class DuplicateDetector:
|
|
289
289
|
Returns:
|
290
290
|
Hex-encoded SHA256 hash string (first 16 characters for readability)
|
291
291
|
"""
|
292
|
+
import hashlib
|
293
|
+
|
292
294
|
# Build hash input from stable, unique PR identifiers
|
293
295
|
# Use server_url + repository + pr_number for global uniqueness
|
294
296
|
hash_input = f"{gh.server_url}/{gh.repository}/pull/{gh.pr_number}"
|
@@ -29,14 +29,12 @@ import functools
|
|
29
29
|
import logging
|
30
30
|
import random
|
31
31
|
import socket
|
32
|
-
import subprocess
|
33
32
|
import time
|
34
33
|
import urllib.error
|
35
34
|
from collections.abc import Callable
|
36
35
|
from dataclasses import dataclass
|
37
36
|
from dataclasses import field
|
38
37
|
from enum import Enum
|
39
|
-
from pathlib import Path
|
40
38
|
from typing import Any
|
41
39
|
from typing import NoReturn
|
42
40
|
from typing import TypeVar
|
@@ -443,6 +441,9 @@ def curl_download(
|
|
443
441
|
Raises:
|
444
442
|
RuntimeError: If curl command fails after retries
|
445
443
|
"""
|
444
|
+
import subprocess
|
445
|
+
from pathlib import Path
|
446
|
+
|
446
447
|
if policy is None:
|
447
448
|
policy = RetryPolicy(max_attempts=3, timeout=timeout)
|
448
449
|
|
@@ -171,7 +171,9 @@ def build_client(token: str | None = None) -> GhClient:
|
|
171
171
|
def get_repo_from_env(client: GhClient) -> GhRepository:
|
172
172
|
"""Return the repository object based on GITHUB_REPOSITORY."""
|
173
173
|
full = _getenv_str("GITHUB_REPOSITORY")
|
174
|
+
log.debug("GITHUB_REPOSITORY environment variable: '%s'", full)
|
174
175
|
if not full or "/" not in full:
|
176
|
+
log.error("Invalid GITHUB_REPOSITORY: '%s' (expected format: 'owner/repo')", full)
|
175
177
|
raise ValueError(_MSG_BAD_GITHUB_REPOSITORY)
|
176
178
|
repo = client.get_repo(full)
|
177
179
|
return repo
|
@@ -11,10 +11,8 @@ from __future__ import annotations
|
|
11
11
|
|
12
12
|
import logging
|
13
13
|
import os
|
14
|
-
import re
|
15
14
|
import shlex
|
16
15
|
import subprocess
|
17
|
-
import tempfile
|
18
16
|
import time
|
19
17
|
from collections.abc import Callable
|
20
18
|
from collections.abc import Iterable
|
@@ -483,7 +481,9 @@ def git_commit_amend(
|
|
483
481
|
# Write message to a temp file to avoid shell-escaping issues
|
484
482
|
tmp_path: Path | None = None
|
485
483
|
if message is not None:
|
486
|
-
|
484
|
+
import tempfile as _tempfile
|
485
|
+
|
486
|
+
with _tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as _tf:
|
487
487
|
_tf.write(message)
|
488
488
|
_tf.flush()
|
489
489
|
tmp_path = Path(_tf.name)
|
@@ -493,6 +493,9 @@ def git_commit_amend(
|
|
493
493
|
# Determine whether to add -s; only suppress if message already has a sign-off for current committer
|
494
494
|
effective_signoff = bool(signoff)
|
495
495
|
try:
|
496
|
+
import os
|
497
|
+
import re
|
498
|
+
|
496
499
|
# Resolve committer email (prefer repo-local; fallback to global/env)
|
497
500
|
committer_email = os.getenv("GIT_COMMITTER_EMAIL", "").strip()
|
498
501
|
if not committer_email:
|
@@ -583,7 +586,9 @@ def git_commit_new(
|
|
583
586
|
# Write message to a temp file to avoid shell-escaping issues
|
584
587
|
tmp_path: Path | None = None
|
585
588
|
if message is not None:
|
586
|
-
|
589
|
+
import tempfile as _tempfile
|
590
|
+
|
591
|
+
with _tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as _tf:
|
587
592
|
_tf.write(message)
|
588
593
|
_tf.flush()
|
589
594
|
tmp_path = Path(_tf.name)
|
@@ -593,6 +598,9 @@ def git_commit_new(
|
|
593
598
|
# Determine whether to add -s; only suppress if message already has a sign-off for current committer
|
594
599
|
effective_signoff = bool(signoff)
|
595
600
|
try:
|
601
|
+
import os
|
602
|
+
import re
|
603
|
+
|
596
604
|
# Resolve committer email (prefer repo-local; fallback to global/env)
|
597
605
|
committer_email = os.getenv("GIT_COMMITTER_EMAIL", "").strip()
|
598
606
|
if not committer_email:
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: github2gerrit
|
3
|
-
Version: 0.1.
|
3
|
+
Version: 0.1.9
|
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
|
@@ -297,7 +297,7 @@ jobs:
|
|
297
297
|
steps:
|
298
298
|
- name: Submit PR to Gerrit
|
299
299
|
id: g2g
|
300
|
-
uses:
|
300
|
+
uses: lfreleng-actions/github2gerrit-action@main
|
301
301
|
with:
|
302
302
|
SUBMIT_SINGLE_COMMITS: "false"
|
303
303
|
USE_PR_AS_COMMIT: "false"
|
@@ -343,7 +343,7 @@ uv run github2gerrit
|
|
343
343
|
uvx github2gerrit https://github.com/owner/repo/pull/123
|
344
344
|
|
345
345
|
# Install from specific version/source
|
346
|
-
uvx --from git+https://github.com/
|
346
|
+
uvx --from git+https://github.com/lfreleng-actions/github2gerrit-action@main github2gerrit https://github.com/owner/repo/pull/123
|
347
347
|
```
|
348
348
|
|
349
349
|
### Available Options
|
@@ -524,7 +524,7 @@ jobs:
|
|
524
524
|
|
525
525
|
- name: Submit PR to Gerrit (with explicit overrides)
|
526
526
|
id: g2g
|
527
|
-
uses:
|
527
|
+
uses: lfreleng-actions/github2gerrit-action@main
|
528
528
|
with:
|
529
529
|
# Behavior
|
530
530
|
SUBMIT_SINGLE_COMMITS: "false"
|
@@ -777,7 +777,7 @@ For CI/CD pipelines (like GitHub Actions), use `uvx` to install and run without
|
|
777
777
|
uvx github2gerrit <PR_URL> --dry-run
|
778
778
|
|
779
779
|
# Install from a specific version or source
|
780
|
-
uvx --from git+https://github.com/
|
780
|
+
uvx --from git+https://github.com/lfreleng-actions/github2gerrit-action@main github2gerrit <PR_URL>
|
781
781
|
|
782
782
|
# Run with specific Python version
|
783
783
|
uvx --python 3.11 github2gerrit <PR_URL>
|
@@ -24,6 +24,7 @@ uv.lock
|
|
24
24
|
.github/workflows/sha-pinned-actions.yaml
|
25
25
|
.github/workflows/testing.yaml
|
26
26
|
LICENSES/Apache-2.0.txt
|
27
|
+
docs/github2gerrit_token_permissions_classic.png
|
27
28
|
src/github2gerrit/__init__.py
|
28
29
|
src/github2gerrit/cli.py
|
29
30
|
src/github2gerrit/commit_normalization.py
|
@@ -3,7 +3,6 @@
|
|
3
3
|
|
4
4
|
"""Tests for duplicate change detection."""
|
5
5
|
|
6
|
-
import json
|
7
6
|
from datetime import UTC
|
8
7
|
from datetime import datetime
|
9
8
|
from datetime import timedelta
|
@@ -374,6 +373,8 @@ project=test/project.git
|
|
374
373
|
target_pr.get_files.return_value = []
|
375
374
|
|
376
375
|
# Mock Gerrit REST API response with matching subject
|
376
|
+
import json
|
377
|
+
|
377
378
|
gerrit_response = [
|
378
379
|
{
|
379
380
|
"_number": 12345,
|
@@ -419,6 +420,8 @@ project=test/project.git
|
|
419
420
|
target_pr.get_files.return_value = []
|
420
421
|
|
421
422
|
# Mock Gerrit REST API response with matching subject
|
423
|
+
import json
|
424
|
+
|
422
425
|
gerrit_response = [
|
423
426
|
{
|
424
427
|
"_number": 12345,
|
@@ -3,7 +3,6 @@
|
|
3
3
|
|
4
4
|
from __future__ import annotations
|
5
5
|
|
6
|
-
import logging
|
7
6
|
import urllib.error
|
8
7
|
from io import StringIO
|
9
8
|
from pathlib import Path
|
@@ -210,6 +209,8 @@ def test_curl_download_failure(monkeypatch: pytest.MonkeyPatch, tmp_path: Path)
|
|
210
209
|
|
211
210
|
def test_log_api_metrics_summary(monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture) -> None:
|
212
211
|
"""Test API metrics summary logging."""
|
212
|
+
import logging
|
213
|
+
|
213
214
|
# Set up some metrics
|
214
215
|
reset_api_metrics()
|
215
216
|
|
@@ -7,7 +7,6 @@ import contextlib
|
|
7
7
|
import io
|
8
8
|
import os
|
9
9
|
import stat
|
10
|
-
import urllib.error
|
11
10
|
from collections.abc import Callable
|
12
11
|
from email.message import Message
|
13
12
|
from pathlib import Path
|
@@ -94,6 +93,8 @@ def test_gerrit_rest_retries_on_http_503_then_succeeds(
|
|
94
93
|
monkeypatch.setattr("github2gerrit.gerrit_rest._PygerritRestApi", None, raising=True)
|
95
94
|
monkeypatch.setattr("time.sleep", lambda s: None, raising=False)
|
96
95
|
|
96
|
+
import urllib.error
|
97
|
+
|
97
98
|
# Prepare sequence: 503 error twice, then 200 OK
|
98
99
|
attempts: dict[str, int] = {"n": 0}
|
99
100
|
|
@@ -143,6 +144,8 @@ def test_gerrit_rest_retries_on_urlerror_timeout_then_fails(
|
|
143
144
|
monkeypatch.setattr("github2gerrit.gerrit_rest._PygerritRestApi", None, raising=True)
|
144
145
|
monkeypatch.setattr("time.sleep", lambda s: None, raising=False)
|
145
146
|
|
147
|
+
import urllib.error
|
148
|
+
|
146
149
|
attempts: dict[str, int] = {"n": 0}
|
147
150
|
|
148
151
|
def _fake_urlopen(req: Any, timeout: float | None = None) -> _DummyResp:
|
@@ -8,7 +8,6 @@ from pathlib import Path
|
|
8
8
|
|
9
9
|
import pytest
|
10
10
|
|
11
|
-
import github2gerrit.gitutils as gitutils
|
12
11
|
from github2gerrit.gitutils import CommandError
|
13
12
|
from github2gerrit.gitutils import CommandResult
|
14
13
|
from github2gerrit.gitutils import git
|
@@ -204,6 +203,8 @@ def test_git_last_commit_trailers_parsing_edge_cases(
|
|
204
203
|
monkeypatch: pytest.MonkeyPatch,
|
205
204
|
) -> None:
|
206
205
|
# Patch git_show to return a crafted commit message body with various trailer forms.
|
206
|
+
import github2gerrit.gitutils as gitutils
|
207
|
+
|
207
208
|
body = (
|
208
209
|
"Subject line\n\n"
|
209
210
|
"Body content with Key: Value in the middle\n"
|
@@ -68,5 +68,5 @@ from github2gerrit.cli import _parse_github_target
|
|
68
68
|
("https://github.com", (None, None, None)),
|
69
69
|
],
|
70
70
|
)
|
71
|
-
def test_parse_github_target(url: str, expected: tuple[
|
71
|
+
def test_parse_github_target(url: str, expected: tuple[object, object, object]) -> None:
|
72
72
|
assert _parse_github_target(url) == expected
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|