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/__init__.py +29 -0
- github2gerrit/cli.py +865 -0
- github2gerrit/config.py +311 -0
- github2gerrit/core.py +1750 -0
- github2gerrit/duplicate_detection.py +542 -0
- github2gerrit/github_api.py +331 -0
- github2gerrit/gitutils.py +655 -0
- github2gerrit/models.py +81 -0
- {github2gerrit-0.1.0.dist-info → github2gerrit-0.1.3.dist-info}/METADATA +5 -4
- github2gerrit-0.1.3.dist-info/RECORD +12 -0
- github2gerrit-0.1.0.dist-info/RECORD +0 -4
- {github2gerrit-0.1.0.dist-info → github2gerrit-0.1.3.dist-info}/WHEEL +0 -0
- {github2gerrit-0.1.0.dist-info → github2gerrit-0.1.3.dist-info}/entry_points.txt +0 -0
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
|