github2gerrit 0.1.5__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.
@@ -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"
@@ -44,9 +44,7 @@ class RateLimitExceededExceptionType(GithubExceptionType):
44
44
  pass
45
45
 
46
46
 
47
- def _load_github_classes() -> tuple[
48
- Any | None, type[BaseException], type[BaseException]
49
- ]:
47
+ def _load_github_classes() -> tuple[Any | None, type[BaseException], type[BaseException]]:
50
48
  try:
51
49
  exc_mod = import_module("github.GithubException")
52
50
  ge = exc_mod.GithubException
@@ -113,7 +111,6 @@ __all__ = [
113
111
  "get_recent_change_ids_from_comments",
114
112
  "get_repo_from_env",
115
113
  "iter_open_pulls",
116
- "time",
117
114
  ]
118
115
 
119
116
  log = logging.getLogger("github2gerrit.github_api")
@@ -126,75 +123,7 @@ def _getenv_str(name: str) -> str:
126
123
  return val.strip()
127
124
 
128
125
 
129
- def _backoff_delay(attempt: int, base: float = 0.5, cap: float = 6.0) -> float:
130
- # Exponential backoff with jitter; cap prevents unbounded waits.
131
- delay: float = float(min(base * (2 ** max(0, attempt - 1)), cap))
132
- # Using random.uniform for jitter is appropriate here - we only need
133
- # pseudorandom distribution to avoid thundering herd, not crypto security
134
- jitter: float = float(random.uniform(0.0, delay / 2.0)) # noqa: S311
135
- return float(delay + jitter)
136
-
137
-
138
- def _should_retry(exc: BaseException) -> bool:
139
- # Retry on common transient conditions:
140
- # - RateLimitExceededException
141
- # - GithubException with 5xx codes
142
- # - GithubException with 403 and rate limit hints
143
- if isinstance(exc, _RATE_LIMIT_EXC | RateLimitExceededExceptionType):
144
- return True
145
- if isinstance(exc, _GITHUB_EXCEPTION | GithubExceptionType):
146
- status = getattr(exc, "status", None)
147
- if isinstance(status, int) and 500 <= status <= 599:
148
- return True
149
- data = getattr(exc, "data", "")
150
- if status == 403 and isinstance(data, str | bytes):
151
- try:
152
- text = data.decode("utf-8") if isinstance(data, bytes) else data
153
- except Exception:
154
- text = str(data)
155
- if "rate limit" in text.lower():
156
- return True
157
- return False
158
-
159
-
160
- def _retry_on_github(
161
- attempts: int = 5,
162
- ) -> Callable[[Callable[..., _T]], Callable[..., _T]]:
163
- def decorator(func: Callable[..., _T]) -> Callable[..., _T]:
164
- def wrapper(*args: Any, **kwargs: Any) -> _T:
165
- last_exc: BaseException | None = None
166
- for attempt in range(1, attempts + 1):
167
- try:
168
- return func(*args, **kwargs)
169
- except BaseException as exc:
170
- last_exc = exc
171
- if not _should_retry(exc) or attempt == attempts:
172
- log.debug(
173
- "GitHub call failed (no retry) at attempt %d: %s",
174
- attempt,
175
- exc,
176
- )
177
- raise
178
- delay = _backoff_delay(attempt)
179
- log.warning(
180
- "GitHub call failed (attempt %d): %s; retrying in "
181
- "%.2fs",
182
- attempt,
183
- exc,
184
- delay,
185
- )
186
- time.sleep(delay)
187
- # Should not reach here, but raise if it does.
188
- if last_exc is None:
189
- raise RuntimeError("unreachable")
190
- raise last_exc
191
-
192
- return wrapper
193
-
194
- return decorator
195
-
196
-
197
- @_retry_on_github()
126
+ @external_api_call(ApiType.GITHUB, "build_client")
198
127
  def build_client(token: str | None = None) -> GhClient:
