github2gerrit 0.1.6__py3-none-any.whl → 0.1.8__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 +458 -192
- github2gerrit/commit_normalization.py +471 -0
- github2gerrit/core.py +822 -252
- github2gerrit/duplicate_detection.py +1 -69
- github2gerrit/external_api.py +517 -0
- github2gerrit/gerrit_rest.py +298 -0
- github2gerrit/gerrit_urls.py +149 -52
- github2gerrit/github_api.py +12 -79
- github2gerrit/gitutils.py +208 -49
- github2gerrit/models.py +2 -0
- github2gerrit/pr_content_filter.py +476 -0
- github2gerrit/similarity.py +2 -2
- github2gerrit/ssh_agent_setup.py +351 -0
- github2gerrit/ssh_common.py +244 -0
- github2gerrit/ssh_discovery.py +4 -0
- github2gerrit/utils.py +113 -0
- github2gerrit-0.1.8.dist-info/METADATA +798 -0
- github2gerrit-0.1.8.dist-info/RECORD +24 -0
- github2gerrit-0.1.6.dist-info/METADATA +0 -552
- github2gerrit-0.1.6.dist-info/RECORD +0 -17
- {github2gerrit-0.1.6.dist-info → github2gerrit-0.1.8.dist-info}/WHEEL +0 -0
- {github2gerrit-0.1.6.dist-info → github2gerrit-0.1.8.dist-info}/entry_points.txt +0 -0
- {github2gerrit-0.1.6.dist-info → github2gerrit-0.1.8.dist-info}/licenses/LICENSE +0 -0
- {github2gerrit-0.1.6.dist-info → github2gerrit-0.1.8.dist-info}/top_level.txt +0 -0
github2gerrit/github_api.py
CHANGED
@@ -18,10 +18,7 @@ from __future__ import annotations
|
|
18
18
|
|
19
19
|
import logging
|
20
20
|
import os
|
21
|
-
import random
|
22
21
|
import re
|
23
|
-
import time
|
24
|
-
from collections.abc import Callable
|
25
22
|
from collections.abc import Iterable
|
26
23
|
from importlib import import_module
|
27
24
|
from typing import Any
|
@@ -29,6 +26,9 @@ from typing import Protocol
|
|
29
26
|
from typing import TypeVar
|
30
27
|
from typing import cast
|
31
28
|
|
29
|
+
from .external_api import ApiType
|
30
|
+
from .external_api import external_api_call
|
31
|
+
|
32
32
|
|
33
33
|
# Error message constants to comply with TRY003
|
34
34
|
_MSG_PYGITHUB_REQUIRED = "PyGithub required"
|
@@ -111,7 +111,6 @@ __all__ = [
|
|
111
111
|
"get_recent_change_ids_from_comments",
|
112
112
|
"get_repo_from_env",
|
113
113
|
"iter_open_pulls",
|
114
|
-
"time",
|
115
114
|
]
|
116
115
|
|
117
116
|
log = logging.getLogger("github2gerrit.github_api")
|
@@ -124,74 +123,7 @@ def _getenv_str(name: str) -> str:
|
|
124
123
|
return val.strip()
|
125
124
|
|
126
125
|
|
127
|
-
|
128
|
-
# Exponential backoff with jitter; cap prevents unbounded waits.
|
129
|
-
delay: float = float(min(base * (2 ** max(0, attempt - 1)), cap))
|
130
|
-
# Using random.uniform for jitter is appropriate here - we only need
|
131
|
-
# pseudorandom distribution to avoid thundering herd, not crypto security
|
132
|
-
jitter: float = float(random.uniform(0.0, delay / 2.0)) # noqa: S311
|
133
|
-
return float(delay + jitter)
|
134
|
-
|
135
|
-
|
136
|
-
def _should_retry(exc: BaseException) -> bool:
|
137
|
-
# Retry on common transient conditions:
|
138
|
-
# - RateLimitExceededException
|
139
|
-
# - GithubException with 5xx codes
|
140
|
-
# - GithubException with 403 and rate limit hints
|
141
|
-
if isinstance(exc, _RATE_LIMIT_EXC | RateLimitExceededExceptionType):
|
142
|
-
return True
|
143
|
-
if isinstance(exc, _GITHUB_EXCEPTION | GithubExceptionType):
|
144
|
-
status = getattr(exc, "status", None)
|
145
|
-
if isinstance(status, int) and 500 <= status <= 599:
|
146
|
-
return True
|
147
|
-
data = getattr(exc, "data", "")
|
148
|
-
if status == 403 and isinstance(data, str | bytes):
|
149
|
-
try:
|
150
|
-
text = data.decode("utf-8") if isinstance(data, bytes) else data
|
151
|
-
except Exception:
|
152
|
-
text = str(data)
|
153
|
-
if "rate limit" in text.lower():
|
154
|
-
return True
|
155
|
-
return False
|
156
|
-
|
157
|
-
|
158
|
-
def _retry_on_github(
|
159
|
-
attempts: int = 5,
|
160
|
-
) -> Callable[[Callable[..., _T]], Callable[..., _T]]:
|
161
|
-
def decorator(func: Callable[..., _T]) -> Callable[..., _T]:
|
162
|
-
def wrapper(*args: Any, **kwargs: Any) -> _T:
|
163
|
-
last_exc: BaseException | None = None
|
164
|
-
for attempt in range(1, attempts + 1):
|
165
|
-
try:
|
166
|
-
return func(*args, **kwargs)
|
167
|
-
except BaseException as exc:
|
168
|
-
last_exc = exc
|
169
|
-
if not _should_retry(exc) or attempt == attempts:
|
170
|
-
log.debug(
|
171
|
-
"GitHub call failed (no retry) at attempt %d: %s",
|
172
|
-
attempt,
|
173
|
-
exc,
|
174
|
-
)
|
175
|
-
raise
|
176
|
-
delay = _backoff_delay(attempt)
|
177
|
-
log.warning(
|
178
|
-
"GitHub call failed (attempt %d): %s; retrying in %.2fs",
|
179
|
-
attempt,
|
180
|
-
exc,
|
181
|
-
delay,
|
182
|
-
)
|
183
|
-
time.sleep(delay)
|
184
|
-
# Should not reach here, but raise if it does.
|
185
|
-
if last_exc is None:
|
186
|
-
raise RuntimeError("unreachable")
|
187
|
-
raise last_exc
|
188
|
-
|
189
|
-
return wrapper
|
190
|
-
|
191
|
-
return decorator
|
192
|
-
|
193
|
-
|
194
|
-
@_retry_on_github()
|
126
|
+
@external_api_call(ApiType.GITHUB, "build_client")
|
195
127
|
def build_client(token: str | None = None) -> GhClient:
|
196
128
|
"""Construct a PyGithub client from a token or environment.
|
197
129
|
|
@@ -209,7 +141,8 @@ def build_client(token: str | None = None) -> GhClient:
|
|
209
141
|
base_url = _getenv_str("GITHUB_API_URL")
|
210
142
|
if not base_url:
|
211
143
|
server_url = _getenv_str("GITHUB_SERVER_URL")
|
212
|
-
|
144
|
+
# Only synthesize API URL for non-github.com servers (GHE instances)
|
145
|
+
if server_url and server_url.rstrip("/") != "https://github.com":
|
213
146
|
base_url = server_url.rstrip("/") + "/api/v3"
|
214
147
|
client_any: Any
|
215
148
|
try:
|
@@ -234,7 +167,7 @@ def build_client(token: str | None = None) -> GhClient:
|
|
234
167
|
return cast(GhClient, client_any)
|
235
168
|
|
236
169
|
|
237
|
-
@
|
170
|
+
@external_api_call(ApiType.GITHUB, "get_repo_from_env")
|
238
171
|
def get_repo_from_env(client: GhClient) -> GhRepository:
|
239
172
|
"""Return the repository object based on GITHUB_REPOSITORY."""
|
240
173
|
full = _getenv_str("GITHUB_REPOSITORY")
|
@@ -244,7 +177,7 @@ def get_repo_from_env(client: GhClient) -> GhRepository:
|
|
244
177
|
return repo
|
245
178
|
|
246
179
|
|
247
|
-
@
|
180
|
+
@external_api_call(ApiType.GITHUB, "get_pull")
|
248
181
|
def get_pull(repo: GhRepository, number: int) -> GhPullRequest:
|
249
182
|
"""Fetch a pull request by number."""
|
250
183
|
pr = repo.get_pull(number)
|
@@ -266,14 +199,14 @@ def get_pr_title_body(pr: GhPullRequest) -> tuple[str, str]:
|
|
266
199
|
_CHANGE_ID_RE: re.Pattern[str] = re.compile(r"Change-Id:\s*([A-Za-z0-9._-]+)")
|
267
200
|
|
268
201
|
|
269
|
-
@
|
202
|
+
@external_api_call(ApiType.GITHUB, "get_pr_comments")
|
270
203
|
def _get_issue(pr: GhPullRequest) -> GhIssue:
|
271
204
|
"""Return the issue object corresponding to a pull request."""
|
272
205
|
issue = pr.as_issue()
|
273
206
|
return issue
|
274
207
|
|
275
208
|
|
276
|
-
@
|
209
|
+
@external_api_call(ApiType.GITHUB, "get_issue_from_pr")
|
277
210
|
def get_recent_change_ids_from_comments(
|
278
211
|
pr: GhPullRequest,
|
279
212
|
*,
|
@@ -308,7 +241,7 @@ def get_recent_change_ids_from_comments(
|
|
308
241
|
return found
|
309
242
|
|
310
243
|
|
311
|
-
@
|
244
|
+
@external_api_call(ApiType.GITHUB, "create_pr_comment")
|
312
245
|
def create_pr_comment(pr: GhPullRequest, body: str) -> None:
|
313
246
|
"""Create a new comment on the pull request."""
|
314
247
|
if not body.strip():
|
@@ -317,7 +250,7 @@ def create_pr_comment(pr: GhPullRequest, body: str) -> None:
|
|
317
250
|
issue.create_comment(body)
|
318
251
|
|
319
252
|
|
320
|
-
@
|
253
|
+
@external_api_call(ApiType.GITHUB, "close_pr")
|
321
254
|
def close_pr(pr: GhPullRequest, *, comment: str | None = None) -> None:
|
322
255
|
"""Close a pull request, optionally posting a comment first."""
|
323
256
|
if comment and comment.strip():
|
github2gerrit/gitutils.py
CHANGED
@@ -11,8 +11,10 @@ from __future__ import annotations
|
|
11
11
|
|
12
12
|
import logging
|
13
13
|
import os
|
14
|
+
import re
|
14
15
|
import shlex
|
15
16
|
import subprocess
|
17
|
+
import tempfile
|
16
18
|
import time
|
17
19
|
from collections.abc import Callable
|
18
20
|
from collections.abc import Iterable
|
@@ -20,20 +22,8 @@ from collections.abc import Mapping
|
|
20
22
|
from collections.abc import Sequence
|
21
23
|
from dataclasses import dataclass
|
22
24
|
from pathlib import Path
|
23
|
-
from typing import Any
|
24
25
|
|
25
|
-
|
26
|
-
def _is_verbose_mode() -> bool:
|
27
|
-
"""Check if verbose mode is enabled via environment variable."""
|
28
|
-
return os.getenv("G2G_VERBOSE", "").lower() in ("true", "1", "yes")
|
29
|
-
|
30
|
-
|
31
|
-
def _log_exception_conditionally(logger: logging.Logger, message: str, *args: Any) -> None:
|
32
|
-
"""Log exception with traceback only if verbose mode is enabled."""
|
33
|
-
if _is_verbose_mode():
|
34
|
-
logger.exception(message, *args)
|
35
|
-
else:
|
36
|
-
logger.error(message, *args)
|
26
|
+
from .utils import log_exception_conditionally
|
37
27
|
|
38
28
|
|
39
29
|
__all__ = [
|
@@ -202,7 +192,7 @@ def run_cmd(
|
|
202
192
|
)
|
203
193
|
except subprocess.TimeoutExpired as exc:
|
204
194
|
msg = f"Command timed out: {cmd!r}"
|
205
|
-
|
195
|
+
log_exception_conditionally(log, msg)
|
206
196
|
# TimeoutExpired carries 'output' and 'stderr' attributes,
|
207
197
|
# which may be bytes depending on invocation context.
|
208
198
|
out = getattr(exc, "output", None)
|
@@ -216,7 +206,7 @@ def run_cmd(
|
|
216
206
|
) from exc
|
217
207
|
except OSError as exc:
|
218
208
|
msg = f"Failed to execute command: {cmd!r} ({exc})"
|
219
|
-
|
209
|
+
log_exception_conditionally(log, msg)
|
220
210
|
raise CommandError(msg, cmd=cmd) from exc
|
221
211
|
|
222
212
|
result = CommandResult(
|
@@ -245,32 +235,48 @@ def run_cmd(
|
|
245
235
|
if result.stdout:
|
246
236
|
log.debug("stdout: %s", mask_text(result.stdout, masks))
|
247
237
|
if result.stderr:
|
248
|
-
|
238
|
+
# Filter out git init default branch hints to keep logs clean
|
239
|
+
stderr_lines = result.stderr.splitlines()
|
240
|
+
filtered_lines = []
|
241
|
+
skip_hint = False
|
242
|
+
for line in stderr_lines:
|
243
|
+
if "hint: Using '" in line and "as the name for the initial branch" in line:
|
244
|
+
skip_hint = True
|
245
|
+
elif (skip_hint and line.startswith("hint:")) or (skip_hint and not line.strip()):
|
246
|
+
continue
|
247
|
+
else:
|
248
|
+
skip_hint = False
|
249
|
+
filtered_lines.append(line)
|
250
|
+
|
251
|
+
if filtered_lines:
|
252
|
+
filtered_stderr = "\n".join(filtered_lines)
|
253
|
+
log.debug("stderr: %s", mask_text(filtered_stderr, masks))
|
249
254
|
|
250
255
|
return result
|
251
256
|
|
252
257
|
|
253
|
-
def non_interactive_env() -> dict[str, str]:
|
258
|
+
def non_interactive_env(include_git_ssh_command: bool = True) -> dict[str, str]:
|
254
259
|
"""Return a non-interactive SSH/Git environment to bypass local
|
255
|
-
agents/keychains.
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
266
|
-
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
"
|
273
|
-
|
260
|
+
agents/keychains.
|
261
|
+
|
262
|
+
Args:
|
263
|
+
include_git_ssh_command: Whether to include a default GIT_SSH_COMMAND.
|
264
|
+
Set to False when the caller will provide their own.
|
265
|
+
|
266
|
+
Returns:
|
267
|
+
Dictionary of environment variables for non-interactive operations.
|
268
|
+
"""
|
269
|
+
# Import here to avoid circular imports
|
270
|
+
from .ssh_common import build_non_interactive_ssh_env
|
271
|
+
|
272
|
+
env = build_non_interactive_ssh_env()
|
273
|
+
|
274
|
+
if include_git_ssh_command:
|
275
|
+
from .ssh_common import build_git_ssh_command
|
276
|
+
|
277
|
+
env["GIT_SSH_COMMAND"] = build_git_ssh_command()
|
278
|
+
|
279
|
+
return env
|
274
280
|
|
275
281
|
|
276
282
|
def run_cmd_with_retries(
|
@@ -474,16 +480,73 @@ def git_commit_amend(
|
|
474
480
|
|
475
481
|
If message is provided, it takes precedence over message_file.
|
476
482
|
"""
|
483
|
+
# Write message to a temp file to avoid shell-escaping issues
|
484
|
+
tmp_path: Path | None = None
|
485
|
+
if message is not None:
|
486
|
+
with tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as _tf:
|
487
|
+
_tf.write(message)
|
488
|
+
_tf.flush()
|
489
|
+
tmp_path = Path(_tf.name)
|
490
|
+
message_file = tmp_path
|
491
|
+
message = None
|
492
|
+
|
493
|
+
# Determine whether to add -s; only suppress if message already has a sign-off for current committer
|
494
|
+
effective_signoff = bool(signoff)
|
495
|
+
try:
|
496
|
+
# Resolve committer email (prefer repo-local; fallback to global/env)
|
497
|
+
committer_email = os.getenv("GIT_COMMITTER_EMAIL", "").strip()
|
498
|
+
if not committer_email:
|
499
|
+
try:
|
500
|
+
res = run_cmd(["git", "config", "--get", "user.email"], cwd=cwd)
|
501
|
+
committer_email = (res.stdout or "").strip()
|
502
|
+
except Exception:
|
503
|
+
committer_email = ""
|
504
|
+
if not committer_email:
|
505
|
+
try:
|
506
|
+
ge = git_config_get("user.email", global_=True)
|
507
|
+
if ge:
|
508
|
+
committer_email = ge.strip()
|
509
|
+
except Exception:
|
510
|
+
committer_email = ""
|
511
|
+
|
512
|
+
def _has_committer_signoff(text: str) -> bool:
|
513
|
+
for ln in text.splitlines():
|
514
|
+
if ln.lower().startswith("signed-off-by:"):
|
515
|
+
m = re.search(r"<([^>]+)>", ln)
|
516
|
+
if m and committer_email and m.group(1).strip().lower() == committer_email.lower():
|
517
|
+
return True
|
518
|
+
return False
|
519
|
+
|
520
|
+
msg_text: str | None = None
|
521
|
+
if message_file is not None:
|
522
|
+
try:
|
523
|
+
msg_text = Path(message_file).read_text(encoding="utf-8")
|
524
|
+
except Exception:
|
525
|
+
msg_text = None
|
526
|
+
|
527
|
+
if msg_text is not None:
|
528
|
+
if committer_email and _has_committer_signoff(msg_text):
|
529
|
+
effective_signoff = False
|
530
|
+
else:
|
531
|
+
# No explicit message provided; check current commit body
|
532
|
+
try:
|
533
|
+
body = git_show("HEAD", cwd=cwd, fmt="%B")
|
534
|
+
if committer_email and _has_committer_signoff(body):
|
535
|
+
effective_signoff = False
|
536
|
+
except GitError:
|
537
|
+
pass
|
538
|
+
except Exception:
|
539
|
+
# Best effort only; default to requested signoff
|
540
|
+
effective_signoff = bool(signoff)
|
541
|
+
|
477
542
|
args: list[str] = ["commit", "--amend"]
|
478
543
|
if no_edit and not message and not message_file:
|
479
544
|
args.append("--no-edit")
|
480
|
-
if
|
545
|
+
if effective_signoff:
|
481
546
|
args.append("-s")
|
482
547
|
if author:
|
483
548
|
args.extend(["--author", author])
|
484
|
-
if
|
485
|
-
args.extend(["-m", message])
|
486
|
-
elif message_file:
|
549
|
+
if message_file:
|
487
550
|
args.extend(["-F", str(message_file)])
|
488
551
|
|
489
552
|
try:
|
@@ -496,6 +559,12 @@ def git_commit_amend(
|
|
496
559
|
stdout=exc.stdout,
|
497
560
|
stderr=exc.stderr,
|
498
561
|
) from exc
|
562
|
+
finally:
|
563
|
+
if tmp_path is not None:
|
564
|
+
from contextlib import suppress
|
565
|
+
|
566
|
+
with suppress(Exception):
|
567
|
+
tmp_path.unlink(missing_ok=True)
|
499
568
|
|
500
569
|
|
501
570
|
def git_commit_new(
|
@@ -511,17 +580,62 @@ def git_commit_new(
|
|
511
580
|
if not message and not message_file:
|
512
581
|
raise ValueError(_MSG_COMMIT_NO_MESSAGE)
|
513
582
|
|
583
|
+
# Write message to a temp file to avoid shell-escaping issues
|
584
|
+
tmp_path: Path | None = None
|
585
|
+
if message is not None:
|
586
|
+
with tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as _tf:
|
587
|
+
_tf.write(message)
|
588
|
+
_tf.flush()
|
589
|
+
tmp_path = Path(_tf.name)
|
590
|
+
message_file = tmp_path
|
591
|
+
message = None
|
592
|
+
|
593
|
+
# Determine whether to add -s; only suppress if message already has a sign-off for current committer
|
594
|
+
effective_signoff = bool(signoff)
|
595
|
+
try:
|
596
|
+
# Resolve committer email (prefer repo-local; fallback to global/env)
|
597
|
+
committer_email = os.getenv("GIT_COMMITTER_EMAIL", "").strip()
|
598
|
+
if not committer_email:
|
599
|
+
try:
|
600
|
+
res = run_cmd(["git", "config", "--get", "user.email"], cwd=cwd)
|
601
|
+
committer_email = (res.stdout or "").strip()
|
602
|
+
except Exception:
|
603
|
+
committer_email = ""
|
604
|
+
if not committer_email:
|
605
|
+
try:
|
606
|
+
ge = git_config_get("user.email", global_=True)
|
607
|
+
if ge:
|
608
|
+
committer_email = ge.strip()
|
609
|
+
except Exception:
|
610
|
+
committer_email = ""
|
611
|
+
|
612
|
+
def _has_committer_signoff(text: str) -> bool:
|
613
|
+
for ln in text.splitlines():
|
614
|
+
if ln.lower().startswith("signed-off-by:"):
|
615
|
+
m = re.search(r"<([^>]+)>", ln)
|
616
|
+
if m and committer_email and m.group(1).strip().lower() == committer_email.lower():
|
617
|
+
return True
|
618
|
+
return False
|
619
|
+
|
620
|
+
if message_file is not None:
|
621
|
+
try:
|
622
|
+
msg_text = Path(message_file).read_text(encoding="utf-8")
|
623
|
+
except Exception:
|
624
|
+
msg_text = None
|
625
|
+
if msg_text and _has_committer_signoff(msg_text):
|
626
|
+
effective_signoff = False
|
627
|
+
except Exception:
|
628
|
+
effective_signoff = bool(signoff)
|
629
|
+
|
514
630
|
args: list[str] = ["commit"]
|
515
|
-
if
|
631
|
+
if effective_signoff:
|
516
632
|
args.append("-s")
|
517
633
|
if author:
|
518
634
|
args.extend(["--author", author])
|
519
635
|
if allow_empty:
|
520
636
|
args.append("--allow-empty")
|
521
637
|
|
522
|
-
if
|
523
|
-
args.extend(["-m", message])
|
524
|
-
else:
|
638
|
+
if message_file:
|
525
639
|
args.extend(["-F", str(message_file)])
|
526
640
|
|
527
641
|
try:
|
@@ -534,6 +648,12 @@ def git_commit_new(
|
|
534
648
|
stdout=exc.stdout,
|
535
649
|
stderr=exc.stderr,
|
536
650
|
) from exc
|
651
|
+
finally:
|
652
|
+
if tmp_path is not None:
|
653
|
+
from contextlib import suppress
|
654
|
+
|
655
|
+
with suppress(Exception):
|
656
|
+
tmp_path.unlink(missing_ok=True)
|
537
657
|
|
538
658
|
|
539
659
|
def git_show(
|
@@ -561,21 +681,60 @@ def git_show(
|
|
561
681
|
|
562
682
|
|
563
683
|
def _parse_trailers(text: str) -> dict[str, list[str]]:
|
564
|
-
"""Parse trailers from a commit message
|
684
|
+
"""Parse trailers from a commit message footer only.
|
565
685
|
|
566
|
-
|
686
|
+
Git trailers are key-value pairs that appear at the end of commit messages,
|
687
|
+
separated from the body by a blank line. This function only parses trailers
|
688
|
+
from the actual footer section to avoid false positives from the message body.
|
567
689
|
"""
|
568
690
|
trailers: dict[str, list[str]] = {}
|
569
|
-
|
570
|
-
|
691
|
+
lines = text.splitlines()
|
692
|
+
|
693
|
+
# Find the start of the trailer block by working backwards
|
694
|
+
# Trailers must be at the end, separated by a blank line from the body
|
695
|
+
trailer_start = len(lines)
|
696
|
+
in_trailer_block = True
|
697
|
+
|
698
|
+
for i in range(len(lines) - 1, -1, -1):
|
699
|
+
line = lines[i].strip()
|
700
|
+
|
701
|
+
if not line and in_trailer_block:
|
702
|
+
# Found blank line, trailers end here
|
703
|
+
trailer_start = i + 1
|
704
|
+
break
|
705
|
+
elif not line:
|
706
|
+
# Blank line in middle, not in trailer block anymore
|
707
|
+
in_trailer_block = False
|
708
|
+
trailer_start = len(lines)
|
709
|
+
elif in_trailer_block and ":" in line:
|
710
|
+
# Potential trailer line
|
711
|
+
key, val = line.split(":", 1)
|
712
|
+
k = key.strip()
|
713
|
+
v = val.strip()
|
714
|
+
if k and v and not k.startswith(" ") and not k.startswith("\t"):
|
715
|
+
# Valid trailer format
|
716
|
+
continue
|
717
|
+
else:
|
718
|
+
# Invalid trailer format, stop looking
|
719
|
+
in_trailer_block = False
|
720
|
+
trailer_start = len(lines)
|
721
|
+
elif in_trailer_block:
|
722
|
+
# Non-trailer line in what we thought was trailer block
|
723
|
+
in_trailer_block = False
|
724
|
+
trailer_start = len(lines)
|
725
|
+
|
726
|
+
# Parse only the trailer section
|
727
|
+
for i in range(trailer_start, len(lines)):
|
728
|
+
line = lines[i].strip()
|
571
729
|
if not line or ":" not in line:
|
572
730
|
continue
|
573
731
|
key, val = line.split(":", 1)
|
574
732
|
k = key.strip()
|
575
733
|
v = val.strip()
|
576
|
-
if not k or not v:
|
734
|
+
if not k or not v or k.startswith((" ", "\t")):
|
577
735
|
continue
|
578
736
|
trailers.setdefault(k, []).append(v)
|
737
|
+
|
579
738
|
return trailers
|
580
739
|
|
581
740
|
|
github2gerrit/models.py
CHANGED
@@ -45,6 +45,7 @@ class Inputs:
|
|
45
45
|
# Behavior toggles
|
46
46
|
preserve_github_prs: bool
|
47
47
|
dry_run: bool
|
48
|
+
normalise_commit: bool
|
48
49
|
|
49
50
|
# Optional (reusable workflow compatibility / overrides)
|
50
51
|
gerrit_server: str
|
@@ -52,6 +53,7 @@ class Inputs:
|
|
52
53
|
gerrit_project: str
|
53
54
|
issue_id: str
|
54
55
|
allow_duplicates: bool
|
56
|
+
ci_testing: bool
|
55
57
|
duplicates_filter: str = "open"
|
56
58
|
|
57
59
|
|