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.
- github2gerrit/approvers.py +296 -0
- github2gerrit/cli.py +349 -46
- github2gerrit/config.py +135 -8
- github2gerrit/core.py +17 -14
- github2gerrit/duplicate_detection.py +17 -5
- github2gerrit/gitutils.py +1 -1
- github2gerrit/models.py +40 -7
- github2gerrit/pr_approval.py +35 -6
- github2gerrit/pr_commands.py +113 -5
- github2gerrit/ssh_config_parser.py +36 -60
- github2gerrit/utils.py +10 -4
- github2gerrit-2.2.0.dist-info/METADATA +463 -0
- {github2gerrit-2.1.0.dist-info → github2gerrit-2.2.0.dist-info}/RECORD +16 -15
- github2gerrit-2.1.0.dist-info/METADATA +0 -394
- {github2gerrit-2.1.0.dist-info → github2gerrit-2.2.0.dist-info}/WHEEL +0 -0
- {github2gerrit-2.1.0.dist-info → github2gerrit-2.2.0.dist-info}/entry_points.txt +0 -0
- {github2gerrit-2.1.0.dist-info → github2gerrit-2.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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)
|