199
128
  """Construct a PyGithub client from a token or environment.
200
129
 
@@ -212,7 +141,8 @@ def build_client(token: str | None = None) -> GhClient:
212
141
  base_url = _getenv_str("GITHUB_API_URL")
213
142
  if not base_url:
214
143
  server_url = _getenv_str("GITHUB_SERVER_URL")
215
- if server_url:
144
+ # Only synthesize API URL for non-github.com servers (GHE instances)
145
+ if server_url and server_url.rstrip("/") != "https://github.com":
216
146
  base_url = server_url.rstrip("/") + "/api/v3"
217
147
  client_any: Any
218
148
  try:
@@ -221,29 +151,23 @@ def build_client(token: str | None = None) -> GhClient:
221
151
  if auth_factory is not None and hasattr(auth_factory, "Token"):
222
152
  auth_obj = auth_factory.Token(tok)
223
153
  if base_url:
224
- client_any = Github(
225
- auth=auth_obj, per_page=100, base_url=base_url
226
- )
154
+ client_any = Github(auth=auth_obj, per_page=100, base_url=base_url)
227
155
  else:
228
156
  client_any = Github(auth=auth_obj, per_page=100)
229
157
  else:
230
158
  if base_url:
231
- client_any = Github(
232
- login_or_token=tok, per_page=100, base_url=base_url
233
- )
159
+ client_any = Github(login_or_token=tok, per_page=100, base_url=base_url)
234
160
  else:
235
161
  client_any = Github(login_or_token=tok, per_page=100)
236
162
  except Exception:
237
163
  if base_url:
238
- client_any = Github(
239
- login_or_token=tok, per_page=100, base_url=base_url
240
- )
164
+ client_any = Github(login_or_token=tok, per_page=100, base_url=base_url)
241
165
  else:
242
166
  client_any = Github(login_or_token=tok, per_page=100)
243
167
  return cast(GhClient, client_any)
244
168
 
245
169
 
246
- @_retry_on_github()
170
+ @external_api_call(ApiType.GITHUB, "get_repo_from_env")
247
171
  def get_repo_from_env(client: GhClient) -> GhRepository:
248
172
  """Return the repository object based on GITHUB_REPOSITORY."""
249
173
  full = _getenv_str("GITHUB_REPOSITORY")
@@ -253,7 +177,7 @@ def get_repo_from_env(client: GhClient) -> GhRepository:
253
177
  return repo
254
178
 
255
179
 
256
- @_retry_on_github()
180
+ @external_api_call(ApiType.GITHUB, "get_pull")
257
181
  def get_pull(repo: GhRepository, number: int) -> GhPullRequest:
258
182
  """Fetch a pull request by number."""
259
183
  pr = repo.get_pull(number)
@@ -275,14 +199,14 @@ def get_pr_title_body(pr: GhPullRequest) -> tuple[str, str]:
275
199
  _CHANGE_ID_RE: re.Pattern[str] = re.compile(r"Change-Id:\s*([A-Za-z0-9._-]+)")
276
200
 
277
201
 
278
- @_retry_on_github()
202
+ @external_api_call(ApiType.GITHUB, "get_pr_comments")
279
203
  def _get_issue(pr: GhPullRequest) -> GhIssue:
280
204
  """Return the issue object corresponding to a pull request."""
281
205
  issue = pr.as_issue()
282
206
  return issue
283
207
 
284
208
 
285
- @_retry_on_github()
209
+ @external_api_call(ApiType.GITHUB, "get_issue_from_pr")
286
210
  def get_recent_change_ids_from_comments(
287
211
  pr: GhPullRequest,
288
212
  *,
@@ -317,7 +241,7 @@ def get_recent_change_ids_from_comments(
317
241
  return found
318
242
 
319
243
 
320
- @_retry_on_github()
244
+ @external_api_call(ApiType.GITHUB, "create_pr_comment")
321
245
  def create_pr_comment(pr: GhPullRequest, body: str) -> None:
322
246
  """Create a new comment on the pull request."""
323
247
  if not body.strip():
@@ -326,14 +250,12 @@ def create_pr_comment(pr: GhPullRequest, body: str) -> None:
326
250
  issue.create_comment(body)
327
251
 
328
252
 
329
- @_retry_on_github()
253
+ @external_api_call(ApiType.GITHUB, "close_pr")
330
254
  def close_pr(pr: GhPullRequest, *, comment: str | None = None) -> None:
331
255
  """Close a pull request, optionally posting a comment first."""
332
256
  if comment and comment.strip():
333
257
  try:
334
258
  create_pr_comment(pr, comment)
335
259
  except Exception as exc:
336
- log.warning(
337
- "Failed to add close comment to PR #%s: %s", pr.number, exc
338
- )
260
+ log.warning("Failed to add close comment to PR #%s: %s", pr.number, exc)
339
261
  pr.edit(state="closed")
github2gerrit/gitutils.py CHANGED
@@ -20,22 +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(
32
- logger: logging.Logger, message: str, *args: Any
33
- ) -> None:
34
- """Log exception with traceback only if verbose mode is enabled."""
35
- if _is_verbose_mode():
36
- logger.exception(message, *args)
37
- else:
38
- logger.error(message, *args)
24
+ from .utils import log_exception_conditionally
39
25
 
40
26
 
41
27
  __all__ = [
@@ -67,10 +53,7 @@ if not log.handlers:
67
53
  # Provide a minimal default if the app has not configured logging.
68
54
  level_name = os.getenv("G2G_LOG_LEVEL", "INFO").upper()
69
55
  level = getattr(logging, level_name, logging.INFO)
70
- fmt = (
71
- "%(asctime)s %(levelname)-8s %(name)s "
72
- "%(filename)s:%(lineno)d | %(message)s"
73
- )
56
+ fmt = "%(asctime)s %(levelname)-8s %(name)s %(filename)s:%(lineno)d | %(message)s"
74
57
  logging.basicConfig(level=level, format=fmt)
75
58
 
76
59
 
@@ -189,6 +172,7 @@ def run_cmd(
189
172
  - Raises CommandError on failure when check=True.
190
173
  """
