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,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