github2gerrit 0.1.6__py3-none-any.whl → 0.1.7__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 +457 -186
- github2gerrit/commit_normalization.py +471 -0
- github2gerrit/core.py +828 -251
- github2gerrit/duplicate_detection.py +1 -67
- github2gerrit/external_api.py +518 -0
- github2gerrit/gerrit_rest.py +298 -0
- github2gerrit/gerrit_urls.py +149 -52
- github2gerrit/github_api.py +12 -79
- github2gerrit/gitutils.py +216 -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.7.dist-info/METADATA +798 -0
- github2gerrit-0.1.7.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.7.dist-info}/WHEEL +0 -0
- {github2gerrit-0.1.6.dist-info → github2gerrit-0.1.7.dist-info}/entry_points.txt +0 -0
- {github2gerrit-0.1.6.dist-info → github2gerrit-0.1.7.dist-info}/licenses/LICENSE +0 -0
- {github2gerrit-0.1.6.dist-info → github2gerrit-0.1.7.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
@@ -20,20 +20,8 @@ from collections.abc import Mapping
|
|
20
20
|
from collections.abc import Sequence
|
21
21
|
from dataclasses import dataclass
|
22
22
|
from pathlib import Path
|
23
|
-
from typing import Any
|
24
23
|
|
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)
|
24
|
+
from .utils import log_exception_conditionally
|
37
25
|
|
38
26
|
|
39
27
|
__all__ = [
|
@@ -202,7 +190,7 @@ def run_cmd(
|
|
202
190
|
)
|
203
191
|
except subprocess.TimeoutExpired as exc:
|
204
192
|
msg = f"Command timed out: {cmd!r}"
|
205
|
-
|
193
|
+
log_exception_conditionally(log, msg)
|
206
194
|
# TimeoutExpired carries 'output' and 'stderr' attributes,
|
207
195
|
# which may be bytes depending on invocation context.
|
208
196
|
out = getattr(exc, "output", None)
|
@@ -216,7 +204,7 @@ def run_cmd(
|
|
216
204
|
) from exc
|
217
205
|
except OSError as exc:
|
218
206
|
msg = f"Failed to execute command: {cmd!r} ({exc})"
|
219
|
-
|
207
|
+
log_exception_conditionally(log, msg)
|
220
208
|
raise CommandError(msg, cmd=cmd) from exc
|
221
209
|
|
222
210
|
result = CommandResult(
|
@@ -245,32 +233,48 @@ def run_cmd(
|
|
245
233
|
if result.stdout:
|
246
234
|
log.debug("stdout: %s", mask_text(result.stdout, masks))
|
247
235
|
if result.stderr:
|
248
|
-
|
236
|
+
# Filter out git init default branch hints to keep logs clean
|
237
|
+
stderr_lines = result.stderr.splitlines()
|
238
|
+
filtered_lines = []
|
239
|
+
skip_hint = False
|
240
|
+
for line in stderr_lines:
|
241
|
+
if "hint: Using '" in line and "as the name for the initial branch" in line:
|
242
|
+
skip_hint = True
|
243
|
+
elif (skip_hint and line.startswith("hint:")) or (skip_hint and not line.strip()):
|
244
|
+
continue
|
245
|
+
else:
|
246
|
+
skip_hint = False
|
247
|
+
filtered_lines.append(line)
|
248
|
+
|
249
|
+
if filtered_lines:
|
250
|
+
filtered_stderr = "\n".join(filtered_lines)
|
251
|
+
log.debug("stderr: %s", mask_text(filtered_stderr, masks))
|
249
252
|
|
250
253
|
return result
|
251
254
|
|
252
255
|
|
253
|
-
def non_interactive_env() -> dict[str, str]:
|
256
|
+
def non_interactive_env(include_git_ssh_command: bool = True) -> dict[str, str]:
|
254
257
|
"""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
|
-
|
258
|
+
agents/keychains.
|
259
|
+
|
260
|
+
Args:
|
261
|
+
include_git_ssh_command: Whether to include a default GIT_SSH_COMMAND.
|
262
|
+
Set to False when the caller will provide their own.
|
263
|
+
|
264
|
+
Returns:
|
265
|
+
Dictionary of environment variables for non-interactive operations.
|
266
|
+
"""
|
267
|
+
# Import here to avoid circular imports
|
268
|
+
from .ssh_common import build_non_interactive_ssh_env
|
269
|
+
|
270
|
+
env = build_non_interactive_ssh_env()
|
271
|
+
|
272
|
+
if include_git_ssh_command:
|
273
|
+
from .ssh_common import build_git_ssh_command
|
274
|
+
|
275
|
+
env["GIT_SSH_COMMAND"] = build_git_ssh_command()
|
276
|
+
|
277
|
+
return env
|
274
278
|
|
275
279
|
|
276
280
|
def run_cmd_with_retries(
|
@@ -474,16 +478,78 @@ def git_commit_amend(
|
|
474
478
|
|
475
479
|
If message is provided, it takes precedence over message_file.
|
476
480
|
"""
|
481
|
+
# Write message to a temp file to avoid shell-escaping issues
|
482
|
+
tmp_path: Path | None = None
|
483
|
+
if message is not None:
|
484
|
+
import tempfile as _tempfile
|
485
|
+
|
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
|
+
import os
|
497
|
+
import re
|
498
|
+
|
499
|
+
# Resolve committer email (prefer repo-local; fallback to global/env)
|
500
|
+
committer_email = os.getenv("GIT_COMMITTER_EMAIL", "").strip()
|
501
|
+
if not committer_email:
|
502
|
+
try:
|
503
|
+
res = run_cmd(["git", "config", "--get", "user.email"], cwd=cwd)
|
504
|
+
committer_email = (res.stdout or "").strip()
|
505
|
+
except Exception:
|
506
|
+
committer_email = ""
|
507
|
+
if not committer_email:
|
508
|
+
try:
|
509
|
+
ge = git_config_get("user.email", global_=True)
|
510
|
+
if ge:
|
511
|
+
committer_email = ge.strip()
|
512
|
+
except Exception:
|
513
|
+
committer_email = ""
|
514
|
+
|
515
|
+
def _has_committer_signoff(text: str) -> bool:
|
516
|
+
for ln in text.splitlines():
|
517
|
+
if ln.lower().startswith("signed-off-by:"):
|
518
|
+
m = re.search(r"<([^>]+)>", ln)
|
519
|
+
if m and committer_email and m.group(1).strip().lower() == committer_email.lower():
|
520
|
+
return True
|
521
|
+
return False
|
522
|
+
|
523
|
+
msg_text: str | None = None
|
524
|
+
if message_file is not None:
|
525
|
+
try:
|
526
|
+
msg_text = Path(message_file).read_text(encoding="utf-8")
|
527
|
+
except Exception:
|
528
|
+
msg_text = None
|
529
|
+
|
530
|
+
if msg_text is not None:
|
531
|
+
if committer_email and _has_committer_signoff(msg_text):
|
532
|
+
effective_signoff = False
|
533
|
+
else:
|
534
|
+
# No explicit message provided; check current commit body
|
535
|
+
try:
|
536
|
+
body = git_show("HEAD", cwd=cwd, fmt="%B")
|
537
|
+
if committer_email and _has_committer_signoff(body):
|
538
|
+
effective_signoff = False
|
539
|
+
except GitError:
|
540
|
+
pass
|
541
|
+
except Exception:
|
542
|
+
# Best effort only; default to requested signoff
|
543
|
+
effective_signoff = bool(signoff)
|
544
|
+
|
477
545
|
args: list[str] = ["commit", "--amend"]
|
478
546
|
if no_edit and not message and not message_file:
|
479
547
|
args.append("--no-edit")
|
480
|
-
if
|
548
|
+
if effective_signoff:
|
481
549
|
args.append("-s")
|
482
550
|
if author:
|
483
551
|
args.extend(["--author", author])
|
484
|
-
if
|
485
|
-
args.extend(["-m", message])
|
486
|
-
elif message_file:
|
552
|
+
if message_file:
|
487
553
|
args.extend(["-F", str(message_file)])
|
488
554
|
|
489
555
|
try:
|
@@ -496,6 +562,12 @@ def git_commit_amend(
|
|
496
562
|
stdout=exc.stdout,
|
497
563
|
stderr=exc.stderr,
|
498
564
|
) from exc
|
565
|
+
finally:
|
566
|
+
if tmp_path is not None:
|
567
|
+
from contextlib import suppress
|
568
|
+
|
569
|
+
with suppress(Exception):
|
570
|
+
tmp_path.unlink(missing_ok=True)
|
499
571
|
|
500
572
|
|
501
573
|
def git_commit_new(
|
@@ -511,17 +583,67 @@ def git_commit_new(
|
|
511
583
|
if not message and not message_file:
|
512
584
|
raise ValueError(_MSG_COMMIT_NO_MESSAGE)
|
513
585
|
|
586
|
+
# Write message to a temp file to avoid shell-escaping issues
|
587
|
+
tmp_path: Path | None = None
|
588
|
+
if message is not None:
|
589
|
+
import tempfile as _tempfile
|
590
|
+
|
591
|
+
with _tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as _tf:
|
592
|
+
_tf.write(message)
|
593
|
+
_tf.flush()
|
594
|
+
tmp_path = Path(_tf.name)
|
595
|
+
message_file = tmp_path
|
596
|
+
message = None
|
597
|
+
|
598
|
+
# Determine whether to add -s; only suppress if message already has a sign-off for current committer
|
599
|
+
effective_signoff = bool(signoff)
|
600
|
+
try:
|
601
|
+
import os
|
602
|
+
import re
|
603
|
+
|
604
|
+
# Resolve committer email (prefer repo-local; fallback to global/env)
|
605
|
+
committer_email = os.getenv("GIT_COMMITTER_EMAIL", "").strip()
|
606
|
+
if not committer_email:
|
607
|
+
try:
|
608
|
+
res = run_cmd(["git", "config", "--get", "user.email"], cwd=cwd)
|
609
|
+
committer_email = (res.stdout or "").strip()
|
610
|
+
except Exception:
|
611
|
+
committer_email = ""
|
612
|
+
if not committer_email:
|
613
|
+
try:
|
614
|
+
ge = git_config_get("user.email", global_=True)
|
615
|
+
if ge:
|
616
|
+
committer_email = ge.strip()
|
617
|
+
except Exception:
|
618
|
+
committer_email = ""
|
619
|
+
|
620
|
+
def _has_committer_signoff(text: str) -> bool:
|
621
|
+
for ln in text.splitlines():
|
622
|
+
if ln.lower().startswith("signed-off-by:"):
|
623
|
+
m = re.search(r"<([^>]+)>", ln)
|
624
|
+
if m and committer_email and m.group(1).strip().lower() == committer_email.lower():
|
625
|
+
return True
|
626
|
+
return False
|
627
|
+
|
628
|
+
if message_file is not None:
|
629
|
+
try:
|
630
|
+
msg_text = Path(message_file).read_text(encoding="utf-8")
|
631
|
+
except Exception:
|
632
|
+
msg_text = None
|
633
|
+
if msg_text and _has_committer_signoff(msg_text):
|
634
|
+
effective_signoff = False
|
635
|
+
except Exception:
|
636
|
+
effective_signoff = bool(signoff)
|
637
|
+
|
514
638
|
args: list[str] = ["commit"]
|
515
|
-
if
|
639
|
+
if effective_signoff:
|
516
640
|
args.append("-s")
|
517
641
|
if author:
|
518
642
|
args.extend(["--author", author])
|
519
643
|
if allow_empty:
|
520
644
|
args.append("--allow-empty")
|
521
645
|
|
522
|
-
if
|
523
|
-
args.extend(["-m", message])
|
524
|
-
else:
|
646
|
+
if message_file:
|
525
647
|
args.extend(["-F", str(message_file)])
|
526
648
|
|
527
649
|
try:
|
@@ -534,6 +656,12 @@ def git_commit_new(
|
|
534
656
|
stdout=exc.stdout,
|
535
657
|
stderr=exc.stderr,
|
536
658
|
) from exc
|
659
|
+
finally:
|
660
|
+
if tmp_path is not None:
|
661
|
+
from contextlib import suppress
|
662
|
+
|
663
|
+
with suppress(Exception):
|
664
|
+
tmp_path.unlink(missing_ok=True)
|
537
665
|
|
538
666
|
|
539
667
|
def git_show(
|
@@ -561,21 +689,60 @@ def git_show(
|
|
561
689
|
|
562
690
|
|
563
691
|
def _parse_trailers(text: str) -> dict[str, list[str]]:
|
564
|
-
"""Parse trailers from a commit message
|
692
|
+
"""Parse trailers from a commit message footer only.
|
565
693
|
|
566
|
-
|
694
|
+
Git trailers are key-value pairs that appear at the end of commit messages,
|
695
|
+
separated from the body by a blank line. This function only parses trailers
|
696
|
+
from the actual footer section to avoid false positives from the message body.
|
567
697
|
"""
|
568
698
|
trailers: dict[str, list[str]] = {}
|
569
|
-
|
570
|
-
|
699
|
+
lines = text.splitlines()
|
700
|
+
|
701
|
+
# Find the start of the trailer block by working backwards
|
702
|
+
# Trailers must be at the end, separated by a blank line from the body
|
703
|
+
trailer_start = len(lines)
|
704
|
+
in_trailer_block = True
|
705
|
+
|
706
|
+
for i in range(len(lines) - 1, -1, -1):
|
707
|
+
line = lines[i].strip()
|
708
|
+
|
709
|
+
if not line and in_trailer_block:
|
710
|
+
# Found blank line, trailers end here
|
711
|
+
trailer_start = i + 1
|
712
|
+
break
|
713
|
+
elif not line:
|
714
|
+
# Blank line in middle, not in trailer block anymore
|
715
|
+
in_trailer_block = False
|
716
|
+
trailer_start = len(lines)
|
717
|
+
elif in_trailer_block and ":" in line:
|
718
|
+
# Potential trailer line
|
719
|
+
key, val = line.split(":", 1)
|
720
|
+
k = key.strip()
|
721
|
+
v = val.strip()
|
722
|
+
if k and v and not k.startswith(" ") and not k.startswith("\t"):
|
723
|
+
# Valid trailer format
|
724
|
+
continue
|
725
|
+
else:
|
726
|
+
# Invalid trailer format, stop looking
|
727
|
+
in_trailer_block = False
|
728
|
+
trailer_start = len(lines)
|
729
|
+
elif in_trailer_block:
|
730
|
+
# Non-trailer line in what we thought was trailer block
|
731
|
+
in_trailer_block = False
|
732
|
+
trailer_start = len(lines)
|
733
|
+
|
734
|
+
# Parse only the trailer section
|
735
|
+
for i in range(trailer_start, len(lines)):
|
736
|
+
line = lines[i].strip()
|
571
737
|
if not line or ":" not in line:
|
572
738
|
continue
|
573
739
|
key, val = line.split(":", 1)
|
574
740
|
k = key.strip()
|
575
741
|
v = val.strip()
|
576
|
-
if not k or not v:
|
742
|
+
if not k or not v or k.startswith((" ", "\t")):
|
577
743
|
continue
|
578
744
|
trailers.setdefault(k, []).append(v)
|
745
|
+
|
579
746
|
return trailers
|
580
747
|
|
581
748
|
|
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
|
|