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
@@ -0,0 +1,655 @@
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
2
|
+
# SPDX-FileCopyrightText: 2025 The Linux Foundation
|
3
|
+
#
|
4
|
+
# Subprocess and git helper utilities with logging and error handling.
|
5
|
+
# - Strict typing
|
6
|
+
# - Centralized logging
|
7
|
+
# - Secret masking in logs
|
8
|
+
# - Optional retries with exponential backoff for transient errors
|
9
|
+
|
10
|
+
from __future__ import annotations
|
11
|
+
|
12
|
+
import logging
|
13
|
+
import os
|
14
|
+
import shlex
|
15
|
+
import subprocess
|
16
|
+
import time
|
17
|
+
from collections.abc import Callable
|
18
|
+
from collections.abc import Iterable
|
19
|
+
from collections.abc import Mapping
|
20
|
+
from collections.abc import Sequence
|
21
|
+
from dataclasses import dataclass
|
22
|
+
from pathlib import Path
|
23
|
+
|
24
|
+
|
25
|
+
__all__ = [
|
26
|
+
"CommandError",
|
27
|
+
"CommandResult",
|
28
|
+
"GitError",
|
29
|
+
"enumerate_reviewer_emails",
|
30
|
+
"git",
|
31
|
+
"git_cherry_pick",
|
32
|
+
"git_commit_amend",
|
33
|
+
"git_commit_new",
|
34
|
+
"git_config",
|
35
|
+
"git_config_get",
|
36
|
+
"git_config_get_all",
|
37
|
+
"git_last_commit_trailers",
|
38
|
+
"git_show",
|
39
|
+
"mask_text",
|
40
|
+
"run_cmd",
|
41
|
+
"run_cmd_with_retries",
|
42
|
+
]
|
43
|
+
|
44
|
+
|
45
|
+
_LOGGER_NAME = "github2gerrit.git"
|
46
|
+
log = logging.getLogger(_LOGGER_NAME)
|
47
|
+
if not log.handlers:
|
48
|
+
# Provide a minimal default if the app has not configured logging.
|
49
|
+
level_name = os.getenv("G2G_LOG_LEVEL", "INFO").upper()
|
50
|
+
level = getattr(logging, level_name, logging.INFO)
|
51
|
+
fmt = (
|
52
|
+
"%(asctime)s %(levelname)-8s %(name)s "
|
53
|
+
"%(filename)s:%(lineno)d | %(message)s"
|
54
|
+
)
|
55
|
+
logging.basicConfig(level=level, format=fmt)
|
56
|
+
|
57
|
+
|
58
|
+
class CommandError(RuntimeError):
|
59
|
+
"""Raised when a subprocess command fails."""
|
60
|
+
|
61
|
+
def __init__(
|
62
|
+
self,
|
63
|
+
message: str,
|
64
|
+
*,
|
65
|
+
cmd: Sequence[str] | None = None,
|
66
|
+
returncode: int | None = None,
|
67
|
+
stdout: str | None = None,
|
68
|
+
stderr: str | None = None,
|
69
|
+
) -> None:
|
70
|
+
super().__init__(message)
|
71
|
+
self.cmd = list(cmd) if cmd is not None else None
|
72
|
+
self.returncode = returncode
|
73
|
+
self.stdout = stdout
|
74
|
+
self.stderr = stderr
|
75
|
+
|
76
|
+
|
77
|
+
class GitError(CommandError):
|
78
|
+
"""Raised when a git command fails."""
|
79
|
+
|
80
|
+
|
81
|
+
@dataclass(frozen=True)
|
82
|
+
class CommandResult:
|
83
|
+
returncode: int
|
84
|
+
stdout: str
|
85
|
+
stderr: str
|
86
|
+
|
87
|
+
|
88
|
+
def _to_str_opt(val: str | bytes | None) -> str | None:
|
89
|
+
"""Convert an optional bytes/str value to str safely."""
|
90
|
+
if val is None:
|
91
|
+
return None
|
92
|
+
if isinstance(val, bytes):
|
93
|
+
return val.decode("utf-8", errors="replace")
|
94
|
+
return val
|
95
|
+
|
96
|
+
|
97
|
+
def mask_text(text: str, masks: Iterable[str]) -> str:
|
98
|
+
"""Replace each mask value in text with asterisks."""
|
99
|
+
masked = text
|
100
|
+
for token in masks:
|
101
|
+
if not token:
|
102
|
+
continue
|
103
|
+
masked = masked.replace(token, "***")
|
104
|
+
return masked
|
105
|
+
|
106
|
+
|
107
|
+
def _format_cmd_for_log(
|
108
|
+
cmd: Sequence[str],
|
109
|
+
masks: Iterable[str],
|
110
|
+
) -> str:
|
111
|
+
quoted = [shlex.quote(x) for x in cmd]
|
112
|
+
line = " ".join(quoted)
|
113
|
+
return mask_text(line, masks)
|
114
|
+
|
115
|
+
|
116
|
+
def _merge_env(
|
117
|
+
base: Mapping[str, str] | None,
|
118
|
+
extra: Mapping[str, str] | None,
|
119
|
+
) -> dict[str, str]:
|
120
|
+
if base is None:
|
121
|
+
out: dict[str, str] = dict(os.environ)
|
122
|
+
else:
|
123
|
+
out = dict(base)
|
124
|
+
if extra:
|
125
|
+
out.update(extra)
|
126
|
+
return out
|
127
|
+
|
128
|
+
|
129
|
+
def _is_transient_git_error(stderr: str) -> bool:
|
130
|
+
"""Heuristics for transient git/network errors suitable for retry."""
|
131
|
+
s = stderr.lower()
|
132
|
+
patterns = [
|
133
|
+
"unable to access",
|
134
|
+
"could not resolve host",
|
135
|
+
"failed to connect",
|
136
|
+
"connection timed out",
|
137
|
+
"connection reset by peer",
|
138
|
+
"early eof",
|
139
|
+
"the remote end hung up unexpectedly",
|
140
|
+
"http/2 stream",
|
141
|
+
"transport endpoint is not connected",
|
142
|
+
"network is unreachable",
|
143
|
+
"temporary failure",
|
144
|
+
"ssl: couldn't",
|
145
|
+
"ssl: certificate",
|
146
|
+
]
|
147
|
+
return any(pat in s for pat in patterns)
|
148
|
+
|
149
|
+
|
150
|
+
def _backoff_delay(attempt: int, base: float = 0.5, cap: float = 5.0) -> float:
|
151
|
+
# Exponential backoff: base * 2^(attempt-1), capped
|
152
|
+
delay: float = float(base * (2 ** max(0, attempt - 1)))
|
153
|
+
return float(min(delay, cap))
|
154
|
+
|
155
|
+
|
156
|
+
def run_cmd(
|
157
|
+
cmd: Sequence[str],
|
158
|
+
*,
|
159
|
+
cwd: Path | None = None,
|
160
|
+
env: Mapping[str, str] | None = None,
|
161
|
+
timeout: float | None = None,
|
162
|
+
check: bool = True,
|
163
|
+
masks: Iterable[str] | None = None,
|
164
|
+
stdin_data: str | None = None,
|
165
|
+
) -> CommandResult:
|
166
|
+
"""Run a subprocess command and capture output.
|
167
|
+
|
168
|
+
- Logs command line with secrets masked.
|
169
|
+
- Returns stdout/stderr and return code.
|
170
|
+
- Raises CommandError on failure when check=True.
|
171
|
+
"""
|
172
|
+
masks = list(masks or [])
|
173
|
+
env_full = _merge_env(None, env)
|
174
|
+
|
175
|
+
log.debug("Executing: %s", _format_cmd_for_log(cmd, masks))
|
176
|
+
try:
|
177
|
+
proc = subprocess.run( # noqa: S603
|
178
|
+
list(cmd),
|
179
|
+
cwd=str(cwd) if cwd else None,
|
180
|
+
env=env_full,
|
181
|
+
input=stdin_data,
|
182
|
+
text=True,
|
183
|
+
shell=False,
|
184
|
+
capture_output=True,
|
185
|
+
timeout=timeout,
|
186
|
+
check=False,
|
187
|
+
)
|
188
|
+
except subprocess.TimeoutExpired as exc:
|
189
|
+
msg = f"Command timed out: {cmd!r}"
|
190
|
+
log.exception(msg)
|
191
|
+
# TimeoutExpired carries 'output' and 'stderr' attributes,
|
192
|
+
# which may be bytes depending on invocation context.
|
193
|
+
out = getattr(exc, "output", None)
|
194
|
+
err = getattr(exc, "stderr", None)
|
195
|
+
raise CommandError(
|
196
|
+
msg,
|
197
|
+
cmd=cmd,
|
198
|
+
returncode=None,
|
199
|
+
stdout=_to_str_opt(out),
|
200
|
+
stderr=_to_str_opt(err),
|
201
|
+
) from exc
|
202
|
+
except OSError as exc:
|
203
|
+
msg = f"Failed to execute command: {cmd!r} ({exc})"
|
204
|
+
log.exception(msg)
|
205
|
+
raise CommandError(msg, cmd=cmd) from exc
|
206
|
+
|
207
|
+
result = CommandResult(
|
208
|
+
returncode=proc.returncode,
|
209
|
+
stdout=proc.stdout or "",
|
210
|
+
stderr=proc.stderr or "",
|
211
|
+
)
|
212
|
+
|
213
|
+
if result.returncode != 0:
|
214
|
+
log.debug(
|
215
|
+
"Command failed (rc=%s): %s\nstdout: %s\nstderr: %s",
|
216
|
+
result.returncode,
|
217
|
+
_format_cmd_for_log(cmd, masks),
|
218
|
+
mask_text(result.stdout, masks),
|
219
|
+
mask_text(result.stderr, masks),
|
220
|
+
)
|
221
|
+
if check:
|
222
|
+
raise CommandError( # noqa: TRY003
|
223
|
+
"Command failed",
|
224
|
+
cmd=cmd,
|
225
|
+
returncode=result.returncode,
|
226
|
+
stdout=result.stdout,
|
227
|
+
stderr=result.stderr,
|
228
|
+
)
|
229
|
+
else:
|
230
|
+
if result.stdout:
|
231
|
+
log.debug("stdout: %s", mask_text(result.stdout, masks))
|
232
|
+
if result.stderr:
|
233
|
+
log.debug("stderr: %s", mask_text(result.stderr, masks))
|
234
|
+
|
235
|
+
return result
|
236
|
+
|
237
|
+
|
238
|
+
def run_cmd_with_retries(
|
239
|
+
cmd: Sequence[str],
|
240
|
+
*,
|
241
|
+
cwd: Path | None = None,
|
242
|
+
env: Mapping[str, str] | None = None,
|
243
|
+
timeout: float | None = None,
|
244
|
+
check: bool = True,
|
245
|
+
masks: Iterable[str] | None = None,
|
246
|
+
stdin_data: str | None = None,
|
247
|
+
retries: int = 2,
|
248
|
+
retry_on: Callable[[CommandResult], bool] | None = None,
|
249
|
+
) -> CommandResult:
|
250
|
+
"""Run a command with basic exponential backoff retries on transient errors.
|
251
|
+
|
252
|
+
The default retry predicate considers common transient git errors.
|
253
|
+
"""
|
254
|
+
masks = list(masks or [])
|
255
|
+
|
256
|
+
def _default_retry_on(res: CommandResult) -> bool:
|
257
|
+
return res.returncode != 0 and _is_transient_git_error(res.stderr)
|
258
|
+
|
259
|
+
predicate = retry_on or _default_retry_on
|
260
|
+
attempt = 0
|
261
|
+
# removed unused variable 'last_res'
|
262
|
+
|
263
|
+
while True:
|
264
|
+
attempt += 1
|
265
|
+
try:
|
266
|
+
res = run_cmd(
|
267
|
+
cmd,
|
268
|
+
cwd=cwd,
|
269
|
+
env=env,
|
270
|
+
timeout=timeout,
|
271
|
+
check=False,
|
272
|
+
masks=masks,
|
273
|
+
stdin_data=stdin_data,
|
274
|
+
)
|
275
|
+
except CommandError: # noqa: TRY203
|
276
|
+
# Non-exec or timeout errors are not retried here.
|
277
|
+
raise
|
278
|
+
|
279
|
+
if res.returncode == 0 or attempt > (retries + 1):
|
280
|
+
if check and res.returncode != 0:
|
281
|
+
raise CommandError( # noqa: TRY003
|
282
|
+
"Command failed after retries",
|
283
|
+
cmd=cmd,
|
284
|
+
returncode=res.returncode,
|
285
|
+
stdout=res.stdout,
|
286
|
+
stderr=res.stderr,
|
287
|
+
)
|
288
|
+
return res
|
289
|
+
|
290
|
+
if predicate(res):
|
291
|
+
delay = _backoff_delay(attempt)
|
292
|
+
log.warning(
|
293
|
+
"Retrying (attempt %d) after transient error; delay %.1fs. "
|
294
|
+
"cmd=%s",
|
295
|
+
attempt,
|
296
|
+
delay,
|
297
|
+
_format_cmd_for_log(cmd, masks),
|
298
|
+
)
|
299
|
+
time.sleep(delay)
|
300
|
+
continue
|
301
|
+
|
302
|
+
# Non-transient failure; stop.
|
303
|
+
if check:
|
304
|
+
raise CommandError( # noqa: TRY003
|
305
|
+
"Command failed (non-retryable)",
|
306
|
+
cmd=cmd,
|
307
|
+
returncode=res.returncode,
|
308
|
+
stdout=res.stdout,
|
309
|
+
stderr=res.stderr,
|
310
|
+
)
|
311
|
+
return res
|
312
|
+
|
313
|
+
|
314
|
+
# ----------------------------
|
315
|
+
# Git helper functions
|
316
|
+
# ----------------------------
|
317
|
+
|
318
|
+
|
319
|
+
def git(
|
320
|
+
args: Sequence[str],
|
321
|
+
*,
|
322
|
+
cwd: Path | None = None,
|
323
|
+
env: Mapping[str, str] | None = None,
|
324
|
+
timeout: float | None = None,
|
325
|
+
check: bool = True,
|
326
|
+
masks: Iterable[str] | None = None,
|
327
|
+
retries: int = 2,
|
328
|
+
) -> CommandResult:
|
329
|
+
"""Run a git subcommand with retries on transient errors."""
|
330
|
+
cmd = ["git", *args]
|
331
|
+
return run_cmd_with_retries(
|
332
|
+
cmd,
|
333
|
+
cwd=cwd,
|
334
|
+
env=env,
|
335
|
+
timeout=timeout,
|
336
|
+
check=check,
|
337
|
+
masks=list(masks or []),
|
338
|
+
retries=retries,
|
339
|
+
)
|
340
|
+
|
341
|
+
|
342
|
+
def git_quiet(
|
343
|
+
args: Sequence[str],
|
344
|
+
*,
|
345
|
+
cwd: Path | None = None,
|
346
|
+
env: Mapping[str, str] | None = None,
|
347
|
+
timeout: float | None = None,
|
348
|
+
) -> CommandResult:
|
349
|
+
"""Run a git subcommand quietly (no failure logging for expected)."""
|
350
|
+
cmd = ["git", *args]
|
351
|
+
try:
|
352
|
+
return run_cmd(
|
353
|
+
cmd,
|
354
|
+
cwd=cwd,
|
355
|
+
env=env,
|
356
|
+
timeout=timeout,
|
357
|
+
check=False,
|
358
|
+
)
|
359
|
+
except Exception:
|
360
|
+
return CommandResult(returncode=1, stdout="", stderr="")
|
361
|
+
|
362
|
+
|
363
|
+
def git_config(
|
364
|
+
key: str,
|
365
|
+
value: str,
|
366
|
+
*,
|
367
|
+
global_: bool = False,
|
368
|
+
cwd: Path | None = None,
|
369
|
+
) -> None:
|
370
|
+
args = ["config"]
|
371
|
+
if global_:
|
372
|
+
args.append("--global")
|
373
|
+
args.extend([key, value])
|
374
|
+
try:
|
375
|
+
git(args, cwd=cwd)
|
376
|
+
except CommandError as exc:
|
377
|
+
# If we got an error about multiple values, try with --replace-all
|
378
|
+
if exc.returncode == 5 and "multiple values" in (exc.stderr or ""):
|
379
|
+
args = ["config"]
|
380
|
+
if global_:
|
381
|
+
args.append("--global")
|
382
|
+
args.append("--replace-all")
|
383
|
+
args.extend([key, value])
|
384
|
+
try:
|
385
|
+
git(args, cwd=cwd)
|
386
|
+
except CommandError:
|
387
|
+
# If replace-all also fails, raise the original error
|
388
|
+
raise GitError( # noqa: TRY003
|
389
|
+
f"git config failed for {key}",
|
390
|
+
cmd=exc.cmd,
|
391
|
+
returncode=exc.returncode,
|
392
|
+
stdout=exc.stdout,
|
393
|
+
stderr=exc.stderr,
|
394
|
+
) from exc
|
395
|
+
else:
|
396
|
+
raise GitError( # noqa: TRY003
|
397
|
+
f"git config failed for {key}",
|
398
|
+
cmd=exc.cmd,
|
399
|
+
returncode=exc.returncode,
|
400
|
+
stdout=exc.stdout,
|
401
|
+
stderr=exc.stderr,
|
402
|
+
) from exc
|
403
|
+
|
404
|
+
|
405
|
+
def git_cherry_pick(
|
406
|
+
commit: str,
|
407
|
+
*,
|
408
|
+
cwd: Path | None = None,
|
409
|
+
strategy_opts: Sequence[str] | None = None,
|
410
|
+
) -> None:
|
411
|
+
args: list[str] = ["cherry-pick"]
|
412
|
+
if strategy_opts:
|
413
|
+
args.extend(strategy_opts)
|
414
|
+
args.append(commit)
|
415
|
+
try:
|
416
|
+
git(args, cwd=cwd)
|
417
|
+
except CommandError as exc:
|
418
|
+
raise GitError( # noqa: TRY003
|
419
|
+
f"git cherry-pick {commit} failed",
|
420
|
+
cmd=exc.cmd,
|
421
|
+
returncode=exc.returncode,
|
422
|
+
stdout=exc.stdout,
|
423
|
+
stderr=exc.stderr,
|
424
|
+
) from exc
|
425
|
+
|
426
|
+
|
427
|
+
def git_commit_amend(
|
428
|
+
*,
|
429
|
+
cwd: Path | None = None,
|
430
|
+
no_edit: bool = True,
|
431
|
+
signoff: bool = True,
|
432
|
+
author: str | None = None,
|
433
|
+
message: str | None = None,
|
434
|
+
message_file: Path | None = None,
|
435
|
+
) -> None:
|
436
|
+
"""Amend the current commit.
|
437
|
+
|
438
|
+
If message is provided, it takes precedence over message_file.
|
439
|
+
"""
|
440
|
+
args: list[str] = ["commit", "--amend"]
|
441
|
+
if no_edit and not message and not message_file:
|
442
|
+
args.append("--no-edit")
|
443
|
+
if signoff:
|
444
|
+
args.append("-s")
|
445
|
+
if author:
|
446
|
+
args.extend(["--author", author])
|
447
|
+
if message:
|
448
|
+
args.extend(["-m", message])
|
449
|
+
elif message_file:
|
450
|
+
args.extend(["-F", str(message_file)])
|
451
|
+
|
452
|
+
try:
|
453
|
+
git(args, cwd=cwd)
|
454
|
+
except CommandError as exc:
|
455
|
+
raise GitError( # noqa: TRY003
|
456
|
+
"git commit --amend failed",
|
457
|
+
cmd=exc.cmd,
|
458
|
+
returncode=exc.returncode,
|
459
|
+
stdout=exc.stdout,
|
460
|
+
stderr=exc.stderr,
|
461
|
+
) from exc
|
462
|
+
|
463
|
+
|
464
|
+
def git_commit_new(
|
465
|
+
*,
|
466
|
+
cwd: Path | None = None,
|
467
|
+
message: str | None = None,
|
468
|
+
message_file: Path | None = None,
|
469
|
+
signoff: bool = True,
|
470
|
+
author: str | None = None,
|
471
|
+
allow_empty: bool = False,
|
472
|
+
) -> None:
|
473
|
+
"""Create a new commit using message or message_file."""
|
474
|
+
if not message and not message_file:
|
475
|
+
raise ValueError("Either message or message_file must be provided") # noqa: TRY003
|
476
|
+
|
477
|
+
args: list[str] = ["commit"]
|
478
|
+
if signoff:
|
479
|
+
args.append("-s")
|
480
|
+
if author:
|
481
|
+
args.extend(["--author", author])
|
482
|
+
if allow_empty:
|
483
|
+
args.append("--allow-empty")
|
484
|
+
|
485
|
+
if message:
|
486
|
+
args.extend(["-m", message])
|
487
|
+
else:
|
488
|
+
args.extend(["-F", str(message_file)])
|
489
|
+
|
490
|
+
try:
|
491
|
+
git(args, cwd=cwd)
|
492
|
+
except CommandError as exc:
|
493
|
+
raise GitError( # noqa: TRY003
|
494
|
+
"git commit failed",
|
495
|
+
cmd=exc.cmd,
|
496
|
+
returncode=exc.returncode,
|
497
|
+
stdout=exc.stdout,
|
498
|
+
stderr=exc.stderr,
|
499
|
+
) from exc
|
500
|
+
|
501
|
+
|
502
|
+
def git_show(
|
503
|
+
rev: str,
|
504
|
+
*,
|
505
|
+
cwd: Path | None = None,
|
506
|
+
fmt: str | None = None,
|
507
|
+
) -> str:
|
508
|
+
"""Show a commit content or its formatted output."""
|
509
|
+
args: list[str] = ["show", rev]
|
510
|
+
if fmt:
|
511
|
+
args.extend([f"--format={fmt}", "-s"])
|
512
|
+
try:
|
513
|
+
res = git(args, cwd=cwd)
|
514
|
+
except CommandError as exc:
|
515
|
+
raise GitError( # noqa: TRY003
|
516
|
+
f"git show {rev} failed",
|
517
|
+
cmd=exc.cmd,
|
518
|
+
returncode=exc.returncode,
|
519
|
+
stdout=exc.stdout,
|
520
|
+
stderr=exc.stderr,
|
521
|
+
) from exc
|
522
|
+
else:
|
523
|
+
return res.stdout
|
524
|
+
|
525
|
+
|
526
|
+
def _parse_trailers(text: str) -> dict[str, list[str]]:
|
527
|
+
"""Parse trailers from a commit message body.
|
528
|
+
|
529
|
+
Expects lines like 'Key: Value'. Multiple values per key are supported.
|
530
|
+
"""
|
531
|
+
trailers: dict[str, list[str]] = {}
|
532
|
+
for raw in text.splitlines():
|
533
|
+
line = raw.strip()
|
534
|
+
if not line or ":" not in line:
|
535
|
+
continue
|
536
|
+
key, val = line.split(":", 1)
|
537
|
+
k = key.strip()
|
538
|
+
v = val.strip()
|
539
|
+
if not k or not v:
|
540
|
+
continue
|
541
|
+
trailers.setdefault(k, []).append(v)
|
542
|
+
return trailers
|
543
|
+
|
544
|
+
|
545
|
+
def git_last_commit_trailers(
|
546
|
+
keys: Sequence[str] | None = None,
|
547
|
+
*,
|
548
|
+
cwd: Path | None = None,
|
549
|
+
) -> dict[str, list[str]]:
|
550
|
+
"""Return trailers for the last commit, optionally filtered by keys."""
|
551
|
+
try:
|
552
|
+
# Use pretty format to print only body for robust parsing.
|
553
|
+
body = git_show("HEAD", cwd=cwd, fmt="%B")
|
554
|
+
# Trailers are usually at the end, but we parse all lines.
|
555
|
+
trailers = _parse_trailers(body)
|
556
|
+
if keys is None:
|
557
|
+
return trailers
|
558
|
+
subset: dict[str, list[str]] = {}
|
559
|
+
for k in keys:
|
560
|
+
if k in trailers:
|
561
|
+
subset[k] = trailers[k]
|
562
|
+
except GitError:
|
563
|
+
# If HEAD is unavailable (fresh repo), return empty.
|
564
|
+
return {}
|
565
|
+
else:
|
566
|
+
return subset
|
567
|
+
|
568
|
+
|
569
|
+
def git_config_get(
|
570
|
+
key: str,
|
571
|
+
*,
|
572
|
+
global_: bool = False,
|
573
|
+
) -> str | None:
|
574
|
+
"""Get a git config value (single) from local or global config."""
|
575
|
+
args = ["config"]
|
576
|
+
if global_:
|
577
|
+
args.append("--global")
|
578
|
+
args.extend(["--get", key])
|
579
|
+
try:
|
580
|
+
res = git_quiet(args, cwd=None)
|
581
|
+
if res.returncode == 0:
|
582
|
+
value = res.stdout.strip()
|
583
|
+
return value if value else None
|
584
|
+
else:
|
585
|
+
return None
|
586
|
+
except Exception:
|
587
|
+
return None
|
588
|
+
|
589
|
+
|
590
|
+
def git_config_get_all(
|
591
|
+
key: str,
|
592
|
+
*,
|
593
|
+
global_: bool = False,
|
594
|
+
) -> list[str]:
|
595
|
+
"""Get all git config values for a key (may return multiple lines)."""
|
596
|
+
args = ["config"]
|
597
|
+
if global_:
|
598
|
+
args.append("--global")
|
599
|
+
args.extend(["--get-all", key])
|
600
|
+
try:
|
601
|
+
res = git_quiet(args, cwd=None)
|
602
|
+
if res.returncode == 0:
|
603
|
+
values = [
|
604
|
+
ln.strip() for ln in res.stdout.splitlines() if ln.strip()
|
605
|
+
]
|
606
|
+
return values
|
607
|
+
else:
|
608
|
+
return []
|
609
|
+
except Exception:
|
610
|
+
return []
|
611
|
+
|
612
|
+
|
613
|
+
def enumerate_reviewer_emails() -> list[str]:
|
614
|
+
"""Return reviewer emails from local/global git config.
|
615
|
+
|
616
|
+
Sources checked in order:
|
617
|
+
- git config --get-all github2gerrit.reviewersEmail
|
618
|
+
- git config --get-all g2g.reviewersEmail
|
619
|
+
- git config --get-all reviewers.email
|
620
|
+
(all may be comma-separated; values are split on commas)
|
621
|
+
- git config user.email (local then global) as a fallback
|
622
|
+
|
623
|
+
Returns:
|
624
|
+
A de-duplicated list of emails (order preserved).
|
625
|
+
"""
|
626
|
+
emails: list[str] = []
|
627
|
+
|
628
|
+
def _add_email(e: str) -> None:
|
629
|
+
v = e.strip()
|
630
|
+
if v and v not in emails:
|
631
|
+
emails.append(v)
|
632
|
+
|
633
|
+
# Candidate keys that may hold reviewer emails
|
634
|
+
candidate_keys = [
|
635
|
+
"github2gerrit.reviewersEmail",
|
636
|
+
"g2g.reviewersEmail",
|
637
|
+
"reviewers.email",
|
638
|
+
]
|
639
|
+
|
640
|
+
for key in candidate_keys:
|
641
|
+
vals = git_config_get_all(key) + git_config_get_all(key, global_=True)
|
642
|
+
for v in vals:
|
643
|
+
# Support comma-separated lists within individual values
|
644
|
+
for part in v.split(","):
|
645
|
+
_add_email(part)
|
646
|
+
|
647
|
+
# Fallback to user.email (local then global)
|
648
|
+
user_email = git_config_get("user.email")
|
649
|
+
if user_email:
|
650
|
+
_add_email(user_email)
|
651
|
+
ue_g = git_config_get("user.email", global_=True)
|
652
|
+
if ue_g:
|
653
|
+
_add_email(ue_g)
|
654
|
+
|
655
|
+
return emails
|