github2gerrit 0.1.0__py3-none-any.whl → 0.1.3__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 ADDED
@@ -0,0 +1,1750 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2025 The Linux Foundation
3
+ #
4
+ # High-level orchestrator scaffold for the GitHub PR -> Gerrit flow.
5
+ #
6
+ # This module defines the public orchestration surface and typed data models
7
+ # used to execute the end-to-end workflow. The major steps are implemented:
8
+ # configuration resolution, commit preparation (single or squash), pushing
9
+ # to Gerrit, querying results, and posting comments, with a dry-run mode
10
+ # for non-destructive validations.
11
+ #
12
+ # Design principles applied:
13
+ # - Single Responsibility: orchestration logic is grouped here; git/exec
14
+ # helpers live in gitutils.py; CLI argument parsing lives in cli.py.
15
+ # - Strict typing: all public functions and data models are typed.
16
+ # - Central logging: use Python logging; callers can configure handlers.
17
+ # - Compatibility: inputs map 1:1 with the existing shell-based action.
18
+ #
19
+ # Capabilities overview:
20
+ # - Invoked by the Typer CLI entrypoint.
21
+ # - Reads .gitreview for Gerrit host/port/project when present; otherwise
22
+ # resolves from explicit inputs.
23
+ # - Supports both "single commit" and "squash" submission strategies.
24
+ # - Pushes via git-review to refs/for/<branch> and manages Change-Id.
25
+ # - Queries Gerrit for URL/change-number and updates PR comments.
26
+
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+ import os
31
+ import re
32
+ import stat
33
+ import urllib.parse
34
+ import urllib.request
35
+ from collections.abc import Iterable
36
+ from collections.abc import Sequence
37
+ from dataclasses import dataclass
38
+ from pathlib import Path
39
+ from typing import Any
40
+
41
+
42
+ try:
43
+ from pygerrit2 import GerritRestAPI
44
+ from pygerrit2 import HTTPBasicAuth
45
+ except ImportError:
46
+ GerritRestAPI = None
47
+ HTTPBasicAuth = None
48
+
49
+ from .github_api import build_client
50
+ from .github_api import close_pr
51
+ from .github_api import create_pr_comment
52
+ from .github_api import get_pr_title_body
53
+ from .github_api import get_pull
54
+ from .github_api import get_recent_change_ids_from_comments
55
+ from .github_api import get_repo_from_env
56
+ from .github_api import iter_open_pulls
57
+ from .gitutils import CommandError
58
+ from .gitutils import GitError
59
+ from .gitutils import git_cherry_pick
60
+ from .gitutils import git_commit_amend
61
+ from .gitutils import git_commit_new
62
+ from .gitutils import git_config
63
+ from .gitutils import git_last_commit_trailers
64
+ from .gitutils import git_show
65
+ from .gitutils import run_cmd
66
+ from .models import GitHubContext
67
+ from .models import Inputs
68
+
69
+
70
+ log = logging.getLogger("github2gerrit.core")
71
+
72
+
73
+ def _insert_issue_id_into_commit_message(message: str, issue_id: str) -> str:
74
+ """
75
+ Insert Issue ID into commit message after the first line.
76
+
77
+ Format:
78
+ Title line
79
+
80
+ Issue-ID: CIMAN-33
81
+
82
+ Rest of body...
83
+ """
84
+ if not issue_id.strip():
85
+ return message
86
+
87
+ # Validate that Issue ID is a single line string
88
+ cleaned_issue_id = issue_id.strip()
89
+ if "\n" in cleaned_issue_id or "\r" in cleaned_issue_id:
90
+ raise ValueError("Issue ID must be single line") # noqa: TRY003
91
+
92
+ # Use the cleaned issue ID for insertion
93
+ issue_line = cleaned_issue_id
94
+
95
+ lines = message.splitlines()
96
+ if not lines:
97
+ return message
98
+
99
+ # Take the first line as title
100
+ title = lines[0]
101
+
102
+ # Build new message with Issue ID on third line
103
+ new_lines = [title, "", issue_line]
104
+
105
+ # Add rest of the body if it exists
106
+ if len(lines) > 1:
107
+ # Skip empty lines immediately after title to avoid double spacing
108
+ body_start = 1
109
+ while body_start < len(lines) and not lines[body_start].strip():
110
+ body_start += 1
111
+
112
+ if body_start < len(lines):
113
+ new_lines.append("") # Empty line before body
114
+ new_lines.extend(lines[body_start:])
115
+
116
+ return "\n".join(new_lines)
117
+
118
+
119
+ # ---------------------
120
+ # Utility functions
121
+ # ---------------------
122
+
123
+
124
+ def _match_first_group(pattern: str, text: str) -> str | None:
125
+ m = re.search(pattern, text)
126
+ if not m:
127
+ return None
128
+ if m.groups():
129
+ return m.group(1)
130
+ return m.group(0)
131
+
132
+
133
+ def _is_valid_change_id(value: str) -> bool:
134
+ # Gerrit Change-Id usually matches I<40-hex> but the shell workflow
135
+ # uses a looser grep. Keep validation permissive for now.
136
+ if not value:
137
+ return False
138
+ return bool(re.fullmatch(r"[A-Za-z0-9._-]+", value))
139
+
140
+
141
+ @dataclass(frozen=True)
142
+ class GerritInfo:
143
+ host: str
144
+ port: int
145
+ project: str
146
+
147
+
148
+ @dataclass(frozen=True)
149
+ class RepoNames:
150
+ # Gerrit repo path, e.g. "releng/builder"
151
+ project_gerrit: str
152
+ # GitHub repo name (no org/owner), e.g. "releng-builder"
153
+ project_github: str
154
+
155
+
156
+ @dataclass(frozen=True)
157
+ class PreparedChange:
158
+ # One or more Change-Id values that will be (or were) pushed.
159
+ change_ids: list[str]
160
+ # The commit shas created/pushed to Gerrit. May be empty until queried.
161
+ commit_shas: list[str]
162
+
163
+
164
+ @dataclass(frozen=True)
165
+ class SubmissionResult:
166
+ # URLs of created/updated Gerrit changes.
167
+ change_urls: list[str]
168
+ # Numeric change-ids in Gerrit (change number).
169
+ change_numbers: list[str]
170
+ # Associated patch set commit shas in Gerrit (if available).
171
+ commit_shas: list[str]
172
+
173
+
174
+ class OrchestratorError(RuntimeError):
175
+ """Raised on unrecoverable orchestration failures."""
176
+
177
+
178
+ class Orchestrator:
179
+ """Coordinates the end-to-end PR -> Gerrit submission flow.
180
+
181
+ Responsibilities (to be implemented):
182
+ - Discover and validate environment and inputs.
183
+ - Derive Gerrit connection and project names.
184
+ - Prepare commits (single or squashed) and manage Change-Id.
185
+ - Push to Gerrit using git-review with topic and reviewers.
186
+ - Query Gerrit for URL/change-number and produce outputs.
187
+ - Comment on the PR and optionally close it.
188
+ """
189
+
190
+ def __init__(
191
+ self,
192
+ *,
193
+ workspace: Path,
194
+ ) -> None:
195
+ self.workspace = workspace
196
+ # SSH configuration paths (set by _setup_ssh)
197
+ self._ssh_key_path: Path | None = None
198
+ self._ssh_known_hosts_path: Path | None = None
199
+
200
+ # ---------------
201
+ # Public API
202
+ # ---------------
203
+
204
+ def execute(
205
+ self,
206
+ inputs: Inputs,
207
+ gh: GitHubContext,
208
+ ) -> SubmissionResult:
209
+ """Run the full pipeline and return a structured result.
210
+
211
+ This method defines the high-level call order. Sub-steps are
212
+ placeholders and must be implemented with real logic. Until then,
213
+ this raises NotImplementedError after logging the intended plan.
214
+ """
215
+ log.info("Starting PR -> Gerrit pipeline")
216
+ self._guard_pull_request_context(gh)
217
+
218
+ # Initialize git repository in workspace if it doesn't exist
219
+ if not (self.workspace / ".git").exists():
220
+ self._setup_git_workspace(inputs, gh)
221
+
222
+ gitreview = self._read_gitreview(self.workspace / ".gitreview", gh)
223
+ repo_names = self._derive_repo_names(gitreview, gh)
224
+ gerrit = self._resolve_gerrit_info(gitreview, inputs, repo_names)
225
+
226
+ if inputs.dry_run:
227
+ # Perform preflight validations and exit without making changes
228
+ self._dry_run_preflight(
229
+ gerrit=gerrit, inputs=inputs, gh=gh, repo=repo_names
230
+ )
231
+ log.info("Dry run complete; skipping write operations to Gerrit")
232
+ return SubmissionResult(
233
+ change_urls=[], change_numbers=[], commit_shas=[]
234
+ )
235
+ self._setup_ssh(inputs)
236
+
237
+ if inputs.submit_single_commits:
238
+ prep = self._prepare_single_commits(inputs, gh, gerrit)
239
+ else:
240
+ prep = self._prepare_squashed_commit(inputs, gh, gerrit)
241
+
242
+ self._configure_git(gerrit, inputs)
243
+ self._apply_pr_title_body_if_requested(inputs, gh)
244
+
245
+ self._push_to_gerrit(
246
+ gerrit=gerrit,
247
+ repo=repo_names,
248
+ branch=self._resolve_target_branch(),
249
+ reviewers=self._resolve_reviewers(inputs),
250
+ single_commits=inputs.submit_single_commits,
251
+ )
252
+
253
+ result = self._query_gerrit_for_results(
254
+ gerrit=gerrit,
255
+ repo=repo_names,
256
+ change_ids=prep.change_ids,
257
+ )
258
+
259
+ self._add_backref_comment_in_gerrit(
260
+ gerrit=gerrit,
261
+ repo=repo_names,
262
+ branch=self._resolve_target_branch(),
263
+ commit_shas=result.commit_shas,
264
+ gh=gh,
265
+ )
266
+
267
+ self._comment_on_pull_request(gh, gerrit, result)
268
+
269
+ self._close_pull_request_if_required(gh)
270
+
271
+ log.info("Pipeline complete: %s", result)
272
+ self._cleanup_ssh()
273
+ return result
274
+
275
+ # ---------------
276
+ # Step scaffolds
277
+ # ---------------
278
+
279
+ def _guard_pull_request_context(self, gh: GitHubContext) -> None:
280
+ if gh.pr_number is None:
281
+ raise OrchestratorError("missing PR context") # noqa: TRY003
282
+ log.debug("PR context OK: #%s", gh.pr_number)
283
+
284
+ def _parse_gitreview_text(self, text: str) -> GerritInfo | None:
285
+ host = _match_first_group(r"(?m)^host=(.+)$", text)
286
+ port_s = _match_first_group(r"(?m)^port=(\d+)$", text)
287
+ proj = _match_first_group(r"(?m)^project=(.+)$", text)
288
+ if host and proj:
289
+ project = proj.removesuffix(".git")
290
+ port = int(port_s) if port_s else 29418
291
+ return GerritInfo(
292
+ host=host.strip(),
293
+ port=port,
294
+ project=project.strip(),
295
+ )
296
+ return None
297
+
298
+ def _read_gitreview(
299
+ self,
300
+ path: Path,
301
+ gh: GitHubContext | None = None,
302
+ ) -> GerritInfo | None:
303
+ """Read .gitreview and return GerritInfo if present.
304
+
305
+ Expected keys:
306
+ host=<hostname>
307
+ port=<port>
308
+ project=<repo/path>.git
309
+ """
310
+ if not path.exists():
311
+ log.info(".gitreview not found locally; attempting remote fetch")
312
+ # If invoked via direct URL or in environments with a token,
313
+ # attempt to read .gitreview from the repository using the API.
314
+ try:
315
+ client = build_client()
316
+ repo_obj: Any = get_repo_from_env(client)
317
+ # Prefer a specific ref when available; otherwise default branch
318
+ ref = os.getenv("GITHUB_HEAD_REF") or os.getenv("GITHUB_SHA")
319
+ if ref:
320
+ content = repo_obj.get_contents(".gitreview", ref=ref)
321
+ else:
322
+ content = repo_obj.get_contents(".gitreview")
323
+ text_remote = (
324
+ getattr(content, "decoded_content", b"") or b""
325
+ ).decode("utf-8")
326
+ info_remote = self._parse_gitreview_text(text_remote)
327
+ if info_remote:
328
+ log.debug("Parsed remote .gitreview: %s", info_remote)
329
+ return info_remote
330
+ log.info("Remote .gitreview missing required keys; ignoring")
331
+ except Exception as exc:
332
+ log.debug("Remote .gitreview not available: %s", exc)
333
+ # Attempt raw.githubusercontent.com as a fallback
334
+ try:
335
+ repo_full = (
336
+ (
337
+ gh.repository
338
+ if gh
339
+ else os.getenv("GITHUB_REPOSITORY", "")
340
+ )
341
+ or ""
342
+ ).strip()
343
+ branches: list[str] = []
344
+ # Prefer PR head/base refs via GitHub API when running
345
+ # from a direct URL when a token is available
346
+ try:
347
+ if (
348
+ gh
349
+ and gh.pr_number
350
+ and os.getenv("G2G_TARGET_URL")
351
+ and (os.getenv("GITHUB_TOKEN") or os.getenv("GH_TOKEN"))
352
+ ):
353
+ client = build_client()
354
+ repo_obj = get_repo_from_env(client)
355
+ pr_obj = get_pull(repo_obj, int(gh.pr_number))
356
+ api_head = str(
357
+ getattr(
358
+ getattr(pr_obj, "head", object()), "ref", ""
359
+ )
360
+ or ""
361
+ )
362
+ api_base = str(
363
+ getattr(
364
+ getattr(pr_obj, "base", object()), "ref", ""
365
+ )
366
+ or ""
367
+ )
368
+ if api_head:
369
+ branches.append(api_head)
370
+ if api_base:
371
+ branches.append(api_base)
372
+ except Exception as exc_api:
373
+ log.debug(
374
+ "Could not resolve PR refs via API for .gitreview: %s",
375
+ exc_api,
376
+ )
377
+ if gh and gh.head_ref:
378
+ branches.append(gh.head_ref)
379
+ if gh and gh.base_ref:
380
+ branches.append(gh.base_ref)
381
+ branches.extend(["master", "main"])
382
+ tried: set[str] = set()
383
+ for br in branches:
384
+ if not br or br in tried:
385
+ continue
386
+ tried.add(br)
387
+ url = (
388
+ f"https://raw.githubusercontent.com/"
389
+ f"{repo_full}/refs/heads/{br}/.gitreview"
390
+ )
391
+ parsed = urllib.parse.urlparse(url)
392
+ if (
393
+ parsed.scheme != "https"
394
+ or parsed.netloc != "raw.githubusercontent.com"
395
+ ):
396
+ continue
397
+ log.info("Fetching .gitreview via raw URL: %s", url)
398
+ with urllib.request.urlopen(url, timeout=5) as resp: # noqa: S310
399
+ text_remote = resp.read().decode("utf-8")
400
+ info_remote = self._parse_gitreview_text(text_remote)
401
+ if info_remote:
402
+ log.debug("Parsed remote .gitreview: %s", info_remote)
403
+ return info_remote
404
+ except Exception as exc2:
405
+ log.debug("Raw .gitreview fetch failed: %s", exc2)
406
+ log.info("Remote .gitreview not available via API or HTTP")
407
+ log.info("Falling back to inputs/env")
408
+ return None
409
+
410
+ try:
411
+ text = path.read_text(encoding="utf-8")
412
+ except Exception as exc:
413
+ msg = f"failed to read .gitreview: {exc}"
414
+ raise OrchestratorError(msg) from exc
415
+ info_local = self._parse_gitreview_text(text)
416
+ if not info_local:
417
+ msg = "invalid .gitreview: missing host/project"
418
+ raise OrchestratorError(msg)
419
+ log.debug("Parsed .gitreview: %s", info_local)
420
+ return info_local
421
+
422
+ def _derive_repo_names(
423
+ self,
424
+ gitreview: GerritInfo | None,
425
+ gh: GitHubContext,
426
+ ) -> RepoNames:
427
+ """Compute Gerrit and GitHub repo names following existing rules.
428
+
429
+ - Gerrit project remains as-is (from .gitreview when present).
430
+ - GitHub repo name is Gerrit project path with '/' replaced by '-'.
431
+ If .gitreview is not available, derive from GITHUB_REPOSITORY.
432
+ """
433
+ if gitreview:
434
+ gerrit_name = gitreview.project
435
+ github_name = gerrit_name.replace("/", "-")
436
+ names = RepoNames(
437
+ project_gerrit=gerrit_name,
438
+ project_github=github_name,
439
+ )
440
+ log.debug("Derived names from .gitreview: %s", names)
441
+ return names
442
+
443
+ # Fallback: use the repository name portion only.
444
+ repo_full = gh.repository
445
+ if not repo_full or "/" not in repo_full:
446
+ raise OrchestratorError("bad repository context") # noqa: TRY003
447
+ owner, name = repo_full.split("/", 1)
448
+ # Fallback: map all '-' to '/' for Gerrit path (e.g., 'my/repo/name')
449
+ gerrit_name = name.replace("-", "/")
450
+ names = RepoNames(project_gerrit=gerrit_name, project_github=name)
451
+ log.debug("Derived names from context: %s", names)
452
+ return names
453
+
454
+ def _resolve_gerrit_info(
455
+ self,
456
+ gitreview: GerritInfo | None,
457
+ inputs: Inputs,
458
+ repo: RepoNames,
459
+ ) -> GerritInfo:
460
+ """Resolve Gerrit connection info from .gitreview or inputs."""
461
+ if gitreview:
462
+ return gitreview
463
+
464
+ host = inputs.gerrit_server.strip()
465
+ if not host:
466
+ raise OrchestratorError("missing GERRIT_SERVER") # noqa: TRY003
467
+ port_s = inputs.gerrit_server_port.strip() or "29418"
468
+ try:
469
+ port = int(port_s)
470
+ except ValueError as exc:
471
+ msg = "bad GERRIT_SERVER_PORT"
472
+ raise OrchestratorError(msg) from exc
473
+
474
+ project = inputs.gerrit_project.strip()
475
+ if not project:
476
+ if inputs.dry_run:
477
+ project = repo.project_gerrit
478
+ log.info("Dry run: using derived Gerrit project '%s'", project)
479
+ elif os.getenv("G2G_TARGET_URL", "").strip():
480
+ project = repo.project_gerrit
481
+ log.info(
482
+ "Using derived Gerrit project '%s' from repository name",
483
+ project,
484
+ )
485
+ else:
486
+ raise OrchestratorError("missing GERRIT_PROJECT") # noqa: TRY003
487
+
488
+ info = GerritInfo(host=host, port=port, project=project)
489
+ log.debug("Resolved Gerrit info: %s", info)
490
+ return info
491
+
492
+ def _setup_ssh(self, inputs: Inputs) -> None:
493
+ """Set up temporary SSH configuration for Gerrit access.
494
+
495
+ This method creates tool-specific SSH files in the workspace without
496
+ modifying user SSH configuration. Key features:
497
+
498
+ - Creates temporary SSH key and known_hosts files
499
+ - Uses GIT_SSH_COMMAND to specify exact SSH behavior
500
+ - Prevents SSH agent scanning with IdentitiesOnly=yes
501
+ - Host-specific configuration without global impact
502
+ - Automatic cleanup when done
503
+
504
+ Does not modify user files.
505
+ """
506
+ if not inputs.gerrit_ssh_privkey_g2g or not inputs.gerrit_known_hosts:
507
+ log.debug("SSH key or known hosts not provided, skipping SSH setup")
508
+ return
509
+
510
+ log.info("Setting up temporary SSH configuration for Gerrit")
511
+ log.debug("Using workspace-specific SSH files to avoid user changes")
512
+
513
+ # Create tool-specific SSH directory in workspace to avoid touching
514
+ # user files
515
+ tool_ssh_dir = self.workspace / ".ssh-g2g"
516
+ tool_ssh_dir.mkdir(mode=0o700, exist_ok=True)
517
+
518
+ # Write SSH private key to tool-specific location
519
+ key_path = tool_ssh_dir / "gerrit_key"
520
+ with open(key_path, "w", encoding="utf-8") as f:
521
+ f.write(inputs.gerrit_ssh_privkey_g2g.strip() + "\n")
522
+ key_path.chmod(0o600)
523
+ log.debug("SSH private key written to %s", key_path)
524
+ log.debug("Key file is tool-specific and won't interfere with user SSH")
525
+
526
+ # Write known hosts to tool-specific location
527
+ known_hosts_path = tool_ssh_dir / "known_hosts"
528
+ with open(known_hosts_path, "w", encoding="utf-8") as f:
529
+ f.write(inputs.gerrit_known_hosts.strip() + "\n")
530
+ known_hosts_path.chmod(0o644)
531
+ log.debug("Known hosts written to %s", known_hosts_path)
532
+ log.debug("Using isolated known_hosts to prevent user conflicts")
533
+
534
+ # Store paths for later use in git commands
535
+ self._ssh_key_path = key_path
536
+ self._ssh_known_hosts_path = known_hosts_path
537
+
538
+ @property
539
+ def _git_ssh_command(self) -> str | None:
540
+ """Generate GIT_SSH_COMMAND for secure, isolated SSH configuration.
541
+
542
+ This prevents SSH from scanning the user's SSH agent or using
543
+ unintended keys by setting IdentitiesOnly=yes and specifying
544
+ exact key and known_hosts files.
545
+ """
546
+ if not self._ssh_key_path or not self._ssh_known_hosts_path:
547
+ return None
548
+
549
+ # Build SSH command with strict options to prevent key scanning
550
+ ssh_options = [
551
+ f"-i {self._ssh_key_path}",
552
+ f"-o UserKnownHostsFile={self._ssh_known_hosts_path}",
553
+ "-o IdentitiesOnly=yes", # Critical: prevents SSH agent scanning
554
+ "-o StrictHostKeyChecking=yes",
555
+ "-o PasswordAuthentication=no",
556
+ "-o PubkeyAcceptedKeyTypes=+ssh-rsa",
557
+ "-o ConnectTimeout=10",
558
+ ]
559
+
560
+ ssh_cmd = f"ssh {' '.join(ssh_options)}"
561
+ masked_cmd = ssh_cmd.replace(str(self._ssh_key_path), "[KEY_PATH]")
562
+ log.debug("Generated SSH command: %s", masked_cmd)
563
+ return ssh_cmd
564
+
565
+ def _cleanup_ssh(self) -> None:
566
+ """Clean up temporary SSH files created by this tool.
567
+
568
+ Removes the workspace-specific .ssh-g2g directory and all contents.
569
+ This ensures no temporary files are left behind.
570
+ """
571
+ if not hasattr(self, "_ssh_key_path") or not hasattr(
572
+ self, "_ssh_known_hosts_path"
573
+ ):
574
+ return
575
+
576
+ try:
577
+ # Remove temporary SSH directory and all contents
578
+ tool_ssh_dir = self.workspace / ".ssh-g2g"
579
+ if tool_ssh_dir.exists():
580
+ import shutil
581
+
582
+ shutil.rmtree(tool_ssh_dir)
583
+ log.debug(
584
+ "Cleaned up temporary SSH directory: %s", tool_ssh_dir
585
+ )
586
+ except Exception as exc:
587
+ log.warning("Failed to clean up temporary SSH files: %s", exc)
588
+
589
+ def _configure_git(
590
+ self,
591
+ gerrit: GerritInfo,
592
+ inputs: Inputs,
593
+ ) -> None:
594
+ """Set git global config and initialize git-review if needed."""
595
+ log.info("Configuring git and git-review for %s", gerrit.host)
596
+ # Prefer repo-local config; fallback to global if needed
597
+ try:
598
+ git_config(
599
+ "gitreview.username",
600
+ inputs.gerrit_ssh_user_g2g,
601
+ global_=False,
602
+ cwd=self.workspace,
603
+ )
604
+ except GitError:
605
+ git_config(
606
+ "gitreview.username", inputs.gerrit_ssh_user_g2g, global_=True
607
+ )
608
+ try:
609
+ git_config(
610
+ "user.name",
611
+ inputs.gerrit_ssh_user_g2g,
612
+ global_=False,
613
+ cwd=self.workspace,
614
+ )
615
+ except GitError:
616
+ git_config("user.name", inputs.gerrit_ssh_user_g2g, global_=True)
617
+ try:
618
+ git_config(
619
+ "user.email",
620
+ inputs.gerrit_ssh_user_g2g_email,
621
+ global_=False,
622
+ cwd=self.workspace,
623
+ )
624
+ except GitError:
625
+ git_config(
626
+ "user.email", inputs.gerrit_ssh_user_g2g_email, global_=True
627
+ )
628
+
629
+ # Ensure git-review host/port/project are configured
630
+ # when .gitreview is absent
631
+ try:
632
+ git_config(
633
+ "gitreview.hostname",
634
+ gerrit.host,
635
+ global_=False,
636
+ cwd=self.workspace,
637
+ )
638
+ git_config(
639
+ "gitreview.port",
640
+ str(gerrit.port),
641
+ global_=False,
642
+ cwd=self.workspace,
643
+ )
644
+ git_config(
645
+ "gitreview.project",
646
+ gerrit.project,
647
+ global_=False,
648
+ cwd=self.workspace,
649
+ )
650
+ except GitError:
651
+ git_config("gitreview.hostname", gerrit.host, global_=True)
652
+ git_config("gitreview.port", str(gerrit.port), global_=True)
653
+ git_config("gitreview.project", gerrit.project, global_=True)
654
+
655
+ # Add 'gerrit' remote if missing (required by git-review)
656
+ try:
657
+ run_cmd(
658
+ ["git", "config", "--get", "remote.gerrit.url"],
659
+ cwd=self.workspace,
660
+ )
661
+ except CommandError:
662
+ ssh_user = inputs.gerrit_ssh_user_g2g.strip()
663
+ remote_url = (
664
+ f"ssh://{ssh_user}@{gerrit.host}:{gerrit.port}/{gerrit.project}"
665
+ )
666
+ log.info("Adding 'gerrit' remote: %s", remote_url)
667
+ # Use our specific SSH configuration for adding remote
668
+ env = (
669
+ {"GIT_SSH_COMMAND": self._git_ssh_command}
670
+ if self._git_ssh_command
671
+ else None
672
+ )
673
+ run_cmd(
674
+ ["git", "remote", "add", "gerrit", remote_url],
675
+ check=False,
676
+ cwd=self.workspace,
677
+ env=env,
678
+ )
679
+
680
+ # Workaround for submodules commit-msg hook
681
+ hooks_path = run_cmd(
682
+ ["git", "rev-parse", "--show-toplevel"], cwd=self.workspace
683
+ ).stdout.strip()
684
+ try:
685
+ git_config(
686
+ "core.hooksPath",
687
+ str(Path(hooks_path) / ".git" / "hooks"),
688
+ cwd=self.workspace,
689
+ )
690
+ except GitError:
691
+ git_config(
692
+ "core.hooksPath",
693
+ str(Path(hooks_path) / ".git" / "hooks"),
694
+ global_=True,
695
+ )
696
+ # Initialize git-review (copies commit-msg hook)
697
+ try:
698
+ # Use our specific SSH configuration for git-review setup
699
+ env = (
700
+ {"GIT_SSH_COMMAND": self._git_ssh_command}
701
+ if self._git_ssh_command
702
+ else None
703
+ )
704
+ run_cmd(["git", "review", "-s", "-v"], cwd=self.workspace, env=env)
705
+ except CommandError as exc:
706
+ msg = f"Failed to initialize git-review: {exc}"
707
+ raise OrchestratorError(msg) from exc
708
+
709
+ def _prepare_single_commits(
710
+ self,
711
+ inputs: Inputs,
712
+ gh: GitHubContext,
713
+ gerrit: GerritInfo,
714
+ ) -> PreparedChange:
715
+ """Cherry-pick commits one-by-one and ensure Change-Id is present."""
716
+ log.info("Preparing single-commit submission for PR #%s", gh.pr_number)
717
+ branch = self._resolve_target_branch()
718
+ # Determine commit range: commits in HEAD not in base branch
719
+ base_ref = f"origin/{branch}"
720
+ # Use our SSH command for git operations that might need SSH
721
+ env = (
722
+ {"GIT_SSH_COMMAND": self._git_ssh_command}
723
+ if self._git_ssh_command
724
+ else None
725
+ )
726
+ run_cmd(["git", "fetch", "origin", branch], cwd=self.workspace, env=env)
727
+ revs = run_cmd(
728
+ ["git", "rev-list", "--reverse", f"{base_ref}..HEAD"],
729
+ cwd=self.workspace,
730
+ ).stdout
731
+ commit_list = [c for c in revs.splitlines() if c.strip()]
732
+ if not commit_list:
733
+ log.info("No commits to submit; returning empty PreparedChange")
734
+ return PreparedChange(change_ids=[], commit_shas=[])
735
+ # Create temp branch from base sha; export for downstream
736
+ base_sha = run_cmd(
737
+ ["git", "rev-parse", base_ref], cwd=self.workspace
738
+ ).stdout.strip()
739
+ tmp_branch = f"g2g_tmp_{gh.pr_number or 'pr'!s}_{os.getpid()}"
740
+ os.environ["G2G_TMP_BRANCH"] = tmp_branch
741
+ run_cmd(
742
+ ["git", "checkout", "-b", tmp_branch, base_sha], cwd=self.workspace
743
+ )
744
+ change_ids: list[str] = []
745
+ for csha in commit_list:
746
+ run_cmd(["git", "checkout", tmp_branch], cwd=self.workspace)
747
+ git_cherry_pick(csha, cwd=self.workspace)
748
+ # Preserve author of the original commit
749
+ author = run_cmd(
750
+ ["git", "show", "-s", "--pretty=format:%an <%ae>", csha],
751
+ cwd=self.workspace,
752
+ ).stdout.strip()
753
+ git_commit_amend(
754
+ author=author, no_edit=True, signoff=True, cwd=self.workspace
755
+ )
756
+ # Extract newly added Change-Id from last commit trailers
757
+ trailers = git_last_commit_trailers(
758
+ keys=["Change-Id"], cwd=self.workspace
759
+ )
760
+ for cid in trailers.get("Change-Id", []):
761
+ if cid:
762
+ change_ids.append(cid)
763
+ # Return to base branch for next iteration context
764
+ run_cmd(["git", "checkout", branch], cwd=self.workspace)
765
+ # Deduplicate while preserving order
766
+ seen = set()
767
+ uniq_ids = []
768
+ for cid in change_ids:
769
+ if cid not in seen:
770
+ uniq_ids.append(cid)
771
+ seen.add(cid)
772
+ run_cmd(["git", "log", "-n3", tmp_branch], cwd=self.workspace)
773
+ if uniq_ids:
774
+ log.info(
775
+ "Generated %d unique Change-ID(s) for PR #%s: %s",
776
+ len(uniq_ids),
777
+ gh.pr_number,
778
+ ", ".join(uniq_ids),
779
+ )
780
+ else:
781
+ log.warning("No Change-IDs generated for PR #%s", gh.pr_number)
782
+ return PreparedChange(change_ids=uniq_ids, commit_shas=[])
783
+
784
+ def _prepare_squashed_commit(
785
+ self,
786
+ inputs: Inputs,
787
+ gh: GitHubContext,
788
+ gerrit: GerritInfo,
789
+ ) -> PreparedChange:
790
+ """Squash PR commits into a single commit and handle Change-Id."""
791
+ log.info("Preparing squashed commit for PR #%s", gh.pr_number)
792
+ branch = self._resolve_target_branch()
793
+ env = (
794
+ {"GIT_SSH_COMMAND": self._git_ssh_command}
795
+ if self._git_ssh_command
796
+ else None
797
+ )
798
+ run_cmd(["git", "fetch", "origin", branch], cwd=self.workspace, env=env)
799
+ base_ref = f"origin/{branch}"
800
+ base_sha = run_cmd(
801
+ ["git", "rev-parse", base_ref], cwd=self.workspace
802
+ ).stdout.strip()
803
+ head_sha = run_cmd(
804
+ ["git", "rev-parse", "HEAD"], cwd=self.workspace
805
+ ).stdout.strip()
806
+
807
+ # Create temp branch from base and merge-squash PR head
808
+ tmp_branch = f"g2g_tmp_{gh.pr_number or 'pr'!s}_{os.getpid()}"
809
+ os.environ["G2G_TMP_BRANCH"] = tmp_branch
810
+ run_cmd(
811
+ ["git", "checkout", "-b", tmp_branch, base_sha], cwd=self.workspace
812
+ )
813
+ run_cmd(["git", "merge", "--squash", head_sha], cwd=self.workspace)
814
+
815
+ def _collect_log_lines() -> list[str]:
816
+ body = run_cmd(
817
+ [
818
+ "git",
819
+ "log",
820
+ "--format=%B",
821
+ "--reverse",
822
+ f"{base_ref}..{head_sha}",
823
+ ],
824
+ cwd=self.workspace,
825
+ ).stdout
826
+ return [ln for ln in body.splitlines() if ln.strip()]
827
+
828
+ def _parse_message_parts(
829
+ lines: list[str],
830
+ ) -> tuple[
831
+ list[str],
832
+ list[str],
833
+ list[str],
834
+ ]:
835
+ change_ids: list[str] = []
836
+ signed_off: list[str] = []
837
+ message_lines: list[str] = []
838
+ in_metadata_section = False
839
+ for ln in lines:
840
+ if ln.strip() in ("---", "```") or ln.startswith(
841
+ "updated-dependencies:"
842
+ ):
843
+ in_metadata_section = True
844
+ continue
845
+ if in_metadata_section:
846
+ if ln.startswith(("- dependency-", " dependency-")):
847
+ continue
848
+ if (
849
+ not ln.startswith((" ", "-", "dependency-"))
850
+ and ln.strip()
851
+ ):
852
+ in_metadata_section = False
853
+ if ln.startswith("Change-Id:"):
854
+ cid = ln.split(":", 1)[1].strip()
855
+ if cid:
856
+ change_ids.append(cid)
857
+ continue
858
+ if ln.startswith("Signed-off-by:"):
859
+ signed_off.append(ln)
860
+ continue
861
+ if not in_metadata_section:
862
+ message_lines.append(ln)
863
+ signed_off = sorted(set(signed_off))
864
+ return message_lines, signed_off, change_ids
865
+
866
+ def _clean_title_line(title_line: str) -> str:
867
+ # Remove markdown links
868
+ title_line = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", title_line)
869
+ # Remove trailing ellipsis/truncation
870
+ title_line = re.sub(r"\s*[.]{3,}.*$", "", title_line)
871
+ # Split on common separators to avoid leaking body content
872
+ for separator in [". Bumps ", " Bumps ", ". - ", " - "]:
873
+ if separator in title_line:
874
+ title_line = title_line.split(separator)[0].strip()
875
+ break
876
+ # Remove simple markdown/formatting artifacts
877
+ title_line = re.sub(r"[*_`]", "", title_line).strip()
878
+ if len(title_line) > 100:
879
+ break_points = [". ", "! ", "? ", " - ", ": "]
880
+ for bp in break_points:
881
+ if bp in title_line[:100]:
882
+ title_line = title_line[
883
+ : title_line.index(bp) + len(bp.strip())
884
+ ]
885
+ break
886
+ else:
887
+ words = title_line[:100].split()
888
+ title_line = (
889
+ " ".join(words[:-1])
890
+ if len(words) > 1
891
+ else title_line[:100].rstrip()
892
+ )
893
+ return title_line
894
+
895
+ def _build_clean_message_lines(message_lines: list[str]) -> list[str]:
896
+ if not message_lines:
897
+ return []
898
+ title_line = _clean_title_line(message_lines[0].strip())
899
+ out: list[str] = [title_line]
900
+ if len(message_lines) > 1:
901
+ body_start = 1
902
+ while (
903
+ body_start < len(message_lines)
904
+ and not message_lines[body_start].strip()
905
+ ):
906
+ body_start += 1
907
+ if body_start < len(message_lines):
908
+ out.append("")
909
+ out.extend(message_lines[body_start:])
910
+ return out
911
+
912
+ def _maybe_reuse_change_id(pr_str: str) -> str:
913
+ reuse = ""
914
+ sync_all_prs = (
915
+ os.getenv("SYNC_ALL_OPEN_PRS", "false").lower() == "true"
916
+ )
917
+ if (
918
+ not sync_all_prs
919
+ and gh.event_name == "pull_request_target"
920
+ and gh.event_action in ("reopened", "synchronize")
921
+ ):
922
+ try:
923
+ client = build_client()
924
+ repo = get_repo_from_env(client)
925
+ pr_obj = get_pull(repo, int(pr_str))
926
+ cand = get_recent_change_ids_from_comments(
927
+ pr_obj, max_comments=50
928
+ )
929
+ if cand:
930
+ reuse = cand[-1]
931
+ log.debug(
932
+ "Reusing Change-ID %s for PR #%s (single-PR mode)",
933
+ reuse,
934
+ pr_str,
935
+ )
936
+ except Exception:
937
+ reuse = ""
938
+ elif sync_all_prs:
939
+ log.debug(
940
+ "Skipping Change-ID reuse for PR #%s (multi-PR mode)",
941
+ pr_str,
942
+ )
943
+ return reuse
944
+
945
+ def _compose_commit_message(
946
+ lines_in: list[str],
947
+ signed_off: list[str],
948
+ reuse_cid: str,
949
+ ) -> str:
950
+ from .duplicate_detection import DuplicateDetector
951
+
952
+ msg = "\n".join(lines_in).strip()
953
+ msg = _insert_issue_id_into_commit_message(msg, inputs.issue_id)
954
+ github_hash = DuplicateDetector._generate_github_change_hash(gh)
955
+ msg += f"\n\nGitHub-Hash: {github_hash}"
956
+ if signed_off:
957
+ msg += "\n\n" + "\n".join(signed_off)
958
+ if reuse_cid:
959
+ msg += f"\n\nChange-Id: {reuse_cid}"
960
+ return msg
961
+
962
+ # Build message parts
963
+ raw_lines = _collect_log_lines()
964
+ message_lines, signed_off, _existing_cids = _parse_message_parts(
965
+ raw_lines
966
+ )
967
+ clean_lines = _build_clean_message_lines(message_lines)
968
+ pr_str = str(gh.pr_number or "").strip()
969
+ reuse_cid = _maybe_reuse_change_id(pr_str)
970
+ commit_msg = _compose_commit_message(clean_lines, signed_off, reuse_cid)
971
+
972
+ # Preserve primary author from the PR head commit
973
+ author = run_cmd(
974
+ ["git", "show", "-s", "--pretty=format:%an <%ae>", head_sha],
975
+ cwd=self.workspace,
976
+ ).stdout.strip()
977
+ git_commit_new(
978
+ message=commit_msg,
979
+ author=author,
980
+ signoff=True,
981
+ cwd=self.workspace,
982
+ )
983
+
984
+ # Debug: Check commit message after creation
985
+ actual_msg = run_cmd(
986
+ ["git", "show", "-s", "--pretty=format:%B", "HEAD"],
987
+ cwd=self.workspace,
988
+ ).stdout.strip()
989
+ log.debug("Commit message after creation:\n%s", actual_msg)
990
+
991
+ # Ensure Change-Id via commit-msg hook (amend if needed)
992
+ cids = self._ensure_change_id_present(gerrit, author)
993
+ if cids:
994
+ log.info(
995
+ "Generated Change-ID(s) for PR #%s: %s",
996
+ gh.pr_number,
997
+ ", ".join(cids),
998
+ )
999
+ else:
1000
+ log.warning("No Change-ID generated for PR #%s", gh.pr_number)
1001
+ return PreparedChange(change_ids=cids, commit_shas=[])
1002
+
1003
+ def _apply_pr_title_body_if_requested(
1004
+ self,
1005
+ inputs: Inputs,
1006
+ gh: GitHubContext,
1007
+ ) -> None:
1008
+ """Optionally replace commit message with PR title/body."""
1009
+ if not inputs.use_pr_as_commit:
1010
+ log.debug("USE_PR_AS_COMMIT disabled; skipping")
1011
+ return
1012
+ log.info("Applying PR title/body to commit for PR #%s", gh.pr_number)
1013
+ pr = str(gh.pr_number or "").strip()
1014
+ if not pr:
1015
+ return
1016
+ # Fetch PR title/body via GitHub API (PyGithub)
1017
+ client = build_client()
1018
+ repo = get_repo_from_env(client)
1019
+ pr_obj = get_pull(repo, int(pr))
1020
+ title, body = get_pr_title_body(pr_obj)
1021
+ title = (title or "").strip()
1022
+ body = (body or "").strip()
1023
+
1024
+ # Clean up title to ensure it's a proper first line for commit message
1025
+ if title:
1026
+ # Remove markdown links like [text](url) and keep just the text
1027
+ title = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", title)
1028
+ # Remove any trailing ellipsis or truncation indicators
1029
+ title = re.sub(r"\s*[.]{3,}.*$", "", title)
1030
+ # Ensure title doesn't accidentally contain body content
1031
+ # Split on common separators and take only the first meaningful part
1032
+ for separator in [". Bumps ", " Bumps ", ". - ", " - "]:
1033
+ if separator in title:
1034
+ title = title.split(separator)[0].strip()
1035
+ break
1036
+ # Remove any remaining markdown or formatting artifacts
1037
+ title = re.sub(r"[*_`]", "", title)
1038
+ title = title.strip()
1039
+
1040
+ # Compose message; preserve any Signed-off-by lines
1041
+ current_body = git_show("HEAD", fmt="%B")
1042
+ signed = [
1043
+ ln
1044
+ for ln in current_body.splitlines()
1045
+ if ln.startswith("Signed-off-by:")
1046
+ ]
1047
+ msg_parts = [title, "", body] if title or body else [current_body]
1048
+ commit_message = "\n".join(msg_parts).strip()
1049
+
1050
+ # Add Issue-ID if provided
1051
+ commit_message = _insert_issue_id_into_commit_message(
1052
+ commit_message, inputs.issue_id
1053
+ )
1054
+
1055
+ if signed:
1056
+ commit_message += "\n\n" + "\n".join(signed)
1057
+ author = run_cmd(
1058
+ ["git", "show", "-s", "--pretty=format:%an <%ae>", "HEAD"]
1059
+ ).stdout.strip()
1060
+ git_commit_amend(
1061
+ no_edit=False,
1062
+ signoff=not bool(signed),
1063
+ author=author,
1064
+ message=commit_message,
1065
+ )
1066
+
1067
+ def _push_to_gerrit(
1068
+ self,
1069
+ *,
1070
+ gerrit: GerritInfo,
1071
+ repo: RepoNames,
1072
+ branch: str,
1073
+ reviewers: str,
1074
+ single_commits: bool,
1075
+ ) -> None:
1076
+ """Push prepared commit(s) to Gerrit using git-review."""
1077
+ log.info(
1078
+ "Pushing changes to Gerrit %s:%s project=%s branch=%s",
1079
+ gerrit.host,
1080
+ gerrit.port,
1081
+ repo.project_gerrit,
1082
+ branch,
1083
+ )
1084
+ if single_commits:
1085
+ tmp_branch = os.getenv("G2G_TMP_BRANCH", "tmp_branch")
1086
+ run_cmd(["git", "checkout", tmp_branch], cwd=self.workspace)
1087
+ prefix = os.getenv("G2G_TOPIC_PREFIX", "GH").strip() or "GH"
1088
+ pr_num = os.getenv("PR_NUMBER", "").strip()
1089
+ if pr_num:
1090
+ topic = f"{prefix}-{repo.project_github}-{pr_num}"
1091
+ else:
1092
+ topic = f"{prefix}-{repo.project_github}"
1093
+ try:
1094
+ args = [
1095
+ "git",
1096
+ "review",
1097
+ "--yes",
1098
+ "-v",
1099
+ "-t",
1100
+ topic,
1101
+ ]
1102
+ revs = [
1103
+ r.strip() for r in (reviewers or "").split(",") if r.strip()
1104
+ ]
1105
+ for r in revs:
1106
+ args.extend(["--reviewer", r])
1107
+ # Branch as positional argument (not a flag)
1108
+ args.append(branch)
1109
+
1110
+ # Use our specific SSH configuration
1111
+ env = (
1112
+ {"GIT_SSH_COMMAND": self._git_ssh_command}
1113
+ if self._git_ssh_command
1114
+ else None
1115
+ )
1116
+ run_cmd(args, cwd=self.workspace, env=env)
1117
+ except CommandError as exc:
1118
+ msg = f"Failed to push changes to Gerrit with git-review: {exc}"
1119
+ raise OrchestratorError(msg) from exc
1120
+ # Cleanup temporary branch used during preparation
1121
+ tmp_branch = (os.getenv("G2G_TMP_BRANCH", "") or "").strip()
1122
+ if tmp_branch:
1123
+ # Switch back to the target branch, then delete the temp branch
1124
+ run_cmd(
1125
+ ["git", "checkout", f"origin/{branch}"],
1126
+ check=False,
1127
+ cwd=self.workspace,
1128
+ )
1129
+ run_cmd(
1130
+ ["git", "branch", "-D", tmp_branch],
1131
+ check=False,
1132
+ cwd=self.workspace,
1133
+ )
1134
+
1135
+ def _query_gerrit_for_results(
1136
+ self,
1137
+ *,
1138
+ gerrit: GerritInfo,
1139
+ repo: RepoNames,
1140
+ change_ids: Sequence[str],
1141
+ ) -> SubmissionResult:
1142
+ """Query Gerrit for change URL/number and patchset sha via REST."""
1143
+ log.info("Querying Gerrit for submitted change(s) via REST")
1144
+ # Build Gerrit REST client (prefer HTTP basic auth if provided)
1145
+ base_path = os.getenv("GERRIT_HTTP_BASE_PATH", "").strip().strip("/")
1146
+ base_url = (
1147
+ f"https://{gerrit.host}/"
1148
+ if not base_path
1149
+ else f"https://{gerrit.host}/{base_path}/"
1150
+ )
1151
+ http_user = (
1152
+ os.getenv("GERRIT_HTTP_USER", "").strip()
1153
+ or os.getenv("GERRIT_SSH_USER_G2G", "").strip()
1154
+ )
1155
+ http_pass = os.getenv("GERRIT_HTTP_PASSWORD", "").strip()
1156
+ if GerritRestAPI is None:
1157
+ raise OrchestratorError( # noqa: TRY003
1158
+ "pygerrit2 is required to query Gerrit REST API"
1159
+ )
1160
+ if http_user and http_pass:
1161
+ if HTTPBasicAuth is None:
1162
+ raise OrchestratorError( # noqa: TRY003
1163
+ "pygerrit2 is required for HTTP authentication"
1164
+ )
1165
+ rest = GerritRestAPI(
1166
+ url=base_url, auth=HTTPBasicAuth(http_user, http_pass)
1167
+ )
1168
+ else:
1169
+ rest = GerritRestAPI(url=base_url)
1170
+ urls: list[str] = []
1171
+ nums: list[str] = []
1172
+ shas: list[str] = []
1173
+ for cid in change_ids:
1174
+ if not cid:
1175
+ continue
1176
+ # Limit results to 1, filter by project and open status,
1177
+ # include current revision
1178
+ query = f"limit:1 is:open project:{repo.project_gerrit} {cid}"
1179
+ path = f"/changes/?q={query}&o=CURRENT_REVISION&n=1"
1180
+ try:
1181
+ changes = rest.get(path)
1182
+ except Exception as exc:
1183
+ status = getattr(
1184
+ getattr(exc, "response", None), "status_code", None
1185
+ )
1186
+ if not base_path and status == 404:
1187
+ try:
1188
+ fallback_url = f"https://{gerrit.host}/r/"
1189
+ if GerritRestAPI is None:
1190
+ log.warning(
1191
+ "pygerrit2 missing; skipping REST fallback"
1192
+ )
1193
+ continue
1194
+ if http_user and http_pass:
1195
+ if HTTPBasicAuth is None:
1196
+ log.warning(
1197
+ "pygerrit2 auth missing; skipping fallback"
1198
+ )
1199
+ continue
1200
+ rest_fallback = GerritRestAPI(
1201
+ url=fallback_url,
1202
+ auth=HTTPBasicAuth(http_user, http_pass),
1203
+ )
1204
+ else:
1205
+ rest_fallback = GerritRestAPI(url=fallback_url)
1206
+ changes = rest_fallback.get(path)
1207
+ except Exception as exc2:
1208
+ log.warning(
1209
+ "Failed to query change via REST for %s "
1210
+ "(including '/r' fallback): %s",
1211
+ cid,
1212
+ exc2,
1213
+ )
1214
+ continue
1215
+ else:
1216
+ log.warning(
1217
+ "Failed to query change via REST for %s: %s", cid, exc
1218
+ )
1219
+ continue
1220
+ if not changes:
1221
+ continue
1222
+ change = changes[0]
1223
+ num = str(change.get("_number", ""))
1224
+ current_rev = change.get("current_revision", "")
1225
+ # Construct a stable web URL for the change
1226
+ if num:
1227
+ urls.append(
1228
+ f"https://{gerrit.host}/c/{repo.project_gerrit}/+/{num}"
1229
+ )
1230
+ nums.append(num)
1231
+ if current_rev:
1232
+ shas.append(current_rev)
1233
+ # Export env variables (compat)
1234
+ if urls:
1235
+ os.environ["GERRIT_CHANGE_REQUEST_URL"] = "\n".join(urls)
1236
+ if nums:
1237
+ os.environ["GERRIT_CHANGE_REQUEST_NUM"] = "\n".join(nums)
1238
+ if shas:
1239
+ os.environ["GERRIT_COMMIT_SHA"] = "\n".join(shas)
1240
+ return SubmissionResult(
1241
+ change_urls=urls, change_numbers=nums, commit_shas=shas
1242
+ )
1243
+
1244
+ def _setup_git_workspace(self, inputs: Inputs, gh: GitHubContext) -> None:
1245
+ """Initialize and set up git workspace for PR processing."""
1246
+ from .gitutils import run_cmd
1247
+
1248
+ # Initialize git repository
1249
+ run_cmd(["git", "init"], cwd=self.workspace)
1250
+
1251
+ # Add GitHub remote
1252
+ repo_full = gh.repository.strip() if gh.repository else ""
1253
+ server_url = gh.server_url or "https://github.com"
1254
+ server_url = server_url.rstrip("/")
1255
+ repo_url = f"{server_url}/{repo_full}.git"
1256
+ run_cmd(
1257
+ ["git", "remote", "add", "origin", repo_url],
1258
+ cwd=self.workspace,
1259
+ )
1260
+
1261
+ # Fetch PR head
1262
+ if gh.pr_number:
1263
+ pr_ref = (
1264
+ f"refs/pull/{gh.pr_number}/head:"
1265
+ f"refs/remotes/origin/pr/{gh.pr_number}/head"
1266
+ )
1267
+ run_cmd(
1268
+ [
1269
+ "git",
1270
+ "fetch",
1271
+ f"--depth={inputs.fetch_depth}",
1272
+ "origin",
1273
+ pr_ref,
1274
+ ],
1275
+ cwd=self.workspace,
1276
+ )
1277
+ # Checkout PR head
1278
+ pr_head_ref = f"refs/remotes/origin/pr/{gh.pr_number}/head"
1279
+ run_cmd(
1280
+ ["git", "checkout", "-B", "g2g_pr_head", pr_head_ref],
1281
+ cwd=self.workspace,
1282
+ )
1283
+
1284
+ def _install_commit_msg_hook(self, gerrit: GerritInfo) -> None:
1285
+ """Manually install commit-msg hook from Gerrit."""
1286
+ from .gitutils import run_cmd
1287
+
1288
+ hooks_dir = self.workspace / ".git" / "hooks"
1289
+ hooks_dir.mkdir(exist_ok=True)
1290
+ hook_path = hooks_dir / "commit-msg"
1291
+
1292
+ # Download commit-msg hook using SSH
1293
+ try:
1294
+ # Use curl to download the hook (more reliable than scp)
1295
+ curl_cmd = [
1296
+ "curl",
1297
+ "-o",
1298
+ str(hook_path),
1299
+ f"https://{gerrit.host}/r/tools/hooks/commit-msg",
1300
+ ]
1301
+ run_cmd(curl_cmd, cwd=self.workspace)
1302
+
1303
+ # Make hook executable
1304
+ hook_path.chmod(hook_path.stat().st_mode | stat.S_IEXEC)
1305
+ log.debug("Successfully installed commit-msg hook via curl")
1306
+
1307
+ except Exception as exc:
1308
+ log.warning("Failed to install commit-msg hook via curl: %s", exc)
1309
+ msg = f"Could not install commit-msg hook: {exc}"
1310
+ raise OrchestratorError(msg) from exc
1311
+
1312
+ def _ensure_change_id_present(
1313
+ self, gerrit: GerritInfo, author: str
1314
+ ) -> list[str]:
1315
+ """Ensure the last commit has a Change-Id.
1316
+
1317
+ Installs the commit-msg hook and amends the commit if needed.
1318
+ """
1319
+ trailers = git_last_commit_trailers(
1320
+ keys=["Change-Id"], cwd=self.workspace
1321
+ )
1322
+ if not trailers.get("Change-Id"):
1323
+ log.debug(
1324
+ "No Change-Id found, installing commit-msg hook and amending "
1325
+ "commit"
1326
+ )
1327
+ self._install_commit_msg_hook(gerrit)
1328
+ git_commit_amend(
1329
+ no_edit=True, signoff=True, author=author, cwd=self.workspace
1330
+ )
1331
+ # Debug: Check commit message after amend
1332
+ actual_msg = run_cmd(
1333
+ ["git", "show", "-s", "--pretty=format:%B", "HEAD"],
1334
+ cwd=self.workspace,
1335
+ ).stdout.strip()
1336
+ log.debug("Commit message after amend:\n%s", actual_msg)
1337
+ trailers = git_last_commit_trailers(
1338
+ keys=["Change-Id"], cwd=self.workspace
1339
+ )
1340
+ return [c for c in trailers.get("Change-Id", []) if c]
1341
+
1342
+ def _add_backref_comment_in_gerrit(
1343
+ self,
1344
+ *,
1345
+ gerrit: GerritInfo,
1346
+ repo: RepoNames,
1347
+ branch: str,
1348
+ commit_shas: Sequence[str],
1349
+ gh: GitHubContext,
1350
+ ) -> None:
1351
+ """Post a comment in Gerrit pointing back to the GitHub PR and run."""
1352
+ if not commit_shas:
1353
+ log.warning("No commit shas to comment on in Gerrit")
1354
+ return
1355
+ log.info("Adding back-reference comment in Gerrit")
1356
+ user = os.getenv("GERRIT_SSH_USER_G2G", "")
1357
+ server = gerrit.host
1358
+ pr_url = f"{gh.server_url}/{gh.repository}/pull/{gh.pr_number}"
1359
+ run_url = (
1360
+ f"{gh.server_url}/{gh.repository}/actions/runs/{gh.run_id}"
1361
+ if gh.run_id
1362
+ else "N/A"
1363
+ )
1364
+ message = f"GHPR: {pr_url} | Action-Run: {run_url}"
1365
+ log.info("Adding back-reference comment: %s", message)
1366
+ for csha in commit_shas:
1367
+ if not csha:
1368
+ continue
1369
+ try:
1370
+ log.debug("Executing SSH command for commit %s", csha)
1371
+ run_cmd(
1372
+ [
1373
+ "ssh",
1374
+ "-n",
1375
+ "-p",
1376
+ str(gerrit.port),
1377
+ f"{user}@{server}",
1378
+ "gerrit",
1379
+ "review",
1380
+ "-m",
1381
+ message,
1382
+ "--branch",
1383
+ branch,
1384
+ "--project",
1385
+ repo.project_gerrit,
1386
+ csha,
1387
+ ]
1388
+ )
1389
+ log.info(
1390
+ "Successfully added back-reference comment for %s: %s",
1391
+ csha,
1392
+ message,
1393
+ )
1394
+ except Exception:
1395
+ log.exception(
1396
+ "Failed to add back-reference comment for %s", csha
1397
+ )
1398
+ # Continue processing - this is not a fatal error
1399
+
1400
+ def _comment_on_pull_request(
1401
+ self,
1402
+ gh: GitHubContext,
1403
+ gerrit: GerritInfo,
1404
+ result: SubmissionResult,
1405
+ ) -> None:
1406
+ """Post a comment on the PR with the Gerrit change URL(s)."""
1407
+ log.info("Adding reference comment on PR #%s", gh.pr_number)
1408
+ if not gh.pr_number:
1409
+ return
1410
+ urls = result.change_urls or []
1411
+ org = os.getenv("ORGANIZATION", gh.repository_owner)
1412
+ text = (
1413
+ f"The pull-request PR-{gh.pr_number} is submitted to Gerrit "
1414
+ f"[{org}](https://{gerrit.host})!\n\n"
1415
+ )
1416
+ if urls:
1417
+ text += "To follow up on the change visit:\n\n" + "\n".join(urls)
1418
+ try:
1419
+ client = build_client()
1420
+ repo = get_repo_from_env(client)
1421
+ # At this point, gh.pr_number is non-None due to earlier guard.
1422
+ pr_obj = get_pull(repo, int(gh.pr_number))
1423
+ create_pr_comment(pr_obj, text)
1424
+ except Exception as exc:
1425
+ log.warning("Failed to add PR comment: %s", exc)
1426
+
1427
+ def _close_pull_request_if_required(
1428
+ self,
1429
+ gh: GitHubContext,
1430
+ ) -> None:
1431
+ """Close the PR if policy requires (pull_request_target events).
1432
+
1433
+ When PRESERVE_GITHUB_PRS is true, skip closing PRs (useful for testing).
1434
+ """
1435
+ # Respect PRESERVE_GITHUB_PRS to avoid closing PRs during tests
1436
+ preserve = os.getenv("PRESERVE_GITHUB_PRS", "").strip().lower()
1437
+ if preserve in ("1", "true", "yes"):
1438
+ log.info(
1439
+ "PRESERVE_GITHUB_PRS is enabled; skipping PR close for #%s",
1440
+ gh.pr_number,
1441
+ )
1442
+ return
1443
+ # The current shell action closes PR on pull_request_target events.
1444
+ if gh.event_name != "pull_request_target":
1445
+ log.debug("Event is not pull_request_target; not closing PR")
1446
+ return
1447
+ log.info("Closing PR #%s", gh.pr_number)
1448
+ try:
1449
+ client = build_client()
1450
+ repo = get_repo_from_env(client)
1451
+ pr_number = gh.pr_number
1452
+ if pr_number is None:
1453
+ return
1454
+ pr_obj = get_pull(repo, pr_number)
1455
+ close_pr(pr_obj, comment="Auto-closing pull request")
1456
+ except Exception as exc:
1457
+ log.warning("Failed to close PR #%s: %s", gh.pr_number, exc)
1458
+
1459
+ def _dry_run_preflight(
1460
+ self,
1461
+ *,
1462
+ gerrit: GerritInfo,
1463
+ inputs: Inputs,
1464
+ gh: GitHubContext,
1465
+ repo: RepoNames,
1466
+ ) -> None:
1467
+ """Validate config, DNS, and credentials in dry-run mode.
1468
+
1469
+ - Resolve Gerrit host via DNS
1470
+ - Verify SSH (TCP) reachability on the Gerrit port
1471
+ - Verify Gerrit REST endpoint is reachable; if credentials are provided,
1472
+ verify authentication by querying /accounts/self
1473
+ - Verify GitHub token by fetching repository and PR metadata
1474
+ - Do NOT perform any write operations
1475
+ """
1476
+ import socket
1477
+
1478
+ log.info("Dry-run: starting preflight checks")
1479
+ if os.getenv("G2G_DRYRUN_DISABLE_NETWORK", "").strip().lower() in (
1480
+ "1",
1481
+ "true",
1482
+ "yes",
1483
+ "on",
1484
+ ):
1485
+ log.info(
1486
+ "Dry-run: network checks disabled (G2G_DRYRUN_DISABLE_NETWORK)"
1487
+ )
1488
+ log.info(
1489
+ "Dry-run targets: Gerrit project=%s branch=%s "
1490
+ "topic_prefix=GH-%s",
1491
+ repo.project_gerrit,
1492
+ self._resolve_target_branch(),
1493
+ repo.project_github,
1494
+ )
1495
+ if inputs.reviewers_email:
1496
+ log.info(
1497
+ "Reviewers (from inputs/config): %s", inputs.reviewers_email
1498
+ )
1499
+ elif os.getenv("REVIEWERS_EMAIL"):
1500
+ log.info(
1501
+ "Reviewers (from environment): %s",
1502
+ os.getenv("REVIEWERS_EMAIL"),
1503
+ )
1504
+ return
1505
+
1506
+ # DNS resolution for Gerrit host
1507
+ try:
1508
+ socket.getaddrinfo(gerrit.host, None)
1509
+ log.info(
1510
+ "DNS resolution for Gerrit host '%s' succeeded", gerrit.host
1511
+ )
1512
+ except Exception as exc:
1513
+ msg = "DNS resolution failed"
1514
+ raise OrchestratorError(msg) from exc
1515
+
1516
+ # SSH (TCP) reachability on Gerrit port
1517
+ try:
1518
+ with socket.create_connection(
1519
+ (gerrit.host, gerrit.port), timeout=5
1520
+ ):
1521
+ pass
1522
+ log.info(
1523
+ "SSH TCP connectivity to %s:%s verified",
1524
+ gerrit.host,
1525
+ gerrit.port,
1526
+ )
1527
+ except Exception as exc:
1528
+ msg = "SSH TCP connectivity failed"
1529
+ raise OrchestratorError(msg) from exc
1530
+
1531
+ # Gerrit REST reachability and optional auth check
1532
+ base_path = os.getenv("GERRIT_HTTP_BASE_PATH", "").strip().strip("/")
1533
+ http_user = (
1534
+ os.getenv("GERRIT_HTTP_USER", "").strip()
1535
+ or os.getenv("GERRIT_SSH_USER_G2G", "").strip()
1536
+ )
1537
+ http_pass = os.getenv("GERRIT_HTTP_PASSWORD", "").strip()
1538
+ self._verify_gerrit_rest(gerrit.host, base_path, http_user, http_pass)
1539
+
1540
+ # GitHub token and metadata checks
1541
+ try:
1542
+ client = build_client()
1543
+ repo_obj = get_repo_from_env(client)
1544
+ if gh.pr_number is not None:
1545
+ pr_obj = get_pull(repo_obj, gh.pr_number)
1546
+ log.info(
1547
+ "GitHub PR #%s metadata loaded successfully", gh.pr_number
1548
+ )
1549
+ try:
1550
+ title, _ = get_pr_title_body(pr_obj)
1551
+ log.info("GitHub PR title: %s", title)
1552
+ except Exception as exc:
1553
+ log.debug("Failed to read PR title: %s", exc)
1554
+ else:
1555
+ # Enumerate at least one open PR to validate scope
1556
+ prs = list(iter_open_pulls(repo_obj))
1557
+ log.info(
1558
+ "GitHub repository '%s' open PR count: %d",
1559
+ gh.repository,
1560
+ len(prs),
1561
+ )
1562
+ except Exception as exc:
1563
+ msg = "GitHub API validation failed"
1564
+ raise OrchestratorError(msg) from exc
1565
+
1566
+ # Log effective targets
1567
+ log.info(
1568
+ "Dry-run targets: Gerrit project=%s branch=%s topic_prefix=GH-%s",
1569
+ repo.project_gerrit,
1570
+ self._resolve_target_branch(),
1571
+ repo.project_github,
1572
+ )
1573
+ if inputs.reviewers_email:
1574
+ log.info(
1575
+ "Reviewers (from inputs/config): %s", inputs.reviewers_email
1576
+ )
1577
+ elif os.getenv("REVIEWERS_EMAIL"):
1578
+ log.info(
1579
+ "Reviewers (from environment): %s", os.getenv("REVIEWERS_EMAIL")
1580
+ )
1581
+
1582
+ def _verify_gerrit_rest(
1583
+ self,
1584
+ host: str,
1585
+ base_path: str,
1586
+ http_user: str,
1587
+ http_pass: str,
1588
+ ) -> None:
1589
+ """Probe Gerrit REST endpoint with optional auth and '/r' fallback."""
1590
+
1591
+ def _build_client(url: str) -> Any:
1592
+ if http_user and http_pass:
1593
+ if GerritRestAPI is None:
1594
+ raise OrchestratorError("pygerrit2 missing") # noqa: TRY003
1595
+ if HTTPBasicAuth is None:
1596
+ raise OrchestratorError("pygerrit2 auth missing") # noqa: TRY003
1597
+ return GerritRestAPI(
1598
+ url=url, auth=HTTPBasicAuth(http_user, http_pass)
1599
+ )
1600
+ else:
1601
+ if GerritRestAPI is None:
1602
+ raise OrchestratorError("pygerrit2 missing") # noqa: TRY003
1603
+ return GerritRestAPI(url=url)
1604
+
1605
+ def _probe(url: str) -> None:
1606
+ rest: Any = _build_client(url)
1607
+ if http_user and http_pass:
1608
+ _ = rest.get("/accounts/self")
1609
+ log.info(
1610
+ "Gerrit REST authenticated access verified for user '%s'",
1611
+ http_user,
1612
+ )
1613
+ else:
1614
+ _ = rest.get("/dashboard/self")
1615
+ log.info("Gerrit REST endpoint reachable (unauthenticated)")
1616
+
1617
+ base_url = (
1618
+ f"https://{host}/"
1619
+ if not base_path
1620
+ else f"https://{host}/{base_path}/"
1621
+ )
1622
+ try:
1623
+ _probe(base_url)
1624
+ except Exception as exc:
1625
+ status = getattr(
1626
+ getattr(exc, "response", None), "status_code", None
1627
+ )
1628
+ if not base_path and status == 404:
1629
+ try:
1630
+ fallback_url = f"https://{host}/r/"
1631
+ _probe(fallback_url)
1632
+ except Exception as exc2:
1633
+ log.warning(
1634
+ "Gerrit REST probe did not succeed "
1635
+ "(including '/r' fallback): %s",
1636
+ exc2,
1637
+ )
1638
+ else:
1639
+ log.warning("Gerrit REST probe did not succeed: %s", exc)
1640
+
1641
+ # ---------------
1642
+ # Helpers
1643
+ # ---------------
1644
+
1645
+ def _append_github_output(self, outputs: dict[str, str]) -> None:
1646
+ gh_out = os.getenv("GITHUB_OUTPUT")
1647
+ if not gh_out:
1648
+ return
1649
+ try:
1650
+ with open(gh_out, "a", encoding="utf-8") as fh:
1651
+ for key, val in outputs.items():
1652
+ if not val:
1653
+ continue
1654
+ if "\n" in val and os.getenv("GITHUB_ACTIONS") == "true":
1655
+ fh.write(f"{key}<<G2G\n")
1656
+ fh.write(f"{val}\n")
1657
+ fh.write("G2G\n")
1658
+ else:
1659
+ fh.write(f"{key}={val}\n")
1660
+ except Exception as exc:
1661
+ log.debug("Failed to write GITHUB_OUTPUT: %s", exc)
1662
+
1663
+ def _resolve_target_branch(self) -> str:
1664
+ # Preference order:
1665
+ # 1) GERRIT_BRANCH (explicit override)
1666
+ # 2) GITHUB_BASE_REF (provided in Actions PR context)
1667
+ # 3) origin/HEAD default (if available)
1668
+ # 4) 'main' as a common default
1669
+ # 5) 'master' as a legacy default
1670
+ b = os.getenv("GERRIT_BRANCH", "").strip()
1671
+ if b:
1672
+ return b
1673
+ b = os.getenv("GITHUB_BASE_REF", "").strip()
1674
+ if b:
1675
+ return b
1676
+ # Try resolve origin/HEAD -> origin/<branch>
1677
+ try:
1678
+ from .gitutils import git_quiet
1679
+
1680
+ res = git_quiet(
1681
+ ["rev-parse", "--abbrev-ref", "origin/HEAD"],
1682
+ cwd=self.workspace,
1683
+ )
1684
+ if res.returncode == 0:
1685
+ name = (res.stdout or "").strip()
1686
+ branch = name.split("/", 1)[1] if "/" in name else name
1687
+ if branch:
1688
+ return branch
1689
+ except Exception as exc:
1690
+ log.debug("origin/HEAD probe failed: %s", exc)
1691
+ # Prefer 'master' when present
1692
+ try:
1693
+ from .gitutils import git_quiet
1694
+
1695
+ res3 = git_quiet(
1696
+ ["show-ref", "--verify", "refs/remotes/origin/master"],
1697
+ cwd=self.workspace,
1698
+ )
1699
+ if res3.returncode == 0:
1700
+ return "master"
1701
+ except Exception as exc:
1702
+ log.debug("origin/master probe failed: %s", exc)
1703
+ # Fall back to 'main' if present
1704
+ try:
1705
+ from .gitutils import git_quiet
1706
+
1707
+ res2 = git_quiet(
1708
+ ["show-ref", "--verify", "refs/remotes/origin/main"],
1709
+ cwd=self.workspace,
1710
+ )
1711
+ if res2.returncode == 0:
1712
+ return "main"
1713
+ except Exception as exc:
1714
+ log.debug("origin/main probe failed: %s", exc)
1715
+ return "master"
1716
+
1717
+ def _resolve_reviewers(self, inputs: Inputs) -> str:
1718
+ # If empty, use the Gerrit SSH user's email as default.
1719
+ if inputs.reviewers_email.strip():
1720
+ return inputs.reviewers_email.strip()
1721
+ return inputs.gerrit_ssh_user_g2g_email.strip()
1722
+
1723
+ def _get_last_change_ids_from_head(self) -> list[str]:
1724
+ """Return Change-Id trailer(s) from HEAD commit, if present."""
1725
+ try:
1726
+ trailers = git_last_commit_trailers(keys=["Change-Id"])
1727
+ except GitError:
1728
+ return []
1729
+ values = trailers.get("Change-Id", [])
1730
+ return [v for v in values if v]
1731
+
1732
+ def _validate_change_ids(self, ids: Iterable[str]) -> list[str]:
1733
+ """Basic validation for Change-Id strings."""
1734
+ out: list[str] = []
1735
+ for cid in ids:
1736
+ c = cid.strip()
1737
+ if not c:
1738
+ continue
1739
+ if not _is_valid_change_id(c):
1740
+ log.debug("Ignoring invalid Change-Id: %s", c)
1741
+ continue
1742
+ out.append(c)
1743
+ return out
1744
+
1745
+
1746
+ # ---------------------
1747
+ # Utility functions
1748
+ # ---------------------
1749
+
1750
+ # moved _is_valid_change_id above its first use