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/config.py
ADDED
@@ -0,0 +1,311 @@
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
2
|
+
# SPDX-FileCopyrightText: 2025 The Linux Foundation
|
3
|
+
#
|
4
|
+
# Configuration loader for github2gerrit.
|
5
|
+
#
|
6
|
+
# This module provides a simple INI-based configuration system that lets
|
7
|
+
# you define per-organization settings in a file such as:
|
8
|
+
#
|
9
|
+
# ~/.config/github2gerrit/configuration.txt
|
10
|
+
#
|
11
|
+
# Example:
|
12
|
+
#
|
13
|
+
# [default]
|
14
|
+
# GERRIT_SERVER = "gerrit.example.org"
|
15
|
+
# GERRIT_SERVER_PORT = "29418"
|
16
|
+
#
|
17
|
+
# [onap]
|
18
|
+
# GERRIT_HTTP_USER = "modesevenindustrialsolutions"
|
19
|
+
# GERRIT_HTTP_PASSWORD = "my_gerrit_token"
|
20
|
+
# GERRIT_PROJECT = "integration/test-repo"
|
21
|
+
# REVIEWERS_EMAIL = "a@example.org,b@example.org"
|
22
|
+
# PRESERVE_GITHUB_PRS = "true"
|
23
|
+
#
|
24
|
+
# Values are returned as strings with surrounding quotes stripped.
|
25
|
+
# Boolean-like values are normalized to "true"/"false" strings.
|
26
|
+
# You can reference environment variables using ${ENV:VAR_NAME}.
|
27
|
+
#
|
28
|
+
# Precedence model (recommended):
|
29
|
+
# - CLI flags (highest)
|
30
|
+
# - Environment variables
|
31
|
+
# - Config file values (loaded by this module)
|
32
|
+
# - Tool defaults (lowest)
|
33
|
+
#
|
34
|
+
# Callers can:
|
35
|
+
# - load_org_config() to retrieve a dict of key->value (strings)
|
36
|
+
# - apply_config_to_env() to export values to process environment for
|
37
|
+
# any keys not already set by the environment/runner
|
38
|
+
#
|
39
|
+
# Notes:
|
40
|
+
# - Section names are matched case-insensitively.
|
41
|
+
# - If no organization is provided, we try ORGANIZATION, then
|
42
|
+
# GITHUB_REPOSITORY_OWNER from the environment.
|
43
|
+
# - A [default] section can provide baseline values for all orgs.
|
44
|
+
# - Unknown keys are preserved (uppercased) to keep this future-proof.
|
45
|
+
|
46
|
+
from __future__ import annotations
|
47
|
+
|
48
|
+
import configparser
|
49
|
+
import logging
|
50
|
+
import os
|
51
|
+
import re
|
52
|
+
from pathlib import Path
|
53
|
+
from typing import Any
|
54
|
+
from typing import cast
|
55
|
+
|
56
|
+
|
57
|
+
log = logging.getLogger("github2gerrit.config")
|
58
|
+
|
59
|
+
DEFAULT_CONFIG_PATH = "~/.config/github2gerrit/configuration.txt"
|
60
|
+
|
61
|
+
# Recognized keys. Unknown keys are still passed through, but these are
|
62
|
+
# the most relevant to current tooling and action inputs/options.
|
63
|
+
KNOWN_KEYS: set[str] = {
|
64
|
+
# Action inputs
|
65
|
+
"SUBMIT_SINGLE_COMMITS",
|
66
|
+
"USE_PR_AS_COMMIT",
|
67
|
+
"FETCH_DEPTH",
|
68
|
+
"GERRIT_KNOWN_HOSTS",
|
69
|
+
"GERRIT_SSH_PRIVKEY_G2G",
|
70
|
+
"GERRIT_SSH_USER_G2G",
|
71
|
+
"GERRIT_SSH_USER_G2G_EMAIL",
|
72
|
+
"ORGANIZATION",
|
73
|
+
"REVIEWERS_EMAIL",
|
74
|
+
"PR_NUMBER",
|
75
|
+
"SYNC_ALL_OPEN_PRS",
|
76
|
+
"PRESERVE_GITHUB_PRS",
|
77
|
+
# Optional inputs (reusable workflow compatibility)
|
78
|
+
"GERRIT_SERVER",
|
79
|
+
"GERRIT_SERVER_PORT",
|
80
|
+
"GERRIT_HTTP_BASE_PATH",
|
81
|
+
"GERRIT_PROJECT",
|
82
|
+
# Gerrit REST auth
|
83
|
+
"GERRIT_HTTP_USER",
|
84
|
+
"GERRIT_HTTP_PASSWORD",
|
85
|
+
}
|
86
|
+
|
87
|
+
_ENV_REF = re.compile(r"\$\{ENV:([A-Za-z_][A-Za-z0-9_]*)\}")
|
88
|
+
|
89
|
+
|
90
|
+
def _expand_env_refs(value: str) -> str:
|
91
|
+
"""Expand ${ENV:VAR} references using current environment."""
|
92
|
+
|
93
|
+
def repl(match: re.Match[str]) -> str:
|
94
|
+
var = match.group(1)
|
95
|
+
return os.getenv(var, "") or ""
|
96
|
+
|
97
|
+
return _ENV_REF.sub(repl, value)
|
98
|
+
|
99
|
+
|
100
|
+
def _strip_quotes(value: str) -> str:
|
101
|
+
v = value.strip()
|
102
|
+
if len(v) >= 2 and ((v[0] == v[-1] == '"') or (v[0] == v[-1] == "'")):
|
103
|
+
return v[1:-1]
|
104
|
+
return v
|
105
|
+
|
106
|
+
|
107
|
+
def _normalize_bool_like(value: str) -> str | None:
|
108
|
+
"""Return 'true'/'false' for boolean-like values, else None."""
|
109
|
+
s = value.strip().lower()
|
110
|
+
if s in {"1", "true", "yes", "on"}:
|
111
|
+
return "true"
|
112
|
+
if s in {"0", "false", "no", "off"}:
|
113
|
+
return "false"
|
114
|
+
return None
|
115
|
+
|
116
|
+
|
117
|
+
def _coerce_value(raw: str) -> str:
|
118
|
+
"""Coerce a raw string to normalized representation."""
|
119
|
+
expanded = _expand_env_refs(raw)
|
120
|
+
unquoted = _strip_quotes(expanded)
|
121
|
+
# Normalize escaped newline sequences into real newlines so that values
|
122
|
+
# like SSH keys or known_hosts entries can be specified inline using
|
123
|
+
# '\n' or '\r\n' in configuration files.
|
124
|
+
normalized_newlines = (
|
125
|
+
unquoted.replace("\\r\\n", "\n")
|
126
|
+
.replace("\\n", "\n")
|
127
|
+
.replace("\r\n", "\n")
|
128
|
+
)
|
129
|
+
b = _normalize_bool_like(normalized_newlines)
|
130
|
+
return b if b is not None else normalized_newlines
|
131
|
+
|
132
|
+
|
133
|
+
def _select_section(
|
134
|
+
cp: configparser.RawConfigParser,
|
135
|
+
org: str,
|
136
|
+
) -> str | None:
|
137
|
+
"""Find a section name case-insensitively."""
|
138
|
+
target = org.strip().lower()
|
139
|
+
for sec in cp.sections():
|
140
|
+
if sec.strip().lower() == target:
|
141
|
+
return sec
|
142
|
+
return None
|
143
|
+
|
144
|
+
|
145
|
+
def _load_ini(path: Path) -> configparser.RawConfigParser:
|
146
|
+
cp = configparser.RawConfigParser()
|
147
|
+
# Preserve option case; mypy requires a cast for attribute assignment
|
148
|
+
cast(Any, cp).optionxform = str
|
149
|
+
try:
|
150
|
+
with path.open("r", encoding="utf-8") as fh:
|
151
|
+
raw_text = fh.read()
|
152
|
+
# Pre-process simple multi-line quoted values of the form:
|
153
|
+
# key = "
|
154
|
+
# line1
|
155
|
+
# line2
|
156
|
+
# "
|
157
|
+
# We collapse these into a single line with '\n' escapes so that
|
158
|
+
# configparser can ingest them reliably; later, _coerce_value()
|
159
|
+
# converts the escapes back to real newlines.
|
160
|
+
lines = raw_text.splitlines()
|
161
|
+
out_lines: list[str] = []
|
162
|
+
i = 0
|
163
|
+
while i < len(lines):
|
164
|
+
line = lines[i]
|
165
|
+
eq_idx = line.find("=")
|
166
|
+
if eq_idx != -1:
|
167
|
+
left = line[: eq_idx + 1]
|
168
|
+
rhs = line[eq_idx + 1 :].strip()
|
169
|
+
if rhs == '"':
|
170
|
+
i += 1
|
171
|
+
block: list[str] = []
|
172
|
+
# Collect until a line with only a closing quote
|
173
|
+
# (ignoring spaces)
|
174
|
+
while i < len(lines) and lines[i].strip() != '"':
|
175
|
+
block.append(lines[i])
|
176
|
+
i += 1
|
177
|
+
if i < len(lines) and lines[i].strip() == '"':
|
178
|
+
joined = "\\n".join(block)
|
179
|
+
out_lines.append(f'{left} "{joined}"')
|
180
|
+
i += 1
|
181
|
+
continue
|
182
|
+
else:
|
183
|
+
# No closing quote found; fall through
|
184
|
+
# and keep original line
|
185
|
+
out_lines.append(line)
|
186
|
+
continue
|
187
|
+
out_lines.append(line)
|
188
|
+
i += 1
|
189
|
+
preprocessed = "\n".join(out_lines) + ("\n" if out_lines else "")
|
190
|
+
cp.read_string(preprocessed)
|
191
|
+
except FileNotFoundError:
|
192
|
+
log.debug("Config file not found: %s", path)
|
193
|
+
except Exception as exc:
|
194
|
+
log.warning("Failed to read config file %s: %s", path, exc)
|
195
|
+
return cp
|
196
|
+
|
197
|
+
|
198
|
+
def _detect_org() -> str | None:
|
199
|
+
# Prefer explicit ORGANIZATION, then GitHub default env var
|
200
|
+
org = os.getenv("ORGANIZATION", "").strip()
|
201
|
+
if org:
|
202
|
+
return org
|
203
|
+
owner = os.getenv("GITHUB_REPOSITORY_OWNER", "").strip()
|
204
|
+
return owner or None
|
205
|
+
|
206
|
+
|
207
|
+
def _merge_dicts(
|
208
|
+
base: dict[str, str],
|
209
|
+
override: dict[str, str],
|
210
|
+
) -> dict[str, str]:
|
211
|
+
out = dict(base)
|
212
|
+
out.update(override)
|
213
|
+
return out
|
214
|
+
|
215
|
+
|
216
|
+
def _normalize_keys(d: dict[str, str]) -> dict[str, str]:
|
217
|
+
return {k.strip().upper(): v for k, v in d.items() if k.strip()}
|
218
|
+
|
219
|
+
|
220
|
+
def load_org_config(
|
221
|
+
org: str | None = None,
|
222
|
+
path: str | Path | None = None,
|
223
|
+
) -> dict[str, str]:
|
224
|
+
"""Load configuration for a GitHub organization.
|
225
|
+
|
226
|
+
Args:
|
227
|
+
org:
|
228
|
+
Name of the GitHub org (stanza). If not provided, inferred from
|
229
|
+
ORGANIZATION or GITHUB_REPOSITORY_OWNER environment variables.
|
230
|
+
path:
|
231
|
+
Path to the INI file. If not provided, uses:
|
232
|
+
~/.config/github2gerrit/configuration.txt
|
233
|
+
If G2G_CONFIG_PATH is set, it takes precedence.
|
234
|
+
|
235
|
+
Returns:
|
236
|
+
A dict mapping KEY -> value (strings). Unknown keys are preserved,
|
237
|
+
known boolean-like values are normalized to 'true'/'false', quotes
|
238
|
+
are stripped, and ${ENV:VAR} are expanded.
|
239
|
+
"""
|
240
|
+
if path is None:
|
241
|
+
path = os.getenv("G2G_CONFIG_PATH", "").strip() or DEFAULT_CONFIG_PATH
|
242
|
+
cfg_path = Path(path).expanduser()
|
243
|
+
|
244
|
+
cp = _load_ini(cfg_path)
|
245
|
+
effective_org = org or _detect_org()
|
246
|
+
result: dict[str, str] = {}
|
247
|
+
|
248
|
+
# Start with [default]
|
249
|
+
if cp.has_section("default"):
|
250
|
+
for k, v in cp.items("default"):
|
251
|
+
result[k.strip().upper()] = _coerce_value(v)
|
252
|
+
|
253
|
+
# Overlay with [org] if present
|
254
|
+
if effective_org:
|
255
|
+
chosen = _select_section(cp, effective_org)
|
256
|
+
if chosen:
|
257
|
+
for k, v in cp.items(chosen):
|
258
|
+
result[k.strip().upper()] = _coerce_value(v)
|
259
|
+
else:
|
260
|
+
log.debug(
|
261
|
+
"Org section '%s' not found in %s",
|
262
|
+
effective_org,
|
263
|
+
cfg_path,
|
264
|
+
)
|
265
|
+
|
266
|
+
normalized = _normalize_keys(result)
|
267
|
+
return normalized
|
268
|
+
|
269
|
+
|
270
|
+
def apply_config_to_env(cfg: dict[str, str]) -> None:
|
271
|
+
"""Set environment variables for any keys not already set.
|
272
|
+
|
273
|
+
This is useful to make configuration values visible to downstream
|
274
|
+
code that reads via os.environ, while still letting explicit env
|
275
|
+
or CLI flags take precedence.
|
276
|
+
|
277
|
+
We only set keys that are not already present in the environment.
|
278
|
+
"""
|
279
|
+
for k, v in cfg.items():
|
280
|
+
if (os.getenv(k) or "").strip() == "":
|
281
|
+
os.environ[k] = v
|
282
|
+
|
283
|
+
|
284
|
+
def filter_known(
|
285
|
+
cfg: dict[str, str],
|
286
|
+
include_extra: bool = True,
|
287
|
+
) -> dict[str, str]:
|
288
|
+
"""Return a filtered view of cfg.
|
289
|
+
|
290
|
+
If include_extra is False, only keys from KNOWN_KEYS are included.
|
291
|
+
If True (default), all keys are included.
|
292
|
+
"""
|
293
|
+
if include_extra:
|
294
|
+
return dict(cfg)
|
295
|
+
return {k: v for k, v in cfg.items() if k in KNOWN_KEYS}
|
296
|
+
|
297
|
+
|
298
|
+
def overlay_missing(
|
299
|
+
primary: dict[str, str],
|
300
|
+
fallback: dict[str, str],
|
301
|
+
) -> dict[str, str]:
|
302
|
+
"""Merge fallback into primary for any missing keys.
|
303
|
+
|
304
|
+
This is a helper when composing precedence:
|
305
|
+
merged = overlay_missing(env_view, config_view)
|
306
|
+
"""
|
307
|
+
merged = dict(primary)
|
308
|
+
for k, v in fallback.items():
|
309
|
+
if k not in merged or merged[k] == "":
|
310
|
+
merged[k] = v
|
311
|
+
return merged
|