github2gerrit 0.1.0__py3-none-any.whl → 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
github2gerrit/cli.py ADDED
@@ -0,0 +1,865 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2025 The Linux Foundation
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import logging
8
+ import os
9
+ import tempfile
10
+ from pathlib import Path
11
+ from typing import Any
12
+ from typing import cast
13
+ from urllib.parse import urlparse
14
+
15
+ import click
16
+ import typer
17
+
18
+ from . import models
19
+ from .config import apply_config_to_env
20
+ from .config import load_org_config
21
+ from .core import Orchestrator
22
+ from .core import SubmissionResult
23
+ from .duplicate_detection import DuplicateChangeError
24
+ from .duplicate_detection import check_for_duplicates
25
+ from .github_api import build_client
26
+ from .github_api import get_pull
27
+ from .github_api import get_repo_from_env
28
+ from .github_api import iter_open_pulls
29
+ from .gitutils import run_cmd
30
+ from .models import GitHubContext
31
+ from .models import Inputs
32
+
33
+
34
+ def _parse_github_target(url: str) -> tuple[str | None, str | None, int | None]:
35
+ """
36
+ Parse a GitHub repository or pull request URL.
37
+
38
+ Returns:
39
+ (org, repo, pr_number) where pr_number may be None for repo URLs.
40
+ """
41
+ try:
42
+ u = urlparse(url)
43
+ except Exception:
44
+ return None, None, None
45
+
46
+ allow_ghe = _env_bool("ALLOW_GHE_URLS", False)
47
+ bad_hosts = {
48
+ "gitlab.com",
49
+ "www.gitlab.com",
50
+ "bitbucket.org",
51
+ "www.bitbucket.org",
52
+ }
53
+ if u.netloc in bad_hosts:
54
+ return None, None, None
55
+ if not allow_ghe and u.netloc not in ("github.com", "www.github.com"):
56
+ return None, None, None
57
+
58
+ parts = [p for p in (u.path or "").split("/") if p]
59
+ if len(parts) < 2:
60
+ return None, None, None
61
+
62
+ owner, repo = parts[0], parts[1]
63
+ pr_number: int | None = None
64
+ if len(parts) >= 4 and parts[2] in ("pull", "pulls"):
65
+ try:
66
+ pr_number = int(parts[3])
67
+ except Exception:
68
+ pr_number = None
69
+
70
+ return owner, repo, pr_number
71
+
72
+
73
+ APP_NAME = "github2gerrit"
74
+
75
+
76
+ class _SingleUsageGroup(click.Group): # type: ignore[misc]
77
+ def format_usage(self, ctx: Any, formatter: Any) -> None:
78
+ # Force a simplified usage line without COMMAND [ARGS]...
79
+ formatter.write_usage(
80
+ ctx.command_path, "[OPTIONS] TARGET_URL", prefix="Usage: "
81
+ )
82
+
83
+
84
+ app: typer.Typer = typer.Typer(
85
+ add_completion=False,
86
+ no_args_is_help=False,
87
+ cls=_SingleUsageGroup,
88
+ )
89
+
90
+
91
+ def _resolve_org(default_org: str | None) -> str:
92
+ if default_org:
93
+ return default_org
94
+ gh_owner = os.getenv("GITHUB_REPOSITORY_OWNER")
95
+ if gh_owner:
96
+ return gh_owner
97
+ # Fallback to empty string for compatibility with existing action
98
+ return ""
99
+
100
+
101
+ @app.command() # type: ignore[misc]
102
+ def main(
103
+ ctx: typer.Context,
104
+ target_url: str | None = typer.Argument(
105
+ None,
106
+ help="GitHub repository or PR URL",
107
+ metavar="TARGET_URL",
108
+ ),
109
+ submit_single_commits: bool = typer.Option(
110
+ False,
111
+ "--submit-single-commits",
112
+ help="Submit one commit at a time to the Gerrit repository.",
113
+ ),
114
+ use_pr_as_commit: bool = typer.Option(
115
+ False,
116
+ "--use-pr-as-commit",
117
+ help="Use PR title and body as the commit message.",
118
+ ),
119
+ fetch_depth: int = typer.Option(
120
+ 10,
121
+ "--fetch-depth",
122
+ envvar="FETCH_DEPTH",
123
+ help="Fetch-depth for the clone.",
124
+ ),
125
+ gerrit_known_hosts: str = typer.Option(
126
+ "",
127
+ "--gerrit-known-hosts",
128
+ envvar="GERRIT_KNOWN_HOSTS",
129
+ help="Known hosts entries for Gerrit SSH.",
130
+ ),
131
+ gerrit_ssh_privkey_g2g: str = typer.Option(
132
+ "",
133
+ "--gerrit-ssh-privkey-g2g",
134
+ envvar="GERRIT_SSH_PRIVKEY_G2G",
135
+ help="SSH private key for Gerrit (string content).",
136
+ ),
137
+ gerrit_ssh_user_g2g: str = typer.Option(
138
+ "",
139
+ "--gerrit-ssh-user-g2g",
140
+ envvar="GERRIT_SSH_USER_G2G",
141
+ help="Gerrit SSH user.",
142
+ ),
143
+ gerrit_ssh_user_g2g_email: str = typer.Option(
144
+ "",
145
+ "--gerrit-ssh-user-g2g-email",
146
+ envvar="GERRIT_SSH_USER_G2G_EMAIL",
147
+ help="Email address for the Gerrit SSH user.",
148
+ ),
149
+ organization: str | None = typer.Option(
150
+ None,
151
+ "--organization",
152
+ envvar="ORGANIZATION",
153
+ help=("Organization (defaults to GITHUB_REPOSITORY_OWNER when unset)."),
154
+ ),
155
+ reviewers_email: str = typer.Option(
156
+ "",
157
+ "--reviewers-email",
158
+ envvar="REVIEWERS_EMAIL",
159
+ help="Comma-separated list of reviewer emails.",
160
+ ),
161
+ allow_ghe_urls: bool = typer.Option(
162
+ False,
163
+ "--allow-ghe-urls/--no-allow-ghe-urls",
164
+ envvar="ALLOW_GHE_URLS",
165
+ help="Allow non-github.com GitHub Enterprise URLs in direct URL mode.",
166
+ ),
167
+ preserve_github_prs: bool = typer.Option(
168
+ False,
169
+ "--preserve-github-prs",
170
+ envvar="PRESERVE_GITHUB_PRS",
171
+ help="Do not close GitHub PRs after pushing to Gerrit.",
172
+ ),
173
+ dry_run: bool = typer.Option(
174
+ False,
175
+ "--dry-run",
176
+ envvar="DRY_RUN",
177
+ help="Validate settings and PR metadata; do not write to Gerrit.",
178
+ ),
179
+ gerrit_server: str = typer.Option(
180
+ "",
181
+ "--gerrit-server",
182
+ envvar="GERRIT_SERVER",
183
+ help="Gerrit server hostname (optional; .gitreview preferred).",
184
+ ),
185
+ gerrit_server_port: str = typer.Option(
186
+ "29418",
187
+ "--gerrit-server-port",
188
+ envvar="GERRIT_SERVER_PORT",
189
+ help="Gerrit SSH port (default: 29418).",
190
+ ),
191
+ gerrit_project: str = typer.Option(
192
+ "",
193
+ "--gerrit-project",
194
+ envvar="GERRIT_PROJECT",
195
+ help="Gerrit project (optional; .gitreview preferred).",
196
+ ),
197
+ issue_id: str = typer.Option(
198
+ "",
199
+ "--issue-id",
200
+ envvar="ISSUE_ID",
201
+ help="Issue ID to include in commit message (e.g., Issue-ID: ABC-123).",
202
+ ),
203
+ allow_duplicates: bool = typer.Option(
204
+ False,
205
+ "--allow-duplicates",
206
+ envvar="ALLOW_DUPLICATES",
207
+ help="Allow submitting duplicate changes without error.",
208
+ ),
209
+ verbose: bool = typer.Option(
210
+ False,
211
+ "--verbose",
212
+ "-v",
213
+ envvar="G2G_VERBOSE",
214
+ help="Enable verbose debug logging.",
215
+ ),
216
+ ) -> None:
217
+ """
218
+ Tool to convert GitHub pull requests into Gerrit changes
219
+
220
+ - Providing a URL to a pull request: converts that pull request
221
+ into a Gerrit change
222
+
223
+ - Providing a URL to a GitHub repository converts all open pull
224
+ requests into Gerrit changes
225
+
226
+ - No arguments for CI/CD environment; reads parameters from
227
+ environment variables
228
+ """
229
+ # Set up logging level based on verbose flag
230
+ if verbose:
231
+ os.environ["G2G_LOG_LEVEL"] = "DEBUG"
232
+ _reconfigure_logging()
233
+ # Normalize CLI options into environment for unified processing.
234
+ # For boolean flags, only set if explicitly provided via CLI
235
+ if submit_single_commits:
236
+ os.environ["SUBMIT_SINGLE_COMMITS"] = "true"
237
+ if use_pr_as_commit:
238
+ os.environ["USE_PR_AS_COMMIT"] = "true"
239
+ os.environ["FETCH_DEPTH"] = str(fetch_depth)
240
+ if gerrit_known_hosts:
241
+ os.environ["GERRIT_KNOWN_HOSTS"] = gerrit_known_hosts
242
+ if gerrit_ssh_privkey_g2g:
243
+ os.environ["GERRIT_SSH_PRIVKEY_G2G"] = gerrit_ssh_privkey_g2g
244
+ if gerrit_ssh_user_g2g:
245
+ os.environ["GERRIT_SSH_USER_G2G"] = gerrit_ssh_user_g2g
246
+ if gerrit_ssh_user_g2g_email:
247
+ os.environ["GERRIT_SSH_USER_G2G_EMAIL"] = gerrit_ssh_user_g2g_email
248
+ resolved_org = _resolve_org(organization)
249
+ if resolved_org:
250
+ os.environ["ORGANIZATION"] = resolved_org
251
+ if reviewers_email:
252
+ os.environ["REVIEWERS_EMAIL"] = reviewers_email
253
+ if preserve_github_prs:
254
+ os.environ["PRESERVE_GITHUB_PRS"] = "true"
255
+ if dry_run:
256
+ os.environ["DRY_RUN"] = "true"
257
+ os.environ["ALLOW_GHE_URLS"] = "true" if allow_ghe_urls else "false"
258
+ if gerrit_server:
259
+ os.environ["GERRIT_SERVER"] = gerrit_server
260
+ if gerrit_server_port:
261
+ os.environ["GERRIT_SERVER_PORT"] = gerrit_server_port
262
+ if gerrit_project:
263
+ os.environ["GERRIT_PROJECT"] = gerrit_project
264
+ if issue_id:
265
+ os.environ["ISSUE_ID"] = issue_id
266
+ if allow_duplicates:
267
+ os.environ["ALLOW_DUPLICATES"] = "true"
268
+ # URL mode handling
269
+ if target_url:
270
+ org, repo, pr = _parse_github_target(target_url)
271
+ if org:
272
+ os.environ["ORGANIZATION"] = org
273
+ if org and repo:
274
+ os.environ["GITHUB_REPOSITORY"] = f"{org}/{repo}"
275
+ if pr:
276
+ os.environ["PR_NUMBER"] = str(pr)
277
+ os.environ["SYNC_ALL_OPEN_PRS"] = "false"
278
+ else:
279
+ os.environ["SYNC_ALL_OPEN_PRS"] = "true"
280
+ os.environ["G2G_TARGET_URL"] = "1"
281
+ # Delegate to common processing path
282
+ try:
283
+ _process()
284
+ except typer.Exit:
285
+ # Propagate expected exit codes (e.g., validation errors)
286
+ raise
287
+ except Exception as exc:
288
+ log.debug("main(): _process failed: %s", exc)
289
+ return
290
+
291
+
292
+ def _setup_logging() -> logging.Logger:
293
+ level_name = os.getenv("G2G_LOG_LEVEL", "INFO").upper()
294
+ level = getattr(logging, level_name, logging.INFO)
295
+ fmt = (
296
+ "%(asctime)s %(levelname)-8s %(name)s "
297
+ "%(filename)s:%(lineno)d | %(message)s"
298
+ )
299
+ logging.basicConfig(level=level, format=fmt)
300
+ return logging.getLogger(APP_NAME)
301
+
302
+
303
+ def _reconfigure_logging() -> None:
304
+ """Reconfigure logging level based on current environment variables."""
305
+ level_name = os.getenv("G2G_LOG_LEVEL", "INFO").upper()
306
+ level = getattr(logging, level_name, logging.INFO)
307
+ logging.getLogger().setLevel(level)
308
+ for handler in logging.getLogger().handlers:
309
+ handler.setLevel(level)
310
+
311
+
312
+ log = _setup_logging()
313
+
314
+
315
+ def _env_str(name: str, default: str = "") -> str:
316
+ val = os.getenv(name)
317
+ return val if val is not None else default
318
+
319
+
320
+ def _env_bool(name: str, default: bool = False) -> bool:
321
+ val = os.getenv(name)
322
+ if val is None:
323
+ return default
324
+ s = val.strip().lower()
325
+ return s in ("1", "true", "yes", "on")
326
+
327
+
328
+ def _build_inputs_from_env() -> Inputs:
329
+ return Inputs(
330
+ submit_single_commits=_env_bool("SUBMIT_SINGLE_COMMITS", False),
331
+ use_pr_as_commit=_env_bool("USE_PR_AS_COMMIT", False),
332
+ fetch_depth=int(_env_str("FETCH_DEPTH", "10") or "10"),
333
+ gerrit_known_hosts=_env_str("GERRIT_KNOWN_HOSTS"),
334
+ gerrit_ssh_privkey_g2g=_env_str("GERRIT_SSH_PRIVKEY_G2G"),
335
+ gerrit_ssh_user_g2g=_env_str("GERRIT_SSH_USER_G2G"),
336
+ gerrit_ssh_user_g2g_email=_env_str("GERRIT_SSH_USER_G2G_EMAIL"),
337
+ organization=_env_str(
338
+ "ORGANIZATION", _env_str("GITHUB_REPOSITORY_OWNER")
339
+ ),
340
+ reviewers_email=_env_str("REVIEWERS_EMAIL", ""),
341
+ preserve_github_prs=_env_bool("PRESERVE_GITHUB_PRS", False),
342
+ dry_run=_env_bool("DRY_RUN", False),
343
+ gerrit_server=_env_str("GERRIT_SERVER", ""),
344
+ gerrit_server_port=_env_str("GERRIT_SERVER_PORT", "29418"),
345
+ gerrit_project=_env_str("GERRIT_PROJECT"),
346
+ issue_id=_env_str("ISSUE_ID"),
347
+ allow_duplicates=_env_bool("ALLOW_DUPLICATES", False),
348
+ )
349
+
350
+
351
+ def _process_bulk(data: Inputs, gh: GitHubContext) -> None:
352
+ client = build_client()
353
+ repo = get_repo_from_env(client)
354
+
355
+ all_urls: list[str] = []
356
+ all_nums: list[str] = []
357
+
358
+ prs_list = list(iter_open_pulls(repo))
359
+ log.info("Found %d open PRs to process", len(prs_list))
360
+ for pr in prs_list:
361
+ pr_number = int(getattr(pr, "number", 0) or 0)
362
+ if pr_number <= 0:
363
+ continue
364
+
365
+ per_ctx = models.GitHubContext(
366
+ event_name=gh.event_name,
367
+ event_action=gh.event_action,
368
+ event_path=gh.event_path,
369
+ repository=gh.repository,
370
+ repository_owner=gh.repository_owner,
371
+ server_url=gh.server_url,
372
+ run_id=gh.run_id,
373
+ sha=gh.sha,
374
+ base_ref=gh.base_ref,
375
+ head_ref=gh.head_ref,
376
+ pr_number=pr_number,
377
+ )
378
+
379
+ log.info("Starting processing of PR #%d", pr_number)
380
+ log.debug(
381
+ "Processing PR #%d in multi-PR mode with event_name=%s, "
382
+ "event_action=%s",
383
+ pr_number,
384
+ gh.event_name,
385
+ gh.event_action,
386
+ )
387
+
388
+ try:
389
+ check_for_duplicates(
390
+ per_ctx, allow_duplicates=data.allow_duplicates
391
+ )
392
+ except DuplicateChangeError as exc:
393
+ log.exception("Skipping PR #%d", pr_number)
394
+ typer.echo(f"Skipping PR #{pr_number}: {exc}")
395
+ continue
396
+
397
+ try:
398
+ with tempfile.TemporaryDirectory() as temp_dir:
399
+ workspace = Path(temp_dir)
400
+ orch = Orchestrator(workspace=workspace)
401
+ result_multi = orch.execute(inputs=data, gh=per_ctx)
402
+ if result_multi.change_urls:
403
+ all_urls.extend(result_multi.change_urls)
404
+ for url in result_multi.change_urls:
405
+ typer.echo(f"Gerrit change URL: {url}")
406
+ log.info(
407
+ "PR #%d created Gerrit change: %s",
408
+ pr_number,
409
+ url,
410
+ )
411
+ if result_multi.change_numbers:
412
+ all_nums.extend(result_multi.change_numbers)
413
+ log.info(
414
+ "PR #%d change numbers: %s",
415
+ pr_number,
416
+ result_multi.change_numbers,
417
+ )
418
+ except Exception as exc:
419
+ log.exception("Failed to process PR #%d", pr_number)
420
+ typer.echo(f"Failed to process PR #{pr_number}: {exc}")
421
+ log.info("Continuing to next PR despite failure")
422
+ continue
423
+
424
+ if all_urls:
425
+ os.environ["GERRIT_CHANGE_REQUEST_URL"] = "\n".join(all_urls)
426
+ if all_nums:
427
+ os.environ["GERRIT_CHANGE_REQUEST_NUM"] = "\n".join(all_nums)
428
+
429
+ _append_github_output(
430
+ {
431
+ "gerrit_change_request_url": os.getenv(
432
+ "GERRIT_CHANGE_REQUEST_URL", ""
433
+ ),
434
+ "gerrit_change_request_num": os.getenv(
435
+ "GERRIT_CHANGE_REQUEST_NUM", ""
436
+ ),
437
+ }
438
+ )
439
+
440
+ log.info("Submission pipeline complete (multi-PR).")
441
+ return
442
+
443
+
444
+ def _process_single(data: Inputs, gh: GitHubContext) -> None:
445
+ # Create temporary directory for all git operations
446
+ with tempfile.TemporaryDirectory() as temp_dir:
447
+ workspace = Path(temp_dir)
448
+
449
+ try:
450
+ _prepare_local_checkout(workspace, gh, data)
451
+ except Exception as exc:
452
+ log.debug("Local checkout preparation failed: %s", exc)
453
+
454
+ orch = Orchestrator(workspace=workspace)
455
+ try:
456
+ result = orch.execute(inputs=data, gh=gh)
457
+ except Exception as exc:
458
+ log.debug("Execution failed; continuing to write outputs: %s", exc)
459
+
460
+ result = SubmissionResult(
461
+ change_urls=[], change_numbers=[], commit_shas=[]
462
+ )
463
+ if result.change_urls:
464
+ os.environ["GERRIT_CHANGE_REQUEST_URL"] = "\n".join(
465
+ result.change_urls
466
+ )
467
+ # Output Gerrit change URL(s) to console
468
+ for url in result.change_urls:
469
+ typer.echo(f"Gerrit change URL: {url}")
470
+ if result.change_numbers:
471
+ os.environ["GERRIT_CHANGE_REQUEST_NUM"] = "\n".join(
472
+ result.change_numbers
473
+ )
474
+
475
+ # Also write outputs to GITHUB_OUTPUT if available
476
+ _append_github_output(
477
+ {
478
+ "gerrit_change_request_url": os.getenv(
479
+ "GERRIT_CHANGE_REQUEST_URL", ""
480
+ ),
481
+ "gerrit_change_request_num": os.getenv(
482
+ "GERRIT_CHANGE_REQUEST_NUM", ""
483
+ ),
484
+ "gerrit_commit_sha": os.getenv("GERRIT_COMMIT_SHA", ""),
485
+ }
486
+ )
487
+
488
+ log.info("Submission pipeline complete.")
489
+ return
490
+
491
+
492
+ def _prepare_local_checkout(
493
+ workspace: Path, gh: GitHubContext, data: Inputs
494
+ ) -> None:
495
+ repo_full = gh.repository.strip() if gh.repository else ""
496
+ server_url = gh.server_url or os.getenv(
497
+ "GITHUB_SERVER_URL", "https://github.com"
498
+ )
499
+ server_url = (server_url or "https://github.com").rstrip("/")
500
+ base_ref = gh.base_ref or ""
501
+ pr_num_str: str = str(gh.pr_number) if gh.pr_number else "0"
502
+
503
+ if not repo_full:
504
+ return
505
+
506
+ repo_url = f"{server_url}/{repo_full}.git"
507
+ run_cmd(["git", "init"], cwd=workspace)
508
+ run_cmd(["git", "remote", "add", "origin", repo_url], cwd=workspace)
509
+
510
+ # Fetch base branch and PR head
511
+ if base_ref:
512
+ try:
513
+ branch_ref = f"refs/heads/{base_ref}:refs/remotes/origin/{base_ref}"
514
+ run_cmd(
515
+ [
516
+ "git",
517
+ "fetch",
518
+ f"--depth={data.fetch_depth}",
519
+ "origin",
520
+ branch_ref,
521
+ ],
522
+ cwd=workspace,
523
+ )
524
+ except Exception as exc:
525
+ log.debug("Base branch fetch failed for %s: %s", base_ref, exc)
526
+
527
+ if pr_num_str:
528
+ pr_ref = (
529
+ f"refs/pull/{pr_num_str}/head:"
530
+ f"refs/remotes/origin/pr/{pr_num_str}/head"
531
+ )
532
+ run_cmd(
533
+ [
534
+ "git",
535
+ "fetch",
536
+ f"--depth={data.fetch_depth}",
537
+ "origin",
538
+ pr_ref,
539
+ ],
540
+ cwd=workspace,
541
+ )
542
+ run_cmd(
543
+ [
544
+ "git",
545
+ "checkout",
546
+ "-B",
547
+ "g2g_pr_head",
548
+ f"refs/remotes/origin/pr/{pr_num_str}/head",
549
+ ],
550
+ cwd=workspace,
551
+ )
552
+
553
+
554
+ def _load_effective_inputs() -> Inputs:
555
+ # Build inputs from environment (used by URL callback path)
556
+ data = _build_inputs_from_env()
557
+
558
+ # Load per-org configuration and apply to environment before validation
559
+ org_for_cfg = (
560
+ data.organization
561
+ or os.getenv("ORGANIZATION")
562
+ or os.getenv("GITHUB_REPOSITORY_OWNER")
563
+ )
564
+ cfg = load_org_config(org_for_cfg)
565
+ apply_config_to_env(cfg)
566
+
567
+ # Refresh inputs after applying configuration to environment
568
+ data = _build_inputs_from_env()
569
+
570
+ # Derive reviewers from local git config if running locally and unset
571
+ if not os.getenv("REVIEWERS_EMAIL") and (
572
+ os.getenv("G2G_TARGET_URL") or not os.getenv("GITHUB_EVENT_NAME")
573
+ ):
574
+ try:
575
+ from .gitutils import enumerate_reviewer_emails
576
+
577
+ emails = enumerate_reviewer_emails()
578
+ if emails:
579
+ os.environ["REVIEWERS_EMAIL"] = ",".join(emails)
580
+ data = Inputs(
581
+ submit_single_commits=data.submit_single_commits,
582
+ use_pr_as_commit=data.use_pr_as_commit,
583
+ fetch_depth=data.fetch_depth,
584
+ gerrit_known_hosts=data.gerrit_known_hosts,
585
+ gerrit_ssh_privkey_g2g=data.gerrit_ssh_privkey_g2g,
586
+ gerrit_ssh_user_g2g=data.gerrit_ssh_user_g2g,
587
+ gerrit_ssh_user_g2g_email=data.gerrit_ssh_user_g2g_email,
588
+ organization=data.organization,
589
+ reviewers_email=os.environ["REVIEWERS_EMAIL"],
590
+ preserve_github_prs=data.preserve_github_prs,
591
+ dry_run=data.dry_run,
592
+ gerrit_server=data.gerrit_server,
593
+ gerrit_server_port=data.gerrit_server_port,
594
+ gerrit_project=data.gerrit_project,
595
+ issue_id=data.issue_id,
596
+ allow_duplicates=data.allow_duplicates,
597
+ )
598
+ log.info("Derived reviewers: %s", data.reviewers_email)
599
+ except Exception as exc:
600
+ log.debug("Could not derive reviewers from git config: %s", exc)
601
+
602
+ return data
603
+
604
+
605
+ def _append_github_output(outputs: dict[str, str]) -> None:
606
+ gh_out = os.getenv("GITHUB_OUTPUT")
607
+ if not gh_out:
608
+ return
609
+ try:
610
+ with open(gh_out, "a", encoding="utf-8") as fh:
611
+ for key, val in outputs.items():
612
+ if not val:
613
+ continue
614
+ if "\n" in val:
615
+ fh.write(f"{key}<<G2G\n")
616
+ fh.write(f"{val}\n")
617
+ fh.write("G2G\n")
618
+ else:
619
+ fh.write(f"{key}={val}\n")
620
+ except Exception as exc:
621
+ log.debug("Failed to write GITHUB_OUTPUT: %s", exc)
622
+
623
+
624
+ def _augment_pr_refs_if_needed(gh: GitHubContext) -> GitHubContext:
625
+ if (
626
+ os.getenv("G2G_TARGET_URL")
627
+ and gh.pr_number
628
+ and (not gh.head_ref or not gh.base_ref)
629
+ ):
630
+ try:
631
+ client = build_client()
632
+ repo = get_repo_from_env(client)
633
+ pr_obj = get_pull(repo, int(gh.pr_number))
634
+ base_ref = str(
635
+ getattr(getattr(pr_obj, "base", object()), "ref", "") or ""
636
+ )
637
+ head_ref = str(
638
+ getattr(getattr(pr_obj, "head", object()), "ref", "") or ""
639
+ )
640
+ head_sha = str(
641
+ getattr(getattr(pr_obj, "head", object()), "sha", "") or ""
642
+ )
643
+ if base_ref:
644
+ os.environ["GITHUB_BASE_REF"] = base_ref
645
+ log.info("Resolved base_ref via GitHub API: %s", base_ref)
646
+ if head_ref:
647
+ os.environ["GITHUB_HEAD_REF"] = head_ref
648
+ log.info("Resolved head_ref via GitHub API: %s", head_ref)
649
+ if head_sha:
650
+ os.environ["GITHUB_SHA"] = head_sha
651
+ log.info("Resolved head sha via GitHub API: %s", head_sha)
652
+ return _read_github_context()
653
+ except Exception as exc:
654
+ log.debug("Could not resolve PR refs via GitHub API: %s", exc)
655
+ return gh
656
+
657
+
658
+ def _process() -> None:
659
+ data = _load_effective_inputs()
660
+
661
+ # Validate inputs
662
+ try:
663
+ _validate_inputs(data)
664
+ except typer.BadParameter as exc:
665
+ log.exception("Validation failed")
666
+ typer.echo(str(exc), err=True)
667
+ raise typer.Exit(code=2) from exc
668
+
669
+ gh = _read_github_context()
670
+ _log_effective_config(data, gh)
671
+
672
+ # Test mode: short-circuit after validation
673
+ if _env_bool("G2G_TEST_MODE", False):
674
+ log.info("Validation complete. Ready to execute submission pipeline.")
675
+ typer.echo("Validation complete. Ready to execute submission pipeline.")
676
+ return
677
+
678
+ # Bulk mode for URL/workflow_dispatch
679
+ sync_all = _env_bool("SYNC_ALL_OPEN_PRS", False)
680
+ if sync_all and (
681
+ gh.event_name == "workflow_dispatch" or os.getenv("G2G_TARGET_URL")
682
+ ):
683
+ _process_bulk(data, gh)
684
+ return
685
+
686
+ if not gh.pr_number:
687
+ log.error(
688
+ "PR_NUMBER is empty. This tool requires a valid pull request "
689
+ "context. Current event: %s",
690
+ gh.event_name,
691
+ )
692
+ typer.echo(
693
+ "PR_NUMBER is empty. This tool requires a valid pull request "
694
+ f"context. Current event: {gh.event_name}",
695
+ err=True,
696
+ )
697
+ raise typer.Exit(code=2)
698
+
699
+ # Test mode handled earlier
700
+
701
+ # Execute single-PR submission
702
+ # Augment PR refs via API when in URL mode and token present
703
+ gh = _augment_pr_refs_if_needed(gh)
704
+
705
+ # Check for duplicates in single-PR mode (before workspace setup)
706
+ if gh.pr_number and not _env_bool("SYNC_ALL_OPEN_PRS", False):
707
+ try:
708
+ check_for_duplicates(gh, allow_duplicates=data.allow_duplicates)
709
+ except DuplicateChangeError as exc:
710
+ log.exception("Duplicate change detected")
711
+ typer.echo(f"Error: {exc}", err=True)
712
+ typer.echo(
713
+ "Use --allow-duplicates to override this check.", err=True
714
+ )
715
+ raise typer.Exit(code=3) from exc
716
+
717
+ _process_single(data, gh)
718
+ return
719
+
720
+
721
+ def _mask_secret(value: str, keep: int = 4) -> str:
722
+ if not value:
723
+ return ""
724
+ if len(value) <= keep:
725
+ return "*" * len(value)
726
+ return f"{value[:keep]}{'*' * (len(value) - keep)}"
727
+
728
+
729
+ def _load_event(path: Path | None) -> dict[str, Any]:
730
+ if not path or not path.exists():
731
+ return {}
732
+ try:
733
+ return cast(
734
+ dict[str, Any], json.loads(path.read_text(encoding="utf-8"))
735
+ )
736
+ except Exception as exc:
737
+ log.warning("Failed to parse GITHUB_EVENT_PATH: %s", exc)
738
+ return {}
739
+
740
+
741
+ def _extract_pr_number(evt: dict[str, Any]) -> int | None:
742
+ # Try standard pull_request payload
743
+ pr = evt.get("pull_request")
744
+ if isinstance(pr, dict) and isinstance(pr.get("number"), int):
745
+ return int(pr["number"])
746
+
747
+ # Try issues payload (when used on issues events)
748
+ issue = evt.get("issue")
749
+ if isinstance(issue, dict) and isinstance(issue.get("number"), int):
750
+ return int(issue["number"])
751
+
752
+ # Try a direct number field
753
+ if isinstance(evt.get("number"), int):
754
+ return int(evt["number"])
755
+
756
+ return None
757
+
758
+
759
+ def _read_github_context() -> GitHubContext:
760
+ event_name = os.getenv("GITHUB_EVENT_NAME", "")
761
+ event_action = ""
762
+ event_path_str = os.getenv("GITHUB_EVENT_PATH")
763
+ event_path = Path(event_path_str) if event_path_str else None
764
+
765
+ evt = _load_event(event_path)
766
+ if isinstance(evt.get("action"), str):
767
+ event_action = evt["action"]
768
+
769
+ repository = os.getenv("GITHUB_REPOSITORY", "")
770
+ repository_owner = os.getenv("GITHUB_REPOSITORY_OWNER", "")
771
+ server_url = os.getenv("GITHUB_SERVER_URL", "https://github.com")
772
+ run_id = os.getenv("GITHUB_RUN_ID", "")
773
+ sha = os.getenv("GITHUB_SHA", "")
774
+
775
+ base_ref = os.getenv("GITHUB_BASE_REF", "")
776
+ head_ref = os.getenv("GITHUB_HEAD_REF", "")
777
+
778
+ pr_number = _extract_pr_number(evt)
779
+ if pr_number is None:
780
+ env_pr = os.getenv("PR_NUMBER")
781
+ if env_pr and env_pr.isdigit():
782
+ pr_number = int(env_pr)
783
+
784
+ ctx = models.GitHubContext(
785
+ event_name=event_name,
786
+ event_action=event_action,
787
+ event_path=event_path,
788
+ repository=repository,
789
+ repository_owner=repository_owner,
790
+ server_url=server_url,
791
+ run_id=run_id,
792
+ sha=sha,
793
+ base_ref=base_ref,
794
+ head_ref=head_ref,
795
+ pr_number=pr_number,
796
+ )
797
+ return ctx
798
+
799
+
800
+ def _validate_inputs(data: Inputs) -> None:
801
+ if data.use_pr_as_commit and data.submit_single_commits:
802
+ msg = (
803
+ "USE_PR_AS_COMMIT and SUBMIT_SINGLE_COMMITS cannot be enabled at "
804
+ "the same time"
805
+ )
806
+ raise typer.BadParameter(msg)
807
+
808
+ # Presence checks for required fields used by existing action
809
+ for field_name in (
810
+ "gerrit_known_hosts",
811
+ "gerrit_ssh_privkey_g2g",
812
+ "gerrit_ssh_user_g2g",
813
+ "gerrit_ssh_user_g2g_email",
814
+ ):
815
+ if not getattr(data, field_name):
816
+ log.error("Missing required input: %s", field_name)
817
+ raise typer.BadParameter(f"Missing required input: {field_name}") # noqa: TRY003
818
+
819
+ # Validate fetch depth is a positive integer
820
+ if data.fetch_depth <= 0:
821
+ log.error("Invalid FETCH_DEPTH: %s", data.fetch_depth)
822
+ raise typer.BadParameter("FETCH_DEPTH must be a positive integer") # noqa: TRY003
823
+
824
+ # Validate Issue ID is a single line string if provided
825
+ if data.issue_id and ("\n" in data.issue_id or "\r" in data.issue_id):
826
+ raise typer.BadParameter("Issue ID must be single line") # noqa: TRY003
827
+
828
+
829
+ def _log_effective_config(data: Inputs, gh: GitHubContext) -> None:
830
+ # Avoid logging sensitive values
831
+ safe_privkey = _mask_secret(data.gerrit_ssh_privkey_g2g)
832
+ log.info("Effective configuration (sanitized):")
833
+ log.info(" SUBMIT_SINGLE_COMMITS: %s", data.submit_single_commits)
834
+ log.info(" USE_PR_AS_COMMIT: %s", data.use_pr_as_commit)
835
+ log.info(" FETCH_DEPTH: %s", data.fetch_depth)
836
+ log.info(
837
+ " GERRIT_KNOWN_HOSTS: %s",
838
+ "<provided>" if data.gerrit_known_hosts else "<missing>",
839
+ )
840
+ log.info(" GERRIT_SSH_PRIVKEY_G2G: %s", safe_privkey)
841
+ log.info(" GERRIT_SSH_USER_G2G: %s", data.gerrit_ssh_user_g2g)
842
+ log.info(" GERRIT_SSH_USER_G2G_EMAIL: %s", data.gerrit_ssh_user_g2g_email)
843
+ log.info(" ORGANIZATION: %s", data.organization)
844
+ log.info(" REVIEWERS_EMAIL: %s", data.reviewers_email or "")
845
+ log.info(" PRESERVE_GITHUB_PRS: %s", data.preserve_github_prs)
846
+ log.info(" DRY_RUN: %s", data.dry_run)
847
+ log.info(" GERRIT_SERVER: %s", data.gerrit_server or "")
848
+ log.info(" GERRIT_SERVER_PORT: %s", data.gerrit_server_port or "")
849
+ log.info(" GERRIT_PROJECT: %s", data.gerrit_project or "")
850
+ log.info("GitHub context:")
851
+ log.info(" event_name: %s", gh.event_name)
852
+ log.info(" event_action: %s", gh.event_action)
853
+ log.info(" repository: %s", gh.repository)
854
+ log.info(" repository_owner: %s", gh.repository_owner)
855
+ log.info(" pr_number: %s", gh.pr_number)
856
+ log.info(" base_ref: %s", gh.base_ref)
857
+ log.info(" head_ref: %s", gh.head_ref)
858
+ log.info(" sha: %s", gh.sha)
859
+
860
+
861
+ if __name__ == "__main__":
862
+ # Invoke the Typer app when executed as a script.
863
+ # Example:
864
+ # python -m github2gerrit.cli --help
865
+ app()