github2gerrit 0.1.9__py3-none-any.whl → 0.1.11__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/core.py CHANGED
@@ -26,6 +26,7 @@
26
26
 
27
27
  from __future__ import annotations
28
28
 
29
+ import json
29
30
  import logging
30
31
  import os
31
32
  import re
@@ -59,9 +60,13 @@ from .gitutils import git_config
59
60
  from .gitutils import git_last_commit_trailers
60
61
  from .gitutils import git_show
61
62
  from .gitutils import run_cmd
63
+ from .mapping_comment import ChangeIdMapping
64
+ from .mapping_comment import serialize_mapping_comment
62
65
  from .models import GitHubContext
63
66
  from .models import Inputs
64
67
  from .pr_content_filter import filter_pr_body
68
+ from .reconcile_matcher import LocalCommit
69
+ from .reconcile_matcher import create_local_commit
65
70
  from .ssh_common import merge_known_hosts_content
66
71
  from .utils import env_bool
67
72
  from .utils import log_exception_conditionally
@@ -114,14 +119,15 @@ _MSG_PYGERRIT2_AUTH_MISSING = "pygerrit2 auth missing"
114
119
 
115
120
  def _insert_issue_id_into_commit_message(message: str, issue_id: str) -> str:
116
121
  """
117
- Insert Issue ID into commit message after the first line.
122
+ Insert Issue ID into commit message footer above Change-Id.
118
123
 
119
124
  Format:
120
125
  Title line
121
126
 
122
- Issue-ID: CIMAN-33
127
+ Body content...
123
128
 
124
- Rest of body...
129
+ Issue-ID: CIMAN-33
130
+ Change-Id: I1234567890123456789012345678901234567890
125
131
  """
126
132
  if not issue_id.strip():
127
133
  return message
@@ -132,30 +138,70 @@ def _insert_issue_id_into_commit_message(message: str, issue_id: str) -> str:
132
138
  raise ValueError(_MSG_ISSUE_ID_MULTILINE)
133
139
 
134
140
  # Format as proper Issue-ID trailer
135
- issue_line = cleaned_issue_id if cleaned_issue_id.startswith("Issue-ID:") else f"Issue-ID: {cleaned_issue_id}"
141
+ issue_line = (
142
+ cleaned_issue_id
143
+ if cleaned_issue_id.startswith("Issue-ID:")
144
+ else f"Issue-ID: {cleaned_issue_id}"
145
+ )
136
146
 
147
+ # Parse the message to find trailers
137
148
  lines = message.splitlines()
138
149
  if not lines:
139
150
  return message
140
151
 
141
- # Take the first line as title
142
- title = lines[0]
152
+ # Find the start of trailers (lines like "Key: value" at the end)
153
+ trailer_start = len(lines)
154
+ for i in range(len(lines) - 1, -1, -1):
155
+ line = lines[i].strip()
156
+ if not line:
157
+ continue
158
+ # Common trailer patterns
159
+ if any(
160
+ line.startswith(prefix)
161
+ for prefix in [
162
+ "Change-Id:",
163
+ "Signed-off-by:",
164
+ "Co-authored-by:",
165
+ "GitHub-",
166
+ ]
167
+ ):
168
+ trailer_start = i
169
+ else:
170
+ break
171
+
172
+ # Insert Issue-ID at the beginning of trailers
173
+ if trailer_start < len(lines):
174
+ # There are existing trailers
175
+ lines.insert(trailer_start, issue_line)
176
+ else:
177
+ # No existing trailers, add at the end
178
+ if lines and lines[-1].strip():
179
+ lines.append("") # Empty line before trailer
180
+ lines.append(issue_line)
181
+
182
+ return "\n".join(lines)
183
+
184
+
185
+ def _clean_ellipses_from_message(message: str) -> str:
186
+ """Clean ellipses from commit message content."""
187
+ if not message:
188
+ return message
143
189
 
144
- # Build new message with Issue ID on third line
145
- new_lines = [title, "", issue_line]
190
+ lines = message.splitlines()
191
+ cleaned_lines = []
146
192
 
147
- # Add rest of the body if it exists
148
- if len(lines) > 1:
149
- # Skip empty lines immediately after title to avoid double spacing
150
- body_start = 1
151
- while body_start < len(lines) and not lines[body_start].strip():
152
- body_start += 1
193
+ for line in lines:
194
+ # Skip lines that are just "..." or whitespace + "..."
195
+ stripped = line.strip()
196
+ if stripped == "..." or stripped == "…":
197
+ continue
153
198
 
154
- if body_start < len(lines):
155
- new_lines.append("") # Empty line before body
156
- new_lines.extend(lines[body_start:])
199
+ # Remove trailing ellipses from lines
200
+ cleaned_line = re.sub(r"\s*\.{3,}\s*$", "", line)
201
+ cleaned_line = re.sub(r"\s*…\s*$", "", cleaned_line)
202
+ cleaned_lines.append(cleaned_line)
157
203
 
158
- return "\n".join(new_lines)
204
+ return "\n".join(cleaned_lines)
159
205
 
160
206
 
161
207
  # ---------------------
@@ -186,7 +232,9 @@ def _is_valid_change_id(value: str) -> bool:
186
232
  return (
187
233
  value.startswith("I")
188
234
  and 10 <= len(value) <= 40
189
- and not re.fullmatch(r"I[0-9a-fA-F]+", value) # Exclude hex-like patterns
235
+ and not re.fullmatch(
236
+ r"I[0-9a-fA-F]+", value
237
+ ) # Exclude hex-like patterns
190
238
  and bool(re.fullmatch(r"I[A-Za-z0-9._-]+", value))
191
239
  )
192
240
 
@@ -213,6 +261,12 @@ class PreparedChange:
213
261
  # The commit shas created/pushed to Gerrit. May be empty until queried.
214
262
  commit_shas: list[str]
215
263
 
264
+ def all_change_ids(self) -> list[str]:
265
+ """
266
+ Return all Change-Ids (copy) for post-push comment emission.
267
+ """
268
+ return list(self.change_ids)
269
+
216
270
 
217
271
  @dataclass(frozen=True)
218
272
  class SubmissionResult:
@@ -240,6 +294,409 @@ class Orchestrator:
240
294
  - Comment on the PR and optionally close it.
241
295
  """
242
296
 
297
+ # Phase 1 helper: build deterministic PR metadata trailers
298
+ # Phase 3 introduces reconciliation helpers below for reusing prior
299
+ # Change-Ids
300
+ def _build_pr_metadata_trailers(self, gh: GitHubContext) -> list[str]:
301
+ """
302
+ Build GitHub PR metadata trailers (GitHub-PR, GitHub-Hash).
303
+
304
+ Always deterministic:
305
+ - GitHub-PR: full PR URL
306
+ - GitHub-Hash: stable hash derived from server/repo/pr_number
307
+
308
+ Returns:
309
+ List of trailer lines (without preceding newlines).
310
+ """
311
+ trailers: list[str] = []
312
+ try:
313
+ pr_num = gh.pr_number
314
+ except Exception:
315
+ pr_num = None
316
+ if not pr_num:
317
+ return trailers
318
+ pr_url = f"{gh.server_url}/{gh.repository}/pull/{pr_num}"
319
+ trailers.append(f"GitHub-PR: {pr_url}")
320
+ try:
321
+ from .duplicate_detection import DuplicateDetector
322
+
323
+ gh_hash = DuplicateDetector._generate_github_change_hash(gh)
324
+ trailers.append(f"GitHub-Hash: {gh_hash}")
325
+ except Exception as exc:
326
+ log.debug("Failed to compute GitHub-Hash trailer: %s", exc)
327
+ return trailers
328
+
329
+ def _emit_change_id_map_comment(
330
+ self,
331
+ *,
332
+ gh_context: GitHubContext | None,
333
+ change_ids: list[str],
334
+ multi: bool,
335
+ topic: str,
336
+ replace_existing: bool = True,
337
+ ) -> None:
338
+ """
339
+ Emit or update a machine-parseable PR comment enumerating Change-Ids.
340
+
341
+ Args:
342
+ gh_context: GitHub context information
343
+ change_ids: Ordered list of Change-IDs to emit
344
+ multi: True for multi-commit mode, False for squash
345
+ topic: Gerrit topic name
346
+ replace_existing: If True, replace existing mapping comment
347
+ """
348
+ if not gh_context or not gh_context.pr_number:
349
+ return
350
+
351
+ # Sanitize and dedupe while preserving order
352
+ seen: set[str] = set()
353
+ ordered: list[str] = []
354
+ for cid in change_ids:
355
+ if cid and cid not in seen:
356
+ ordered.append(cid)
357
+ seen.add(cid)
358
+ if not ordered:
359
+ return
360
+
361
+ try:
362
+ from .github_api import build_client
363
+ from .github_api import create_pr_comment
364
+ from .github_api import get_pull
365
+ from .github_api import get_repo_from_env
366
+ except Exception as exc:
367
+ log.debug("GitHub API imports failed for comment emission: %s", exc)
368
+ return
369
+
370
+ try:
371
+ client = build_client()
372
+ repo = get_repo_from_env(client)
373
+ pr_obj = get_pull(repo, int(gh_context.pr_number))
374
+
375
+ # Build metadata
376
+ mode_str = "multi-commit" if multi else "squash"
377
+ meta = self._build_pr_metadata_trailers(gh_context)
378
+ gh_hash = ""
379
+ for trailer in meta:
380
+ if trailer.startswith("GitHub-Hash:"):
381
+ gh_hash = trailer.split(":", 1)[1].strip()
382
+ break
383
+
384
+ pr_url = (
385
+ f"{gh_context.server_url}/{gh_context.repository}/pull/"
386
+ f"{gh_context.pr_number}"
387
+ )
388
+
389
+ # Create mapping comment using utility
390
+ # Include reconciliation digest if available
391
+ digest = ""
392
+ plan_snapshot = getattr(self, "_reconciliation_plan", None)
393
+ if isinstance(plan_snapshot, dict):
394
+ digest = plan_snapshot.get("digest", "") or ""
395
+ comment_body = serialize_mapping_comment(
396
+ pr_url=pr_url,
397
+ mode=mode_str,
398
+ topic=topic,
399
+ change_ids=ordered,
400
+ github_hash=gh_hash,
401
+ digest=digest or None,
402
+ )
403
+
404
+ if replace_existing:
405
+ # Try to find and update existing mapping comment
406
+ issue = pr_obj.as_issue()
407
+ comments = list(issue.get_comments())
408
+
409
+ from .mapping_comment import find_mapping_comments
410
+ from .mapping_comment import update_mapping_comment_body
411
+
412
+ comment_indices = find_mapping_comments(
413
+ [c.body or "" for c in comments]
414
+ )
415
+ if comment_indices:
416
+ # Update the latest mapping comment
417
+ latest_idx = comment_indices[-1]
418
+ latest_comment = comments[latest_idx]
419
+
420
+ # Create new mapping for update
421
+ new_mapping = ChangeIdMapping(
422
+ pr_url=pr_url,
423
+ mode=mode_str,
424
+ topic=topic,
425
+ change_ids=ordered,
426
+ github_hash=gh_hash,
427
+ digest=digest,
428
+ )
429
+
430
+ body = latest_comment.body or ""
431
+ updated_body = update_mapping_comment_body(
432
+ body, new_mapping
433
+ )
434
+ latest_comment.edit(updated_body) # type: ignore[attr-defined]
435
+ log.debug(
436
+ "Updated existing mapping comment for PR #%s",
437
+ gh_context.pr_number,
438
+ )
439
+ return
440
+
441
+ # Create new comment if no existing one or replace_existing is False
442
+ create_pr_comment(pr_obj, comment_body)
443
+ log.debug(
444
+ "Emitted Change-Id map comment for PR #%s with %d id(s)",
445
+ gh_context.pr_number,
446
+ len(ordered),
447
+ )
448
+
449
+ except Exception as exc:
450
+ log.debug(
451
+ "Failed to emit Change-Id mapping comment for PR #%s: %s",
452
+ getattr(gh_context, "pr_number", "?"),
453
+ exc,
454
+ )
455
+
456
+ def _perform_robust_reconciliation(
457
+ self,
458
+ inputs: Inputs,
459
+ gh: GitHubContext,
460
+ gerrit: GerritInfo,
461
+ local_commits: list[LocalCommit],
462
+ ) -> list[str]:
463
+ """
464
+ Delegate to extracted reconciliation module.
465
+ Captures reconciliation plan for later verification (digest check).
466
+ """
467
+ if not local_commits:
468
+ self._reconciliation_plan = None
469
+ return []
470
+ # Lazy import to avoid cycles
471
+ from .orchestrator import perform_reconciliation
472
+ from .orchestrator.reconciliation import (
473
+ _compute_plan_digest as _plan_digest,
474
+ )
475
+
476
+ meta_trailers = self._build_pr_metadata_trailers(gh)
477
+ expected_pr_url = f"{gh.server_url}/{gh.repository}/pull/{gh.pr_number}"
478
+ expected_github_hash = ""
479
+ for trailer in meta_trailers:
480
+ if trailer.startswith("GitHub-Hash:"):
481
+ expected_github_hash = trailer.split(":", 1)[1].strip()
482
+ break
483
+ change_ids = perform_reconciliation(
484
+ inputs=inputs,
485
+ gh=gh,
486
+ gerrit=gerrit,
487
+ local_commits=local_commits,
488
+ expected_pr_url=expected_pr_url,
489
+ expected_github_hash=expected_github_hash or None,
490
+ )
491
+ # Store lightweight plan snapshot (only fields needed for verify)
492
+ try:
493
+ self._reconciliation_plan = {
494
+ "change_ids": change_ids,
495
+ "digest": _plan_digest(change_ids),
496
+ }
497
+ except Exception:
498
+ # Non-fatal; verification will gracefully degrade
499
+ self._reconciliation_plan = None
500
+ return change_ids
501
+
502
+ def _verify_reconciliation_digest(
503
+ self,
504
+ gh: GitHubContext,
505
+ gerrit: GerritInfo,
506
+ ) -> None:
507
+ """
508
+ Verification phase: re-query Gerrit by topic and compare digest.
509
+
510
+ - Rebuild observed Change-Id ordering aligned to original plan order
511
+ - Compute observed digest
512
+ - Emit VERIFICATION_SUMMARY log line
513
+ - If mismatch and VERIFY_DIGEST_STRICT=true raise OrchestratorError
514
+
515
+ Assumes self._reconciliation_plan set by reconciliation step:
516
+ {
517
+ "change_ids": [...],
518
+ "digest": "<sha12>"
519
+ }
520
+ """
521
+ plan = getattr(self, "_reconciliation_plan", None)
522
+ if not plan:
523
+ log.debug("No reconciliation plan present; skipping verification")
524
+ return
525
+ planned_ids = plan.get("change_ids") or []
526
+ planned_digest = plan.get("digest") or ""
527
+ if not planned_ids or not planned_digest:
528
+ log.debug("Incomplete plan data; skipping verification")
529
+ return
530
+
531
+ topic = (
532
+ f"GH-{gh.repository_owner}-{gh.repository.split('/')[-1]}-"
533
+ f"{gh.pr_number}"
534
+ )
535
+ try:
536
+ from .gerrit_query import query_changes_by_topic
537
+ from .gerrit_rest import GerritRestClient
538
+
539
+ client = GerritRestClient(
540
+ base_url=f"https://{gerrit.host}:{gerrit.port}",
541
+ auth=None,
542
+ )
543
+ # Re-query only NEW changes; merged ones are stable but keep for
544
+ # compatibility with earlier reuse logic if needed.
545
+ changes = query_changes_by_topic(
546
+ client,
547
+ topic,
548
+ statuses=["NEW", "MERGED"],
549
+ )
550
+ # Map change_id -> change for quick lookup
551
+ id_set = {c.change_id for c in changes}
552
+ # Preserve original ordering: filter plan list by those still
553
+ # present, then append any newly discovered (unexpected) ones.
554
+ observed_ordered: list[str] = [
555
+ cid for cid in planned_ids if cid in id_set
556
+ ]
557
+ extras = [cid for cid in id_set if cid not in observed_ordered]
558
+ if extras:
559
+ observed_ordered.extend(sorted(extras))
560
+ # Compute digest identical to reconciliation module logic
561
+ from .orchestrator.reconciliation import (
562
+ _compute_plan_digest as _plan_digest,
563
+ )
564
+
565
+ observed_digest = _plan_digest(observed_ordered)
566
+ match = observed_digest == planned_digest
567
+ summary = {
568
+ "planned_digest": planned_digest,
569
+ "observed_digest": observed_digest,
570
+ "match": match,
571
+ "planned_count": len(planned_ids),
572
+ "observed_count": len(observed_ordered),
573
+ "extras": extras,
574
+ }
575
+ log.info(
576
+ "VERIFICATION_SUMMARY json=%s",
577
+ json.dumps(summary, separators=(",", ":")),
578
+ )
579
+ if not match:
580
+ msg = (
581
+ "Reconciliation digest mismatch (planned != observed). "
582
+ "Enable stricter diagnostics or inspect Gerrit topic drift."
583
+ )
584
+ if os.getenv("VERIFY_DIGEST_STRICT", "true").lower() in (
585
+ "1",
586
+ "true",
587
+ "yes",
588
+ ):
589
+ self._raise_verification_error(msg)
590
+ log.warning(msg)
591
+ except OrchestratorError:
592
+ # Re-raise verification errors unchanged
593
+ raise
594
+ except Exception as exc:
595
+ log.debug("Verification phase failed (non-fatal): %s", exc)
596
+
597
+ def _raise_verification_error(self, msg: str) -> None:
598
+ """Helper to raise verification errors (extracted for TRY301
599
+ compliance)."""
600
+ raise OrchestratorError(msg)
601
+
602
+ def _extract_local_commits_for_reconciliation(
603
+ self,
604
+ inputs: Inputs,
605
+ gh: GitHubContext,
606
+ ) -> list[LocalCommit]:
607
+ """
608
+ Extract local commits as LocalCommit objects for reconciliation.
609
+
610
+ Args:
611
+ inputs: Configuration inputs
612
+ gh: GitHub context
613
+
614
+ Returns:
615
+ List of LocalCommit objects representing local commits to be
616
+ submitted
617
+ """
618
+ branch = self._resolve_target_branch()
619
+ base_ref = f"origin/{branch}"
620
+
621
+ # Get commit range: commits in HEAD not in base branch
622
+ try:
623
+ run_cmd(
624
+ ["git", "fetch", "origin", branch],
625
+ cwd=self.workspace,
626
+ env=self._ssh_env(),
627
+ )
628
+
629
+ revs = run_cmd(
630
+ ["git", "rev-list", "--reverse", f"{base_ref}..HEAD"],
631
+ cwd=self.workspace,
632
+ ).stdout
633
+
634
+ commit_list = [c.strip() for c in revs.splitlines() if c.strip()]
635
+
636
+ except (CommandError, GitError) as exc:
637
+ log.warning("Failed to extract commit range: %s", exc)
638
+ return []
639
+
640
+ if not commit_list:
641
+ log.debug("No commits found in range %s..HEAD", base_ref)
642
+ return []
643
+
644
+ local_commits = []
645
+
646
+ for index, commit_sha in enumerate(commit_list):
647
+ try:
648
+ # Get commit subject
649
+ subject = run_cmd(
650
+ ["git", "show", "-s", "--pretty=format:%s", commit_sha],
651
+ cwd=self.workspace,
652
+ ).stdout.strip()
653
+
654
+ # Get full commit message
655
+ commit_message = run_cmd(
656
+ ["git", "show", "-s", "--pretty=format:%B", commit_sha],
657
+ cwd=self.workspace,
658
+ ).stdout
659
+
660
+ # Get modified files
661
+ files_output = run_cmd(
662
+ [
663
+ "git",
664
+ "show",
665
+ "--name-only",
666
+ "--pretty=format:",
667
+ commit_sha,
668
+ ],
669
+ cwd=self.workspace,
670
+ ).stdout
671
+
672
+ files = [
673
+ f.strip() for f in files_output.splitlines() if f.strip()
674
+ ]
675
+
676
+ # Create LocalCommit object
677
+ local_commit = create_local_commit(
678
+ index=index,
679
+ sha=commit_sha,
680
+ subject=subject,
681
+ files=files,
682
+ commit_message=commit_message,
683
+ )
684
+
685
+ local_commits.append(local_commit)
686
+
687
+ except (CommandError, GitError) as exc:
688
+ log.warning(
689
+ "Failed to extract commit info for %s: %s",
690
+ commit_sha[:8],
691
+ exc,
692
+ )
693
+ continue
694
+
695
+ log.info(
696
+ "Extracted %d local commits for reconciliation", len(local_commits)
697
+ )
698
+ return local_commits
699
+
243
700
  def __init__(
244
701
  self,
245
702
  *,
@@ -251,6 +708,8 @@ class Orchestrator:
251
708
  self._ssh_known_hosts_path: Path | None = None
252
709
  self._ssh_agent_manager: SSHAgentManager | None = None
253
710
  self._use_ssh_agent: bool = False
711
+ # Store inputs for access by helper methods
712
+ self._inputs: Inputs | None = None
254
713
 
255
714
  # ---------------
256
715
  # Public API
@@ -271,7 +730,8 @@ class Orchestrator:
271
730
  GitHub output writes), but does perform internal environment mutations
272
731
  (e.g., G2G_TMP_BRANCH) for subprocess coordination within the workflow.
273
732
  """