191
174
  masks = list(masks or [])
175
+ env = env or non_interactive_env()
192
176
  env_full = _merge_env(None, env)
193
177
 
194
178
  log.debug("Executing: %s", _format_cmd_for_log(cmd, masks))
@@ -206,7 +190,7 @@ def run_cmd(
206
190
  )
207
191
  except subprocess.TimeoutExpired as exc:
208
192
  msg = f"Command timed out: {cmd!r}"
209
- _log_exception_conditionally(log, msg)
193
+ log_exception_conditionally(log, msg)
210
194
  # TimeoutExpired carries 'output' and 'stderr' attributes,
211
195
  # which may be bytes depending on invocation context.
212
196
  out = getattr(exc, "output", None)
@@ -220,7 +204,7 @@ def run_cmd(
220
204
  ) from exc
221
205
  except OSError as exc:
222
206
  msg = f"Failed to execute command: {cmd!r} ({exc})"
223
- _log_exception_conditionally(log, msg)
207
+ log_exception_conditionally(log, msg)
224
208
  raise CommandError(msg, cmd=cmd) from exc
225
209
 
226
210
  result = CommandResult(
@@ -249,11 +233,50 @@ def run_cmd(
249
233
  if result.stdout:
250
234
  log.debug("stdout: %s", mask_text(result.stdout, masks))
251
235
  if result.stderr:
252
- log.debug("stderr: %s", mask_text(result.stderr, masks))
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))
253
252
 
254
253
  return result
255
254
 
256
255
 
256
+ def non_interactive_env(include_git_ssh_command: bool = True) -> dict[str, str]:
257
+ """Return a non-interactive SSH/Git environment to bypass local
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
278
+
279
+
257
280
  def run_cmd_with_retries(
258
281
  cmd: Sequence[str],
259
282
  *,
@@ -271,6 +294,7 @@ def run_cmd_with_retries(
271
294
  The default retry predicate considers common transient git errors.
272
295
  """
273
296
  masks = list(masks or [])
297
+ env = env or non_interactive_env()
274
298
 
275
299
  def _default_retry_on(res: CommandResult) -> bool:
276
300
  return res.returncode != 0 and _is_transient_git_error(res.stderr)
