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