github2gerrit 0.1.9__py3-none-any.whl → 0.1.11__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- github2gerrit/cli.py +796 -200
- github2gerrit/commit_normalization.py +44 -15
- github2gerrit/config.py +77 -30
- github2gerrit/core.py +1576 -260
- github2gerrit/duplicate_detection.py +224 -100
- 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 +66 -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.9.dist-info → github2gerrit-0.1.11.dist-info}/METADATA +99 -25
- github2gerrit-0.1.11.dist-info/RECORD +31 -0
- {github2gerrit-0.1.9.dist-info → github2gerrit-0.1.11.dist-info}/WHEEL +1 -2
- github2gerrit-0.1.9.dist-info/RECORD +0 -24
- github2gerrit-0.1.9.dist-info/top_level.txt +0 -1
- {github2gerrit-0.1.9.dist-info → github2gerrit-0.1.11.dist-info}/entry_points.txt +0 -0
- {github2gerrit-0.1.9.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,25 +2156,35 @@ class Orchestrator:
|
|
1407
2156
|
|
1408
2157
|
# Apply conventional commit normalization if enabled
|
1409
2158
|
if inputs.normalise_commit:
|
1410
|
-
title = normalize_commit_title(
|
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
|
-
|
2168
|
+
signed_lines = [
|
2169
|
+
ln for ln in lines_cur if ln.startswith("Signed-off-by:")
|
2170
|
+
]
|
2171
|
+
change_id_lines = [
|
2172
|
+
ln for ln in lines_cur if ln.startswith("Change-Id:")
|
2173
|
+
]
|
2174
|
+
github_hash_lines = [
|
2175
|
+
ln for ln in lines_cur if ln.startswith("GitHub-Hash:")
|
2176
|
+
]
|
2177
|
+
github_pr_lines = [
|
2178
|
+
ln for ln in lines_cur if ln.startswith("GitHub-PR:")
|
2179
|
+
]
|
1420
2180
|
|
1421
2181
|
msg_parts = [title, "", body] if title or body else [current_body]
|
1422
2182
|
commit_message = "\n".join(msg_parts).strip()
|
1423
2183
|
|
1424
|
-
#
|
1425
|
-
|
2184
|
+
# Issue-ID will be added in the footer section later
|
2185
|
+
# (removed from here to avoid duplication)
|
1426
2186
|
|
1427
|
-
#
|
1428
|
-
# to keep a blank line before Signed-off-by/Change-Id trailers.
|
2187
|
+
# Prepare GitHub-Hash (will be placed after GitHub-PR at footer)
|
1429
2188
|
if github_hash_lines:
|
1430
2189
|
gh_hash_line = github_hash_lines[-1]
|
1431
2190
|
else:
|
@@ -1434,9 +2193,7 @@ class Orchestrator:
|
|
1434
2193
|
gh_val = DuplicateDetector._generate_github_change_hash(gh)
|
1435
2194
|
gh_hash_line = f"GitHub-Hash: {gh_val}"
|
1436
2195
|
|
1437
|
-
|
1438
|
-
|
1439
|
-
# Build trailers: Signed-off-by first, Change-Id last.
|
2196
|
+
# Build trailers: Signed-off-by first, Change-Id next.
|
1440
2197
|
trailers_out: list[str] = []
|
1441
2198
|
if signed_lines:
|
1442
2199
|
seen_so: set[str] = set()
|
@@ -1446,8 +2203,27 @@ class Orchestrator:
|
|
1446
2203
|
seen_so.add(ln)
|
1447
2204
|
if change_id_lines:
|
1448
2205
|
trailers_out.append(change_id_lines[-1])
|
1449
|
-
|
1450
|
-
|
2206
|
+
|
2207
|
+
# GitHub-PR (after Change-Id)
|
2208
|
+
if github_pr_lines:
|
2209
|
+
pr_line = github_pr_lines[-1]
|
2210
|
+
else:
|
2211
|
+
pr_line = (
|
2212
|
+
f"GitHub-PR: {gh.server_url}/{gh.repository}/pull/"
|
2213
|
+
f"{gh.pr_number}"
|
2214
|
+
if gh.pr_number
|
2215
|
+
else ""
|
2216
|
+
)
|
2217
|
+
|
2218
|
+
# Assemble footer in desired order:
|
2219
|
+
footer_lines: list[str] = []
|
2220
|
+
footer_lines.extend(trailers_out)
|
2221
|
+
if pr_line:
|
2222
|
+
footer_lines.append(pr_line)
|
2223
|
+
footer_lines.append(gh_hash_line)
|
2224
|
+
|
2225
|
+
if footer_lines:
|
2226
|
+
commit_message += "\n\n" + "\n".join(footer_lines)
|
1451
2227
|
|
1452
2228
|
author = run_cmd(
|
1453
2229
|
["git", "show", "-s", "--pretty=format:%an <%ae>", "HEAD"],
|
@@ -1461,6 +2237,41 @@ class Orchestrator:
|
|
1461
2237
|
author=author,
|
1462
2238
|
message=commit_message,
|
1463
2239
|
)
|
2240
|
+
# Phase 2: collect Change-Id trailers for later comment emission
|
2241
|
+
try:
|
2242
|
+
trailers_after = git_last_commit_trailers(
|
2243
|
+
keys=["Change-Id"], cwd=self.workspace
|
2244
|
+
)
|
2245
|
+
self._latest_apply_pr_change_ids = trailers_after.get(
|
2246
|
+
"Change-Id", []
|
2247
|
+
)
|
2248
|
+
except Exception as exc:
|
2249
|
+
log.debug(
|
2250
|
+
"Failed to collect Change-Ids after apply_pr_title: %s", exc
|
2251
|
+
)
|
2252
|
+
# Phase 1: ensure trailers present even if earlier logic skipped
|
2253
|
+
# (idempotent)
|
2254
|
+
try:
|
2255
|
+
meta = self._build_pr_metadata_trailers(gh)
|
2256
|
+
if meta:
|
2257
|
+
cur_msg = run_cmd(
|
2258
|
+
["git", "show", "-s", "--pretty=format:%B", "HEAD"],
|
2259
|
+
cwd=self.workspace,
|
2260
|
+
).stdout
|
2261
|
+
needed = [m for m in meta if m not in cur_msg]
|
2262
|
+
if needed:
|
2263
|
+
new_msg = cur_msg.rstrip() + "\n" + "\n".join(needed) + "\n"
|
2264
|
+
git_commit_amend(
|
2265
|
+
cwd=self.workspace,
|
2266
|
+
no_edit=False,
|
2267
|
+
signoff=False,
|
2268
|
+
author=author,
|
2269
|
+
message=new_msg,
|
2270
|
+
)
|
2271
|
+
except Exception as meta_exc:
|
2272
|
+
log.debug(
|
2273
|
+
"Skipping post-apply metadata trailer ensure: %s", meta_exc
|
2274
|
+
)
|
1464
2275
|
|
1465
2276
|
def _push_to_gerrit(
|
1466
2277
|
self,
|
@@ -1470,9 +2281,10 @@ class Orchestrator:
|
|
1470
2281
|
branch: str,
|
1471
2282
|
reviewers: str,
|
1472
2283
|
single_commits: bool,
|
2284
|
+
prepared: PreparedChange | None = None,
|
1473
2285
|
) -> None:
|
1474
2286
|
"""Push prepared commit(s) to Gerrit using git-review."""
|
1475
|
-
log.
|
2287
|
+
log.debug(
|
1476
2288
|
"Pushing changes to Gerrit %s:%s project=%s branch=%s",
|
1477
2289
|
gerrit.host,
|
1478
2290
|
gerrit.port,
|
@@ -1485,7 +2297,11 @@ class Orchestrator:
|
|
1485
2297
|
run_cmd(["git", "checkout", tmp_branch], cwd=self.workspace)
|
1486
2298
|
prefix = os.getenv("G2G_TOPIC_PREFIX", "GH").strip() or "GH"
|
1487
2299
|
pr_num = os.getenv("PR_NUMBER", "").strip()
|
1488
|
-
topic =
|
2300
|
+
topic = (
|
2301
|
+
f"{prefix}-{repo.project_github}-{pr_num}"
|
2302
|
+
if pr_num
|
2303
|
+
else f"{prefix}-{repo.project_github}"
|
2304
|
+
)
|
1489
2305
|
|
1490
2306
|
# Use our specific SSH configuration
|
1491
2307
|
env = self._ssh_env()
|
@@ -1499,32 +2315,164 @@ class Orchestrator:
|
|
1499
2315
|
"-t",
|
1500
2316
|
topic,
|
1501
2317
|
]
|
1502
|
-
|
2318
|
+
collected_change_ids: list[str] = []
|
2319
|
+
if prepared:
|
2320
|
+
collected_change_ids.extend(prepared.all_change_ids())
|
2321
|
+
# Add any Change-Ids captured from apply_pr path (squash amend)
|
2322
|
+
extra_ids = getattr(self, "_latest_apply_pr_change_ids", [])
|
2323
|
+
for cid in extra_ids:
|
2324
|
+
if cid and cid not in collected_change_ids:
|
2325
|
+
collected_change_ids.append(cid)
|
2326
|
+
revs = [
|
2327
|
+
r.strip()
|
2328
|
+
for r in (reviewers or "").split(",")
|
2329
|
+
if r.strip() and "@" in r and r.strip() != branch
|
2330
|
+
]
|
1503
2331
|
for r in revs:
|
1504
2332
|
args.extend(["--reviewer", r])
|
1505
2333
|
# Branch as positional argument (not a flag)
|
1506
2334
|
args.append(branch)
|
1507
2335
|
|
1508
2336
|
if env_bool("CI_TESTING", False):
|
1509
|
-
log.
|
1510
|
-
|
2337
|
+
log.debug(
|
2338
|
+
"CI_TESTING enabled: using synthetic orphan commit "
|
2339
|
+
"push path"
|
2340
|
+
)
|
2341
|
+
self._create_orphan_commit_and_push(
|
2342
|
+
gerrit, repo, branch, reviewers, topic, env
|
2343
|
+
)
|
1511
2344
|
return
|
1512
2345
|
log.debug("Executing git review command: %s", " ".join(args))
|
1513
2346
|
run_cmd(args, cwd=self.workspace, env=env)
|
1514
|
-
log.
|
2347
|
+
log.debug("Successfully pushed changes to Gerrit")
|
1515
2348
|
except CommandError as exc:
|
1516
2349
|
# Check if this is a "no common ancestry" error in CI_TESTING mode
|
1517
2350
|
if self._should_handle_unrelated_history(exc):
|
1518
|
-
log.
|
1519
|
-
|
2351
|
+
log.debug(
|
2352
|
+
"Detected unrelated repository history. Creating orphan "
|
2353
|
+
"commit for CI testing..."
|
2354
|
+
)
|
2355
|
+
self._create_orphan_commit_and_push(
|
2356
|
+
gerrit, repo, branch, reviewers, topic, env
|
2357
|
+
)
|
1520
2358
|
return
|
1521
2359
|
|
2360
|
+
# Check for account not found error and try with case-normalized
|
2361
|
+
# emails
|
2362
|
+
account_not_found_emails = self._extract_account_not_found_emails(
|
2363
|
+
exc
|
2364
|
+
)
|
2365
|
+
if account_not_found_emails:
|
2366
|
+
normalized_reviewers = self._normalize_reviewer_emails(
|
2367
|
+
reviewers, account_not_found_emails
|
2368
|
+
)
|
2369
|
+
if normalized_reviewers != reviewers:
|
2370
|
+
log.debug(
|
2371
|
+
"Retrying with case-normalized email addresses..."
|
2372
|
+
)
|
2373
|
+
try:
|
2374
|
+
# Rebuild args with normalized reviewers
|
2375
|
+
retry_args = args[:-1] # Remove branch (last arg)
|
2376
|
+
# Clear previous reviewer args and add normalized ones
|
2377
|
+
retry_args = [
|
2378
|
+
arg for arg in retry_args if arg != "--reviewer"
|
2379
|
+
]
|
2380
|
+
retry_args = [
|
2381
|
+
retry_args[i]
|
2382
|
+
for i in range(len(retry_args))
|
2383
|
+
if i == 0 or retry_args[i - 1] != "--reviewer"
|
2384
|
+
]
|
2385
|
+
|
2386
|
+
norm_revs = [
|
2387
|
+
r.strip()
|
2388
|
+
for r in (normalized_reviewers or "").split(",")
|
2389
|
+
if r.strip() and "@" in r and r.strip() != branch
|
2390
|
+
]
|
2391
|
+
for r in norm_revs:
|
2392
|
+
retry_args.extend(["--reviewer", r])
|
2393
|
+
retry_args.append(branch)
|
2394
|
+
|
2395
|
+
log.debug(
|
2396
|
+
"Retrying git review command with normalized "
|
2397
|
+
"emails: %s",
|
2398
|
+
" ".join(retry_args),
|
2399
|
+
)
|
2400
|
+
run_cmd(retry_args, cwd=self.workspace, env=env)
|
2401
|
+
log.debug(
|
2402
|
+
"Successfully pushed changes to Gerrit with "
|
2403
|
+
"normalized email addresses"
|
2404
|
+
)
|
2405
|
+
|
2406
|
+
# Update configuration file with normalized email
|
2407
|
+
# addresses
|
2408
|
+
self._update_config_with_normalized_emails(
|
2409
|
+
account_not_found_emails
|
2410
|
+
)
|
2411
|
+
except CommandError as retry_exc:
|
2412
|
+
log.warning(
|
2413
|
+
"Retry with normalized emails also failed: %s",
|
2414
|
+
self._analyze_gerrit_push_failure(retry_exc),
|
2415
|
+
)
|
2416
|
+
# Continue with original error handling
|
2417
|
+
else:
|
2418
|
+
# On success, emit mapping comment before return
|
2419
|
+
try:
|
2420
|
+
gh_context = getattr(
|
2421
|
+
self, "_gh_context_for_push", None
|
2422
|
+
)
|
2423
|
+
replace_existing = getattr(
|
2424
|
+
self, "_inputs", None
|
2425
|
+
) and getattr(
|
2426
|
+
self._inputs,
|
2427
|
+
"persist_single_mapping_comment",
|
2428
|
+
True,
|
2429
|
+
)
|
2430
|
+
self._emit_change_id_map_comment(
|
2431
|
+
gh_context=gh_context,
|
2432
|
+
change_ids=collected_change_ids,
|
2433
|
+
multi=single_commits,
|
2434
|
+
topic=topic,
|
2435
|
+
replace_existing=bool(replace_existing),
|
2436
|
+
)
|
2437
|
+
except Exception as cexc:
|
2438
|
+
log.debug(
|
2439
|
+
"Failed to emit Change-Id map comment "
|
2440
|
+
"(retry path): %s",
|
2441
|
+
cexc,
|
2442
|
+
)
|
2443
|
+
return
|
2444
|
+
|
1522
2445
|
# Analyze the specific failure reason from git review output
|
1523
2446
|
error_details = self._analyze_gerrit_push_failure(exc)
|
1524
|
-
log_exception_conditionally(
|
1525
|
-
|
2447
|
+
log_exception_conditionally(
|
2448
|
+
log, "Gerrit push failed: %s", error_details
|
2449
|
+
)
|
2450
|
+
msg = (
|
2451
|
+
f"Failed to push changes to Gerrit with git-review: "
|
2452
|
+
f"{error_details}"
|
2453
|
+
)
|
1526
2454
|
raise OrchestratorError(msg) from exc
|
1527
2455
|
# Cleanup temporary branch used during preparation
|
2456
|
+
else:
|
2457
|
+
# Successful push: emit mapping comment (Phase 2)
|
2458
|
+
try:
|
2459
|
+
gh_context = getattr(self, "_gh_context_for_push", None)
|
2460
|
+
replace_existing = getattr(self, "_inputs", None) and getattr(
|
2461
|
+
self._inputs, "persist_single_mapping_comment", True
|
2462
|
+
)
|
2463
|
+
self._emit_change_id_map_comment(
|
2464
|
+
gh_context=gh_context,
|
2465
|
+
change_ids=collected_change_ids,
|
2466
|
+
multi=single_commits,
|
2467
|
+
topic=topic,
|
2468
|
+
replace_existing=bool(replace_existing),
|
2469
|
+
)
|
2470
|
+
except Exception as exc_emit:
|
2471
|
+
log.debug(
|
2472
|
+
"Failed to emit Change-Id map comment (success path): %s",
|
2473
|
+
exc_emit,
|
2474
|
+
)
|
2475
|
+
# Cleanup temporary branch used during preparation
|
1528
2476
|
tmp_branch = (os.getenv("G2G_TMP_BRANCH", "") or "").strip()
|
1529
2477
|
if tmp_branch:
|
1530
2478
|
# Switch back to the target branch, then delete the temp branch
|
@@ -1541,8 +2489,144 @@ class Orchestrator:
|
|
1541
2489
|
env=env,
|
1542
2490
|
)
|
1543
2491
|
|
2492
|
+
def _extract_account_not_found_emails(self, exc: CommandError) -> list[str]:
|
2493
|
+
"""Extract email addresses from 'Account not found' errors.
|
2494
|
+
|
2495
|
+
Args:
|
2496
|
+
exc: The CommandError from git review failure
|
2497
|
+
|
2498
|
+
Returns:
|
2499
|
+
List of email addresses that were not found in Gerrit
|
2500
|
+
"""
|
2501
|
+
combined_output = f"{exc.stdout}\n{exc.stderr}"
|
2502
|
+
import re
|
2503
|
+
|
2504
|
+
# Pattern to match: Account 'email@domain.com' not found
|
2505
|
+
pattern = r"Account\s+'([^']+)'\s+not\s+found"
|
2506
|
+
matches = re.findall(pattern, combined_output, re.IGNORECASE)
|
2507
|
+
|
2508
|
+
# Filter to only include valid email addresses
|
2509
|
+
email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
|
2510
|
+
valid_emails = [
|
2511
|
+
email for email in matches if re.match(email_pattern, email)
|
2512
|
+
]
|
2513
|
+
|
2514
|
+
if valid_emails:
|
2515
|
+
log.debug("Found 'Account not found' emails: %s", valid_emails)
|
2516
|
+
|
2517
|
+
return valid_emails
|
2518
|
+
|
2519
|
+
def _normalize_reviewer_emails(
|
2520
|
+
self, reviewers: str, failed_emails: list[str]
|
2521
|
+
) -> str:
|
2522
|
+
"""Normalize reviewer email addresses to lowercase.
|
2523
|
+
|
2524
|
+
Args:
|
2525
|
+
reviewers: Comma-separated string of reviewer emails
|
2526
|
+
failed_emails: List of emails that failed account lookup
|
2527
|
+
|
2528
|
+
Returns:
|
2529
|
+
Comma-separated string with failed emails converted to lowercase
|
2530
|
+
"""
|
2531
|
+
if not reviewers or not failed_emails:
|
2532
|
+
return reviewers
|
2533
|
+
|
2534
|
+
reviewer_list = [r.strip() for r in reviewers.split(",") if r.strip()]
|
2535
|
+
normalized_list = []
|
2536
|
+
|
2537
|
+
for reviewer in reviewer_list:
|
2538
|
+
if reviewer in failed_emails:
|
2539
|
+
normalized = reviewer.lower()
|
2540
|
+
if normalized != reviewer:
|
2541
|
+
log.info(
|
2542
|
+
"Normalizing email case: %s -> %s", reviewer, normalized
|
2543
|
+
)
|
2544
|
+
normalized_list.append(normalized)
|
2545
|
+
else:
|
2546
|
+
normalized_list.append(reviewer)
|
2547
|
+
|
2548
|
+
return ",".join(normalized_list)
|
2549
|
+
|
2550
|
+
def _update_config_with_normalized_emails(
|
2551
|
+
self, original_emails: list[str]
|
2552
|
+
) -> None:
|
2553
|
+
"""Update configuration file with normalized email addresses.
|
2554
|
+
|
2555
|
+
Args:
|
2556
|
+
original_emails: List of original emails that were normalized
|
2557
|
+
"""
|
2558
|
+
try:
|
2559
|
+
# Get current organization for config lookup
|
2560
|
+
org = os.getenv("ORGANIZATION") or os.getenv(
|
2561
|
+
"GITHUB_REPOSITORY_OWNER"
|
2562
|
+
)
|
2563
|
+
if not org:
|
2564
|
+
log.debug("No organization found, skipping config file update")
|
2565
|
+
return
|
2566
|
+
|
2567
|
+
config_path = os.getenv("G2G_CONFIG_PATH", "").strip()
|
2568
|
+
if not config_path:
|
2569
|
+
config_path = "~/.config/github2gerrit/configuration.txt"
|
2570
|
+
|
2571
|
+
config_path_obj = Path(config_path).expanduser()
|
2572
|
+
if not config_path_obj.exists():
|
2573
|
+
log.debug(
|
2574
|
+
"Config file does not exist, skipping update: %s",
|
2575
|
+
config_path_obj,
|
2576
|
+
)
|
2577
|
+
return
|
2578
|
+
|
2579
|
+
# Read current config content
|
2580
|
+
content = config_path_obj.read_text(encoding="utf-8")
|
2581
|
+
original_content = content
|
2582
|
+
|
2583
|
+
# Look for email addresses in the content and normalize them
|
2584
|
+
for original_email in original_emails:
|
2585
|
+
normalized_email = original_email.lower()
|
2586
|
+
if normalized_email != original_email:
|
2587
|
+
# Replace the original email with normalized version
|
2588
|
+
# This handles both quoted and unquoted email addresses
|
2589
|
+
patterns = [
|
2590
|
+
f'"{original_email}"', # Quoted
|
2591
|
+
f"'{original_email}'", # Single quoted
|
2592
|
+
original_email, # Unquoted
|
2593
|
+
]
|
2594
|
+
|
2595
|
+
for pattern in patterns:
|
2596
|
+
if pattern in content:
|
2597
|
+
replacement = pattern.replace(
|
2598
|
+
original_email, normalized_email
|
2599
|
+
)
|
2600
|
+
content = content.replace(pattern, replacement)
|
2601
|
+
log.info(
|
2602
|
+
"Updated config file: %s -> %s",
|
2603
|
+
pattern,
|
2604
|
+
replacement,
|
2605
|
+
)
|
2606
|
+
|
2607
|
+
# Write back if changes were made
|
2608
|
+
if content != original_content:
|
2609
|
+
config_path_obj.write_text(content, encoding="utf-8")
|
2610
|
+
log.info(
|
2611
|
+
"Configuration file updated with normalized email "
|
2612
|
+
"addresses: %s",
|
2613
|
+
config_path_obj,
|
2614
|
+
)
|
2615
|
+
else:
|
2616
|
+
log.debug(
|
2617
|
+
"No email addresses found in config file to normalize"
|
2618
|
+
)
|
2619
|
+
|
2620
|
+
except Exception as exc:
|
2621
|
+
log.warning(
|
2622
|
+
"Failed to update configuration file with normalized "
|
2623
|
+
"emails: %s",
|
2624
|
+
exc,
|
2625
|
+
)
|
2626
|
+
|
1544
2627
|
def _should_handle_unrelated_history(self, exc: CommandError) -> bool:
|
1545
|
-
"""Check if we should handle unrelated repository history in CI
|
2628
|
+
"""Check if we should handle unrelated repository history in CI
|
2629
|
+
testing mode."""
|
1546
2630
|
if not env_bool("CI_TESTING", False):
|
1547
2631
|
return False
|
1548
2632
|
|
@@ -1565,25 +2649,61 @@ class Orchestrator:
|
|
1565
2649
|
return any(p in combined_lower for p in phrases)
|
1566
2650
|
|
1567
2651
|
def _create_orphan_commit_and_push(
|
1568
|
-
self,
|
2652
|
+
self,
|
2653
|
+
gerrit: GerritInfo,
|
2654
|
+
repo: RepoNames,
|
2655
|
+
branch: str,
|
2656
|
+
reviewers: str,
|
2657
|
+
topic: str,
|
2658
|
+
env: dict[str, str],
|
1569
2659
|
) -> None:
|
1570
|
-
"""Create a synthetic commit on top of the remote base with the PR
|
1571
|
-
|
2660
|
+
"""Create a synthetic commit on top of the remote base with the PR
|
2661
|
+
tree (CI testing mode)."""
|
2662
|
+
log.debug(
|
2663
|
+
"CI_TESTING: Creating synthetic commit on top of remote base "
|
2664
|
+
"for unrelated repository"
|
2665
|
+
)
|
1572
2666
|
|
1573
2667
|
try:
|
1574
2668
|
# Capture the current PR commit message and tree
|
1575
|
-
commit_msg = run_cmd(
|
1576
|
-
|
2669
|
+
commit_msg = run_cmd(
|
2670
|
+
["git", "log", "--format=%B", "-n", "1", "HEAD"],
|
2671
|
+
cwd=self.workspace,
|
2672
|
+
).stdout.strip()
|
2673
|
+
pr_tree = run_cmd(
|
2674
|
+
["git", "show", "--quiet", "--format=%T", "HEAD"],
|
2675
|
+
cwd=self.workspace,
|
2676
|
+
).stdout.strip()
|
1577
2677
|
|
1578
2678
|
# Create/update a synthetic branch based on the remote base branch
|
1579
2679
|
synth_branch = f"synth-{topic}"
|
1580
2680
|
# Ensure remote ref exists locally (best-effort)
|
1581
|
-
run_cmd(
|
1582
|
-
|
2681
|
+
run_cmd(
|
2682
|
+
["git", "fetch", "gerrit", branch],
|
2683
|
+
cwd=self.workspace,
|
2684
|
+
env=env,
|
2685
|
+
check=False,
|
2686
|
+
)
|
2687
|
+
run_cmd(
|
2688
|
+
[
|
2689
|
+
"git",
|
2690
|
+
"checkout",
|
2691
|
+
"-B",
|
2692
|
+
synth_branch,
|
2693
|
+
f"remotes/gerrit/{branch}",
|
2694
|
+
],
|
2695
|
+
cwd=self.workspace,
|
2696
|
+
env=env,
|
2697
|
+
)
|
1583
2698
|
|
1584
2699
|
# Replace working tree contents with the PR tree
|
1585
2700
|
# 1) Remove current tracked files (ignore errors if none)
|
1586
|
-
run_cmd(
|
2701
|
+
run_cmd(
|
2702
|
+
["git", "rm", "-r", "--quiet", "."],
|
2703
|
+
cwd=self.workspace,
|
2704
|
+
env=env,
|
2705
|
+
check=False,
|
2706
|
+
)
|
1587
2707
|
# 2) Clean untracked files/dirs (preserve our SSH known_hosts dir)
|
1588
2708
|
run_cmd(
|
1589
2709
|
["git", "clean", "-fdx", "-e", ".ssh-g2g", "-e", ".ssh-g2g/**"],
|
@@ -1592,15 +2712,23 @@ class Orchestrator:
|
|
1592
2712
|
check=False,
|
1593
2713
|
)
|
1594
2714
|
# 3) Checkout the PR tree into working directory
|
1595
|
-
run_cmd(
|
2715
|
+
run_cmd(
|
2716
|
+
["git", "checkout", pr_tree, "--", "."],
|
2717
|
+
cwd=self.workspace,
|
2718
|
+
env=env,
|
2719
|
+
)
|
1596
2720
|
run_cmd(["git", "add", "-A"], cwd=self.workspace, env=env)
|
1597
2721
|
|
1598
|
-
# Commit synthetic change with the same message (should already
|
2722
|
+
# Commit synthetic change with the same message (should already
|
2723
|
+
# include Change-Id)
|
1599
2724
|
import tempfile as _tempfile
|
1600
2725
|
from pathlib import Path as _Path
|
1601
2726
|
|
1602
|
-
with _tempfile.NamedTemporaryFile(
|
1603
|
-
|
2727
|
+
with _tempfile.NamedTemporaryFile(
|
2728
|
+
"w", delete=False, encoding="utf-8"
|
2729
|
+
) as _tf:
|
2730
|
+
# Ensure Signed-off-by for current committer (uploader) is
|
2731
|
+
# present in the footer
|
1604
2732
|
try:
|
1605
2733
|
committer_name = run_cmd(
|
1606
2734
|
["git", "config", "--get", "user.name"],
|
@@ -1617,7 +2745,9 @@ class Orchestrator:
|
|
1617
2745
|
committer_email = ""
|
1618
2746
|
msg_to_write = commit_msg
|
1619
2747
|
if committer_name and committer_email:
|
1620
|
-
sob_line =
|
2748
|
+
sob_line = (
|
2749
|
+
f"Signed-off-by: {committer_name} <{committer_email}>"
|
2750
|
+
)
|
1621
2751
|
if sob_line not in msg_to_write:
|
1622
2752
|
if not msg_to_write.endswith("\n"):
|
1623
2753
|
msg_to_write += "\n"
|
@@ -1628,20 +2758,39 @@ class Orchestrator:
|
|
1628
2758
|
_tf.flush()
|
1629
2759
|
_tmp_msg_path = _Path(_tf.name)
|
1630
2760
|
try:
|
1631
|
-
run_cmd(
|
2761
|
+
run_cmd(
|
2762
|
+
["git", "commit", "-F", str(_tmp_msg_path)],
|
2763
|
+
cwd=self.workspace,
|
2764
|
+
env=env,
|
2765
|
+
)
|
1632
2766
|
finally:
|
1633
2767
|
from contextlib import suppress
|
1634
2768
|
|
1635
2769
|
with suppress(Exception):
|
1636
2770
|
_tmp_msg_path.unlink(missing_ok=True)
|
1637
2771
|
|
1638
|
-
# Push directly to refs/for/<branch> with topic and reviewers to
|
2772
|
+
# Push directly to refs/for/<branch> with topic and reviewers to
|
2773
|
+
# avoid rebase behavior
|
1639
2774
|
push_ref = f"refs/for/{branch}%topic={topic}"
|
1640
|
-
revs = [
|
2775
|
+
revs = [
|
2776
|
+
r.strip()
|
2777
|
+
for r in (reviewers or "").split(",")
|
2778
|
+
if r.strip() and "@" in r and r.strip() != branch
|
2779
|
+
]
|
1641
2780
|
for r in revs:
|
1642
2781
|
push_ref += f",r={r}"
|
1643
|
-
run_cmd(
|
1644
|
-
|
2782
|
+
run_cmd(
|
2783
|
+
[
|
2784
|
+
"git",
|
2785
|
+
"push",
|
2786
|
+
"--no-follow-tags",
|
2787
|
+
"gerrit",
|
2788
|
+
f"HEAD:{push_ref}",
|
2789
|
+
],
|
2790
|
+
cwd=self.workspace,
|
2791
|
+
env=env,
|
2792
|
+
)
|
2793
|
+
log.debug("Successfully pushed synthetic commit to Gerrit")
|
1645
2794
|
|
1646
2795
|
except CommandError as orphan_exc:
|
1647
2796
|
error_details = self._analyze_gerrit_push_failure(orphan_exc)
|
@@ -1655,6 +2804,10 @@ class Orchestrator:
|
|
1655
2804
|
combined_output = f"{stdout}\n{stderr}"
|
1656
2805
|
combined_lower = combined_output.lower()
|
1657
2806
|
|
2807
|
+
# Remove extra whitespace and normalize line breaks for better pattern
|
2808
|
+
# matching
|
2809
|
+
normalized_output = " ".join(combined_lower.split())
|
2810
|
+
|
1658
2811
|
# Check for SSH host key verification failures first
|
1659
2812
|
if (
|
1660
2813
|
"host key verification failed" in combined_lower
|
@@ -1671,7 +2824,10 @@ class Orchestrator:
|
|
1671
2824
|
"'ssh-keyscan -p 29418 <gerrit-host>' "
|
1672
2825
|
"to get the current host keys."
|
1673
2826
|
)
|
1674
|
-
elif
|
2827
|
+
elif (
|
2828
|
+
"authenticity of host" in combined_lower
|
2829
|
+
and "can't be established" in combined_lower
|
2830
|
+
):
|
1675
2831
|
return (
|
1676
2832
|
"SSH host key unknown. The GERRIT_KNOWN_HOSTS value does not "
|
1677
2833
|
"contain the host key for the Gerrit server. "
|
@@ -1681,18 +2837,37 @@ class Orchestrator:
|
|
1681
2837
|
"'ssh-keyscan -p 29418 <gerrit-host>' to get the host keys."
|
1682
2838
|
)
|
1683
2839
|
# Check for specific SSH key issues before general permission denied
|
1684
|
-
elif
|
1685
|
-
|
1686
|
-
|
1687
|
-
|
2840
|
+
elif (
|
2841
|
+
"key_load_public" in combined_lower
|
2842
|
+
and "invalid format" in combined_lower
|
2843
|
+
):
|
2844
|
+
return (
|
2845
|
+
"SSH key format is invalid. Check that the SSH private key "
|
2846
|
+
"is properly formatted."
|
2847
|
+
)
|
2848
|
+
elif "no matching host key type found" in normalized_output:
|
2849
|
+
return (
|
2850
|
+
"SSH key type not supported by server. The server may not "
|
2851
|
+
"accept this SSH key algorithm."
|
2852
|
+
)
|
1688
2853
|
elif "authentication failed" in combined_lower:
|
1689
|
-
return
|
2854
|
+
return (
|
2855
|
+
"SSH authentication failed - check SSH key, username, and "
|
2856
|
+
"server configuration"
|
2857
|
+
)
|
1690
2858
|
# Check for connection timeout/refused before "could not read" check
|
1691
|
-
elif
|
1692
|
-
|
2859
|
+
elif (
|
2860
|
+
"connection timed out" in combined_lower
|
2861
|
+
or "connection refused" in combined_lower
|
2862
|
+
):
|
2863
|
+
return (
|
2864
|
+
"Connection failed - check network connectivity and Gerrit "
|
2865
|
+
"server availability"
|
2866
|
+
)
|
1693
2867
|
# Check for specific SSH publickey-only authentication failures
|
1694
2868
|
elif "permission denied (publickey)" in combined_lower and not any(
|
1695
|
-
auth_method in combined_lower
|
2869
|
+
auth_method in combined_lower
|
2870
|
+
for auth_method in ["gssapi", "password", "keyboard"]
|
1696
2871
|
):
|
1697
2872
|
return (
|
1698
2873
|
"SSH public key authentication failed. The SSH key may be "
|
@@ -1702,14 +2877,33 @@ class Orchestrator:
|
|
1702
2877
|
elif "permission denied" in combined_lower:
|
1703
2878
|
return "SSH permission denied - check SSH key and user permissions"
|
1704
2879
|
elif "could not read from remote repository" in combined_lower:
|
1705
|
-
return
|
2880
|
+
return (
|
2881
|
+
"Could not read from remote repository - check SSH "
|
2882
|
+
"authentication and repository access permissions"
|
2883
|
+
)
|
1706
2884
|
# Check for Gerrit-specific issues
|
1707
2885
|
elif "missing issue-id" in combined_lower:
|
1708
2886
|
return "Missing Issue-ID in commit message."
|
1709
2887
|
elif "commit not associated to any issue" in combined_lower:
|
1710
2888
|
return "Commit not associated to any issue."
|
1711
|
-
elif
|
2889
|
+
elif (
|
2890
|
+
"remote rejected" in combined_lower
|
2891
|
+
and "refs/for/" in combined_lower
|
2892
|
+
):
|
1712
2893
|
# Extract specific rejection reason from output
|
2894
|
+
# Handle multiline rejection messages by looking in normalized
|
2895
|
+
# output
|
2896
|
+
import re
|
2897
|
+
|
2898
|
+
# Look for the rejection pattern in the normalized output
|
2899
|
+
rejection_match = re.search(
|
2900
|
+
r"!\s*\[remote rejected\].*?\((.*?)\)", normalized_output
|
2901
|
+
)
|
2902
|
+
if rejection_match:
|
2903
|
+
reason = rejection_match.group(1).strip()
|
2904
|
+
return f"Gerrit rejected the push: {reason}"
|
2905
|
+
|
2906
|
+
# Fallback: look line by line
|
1713
2907
|
lines = combined_output.split("\n")
|
1714
2908
|
for line in lines:
|
1715
2909
|
if "! [remote rejected]" in line:
|
@@ -1730,35 +2924,22 @@ class Orchestrator:
|
|
1730
2924
|
change_ids: Sequence[str],
|
1731
2925
|
) -> SubmissionResult:
|
1732
2926
|
"""Query Gerrit for change URL/number and patchset sha via REST."""
|
1733
|
-
log.
|
2927
|
+
log.debug("Querying Gerrit for submitted change(s) via REST")
|
1734
2928
|
|
1735
|
-
# pygerrit2 netrc filter is already applied in execute() unless
|
2929
|
+
# pygerrit2 netrc filter is already applied in execute() unless
|
2930
|
+
# verbose mode
|
1736
2931
|
|
1737
2932
|
# Create centralized URL builder (auto-discovers base path)
|
1738
2933
|
url_builder = create_gerrit_url_builder(gerrit.host)
|
1739
2934
|
|
1740
2935
|
# Get authentication credentials
|
1741
|
-
http_user =
|
2936
|
+
http_user = (
|
2937
|
+
os.getenv("GERRIT_HTTP_USER", "").strip()
|
2938
|
+
or os.getenv("GERRIT_SSH_USER_G2G", "").strip()
|
2939
|
+
)
|
1742
2940
|
http_pass = os.getenv("GERRIT_HTTP_PASSWORD", "").strip()
|
1743
2941
|
|
1744
|
-
#
|
1745
|
-
if GerritRestAPI is None:
|
1746
|
-
raise OrchestratorError(_MSG_PYGERRIT2_REQUIRED_REST)
|
1747
|
-
|
1748
|
-
def _create_rest_client(base_url: str) -> Any:
|
1749
|
-
"""Helper to create REST client with optional auth."""
|
1750
|
-
if http_user and http_pass:
|
1751
|
-
if HTTPBasicAuth is None:
|
1752
|
-
raise OrchestratorError(_MSG_PYGERRIT2_REQUIRED_AUTH)
|
1753
|
-
if GerritRestAPI is None:
|
1754
|
-
raise OrchestratorError(_MSG_PYGERRIT2_REQUIRED_REST)
|
1755
|
-
return GerritRestAPI(url=base_url, auth=HTTPBasicAuth(http_user, http_pass))
|
1756
|
-
else:
|
1757
|
-
if GerritRestAPI is None:
|
1758
|
-
raise OrchestratorError(_MSG_PYGERRIT2_REQUIRED_REST)
|
1759
|
-
return GerritRestAPI(url=base_url)
|
1760
|
-
|
1761
|
-
# Try API URLs in order of preference (client creation happens in retry loop)
|
2942
|
+
# Query changes using centralized REST client
|
1762
2943
|
urls: list[str] = []
|
1763
2944
|
nums: list[str] = []
|
1764
2945
|
shas: list[str] = []
|
@@ -1785,7 +2966,9 @@ class Orchestrator:
|
|
1785
2966
|
log.debug("Gerrit API base URL (discovered): %s", api_base_url)
|
1786
2967
|
changes = client.get(path)
|
1787
2968
|
except Exception as exc:
|
1788
|
-
log.warning(
|
2969
|
+
log.warning(
|
2970
|
+
"Failed to query change via REST for %s: %s", cid, exc
|
2971
|
+
)
|
1789
2972
|
continue
|
1790
2973
|
if not changes:
|
1791
2974
|
continue
|
@@ -1799,13 +2982,17 @@ class Orchestrator:
|
|
1799
2982
|
continue
|
1800
2983
|
# Construct a stable web URL for the change
|
1801
2984
|
if num:
|
1802
|
-
change_url = url_builder.change_url(
|
2985
|
+
change_url = url_builder.change_url(
|
2986
|
+
repo.project_gerrit, int(num)
|
2987
|
+
)
|
1803
2988
|
urls.append(change_url)
|
1804
2989
|
nums.append(num)
|
1805
2990
|
if current_rev:
|
1806
2991
|
shas.append(current_rev)
|
1807
2992
|
|
1808
|
-
return SubmissionResult(
|
2993
|
+
return SubmissionResult(
|
2994
|
+
change_urls=urls, change_numbers=nums, commit_shas=shas
|
2995
|
+
)
|
1809
2996
|
|
1810
2997
|
def _setup_git_workspace(self, inputs: Inputs, gh: GitHubContext) -> None:
|
1811
2998
|
"""Initialize and set up git workspace for PR processing."""
|
@@ -1813,7 +3000,9 @@ class Orchestrator:
|
|
1813
3000
|
|
1814
3001
|
# Try modern git init with explicit branch first
|
1815
3002
|
try:
|
1816
|
-
run_cmd(
|
3003
|
+
run_cmd(
|
3004
|
+
["git", "init", "--initial-branch=master"], cwd=self.workspace
|
3005
|
+
)
|
1817
3006
|
except Exception:
|
1818
3007
|
# Fallback for older git versions (hint filtered at logging level)
|
1819
3008
|
run_cmd(["git", "init"], cwd=self.workspace)
|
@@ -1830,7 +3019,10 @@ class Orchestrator:
|
|
1830
3019
|
|
1831
3020
|
# Fetch PR head
|
1832
3021
|
if gh.pr_number:
|
1833
|
-
pr_ref =
|
3022
|
+
pr_ref = (
|
3023
|
+
f"refs/pull/{gh.pr_number}/head:refs/remotes/origin/pr/"
|
3024
|
+
f"{gh.pr_number}/head"
|
3025
|
+
)
|
1834
3026
|
run_cmd(
|
1835
3027
|
[
|
1836
3028
|
"git",
|
@@ -1866,10 +3058,14 @@ class Orchestrator:
|
|
1866
3058
|
def _raise_orch(msg: str) -> None:
|
1867
3059
|
raise OrchestratorError(msg) # noqa: TRY301
|
1868
3060
|
|
1869
|
-
_MSG_HOOK_SIZE_BOUNDS =
|
3061
|
+
_MSG_HOOK_SIZE_BOUNDS = (
|
3062
|
+
"commit-msg hook size outside expected bounds"
|
3063
|
+
)
|
1870
3064
|
_MSG_HOOK_READ_FAILED = "failed reading commit-msg hook"
|
1871
3065
|
_MSG_HOOK_NO_SHEBANG = "commit-msg hook missing shebang"
|
1872
|
-
_MSG_HOOK_BAD_CONTENT =
|
3066
|
+
_MSG_HOOK_BAD_CONTENT = (
|
3067
|
+
"commit-msg hook content lacks expected markers"
|
3068
|
+
)
|
1873
3069
|
|
1874
3070
|
# Use centralized curl download with retry/logging/metrics
|
1875
3071
|
return_code, status_code = curl_download(
|
@@ -1882,7 +3078,8 @@ class Orchestrator:
|
|
1882
3078
|
|
1883
3079
|
size = hook_path.stat().st_size
|
1884
3080
|
log.debug(
|
1885
|
-
"curl fetch of commit-msg: url=%s http_status=%s size=%dB
|
3081
|
+
"curl fetch of commit-msg: url=%s http_status=%s size=%dB "
|
3082
|
+
"rc=%s",
|
1886
3083
|
hook_url,
|
1887
3084
|
status_code,
|
1888
3085
|
size,
|
@@ -1904,33 +3101,50 @@ class Orchestrator:
|
|
1904
3101
|
if not text_head.startswith("#!"):
|
1905
3102
|
_raise_orch(_MSG_HOOK_NO_SHEBANG)
|
1906
3103
|
# Look for recognizable strings
|
1907
|
-
if not any(
|
3104
|
+
if not any(
|
3105
|
+
m in text_head
|
3106
|
+
for m in ("Change-Id", "Gerrit Code Review", "add_change_id")
|
3107
|
+
):
|
1908
3108
|
_raise_orch(_MSG_HOOK_BAD_CONTENT)
|
1909
3109
|
|
1910
3110
|
# Make hook executable (single chmod)
|
1911
3111
|
hook_path.chmod(hook_path.stat().st_mode | stat.S_IEXEC)
|
1912
|
-
log.debug(
|
3112
|
+
log.debug(
|
3113
|
+
"Successfully installed commit-msg hook from %s", hook_url
|
3114
|
+
)
|
1913
3115
|
|
1914
3116
|
except Exception as exc:
|
1915
|
-
log.warning(
|
3117
|
+
log.warning(
|
3118
|
+
"Failed to install commit-msg hook via centralized curl: %s",
|
3119
|
+
exc,
|
3120
|
+
)
|
1916
3121
|
msg = f"Could not install commit-msg hook: {exc}"
|
1917
3122
|
raise OrchestratorError(msg) from exc
|
1918
3123
|
|
1919
|
-
def _ensure_change_id_present(
|
3124
|
+
def _ensure_change_id_present(
|
3125
|
+
self, gerrit: GerritInfo, author: str
|
3126
|
+
) -> list[str]:
|
1920
3127
|
"""Ensure the last commit has a Change-Id.
|
1921
3128
|
|
1922
3129
|
Installs the commit-msg hook and amends the commit if needed.
|
1923
3130
|
"""
|
1924
|
-
trailers = git_last_commit_trailers(
|
3131
|
+
trailers = git_last_commit_trailers(
|
3132
|
+
keys=["Change-Id"], cwd=self.workspace
|
3133
|
+
)
|
1925
3134
|
existing_change_ids = trailers.get("Change-Id", [])
|
1926
3135
|
|
1927
3136
|
if existing_change_ids:
|
1928
|
-
log.debug(
|
3137
|
+
log.debug(
|
3138
|
+
"Found existing Change-Id(s) in footer: %s", existing_change_ids
|
3139
|
+
)
|
1929
3140
|
# Clean up any duplicate Change-IDs in the message body
|
1930
3141
|
self._clean_change_ids_from_body(author)
|
1931
3142
|
return [c for c in existing_change_ids if c]
|
1932
3143
|
|
1933
|
-
log.debug(
|
3144
|
+
log.debug(
|
3145
|
+
"No Change-Id found; attempting to install commit-msg hook and "
|
3146
|
+
"amend commit"
|
3147
|
+
)
|
1934
3148
|
try:
|
1935
3149
|
self._install_commit_msg_hook(gerrit)
|
1936
3150
|
git_commit_amend(
|
@@ -1941,7 +3155,8 @@ class Orchestrator:
|
|
1941
3155
|
)
|
1942
3156
|
except Exception as exc:
|
1943
3157
|
log.warning(
|
1944
|
-
"Commit-msg hook installation failed, falling back to direct
|
3158
|
+
"Commit-msg hook installation failed, falling back to direct "
|
3159
|
+
"Change-Id injection: %s",
|
1945
3160
|
exc,
|
1946
3161
|
)
|
1947
3162
|
# Fallback: generate a Change-Id and append to the commit
|
@@ -1955,11 +3170,15 @@ class Orchestrator:
|
|
1955
3170
|
seed = f"{current_msg}\n{time.time()}"
|
1956
3171
|
import hashlib as _hashlib # local alias to satisfy linters
|
1957
3172
|
|
1958
|
-
change_id =
|
3173
|
+
change_id = (
|
3174
|
+
"I" + _hashlib.sha256(seed.encode("utf-8")).hexdigest()[:40]
|
3175
|
+
)
|
1959
3176
|
|
1960
3177
|
# Clean message and ensure proper footer placement
|
1961
3178
|
cleaned_msg = self._clean_commit_message_for_change_id(current_msg)
|
1962
|
-
new_msg =
|
3179
|
+
new_msg = (
|
3180
|
+
cleaned_msg.rstrip() + "\n\n" + f"Change-Id: {change_id}\n"
|
3181
|
+
)
|
1963
3182
|
git_commit_amend(
|
1964
3183
|
no_edit=False,
|
1965
3184
|
signoff=True,
|
@@ -1973,11 +3192,14 @@ class Orchestrator:
|
|
1973
3192
|
cwd=self.workspace,
|
1974
3193
|
).stdout.strip()
|
1975
3194
|
log.debug("Commit message after amend:\n%s", actual_msg)
|
1976
|
-
trailers = git_last_commit_trailers(
|
3195
|
+
trailers = git_last_commit_trailers(
|
3196
|
+
keys=["Change-Id"], cwd=self.workspace
|
3197
|
+
)
|
1977
3198
|
return [c for c in trailers.get("Change-Id", []) if c]
|
1978
3199
|
|
1979
3200
|
def _clean_change_ids_from_body(self, author: str) -> None:
|
1980
|
-
"""Remove any Change-Id lines from the commit message body, keeping
|
3201
|
+
"""Remove any Change-Id lines from the commit message body, keeping
|
3202
|
+
only footer trailers."""
|
1981
3203
|
current_msg = run_cmd(
|
1982
3204
|
["git", "show", "-s", "--pretty=format:%B", "HEAD"],
|
1983
3205
|
cwd=self.workspace,
|
@@ -1996,16 +3218,22 @@ class Orchestrator:
|
|
1996
3218
|
)
|
1997
3219
|
|
1998
3220
|
def _clean_commit_message_for_change_id(self, message: str) -> str:
|
1999
|
-
"""Remove Change-Id lines from message body while preserving footer
|
3221
|
+
"""Remove Change-Id lines from message body while preserving footer
|
3222
|
+
trailers."""
|
2000
3223
|
lines = message.splitlines()
|
2001
3224
|
|
2002
3225
|
# Parse proper trailers using the fixed trailer parser
|
2003
3226
|
trailers = _parse_trailers(message)
|
2004
3227
|
change_id_trailers = trailers.get("Change-Id", [])
|
2005
3228
|
signed_off_trailers = trailers.get("Signed-off-by", [])
|
2006
|
-
other_trailers = {
|
2007
|
-
|
2008
|
-
|
3229
|
+
other_trailers = {
|
3230
|
+
k: v
|
3231
|
+
for k, v in trailers.items()
|
3232
|
+
if k not in ["Change-Id", "Signed-off-by"]
|
3233
|
+
}
|
3234
|
+
|
3235
|
+
# Find trailer section by working backwards to find continuous
|
3236
|
+
# trailer block
|
2009
3237
|
trailer_start = len(lines)
|
2010
3238
|
|
2011
3239
|
# Work backwards to find where trailers start
|
@@ -2024,12 +3252,15 @@ class Orchestrator:
|
|
2024
3252
|
key, val = line.split(":", 1)
|
2025
3253
|
k = key.strip()
|
2026
3254
|
v = val.strip()
|
2027
|
-
if not (
|
3255
|
+
if not (
|
3256
|
+
k and v and not k.startswith(" ") and not k.startswith("\t")
|
3257
|
+
):
|
2028
3258
|
# Invalid trailer format - trailers start after this
|
2029
3259
|
trailer_start = i + 1
|
2030
3260
|
break
|
2031
3261
|
|
2032
|
-
# Process body lines (before trailers) and remove any Change-Id
|
3262
|
+
# Process body lines (before trailers) and remove any Change-Id
|
3263
|
+
# references
|
2033
3264
|
body_lines = []
|
2034
3265
|
for i in range(trailer_start):
|
2035
3266
|
line = lines[i]
|
@@ -2037,19 +3268,28 @@ class Orchestrator:
|
|
2037
3268
|
if "Change-Id:" in line:
|
2038
3269
|
# If line starts with Change-Id:, skip it entirely
|
2039
3270
|
if line.strip().startswith("Change-Id:"):
|
2040
|
-
log.debug(
|
3271
|
+
log.debug(
|
3272
|
+
"Removing Change-Id line from body: %s", line.strip()
|
3273
|
+
)
|
2041
3274
|
continue
|
2042
3275
|
else:
|
2043
|
-
# If Change-Id is mentioned within the line, remove that
|
3276
|
+
# If Change-Id is mentioned within the line, remove that
|
3277
|
+
# part
|
2044
3278
|
original_line = line
|
2045
3279
|
# Remove Change-Id: followed by the ID value
|
2046
3280
|
|
2047
|
-
# Pattern to match "Change-Id: <value>" where value can
|
3281
|
+
# Pattern to match "Change-Id: <value>" where value can
|
3282
|
+
# contain word chars, hyphens, etc.
|
2048
3283
|
line = re.sub(r"Change-Id:\s*[A-Za-z0-9._-]+\b", "", line)
|
2049
3284
|
# Clean up extra whitespace
|
2050
3285
|
line = re.sub(r"\s+", " ", line).strip()
|
2051
3286
|
if line != original_line:
|
2052
|
-
log.debug(
|
3287
|
+
log.debug(
|
3288
|
+
"Cleaned Change-Id reference from body line: "
|
3289
|
+
"%s -> %s",
|
3290
|
+
original_line.strip(),
|
3291
|
+
line,
|
3292
|
+
)
|
2053
3293
|
body_lines.append(line)
|
2054
3294
|
|
2055
3295
|
# Remove trailing empty lines from body
|
@@ -2099,17 +3339,66 @@ class Orchestrator:
|
|
2099
3339
|
"1",
|
2100
3340
|
"yes",
|
2101
3341
|
):
|
2102
|
-
log.info(
|
3342
|
+
log.info(
|
3343
|
+
"Skipping back-reference comments "
|
3344
|
+
"(G2G_SKIP_GERRIT_COMMENTS=true)"
|
3345
|
+
)
|
2103
3346
|
return
|
2104
3347
|
|
2105
|
-
log.
|
3348
|
+
log.debug("Adding back-reference comment in Gerrit")
|
2106
3349
|
user = os.getenv("GERRIT_SSH_USER_G2G", "")
|
2107
3350
|
server = gerrit.host
|
2108
3351
|
pr_url = f"{gh.server_url}/{gh.repository}/pull/{gh.pr_number}"
|
2109
|
-
run_url =
|
3352
|
+
run_url = (
|
3353
|
+
f"{gh.server_url}/{gh.repository}/actions/runs/{gh.run_id}"
|
3354
|
+
if gh.run_id
|
3355
|
+
else "N/A"
|
3356
|
+
)
|
2110
3357
|
message = f"GHPR: {pr_url} | Action-Run: {run_url}"
|
2111
|
-
log.
|
3358
|
+
log.debug("Adding back-reference comment: %s", message)
|
3359
|
+
# Idempotence override: allow forcing duplicate comments (debug/testing)
|
3360
|
+
force_dup = os.getenv("G2G_FORCE_BACKREF_DUPLICATE", "").lower() in (
|
3361
|
+
"1",
|
3362
|
+
"true",
|
3363
|
+
"yes",
|
3364
|
+
)
|
3365
|
+
|
3366
|
+
def _has_existing_backref(commit_sha: str) -> bool:
|
3367
|
+
if force_dup:
|
3368
|
+
return False
|
3369
|
+
try:
|
3370
|
+
from .gerrit_rest import build_client_for_host
|
3371
|
+
|
3372
|
+
client = build_client_for_host(
|
3373
|
+
gerrit.host, timeout=8.0, max_attempts=3
|
3374
|
+
)
|
3375
|
+
# Query change messages for this commit
|
3376
|
+
path = f"/changes/?q=commit:{commit_sha}&o=MESSAGES"
|
3377
|
+
data = client.get(path)
|
3378
|
+
if not isinstance(data, list):
|
3379
|
+
return False
|
3380
|
+
for entry in data:
|
3381
|
+
msgs = entry.get("messages") or []
|
3382
|
+
for msg in msgs:
|
3383
|
+
txt = (msg.get("message") or "").strip()
|
3384
|
+
if "GHPR:" in txt and pr_url in txt:
|
3385
|
+
log.debug(
|
3386
|
+
"Skipping back-reference for %s "
|
3387
|
+
"(already present)",
|
3388
|
+
commit_sha,
|
3389
|
+
)
|
3390
|
+
return True
|
3391
|
+
except Exception as exc:
|
3392
|
+
log.debug(
|
3393
|
+
"Backref idempotence check failed for %s: %s",
|
3394
|
+
commit_sha,
|
3395
|
+
exc,
|
3396
|
+
)
|
3397
|
+
return False
|
3398
|
+
|
2112
3399
|
for csha in commit_shas:
|
3400
|
+
if _has_existing_backref(csha):
|
3401
|
+
continue
|
2113
3402
|
if not csha:
|
2114
3403
|
continue
|
2115
3404
|
try:
|
@@ -2151,7 +3440,11 @@ class Orchestrator:
|
|
2151
3440
|
f"{shlex.quote(csha)}"
|
2152
3441
|
),
|
2153
3442
|
]
|
2154
|
-
elif
|
3443
|
+
elif (
|
3444
|
+
self._use_ssh_agent
|
3445
|
+
and self._ssh_agent_manager
|
3446
|
+
and self._ssh_agent_manager.known_hosts_path
|
3447
|
+
):
|
2155
3448
|
# SSH agent authentication with known_hosts
|
2156
3449
|
ssh_cmd = [
|
2157
3450
|
"ssh",
|
@@ -2226,14 +3519,15 @@ class Orchestrator:
|
|
2226
3519
|
cwd=self.workspace,
|
2227
3520
|
env=self._ssh_env(),
|
2228
3521
|
)
|
2229
|
-
log.
|
3522
|
+
log.debug(
|
2230
3523
|
"Successfully added back-reference comment for %s: %s",
|
2231
3524
|
csha,
|
2232
3525
|
message,
|
2233
3526
|
)
|
2234
3527
|
except CommandError as exc:
|
2235
3528
|
log.warning(
|
2236
|
-
"Failed to add back-reference comment for %s
|
3529
|
+
"Failed to add back-reference comment for %s "
|
3530
|
+
"(non-fatal): %s",
|
2237
3531
|
csha,
|
2238
3532
|
exc,
|
2239
3533
|
)
|
@@ -2249,11 +3543,14 @@ class Orchestrator:
|
|
2249
3543
|
# Continue processing - this is not a fatal error
|
2250
3544
|
except Exception as exc:
|
2251
3545
|
log.warning(
|
2252
|
-
"Failed to add back-reference comment for %s
|
3546
|
+
"Failed to add back-reference comment for %s "
|
3547
|
+
"(non-fatal): %s",
|
2253
3548
|
csha,
|
2254
3549
|
exc,
|
2255
3550
|
)
|
2256
|
-
log.debug(
|
3551
|
+
log.debug(
|
3552
|
+
"Back-reference comment failure details:", exc_info=True
|
3553
|
+
)
|
2257
3554
|
# Continue processing - this is not a fatal error
|
2258
3555
|
|
2259
3556
|
def _comment_on_pull_request(
|
@@ -2265,9 +3562,12 @@ class Orchestrator:
|
|
2265
3562
|
"""Post a comment on the PR with the Gerrit change URL(s)."""
|
2266
3563
|
# Respect CI_TESTING: do not attempt to update the source/origin PR
|
2267
3564
|
if os.getenv("CI_TESTING", "").strip().lower() in ("1", "true", "yes"):
|
2268
|
-
log.debug(
|
3565
|
+
log.debug(
|
3566
|
+
"Source/origin pull request will NOT be updated with Gerrit "
|
3567
|
+
"change when CI_TESTING set true"
|
3568
|
+
)
|
2269
3569
|
return
|
2270
|
-
log.
|
3570
|
+
log.debug("Adding reference comment on PR #%s", gh.pr_number)
|
2271
3571
|
if not gh.pr_number:
|
2272
3572
|
return
|
2273
3573
|
urls = result.change_urls or []
|
@@ -2275,7 +3575,10 @@ class Orchestrator:
|
|
2275
3575
|
# Create centralized URL builder for organization link
|
2276
3576
|
url_builder = create_gerrit_url_builder(gerrit.host)
|
2277
3577
|
org_url = url_builder.web_url()
|
2278
|
-
text =
|
3578
|
+
text = (
|
3579
|
+
f"The pull-request PR-{gh.pr_number} is submitted to Gerrit "
|
3580
|
+
f"[{org}]({org_url})!\n\n"
|
3581
|
+
)
|
2279
3582
|
if urls:
|
2280
3583
|
text += "To follow up on the change visit:\n\n" + "\n".join(urls)
|
2281
3584
|
try:
|
@@ -2345,24 +3648,29 @@ class Orchestrator:
|
|
2345
3648
|
"""
|
2346
3649
|
import socket
|
2347
3650
|
|
2348
|
-
log.
|
3651
|
+
log.debug("Dry-run: starting preflight checks")
|
2349
3652
|
if os.getenv("G2G_DRYRUN_DISABLE_NETWORK", "").strip().lower() in (
|
2350
3653
|
"1",
|
2351
3654
|
"true",
|
2352
3655
|
"yes",
|
2353
3656
|
"on",
|
2354
3657
|
):
|
2355
|
-
log.
|
2356
|
-
|
2357
|
-
|
3658
|
+
log.debug(
|
3659
|
+
"Dry-run: network checks disabled (G2G_DRYRUN_DISABLE_NETWORK)"
|
3660
|
+
)
|
3661
|
+
log.debug(
|
3662
|
+
"Dry-run targets: Gerrit project=%s branch=%s "
|
3663
|
+
"topic_prefix=GH-%s",
|
2358
3664
|
repo.project_gerrit,
|
2359
3665
|
self._resolve_target_branch(),
|
2360
3666
|
repo.project_github,
|
2361
3667
|
)
|
2362
3668
|
if inputs.reviewers_email:
|
2363
|
-
log.
|
3669
|
+
log.debug(
|
3670
|
+
"Reviewers (from inputs/config): %s", inputs.reviewers_email
|
3671
|
+
)
|
2364
3672
|
elif os.getenv("REVIEWERS_EMAIL"):
|
2365
|
-
log.
|
3673
|
+
log.debug(
|
2366
3674
|
"Reviewers (from environment): %s",
|
2367
3675
|
os.getenv("REVIEWERS_EMAIL"),
|
2368
3676
|
)
|
@@ -2371,16 +3679,20 @@ class Orchestrator:
|
|
2371
3679
|
# DNS resolution for Gerrit host
|
2372
3680
|
try:
|
2373
3681
|
socket.getaddrinfo(gerrit.host, None)
|
2374
|
-
log.
|
3682
|
+
log.debug(
|
3683
|
+
"DNS resolution for Gerrit host '%s' succeeded", gerrit.host
|
3684
|
+
)
|
2375
3685
|
except Exception as exc:
|
2376
3686
|
msg = "DNS resolution failed"
|
2377
3687
|
raise OrchestratorError(msg) from exc
|
2378
3688
|
|
2379
3689
|
# SSH (TCP) reachability on Gerrit port
|
2380
3690
|
try:
|
2381
|
-
with socket.create_connection(
|
3691
|
+
with socket.create_connection(
|
3692
|
+
(gerrit.host, gerrit.port), timeout=5
|
3693
|
+
):
|
2382
3694
|
pass
|
2383
|
-
log.
|
3695
|
+
log.debug(
|
2384
3696
|
"SSH TCP connectivity to %s:%s verified",
|
2385
3697
|
gerrit.host,
|
2386
3698
|
gerrit.port,
|
@@ -2391,7 +3703,10 @@ class Orchestrator:
|
|
2391
3703
|
|
2392
3704
|
# Gerrit REST reachability and optional auth check
|
2393
3705
|
base_path = os.getenv("GERRIT_HTTP_BASE_PATH", "").strip().strip("/")
|
2394
|
-
http_user =
|
3706
|
+
http_user = (
|
3707
|
+
os.getenv("GERRIT_HTTP_USER", "").strip()
|
3708
|
+
or os.getenv("GERRIT_SSH_USER_G2G", "").strip()
|
3709
|
+
)
|
2395
3710
|
http_pass = os.getenv("GERRIT_HTTP_PASSWORD", "").strip()
|
2396
3711
|
self._verify_gerrit_rest(gerrit.host, base_path, http_user, http_pass)
|
2397
3712
|
|
@@ -2401,10 +3716,12 @@ class Orchestrator:
|
|
2401
3716
|
repo_obj = get_repo_from_env(client)
|
2402
3717
|
if gh.pr_number is not None:
|
2403
3718
|
pr_obj = get_pull(repo_obj, gh.pr_number)
|
2404
|
-
log.
|
3719
|
+
log.debug(
|
3720
|
+
"GitHub PR #%s metadata loaded successfully", gh.pr_number
|
3721
|
+
)
|
2405
3722
|
try:
|
2406
3723
|
title, _ = get_pr_title_body(pr_obj)
|
2407
|
-
log.
|
3724
|
+
log.debug("GitHub PR title: %s", title)
|
2408
3725
|
except Exception as exc:
|
2409
3726
|
log.debug("Failed to read PR title: %s", exc)
|
2410
3727
|
else:
|
@@ -2420,16 +3737,20 @@ class Orchestrator:
|
|
2420
3737
|
raise OrchestratorError(msg) from exc
|
2421
3738
|
|
2422
3739
|
# Log effective targets
|
2423
|
-
log.
|
3740
|
+
log.debug(
|
2424
3741
|
"Dry-run targets: Gerrit project=%s branch=%s topic_prefix=GH-%s",
|
2425
3742
|
repo.project_gerrit,
|
2426
3743
|
self._resolve_target_branch(),
|
2427
3744
|
repo.project_github,
|
2428
3745
|
)
|
2429
3746
|
if inputs.reviewers_email:
|
2430
|
-
log.
|
3747
|
+
log.debug(
|
3748
|
+
"Reviewers (from inputs/config): %s", inputs.reviewers_email
|
3749
|
+
)
|
2431
3750
|
elif os.getenv("REVIEWERS_EMAIL"):
|
2432
|
-
log.info(
|
3751
|
+
log.info(
|
3752
|
+
"Reviewers (from environment): %s", os.getenv("REVIEWERS_EMAIL")
|
3753
|
+
)
|
2433
3754
|
|
2434
3755
|
def _verify_gerrit_rest(
|
2435
3756
|
self,
|
@@ -2440,41 +3761,36 @@ class Orchestrator:
|
|
2440
3761
|
) -> None:
|
2441
3762
|
"""Probe Gerrit REST endpoint with optional auth.
|
2442
3763
|
|
2443
|
-
Uses the centralized
|
2444
|
-
|
3764
|
+
Uses the centralized gerrit_rest client to ensure proper base path
|
3765
|
+
handling and consistent API interactions.
|
2445
3766
|
"""
|
3767
|
+
from .gerrit_rest import build_client_for_host
|
2446
3768
|
|
2447
|
-
|
2448
|
-
|
2449
|
-
|
2450
|
-
|
2451
|
-
|
2452
|
-
|
2453
|
-
|
2454
|
-
|
2455
|
-
|
2456
|
-
raise OrchestratorError(_MSG_PYGERRIT2_MISSING)
|
2457
|
-
return GerritRestAPI(url=url)
|
3769
|
+
try:
|
3770
|
+
# Use centralized client builder that handles base path correctly
|
3771
|
+
client = build_client_for_host(
|
3772
|
+
host,
|
3773
|
+
timeout=8.0,
|
3774
|
+
max_attempts=3,
|
3775
|
+
http_user=http_user,
|
3776
|
+
http_password=http_pass,
|
3777
|
+
)
|
2458
3778
|
|
2459
|
-
|
2460
|
-
rest: Any = _build_client(url)
|
3779
|
+
# Test connectivity with appropriate endpoint
|
2461
3780
|
if http_user and http_pass:
|
2462
|
-
_ =
|
2463
|
-
log.
|
3781
|
+
_ = client.get("/accounts/self")
|
3782
|
+
log.debug(
|
2464
3783
|
"Gerrit REST authenticated access verified for user '%s'",
|
2465
3784
|
http_user,
|
2466
3785
|
)
|
2467
3786
|
else:
|
2468
|
-
_ =
|
2469
|
-
log.
|
3787
|
+
_ = client.get("/dashboard/self")
|
3788
|
+
log.debug("Gerrit REST endpoint reachable (unauthenticated)")
|
2470
3789
|
|
2471
|
-
# Create centralized URL builder for REST probing
|
2472
|
-
url_builder = create_gerrit_url_builder(host, base_path)
|
2473
|
-
api_url = url_builder.api_url()
|
2474
|
-
|
2475
|
-
try:
|
2476
|
-
_probe(api_url)
|
2477
3790
|
except Exception as exc:
|
3791
|
+
# Use centralized URL builder for consistent error reporting
|
3792
|
+
url_builder = create_gerrit_url_builder(host, base_path)
|
3793
|
+
api_url = url_builder.api_url()
|
2478
3794
|
log.warning("Gerrit REST probe failed for %s: %s", api_url, exc)
|
2479
3795
|
|
2480
3796
|
# ---------------
|