@@ -308,8 +332,7 @@ def run_cmd_with_retries(
308
332
  if predicate(res):
309
333
  delay = _backoff_delay(attempt)
310
334
  log.warning(
311
- "Retrying (attempt %d) after transient error; delay %.1fs. "
312
- "cmd=%s",
335
+ "Retrying (attempt %d) after transient error; delay %.1fs. cmd=%s",
313
336
  attempt,
314
337
  delay,
315
338
  _format_cmd_for_log(cmd, masks),
@@ -370,7 +393,7 @@ def git_quiet(
370
393
  return run_cmd(
371
394
  cmd,
372
395
  cwd=cwd,
373
- env=env,
396
+ env=env or non_interactive_env(),
374
397
  timeout=timeout,
375
398
  check=False,
376
399
  )
@@ -455,16 +478,78 @@ def git_commit_amend(
455
478
 
456
479
  If message is provided, it takes precedence over message_file.
457
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
+
458
545
  args: list[str] = ["commit", "--amend"]
459
546
  if no_edit and not message and not message_file:
460
547
  args.append("--no-edit")
461
- if signoff:
548
+ if effective_signoff:
462
549
  args.append("-s")
463
550
  if author:
464
551
  args.extend(["--author", author])
465
- if message:
466
- args.extend(["-m", message])
467
- elif message_file:
552
+ if message_file:
468
553
  args.extend(["-F", str(message_file)])
469
554
 
470
555
  try:
@@ -477,6 +562,12 @@ def git_commit_amend(
477
562
  stdout=exc.stdout,
478
563
  stderr=exc.stderr,
479
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)
480
571
 
481
572
 
482
573
  def git_commit_new(
@@ -492,17 +583,67 @@ def git_commit_new(
492
583
  if not message and not message_file:
493
584
  raise ValueError(_MSG_COMMIT_NO_MESSAGE)
494
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
+
495
638
  args: list[str] = ["commit"]
496
- if signoff:
639
+ if effective_signoff:
497
640
  args.append("-s")
498
641
  if author:
499
642
  args.extend(["--author", author])
500
643
  if allow_empty:
501
644
  args.append("--allow-empty")
502
645
 
503
- if message:
504
- args.extend(["-m", message])
505
- else:
646
+ if message_file:
506
647
  args.extend(["-F", str(message_file)])
507
648
 
508
649
  try:
@@ -515,6 +656,12 @@ def git_commit_new(
515
656
  stdout=exc.stdout,
516
657
  stderr=exc.stderr,
517
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)
518
665
 
519
666
 
520
667
  def git_show(
@@ -542,21 +689,60 @@ def git_show(
542
689
 
543
690
 
544
691
  def _parse_trailers(text: str) -> dict[str, list[str]]:
545
- """Parse trailers from a commit message body.
692
+ """Parse trailers from a commit message footer only.
546
693
 
547
- Expects lines like 'Key: Value'. Multiple values per key are supported.
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.
548
697
  """
549
698
  trailers: dict[str, list[str]] = {}
550
- for raw in text.splitlines():
551
- line = raw.strip()
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()
552
737
  if not line or ":" not in line:
553
738
  continue
554
739
  key, val = line.split(":", 1)
555
740
  k = key.strip()
556
741
  v = val.strip()
557
- if not k or not v:
742
+ if not k or not v or k.startswith((" ", "\t")):
558
743
  continue
559
744
  trailers.setdefault(k, []).append(v)
745
+
560
746
  return trailers
561
747
 
562
748
 
@@ -618,9 +804,7 @@ def git_config_get_all(
618
804
  try:
619
805
  res = git_quiet(args, cwd=None)
620
806
  if res.returncode == 0:
621
- values = [
622
- ln.strip() for ln in res.stdout.splitlines() if ln.strip()
623
- ]
807
+ values = [ln.strip() for ln in res.stdout.splitlines() if ln.strip()]
624
808
  return values
625
809
  else:
626
810
  return []
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,8 @@ class Inputs:
52
53
  gerrit_project: str
53
54
  issue_id: str
54
55
  allow_duplicates: bool
56
+ ci_testing: bool
57
+ duplicates_filter: str = "open"
55
58
 
56
59
 
57
60
  @dataclass(frozen=True)