github2gerrit 0.1.0__py3-none-any.whl → 0.1.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- github2gerrit/__init__.py +29 -0
- github2gerrit/cli.py +865 -0
- github2gerrit/config.py +311 -0
- github2gerrit/core.py +1750 -0
- github2gerrit/duplicate_detection.py +542 -0
- github2gerrit/github_api.py +331 -0
- github2gerrit/gitutils.py +655 -0
- github2gerrit/models.py +81 -0
- {github2gerrit-0.1.0.dist-info → github2gerrit-0.1.3.dist-info}/METADATA +5 -4
- github2gerrit-0.1.3.dist-info/RECORD +12 -0
- github2gerrit-0.1.0.dist-info/RECORD +0 -4
- {github2gerrit-0.1.0.dist-info → github2gerrit-0.1.3.dist-info}/WHEEL +0 -0
- {github2gerrit-0.1.0.dist-info → github2gerrit-0.1.3.dist-info}/entry_points.txt +0 -0
github2gerrit/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()
|