github2gerrit 0.1.6__py3-none-any.whl → 0.1.8__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 CHANGED
@@ -3,11 +3,16 @@
3
3
 
4
4
  from __future__ import annotations
5
5
 
6
+ import io
6
7
  import json
7
8
  import logging
8
9
  import os
10
+ import shutil
9
11
  import tempfile
12
+ import zipfile
10
13
  from collections.abc import Callable
14
+ from concurrent.futures import ThreadPoolExecutor
15
+ from concurrent.futures import as_completed
11
16
  from pathlib import Path
12
17
  from typing import TYPE_CHECKING
13
18
  from typing import Any
@@ -15,6 +20,8 @@ from typing import Protocol
15
20
  from typing import TypeVar
16
21
  from typing import cast
17
22
  from urllib.parse import urlparse
23
+ from urllib.request import Request
24
+ from urllib.request import urlopen
18
25
 
19
26
  import click
20
27
  import typer
@@ -35,19 +42,13 @@ from .github_api import iter_open_pulls
35
42
  from .gitutils import run_cmd
36
43
  from .models import GitHubContext
37
44
  from .models import Inputs
38
-
39
-
40
- def _is_verbose_mode() -> bool:
41
- """Check if verbose mode is enabled via environment variable."""
42
- return os.getenv("G2G_VERBOSE", "").lower() in ("true", "1", "yes")
43
-
44
-
45
- def _log_exception_conditionally(logger: logging.Logger, message: str, *args: Any) -> None:
46
- """Log exception with traceback only if verbose mode is enabled."""
47
- if _is_verbose_mode():
48
- logger.exception(message, *args)
49
- else:
50
- logger.error(message, *args)
45
+ from .ssh_common import build_git_ssh_command
46
+ from .ssh_common import build_non_interactive_ssh_env
47
+ from .utils import append_github_output
48
+ from .utils import env_bool
49
+ from .utils import env_str
50
+ from .utils import log_exception_conditionally
51
+ from .utils import parse_bool_env
51
52
 
52
53
 
53
54
  class ConfigurationError(Exception):
@@ -72,7 +73,7 @@ def _parse_github_target(url: str) -> tuple[str | None, str | None, int | None]:
72
73
  except Exception:
73
74
  return None, None, None
74
75
 