274
- log.info("Starting PR -> Gerrit pipeline")
733
+ log.debug("Starting PR -> Gerrit pipeline")
734
+ self._inputs = inputs # Store for access by helper methods
275
735
  self._guard_pull_request_context(gh)
276
736
 
277
737
  # Initialize git repository in workspace if it doesn't exist
@@ -280,16 +740,26 @@ class Orchestrator:
280
740
 
281
741
  gitreview = self._read_gitreview(self.workspace / ".gitreview", gh)
282
742
  repo_names = self._derive_repo_names(gitreview, gh)
283
- log.debug("execute: inputs.dry_run=%s, inputs.ci_testing=%s", inputs.dry_run, inputs.ci_testing)
743
+ log.debug(
744
+ "execute: inputs.dry_run=%s, inputs.ci_testing=%s",
745
+ inputs.dry_run,
746
+ inputs.ci_testing,
747
+ )
284
748
  gerrit = self._resolve_gerrit_info(gitreview, inputs, repo_names)
285
749
 
286
750
  log.debug("execute: resolved gerrit info: %s", gerrit)
287
751
  if inputs.dry_run:
288
- log.debug("execute: entering dry-run mode due to inputs.dry_run=True")
752
+ log.debug(
753
+ "execute: entering dry-run mode due to inputs.dry_run=True"
754
+ )
289
755
  # Perform preflight validations and exit without making changes
290
- self._dry_run_preflight(gerrit=gerrit, inputs=inputs, gh=gh, repo=repo_names)
291
- log.info("Dry run complete; skipping write operations to Gerrit")
292
- return SubmissionResult(change_urls=[], change_numbers=[], commit_shas=[])
756
+ self._dry_run_preflight(
757
+ gerrit=gerrit, inputs=inputs, gh=gh, repo=repo_names
758
+ )
759
+ log.debug("Dry run complete; skipping write operations to Gerrit")
760
+ return SubmissionResult(
761
+ change_urls=[], change_numbers=[], commit_shas=[]
762
+ )
293
763
  self._setup_ssh(inputs, gerrit)
294
764
  # Establish baseline non-interactive SSH/Git environment
295
765
  # for all child processes
@@ -322,14 +792,61 @@ class Orchestrator:
322
792
  prep = self._prepare_squashed_commit(inputs, gh, gerrit)
323
793
 
324
794
  self._configure_git(gerrit, inputs)
795
+
796
+ # Phase 3: Robust reconciliation with multi-pass matching
797
+ if inputs.submit_single_commits:
798
+ # Extract local commits for multi-commit reconciliation
799
+ local_commits = self._extract_local_commits_for_reconciliation(
800
+ inputs, gh
801
+ )
802
+ reuse_ids = self._perform_robust_reconciliation(
803
+ inputs, gh, gerrit, local_commits
804
+ )
805
+
806
+ if reuse_ids:
807
+ try:
808
+ prep = self._prepare_single_commits(
809
+ inputs, gh, gerrit, reuse_change_ids=reuse_ids
810
+ )
811
+ except Exception as exc:
812
+ log.debug(
813
+ "Re-preparation with reuse Change-Ids failed "
814
+ "(continuing without reuse): %s",
815
+ exc,
816
+ )
817
+ else:
818
+ # For squash mode, use modern reconciliation with single commit
819
+ local_commits = self._extract_local_commits_for_reconciliation(
820
+ inputs, gh
821
+ )
822
+ # Limit to first commit for squash mode
823
+ single_commit = local_commits[:1] if local_commits else []
824
+ reuse_ids = self._perform_robust_reconciliation(
825
+ inputs, gh, gerrit, single_commit
826
+ )
827
+ if reuse_ids:
828
+ try:
829
+ prep = self._prepare_squashed_commit(
830
+ inputs, gh, gerrit, reuse_change_ids=reuse_ids[:1]
831
+ )
832
+ except Exception as exc:
833
+ log.debug(
834
+ "Re-preparation with reuse Change-Ids failed "
835
+ "(continuing without reuse): %s",
836
+ exc,
837
+ )
838
+
325
839
  self._apply_pr_title_body_if_requested(inputs, gh)
326
840
 
841
+ # Store context for downstream push/comment emission (Phase 2)
842
+ self._gh_context_for_push = gh
327
843
  self._push_to_gerrit(
328
844
  gerrit=gerrit,
329
845
  repo=repo_names,
330
846
  branch=self._resolve_target_branch(),
331
847
  reviewers=self._resolve_reviewers(inputs),
332
848
  single_commits=inputs.submit_single_commits,
849
+ prepared=prep,
333
850
  )
334
851
 
335
852
  result = self._query_gerrit_for_results(
@@ -350,7 +867,7 @@ class Orchestrator:
350
867
 
351
868
  self._close_pull_request_if_required(gh)
352
869
 
353
- log.info("Pipeline complete: %s", result)
870
+ log.debug("Pipeline complete: %s", result)
354
871
  self._cleanup_ssh()
355
872
  return result
356
873
 
@@ -398,8 +915,14 @@ class Orchestrator:
398
915
  repo_obj: Any = get_repo_from_env(client)
399
916
  # Prefer a specific ref when available; otherwise default branch
400
917
  ref = os.getenv("GITHUB_HEAD_REF") or os.getenv("GITHUB_SHA")
401
- content = repo_obj.get_contents(".gitreview", ref=ref) if ref else repo_obj.get_contents(".gitreview")
402
- text_remote = (getattr(content, "decoded_content", b"") or b"").decode("utf-8")
918
+ content = (
919
+ repo_obj.get_contents(".gitreview", ref=ref)
920
+ if ref
921
+ else repo_obj.get_contents(".gitreview")
922
+ )
923
+ text_remote = (
924
+ getattr(content, "decoded_content", b"") or b""
925
+ ).decode("utf-8")
403
926
  info_remote = self._parse_gitreview_text(text_remote)
404
927
  if info_remote:
405
928
  log.debug("Parsed remote .gitreview: %s", info_remote)
@@ -409,7 +932,14 @@ class Orchestrator:
409
932
  log.debug("Remote .gitreview not available: %s", exc)
410
933
  # Attempt raw.githubusercontent.com as a fallback
411
934
  try:
412
- repo_full = ((gh.repository if gh else os.getenv("GITHUB_REPOSITORY", "")) or "").strip()
935
+ repo_full = (
936
+ (
937
+ gh.repository
938
+ if gh
939
+ else os.getenv("GITHUB_REPOSITORY", "")
940
+ )
941
+ or ""
942
+ ).strip()
413
943
  branches: list[str] = []
414
944
  # Prefer PR head/base refs via GitHub API when running
415
945
  # from a direct URL when a token is available
@@ -423,8 +953,18 @@ class Orchestrator:
423
953
  client = build_client()
424
954
  repo_obj = get_repo_from_env(client)
425
955
  pr_obj = get_pull(repo_obj, int(gh.pr_number))
426
- api_head = str(getattr(getattr(pr_obj, "head", object()), "ref", "") or "")
427
- api_base = str(getattr(getattr(pr_obj, "base", object()), "ref", "") or "")
956
+ api_head = str(
957
+ getattr(
958
+ getattr(pr_obj, "head", object()), "ref", ""
959
+ )
960
+ or ""
961
+ )
962
+ api_base = str(
963
+ getattr(
964
+ getattr(pr_obj, "base", object()), "ref", ""
965
+ )
966
+ or ""
967
+ )
428
968
  if api_head:
429
969
  branches.append(api_head)
430
970
  if api_base:
@@ -446,7 +986,10 @@ class Orchestrator:
446
986
  tried.add(br)
447
987
  url = f"https://raw.githubusercontent.com/{repo_full}/refs/heads/{br}/.gitreview"
448
988
  parsed = urllib.parse.urlparse(url)
449
- if parsed.scheme != "https" or parsed.netloc != "raw.githubusercontent.com":
989
+ if (
990
+ parsed.scheme != "https"
991
+ or parsed.netloc != "raw.githubusercontent.com"
992
+ ):
450
993
  continue
451
994
  log.info("Fetching .gitreview via raw URL: %s", url)
452
995
  with urllib.request.urlopen(url, timeout=5) as resp: # noqa: S310
@@ -498,7 +1041,7 @@ class Orchestrator:
498
1041
  repo_full = gh.repository
499
1042
  if not repo_full or "/" not in repo_full:
500
1043
  raise OrchestratorError(_MSG_BAD_REPOSITORY_CONTEXT)
501
- owner, name = repo_full.split("/", 1)
1044
+ _owner, name = repo_full.split("/", 1)
502
1045
  # Fallback: map all '-' to '/' for Gerrit path (e.g., 'my/repo/name')
503
1046
  gerrit_name = name.replace("-", "/")
504
1047
  names = RepoNames(project_gerrit=gerrit_name, project_github=name)
@@ -512,7 +1055,9 @@ class Orchestrator:
512
1055
  repo: RepoNames,
513
1056
  ) -> GerritInfo:
514
1057
  """Resolve Gerrit connection info from .gitreview or inputs."""
515
- log.debug("_resolve_gerrit_info: inputs.ci_testing=%s", inputs.ci_testing)
1058
+ log.debug(
1059
+ "_resolve_gerrit_info: inputs.ci_testing=%s", inputs.ci_testing
1060
+ )
516
1061
  log.debug("_resolve_gerrit_info: gitreview=%s", gitreview)
517
1062
 
518
1063
  # If CI testing flag is set, ignore .gitreview and use environment
@@ -527,7 +1072,7 @@ class Orchestrator:
527
1072
  host = inputs.gerrit_server.strip()
528
1073
  if not host:
529
1074
  raise OrchestratorError(_MSG_MISSING_GERRIT_SERVER)
530
- port_s = inputs.gerrit_server_port.strip() or "29418"
1075
+ port_s = str(inputs.gerrit_server_port).strip() or "29418"
531
1076
  try:
532
1077
  port = int(port_s)
533
1078
  except ValueError as exc:
@@ -570,12 +1115,16 @@ class Orchestrator:
570
1115
  log.debug("SSH private key not provided, skipping SSH setup")
571
1116
  return
572
1117
 
573
- # Auto-discover or augment host keys (merge missing types/[host]:port entries)
1118
+ # Auto-discover or augment host keys (merge missing
1119
+ # types/[host]:port entries)
574
1120
  effective_known_hosts = inputs.gerrit_known_hosts
575
1121
  if auto_discover_gerrit_host_keys is not None:
576
1122
  try:
577
1123
  if not effective_known_hosts:
