github2gerrit 2.1.0__py3-none-any.whl → 2.2.0__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,296 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 The Linux Foundation
3
+ """Additional sources of approval authority.
4
+
5
+ The fork approval gate trusts a reviewer on ``author_association`` (see
6
+ :mod:`github2gerrit.trust`). On a Gerrit mirror the people who review
7
+ the code live in Gerrit, and nothing guarantees they appear on GitHub
8
+ with any standing at all. Where that happens no review can ever clear
9
+ the gate, and the feature is unusable for that project.
10
+
11
+ This module supplies two opt-in ways to name approvers directly. Both
12
+ default to off: they **widen** the approver set, and widening a trust
13
+ decision is not something to do silently.
14
+
15
+ Everything else about the gate still applies to an approval admitted
16
+ here. It must be an approving review bound to the current head commit,
17
+ a trusted ``CHANGES_REQUESTED`` still blocks, and the pull request
18
+ author is still excluded whatever list names them. That last point
19
+ matters most \u2014 it is the one guarantee GitHub enforces structurally,
20
+ and no widening may cost us it.
21
+
22
+ Where the file is read from
23
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~
24
+ ``INFO.yaml`` is read from the **base** repository, at the pull
25
+ request's **base ref**, for the same reason ``.gitreview`` is (see
26
+ ``Orchestrator._read_gitreview``): a fork must never supply the file
27
+ that decides who may authorise the fork's own transfer. Both operands
28
+ come from GitHub's own metadata for the base side of the pull request,
29
+ which the head cannot influence. Where the base ref is unknown the
30
+ read is declined rather than falling back to the default branch, which
31
+ would answer with a roster the pull request does not target.
32
+
33
+ The ``id`` field is not a GitHub login
34
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
35
+ In the Linux Foundation's schema ``id`` is an LFID. On
36
+ ``opendaylight/mdsal`` the committers are ``rovarga``, ``tpantelis``
37
+ and ``JieHan2017``, and there is no ``github_id`` field at all.
38
+
39
+ Treating an LFID as a GitHub login is an impersonation vector: whoever
40
+ registers a GitHub username matching an unclaimed LFID inherits
41
+ committer authority. So ``github_id`` is preferred wherever the file
42
+ carries one, and matching on ``id`` is a *separate* opt-in that a
43
+ project must choose knowingly.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import logging
49
+ import os
50
+ from collections.abc import Iterable
51
+ from typing import Any
52
+
53
+ import yaml
54
+
55
+ from .utils import env_bool
56
+
57
+
58
+ __all__ = [
59
+ "APPROVER_LOGINS_ENV",
60
+ "INFO_YAML_LFID_ENV",
61
+ "INFO_YAML_PATH",
62
+ "USE_INFO_YAML_ENV",
63
+ "describe_additional_sources",
64
+ "explicit_approvers",
65
+ "parse_info_yaml",
66
+ "resolve_additional_approvers",
67
+ ]
68
+
69
+ log = logging.getLogger("github2gerrit.approvers")
70
+
71
+ APPROVER_LOGINS_ENV = "G2G_APPROVER_LOGINS"
72
+ """Comma-separated GitHub logins whose approving reviews always count."""
73
+
74
+ USE_INFO_YAML_ENV = "G2G_APPROVERS_FROM_INFO_YAML"
75
+ """Enable reading approvers from the base repository's ``INFO.yaml``."""
76
+
77
+ INFO_YAML_LFID_ENV = "G2G_INFO_YAML_MATCH_LFID"
78
+ """Also treat an ``INFO.yaml`` ``id`` as a GitHub login.
79
+
80
+ Off by default: ``id`` is an LFID, and matching it against a GitHub
81
+ login lets whoever registers that username on GitHub inherit committer
82
+ authority.
83
+ """
84
+
85
+ INFO_YAML_PATH = "INFO.yaml"
86
+ """Path of the file within the base repository."""
87
+
88
+ _PERSON_KEYS = ("project_lead",)
89
+ """Top-level keys holding a single authority-bearing person.
90
+
91
+ ``primary_contact`` is deliberately absent. It names whoever should be
92
+ contacted about the project, which is often the lead but may be a
93
+ separate operational or administrative contact. Admitting it would
94
+ grant that account approval authority the file never claimed for it.
95
+ """
96
+
97
+ _ROSTER_KEYS = ("committers",)
98
+ """Top-level keys holding a list of people."""
99
+
100
+
101
+ def explicit_approvers() -> frozenset[str]:
102
+ """Return the logins named by :data:`APPROVER_LOGINS_ENV`.
103
+
104
+ Returns:
105
+ Lower-cased logins. Empty when the variable is unset or blank,
106
+ which is the default and leaves the gate keyed on
107
+ ``author_association`` alone.
108
+ """
109
+ raw = os.getenv(APPROVER_LOGINS_ENV, "").strip()
110
+ if not raw:
111
+ return frozenset()
112
+
113
+ parsed = frozenset(
114
+ part.strip().lower() for part in raw.split(",") if part.strip()
115
+ )
116
+ if parsed:
117
+ log.debug(
118
+ "Additional approvers from %s: %s",
119
+ APPROVER_LOGINS_ENV,
120
+ sorted(parsed),
121
+ )
122
+ return parsed
123
+
124
+
125
+ def _logins_from_person(person: Any, *, match_lfid: bool) -> Iterable[str]:
126
+ """Yield the GitHub logins a single ``INFO.yaml`` entry names."""
127
+ if not isinstance(person, dict):
128
+ return
129
+
130
+ github_id = person.get("github_id")
131
+ if isinstance(github_id, str) and github_id.strip():
132
+ yield github_id.strip().lower()
133
+ # An explicit github_id is authoritative for this person, so
134
+ # their LFID adds nothing and would only widen the set.
135
+ return
136
+
137
+ if not match_lfid:
138
+ return
139
+
140
+ lfid = person.get("id")
141
+ if isinstance(lfid, str) and lfid.strip():
142
+ yield lfid.strip().lower()
143
+
144
+
145
+ def parse_info_yaml(text: str, *, match_lfid: bool = False) -> frozenset[str]:
146
+ """Extract approver logins from ``INFO.yaml`` content.
147
+
148
+ Reads the project lead and the committer roster, and nothing else.
149
+ ``primary_contact`` is deliberately excluded; see
150
+ :data:`_PERSON_KEYS`.
151
+
152
+ YAML anchors and merge keys are resolved by the parser, so a
153
+ committer written as ``<<: *project_lead`` is understood, and
154
+ duplicates collapse into the returned set.
155
+
156
+ Args:
157
+ text: Raw file content.
158
+ match_lfid: Whether to fall back to the ``id`` field when an
159
+ entry carries no ``github_id``. See the module docstring
160
+ for why this is off by default.
161
+
162
+ Returns:
163
+ Lower-cased logins. Empty when the file is unparsable or
164
+ names nobody \u2014 a malformed file must not widen the approver
165
+ set, and must not raise into the gate either.
166
+ """
167
+ try:
168
+ parsed = yaml.safe_load(text)
169
+ except (yaml.YAMLError, RecursionError) as exc:
170
+ # RecursionError is not a YAMLError: PyYAML raises it on
171
+ # deeply nested input, and letting it escape would turn a
172
+ # malformed base file into a failed workflow rather than the
173
+ # empty result this function promises.
174
+ log.warning("Could not parse %s; ignoring it: %s", INFO_YAML_PATH, exc)
175
+ return frozenset()
176
+
177
+ if not isinstance(parsed, dict):
178
+ log.warning("%s is not a mapping; ignoring it", INFO_YAML_PATH)
179
+ return frozenset()
180
+
181
+ logins: set[str] = set()
182
+ for key in _PERSON_KEYS:
183
+ logins.update(
184
+ _logins_from_person(parsed.get(key), match_lfid=match_lfid)
185
+ )
186
+ for key in _ROSTER_KEYS:
187
+ roster = parsed.get(key)
188
+ if isinstance(roster, list):
189
+ for person in roster:
190
+ logins.update(
191
+ _logins_from_person(person, match_lfid=match_lfid)
192
+ )
193
+
194
+ return frozenset(logins)
195
+
196
+
197
+ def _fetch_info_yaml(repo_obj: Any, ref: str) -> str:
198
+ """Read ``INFO.yaml`` from *repo_obj* at *ref*.
199
+
200
+ Returns an empty string on any failure. A repository without the
201
+ file is the common case, not an error.
202
+ """
203
+ try:
204
+ content = repo_obj.get_contents(INFO_YAML_PATH, ref=ref)
205
+ raw = getattr(content, "decoded_content", b"") or b""
206
+ return bytes(raw).decode("utf-8")
207
+ except Exception as exc:
208
+ log.debug("Could not read %s at ref %r: %s", INFO_YAML_PATH, ref, exc)
209
+ return ""
210
+
211
+
212
+ def resolve_additional_approvers(
213
+ *,
214
+ base_repo: Any | None = None,
215
+ base_ref: str = "",
216
+ ) -> frozenset[str]:
217
+ """Return every login admitted by an opt-in approver source.
218
+
219
+ Args:
220
+ base_repo: The **base** repository object, exposing
221
+ ``get_contents(path, ref=...)``. Passing the head
222
+ repository would let a fork nominate its own approvers.
223
+ base_ref: The pull request's base ref. Required for the
224
+ ``INFO.yaml`` source: without it the read is declined
225
+ rather than falling back to the default branch, which
226
+ would answer with a different branch's roster than the one
227
+ the pull request targets. ``.gitreview`` resolution fails
228
+ closed in exactly the same situation.
229
+
230
+ Returns:
231
+ Lower-cased logins, empty when no source is enabled.
232
+ """
233
+ logins = set(explicit_approvers())
234
+
235
+ if env_bool(USE_INFO_YAML_ENV, False):
236
+ logins.update(_info_yaml_approvers(base_repo, base_ref))
237
+
238
+ return frozenset(logins)
239
+
240
+
241
+ def _info_yaml_approvers(
242
+ base_repo: Any | None, base_ref: str
243
+ ) -> frozenset[str]:
244
+ """Resolve approvers from ``INFO.yaml``, or name nobody."""
245
+ if base_repo is None:
246
+ log.debug("No base repository available; skipping %s", INFO_YAML_PATH)
247
+ return frozenset()
248
+
249
+ ref = base_ref.strip()
250
+ if not ref:
251
+ log.warning(
252
+ "No authoritative base ref for this pull request; declining to "
253
+ "read %s. Reading the default branch instead could authorise "
254
+ "from a different branch's roster than the one the pull "
255
+ "request targets.",
256
+ INFO_YAML_PATH,
257
+ )
258
+ return frozenset()
259
+
260
+ text = _fetch_info_yaml(base_repo, ref)
261
+ if not text:
262
+ return frozenset()
263
+
264
+ logins = parse_info_yaml(
265
+ text, match_lfid=env_bool(INFO_YAML_LFID_ENV, False)
266
+ )
267
+ if logins:
268
+ log.debug(
269
+ "Additional approvers from %s at ref %r: %s",
270
+ INFO_YAML_PATH,
271
+ ref,
272
+ sorted(logins),
273
+ )
274
+ return logins
275
+
276
+
277
+ def describe_additional_sources() -> str:
278
+ """Return a human-readable summary of the enabled sources.
279
+
280
+ Empty when none is enabled, so callers can omit the clause
281
+ entirely rather than telling a contributor about machinery the
282
+ project does not use.
283
+ """
284
+ sources: list[str] = []
285
+ if explicit_approvers():
286
+ sources.append("a configured approver list")
287
+ if env_bool(USE_INFO_YAML_ENV, False):
288
+ detail = (
289
+ "`github_id` or `id`"
290
+ if env_bool(INFO_YAML_LFID_ENV, False)
291
+ else "`github_id`"
292
+ )
293
+ sources.append(
294
+ f"the base repository's `{INFO_YAML_PATH}` (matched on {detail})"
295
+ )
296
+ return ", ".join(sources)