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.
@@ -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