578
- log.info("GERRIT_KNOWN_HOSTS not provided, attempting auto-discovery...")
1124
+ log.info(
1125
+ "GERRIT_KNOWN_HOSTS not provided, attempting "
1126
+ "auto-discovery..."
1127
+ )
579
1128
  discovered_keys = auto_discover_gerrit_host_keys(
580
1129
  gerrit_hostname=gerrit.host,
581
1130
  gerrit_port=gerrit.port,
@@ -585,14 +1134,19 @@ class Orchestrator:
585
1134
  if discovered_keys:
586
1135
  effective_known_hosts = discovered_keys
587
1136
  log.info(
588
- "Successfully auto-discovered SSH host keys for %s:%d",
1137
+ "Successfully auto-discovered SSH host keys for "
1138
+ "%s:%d",
589
1139
  gerrit.host,
590
1140
  gerrit.port,
591
1141
  )
592
1142
  else:
593
- log.warning("Auto-discovery failed, SSH host key verification may fail")
1143
+ log.warning(
1144
+ "Auto-discovery failed, SSH host key verification "
1145
+ "may fail"
1146
+ )
594
1147
  else:
595
- # Provided known_hosts exists; ensure it contains [host]:port entries and modern key types
1148
+ # Provided known_hosts exists; ensure it contains
1149
+ # [host]:port entries and modern key types
596
1150
  lower = effective_known_hosts.lower()
597
1151
  bracket_host = f"[{gerrit.host}]:{gerrit.port}"
598
1152
  bracket_lower = bracket_host.lower()
@@ -600,7 +1154,8 @@ class Orchestrator:
600
1154
  if bracket_lower not in lower:
601
1155
  needs_discovery = True
602
1156
  else:
603
- # Confirm at least one known key type exists for the bracketed host
1157
+ # Confirm at least one known key type exists for the
1158
+ # bracketed host
604
1159
  if (
605
1160
  f"{bracket_lower} ssh-ed25519" not in lower
606
1161
  and f"{bracket_lower} ecdsa-sha2" not in lower
@@ -609,7 +1164,8 @@ class Orchestrator:
609
1164
  needs_discovery = True
610
1165
  if needs_discovery:
611
1166
  log.info(
612
- "Augmenting provided GERRIT_KNOWN_HOSTS with discovered entries for %s:%d",
1167
+ "Augmenting provided GERRIT_KNOWN_HOSTS with "
1168
+ "discovered entries for %s:%d",
613
1169
  gerrit.host,
614
1170
  gerrit.port,
615
1171
  )
@@ -621,26 +1177,38 @@ class Orchestrator:
621
1177
  )
622
1178
  if discovered_keys:
623
1179
  # Use centralized merging logic
624
- effective_known_hosts = merge_known_hosts_content(effective_known_hosts, discovered_keys)
1180
+ effective_known_hosts = merge_known_hosts_content(
1181
+ effective_known_hosts, discovered_keys
1182
+ )
625
1183
  log.info(
626
- "Known hosts augmented with discovered entries for %s:%d",
1184
+ "Known hosts augmented with discovered entries "
1185
+ "for %s:%d",
627
1186
  gerrit.host,
628
1187
  gerrit.port,
629
1188
  )
630
1189
  else:
631
- log.warning("Auto-discovery returned no keys; known_hosts not augmented")
1190
+ log.warning(
1191
+ "Auto-discovery returned no keys; known_hosts "
1192
+ "not augmented"
1193
+ )
632
1194
  except Exception as exc:
633
- log.warning("SSH host key auto-discovery/augmentation failed: %s", exc)
1195
+ log.warning(
1196
+ "SSH host key auto-discovery/augmentation failed: %s", exc
1197
+ )
634
1198
 
635
1199
  if not effective_known_hosts:
636
- log.debug("No SSH host keys available (manual or auto-discovered), skipping SSH setup")
1200
+ log.debug(
1201
+ "No SSH host keys available (manual or auto-discovered), "
1202
+ "skipping SSH setup"
1203
+ )
637
1204
  return
638
1205
 
639
1206
  # Check if SSH agent authentication is preferred
640
1207
  use_ssh_agent = env_bool("G2G_USE_SSH_AGENT", default=True)
641
1208
 
642
1209
  if use_ssh_agent and setup_ssh_agent_auth is not None:
643
- # Try SSH agent first as it's more secure and avoids file permission issues
1210
+ # Try SSH agent first as it's more secure and avoids file
1211
+ # permission issues
644
1212
  if self._try_ssh_agent_setup(inputs, effective_known_hosts):
645
1213
  return
646
1214
 
@@ -649,7 +1217,9 @@ class Orchestrator:
649
1217
 
650
1218
  self._setup_file_based_ssh(inputs, effective_known_hosts)
651
1219
 
652
- def _try_ssh_agent_setup(self, inputs: Inputs, effective_known_hosts: str) -> bool:
1220
+ def _try_ssh_agent_setup(
1221
+ self, inputs: Inputs, effective_known_hosts: str
1222
+ ) -> bool:
653
1223
  """Try to setup SSH agent-based authentication.
654
1224
 
655
1225
  Args:
@@ -660,21 +1230,23 @@ class Orchestrator:
660
1230
  True if SSH agent setup succeeded, False otherwise
661
1231
  """
662
1232
  if setup_ssh_agent_auth is None:
663
- log.debug("SSH agent module not available, falling back to file-based SSH") # type: ignore[unreachable]
664
- return False
1233
+ return False # type: ignore[unreachable]
665
1234
 
666
1235
  try:
667
- log.info("Setting up SSH agent-based authentication (more secure)")
1236
+ log.debug("Setting up SSH agent-based authentication (more secure)")
668
1237
  self._ssh_agent_manager = setup_ssh_agent_auth(
669
1238
  workspace=self.workspace,
670
1239
  private_key_content=inputs.gerrit_ssh_privkey_g2g,
671
1240
  known_hosts_content=effective_known_hosts,
672
1241
  )
673
1242
  self._use_ssh_agent = True
674
- log.info("SSH agent authentication configured successfully")
1243
+ log.debug("SSH agent authentication configured successfully")
675
1244
 
676
1245
  except Exception as exc:
677
- log.warning("SSH agent setup failed, falling back to file-based SSH: %s", exc)
1246
+ log.warning(
1247
+ "SSH agent setup failed, falling back to file-based SSH: %s",
1248
+ exc,
1249
+ )
678
1250
  if self._ssh_agent_manager:
679
1251
  self._ssh_agent_manager.cleanup()
680
1252
  self._ssh_agent_manager = None
@@ -682,7 +1254,9 @@ class Orchestrator:
682
1254
  else:
683
1255
  return True
684
1256
 
685
- def _setup_file_based_ssh(self, inputs: Inputs, effective_known_hosts: str) -> None:
1257
+ def _setup_file_based_ssh(
1258
+ self, inputs: Inputs, effective_known_hosts: str
1259
+ ) -> None:
686
1260
  """Setup file-based SSH authentication as fallback.
687
1261
 
688
1262
  Args:
@@ -697,10 +1271,12 @@ class Orchestrator:
697
1271
  tool_ssh_dir = self.workspace / ".ssh-g2g"
698
1272
  tool_ssh_dir.mkdir(mode=0o700, exist_ok=True)
699
1273
 
700
- # Write SSH private key to tool-specific location with secure permissions
1274
+ # Write SSH private key to tool-specific location with secure
1275
+ # permissions
701
1276
  key_path = tool_ssh_dir / "gerrit_key"
702
1277
 
703
- # Use a more robust approach for creating the file with secure permissions
1278
+ # Use a more robust approach for creating the file with secure
1279
+ # permissions
704
1280
  key_content = inputs.gerrit_ssh_privkey_g2g.strip() + "\n"
705
1281
 
706
1282
  # Multiple strategies to create secure key file
@@ -714,7 +1290,8 @@ class Orchestrator:
714
1290
  msg = (
715
1291
  "Failed to create SSH key file with secure permissions. "
716
1292
  "This may be due to CI environment restrictions. "
717
- "Consider using G2G_USE_SSH_AGENT=true (default) for SSH agent authentication."
1293
+ "Consider using G2G_USE_SSH_AGENT=true (default) for SSH "
1294
+ "agent authentication."
718
1295
  )
719
1296
  raise RuntimeError(msg)
720
1297
 
@@ -762,10 +1339,17 @@ class Orchestrator:
762
1339
  # Verify permissions
763
1340
  actual_perms = oct(key_path.stat().st_mode)[-3:]
764
1341
  if actual_perms == "600":
765
- log.debug("SSH key created successfully with strategy: %s", strategy_name)
1342
+ log.debug(
1343
+ "SSH key created successfully with strategy: %s",
1344
+ strategy_name,
1345
+ )
766
1346
  return True
767
1347
  else:
768
- log.debug("Strategy %s resulted in permissions %s", strategy_name, actual_perms)
1348
+ log.debug(
1349
+ "Strategy %s resulted in permissions %s",
1350
+ strategy_name,
1351
+ actual_perms,
1352
+ )
769
1353
 
770
1354
  except Exception as exc:
771
1355
  log.debug("Strategy %s failed: %s", strategy_name, exc)
@@ -811,7 +1395,9 @@ class Orchestrator:
811
1395
  finally:
812
1396
  os.umask(original_umask)
813
1397
 
814
- def _strategy_stat_constants(self, key_path: Path, key_content: str) -> None:
1398
+ def _strategy_stat_constants(
1399
+ self, key_path: Path, key_content: str
1400
+ ) -> None:
815
1401
  """Strategy: use stat constants for permission setting."""
816
1402
  import os
817
1403
  import stat
@@ -824,7 +1410,9 @@ class Orchestrator:
824
1410
  os.chmod(str(key_path), mode)
825
1411
  key_path.chmod(mode)
826
1412
 
827
- def _create_key_in_memory_fs(self, key_path: Path, key_content: str) -> bool:
1413
+ def _create_key_in_memory_fs(
1414
+ self, key_path: Path, key_content: str
1415
+ ) -> bool:
828
1416
  """Fallback: try to create key in memory filesystem."""
829
1417
  import shutil
830
1418
  import tempfile
@@ -851,7 +1439,11 @@ class Orchestrator:
851
1439
  tmp_path = None
852
1440
  try:
853
1441
  with tempfile.NamedTemporaryFile(
854
- mode="w", dir=memory_dir, prefix="g2g_key_", suffix=".tmp", delete=False
1442
+ mode="w",
1443
+ dir=memory_dir,
1444
+ prefix="g2g_key_",
1445
+ suffix=".tmp",
1446
+ delete=False,
855
1447
  ) as tmp_file:
856
1448
  tmp_file.write(key_content)
857
1449
  tmp_path = Path(tmp_file.name)
@@ -863,18 +1455,29 @@ class Orchestrator:
863
1455
  if actual_perms == "600":
864
1456
  # Move to final location
865
1457
  shutil.move(str(tmp_path), str(key_path))
866
- log.debug("Successfully created SSH key using memory filesystem: %s", memory_dir)
1458
+ log.debug(
1459
+ "Successfully created SSH key using memory "
1460
+ "filesystem: %s",
1461
+ memory_dir,
1462
+ )
867
1463
  return True
868
1464
  else:
869
1465
  tmp_path.unlink()
870
1466
 
871
1467
  except Exception as exc:
872
- log.debug("Memory filesystem strategy failed for %s: %s", memory_dir, exc)
1468
+ log.debug(
1469
+ "Memory filesystem strategy failed for %s: %s",
1470
+ memory_dir,
1471
+ exc,
1472
+ )
873
1473
  try:
874
1474
  if tmp_path is not None and tmp_path.exists():
875
1475
  tmp_path.unlink()
876
1476
  except Exception as cleanup_exc:
877
- log.debug("Failed to cleanup temporary key file: %s", cleanup_exc)
1477
+ log.debug(
1478
+ "Failed to cleanup temporary key file: %s",
1479
+ cleanup_exc,
1480
+ )
878
1481
 
879
1482
  except Exception as exc:
880
1483
  log.debug("Memory filesystem fallback failed: %s", exc)
@@ -946,7 +1549,9 @@ class Orchestrator:
946
1549
  import shutil
947
1550
 
948
1551
  shutil.rmtree(tool_ssh_dir)
949
- log.debug("Cleaned up temporary SSH directory: %s", tool_ssh_dir)
1552
+ log.debug(
1553
+ "Cleaned up temporary SSH directory: %s", tool_ssh_dir
1554
+ )
950
1555
  except Exception as exc:
951
1556
  log.warning("Failed to clean up temporary SSH files: %s", exc)
952
1557
 
@@ -956,7 +1561,7 @@ class Orchestrator:
956
1561
  inputs: Inputs,
957
1562
  ) -> None:
958
1563
  """Set git global config and initialize git-review if needed."""
959
- log.info("Configuring git and git-review for %s", gerrit.host)
1564
+ log.debug("Configuring git and git-review for %s", gerrit.host)
960
1565
  # Prefer repo-local config; fallback to global if needed
961
1566
  try:
962
1567
  git_config(
@@ -966,7 +1571,9 @@ class Orchestrator:
966
1571
  cwd=self.workspace,
967
1572
  )
968
1573
  except GitError:
969
- git_config("gitreview.username", inputs.gerrit_ssh_user_g2g, global_=True)
1574
+ git_config(
1575
+ "gitreview.username", inputs.gerrit_ssh_user_g2g, global_=True
1576
+ )
970
1577
  try:
971
1578
  git_config(
972
1579
  "user.name",
@@ -984,7 +1591,9 @@ class Orchestrator:
984
1591
  cwd=self.workspace,
985
1592
  )
986
1593
  except GitError:
987
- git_config("user.email", inputs.gerrit_ssh_user_g2g_email, global_=True)
1594
+ git_config(
1595
+ "user.email", inputs.gerrit_ssh_user_g2g_email, global_=True
1596
+ )
988
1597
  # Disable GPG signing to avoid interactive prompts for signing keys
989
1598
  try:
990
1599
  git_config(
@@ -1039,8 +1648,10 @@ class Orchestrator:
1039
1648
  )
1040
1649
  except CommandError:
1041
1650
  ssh_user = inputs.gerrit_ssh_user_g2g.strip()
1042
- remote_url = f"ssh://{ssh_user}@{gerrit.host}:{gerrit.port}/{gerrit.project}"
1043
- log.info("Adding 'gerrit' remote: %s", remote_url)
1651
+ remote_url = (
1652
+ f"ssh://{ssh_user}@{gerrit.host}:{gerrit.port}/{gerrit.project}"
1653
+ )
1654
+ log.debug("Adding 'gerrit' remote: %s", remote_url)
1044
1655
  # Use our specific SSH configuration for adding remote
1045
1656
  env = self._ssh_env()
1046
1657
  run_cmd(
@@ -1051,7 +1662,9 @@ class Orchestrator:
1051
1662
  )
1052
1663
 
1053
1664
  # Workaround for submodules commit-msg hook
1054
- hooks_path = run_cmd(["git", "rev-parse", "--show-toplevel"], cwd=self.workspace).stdout.strip()
1665
+ hooks_path = run_cmd(
1666
+ ["git", "rev-parse", "--show-toplevel"], cwd=self.workspace
1667
+ ).stdout.strip()
1055
1668
  try:
1056
1669
  git_config(
1057
1670
  "core.hooksPath",
@@ -1078,6 +1691,7 @@ class Orchestrator:
1078
1691
  inputs: Inputs,
1079
1692
  gh: GitHubContext,
1080
1693
  gerrit: GerritInfo,
1694
+ reuse_change_ids: list[str] | None = None,
1081
1695
  ) -> PreparedChange:
1082
1696
  """Cherry-pick commits one-by-one and ensure Change-Id is present."""
1083
1697
  log.info("Preparing single-commit submission for PR #%s", gh.pr_number)
@@ -1100,12 +1714,16 @@ class Orchestrator:
1100
1714
  log.info("No commits to submit; returning empty PreparedChange")
1101
1715
  return PreparedChange(change_ids=[], commit_shas=[])
1102
1716
  # Create temp branch from base sha; export for downstream
1103
- base_sha = run_cmd(["git", "rev-parse", base_ref], cwd=self.workspace).stdout.strip()
1717
+ base_sha = run_cmd(
1718
+ ["git", "rev-parse", base_ref], cwd=self.workspace
1719
+ ).stdout.strip()
1104
1720
  tmp_branch = f"g2g_tmp_{gh.pr_number or 'pr'!s}_{os.getpid()}"
1105
1721
  os.environ["G2G_TMP_BRANCH"] = tmp_branch
1106
- run_cmd(["git", "checkout", "-b", tmp_branch, base_sha], cwd=self.workspace)
1722
+ run_cmd(
1723
+ ["git", "checkout", "-b", tmp_branch, base_sha], cwd=self.workspace
1724
+ )
1107
1725
  change_ids: list[str] = []
1108
- for csha in commit_list:
1726
+ for idx, csha in enumerate(commit_list):
1109
1727
  run_cmd(["git", "checkout", tmp_branch], cwd=self.workspace)
1110
1728
  git_cherry_pick(csha, cwd=self.workspace)
1111
1729
  # Preserve author of the original commit
@@ -1113,9 +1731,61 @@ class Orchestrator:
1113
1731
  ["git", "show", "-s", "--pretty=format:%an <%ae>", csha],
1114
1732
  cwd=self.workspace,
1115
1733
  ).stdout.strip()
1116
- git_commit_amend(author=author, no_edit=True, signoff=True, cwd=self.workspace)
1734
+ git_commit_amend(
1735
+ author=author, no_edit=True, signoff=True, cwd=self.workspace
1736
+ )
1737
+ # Phase 3: Reuse Change-Id if provided
1738
+ if reuse_change_ids and idx < len(reuse_change_ids):
1739
+ desired = reuse_change_ids[idx]
1740
+ if desired:
1741
+ cur_msg = run_cmd(
1742
+ ["git", "show", "-s", "--pretty=format:%B", "HEAD"],
1743
+ cwd=self.workspace,
1744
+ ).stdout
1745
+ # Clean ellipses from commit message
1746
+ cur_msg = _clean_ellipses_from_message(cur_msg)
1747
+ if f"Change-Id: {desired}" not in cur_msg:
1748
+ amended = (
1749
+ cur_msg.rstrip() + f"\n\nChange-Id: {desired}\n"
1750
+ )
1751
+ git_commit_amend(
1752
+ author=author,
1753
+ no_edit=False,
1754
+ signoff=False,
1755
+ message=amended,
1756
+ cwd=self.workspace,
1757
+ )
1758
+ # Phase 1: ensure PR metadata trailers (idempotent)
1759
+ try:
1760
+ meta = self._build_pr_metadata_trailers(gh)
1761
+ if meta:
1762
+ cur_msg = run_cmd(
1763
+ ["git", "show", "-s", "--pretty=format:%B", "HEAD"],
1764
+ cwd=self.workspace,
1765
+ ).stdout
1766
+ # Clean ellipses from commit message
1767
+ cur_msg = _clean_ellipses_from_message(cur_msg)
1768
+ needed = [m for m in meta if m not in cur_msg]
1769
+ if needed:
1770
+ new_msg = (
1771
+ cur_msg.rstrip() + "\n" + "\n".join(needed) + "\n"
1772
+ )
1773
+ git_commit_amend(
1774
+ message=new_msg,
1775
+ no_edit=False,
1776
+ signoff=False,
1777
+ cwd=self.workspace,
1778
+ )
1779
+ except Exception as meta_exc:
1780
+ log.debug(
1781
+ "Skipping metadata trailer injection for commit %s: %s",
1782
+ csha,
1783
+ meta_exc,
1784
+ )
1117
1785
  # Extract newly added Change-Id from last commit trailers
1118
- trailers = git_last_commit_trailers(keys=["Change-Id"], cwd=self.workspace)
1786
+ trailers = git_last_commit_trailers(
1787
+ keys=["Change-Id"], cwd=self.workspace
1788
+ )
1119
1789
  for cid in trailers.get("Change-Id", []):
1120
1790
  if cid:
1121
1791
  change_ids.append(cid)
@@ -1138,7 +1808,8 @@ class Orchestrator:
1138
1808
  )
1139
1809
  else:
1140
1810
  log.debug(
1141
- "No Change-IDs collected during preparation for PR #%s (will be ensured via commit-msg hook)",
1811
+ "No Change-IDs collected during preparation for PR #%s "
1812
+ "(will be ensured via commit-msg hook)",
1142
1813
  gh.pr_number,
1143
1814
  )
1144
1815
  return PreparedChange(change_ids=uniq_ids, commit_shas=[])
@@ -1148,9 +1819,10 @@ class Orchestrator:
1148
1819
  inputs: Inputs,
1149
1820
  gh: GitHubContext,
1150
1821
  gerrit: GerritInfo,
1822
+ reuse_change_ids: list[str] | None = None,
1151
1823
  ) -> PreparedChange:
1152
1824
  """Squash PR commits into a single commit and handle Change-Id."""
1153
- log.info("Preparing squashed commit for PR #%s", gh.pr_number)
1825
+ log.debug("Preparing squashed commit for PR #%s", gh.pr_number)
1154
1826
  branch = self._resolve_target_branch()
1155
1827
 
1156
1828
  run_cmd(
@@ -1159,13 +1831,19 @@ class Orchestrator:
1159
1831
  env=self._ssh_env(),
1160
1832
  )
1161
1833
  base_ref = f"origin/{branch}"
1162
- base_sha = run_cmd(["git", "rev-parse", base_ref], cwd=self.workspace).stdout.strip()
1163
- head_sha = run_cmd(["git", "rev-parse", "HEAD"], cwd=self.workspace).stdout.strip()
1834
+ base_sha = run_cmd(
1835
+ ["git", "rev-parse", base_ref], cwd=self.workspace
1836
+ ).stdout.strip()
1837
+ head_sha = run_cmd(
1838
+ ["git", "rev-parse", "HEAD"], cwd=self.workspace
1839
+ ).stdout.strip()
1164
1840
 
1165
1841
  # Create temp branch from base and merge-squash PR head
1166
1842
  tmp_branch = f"g2g_tmp_{gh.pr_number or 'pr'!s}_{os.getpid()}"
1167
1843
  os.environ["G2G_TMP_BRANCH"] = tmp_branch
1168
- run_cmd(["git", "checkout", "-b", tmp_branch, base_sha], cwd=self.workspace)
1844
+ run_cmd(
1845
+ ["git", "checkout", "-b", tmp_branch, base_sha], cwd=self.workspace
1846
+ )
1169
1847
  run_cmd(["git", "merge", "--squash", head_sha], cwd=self.workspace)
1170
1848
 
1171
1849
  def _collect_log_lines() -> list[str]:
@@ -1193,17 +1871,24 @@ class Orchestrator:
1193
1871
  message_lines: list[str] = []
1194
1872
  in_metadata_section = False
1195
1873
  for ln in lines:
1196
- if ln.strip() in ("---", "```") or ln.startswith("updated-dependencies:"):
1874
+ if ln.strip() in ("---", "```") or ln.startswith(
1875
+ "updated-dependencies:"
1876
+ ):
1197
1877
  in_metadata_section = True
1198
1878
  continue
1199
1879
  if in_metadata_section:
1200
1880
  if ln.startswith(("- dependency-", " dependency-")):
1201
1881
  continue
1202
- if not ln.startswith((" ", "-", "dependency-")) and ln.strip():
1882
+ if (
1883
+ not ln.startswith((" ", "-", "dependency-"))
1884
+ and ln.strip()
1885
+ ):
1203
1886
  in_metadata_section = False
1204
1887
  # Skip Change-Id lines from body - they should only be in footer
1205
1888
  if ln.startswith("Change-Id:"):
1206
- log.debug("Skipping Change-Id from commit body: %s", ln.strip())
1889
+ log.debug(
1890
+ "Skipping Change-Id from commit body: %s", ln.strip()
1891
+ )
1207
1892
  continue
1208
1893
  if ln.startswith("Signed-off-by:"):
1209
1894
  signed_off.append(ln)
@@ -1229,11 +1914,17 @@ class Orchestrator:
1229
1914
  break_points = [". ", "! ", "? ", " - ", ": "]
1230
1915
  for bp in break_points:
1231
1916
  if bp in title_line[:100]:
1232
- title_line = title_line[: title_line.index(bp) + len(bp.strip())]
1917
+ title_line = title_line[
1918
+ : title_line.index(bp) + len(bp.strip())
1919
+ ]
1233
1920
  break
1234
1921
  else:
1235
1922
  words = title_line[:100].split()
1236
- title_line = " ".join(words[:-1]) if len(words) > 1 else title_line[:100].rstrip()
1923
+ title_line = (
1924
+ " ".join(words[:-1])
1925
+ if len(words) > 1
1926
+ else title_line[:100].rstrip()
1927
+ )
1237
1928
 
1238
1929
  # Apply conventional commit normalization if enabled
1239
1930
  if inputs.normalise_commit and gh.pr_number:
@@ -1243,10 +1934,18 @@ class Orchestrator:
1243
1934
  repo = get_repo_from_env(client)
1244
1935
  pr_obj = get_pull(repo, int(gh.pr_number))
1245
1936
  author = getattr(pr_obj, "user", {})
1246
- author_login = getattr(author, "login", "") if author else ""
1247
- title_line = normalize_commit_title(title_line, author_login, self.workspace)
1937
+ author_login = (
1938
+ getattr(author, "login", "") if author else ""
1939
+ )
1940
+ title_line = normalize_commit_title(
1941
+ title_line, author_login, self.workspace
1942
+ )
1248
1943
  except Exception as e:
1249
- log.debug("Failed to apply commit normalization in squash mode: %s", e)
1944
+ log.debug(
1945
+ "Failed to apply commit normalization in squash "
1946
+ "mode: %s",
1947
+ e,
1948
+ )
1250
1949
 
1251
1950
  return title_line
1252
1951
 
@@ -1257,16 +1956,27 @@ class Orchestrator:
1257
1956
  out: list[str] = [title_line]
1258
1957
  if len(message_lines) > 1:
1259
1958
  body_start = 1
1260
- while body_start < len(message_lines) and not message_lines[body_start].strip():
1959
+ while (
1960
+ body_start < len(message_lines)
1961
+ and not message_lines[body_start].strip()
1962
+ ):
1261
1963
  body_start += 1
1262
1964
  if body_start < len(message_lines):
1263
1965
  out.append("")
1264
- out.extend(message_lines[body_start:])
1966
+ # Clean up ellipses from body lines
1967
+ body_content = "\n".join(message_lines[body_start:])
1968
+ cleaned_body_content = _clean_ellipses_from_message(
1969
+ body_content
1970
+ )
1971
+ if cleaned_body_content.strip():
1972
+ out.extend(cleaned_body_content.splitlines())
1265
1973
  return out
1266
1974
 
1267
1975
  def _maybe_reuse_change_id(pr_str: str) -> str:
1268
1976
  reuse = ""
1269
- sync_all_prs = os.getenv("SYNC_ALL_OPEN_PRS", "false").lower() == "true"
1977
+ sync_all_prs = (
1978
+ os.getenv("SYNC_ALL_OPEN_PRS", "false").lower() == "true"
1979
+ )
1270
1980
  if (
1271
1981
  not sync_all_prs
1272
1982
  and gh.event_name == "pull_request_target"
@@ -1276,7 +1986,9 @@ class Orchestrator:
1276
1986
  client = build_client()
1277
1987
  repo = get_repo_from_env(client)
1278
1988
  pr_obj = get_pull(repo, int(pr_str))
1279
- cand = get_recent_change_ids_from_comments(pr_obj, max_comments=50)
1989
+ cand = get_recent_change_ids_from_comments(
1990
+ pr_obj, max_comments=50
1991
+ )
1280
1992
  if cand:
1281
1993
  reuse = cand[-1]
1282
1994
  log.debug(
@@ -1299,10 +2011,17 @@ class Orchestrator:
1299
2011
  reuse_cid: str,
1300
2012
  ) -> str:
1301
2013
  msg = "\n".join(lines_in).strip()
1302
- msg = _insert_issue_id_into_commit_message(msg, inputs.issue_id)
1303
2014
 
1304
- # Build footer with proper trailer ordering
2015
+ # Build footer with proper trailer ordering (Issue-ID first,
2016
+ # then others)
1305
2017
  footer_parts = []
2018
+ if inputs.issue_id.strip():
2019
+ issue_line = (
2020
+ inputs.issue_id.strip()
2021
+ if inputs.issue_id.strip().startswith("Issue-ID:")
2022
+ else f"Issue-ID: {inputs.issue_id.strip()}"
2023
+ )
2024
+ footer_parts.append(issue_line)
1306
2025
  if signed_off:
1307
2026
  footer_parts.extend(signed_off)
1308
2027
  if reuse_cid:
@@ -1314,10 +2033,18 @@ class Orchestrator:
1314
2033
 
1315
2034
  # Build message parts
1316
2035
  raw_lines = _collect_log_lines()
1317
- message_lines, signed_off, _existing_cids = _parse_message_parts(raw_lines)
2036
+ message_lines, signed_off, _existing_cids = _parse_message_parts(
2037
+ raw_lines
2038
+ )
1318
2039
  clean_lines = _build_clean_message_lines(message_lines)
1319
2040
  pr_str = str(gh.pr_number or "").strip()
1320
2041
  reuse_cid = _maybe_reuse_change_id(pr_str)
2042
+ # Phase 3: if external reuse list provided, override with first
2043
+ # Change-Id
2044
+ if reuse_change_ids:
2045
+ cand = reuse_change_ids[0]
2046
+ if cand:
2047
+ reuse_cid = cand
1321
2048
  commit_msg = _compose_commit_message(clean_lines, signed_off, reuse_cid)
1322
2049
 
1323
2050
  # Preserve primary author from the PR head commit
@@ -1325,9 +2052,23 @@ class Orchestrator:
1325
2052
  ["git", "show", "-s", "--pretty=format:%an <%ae>", head_sha],
1326
2053
  cwd=self.workspace,
1327
2054
  ).stdout.strip()
1328
- git_commit_new(
1329
- message=commit_msg,
1330
- author=author,
2055
+ # Phase 1: ensure metadata trailers before creating commit
2056
+ # (idempotent merge)
2057
+ try:
2058
+ meta = self._build_pr_metadata_trailers(gh)
2059
+ if meta:
2060
+ needed = [m for m in meta if m not in commit_msg]
2061
+ if needed:
2062
+ commit_msg = commit_msg.rstrip() + "\n" + "\n".join(needed)
2063
+ except Exception as meta_exc:
2064
+ log.debug(
2065
+ "Skipping metadata trailer injection (squash path): %s",
2066
+ meta_exc,
2067
+ )
2068
+
2069
+ git_commit_new(
2070
+ message=commit_msg,
2071
+ author=author,
1331
2072
  signoff=True,
1332
2073
  cwd=self.workspace,
1333
2074
  )
@@ -1349,11 +2090,19 @@ class Orchestrator:
1349
2090
  )
1350
2091
  else:
1351
2092
  # Fallback detection: re-scan commit message for Change-Id trailers
1352
- msg_after = run_cmd(["git", "show", "-s", "--pretty=format:%B", "HEAD"], cwd=self.workspace).stdout
2093
+ msg_after = run_cmd(
2094
+ ["git", "show", "-s", "--pretty=format:%B", "HEAD"],
2095
+ cwd=self.workspace,
2096
+ ).stdout
1353
2097
 
1354
- found = [m.strip() for m in re.findall(r"(?mi)^Change-Id:\s*([A-Za-z0-9._-]+)\s*$", msg_after)]
2098
+ found = [
2099
+ m.strip()
2100
+ for m in re.findall(
2101
+ r"(?mi)^Change-Id:\s*([A-Za-z0-9._-]+)\s*$", msg_after
2102
+ )
2103
+ ]
1355
2104
  if found:
1356
- log.info(
2105
+ log.debug(
1357
2106
  "Detected Change-ID(s) after amend for PR #%s: %s",
1358
2107
  gh.pr_number,
1359
2108
  ", ".join(found),
@@ -1407,25 +2156,35 @@ class Orchestrator:
1407
2156
 
1408
2157
  # Apply conventional commit normalization if enabled
1409
2158
  if inputs.normalise_commit:
1410
- title = normalize_commit_title(title, author_login, self.workspace)
2159
+ title = normalize_commit_title(
2160
+ title, author_login, self.workspace
2161
+ )
1411
2162
 
1412
2163
  # Compose message; preserve existing trailers at footer
1413
2164
  # (Signed-off-by, Change-Id)
1414
2165
  current_body = git_show("HEAD", fmt="%B", cwd=self.workspace)
1415
2166
  # Extract existing trailers from current commit body
1416
2167
  lines_cur = current_body.splitlines()
1417
- signed_lines = [ln for ln in lines_cur if ln.startswith("Signed-off-by:")]
1418
- change_id_lines = [ln for ln in lines_cur if ln.startswith("Change-Id:")]
1419
- github_hash_lines = [ln for ln in lines_cur if ln.startswith("GitHub-Hash:")]
2168
+ signed_lines = [
2169
+ ln for ln in lines_cur if ln.startswith("Signed-off-by:")
2170
+ ]
2171
+ change_id_lines = [
2172
+ ln for ln in lines_cur if ln.startswith("Change-Id:")
2173
+ ]
2174
+ github_hash_lines = [
2175
+ ln for ln in lines_cur if ln.startswith("GitHub-Hash:")
2176
+ ]
2177
+ github_pr_lines = [
2178
+ ln for ln in lines_cur if ln.startswith("GitHub-PR:")
2179
+ ]
1420
2180
 
1421
2181
  msg_parts = [title, "", body] if title or body else [current_body]
1422
2182
  commit_message = "\n".join(msg_parts).strip()
1423
2183
 
1424
- # Add Issue-ID if provided
1425
- commit_message = _insert_issue_id_into_commit_message(commit_message, inputs.issue_id)
2184
+ # Issue-ID will be added in the footer section later
2185
+ # (removed from here to avoid duplication)
1426
2186
 
1427
- # Ensure GitHub-Hash is part of the body (not trailers)
1428
- # to keep a blank line before Signed-off-by/Change-Id trailers.
2187
+ # Prepare GitHub-Hash (will be placed after GitHub-PR at footer)
1429
2188
  if github_hash_lines:
1430
2189
  gh_hash_line = github_hash_lines[-1]
1431
2190
  else:
@@ -1434,9 +2193,7 @@ class Orchestrator:
1434
2193
  gh_val = DuplicateDetector._generate_github_change_hash(gh)
1435
2194
  gh_hash_line = f"GitHub-Hash: {gh_val}"
1436
2195
 
1437
- commit_message += "\n\n" + gh_hash_line
1438
-
1439
- # Build trailers: Signed-off-by first, Change-Id last.
2196
+ # Build trailers: Signed-off-by first, Change-Id next.
1440
2197
  trailers_out: list[str] = []
1441
2198
  if signed_lines:
1442
2199
  seen_so: set[str] = set()
@@ -1446,8 +2203,27 @@ class Orchestrator:
1446
2203
  seen_so.add(ln)
1447
2204
  if change_id_lines:
1448
2205
  trailers_out.append(change_id_lines[-1])
1449
- if trailers_out:
1450
- commit_message += "\n\n" + "\n".join(trailers_out)
2206
+
2207
+ # GitHub-PR (after Change-Id)
2208
+ if github_pr_lines:
2209
+ pr_line = github_pr_lines[-1]
2210
+ else:
2211
+ pr_line = (
2212
+ f"GitHub-PR: {gh.server_url}/{gh.repository}/pull/"
2213
+ f"{gh.pr_number}"
2214
+ if gh.pr_number
2215
+ else ""
2216
+ )
2217
+
2218
+ # Assemble footer in desired order:
2219
+ footer_lines: list[str] = []
2220
+ footer_lines.extend(trailers_out)
2221
+ if pr_line:
2222
+ footer_lines.append(pr_line)
2223
+ footer_lines.append(gh_hash_line)
2224
+
2225
+ if footer_lines:
2226
+ commit_message += "\n\n" + "\n".join(footer_lines)
1451
2227
 
1452
2228
  author = run_cmd(
1453
2229
  ["git", "show", "-s", "--pretty=format:%an <%ae>", "HEAD"],
@@ -1461,6 +2237,41 @@ class Orchestrator:
1461
2237
  author=author,
1462
2238
  message=commit_message,
1463
2239
  )
2240
+ # Phase 2: collect Change-Id trailers for later comment emission
2241
+ try:
2242
+ trailers_after = git_last_commit_trailers(
2243
+ keys=["Change-Id"], cwd=self.workspace
2244
+ )
2245
+ self._latest_apply_pr_change_ids = trailers_after.get(
2246
+ "Change-Id", []
2247
+ )
2248
+ except Exception as exc:
2249
+ log.debug(
2250
+ "Failed to collect Change-Ids after apply_pr_title: %s", exc
2251
+ )
2252
+ # Phase 1: ensure trailers present even if earlier logic skipped
2253
+ # (idempotent)
2254
+ try:
2255
+ meta = self._build_pr_metadata_trailers(gh)
2256
+ if meta:
2257
+ cur_msg = run_cmd(
2258
+ ["git", "show", "-s", "--pretty=format:%B", "HEAD"],
2259
+ cwd=self.workspace,
2260
+ ).stdout
2261
+ needed = [m for m in meta if m not in cur_msg]
2262
+ if needed:
2263
+ new_msg = cur_msg.rstrip() + "\n" + "\n".join(needed) + "\n"
2264
+ git_commit_amend(
2265
+ cwd=self.workspace,
2266
+ no_edit=False,
2267
+ signoff=False,
2268
+ author=author,
2269
+ message=new_msg,
2270
+ )
2271
+ except Exception as meta_exc:
2272
+ log.debug(
2273
+ "Skipping post-apply metadata trailer ensure: %s", meta_exc
2274
+ )
1464
2275
 
1465
2276
  def _push_to_gerrit(
1466
2277
  self,
@@ -1470,9 +2281,10 @@ class Orchestrator:
1470
2281
  branch: str,
1471
2282
  reviewers: str,
1472
2283
  single_commits: bool,
2284
+ prepared: PreparedChange | None = None,
1473
2285
  ) -> None:
1474
2286
  """Push prepared commit(s) to Gerrit using git-review."""
1475
- log.info(
2287
+ log.debug(
1476
2288
  "Pushing changes to Gerrit %s:%s project=%s branch=%s",
1477
2289
  gerrit.host,
1478
2290
  gerrit.port,
@@ -1485,7 +2297,11 @@ class Orchestrator:
1485
2297
  run_cmd(["git", "checkout", tmp_branch], cwd=self.workspace)
1486
2298
  prefix = os.getenv("G2G_TOPIC_PREFIX", "GH").strip() or "GH"
1487
2299
  pr_num = os.getenv("PR_NUMBER", "").strip()
1488
- topic = f"{prefix}-{repo.project_github}-{pr_num}" if pr_num else f"{prefix}-{repo.project_github}"
2300
+ topic = (
2301
+ f"{prefix}-{repo.project_github}-{pr_num}"
2302
+ if pr_num
2303
+ else f"{prefix}-{repo.project_github}"
2304
+ )
1489
2305
 
1490
2306
  # Use our specific SSH configuration
1491
2307
  env = self._ssh_env()
@@ -1499,32 +2315,164 @@ class Orchestrator:
1499
2315
  "-t",
1500
2316
  topic,
1501
2317
  ]
1502
- revs = [r.strip() for r in (reviewers or "").split(",") if r.strip() and "@" in r and r.strip() != branch]
2318
+ collected_change_ids: list[str] = []
2319
+ if prepared:
2320
+ collected_change_ids.extend(prepared.all_change_ids())
2321
+ # Add any Change-Ids captured from apply_pr path (squash amend)
2322
+ extra_ids = getattr(self, "_latest_apply_pr_change_ids", [])
2323
+ for cid in extra_ids:
2324
+ if cid and cid not in collected_change_ids:
2325
+ collected_change_ids.append(cid)
2326
+ revs = [
2327
+ r.strip()
2328
+ for r in (reviewers or "").split(",")
2329
+ if r.strip() and "@" in r and r.strip() != branch
2330
+ ]
1503
2331
  for r in revs:
1504
2332
  args.extend(["--reviewer", r])
1505
2333
  # Branch as positional argument (not a flag)
1506
2334
  args.append(branch)
1507
2335
 
1508
2336
  if env_bool("CI_TESTING", False):
1509
- log.info("CI_TESTING enabled: using synthetic orphan commit push path")
1510
- self._create_orphan_commit_and_push(gerrit, repo, branch, reviewers, topic, env)
2337
+ log.debug(
2338
+ "CI_TESTING enabled: using synthetic orphan commit "
2339
+ "push path"
2340
+ )
2341
+ self._create_orphan_commit_and_push(
2342
+ gerrit, repo, branch, reviewers, topic, env
2343
+ )
1511
2344
  return
1512
2345
  log.debug("Executing git review command: %s", " ".join(args))
1513
2346
  run_cmd(args, cwd=self.workspace, env=env)
1514
- log.info("Successfully pushed changes to Gerrit")
2347
+ log.debug("Successfully pushed changes to Gerrit")
1515
2348
  except CommandError as exc:
1516
2349
  # Check if this is a "no common ancestry" error in CI_TESTING mode
1517
2350
  if self._should_handle_unrelated_history(exc):
1518
- log.info("Detected unrelated repository history. Creating orphan commit for CI testing...")
1519
- self._create_orphan_commit_and_push(gerrit, repo, branch, reviewers, topic, env)
2351
+ log.debug(
2352
+ "Detected unrelated repository history. Creating orphan "
2353
+ "commit for CI testing..."
2354
+ )
2355
+ self._create_orphan_commit_and_push(
2356
+ gerrit, repo, branch, reviewers, topic, env
2357
+ )
1520
2358
  return
1521
2359
 
2360
+ # Check for account not found error and try with case-normalized
2361
+ # emails
2362
+ account_not_found_emails = self._extract_account_not_found_emails(
2363
+ exc
2364
+ )
2365
+ if account_not_found_emails:
2366
+ normalized_reviewers = self._normalize_reviewer_emails(
2367
+ reviewers, account_not_found_emails
2368
+ )
2369
+ if normalized_reviewers != reviewers:
2370
+ log.debug(
2371
+ "Retrying with case-normalized email addresses..."
2372
+ )
2373
+ try:
2374
+ # Rebuild args with normalized reviewers
2375
+ retry_args = args[:-1] # Remove branch (last arg)
2376
+ # Clear previous reviewer args and add normalized ones
2377
+ retry_args = [
2378
+ arg for arg in retry_args if arg != "--reviewer"
2379
+ ]
2380
+ retry_args = [
2381
+ retry_args[i]
2382
+ for i in range(len(retry_args))
2383
+ if i == 0 or retry_args[i - 1] != "--reviewer"
2384
+ ]
2385
+
2386
+ norm_revs = [
2387
+ r.strip()
2388
+ for r in (normalized_reviewers or "").split(",")
2389
+ if r.strip() and "@" in r and r.strip() != branch
2390
+ ]
2391
+ for r in norm_revs:
2392
+ retry_args.extend(["--reviewer", r])
2393
+ retry_args.append(branch)
2394
+
2395
+ log.debug(
2396
+ "Retrying git review command with normalized "
2397
+ "emails: %s",
2398
+ " ".join(retry_args),
2399
+ )
2400
+ run_cmd(retry_args, cwd=self.workspace, env=env)
2401
+ log.debug(
2402
+ "Successfully pushed changes to Gerrit with "
2403
+ "normalized email addresses"
2404
+ )
2405
+
2406
+ # Update configuration file with normalized email
2407
+ # addresses
2408
+ self._update_config_with_normalized_emails(
2409
+ account_not_found_emails
2410
+ )
2411
+ except CommandError as retry_exc:
2412
+ log.warning(
2413
+ "Retry with normalized emails also failed: %s",
2414
+ self._analyze_gerrit_push_failure(retry_exc),
2415
+ )
2416
+ # Continue with original error handling
2417
+ else:
2418
+ # On success, emit mapping comment before return
2419
+ try:
2420
+ gh_context = getattr(
2421
+ self, "_gh_context_for_push", None
2422
+ )
2423
+ replace_existing = getattr(
2424
+ self, "_inputs", None
2425
+ ) and getattr(
2426
+ self._inputs,
2427
+ "persist_single_mapping_comment",
2428
+ True,
2429
+ )
2430
+ self._emit_change_id_map_comment(
2431
+ gh_context=gh_context,
2432
+ change_ids=collected_change_ids,
2433
+ multi=single_commits,
2434
+ topic=topic,
2435
+ replace_existing=bool(replace_existing),
2436
+ )
2437
+ except Exception as cexc:
2438
+ log.debug(
2439
+ "Failed to emit Change-Id map comment "
2440
+ "(retry path): %s",
2441
+ cexc,
2442
+ )
2443
+ return
2444
+
1522
2445
  # Analyze the specific failure reason from git review output
1523
2446
  error_details = self._analyze_gerrit_push_failure(exc)
1524
- log_exception_conditionally(log, "Gerrit push failed: %s", error_details)
1525
- msg = f"Failed to push changes to Gerrit with git-review: {error_details}"
2447
+ log_exception_conditionally(
2448
+ log, "Gerrit push failed: %s", error_details
2449
+ )
2450
+ msg = (
2451
+ f"Failed to push changes to Gerrit with git-review: "
2452
+ f"{error_details}"
2453
+ )
1526
2454
  raise OrchestratorError(msg) from exc
1527
2455
  # Cleanup temporary branch used during preparation
2456
+ else:
2457
+ # Successful push: emit mapping comment (Phase 2)
2458
+ try:
2459
+ gh_context = getattr(self, "_gh_context_for_push", None)
2460
+ replace_existing = getattr(self, "_inputs", None) and getattr(
2461
+ self._inputs, "persist_single_mapping_comment", True
2462
+ )
2463
+ self._emit_change_id_map_comment(
2464
+ gh_context=gh_context,
2465
+ change_ids=collected_change_ids,
2466
+ multi=single_commits,
2467
+ topic=topic,
2468
+ replace_existing=bool(replace_existing),
2469
+ )
2470
+ except Exception as exc_emit:
2471
+ log.debug(
2472
+ "Failed to emit Change-Id map comment (success path): %s",
2473
+ exc_emit,
2474
+ )
2475
+ # Cleanup temporary branch used during preparation
1528
2476
  tmp_branch = (os.getenv("G2G_TMP_BRANCH", "") or "").strip()
1529
2477
  if tmp_branch:
1530
2478
  # Switch back to the target branch, then delete the temp branch
@@ -1541,8 +2489,144 @@ class Orchestrator:
1541
2489
  env=env,
1542
2490
  )
1543
2491
 
2492
+ def _extract_account_not_found_emails(self, exc: CommandError) -> list[str]:
2493
+ """Extract email addresses from 'Account not found' errors.
2494
+
2495
+ Args:
2496
+ exc: The CommandError from git review failure
2497
+
2498
+ Returns:
2499
+ List of email addresses that were not found in Gerrit
2500
+ """
2501
+ combined_output = f"{exc.stdout}\n{exc.stderr}"
2502
+ import re
2503
+
2504
+ # Pattern to match: Account 'email@domain.com' not found
2505
+ pattern = r"Account\s+'([^']+)'\s+not\s+found"
2506
+ matches = re.findall(pattern, combined_output, re.IGNORECASE)
2507
+
2508
+ # Filter to only include valid email addresses
2509
+ email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
2510
+ valid_emails = [
2511
+ email for email in matches if re.match(email_pattern, email)
2512
+ ]
2513
+
2514
+ if valid_emails:
2515
+ log.debug("Found 'Account not found' emails: %s", valid_emails)
2516
+
2517
+ return valid_emails
2518
+
2519
+ def _normalize_reviewer_emails(
2520
+ self, reviewers: str, failed_emails: list[str]
2521
+ ) -> str:
2522
+ """Normalize reviewer email addresses to lowercase.
2523
+
2524
+ Args:
2525
+ reviewers: Comma-separated string of reviewer emails
2526
+ failed_emails: List of emails that failed account lookup
2527
+
2528
+ Returns:
2529
+ Comma-separated string with failed emails converted to lowercase
2530
+ """
2531
+ if not reviewers or not failed_emails:
2532
+ return reviewers
2533
+
2534
+ reviewer_list = [r.strip() for r in reviewers.split(",") if r.strip()]
2535
+ normalized_list = []
2536
+
2537
+ for reviewer in reviewer_list:
2538
+ if reviewer in failed_emails:
2539
+ normalized = reviewer.lower()
2540
+ if normalized != reviewer:
2541
+ log.info(
2542
+ "Normalizing email case: %s -> %s", reviewer, normalized
2543
+ )
2544
+ normalized_list.append(normalized)
2545
+ else:
2546
+ normalized_list.append(reviewer)
2547
+
2548
+ return ",".join(normalized_list)
2549
+
2550
+ def _update_config_with_normalized_emails(
2551
+ self, original_emails: list[str]
2552
+ ) -> None:
2553
+ """Update configuration file with normalized email addresses.
2554
+
2555
+ Args:
2556
+ original_emails: List of original emails that were normalized
2557
+ """
2558
+ try:
2559
+ # Get current organization for config lookup
2560
+ org = os.getenv("ORGANIZATION") or os.getenv(
2561
+ "GITHUB_REPOSITORY_OWNER"
2562
+ )
2563
+ if not org:
2564
+ log.debug("No organization found, skipping config file update")
2565
+ return
2566
+
2567
+ config_path = os.getenv("G2G_CONFIG_PATH", "").strip()
2568
+ if not config_path:
2569
+ config_path = "~/.config/github2gerrit/configuration.txt"
2570
+
2571
+ config_path_obj = Path(config_path).expanduser()
2572
+ if not config_path_obj.exists():
2573
+ log.debug(
2574
+ "Config file does not exist, skipping update: %s",
2575
+ config_path_obj,
2576
+ )
2577
+ return
2578
+
2579
+ # Read current config content
2580
+ content = config_path_obj.read_text(encoding="utf-8")
2581
+ original_content = content
2582
+
2583
+ # Look for email addresses in the content and normalize them
2584
+ for original_email in original_emails:
2585
+ normalized_email = original_email.lower()
2586
+ if normalized_email != original_email:
2587
+ # Replace the original email with normalized version
2588
+ # This handles both quoted and unquoted email addresses
2589
+ patterns = [
2590
+ f'"{original_email}"', # Quoted
2591
+ f"'{original_email}'", # Single quoted
2592
+ original_email, # Unquoted
2593
+ ]
2594
+
2595
+ for pattern in patterns:
2596
+ if pattern in content:
2597
+ replacement = pattern.replace(
2598
+ original_email, normalized_email
2599
+ )
2600
+ content = content.replace(pattern, replacement)
2601
+ log.info(
2602
+ "Updated config file: %s -> %s",
2603
+ pattern,
2604
+ replacement,
2605
+ )
2606
+
2607
+ # Write back if changes were made
2608
+ if content != original_content:
2609
+ config_path_obj.write_text(content, encoding="utf-8")
2610
+ log.info(
2611
+ "Configuration file updated with normalized email "
2612
+ "addresses: %s",
2613
+ config_path_obj,
2614
+ )
2615
+ else:
2616
+ log.debug(
2617
+ "No email addresses found in config file to normalize"
2618
+ )
2619
+
2620
+ except Exception as exc:
2621
+ log.warning(
2622
+ "Failed to update configuration file with normalized "
2623
+ "emails: %s",
2624
+ exc,
2625
+ )
2626
+
1544
2627
  def _should_handle_unrelated_history(self, exc: CommandError) -> bool:
1545
- """Check if we should handle unrelated repository history in CI testing mode."""
2628
+ """Check if we should handle unrelated repository history in CI
2629
+ testing mode."""
1546
2630
  if not env_bool("CI_TESTING", False):
1547
2631
  return False
1548
2632
 
@@ -1565,25 +2649,61 @@ class Orchestrator:
1565
2649
  return any(p in combined_lower for p in phrases)
1566
2650
 
1567
2651
  def _create_orphan_commit_and_push(
1568
- self, gerrit: GerritInfo, repo: RepoNames, branch: str, reviewers: str, topic: str, env: dict[str, str]
2652
+ self,
2653
+ gerrit: GerritInfo,
2654
+ repo: RepoNames,
2655
+ branch: str,
2656
+ reviewers: str,
2657
+ topic: str,
2658
+ env: dict[str, str],
1569
2659
  ) -> None:
1570
- """Create a synthetic commit on top of the remote base with the PR tree (CI testing mode)."""
1571
- log.info("CI_TESTING: Creating synthetic commit on top of remote base for unrelated repository")
2660
+ """Create a synthetic commit on top of the remote base with the PR
2661
+ tree (CI testing mode)."""
2662
+ log.debug(
2663
+ "CI_TESTING: Creating synthetic commit on top of remote base "
2664
+ "for unrelated repository"
2665
+ )
1572
2666
 
1573
2667
  try:
1574
2668
  # Capture the current PR commit message and tree
1575
- commit_msg = run_cmd(["git", "log", "--format=%B", "-n", "1", "HEAD"], cwd=self.workspace).stdout.strip()
1576
- pr_tree = run_cmd(["git", "show", "--quiet", "--format=%T", "HEAD"], cwd=self.workspace).stdout.strip()
2669
+ commit_msg = run_cmd(
2670
+ ["git", "log", "--format=%B", "-n", "1", "HEAD"],
2671
+ cwd=self.workspace,
2672
+ ).stdout.strip()
2673
+ pr_tree = run_cmd(
2674
+ ["git", "show", "--quiet", "--format=%T", "HEAD"],
2675
+ cwd=self.workspace,
2676
+ ).stdout.strip()
1577
2677
 
1578
2678
  # Create/update a synthetic branch based on the remote base branch
1579
2679
  synth_branch = f"synth-{topic}"
1580
2680
  # Ensure remote ref exists locally (best-effort)
1581
- run_cmd(["git", "fetch", "gerrit", branch], cwd=self.workspace, env=env, check=False)
1582
- run_cmd(["git", "checkout", "-B", synth_branch, f"remotes/gerrit/{branch}"], cwd=self.workspace, env=env)
2681
+ run_cmd(
2682
+ ["git", "fetch", "gerrit", branch],
2683
+ cwd=self.workspace,
2684
+ env=env,
2685
+ check=False,
2686
+ )
2687
+ run_cmd(
2688
+ [
2689
+ "git",
2690
+ "checkout",
2691
+ "-B",
2692
+ synth_branch,
2693
+ f"remotes/gerrit/{branch}",
2694
+ ],
2695
+ cwd=self.workspace,
2696
+ env=env,
2697
+ )
1583
2698
 
1584
2699
  # Replace working tree contents with the PR tree
1585
2700
  # 1) Remove current tracked files (ignore errors if none)
1586
- run_cmd(["git", "rm", "-r", "--quiet", "."], cwd=self.workspace, env=env, check=False)
2701
+ run_cmd(
2702
+ ["git", "rm", "-r", "--quiet", "."],
2703
+ cwd=self.workspace,
2704
+ env=env,
2705
+ check=False,
2706
+ )
1587
2707
  # 2) Clean untracked files/dirs (preserve our SSH known_hosts dir)
1588
2708
  run_cmd(
1589
2709
  ["git", "clean", "-fdx", "-e", ".ssh-g2g", "-e", ".ssh-g2g/**"],
@@ -1592,15 +2712,23 @@ class Orchestrator:
1592
2712
  check=False,
1593
2713
  )
1594
2714
  # 3) Checkout the PR tree into working directory
1595
- run_cmd(["git", "checkout", pr_tree, "--", "."], cwd=self.workspace, env=env)
2715
+ run_cmd(
2716
+ ["git", "checkout", pr_tree, "--", "."],
2717
+ cwd=self.workspace,
2718
+ env=env,
2719
+ )
1596
2720
  run_cmd(["git", "add", "-A"], cwd=self.workspace, env=env)
1597
2721
 
1598
- # Commit synthetic change with the same message (should already include Change-Id)
2722
+ # Commit synthetic change with the same message (should already
2723
+ # include Change-Id)
1599
2724
  import tempfile as _tempfile
1600
2725
  from pathlib import Path as _Path
1601
2726
 
1602
- with _tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as _tf:
1603
- # Ensure Signed-off-by for current committer (uploader) is present in the footer
2727
+ with _tempfile.NamedTemporaryFile(
2728
+ "w", delete=False, encoding="utf-8"
2729
+ ) as _tf:
2730
+ # Ensure Signed-off-by for current committer (uploader) is
2731
+ # present in the footer
1604
2732
  try:
1605
2733
  committer_name = run_cmd(
1606
2734
  ["git", "config", "--get", "user.name"],
@@ -1617,7 +2745,9 @@ class Orchestrator:
1617
2745
  committer_email = ""
1618
2746
  msg_to_write = commit_msg
1619
2747
  if committer_name and committer_email:
1620
- sob_line = f"Signed-off-by: {committer_name} <{committer_email}>"
2748
+ sob_line = (
2749
+ f"Signed-off-by: {committer_name} <{committer_email}>"
2750
+ )
1621
2751
  if sob_line not in msg_to_write:
1622
2752
  if not msg_to_write.endswith("\n"):
1623
2753
  msg_to_write += "\n"
@@ -1628,20 +2758,39 @@ class Orchestrator:
1628
2758
  _tf.flush()
1629
2759
  _tmp_msg_path = _Path(_tf.name)
1630
2760
  try:
1631
- run_cmd(["git", "commit", "-F", str(_tmp_msg_path)], cwd=self.workspace, env=env)
2761
+ run_cmd(
2762
+ ["git", "commit", "-F", str(_tmp_msg_path)],
2763
+ cwd=self.workspace,
2764
+ env=env,
2765
+ )
1632
2766
  finally:
1633
2767
  from contextlib import suppress
1634
2768
 
1635
2769
  with suppress(Exception):
1636
2770
  _tmp_msg_path.unlink(missing_ok=True)
1637
2771
 
1638
- # Push directly to refs/for/<branch> with topic and reviewers to avoid rebase behavior
2772
+ # Push directly to refs/for/<branch> with topic and reviewers to
2773
+ # avoid rebase behavior
1639
2774
  push_ref = f"refs/for/{branch}%topic={topic}"
1640
- revs = [r.strip() for r in (reviewers or "").split(",") if r.strip() and "@" in r and r.strip() != branch]
2775
+ revs = [
2776
+ r.strip()
2777
+ for r in (reviewers or "").split(",")
2778
+ if r.strip() and "@" in r and r.strip() != branch
2779
+ ]
1641
2780
  for r in revs:
1642
2781
  push_ref += f",r={r}"
1643
- run_cmd(["git", "push", "--no-follow-tags", "gerrit", f"HEAD:{push_ref}"], cwd=self.workspace, env=env)
1644
- log.info("Successfully pushed synthetic commit to Gerrit")
2782
+ run_cmd(
2783
+ [
2784
+ "git",
2785
+ "push",
2786
+ "--no-follow-tags",
2787
+ "gerrit",
2788
+ f"HEAD:{push_ref}",
2789
+ ],
2790
+ cwd=self.workspace,
2791
+ env=env,
2792
+ )
2793
+ log.debug("Successfully pushed synthetic commit to Gerrit")
1645
2794
 
1646
2795
  except CommandError as orphan_exc:
1647
2796
  error_details = self._analyze_gerrit_push_failure(orphan_exc)
@@ -1655,6 +2804,10 @@ class Orchestrator:
1655
2804
  combined_output = f"{stdout}\n{stderr}"
1656
2805
  combined_lower = combined_output.lower()
1657
2806
 
2807
+ # Remove extra whitespace and normalize line breaks for better pattern
2808
+ # matching
2809
+ normalized_output = " ".join(combined_lower.split())
2810
+
1658
2811
  # Check for SSH host key verification failures first
1659
2812
  if (
1660
2813
  "host key verification failed" in combined_lower
@@ -1671,7 +2824,10 @@ class Orchestrator:
1671
2824
  "'ssh-keyscan -p 29418 <gerrit-host>' "
1672
2825
  "to get the current host keys."
1673
2826
  )
1674
- elif "authenticity of host" in combined_lower and "can't be established" in combined_lower:
2827
+ elif (
2828
+ "authenticity of host" in combined_lower
2829
+ and "can't be established" in combined_lower
2830
+ ):
1675
2831
  return (
1676
2832
  "SSH host key unknown. The GERRIT_KNOWN_HOSTS value does not "
1677
2833
  "contain the host key for the Gerrit server. "
@@ -1681,18 +2837,37 @@ class Orchestrator:
1681
2837
  "'ssh-keyscan -p 29418 <gerrit-host>' to get the host keys."
1682
2838
  )
1683
2839
  # Check for specific SSH key issues before general permission denied
1684
- elif "key_load_public" in combined_lower and "invalid format" in combined_lower:
1685
- return "SSH key format is invalid. Check that the SSH private key is properly formatted."
1686
- elif "no matching host key type found" in combined_lower:
1687
- return "SSH key type not supported by server. The server may not accept this SSH key algorithm."
2840
+ elif (
2841
+ "key_load_public" in combined_lower
2842
+ and "invalid format" in combined_lower
2843
+ ):
2844
+ return (
2845
+ "SSH key format is invalid. Check that the SSH private key "
2846
+ "is properly formatted."
2847
+ )
2848
+ elif "no matching host key type found" in normalized_output:
2849
+ return (
2850
+ "SSH key type not supported by server. The server may not "
2851
+ "accept this SSH key algorithm."
2852
+ )
1688
2853
  elif "authentication failed" in combined_lower:
1689
- return "SSH authentication failed - check SSH key, username, and server configuration"
2854
+ return (
2855
+ "SSH authentication failed - check SSH key, username, and "
2856
+ "server configuration"
2857
+ )
1690
2858
  # Check for connection timeout/refused before "could not read" check
1691
- elif "connection timed out" in combined_lower or "connection refused" in combined_lower:
1692
- return "Connection failed - check network connectivity and Gerrit server availability"
2859
+ elif (
2860
+ "connection timed out" in combined_lower
2861
+ or "connection refused" in combined_lower
2862
+ ):
2863
+ return (
2864
+ "Connection failed - check network connectivity and Gerrit "
2865
+ "server availability"
2866
+ )
1693
2867
  # Check for specific SSH publickey-only authentication failures
1694
2868
  elif "permission denied (publickey)" in combined_lower and not any(
1695
- auth_method in combined_lower for auth_method in ["gssapi", "password", "keyboard"]
2869
+ auth_method in combined_lower
2870
+ for auth_method in ["gssapi", "password", "keyboard"]
1696
2871
  ):
1697
2872
  return (
1698
2873
  "SSH public key authentication failed. The SSH key may be "
@@ -1702,14 +2877,33 @@ class Orchestrator:
1702
2877
  elif "permission denied" in combined_lower:
1703
2878
  return "SSH permission denied - check SSH key and user permissions"
1704
2879
  elif "could not read from remote repository" in combined_lower:
1705
- return "Could not read from remote repository - check SSH authentication and repository access permissions"
2880
+ return (
2881
+ "Could not read from remote repository - check SSH "
2882
+ "authentication and repository access permissions"
2883
+ )
1706
2884
  # Check for Gerrit-specific issues
1707
2885
  elif "missing issue-id" in combined_lower:
1708
2886
  return "Missing Issue-ID in commit message."
1709
2887
  elif "commit not associated to any issue" in combined_lower:
1710
2888
  return "Commit not associated to any issue."
1711
- elif "remote rejected" in combined_lower and "refs/for/" in combined_lower:
2889
+ elif (
2890
+ "remote rejected" in combined_lower
2891
+ and "refs/for/" in combined_lower
2892
+ ):
1712
2893
  # Extract specific rejection reason from output
2894
+ # Handle multiline rejection messages by looking in normalized
2895
+ # output
2896
+ import re
2897
+
2898
+ # Look for the rejection pattern in the normalized output
2899
+ rejection_match = re.search(
2900
+ r"!\s*\[remote rejected\].*?\((.*?)\)", normalized_output
2901
+ )
2902
+ if rejection_match:
2903
+ reason = rejection_match.group(1).strip()
2904
+ return f"Gerrit rejected the push: {reason}"
2905
+
2906
+ # Fallback: look line by line
1713
2907
  lines = combined_output.split("\n")
1714
2908
  for line in lines:
1715
2909
  if "! [remote rejected]" in line:
@@ -1730,35 +2924,22 @@ class Orchestrator:
1730
2924
  change_ids: Sequence[str],
1731
2925
  ) -> SubmissionResult:
1732
2926
  """Query Gerrit for change URL/number and patchset sha via REST."""
1733
- log.info("Querying Gerrit for submitted change(s) via REST")
2927
+ log.debug("Querying Gerrit for submitted change(s) via REST")
1734
2928
 
1735
- # pygerrit2 netrc filter is already applied in execute() unless verbose mode
2929
+ # pygerrit2 netrc filter is already applied in execute() unless
2930
+ # verbose mode
1736
2931
 
1737
2932
  # Create centralized URL builder (auto-discovers base path)
1738
2933
  url_builder = create_gerrit_url_builder(gerrit.host)
1739
2934
 
1740
2935
  # Get authentication credentials
1741
- http_user = os.getenv("GERRIT_HTTP_USER", "").strip() or os.getenv("GERRIT_SSH_USER_G2G", "").strip()
2936
+ http_user = (
2937
+ os.getenv("GERRIT_HTTP_USER", "").strip()
2938
+ or os.getenv("GERRIT_SSH_USER_G2G", "").strip()
2939
+ )
1742
2940
  http_pass = os.getenv("GERRIT_HTTP_PASSWORD", "").strip()
1743
2941
 
1744
- # Build Gerrit REST client (prefer HTTP basic auth if provided)
1745
- if GerritRestAPI is None:
1746
- raise OrchestratorError(_MSG_PYGERRIT2_REQUIRED_REST)
1747
-
1748
- def _create_rest_client(base_url: str) -> Any:
1749
- """Helper to create REST client with optional auth."""
1750
- if http_user and http_pass:
1751
- if HTTPBasicAuth is None:
1752
- raise OrchestratorError(_MSG_PYGERRIT2_REQUIRED_AUTH)
1753
- if GerritRestAPI is None:
1754
- raise OrchestratorError(_MSG_PYGERRIT2_REQUIRED_REST)
1755
- return GerritRestAPI(url=base_url, auth=HTTPBasicAuth(http_user, http_pass))
1756
- else:
1757
- if GerritRestAPI is None:
1758
- raise OrchestratorError(_MSG_PYGERRIT2_REQUIRED_REST)
1759
- return GerritRestAPI(url=base_url)
1760
-
1761
- # Try API URLs in order of preference (client creation happens in retry loop)
2942
+ # Query changes using centralized REST client
1762
2943
  urls: list[str] = []
1763
2944
  nums: list[str] = []
1764
2945
  shas: list[str] = []
@@ -1785,7 +2966,9 @@ class Orchestrator:
1785
2966
  log.debug("Gerrit API base URL (discovered): %s", api_base_url)
1786
2967
  changes = client.get(path)
1787
2968
  except Exception as exc:
1788
- log.warning("Failed to query change via REST for %s: %s", cid, exc)
2969
+ log.warning(
2970
+ "Failed to query change via REST for %s: %s", cid, exc
2971
+ )
1789
2972
  continue
1790
2973
  if not changes:
1791
2974
  continue
@@ -1799,13 +2982,17 @@ class Orchestrator:
1799
2982
  continue
1800
2983
  # Construct a stable web URL for the change
1801
2984
  if num:
1802
- change_url = url_builder.change_url(repo.project_gerrit, int(num))
2985
+ change_url = url_builder.change_url(
2986
+ repo.project_gerrit, int(num)
2987
+ )
1803
2988
  urls.append(change_url)
1804
2989
  nums.append(num)
1805
2990
  if current_rev:
1806
2991
  shas.append(current_rev)
1807
2992
 
1808
- return SubmissionResult(change_urls=urls, change_numbers=nums, commit_shas=shas)
2993
+ return SubmissionResult(
2994
+ change_urls=urls, change_numbers=nums, commit_shas=shas
2995
+ )
1809
2996
 
1810
2997
  def _setup_git_workspace(self, inputs: Inputs, gh: GitHubContext) -> None:
1811
2998
  """Initialize and set up git workspace for PR processing."""
@@ -1813,7 +3000,9 @@ class Orchestrator:
1813
3000
 
1814
3001
  # Try modern git init with explicit branch first
1815
3002
  try:
1816
- run_cmd(["git", "init", "--initial-branch=master"], cwd=self.workspace)
3003
+ run_cmd(
3004
+ ["git", "init", "--initial-branch=master"], cwd=self.workspace
3005
+ )
1817
3006
  except Exception:
1818
3007
  # Fallback for older git versions (hint filtered at logging level)
1819
3008
  run_cmd(["git", "init"], cwd=self.workspace)
@@ -1830,7 +3019,10 @@ class Orchestrator:
1830
3019
 
1831
3020
  # Fetch PR head
1832
3021
  if gh.pr_number:
1833
- pr_ref = f"refs/pull/{gh.pr_number}/head:refs/remotes/origin/pr/{gh.pr_number}/head"
3022
+ pr_ref = (
3023
+ f"refs/pull/{gh.pr_number}/head:refs/remotes/origin/pr/"
3024
+ f"{gh.pr_number}/head"
3025
+ )
1834
3026
  run_cmd(
1835
3027
  [
1836
3028
  "git",
@@ -1866,10 +3058,14 @@ class Orchestrator:
1866
3058
  def _raise_orch(msg: str) -> None:
1867
3059
  raise OrchestratorError(msg) # noqa: TRY301
1868
3060
 
1869
- _MSG_HOOK_SIZE_BOUNDS = "commit-msg hook size outside expected bounds"
3061
+ _MSG_HOOK_SIZE_BOUNDS = (
3062
+ "commit-msg hook size outside expected bounds"
3063
+ )
1870
3064
  _MSG_HOOK_READ_FAILED = "failed reading commit-msg hook"
1871
3065
  _MSG_HOOK_NO_SHEBANG = "commit-msg hook missing shebang"
1872
- _MSG_HOOK_BAD_CONTENT = "commit-msg hook content lacks expected markers"
3066
+ _MSG_HOOK_BAD_CONTENT = (
3067
+ "commit-msg hook content lacks expected markers"
3068
+ )
1873
3069
 
1874
3070
  # Use centralized curl download with retry/logging/metrics
1875
3071
  return_code, status_code = curl_download(
@@ -1882,7 +3078,8 @@ class Orchestrator:
1882
3078
 
1883
3079
  size = hook_path.stat().st_size
1884
3080
  log.debug(
1885
- "curl fetch of commit-msg: url=%s http_status=%s size=%dB rc=%s",
3081
+ "curl fetch of commit-msg: url=%s http_status=%s size=%dB "
3082
+ "rc=%s",
1886
3083
  hook_url,
1887
3084
  status_code,
1888
3085
  size,
@@ -1904,33 +3101,50 @@ class Orchestrator:
1904
3101
  if not text_head.startswith("#!"):
1905
3102
  _raise_orch(_MSG_HOOK_NO_SHEBANG)
1906
3103
  # Look for recognizable strings
1907
- if not any(m in text_head for m in ("Change-Id", "Gerrit Code Review", "add_change_id")):
3104
+ if not any(
3105
+ m in text_head
3106
+ for m in ("Change-Id", "Gerrit Code Review", "add_change_id")
3107
+ ):
1908
3108
  _raise_orch(_MSG_HOOK_BAD_CONTENT)
1909
3109
 
1910
3110
  # Make hook executable (single chmod)
1911
3111
  hook_path.chmod(hook_path.stat().st_mode | stat.S_IEXEC)
1912
- log.debug("Successfully installed commit-msg hook from %s", hook_url)
3112
+ log.debug(
3113
+ "Successfully installed commit-msg hook from %s", hook_url
3114
+ )
1913
3115
 
1914
3116
  except Exception as exc:
1915
- log.warning("Failed to install commit-msg hook via centralized curl: %s", exc)
3117
+ log.warning(
3118
+ "Failed to install commit-msg hook via centralized curl: %s",
3119
+ exc,
3120
+ )
1916
3121
  msg = f"Could not install commit-msg hook: {exc}"
1917
3122
  raise OrchestratorError(msg) from exc
1918
3123
 
1919
- def _ensure_change_id_present(self, gerrit: GerritInfo, author: str) -> list[str]:
3124
+ def _ensure_change_id_present(
3125
+ self, gerrit: GerritInfo, author: str
3126
+ ) -> list[str]:
1920
3127
  """Ensure the last commit has a Change-Id.
1921
3128
 
1922
3129
  Installs the commit-msg hook and amends the commit if needed.
1923
3130
  """
1924
- trailers = git_last_commit_trailers(keys=["Change-Id"], cwd=self.workspace)
3131
+ trailers = git_last_commit_trailers(
3132
+ keys=["Change-Id"], cwd=self.workspace
3133
+ )
1925
3134
  existing_change_ids = trailers.get("Change-Id", [])
1926
3135
 
1927
3136
  if existing_change_ids:
1928
- log.debug("Found existing Change-Id(s) in footer: %s", existing_change_ids)
3137
+ log.debug(
3138
+ "Found existing Change-Id(s) in footer: %s", existing_change_ids
3139
+ )
1929
3140
  # Clean up any duplicate Change-IDs in the message body
1930
3141
  self._clean_change_ids_from_body(author)
1931
3142
  return [c for c in existing_change_ids if c]
1932
3143
 
1933
- log.debug("No Change-Id found; attempting to install commit-msg hook and amend commit")
3144
+ log.debug(
3145
+ "No Change-Id found; attempting to install commit-msg hook and "
3146
+ "amend commit"
3147
+ )
1934
3148
  try:
1935
3149
  self._install_commit_msg_hook(gerrit)
1936
3150
  git_commit_amend(
@@ -1941,7 +3155,8 @@ class Orchestrator:
1941
3155
  )
1942
3156
  except Exception as exc:
1943
3157
  log.warning(
1944
- "Commit-msg hook installation failed, falling back to direct Change-Id injection: %s",
3158
+ "Commit-msg hook installation failed, falling back to direct "
3159
+ "Change-Id injection: %s",
1945
3160
  exc,
1946
3161
  )
1947
3162
  # Fallback: generate a Change-Id and append to the commit
@@ -1955,11 +3170,15 @@ class Orchestrator:
1955
3170
  seed = f"{current_msg}\n{time.time()}"
1956
3171
  import hashlib as _hashlib # local alias to satisfy linters
1957
3172
 
1958
- change_id = "I" + _hashlib.sha256(seed.encode("utf-8")).hexdigest()[:40]
3173
+ change_id = (
3174
+ "I" + _hashlib.sha256(seed.encode("utf-8")).hexdigest()[:40]
3175
+ )
1959
3176
 
1960
3177
  # Clean message and ensure proper footer placement
1961
3178
  cleaned_msg = self._clean_commit_message_for_change_id(current_msg)
1962
- new_msg = cleaned_msg.rstrip() + "\n\n" + f"Change-Id: {change_id}\n"
3179
+ new_msg = (
3180
+ cleaned_msg.rstrip() + "\n\n" + f"Change-Id: {change_id}\n"
3181
+ )
1963
3182
  git_commit_amend(
1964
3183
  no_edit=False,
1965
3184
  signoff=True,
@@ -1973,11 +3192,14 @@ class Orchestrator:
1973
3192
  cwd=self.workspace,
1974
3193
  ).stdout.strip()
1975
3194
  log.debug("Commit message after amend:\n%s", actual_msg)
1976
- trailers = git_last_commit_trailers(keys=["Change-Id"], cwd=self.workspace)
3195
+ trailers = git_last_commit_trailers(
3196
+ keys=["Change-Id"], cwd=self.workspace
3197
+ )
1977
3198
  return [c for c in trailers.get("Change-Id", []) if c]
1978
3199
 
1979
3200
  def _clean_change_ids_from_body(self, author: str) -> None:
1980
- """Remove any Change-Id lines from the commit message body, keeping only footer trailers."""
3201
+ """Remove any Change-Id lines from the commit message body, keeping
3202
+ only footer trailers."""
1981
3203
  current_msg = run_cmd(
1982
3204
  ["git", "show", "-s", "--pretty=format:%B", "HEAD"],
1983
3205
  cwd=self.workspace,
@@ -1996,16 +3218,22 @@ class Orchestrator:
1996
3218
  )
1997
3219
 
1998
3220
  def _clean_commit_message_for_change_id(self, message: str) -> str:
1999
- """Remove Change-Id lines from message body while preserving footer trailers."""
3221
+ """Remove Change-Id lines from message body while preserving footer
3222
+ trailers."""
2000
3223
  lines = message.splitlines()
2001
3224
 
2002
3225
  # Parse proper trailers using the fixed trailer parser
2003
3226
  trailers = _parse_trailers(message)
2004
3227
  change_id_trailers = trailers.get("Change-Id", [])
2005
3228
  signed_off_trailers = trailers.get("Signed-off-by", [])
2006
- other_trailers = {k: v for k, v in trailers.items() if k not in ["Change-Id", "Signed-off-by"]}
2007
-
2008
- # Find trailer section by working backwards to find continuous trailer block
3229
+ other_trailers = {
3230
+ k: v
3231
+ for k, v in trailers.items()
3232
+ if k not in ["Change-Id", "Signed-off-by"]
3233
+ }
3234
+
3235
+ # Find trailer section by working backwards to find continuous
3236
+ # trailer block
2009
3237
  trailer_start = len(lines)
2010
3238
 
2011
3239
  # Work backwards to find where trailers start
@@ -2024,12 +3252,15 @@ class Orchestrator:
2024
3252
  key, val = line.split(":", 1)
2025
3253
  k = key.strip()
2026
3254
  v = val.strip()
2027
- if not (k and v and not k.startswith(" ") and not k.startswith("\t")):
3255
+ if not (
3256
+ k and v and not k.startswith(" ") and not k.startswith("\t")
3257
+ ):
2028
3258
  # Invalid trailer format - trailers start after this
2029
3259
  trailer_start = i + 1
2030
3260
  break
2031
3261
 
2032
- # Process body lines (before trailers) and remove any Change-Id references
3262
+ # Process body lines (before trailers) and remove any Change-Id
3263
+ # references
2033
3264
  body_lines = []
2034
3265
  for i in range(trailer_start):
2035
3266
  line = lines[i]
@@ -2037,19 +3268,28 @@ class Orchestrator:
2037
3268
  if "Change-Id:" in line:
2038
3269
  # If line starts with Change-Id:, skip it entirely
2039
3270
  if line.strip().startswith("Change-Id:"):
2040
- log.debug("Removing Change-Id line from body: %s", line.strip())
3271
+ log.debug(
3272
+ "Removing Change-Id line from body: %s", line.strip()
3273
+ )
2041
3274
  continue
2042
3275
  else:
2043
- # If Change-Id is mentioned within the line, remove that part
3276
+ # If Change-Id is mentioned within the line, remove that
3277
+ # part
2044
3278
  original_line = line
2045
3279
  # Remove Change-Id: followed by the ID value
2046
3280
 
2047
- # Pattern to match "Change-Id: <value>" where value can contain word chars, hyphens, etc.
3281
+ # Pattern to match "Change-Id: <value>" where value can
3282
+ # contain word chars, hyphens, etc.
2048
3283
  line = re.sub(r"Change-Id:\s*[A-Za-z0-9._-]+\b", "", line)
2049
3284
  # Clean up extra whitespace
2050
3285
  line = re.sub(r"\s+", " ", line).strip()
2051
3286
  if line != original_line:
2052
- log.debug("Cleaned Change-Id reference from body line: %s -> %s", original_line.strip(), line)
3287
+ log.debug(
3288
+ "Cleaned Change-Id reference from body line: "
3289
+ "%s -> %s",
3290
+ original_line.strip(),
3291
+ line,
3292
+ )
2053
3293
  body_lines.append(line)
2054
3294
 
2055
3295
  # Remove trailing empty lines from body
@@ -2099,17 +3339,66 @@ class Orchestrator:
2099
3339
  "1",
2100
3340
  "yes",
2101
3341
  ):
2102
- log.info("Skipping back-reference comments (G2G_SKIP_GERRIT_COMMENTS=true)")
3342
+ log.info(
3343
+ "Skipping back-reference comments "
3344
+ "(G2G_SKIP_GERRIT_COMMENTS=true)"
3345
+ )
2103
3346
  return
2104
3347
 
2105
- log.info("Adding back-reference comment in Gerrit")
3348
+ log.debug("Adding back-reference comment in Gerrit")
2106
3349
  user = os.getenv("GERRIT_SSH_USER_G2G", "")
2107
3350
  server = gerrit.host
2108
3351
  pr_url = f"{gh.server_url}/{gh.repository}/pull/{gh.pr_number}"
2109
- run_url = f"{gh.server_url}/{gh.repository}/actions/runs/{gh.run_id}" if gh.run_id else "N/A"
3352
+ run_url = (
3353
+ f"{gh.server_url}/{gh.repository}/actions/runs/{gh.run_id}"
3354
+ if gh.run_id
3355
+ else "N/A"
3356
+ )
2110
3357
  message = f"GHPR: {pr_url} | Action-Run: {run_url}"
2111
- log.info("Adding back-reference comment: %s", message)
3358
+ log.debug("Adding back-reference comment: %s", message)
3359
+ # Idempotence override: allow forcing duplicate comments (debug/testing)
3360
+ force_dup = os.getenv("G2G_FORCE_BACKREF_DUPLICATE", "").lower() in (
3361
+ "1",
3362
+ "true",
3363
+ "yes",
3364
+ )
3365
+
3366
+ def _has_existing_backref(commit_sha: str) -> bool:
3367
+ if force_dup:
3368
+ return False
3369
+ try:
3370
+ from .gerrit_rest import build_client_for_host
3371
+
3372
+ client = build_client_for_host(
3373
+ gerrit.host, timeout=8.0, max_attempts=3
3374
+ )
3375
+ # Query change messages for this commit
3376
+ path = f"/changes/?q=commit:{commit_sha}&o=MESSAGES"
3377
+ data = client.get(path)
3378
+ if not isinstance(data, list):
3379
+ return False
3380
+ for entry in data:
3381
+ msgs = entry.get("messages") or []
3382
+ for msg in msgs:
3383
+ txt = (msg.get("message") or "").strip()
3384
+ if "GHPR:" in txt and pr_url in txt:
3385
+ log.debug(
3386
+ "Skipping back-reference for %s "
3387
+ "(already present)",
3388
+ commit_sha,
3389
+ )
3390
+ return True
3391
+ except Exception as exc:
3392
+ log.debug(
3393
+ "Backref idempotence check failed for %s: %s",
3394
+ commit_sha,
3395
+ exc,
3396
+ )
3397
+ return False
3398
+
2112
3399
  for csha in commit_shas:
3400
+ if _has_existing_backref(csha):
3401
+ continue
2113
3402
  if not csha:
2114
3403
  continue
2115
3404
  try:
@@ -2151,7 +3440,11 @@ class Orchestrator:
2151
3440
  f"{shlex.quote(csha)}"
2152
3441
  ),
2153
3442
  ]
2154
- elif self._use_ssh_agent and self._ssh_agent_manager and self._ssh_agent_manager.known_hosts_path:
3443
+ elif (
3444
+ self._use_ssh_agent
3445
+ and self._ssh_agent_manager
3446
+ and self._ssh_agent_manager.known_hosts_path
3447
+ ):
2155
3448
  # SSH agent authentication with known_hosts
2156
3449
  ssh_cmd = [
2157
3450
  "ssh",
@@ -2226,14 +3519,15 @@ class Orchestrator:
2226
3519
  cwd=self.workspace,
2227
3520
  env=self._ssh_env(),
2228
3521
  )
2229
- log.info(
3522
+ log.debug(
2230
3523
  "Successfully added back-reference comment for %s: %s",
2231
3524
  csha,
2232
3525
  message,
2233
3526
  )
2234
3527
  except CommandError as exc:
2235
3528
  log.warning(
2236
- "Failed to add back-reference comment for %s (non-fatal): %s",
3529
+ "Failed to add back-reference comment for %s "
3530
+ "(non-fatal): %s",
2237
3531
  csha,
2238
3532
  exc,
2239
3533
  )
@@ -2249,11 +3543,14 @@ class Orchestrator:
2249
3543
  # Continue processing - this is not a fatal error
2250
3544
  except Exception as exc:
2251
3545
  log.warning(
2252
- "Failed to add back-reference comment for %s (non-fatal): %s",
3546
+ "Failed to add back-reference comment for %s "
3547
+ "(non-fatal): %s",
2253
3548
  csha,
2254
3549
  exc,
2255
3550
  )
2256
- log.debug("Back-reference comment failure details:", exc_info=True)
3551
+ log.debug(
3552
+ "Back-reference comment failure details:", exc_info=True
3553
+ )
2257
3554
  # Continue processing - this is not a fatal error
2258
3555
 
2259
3556
  def _comment_on_pull_request(
@@ -2265,9 +3562,12 @@ class Orchestrator:
2265
3562
  """Post a comment on the PR with the Gerrit change URL(s)."""
2266
3563
  # Respect CI_TESTING: do not attempt to update the source/origin PR
2267
3564
  if os.getenv("CI_TESTING", "").strip().lower() in ("1", "true", "yes"):
2268
- log.debug("Source/origin pull request will NOT be updated with Gerrit change when CI_TESTING set true")
3565
+ log.debug(
3566
+ "Source/origin pull request will NOT be updated with Gerrit "
3567
+ "change when CI_TESTING set true"
3568
+ )
2269
3569
  return
2270
- log.info("Adding reference comment on PR #%s", gh.pr_number)
3570
+ log.debug("Adding reference comment on PR #%s", gh.pr_number)
2271
3571
  if not gh.pr_number:
2272
3572
  return
2273
3573
  urls = result.change_urls or []
@@ -2275,7 +3575,10 @@ class Orchestrator:
2275
3575
  # Create centralized URL builder for organization link
2276
3576
  url_builder = create_gerrit_url_builder(gerrit.host)
2277
3577
  org_url = url_builder.web_url()
2278
- text = f"The pull-request PR-{gh.pr_number} is submitted to Gerrit [{org}]({org_url})!\n\n"
3578
+ text = (
3579
+ f"The pull-request PR-{gh.pr_number} is submitted to Gerrit "
3580
+ f"[{org}]({org_url})!\n\n"
3581
+ )
2279
3582
  if urls:
2280
3583
  text += "To follow up on the change visit:\n\n" + "\n".join(urls)
2281
3584
  try:
@@ -2345,24 +3648,29 @@ class Orchestrator:
2345
3648
  """
2346
3649
  import socket
2347
3650
 
2348
- log.info("Dry-run: starting preflight checks")
3651
+ log.debug("Dry-run: starting preflight checks")
2349
3652
  if os.getenv("G2G_DRYRUN_DISABLE_NETWORK", "").strip().lower() in (
2350
3653
  "1",
2351
3654
  "true",
2352
3655
  "yes",
2353
3656
  "on",
2354
3657
  ):
2355
- log.info("Dry-run: network checks disabled (G2G_DRYRUN_DISABLE_NETWORK)")
2356
- log.info(
2357
- "Dry-run targets: Gerrit project=%s branch=%s topic_prefix=GH-%s",
3658
+ log.debug(
3659
+ "Dry-run: network checks disabled (G2G_DRYRUN_DISABLE_NETWORK)"
3660
+ )
3661
+ log.debug(
3662
+ "Dry-run targets: Gerrit project=%s branch=%s "
3663
+ "topic_prefix=GH-%s",
2358
3664
  repo.project_gerrit,
2359
3665
  self._resolve_target_branch(),
2360
3666
  repo.project_github,
2361
3667
  )
2362
3668
  if inputs.reviewers_email:
2363
- log.info("Reviewers (from inputs/config): %s", inputs.reviewers_email)
3669
+ log.debug(
3670
+ "Reviewers (from inputs/config): %s", inputs.reviewers_email
3671
+ )
2364
3672
  elif os.getenv("REVIEWERS_EMAIL"):
2365
- log.info(
3673
+ log.debug(
2366
3674
  "Reviewers (from environment): %s",
2367
3675
  os.getenv("REVIEWERS_EMAIL"),
2368
3676
  )
@@ -2371,16 +3679,20 @@ class Orchestrator:
2371
3679
  # DNS resolution for Gerrit host
2372
3680
  try:
2373
3681
  socket.getaddrinfo(gerrit.host, None)
2374
- log.info("DNS resolution for Gerrit host '%s' succeeded", gerrit.host)
3682
+ log.debug(
3683
+ "DNS resolution for Gerrit host '%s' succeeded", gerrit.host
3684
+ )
2375
3685
  except Exception as exc:
2376
3686
  msg = "DNS resolution failed"
2377
3687
  raise OrchestratorError(msg) from exc
2378
3688
 
2379
3689
  # SSH (TCP) reachability on Gerrit port
2380
3690
  try:
2381
- with socket.create_connection((gerrit.host, gerrit.port), timeout=5):
3691
+ with socket.create_connection(
3692
+ (gerrit.host, gerrit.port), timeout=5
3693
+ ):
2382
3694
  pass
2383
- log.info(
3695
+ log.debug(
2384
3696
  "SSH TCP connectivity to %s:%s verified",
2385
3697
  gerrit.host,
2386
3698
  gerrit.port,
@@ -2391,7 +3703,10 @@ class Orchestrator:
2391
3703
 
2392
3704
  # Gerrit REST reachability and optional auth check
2393
3705
  base_path = os.getenv("GERRIT_HTTP_BASE_PATH", "").strip().strip("/")
2394
- http_user = os.getenv("GERRIT_HTTP_USER", "").strip() or os.getenv("GERRIT_SSH_USER_G2G", "").strip()
3706
+ http_user = (
3707
+ os.getenv("GERRIT_HTTP_USER", "").strip()
3708
+ or os.getenv("GERRIT_SSH_USER_G2G", "").strip()
3709
+ )
2395
3710
  http_pass = os.getenv("GERRIT_HTTP_PASSWORD", "").strip()
2396
3711
  self._verify_gerrit_rest(gerrit.host, base_path, http_user, http_pass)
2397
3712
 
@@ -2401,10 +3716,12 @@ class Orchestrator:
2401
3716
  repo_obj = get_repo_from_env(client)
2402
3717
  if gh.pr_number is not None:
2403
3718
  pr_obj = get_pull(repo_obj, gh.pr_number)
2404
- log.info("GitHub PR #%s metadata loaded successfully", gh.pr_number)
3719
+ log.debug(
3720
+ "GitHub PR #%s metadata loaded successfully", gh.pr_number
3721
+ )
2405
3722
  try:
2406
3723
  title, _ = get_pr_title_body(pr_obj)
2407
- log.info("GitHub PR title: %s", title)
3724
+ log.debug("GitHub PR title: %s", title)
2408
3725
  except Exception as exc:
2409
3726
  log.debug("Failed to read PR title: %s", exc)
2410
3727
  else:
@@ -2420,16 +3737,20 @@ class Orchestrator:
2420
3737
  raise OrchestratorError(msg) from exc
2421
3738
 
2422
3739
  # Log effective targets
2423
- log.info(
3740
+ log.debug(
2424
3741
  "Dry-run targets: Gerrit project=%s branch=%s topic_prefix=GH-%s",
2425
3742
  repo.project_gerrit,
2426
3743
  self._resolve_target_branch(),
2427
3744
  repo.project_github,
2428
3745
  )
2429
3746
  if inputs.reviewers_email:
2430
- log.info("Reviewers (from inputs/config): %s", inputs.reviewers_email)
3747
+ log.debug(
3748
+ "Reviewers (from inputs/config): %s", inputs.reviewers_email
3749
+ )
2431
3750
  elif os.getenv("REVIEWERS_EMAIL"):
2432
- log.info("Reviewers (from environment): %s", os.getenv("REVIEWERS_EMAIL"))
3751
+ log.info(
3752
+ "Reviewers (from environment): %s", os.getenv("REVIEWERS_EMAIL")
3753
+ )
2433
3754
 
2434
3755
  def _verify_gerrit_rest(
2435
3756
  self,
@@ -2440,41 +3761,36 @@ class Orchestrator:
2440
3761
  ) -> None:
2441
3762
  """Probe Gerrit REST endpoint with optional auth.
2442
3763
 
2443
- Uses the centralized URL builder to construct the API endpoint
2444
- for consistent URL handling across the application.
3764
+ Uses the centralized gerrit_rest client to ensure proper base path
3765
+ handling and consistent API interactions.
2445
3766
  """
3767
+ from .gerrit_rest import build_client_for_host
2446
3768
 
2447
- def _build_client(url: str) -> Any:
2448
- if http_user and http_pass:
2449
- if GerritRestAPI is None:
2450
- raise OrchestratorError(_MSG_PYGERRIT2_MISSING)
2451
- if HTTPBasicAuth is None:
2452
- raise OrchestratorError(_MSG_PYGERRIT2_AUTH_MISSING)
2453
- return GerritRestAPI(url=url, auth=HTTPBasicAuth(http_user, http_pass))
2454
- else:
2455
- if GerritRestAPI is None:
2456
- raise OrchestratorError(_MSG_PYGERRIT2_MISSING)
2457
- return GerritRestAPI(url=url)
3769
+ try:
3770
+ # Use centralized client builder that handles base path correctly
3771
+ client = build_client_for_host(
3772
+ host,
3773
+ timeout=8.0,
3774
+ max_attempts=3,
3775
+ http_user=http_user,
3776
+ http_password=http_pass,
3777
+ )
2458
3778
 
2459
- def _probe(url: str) -> None:
2460
- rest: Any = _build_client(url)
3779
+ # Test connectivity with appropriate endpoint
2461
3780
  if http_user and http_pass:
2462
- _ = rest.get("/accounts/self")
2463
- log.info(
3781
+ _ = client.get("/accounts/self")
3782
+ log.debug(
2464
3783
  "Gerrit REST authenticated access verified for user '%s'",
2465
3784
  http_user,
2466
3785
  )
2467
3786
  else:
2468
- _ = rest.get("/dashboard/self")
2469
- log.info("Gerrit REST endpoint reachable (unauthenticated)")
3787
+ _ = client.get("/dashboard/self")
3788
+ log.debug("Gerrit REST endpoint reachable (unauthenticated)")
2470
3789
 
2471
- # Create centralized URL builder for REST probing
2472
- url_builder = create_gerrit_url_builder(host, base_path)
2473
- api_url = url_builder.api_url()
2474
-
2475
- try:
2476
- _probe(api_url)
2477
3790
  except Exception as exc:
3791
+ # Use centralized URL builder for consistent error reporting
3792
+ url_builder = create_gerrit_url_builder(host, base_path)
3793
+ api_url = url_builder.api_url()
2478
3794
  log.warning("Gerrit REST probe failed for %s: %s", api_url, exc)
2479
3795
 
2480
3796
  # ---------------