75
- allow_ghe = _env_bool("ALLOW_GHE_URLS", False)
76
+ allow_ghe = env_bool("ALLOW_GHE_URLS", False)
76
77
  bad_hosts = {
77
78
  "gitlab.com",
78
79
  "www.gitlab.com",
@@ -164,36 +165,38 @@ def main(
164
165
  submit_single_commits: bool = typer.Option(
165
166
  False,
166
167
  "--submit-single-commits",
168
+ envvar="SUBMIT_SINGLE_COMMITS",
167
169
  help="Submit one commit at a time to the Gerrit repository.",
168
170
  ),
169
171
  use_pr_as_commit: bool = typer.Option(
170
172
  False,
171
173
  "--use-pr-as-commit",
174
+ envvar="USE_PR_AS_COMMIT",
172
175
  help="Use PR title and body as the commit message.",
173
176
  ),
174
177
  fetch_depth: int = typer.Option(
175
178
  10,
176
179
  "--fetch-depth",
177
180
  envvar="FETCH_DEPTH",
178
- help="Fetch-depth for the clone.",
181
+ help="Fetch depth for checkout.",
179
182
  ),
180
183
  gerrit_known_hosts: str = typer.Option(
181
184
  "",
182
185
  "--gerrit-known-hosts",
183
186
  envvar="GERRIT_KNOWN_HOSTS",
184
- help="Known hosts entries for Gerrit SSH.",
187
+ help="Known hosts entries for Gerrit SSH (single or multi-line).",
185
188
  ),
186
189
  gerrit_ssh_privkey_g2g: str = typer.Option(
187
190
  "",
188
191
  "--gerrit-ssh-privkey-g2g",
189
192
  envvar="GERRIT_SSH_PRIVKEY_G2G",
190
- help="SSH private key for Gerrit (string content).",
193
+ help="SSH private key content used to authenticate to Gerrit.",
191
194
  ),
192
195
  gerrit_ssh_user_g2g: str = typer.Option(
193
196
  "",
194
197
  "--gerrit-ssh-user-g2g",
195
198
  envvar="GERRIT_SSH_USER_G2G",
196
- help="Gerrit SSH user.",
199
+ help="Gerrit SSH username (e.g. automation bot account).",
197
200
  ),
198
201
  gerrit_ssh_user_g2g_email: str = typer.Option(
199
202
  "",
@@ -261,6 +264,12 @@ def main(
261
264
  envvar="ALLOW_DUPLICATES",
262
265
  help="Allow submitting duplicate changes without error.",
263
266
  ),
267
+ ci_testing: bool = typer.Option(
268
+ False,
269
+ "--ci-testing/--no-ci-testing",
270
+ envvar="CI_TESTING",
271
+ help="Enable CI testing mode (overrides .gitreview, handles unrelated repos).",
272
+ ),
264
273
  duplicates: str = typer.Option(
265
274
  "open",
266
275
  "--duplicates",
@@ -269,36 +278,56 @@ def main(
269
278
  'Gerrit statuses for duplicate detection (comma-separated). E.g. "open,merged,abandoned". Default: "open".'
270
279
  ),
271
280
  ),
281
+ normalise_commit: bool = typer.Option(
282
+ True,
283
+ "--normalise-commit/--no-normalise-commit",
284
+ envvar="NORMALISE_COMMIT",
285
+ help="Normalize commit messages to conventional commit format.",
286
+ ),
272
287
  verbose: bool = typer.Option(
273
288
  False,
274
289
  "--verbose",
275
290
  "-v",
276
291
  envvar="G2G_VERBOSE",
277
- help="Enable verbose debug logging.",
292
+ help="Verbose output (sets loglevel to DEBUG).",
278
293
  ),
279
294
  ) -> None:
280
295
  """
281
296
  Tool to convert GitHub pull requests into Gerrit changes
282
297
 
283
- - Providing a URL to a pull request: converts that pull request
284
- into a Gerrit change
298
+ - Providing a URL to a pull request: converts that pull request into a Gerrit change
285
299
 
286
- - Providing a URL to a GitHub repository converts all open pull
287
- requests into Gerrit changes
300
+ - Providing a URL to a GitHub repository converts all open pull requests into Gerrit changes
288
301
 
289
- - No arguments for CI/CD environment; reads parameters from
290
- environment variables
302
+ - No arguments for CI/CD environment; reads parameters from environment variables
291
303
  """
304
+ # Override boolean parameters with properly parsed environment variables
305
+ # This ensures that string "false" from GitHub Actions is handled correctly
306
+ if os.getenv("SUBMIT_SINGLE_COMMITS"):
307
+ submit_single_commits = parse_bool_env(os.getenv("SUBMIT_SINGLE_COMMITS"))
308
+
309
+ if os.getenv("USE_PR_AS_COMMIT"):
310
+ use_pr_as_commit = parse_bool_env(os.getenv("USE_PR_AS_COMMIT"))
311
+
312
+ if os.getenv("PRESERVE_GITHUB_PRS"):
313
+ preserve_github_prs = parse_bool_env(os.getenv("PRESERVE_GITHUB_PRS"))
314
+
315
+ if os.getenv("DRY_RUN"):
316
+ dry_run = parse_bool_env(os.getenv("DRY_RUN"))
317
+
318
+ if os.getenv("ALLOW_DUPLICATES"):
319
+ allow_duplicates = parse_bool_env(os.getenv("ALLOW_DUPLICATES"))
320
+
321
+ if os.getenv("CI_TESTING"):
322
+ ci_testing = parse_bool_env(os.getenv("CI_TESTING"))
292
323
  # Set up logging level based on verbose flag
293
324
  if verbose:
294
325
  os.environ["G2G_LOG_LEVEL"] = "DEBUG"
295
326
  _reconfigure_logging()
296
327
  # Normalize CLI options into environment for unified processing.
297
- # For boolean flags, only set if explicitly provided via CLI
298
- if submit_single_commits:
299
- os.environ["SUBMIT_SINGLE_COMMITS"] = "true"
300
- if use_pr_as_commit:
301
- os.environ["USE_PR_AS_COMMIT"] = "true"
328
+ # Explicitly set all boolean flags to ensure consistent behavior
329
+ os.environ["SUBMIT_SINGLE_COMMITS"] = "true" if submit_single_commits else "false"
330
+ os.environ["USE_PR_AS_COMMIT"] = "true" if use_pr_as_commit else "false"
302
331
  os.environ["FETCH_DEPTH"] = str(fetch_depth)
303
332
  if gerrit_known_hosts:
304
333
  os.environ["GERRIT_KNOWN_HOSTS"] = gerrit_known_hosts
@@ -313,10 +342,9 @@ def main(
313
342
  os.environ["ORGANIZATION"] = resolved_org
314
343
  if reviewers_email:
315
344
  os.environ["REVIEWERS_EMAIL"] = reviewers_email
316
- if preserve_github_prs:
317
- os.environ["PRESERVE_GITHUB_PRS"] = "true"
318
- if dry_run:
319
- os.environ["DRY_RUN"] = "true"
345
+ os.environ["PRESERVE_GITHUB_PRS"] = "true" if preserve_github_prs else "false"
346
+ os.environ["DRY_RUN"] = "true" if dry_run else "false"
347
+ os.environ["NORMALISE_COMMIT"] = "true" if normalise_commit else "false"
320
348
  os.environ["ALLOW_GHE_URLS"] = "true" if allow_ghe_urls else "false"
321
349
  if gerrit_server:
322
350
  os.environ["GERRIT_SERVER"] = gerrit_server
@@ -326,8 +354,8 @@ def main(
326
354
  os.environ["GERRIT_PROJECT"] = gerrit_project
327
355
  if issue_id:
328
356
  os.environ["ISSUE_ID"] = issue_id
329
- if allow_duplicates:
330
- os.environ["ALLOW_DUPLICATES"] = "true"
357
+ os.environ["ALLOW_DUPLICATES"] = "true" if allow_duplicates else "false"
358
+ os.environ["CI_TESTING"] = "true" if ci_testing else "false"
331
359
  if duplicates:
332
360
  os.environ["DUPLICATES"] = duplicates
333
361
  # URL mode handling
@@ -343,6 +371,12 @@ def main(
343
371
  else:
344
372
  os.environ["SYNC_ALL_OPEN_PRS"] = "true"
345
373
  os.environ["G2G_TARGET_URL"] = "1"
374
+ # Debug: Show environment at CLI startup
375
+ log.debug("CLI startup environment check:")
376
+ for key in ["DRY_RUN", "CI_TESTING", "GERRIT_SERVER", "GERRIT_PROJECT"]:
377
+ value = os.environ.get(key, "NOT_SET")
378
+ log.debug(" %s = %s", key, value)
379
+
346
380
  # Delegate to common processing path
347
381
  try:
348
382
  _process()
@@ -351,7 +385,7 @@ def main(
351
385
  raise
352
386
  except Exception as exc:
353
387
  log.debug("main(): _process failed: %s", exc)
354
- return
388
+ raise typer.Exit(code=1) from exc
355
389
 
356
390
 
357
391
  def _setup_logging() -> logging.Logger:
@@ -374,68 +408,59 @@ def _reconfigure_logging() -> None:
374
408
  log = _setup_logging()
375
409
 
376
410
 
377
- def _env_str(name: str, default: str = "") -> str:
378
- val = os.getenv(name)
379
- return val if val is not None else default
380
-
381
-
382
- def _env_bool(name: str, default: bool = False) -> bool:
383
- val = os.getenv(name)
384
- if val is None:
385
- return default
386
- s = val.strip().lower()
387
- return s in ("1", "true", "yes", "on")
388
-
389
-
390
411
  def _build_inputs_from_env() -> Inputs:
391
412
  return Inputs(
392
- submit_single_commits=_env_bool("SUBMIT_SINGLE_COMMITS", False),
393
- use_pr_as_commit=_env_bool("USE_PR_AS_COMMIT", False),
394
- fetch_depth=int(_env_str("FETCH_DEPTH", "10") or "10"),
395
- gerrit_known_hosts=_env_str("GERRIT_KNOWN_HOSTS"),
396
- gerrit_ssh_privkey_g2g=_env_str("GERRIT_SSH_PRIVKEY_G2G"),
397
- gerrit_ssh_user_g2g=_env_str("GERRIT_SSH_USER_G2G"),
398
- gerrit_ssh_user_g2g_email=_env_str("GERRIT_SSH_USER_G2G_EMAIL"),
399
- organization=_env_str("ORGANIZATION", _env_str("GITHUB_REPOSITORY_OWNER")),
400
- reviewers_email=_env_str("REVIEWERS_EMAIL", ""),
401
- preserve_github_prs=_env_bool("PRESERVE_GITHUB_PRS", False),
402
- dry_run=_env_bool("DRY_RUN", False),
403
- gerrit_server=_env_str("GERRIT_SERVER", ""),
404
- gerrit_server_port=_env_str("GERRIT_SERVER_PORT", "29418"),
405
- gerrit_project=_env_str("GERRIT_PROJECT"),
406
- issue_id=_env_str("ISSUE_ID"),
407
- allow_duplicates=_env_bool("ALLOW_DUPLICATES", False),
408
- duplicates_filter=_env_str("DUPLICATES", "open"),
413
+ submit_single_commits=env_bool("SUBMIT_SINGLE_COMMITS", False),
414
+ use_pr_as_commit=env_bool("USE_PR_AS_COMMIT", False),
415
+ fetch_depth=int(env_str("FETCH_DEPTH", "10") or "10"),
416
+ gerrit_known_hosts=env_str("GERRIT_KNOWN_HOSTS"),
417
+ gerrit_ssh_privkey_g2g=env_str("GERRIT_SSH_PRIVKEY_G2G"),
418
+ gerrit_ssh_user_g2g=env_str("GERRIT_SSH_USER_G2G"),
419
+ gerrit_ssh_user_g2g_email=env_str("GERRIT_SSH_USER_G2G_EMAIL"),
420
+ organization=env_str("ORGANIZATION", env_str("GITHUB_REPOSITORY_OWNER")),
421
+ reviewers_email=env_str("REVIEWERS_EMAIL", ""),
422
+ preserve_github_prs=env_bool("PRESERVE_GITHUB_PRS", False),
423
+ dry_run=env_bool("DRY_RUN", False),
424
+ normalise_commit=env_bool("NORMALISE_COMMIT", True),
425
+ gerrit_server=env_str("GERRIT_SERVER", ""),
426
+ gerrit_server_port=env_str("GERRIT_SERVER_PORT", "29418"),
427
+ gerrit_project=env_str("GERRIT_PROJECT"),
428
+ issue_id=env_str("ISSUE_ID", ""),
429
+ allow_duplicates=env_bool("ALLOW_DUPLICATES", False),
430
+ ci_testing=env_bool("CI_TESTING", False),
431
+ duplicates_filter=env_str("DUPLICATES", "open"),
409
432
  )
410
433
 
411
434
 
412
- def _process_bulk(data: Inputs, gh: GitHubContext) -> None:
435
+ def _process_bulk(data: Inputs, gh: GitHubContext) -> bool:
413
436
  client = build_client()
414
437
  repo = get_repo_from_env(client)
415
438
 
416
439
  all_urls: list[str] = []
417
440
  all_nums: list[str] = []
441
+ all_shas: list[str] = []
418
442
 
419
443
  prs_list = list(iter_open_pulls(repo))
420
444
  log.info("Found %d open PRs to process", len(prs_list))
421
- for pr in prs_list:
445
+
446
+ # Result tracking for summary
447
+ processed_count = 0
448
+ succeeded_count = 0
449
+ skipped_count = 0
450
+ failed_count = 0
451
+
452
+ # Use bounded parallel processing with shared clients
453
+ max_workers = min(4, max(1, len(prs_list))) # Cap at 4 workers
454
+
455
+ def process_single_pr(
456
+ pr_data: tuple[Any, models.GitHubContext],
457
+ ) -> tuple[str, SubmissionResult | None, Exception | None]:
458
+ """Process a single PR and return (status, result, exception)."""
459
+ pr, per_ctx = pr_data
422
460
  pr_number = int(getattr(pr, "number", 0) or 0)
423
- if pr_number <= 0:
424
- continue
425
461
 
426
- per_ctx = models.GitHubContext(
427
- event_name=gh.event_name,
428
- event_action=gh.event_action,
429
- event_path=gh.event_path,
430
- repository=gh.repository,
431
- repository_owner=gh.repository_owner,
432
- server_url=gh.server_url,
433
- run_id=gh.run_id,
434
- sha=gh.sha,
435
- base_ref=gh.base_ref,
436
- head_ref=gh.head_ref,
437
- pr_number=pr_number,
438
- )
462
+ if pr_number <= 0:
463
+ return "invalid", None, None
439
464
 
440
465
  log.info("Starting processing of PR #%d", pr_number)
441
466
  log.debug(
@@ -450,58 +475,123 @@ def _process_bulk(data: Inputs, gh: GitHubContext) -> None:
450
475
  os.environ["DUPLICATES"] = data.duplicates_filter
451
476
  check_for_duplicates(per_ctx, allow_duplicates=data.allow_duplicates)
452
477
  except DuplicateChangeError as exc:
453
- _log_exception_conditionally(log, "Skipping PR #%d", pr_number)
478
+ log_exception_conditionally(log, "Skipping PR #%d", pr_number)
454
479
  log.warning(
455
480
  "Skipping PR #%d due to duplicate detection: %s. Use --allow-duplicates to override this check.",
456
481
  pr_number,
457
482
  exc,
458
483
  )
459
- continue
484
+ return "skipped", None, exc
460
485
 
461
486
  try:
462
487
  with tempfile.TemporaryDirectory() as temp_dir:
463
488
  workspace = Path(temp_dir)
464
489
  orch = Orchestrator(workspace=workspace)
465
490
  result_multi = orch.execute(inputs=data, gh=per_ctx)
466
- if result_multi.change_urls:
467
- all_urls.extend(result_multi.change_urls)
468
- for url in result_multi.change_urls:
469
- log.info("Gerrit change URL: %s", url)
470
- log.info(
471
- "PR #%d created Gerrit change: %s",
472
- pr_number,
473
- url,
474
- )
475
- if result_multi.change_numbers:
476
- all_nums.extend(result_multi.change_numbers)
477
- log.info(
478
- "PR #%d change numbers: %s",
479
- pr_number,
480
- result_multi.change_numbers,
481
- )
491
+ return "success", result_multi, None
482
492
  except Exception as exc:
483
- _log_exception_conditionally(log, "Failed to process PR #%d", pr_number)
484
- typer.echo(f"Failed to process PR #{pr_number}: {exc}")
485
- log.info("Continuing to next PR despite failure")
493
+ log_exception_conditionally(log, "Failed to process PR #%d", pr_number)
494
+ return "failed", None, exc
495
+
496
+ # Prepare PR processing tasks
497
+ pr_tasks = []
498
+ for pr in prs_list:
499
+ pr_number = int(getattr(pr, "number", 0) or 0)
500
+ if pr_number <= 0:
486
501
  continue
487
502
 
503
+ per_ctx = models.GitHubContext(
504
+ event_name=gh.event_name,
505
+ event_action=gh.event_action,
506
+ event_path=gh.event_path,
507
+ repository=gh.repository,
508
+ repository_owner=gh.repository_owner,
509
+ server_url=gh.server_url,
510
+ run_id=gh.run_id,
511
+ sha=gh.sha,
512
+ base_ref=gh.base_ref,
513
+ head_ref=gh.head_ref,
514
+ pr_number=pr_number,
515
+ )
516
+ pr_tasks.append((pr, per_ctx))
517
+
518
+ # Process PRs in parallel
519
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
520
+ log.info("Processing %d PRs with %d parallel workers", len(pr_tasks), max_workers)
521
+
522
+ # Submit all tasks
523
+ future_to_pr = {
524
+ executor.submit(process_single_pr, pr_task): pr_task[1].pr_number
525
+ for pr_task in pr_tasks
526
+ if pr_task[1].pr_number is not None
527
+ }
528
+
529
+ # Collect results as they complete
530
+ for future in as_completed(future_to_pr):
531
+ pr_number = future_to_pr[future]
532
+ processed_count += 1
533
+
534
+ try:
535
+ status, result_multi, exc = future.result()
536
+
537
+ if status == "success" and result_multi:
538
+ succeeded_count += 1
539
+ if result_multi.change_urls:
540
+ all_urls.extend(result_multi.change_urls)
541
+ for url in result_multi.change_urls:
542
+ log.info("Gerrit change URL: %s", url)
543
+ log.info("PR #%d created Gerrit change: %s", pr_number, url)
544
+ if result_multi.change_numbers:
545
+ all_nums.extend(result_multi.change_numbers)
546
+ log.info("PR #%d change numbers: %s", pr_number, result_multi.change_numbers)
547
+ if result_multi.commit_shas:
548
+ all_shas.extend(result_multi.commit_shas)
549
+ elif status == "skipped":
550
+ skipped_count += 1
551
+ elif status == "failed":
552
+ failed_count += 1
553
+ typer.echo(f"Failed to process PR #{pr_number}: {exc}")
554
+ log.info("Continuing to next PR despite failure")
555
+ else:
556
+ failed_count += 1
557
+
558
+ except Exception as exc:
559
+ failed_count += 1
560
+ log_exception_conditionally(log, "Failed to process PR #%d", pr_number)
561
+ typer.echo(f"Failed to process PR #{pr_number}: {exc}")
562
+ log.info("Continuing to next PR despite failure")
563
+
564
+ # Aggregate results and provide summary
488
565
  if all_urls:
489
566
  os.environ["GERRIT_CHANGE_REQUEST_URL"] = "\n".join(all_urls)
490
567
  if all_nums:
491
568
  os.environ["GERRIT_CHANGE_REQUEST_NUM"] = "\n".join(all_nums)
569
+ if all_shas:
570
+ os.environ["GERRIT_COMMIT_SHA"] = "\n".join(all_shas)
492
571
 
493
- _append_github_output(
572
+ append_github_output(
494
573
  {
495
- "gerrit_change_request_url": os.getenv("GERRIT_CHANGE_REQUEST_URL", ""),
496
- "gerrit_change_request_num": os.getenv("GERRIT_CHANGE_REQUEST_NUM", ""),
574
+ "gerrit_change_request_url": "\n".join(all_urls) if all_urls else "",
575
+ "gerrit_change_request_num": "\n".join(all_nums) if all_nums else "",
576
+ "gerrit_commit_sha": "\n".join(all_shas) if all_shas else "",
497
577
  }
498
578
  )
499
579
 
500
- log.info("Submission pipeline complete (multi-PR).")
501
- return
580
+ # Summary block
581
+ log.info("=" * 60)
582
+ log.info("BULK PROCESSING SUMMARY:")
583
+ log.info(" Total PRs processed: %d", processed_count)
584
+ log.info(" Succeeded: %d", succeeded_count)
585
+ log.info(" Skipped (duplicates): %d", skipped_count)
586
+ log.info(" Failed: %d", failed_count)
587
+ log.info(" Gerrit changes created: %d", len(all_urls))
588
+ log.info("=" * 60)
502
589
 
590
+ # Return True if no failures occurred
591
+ return failed_count == 0
503
592
 
504
- def _process_single(data: Inputs, gh: GitHubContext) -> None:
593
+
594
+ def _process_single(data: Inputs, gh: GitHubContext) -> bool:
505
595
  # Create temporary directory for all git operations
506
596
  with tempfile.TemporaryDirectory() as temp_dir:
507
597
  workspace = Path(temp_dir)
@@ -527,21 +617,19 @@ def _process_single(data: Inputs, gh: GitHubContext) -> None:
527
617
  log.info("Gerrit change URL: %s", url)
528
618
  if result.change_numbers:
529
619
  os.environ["GERRIT_CHANGE_REQUEST_NUM"] = "\n".join(result.change_numbers)
620
+ if result.commit_shas:
621
+ os.environ["GERRIT_COMMIT_SHA"] = "\n".join(result.commit_shas)
530
622
 
531
623
  # Also write outputs to GITHUB_OUTPUT if available
532
- _append_github_output(
624
+ append_github_output(
533
625
  {
534
- "gerrit_change_request_url": os.getenv("GERRIT_CHANGE_REQUEST_URL", ""),
535
- "gerrit_change_request_num": os.getenv("GERRIT_CHANGE_REQUEST_NUM", ""),
536
- "gerrit_commit_sha": os.getenv("GERRIT_COMMIT_SHA", ""),
626
+ "gerrit_change_request_url": "\n".join(result.change_urls) if result.change_urls else "",
627
+ "gerrit_change_request_num": "\n".join(result.change_numbers) if result.change_numbers else "",
628
+ "gerrit_commit_sha": "\n".join(result.commit_shas) if result.commit_shas else "",
537
629
  }
538
630
  )
539
631
 
540
- if pipeline_success:
541
- log.info("Submission pipeline completed SUCCESSFULLY ✅")
542
- else:
543
- log.error("Submission pipeline FAILED ❌")
544
- return
632
+ return pipeline_success
545
633
 
546
634
 
547
635
  def _prepare_local_checkout(workspace: Path, gh: GitHubContext, data: Inputs) -> None:
@@ -554,31 +642,52 @@ def _prepare_local_checkout(workspace: Path, gh: GitHubContext, data: Inputs) ->
554
642
  if not repo_full:
555
643
  return
556
644
 
557
- repo_url = f"{server_url}/{repo_full}.git"
645
+ # Try SSH first for private repos if available, then fall back to HTTPS/API
646
+ repo_ssh_url = f"git@{server_url.replace('https://', '').replace('http://', '')}:{repo_full}.git"
647
+ repo_https_url = f"{server_url}/{repo_full}.git"
648
+
558
649
  run_cmd(["git", "init"], cwd=workspace)
650
+
651
+ # Determine which URL to use and set up authentication
652
+ env: dict[str, str] = {}
653
+ repo_url = repo_https_url # Default to HTTPS
654
+
655
+ # Check if we should try SSH for private repos
656
+ use_ssh = False
657
+ respect_user_ssh = os.getenv("G2G_RESPECT_USER_SSH", "false").lower() in ("true", "1", "yes")
658
+ gerrit_ssh_privkey = os.getenv("GERRIT_SSH_PRIVKEY_G2G")
659
+
660
+ log.debug(
661
+ "GitHub repo access decision: SSH URL available=%s, G2G_RESPECT_USER_SSH=%s, GERRIT_SSH_PRIVKEY_G2G=%s",
662
+ repo_ssh_url.startswith("git@"),
663
+ respect_user_ssh,
664
+ bool(gerrit_ssh_privkey),
665
+ )
666
+
667
+ if repo_ssh_url.startswith("git@"):
668
+ # For private repos, only try SSH if G2G_RESPECT_USER_SSH is explicitly enabled
669
+ # Don't use SSH just because GERRIT_SSH_PRIVKEY_G2G is set (that's for Gerrit, not GitHub)
670
+ if respect_user_ssh:
671
+ use_ssh = True
672
+ repo_url = repo_ssh_url
673
+ log.debug("Using SSH for GitHub repo access due to G2G_RESPECT_USER_SSH=true")
674
+ else:
675
+ log.debug("Not using SSH for GitHub repo access - G2G_RESPECT_USER_SSH not enabled")
676
+
677
+ if use_ssh:
678
+ env = {
679
+ "GIT_SSH_COMMAND": build_git_ssh_command(),
680
+ **build_non_interactive_ssh_env(),
681
+ }
682
+ log.debug("Using SSH URL for private repo: %s", repo_url)
683
+ else:
684
+ log.debug("Using HTTPS URL: %s", repo_url)
685
+
559
686
  run_cmd(["git", "remote", "add", "origin", repo_url], cwd=workspace)
560
687
 
561
- # Non-interactive SSH/Git environment for any network operations
562
- env = {
563
- "GIT_SSH_COMMAND": (
564
- "ssh -F /dev/null "
565
- "-o IdentitiesOnly=yes "
566
- "-o IdentityAgent=none "
567
- "-o BatchMode=yes "
568
- "-o PreferredAuthentications=publickey "
569
- "-o StrictHostKeyChecking=yes "
570
- "-o PasswordAuthentication=no "
571
- "-o PubkeyAcceptedKeyTypes=+ssh-rsa "
572
- "-o ConnectTimeout=10"
573
- ),
574
- "SSH_AUTH_SOCK": "",
575
- "SSH_AGENT_PID": "",
576
- "SSH_ASKPASS": "/usr/bin/false",
577
- "DISPLAY": "",
578
- "SSH_ASKPASS_REQUIRE": "never",
579
- }
688
+ # Fetch base branch and PR head with fallback to API archive
689
+ fetch_success = False
580
690
 
581
- # Fetch base branch and PR head
582
691
  if base_ref:
583
692
  try:
584
693
  branch_ref = f"refs/heads/{base_ref}:refs/remotes/origin/{base_ref}"
@@ -597,29 +706,161 @@ def _prepare_local_checkout(workspace: Path, gh: GitHubContext, data: Inputs) ->
597
706
  log.debug("Base branch fetch failed for %s: %s", base_ref, exc)
598
707
 
599
708
  if pr_num_str:
600
- pr_ref = f"refs/pull/{pr_num_str}/head:refs/remotes/origin/pr/{pr_num_str}/head"
601
- run_cmd(
602
- [
603
- "git",
604
- "fetch",
605
- f"--depth={data.fetch_depth}",
606
- "origin",
607
- pr_ref,
608
- ],
609
- cwd=workspace,
610
- env=env,
611
- )
612
- run_cmd(
613
- [
614
- "git",
615
- "checkout",
616
- "-B",
617
- "g2g_pr_head",
618
- f"refs/remotes/origin/pr/{pr_num_str}/head",
619
- ],
620
- cwd=workspace,
621
- env=env,
622
- )
709
+ try:
710
+ pr_ref = f"refs/pull/{pr_num_str}/head:refs/remotes/origin/pr/{pr_num_str}/head"
711
+ run_cmd(
712
+ [
713
+ "git",
714
+ "fetch",
715
+ f"--depth={data.fetch_depth}",
716
+ "origin",
717
+ pr_ref,
718
+ ],
719
+ cwd=workspace,
720
+ env=env,
721
+ )
722
+ run_cmd(
723
+ [
724
+ "git",
725
+ "checkout",
726
+ "-B",
727
+ "g2g_pr_head",
728
+ f"refs/remotes/origin/pr/{pr_num_str}/head",
729
+ ],
730
+ cwd=workspace,
731
+ env=env,
732
+ )
733
+ fetch_success = True
734
+ except Exception as exc:
735
+ log.warning("Git fetch failed, attempting API archive fallback: %s", exc)
736
+ # Try API archive fallback for private repos
737
+ try:
738
+ _fallback_to_api_archive(workspace, gh, data, pr_num_str)
739
+ fetch_success = True
740
+ except Exception as api_exc:
741
+ log.exception("API archive fallback also failed")
742
+ raise exc from api_exc
743
+
744
+ if not fetch_success and pr_num_str:
745
+ msg = f"Failed to prepare checkout for PR #{pr_num_str}"
746
+ raise RuntimeError(msg)
747
+
748
+
749
+ def _fallback_to_api_archive(workspace: Path, gh: GitHubContext, data: Inputs, pr_num_str: str) -> None:
750
+ """Fallback to GitHub API archive download for private repos."""
751
+ log.info("Attempting API archive fallback for PR #%s", pr_num_str)
752
+
753
+ # Get GitHub token for authenticated requests
754
+ token = os.getenv("GITHUB_TOKEN")
755
+ if not token:
756
+ msg = "GITHUB_TOKEN required for API archive fallback"
757
+ raise RuntimeError(msg)
758
+
759
+ # Build API URLs
760
+ repo_full = gh.repository
761
+ server_url = gh.server_url or "https://github.com"
762
+
763
+ # Construct GitHub API base URL properly
764
+ if "github.com" in server_url:
765
+ # For github.com, use api.github.com
766
+ api_base = "https://api.github.com"
767
+ elif server_url.startswith("https://"):
768
+ # For GitHub Enterprise, append /api/v3
769
+ api_base = server_url.rstrip("/") + "/api/v3"
770
+ else:
771
+ # Fallback for unexpected formats
772
+ api_base = "https://api.github.com"
773
+
774
+ # Get PR details to find head SHA
775
+ pr_api_url = f"{api_base}/repos/{repo_full}/pulls/{pr_num_str}"
776
+ log.debug("GitHub API PR URL: %s", pr_api_url)
777
+
778
+ headers = {
779
+ "Authorization": f"token {token}",
780
+ "Accept": "application/vnd.github.v3+json",
781
+ "User-Agent": "github2gerrit",
782
+ }
783
+
784
+ try:
785
+ req = Request(pr_api_url, headers=headers) # noqa: S310
786
+ with urlopen(req, timeout=30) as response: # noqa: S310
787
+ pr_data = json.loads(response.read().decode())
788
+ except Exception:
789
+ log.exception("Failed to fetch PR data from GitHub API")
790
+ log.debug("PR API URL was: %s", pr_api_url)
791
+ raise
792
+
793
+ head_sha = pr_data["head"]["sha"]
794
+
795
+ # Download archive
796
+ archive_url = f"{api_base}/repos/{repo_full}/zipball/{head_sha}"
797
+ log.debug("GitHub API archive URL: %s", archive_url)
798
+
799
+ try:
800
+ req = Request(archive_url, headers=headers) # noqa: S310
801
+ with urlopen(req, timeout=120) as response: # noqa: S310
802
+ archive_data = response.read()
803
+ except Exception:
804
+ log.exception("Failed to download archive from GitHub API")
805
+ log.debug("Archive URL was: %s", archive_url)
806
+ raise
807
+
808
+ # Extract archive
809
+ with zipfile.ZipFile(io.BytesIO(archive_data)) as zf:
810
+ # Find the root directory in the archive (usually repo-sha format)
811
+ members = zf.namelist()
812
+ root_dir = None
813
+ for member in members:
814
+ if "/" in member:
815
+ root_dir = member.split("/")[0]
816
+ break
817
+
818
+ if not root_dir:
819
+ msg = "Could not find root directory in archive"
820
+ raise RuntimeError(msg)
821
+
822
+ # Extract to temporary location then move contents
823
+ extract_path = workspace / "archive_temp"
824
+ zf.extractall(extract_path)
825
+
826
+ # Move contents from extracted root to workspace
827
+ extracted_root = extract_path / root_dir
828
+ for item in extracted_root.iterdir():
829
+ if item.name == ".git":
830
+ continue # Skip .git if present
831
+ dest = workspace / item.name
832
+ if dest.exists():
833
+ if dest.is_dir():
834
+ shutil.rmtree(dest)
835
+ else:
836
+ dest.unlink()
837
+ item.rename(dest)
838
+
839
+ # Clean up
840
+ shutil.rmtree(extract_path)
841
+
842
+ # Set up git for the extracted content
843
+ if not (workspace / ".git").exists():
844
+ run_cmd(["git", "init"], cwd=workspace)
845
+
846
+ # Create a commit for the PR content
847
+ run_cmd(["git", "add", "."], cwd=workspace)
848
+ run_cmd(
849
+ [
850
+ "git",
851
+ "commit",
852
+ "-m",
853
+ f"PR #{pr_num_str} content from API archive",
854
+ "--author",
855
+ "GitHub API <noreply@github.com>",
856
+ ],
857
+ cwd=workspace,
858
+ )
859
+
860
+ # Create the expected branch
861
+ run_cmd(["git", "checkout", "-B", "g2g_pr_head"], cwd=workspace)
862
+
863
+ log.info("Successfully extracted PR #%s content via API archive", pr_num_str)
623
864
 
624
865
 
625
866
  def _load_effective_inputs() -> Inputs:
@@ -633,6 +874,15 @@ def _load_effective_inputs() -> Inputs:
633
874
  # Apply dynamic parameter derivation for missing Gerrit parameters
634
875
  cfg = apply_parameter_derivation(cfg, org_for_cfg, save_to_config=True)
635
876
 
877
+ # Debug: Show what configuration would be applied
878
+ log.debug("Configuration to apply: %s", cfg)
879
+ if "DRY_RUN" in cfg:
880
+ log.warning(
881
+ "Configuration contains DRY_RUN=%s, this may override environment DRY_RUN=%s",
882
+ cfg["DRY_RUN"],
883
+ os.getenv("DRY_RUN"),
884
+ )
885
+
636
886
  apply_config_to_env(cfg)
637
887
 
638
888
  # Refresh inputs after applying configuration to environment
@@ -658,11 +908,13 @@ def _load_effective_inputs() -> Inputs:
658
908
  reviewers_email=os.environ["REVIEWERS_EMAIL"],
659
909
  preserve_github_prs=data.preserve_github_prs,
660
910
  dry_run=data.dry_run,
911
+ normalise_commit=data.normalise_commit,
661
912
  gerrit_server=data.gerrit_server,
662
913
  gerrit_server_port=data.gerrit_server_port,
663
914
  gerrit_project=data.gerrit_project,
664
915
  issue_id=data.issue_id,
665
916
  allow_duplicates=data.allow_duplicates,
917
+ ci_testing=data.ci_testing,
666
918
  duplicates_filter=data.duplicates_filter,
667
919
  )
668
920
  log.info("Derived reviewers: %s", data.reviewers_email)
@@ -672,25 +924,6 @@ def _load_effective_inputs() -> Inputs:
672
924
  return data
673
925
 
674
926
 
675
- def _append_github_output(outputs: dict[str, str]) -> None:
676
- gh_out = os.getenv("GITHUB_OUTPUT")
677
- if not gh_out:
678
- return
679
- try:
680
- with open(gh_out, "a", encoding="utf-8") as fh:
681
- for key, val in outputs.items():
682
- if not val:
683
- continue
684
- if "\n" in val:
685
- fh.write(f"{key}<<G2G\n")
686
- fh.write(f"{val}\n")
687
- fh.write("G2G\n")
688
- else:
689
- fh.write(f"{key}={val}\n")
690
- except Exception as exc:
691
- log.debug("Failed to write GITHUB_OUTPUT: %s", exc)
692
-
693
-
694
927
  def _augment_pr_refs_if_needed(gh: GitHubContext) -> GitHubContext:
695
928
  if os.getenv("G2G_TARGET_URL") and gh.pr_number and (not gh.head_ref or not gh.base_ref):
696
929
  try:
@@ -722,7 +955,7 @@ def _process() -> None:
722
955
  try:
723
956
  _validate_inputs(data)
724
957
  except ConfigurationError as exc:
725
- _log_exception_conditionally(log, "Configuration validation failed")
958
+ log_exception_conditionally(log, "Configuration validation failed")
726
959
  typer.echo(f"Configuration validation failed: {exc}", err=True)
727
960
  raise typer.Exit(code=2) from exc
728
961
 
@@ -730,15 +963,31 @@ def _process() -> None:
730
963
  _log_effective_config(data, gh)
731
964
 
732
965
  # Test mode: short-circuit after validation
733
- if _env_bool("G2G_TEST_MODE", False):
966
+ if env_bool("G2G_TEST_MODE", False):
734
967
  log.info("Validation complete. Ready to execute submission pipeline.")
735
968
  typer.echo("Validation complete. Ready to execute submission pipeline.")
736
969
  return
737
970
 
738
971
  # Bulk mode for URL/workflow_dispatch
739
- sync_all = _env_bool("SYNC_ALL_OPEN_PRS", False)
972
+ sync_all = env_bool("SYNC_ALL_OPEN_PRS", False)
740
973
  if sync_all and (gh.event_name == "workflow_dispatch" or os.getenv("G2G_TARGET_URL")):
741
- _process_bulk(data, gh)
974
+ bulk_success = _process_bulk(data, gh)
975
+
976
+ # Log external API metrics summary
977
+ try:
978
+ from .external_api import log_api_metrics_summary
979
+
980
+ log_api_metrics_summary()
981
+ except Exception as exc:
982
+ log.debug("Failed to log API metrics summary: %s", exc)
983
+
984
+ # Final success/failure message for bulk processing
985
+ if bulk_success:
986
+ log.info("Bulk processing completed SUCCESSFULLY ✅")
987
+ else:
988
+ log.error("Bulk processing FAILED ❌")
989
+ raise typer.Exit(code=1)
990
+
742
991
  return
743
992
 
744
993
  if not gh.pr_number:
@@ -759,13 +1008,13 @@ def _process() -> None:
759
1008
  gh = _augment_pr_refs_if_needed(gh)
760
1009
 
761
1010
  # Check for duplicates in single-PR mode (before workspace setup)
762
- if gh.pr_number and not _env_bool("SYNC_ALL_OPEN_PRS", False):
1011
+ if gh.pr_number and not env_bool("SYNC_ALL_OPEN_PRS", False):
763
1012
  try:
764
1013
  if data.duplicates_filter:
765
1014
  os.environ["DUPLICATES"] = data.duplicates_filter
766
1015
  check_for_duplicates(gh, allow_duplicates=data.allow_duplicates)
767
1016
  except DuplicateChangeError as exc:
768
- _log_exception_conditionally(
1017
+ log_exception_conditionally(
769
1018
  log,
770
1019
  "Duplicate detection blocked submission for PR #%d",
771
1020
  gh.pr_number,
@@ -773,7 +1022,23 @@ def _process() -> None:
773
1022
  log.info("Use --allow-duplicates to override this check.")
774
1023
  raise typer.Exit(code=3) from exc
775
1024
 
776
- _process_single(data, gh)
1025
+ pipeline_success = _process_single(data, gh)
1026
+
1027
+ # Log external API metrics summary
1028
+ try:
1029
+ from .external_api import log_api_metrics_summary
1030
+
1031
+ log_api_metrics_summary()
1032
+ except Exception as exc:
1033
+ log.debug("Failed to log API metrics summary: %s", exc)
1034
+
1035
+ # Final success/failure message after all cleanup
1036
+ if pipeline_success:
1037
+ log.info("Submission pipeline completed SUCCESSFULLY ✅")
1038
+ else:
1039
+ log.error("Submission pipeline FAILED ❌")
1040
+ raise typer.Exit(code=1)
1041
+
777
1042
  return
778
1043
 
779
1044
 
@@ -892,7 +1157,7 @@ def _validate_inputs(data: Inputs) -> None:
892
1157
  ", ".join(missing_gerrit_params),
893
1158
  )
894
1159
  # Allow derivation in local mode only if explicitly enabled
895
- if not _env_bool("G2G_ENABLE_DERIVATION", False):
1160
+ if not env_bool("G2G_ENABLE_DERIVATION", False):
896
1161
  required_fields.extend(missing_gerrit_params)
897
1162
  else:
898
1163
  required_fields.extend(missing_gerrit_params)
@@ -947,7 +1212,8 @@ def _log_effective_config(data: Inputs, gh: GitHubContext) -> None:
947
1212
  log.info(" REVIEWERS_EMAIL: %s", data.reviewers_email or "")
948
1213
  log.info(" PRESERVE_GITHUB_PRS: %s", data.preserve_github_prs)
949
1214
  log.info(" DRY_RUN: %s", data.dry_run)
950
- log.info(" GERRIT_SERVER: %s", data.gerrit_server or "")
1215
+ log.info(" CI_TESTING: %s", data.ci_testing)
1216
+ log.info(" GERRIT_SERVER: %s", data.gerrit_server)
951
1217
  log.info(" GERRIT_SERVER_PORT: %s", data.gerrit_server_port or "")
952
1218
  log.info(" GERRIT_PROJECT: %s", data.gerrit_project or "")
953
1219
  log.info("GitHub context:")