saltext.github 1.1.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.
- saltext/github/__init__.py +31 -0
- saltext/github/modules/__init__.py +0 -0
- saltext/github/modules/github.py +1873 -0
- saltext/github/modules/github_org.py +667 -0
- saltext/github/modules/github_repo.py +1658 -0
- saltext/github/modules/github_user.py +75 -0
- saltext/github/resources/__init__.py +0 -0
- saltext/github/resources/github/__init__.py +251 -0
- saltext/github/resources/github/modules/__init__.py +0 -0
- saltext/github/resources/github/modules/actions.py +175 -0
- saltext/github/resources/github/modules/config.py +69 -0
- saltext/github/resources/github/modules/deploy_key.py +122 -0
- saltext/github/resources/github/modules/environment.py +241 -0
- saltext/github/resources/github/modules/grains.py +18 -0
- saltext/github/resources/github/modules/issue.py +157 -0
- saltext/github/resources/github/modules/label.py +161 -0
- saltext/github/resources/github/modules/milestone.py +103 -0
- saltext/github/resources/github/modules/pages.py +149 -0
- saltext/github/resources/github/modules/pull.py +90 -0
- saltext/github/resources/github/modules/repo.py +155 -0
- saltext/github/resources/github/modules/ruleset.py +144 -0
- saltext/github/resources/github/modules/saltutil.py +24 -0
- saltext/github/resources/github/modules/state.py +116 -0
- saltext/github/resources/github/modules/test.py +29 -0
- saltext/github/resources/github/states/__init__.py +0 -0
- saltext/github/resources/github/states/actions.py +117 -0
- saltext/github/resources/github/states/deploy_key.py +130 -0
- saltext/github/resources/github/states/environment.py +99 -0
- saltext/github/resources/github/states/label.py +71 -0
- saltext/github/resources/github/states/pages.py +92 -0
- saltext/github/resources/github/states/repo.py +108 -0
- saltext/github/resources/github/states/ruleset.py +81 -0
- saltext/github/states/__init__.py +0 -0
- saltext/github/states/github.py +966 -0
- saltext/github/states/github_org.py +369 -0
- saltext/github/states/github_repo.py +722 -0
- saltext/github/utils/__init__.py +72 -0
- saltext/github/utils/gh/__init__.py +14 -0
- saltext/github/utils/gh/actions.py +195 -0
- saltext/github/utils/gh/cache.py +126 -0
- saltext/github/utils/gh/client.py +534 -0
- saltext/github/utils/gh/deploy_keys.py +121 -0
- saltext/github/utils/gh/environments.py +271 -0
- saltext/github/utils/gh/exceptions.py +28 -0
- saltext/github/utils/gh/grains_files.py +366 -0
- saltext/github/utils/gh/issues.py +193 -0
- saltext/github/utils/gh/labels.py +142 -0
- saltext/github/utils/gh/members.py +157 -0
- saltext/github/utils/gh/milestones.py +122 -0
- saltext/github/utils/gh/pages.py +128 -0
- saltext/github/utils/gh/profile.py +458 -0
- saltext/github/utils/gh/pulls.py +99 -0
- saltext/github/utils/gh/repos.py +465 -0
- saltext/github/utils/gh/resource.py +35 -0
- saltext/github/utils/gh/rulesets.py +191 -0
- saltext/github/utils/gh/statecores/__init__.py +70 -0
- saltext/github/utils/gh/statecores/actions.py +180 -0
- saltext/github/utils/gh/statecores/deploy_keys.py +241 -0
- saltext/github/utils/gh/statecores/environments.py +212 -0
- saltext/github/utils/gh/statecores/labels.py +116 -0
- saltext/github/utils/gh/statecores/pages.py +177 -0
- saltext/github/utils/gh/statecores/repo.py +249 -0
- saltext/github/utils/gh/statecores/rulesets.py +115 -0
- saltext/github/utils/gh/teams.py +375 -0
- saltext/github/utils/gh/users.py +87 -0
- saltext/github/version.py +1 -0
- saltext_github-1.1.0.dist-info/METADATA +151 -0
- saltext_github-1.1.0.dist-info/RECORD +73 -0
- saltext_github-1.1.0.dist-info/WHEEL +5 -0
- saltext_github-1.1.0.dist-info/entry_points.txt +2 -0
- saltext_github-1.1.0.dist-info/licenses/LICENSE +201 -0
- saltext_github-1.1.0.dist-info/licenses/NOTICE +10 -0
- saltext_github-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared plumbing for talking to the GitHub v3 API via PyGithub.
|
|
3
|
+
|
|
4
|
+
The functions in this package operate on a :class:`GitHubConnection` and
|
|
5
|
+
raise :class:`~saltext.github.utils.gh.exceptions.GitHubApiError`
|
|
6
|
+
(or its :class:`~saltext.github.utils.gh.exceptions.GitHubNotFoundError`
|
|
7
|
+
subclass) on API errors. They are shared between the classic ``github``
|
|
8
|
+
execution module and future resource modules.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import copy
|
|
12
|
+
import functools
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import KW_ONLY
|
|
18
|
+
from dataclasses import InitVar
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from dataclasses import field
|
|
21
|
+
from types import EllipsisType
|
|
22
|
+
|
|
23
|
+
import github
|
|
24
|
+
from github.GithubException import UnknownObjectException
|
|
25
|
+
from salt.exceptions import CommandExecutionError
|
|
26
|
+
|
|
27
|
+
from saltext.github.utils.gh.cache import MISS
|
|
28
|
+
from saltext.github.utils.gh.cache import PersistentCache
|
|
29
|
+
from saltext.github.utils.gh.exceptions import GitHubApiError
|
|
30
|
+
from saltext.github.utils.gh.exceptions import GitHubNotFoundError
|
|
31
|
+
|
|
32
|
+
log = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
API_URL_DEFAULT = "https://api.github.com"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ModWrapper:
|
|
38
|
+
__slots__ = ("conn", "mod")
|
|
39
|
+
|
|
40
|
+
def __init__(self, conn, mod):
|
|
41
|
+
self.conn = conn
|
|
42
|
+
self.mod = mod
|
|
43
|
+
|
|
44
|
+
def __getattr__(self, attr):
|
|
45
|
+
func = getattr(self.mod, attr)
|
|
46
|
+
if not callable(func):
|
|
47
|
+
return func
|
|
48
|
+
|
|
49
|
+
@functools.wraps(func)
|
|
50
|
+
def _owner_default(*args, owner=..., **kwargs):
|
|
51
|
+
if owner is ...:
|
|
52
|
+
return func(self.conn, *args, **kwargs)
|
|
53
|
+
return func(self.conn, *args, owner=owner or self.conn.owner, **kwargs)
|
|
54
|
+
|
|
55
|
+
return _owner_default
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_connection_ident(token, api_url):
|
|
59
|
+
"""
|
|
60
|
+
Render the internal identity for a (token, api_url) combination.
|
|
61
|
+
The output is used in context cache keys and persistent cache banks.
|
|
62
|
+
|
|
63
|
+
token
|
|
64
|
+
API token from profile.
|
|
65
|
+
|
|
66
|
+
api_url
|
|
67
|
+
API URL from profile.
|
|
68
|
+
"""
|
|
69
|
+
return hashlib.sha256(f"{token}:{api_url}".encode()).hexdigest()[:16]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_app_connection_ident(app_id, owner, api_url, permissions=None):
|
|
73
|
+
"""
|
|
74
|
+
Render the internal identity for a GitHub App's installation on an
|
|
75
|
+
owner, the app-authenticated counterpart of
|
|
76
|
+
:func:`get_connection_ident`. Derived from the app's identity
|
|
77
|
+
rather than its short-lived installation tokens, so it stays stable
|
|
78
|
+
across token minting/rotation (keeping e.g. persistent cache banks
|
|
79
|
+
alive between processes).
|
|
80
|
+
|
|
81
|
+
app_id
|
|
82
|
+
App ID (or client ID) from profile.
|
|
83
|
+
|
|
84
|
+
owner
|
|
85
|
+
Owner (organization or user) login the app installation targets.
|
|
86
|
+
|
|
87
|
+
api_url
|
|
88
|
+
API URL from profile.
|
|
89
|
+
|
|
90
|
+
permissions
|
|
91
|
+
Optional permission restrictions the minted tokens are subject
|
|
92
|
+
to. Profiles restricted differently must not share connections
|
|
93
|
+
(and thus identities), their tokens are not interchangeable.
|
|
94
|
+
"""
|
|
95
|
+
perms = ""
|
|
96
|
+
if permissions:
|
|
97
|
+
perms = ":" + json.dumps(permissions, sort_keys=True, separators=(",", ":"))
|
|
98
|
+
return hashlib.sha256(f"app:{app_id}:{owner.lower()}:{api_url}{perms}".encode()).hexdigest()[
|
|
99
|
+
:16
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _app_auth(app_id, private_key):
|
|
104
|
+
"""
|
|
105
|
+
Build PyGithub app (JWT) auth, surfacing invalid credentials early
|
|
106
|
+
with a helpful error.
|
|
107
|
+
"""
|
|
108
|
+
try:
|
|
109
|
+
auth = github.Auth.AppAuth(app_id, private_key)
|
|
110
|
+
# JWT creation (and thus private key parsing) usually happens
|
|
111
|
+
# lazily during the first request - fail fast instead.
|
|
112
|
+
auth.create_jwt()
|
|
113
|
+
except Exception as err: # pylint: disable=broad-except
|
|
114
|
+
raise CommandExecutionError(
|
|
115
|
+
f"Failed initializing GitHub App authentication: {err}"
|
|
116
|
+
) from err
|
|
117
|
+
return auth
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def build_app_auth(app_id, private_key, installation_id, permissions=None):
|
|
121
|
+
"""
|
|
122
|
+
Return PyGithub auth for a GitHub App installation. It mints
|
|
123
|
+
installation tokens on demand and refreshes them before expiry.
|
|
124
|
+
|
|
125
|
+
app_id
|
|
126
|
+
App ID (or client ID) of the GitHub App.
|
|
127
|
+
|
|
128
|
+
private_key
|
|
129
|
+
Private key of the GitHub App in PEM format.
|
|
130
|
+
|
|
131
|
+
installation_id
|
|
132
|
+
ID of the app's installation on the target owner, see
|
|
133
|
+
:func:`resolve_app_installation_id`.
|
|
134
|
+
|
|
135
|
+
permissions
|
|
136
|
+
Optional mapping of permission name to access level the minted
|
|
137
|
+
tokens are restricted to. Defaults to all the app's granted
|
|
138
|
+
permissions.
|
|
139
|
+
"""
|
|
140
|
+
return _app_auth(app_id, private_key).get_installation_auth(
|
|
141
|
+
int(installation_id), token_permissions=permissions or None
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def resolve_app_installation_id(app_id, private_key, owner, api_url):
|
|
146
|
+
"""
|
|
147
|
+
Resolve the ID of a GitHub App's installation on an owner
|
|
148
|
+
(an organization, falling back to user accounts).
|
|
149
|
+
|
|
150
|
+
app_id
|
|
151
|
+
App ID (or client ID) of the GitHub App.
|
|
152
|
+
|
|
153
|
+
private_key
|
|
154
|
+
Private key of the GitHub App in PEM format.
|
|
155
|
+
|
|
156
|
+
owner
|
|
157
|
+
Owner (organization or user) login the app installation targets.
|
|
158
|
+
|
|
159
|
+
api_url
|
|
160
|
+
Base URL of the GitHub API.
|
|
161
|
+
"""
|
|
162
|
+
integration = github.GithubIntegration(auth=_app_auth(app_id, private_key), base_url=api_url)
|
|
163
|
+
try:
|
|
164
|
+
try:
|
|
165
|
+
return integration.get_org_installation(owner).id
|
|
166
|
+
except UnknownObjectException:
|
|
167
|
+
return integration.get_user_installation(owner).id
|
|
168
|
+
except UnknownObjectException as err:
|
|
169
|
+
raise GitHubNotFoundError(
|
|
170
|
+
f"GitHub App {app_id} does not appear to be installed on '{owner}'",
|
|
171
|
+
status=err.status,
|
|
172
|
+
data=err.data,
|
|
173
|
+
) from err
|
|
174
|
+
except github.GithubException as err:
|
|
175
|
+
raise GitHubApiError(
|
|
176
|
+
_error_message(err.status, err.data), status=err.status, data=err.data
|
|
177
|
+
) from err
|
|
178
|
+
finally:
|
|
179
|
+
integration.close()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass(slots=True)
|
|
183
|
+
class GitHubConnection:
|
|
184
|
+
"""
|
|
185
|
+
An authenticated PyGithub client bound to a default repository owner
|
|
186
|
+
(an organization or user), together with caches for API responses.
|
|
187
|
+
|
|
188
|
+
auth
|
|
189
|
+
Personal access token to authenticate with, or a PyGithub
|
|
190
|
+
``Auth`` instance (e.g. the result of :func:`build_app_auth`).
|
|
191
|
+
Passing the latter requires ``ident``.
|
|
192
|
+
|
|
193
|
+
owner
|
|
194
|
+
Login of the organization or user this connection is scoped to.
|
|
195
|
+
Organization-scoped operations require this to be an organization.
|
|
196
|
+
Defaults to the token's user, which requires a token that is tied
|
|
197
|
+
to a user account.
|
|
198
|
+
|
|
199
|
+
api_url
|
|
200
|
+
Base URL of the GitHub API. Set this when targeting a GitHub
|
|
201
|
+
Enterprise Server instance. Defaults to the public GitHub API.
|
|
202
|
+
|
|
203
|
+
ident
|
|
204
|
+
Identity of the connection's credentials/endpoint, safe to
|
|
205
|
+
expose (e.g. as a persistent cache namespace). Defaults to
|
|
206
|
+
:func:`get_connection_ident` for token auth; required for
|
|
207
|
+
``Auth`` instances (whose tokens can be short-lived, see
|
|
208
|
+
:func:`get_app_connection_ident`).
|
|
209
|
+
|
|
210
|
+
store
|
|
211
|
+
Optional :class:`~saltext.github.utils.gh.cache.PersistentCache`
|
|
212
|
+
for slow-changing API data, surviving across processes.
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
auth: InitVar[str | github.Auth.Auth]
|
|
216
|
+
_owner: str | None = None
|
|
217
|
+
_: KW_ONLY
|
|
218
|
+
api_url: str = API_URL_DEFAULT
|
|
219
|
+
cache: dict = field(default_factory=dict)
|
|
220
|
+
|
|
221
|
+
client: github.Github = field(init=False, repr=False)
|
|
222
|
+
ident: str = ""
|
|
223
|
+
store: PersistentCache | None = field(default=None, repr=False)
|
|
224
|
+
|
|
225
|
+
def __post_init__(self, auth: str | github.Auth.Auth):
|
|
226
|
+
self.api_url = (self.api_url or API_URL_DEFAULT).rstrip("/")
|
|
227
|
+
if isinstance(auth, str):
|
|
228
|
+
self.ident = self.ident or get_connection_ident(auth, self.api_url)
|
|
229
|
+
auth = github.Auth.Token(auth)
|
|
230
|
+
elif not self.ident:
|
|
231
|
+
raise ValueError("Passing an Auth instance requires 'ident'")
|
|
232
|
+
self.client = github.Github(auth=auth, base_url=self.api_url, per_page=100)
|
|
233
|
+
|
|
234
|
+
def with_(
|
|
235
|
+
self,
|
|
236
|
+
*,
|
|
237
|
+
owner: str | None | EllipsisType = ...,
|
|
238
|
+
store: PersistentCache | None | EllipsisType = ...,
|
|
239
|
+
):
|
|
240
|
+
"""
|
|
241
|
+
Return an instance of this connection with different internal config.
|
|
242
|
+
|
|
243
|
+
owner
|
|
244
|
+
If specified, ensure the returned connection defaults to this owner.
|
|
245
|
+
|
|
246
|
+
store
|
|
247
|
+
If specified, ensure the returned connection uses this persistent
|
|
248
|
+
store/does not use any.
|
|
249
|
+
"""
|
|
250
|
+
if owner is ... and store is ...:
|
|
251
|
+
return self
|
|
252
|
+
# `auth` is an InitVar, so a replace() does not work.
|
|
253
|
+
# We're intentionally sharing the same client and context cache.
|
|
254
|
+
# Implies that sharing the same token+api_url in profiles
|
|
255
|
+
# with different cache config and accessing them in the same run
|
|
256
|
+
# can return cached data/omit writing it because of the shared context cache.
|
|
257
|
+
# There is no risk of data leak because it's the same token and API though.
|
|
258
|
+
new = copy.copy(self)
|
|
259
|
+
|
|
260
|
+
if owner is not ...:
|
|
261
|
+
new._owner = owner
|
|
262
|
+
if store is not ...:
|
|
263
|
+
new.store = store
|
|
264
|
+
return new
|
|
265
|
+
|
|
266
|
+
@property
|
|
267
|
+
def owner(self):
|
|
268
|
+
"""
|
|
269
|
+
Login of the organization or user this connection is scoped to.
|
|
270
|
+
Defaults to the token's user (lazily resolved).
|
|
271
|
+
"""
|
|
272
|
+
if self._owner is None:
|
|
273
|
+
try:
|
|
274
|
+
self._owner = self.auth_login
|
|
275
|
+
except github.GithubException as err:
|
|
276
|
+
raise CommandExecutionError(
|
|
277
|
+
"Failed to look up the token's user for the default repository "
|
|
278
|
+
f"owner: {_error_message(err.status, err.data)}. Tokens that are "
|
|
279
|
+
"not tied to a user account (e.g. GitHub App installation tokens) "
|
|
280
|
+
"require the 'owner' profile parameter."
|
|
281
|
+
) from err
|
|
282
|
+
return self._owner
|
|
283
|
+
|
|
284
|
+
@property
|
|
285
|
+
def org(self):
|
|
286
|
+
"""
|
|
287
|
+
PyGithub ``Organization`` object this connection is scoped to (cached).
|
|
288
|
+
The connection's owner must be an organization.
|
|
289
|
+
"""
|
|
290
|
+
return self.get_org()
|
|
291
|
+
|
|
292
|
+
def get_org(self, owner=None):
|
|
293
|
+
"""
|
|
294
|
+
Return a PyGithub ``Organization`` object for an owner login (cached).
|
|
295
|
+
Defaults to the connection's owner.
|
|
296
|
+
"""
|
|
297
|
+
owner = owner or self.owner
|
|
298
|
+
key = ("org", owner.lower())
|
|
299
|
+
if key not in self.cache:
|
|
300
|
+
self.cache[key] = self.client.get_organization(owner)
|
|
301
|
+
return self.cache[key]
|
|
302
|
+
|
|
303
|
+
@property
|
|
304
|
+
def auth_user(self):
|
|
305
|
+
"""
|
|
306
|
+
PyGithub ``AuthenticatedUser`` object of the token's user (cached).
|
|
307
|
+
"""
|
|
308
|
+
if "auth_user" not in self.cache:
|
|
309
|
+
self.cache["auth_user"] = self.client.get_user()
|
|
310
|
+
return self.cache["auth_user"]
|
|
311
|
+
|
|
312
|
+
@property
|
|
313
|
+
def auth_login(self):
|
|
314
|
+
"""
|
|
315
|
+
Login of the token's user (cached, persistently when the
|
|
316
|
+
connection carries a store).
|
|
317
|
+
"""
|
|
318
|
+
if "auth_login" not in self.cache:
|
|
319
|
+
login = MISS
|
|
320
|
+
if self.store is not None:
|
|
321
|
+
login = self.store.get_meta("auth_login")
|
|
322
|
+
if login is MISS:
|
|
323
|
+
login = self.auth_user.login
|
|
324
|
+
if self.store is not None:
|
|
325
|
+
self.store.set_meta("auth_login", login)
|
|
326
|
+
self.cache["auth_login"] = login
|
|
327
|
+
return self.cache["auth_login"]
|
|
328
|
+
|
|
329
|
+
def is_auth_user(self, login):
|
|
330
|
+
"""
|
|
331
|
+
Whether a login belongs to the token's user. Returns ``False``
|
|
332
|
+
for tokens that are not tied to a user account (e.g. GitHub App
|
|
333
|
+
installation tokens), whose user lookup fails.
|
|
334
|
+
"""
|
|
335
|
+
try:
|
|
336
|
+
return login.lower() == self.auth_login.lower()
|
|
337
|
+
except github.GithubException:
|
|
338
|
+
return False
|
|
339
|
+
|
|
340
|
+
def owner_type(self, owner=None):
|
|
341
|
+
"""
|
|
342
|
+
Return the account type of an owner login, either ``Organization``
|
|
343
|
+
or ``User`` (cached, persistently when the connection carries a
|
|
344
|
+
store). Defaults to the connection's owner.
|
|
345
|
+
"""
|
|
346
|
+
owner = owner or self.owner
|
|
347
|
+
key = ("owner_type", owner.lower())
|
|
348
|
+
if key not in self.cache:
|
|
349
|
+
otype = MISS
|
|
350
|
+
if self.store is not None:
|
|
351
|
+
otype = self.store.get_meta(f"owner_type_{owner.lower()}")
|
|
352
|
+
if otype is MISS:
|
|
353
|
+
otype = self.client.get_user(owner).type
|
|
354
|
+
if self.store is not None:
|
|
355
|
+
self.store.set_meta(f"owner_type_{owner.lower()}", otype)
|
|
356
|
+
self.cache[key] = otype
|
|
357
|
+
return self.cache[key]
|
|
358
|
+
|
|
359
|
+
def bind(self, mod):
|
|
360
|
+
"""
|
|
361
|
+
Wrap a module to pass the connection as the first positional argument
|
|
362
|
+
and auto-default the ``owner`` param to the one defined on the client
|
|
363
|
+
when any function is called.
|
|
364
|
+
"""
|
|
365
|
+
return ModWrapper(self, mod)
|
|
366
|
+
|
|
367
|
+
def repo_query(self, owner, *path, **kwargs):
|
|
368
|
+
"""
|
|
369
|
+
Make a direct call to the ``/repos`` GitHub API, defaulting the first following
|
|
370
|
+
path segment to the configured ``owner`` if unspecified.
|
|
371
|
+
Otherwise delegates to ``query``.
|
|
372
|
+
|
|
373
|
+
owner
|
|
374
|
+
Pass an explicit owner. Defaults to the owner configured for the connection.
|
|
375
|
+
|
|
376
|
+
Pass an arbitrary number of path segments as string-valued positional args.
|
|
377
|
+
"""
|
|
378
|
+
return self.query(["repos", owner or self.owner] + list(path), **kwargs)
|
|
379
|
+
|
|
380
|
+
def query(self, path, *, params=None, method="GET", headers=None, data=None, per_page=None):
|
|
381
|
+
"""
|
|
382
|
+
Make a direct call to the GitHub API, transparently following pagination.
|
|
383
|
+
|
|
384
|
+
path
|
|
385
|
+
URL path to query, either as a string or a list of segments.
|
|
386
|
+
|
|
387
|
+
params
|
|
388
|
+
Mapping of query parameters.
|
|
389
|
+
|
|
390
|
+
method
|
|
391
|
+
HTTP method to use. Defaults to ``GET``.
|
|
392
|
+
|
|
393
|
+
headers
|
|
394
|
+
Mapping of extra headers to send.
|
|
395
|
+
|
|
396
|
+
data
|
|
397
|
+
Request body. JSON strings are decoded first.
|
|
398
|
+
|
|
399
|
+
per_page
|
|
400
|
+
Pagination size, maximum 100.
|
|
401
|
+
|
|
402
|
+
Returns the decoded response data. List results are merged across pages.
|
|
403
|
+
Raises :class:`GitHubNotFoundError` for missing resources and
|
|
404
|
+
:class:`GitHubApiError` for other error responses.
|
|
405
|
+
"""
|
|
406
|
+
args = dict(params) if isinstance(params, dict) else {}
|
|
407
|
+
segments = [path] if isinstance(path, str) else list(path or [])
|
|
408
|
+
url = "/" + "/".join(str(segment) for segment in segments)
|
|
409
|
+
|
|
410
|
+
log.debug("GitHub API path: %s", url)
|
|
411
|
+
|
|
412
|
+
if per_page and "per_page" not in args:
|
|
413
|
+
args["per_page"] = per_page
|
|
414
|
+
|
|
415
|
+
if isinstance(data, str):
|
|
416
|
+
data = json.loads(data)
|
|
417
|
+
|
|
418
|
+
requester = self.client.requester
|
|
419
|
+
|
|
420
|
+
complete_result = []
|
|
421
|
+
next_page = None
|
|
422
|
+
while True:
|
|
423
|
+
if next_page:
|
|
424
|
+
args["page"] = next_page
|
|
425
|
+
try:
|
|
426
|
+
status, resp_headers, text = requester.requestJson(
|
|
427
|
+
method, url, parameters=args or None, headers=headers, input=data
|
|
428
|
+
)
|
|
429
|
+
except github.GithubException as err:
|
|
430
|
+
exc_type = GitHubNotFoundError if err.status == 404 else GitHubApiError
|
|
431
|
+
raise exc_type(
|
|
432
|
+
_error_message(err.status, err.data), status=err.status, data=err.data
|
|
433
|
+
) from err
|
|
434
|
+
except Exception as err: # pylint: disable=broad-except
|
|
435
|
+
raise CommandExecutionError(f"GitHub API request failed: {err}") from err
|
|
436
|
+
log.debug("GitHub Response Status Code: %s", status)
|
|
437
|
+
decoded = json.loads(text) if text else None
|
|
438
|
+
|
|
439
|
+
if not 200 <= status < 300:
|
|
440
|
+
exc_type = GitHubNotFoundError if status == 404 else GitHubApiError
|
|
441
|
+
raise exc_type(_error_message(status, decoded, text), status=status, data=decoded)
|
|
442
|
+
|
|
443
|
+
if not isinstance(decoded, list):
|
|
444
|
+
# If only querying for one item, such as a single issue,
|
|
445
|
+
# the GitHub API returns a single dictionary, instead of
|
|
446
|
+
# a list of dictionaries. In that case, we can return.
|
|
447
|
+
return decoded
|
|
448
|
+
|
|
449
|
+
complete_result += decoded
|
|
450
|
+
|
|
451
|
+
next_page = _next_page_number(resp_headers)
|
|
452
|
+
if next_page is None:
|
|
453
|
+
break
|
|
454
|
+
|
|
455
|
+
return complete_result
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def revoke_installation_token(conn):
|
|
459
|
+
"""
|
|
460
|
+
Revoke the GitHub App installation access token a connection
|
|
461
|
+
currently authenticates with (would otherwise stay valid for one
|
|
462
|
+
hour after minting).
|
|
463
|
+
|
|
464
|
+
The connection – and any others sharing its client – must not be
|
|
465
|
+
used afterwards: PyGithub only mints a new token once the revoked
|
|
466
|
+
one would have expired, not in response to auth failures. Callers
|
|
467
|
+
should evict the connection from their caches, ensuring later
|
|
468
|
+
invocations build a fresh one.
|
|
469
|
+
|
|
470
|
+
conn
|
|
471
|
+
:class:`GitHubConnection` to use. Must be app-authenticated.
|
|
472
|
+
"""
|
|
473
|
+
if not isinstance(conn.client.requester.auth, github.Auth.AppInstallationAuth):
|
|
474
|
+
raise CommandExecutionError("Only GitHub App ('app_id') profiles support token revocation.")
|
|
475
|
+
conn.query("installation/token", method="DELETE")
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def rate_limit(conn):
|
|
479
|
+
"""
|
|
480
|
+
Return the API rate limit status of a connection's token, keyed by
|
|
481
|
+
resource (``core``, ``search``, ...). The deprecated top-level ``rate``
|
|
482
|
+
alias for ``core`` is excluded.
|
|
483
|
+
|
|
484
|
+
conn
|
|
485
|
+
:class:`GitHubConnection` to use.
|
|
486
|
+
"""
|
|
487
|
+
return conn.query("rate_limit")["resources"]
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _error_message(status, data, fallback=""):
|
|
491
|
+
message = data.get("message", fallback) if isinstance(data, dict) else (data or fallback)
|
|
492
|
+
return f"{status} {message}".strip()
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def api_request(func):
|
|
496
|
+
"""
|
|
497
|
+
Decorator that translates PyGithub exceptions escaping ``func`` into
|
|
498
|
+
:class:`GitHubApiError`/:class:`GitHubNotFoundError`, ensuring callers
|
|
499
|
+
never need to handle PyGithub exception types.
|
|
500
|
+
"""
|
|
501
|
+
|
|
502
|
+
@functools.wraps(func)
|
|
503
|
+
def wrapper(*args, **kwargs):
|
|
504
|
+
try:
|
|
505
|
+
return func(*args, **kwargs)
|
|
506
|
+
except UnknownObjectException as err:
|
|
507
|
+
raise GitHubNotFoundError(
|
|
508
|
+
_error_message(err.status, err.data), status=err.status, data=err.data
|
|
509
|
+
) from err
|
|
510
|
+
except github.GithubException as err:
|
|
511
|
+
raise GitHubApiError(
|
|
512
|
+
_error_message(err.status, err.data), status=err.status, data=err.data
|
|
513
|
+
) from err
|
|
514
|
+
|
|
515
|
+
return wrapper
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _next_page_number(headers):
|
|
519
|
+
"""
|
|
520
|
+
Extract the next page number from a GitHub ``Link`` response header.
|
|
521
|
+
|
|
522
|
+
Returns ``None`` when there is no further page.
|
|
523
|
+
"""
|
|
524
|
+
link = None
|
|
525
|
+
for key, val in (headers or {}).items():
|
|
526
|
+
if key.lower() == "link":
|
|
527
|
+
link = val
|
|
528
|
+
break
|
|
529
|
+
if not link:
|
|
530
|
+
return None
|
|
531
|
+
match = re.search(r'<[^>]*[?&]page=(\d+)[^>]*>\s*;\s*rel="next"', link)
|
|
532
|
+
if match:
|
|
533
|
+
return match.group(1)
|
|
534
|
+
return None
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Operations on GitHub repository deploy keys.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def fingerprint(key):
|
|
13
|
+
"""
|
|
14
|
+
Return the OpenSSH-style SHA256 fingerprint of an SSH public key.
|
|
15
|
+
|
|
16
|
+
key
|
|
17
|
+
SSH public key.
|
|
18
|
+
"""
|
|
19
|
+
blob = base64.b64decode(key.split()[1])
|
|
20
|
+
return "SHA256:" + base64.b64encode(hashlib.sha256(blob).digest()).decode().rstrip("=")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def key_body(key):
|
|
24
|
+
"""
|
|
25
|
+
Reduce an SSH public key to its comparable part (type + base64 body),
|
|
26
|
+
ignoring whitespace variations and the comment.
|
|
27
|
+
|
|
28
|
+
key
|
|
29
|
+
SSH public key.
|
|
30
|
+
"""
|
|
31
|
+
return " ".join(key.split()[:2])
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def list_all(conn, repo_name, *, owner=None):
|
|
35
|
+
"""
|
|
36
|
+
Return all deploy keys of a repository.
|
|
37
|
+
|
|
38
|
+
conn
|
|
39
|
+
:class:`~saltext.github.utils.gh.client.GitHubConnection` to use.
|
|
40
|
+
|
|
41
|
+
repo_name
|
|
42
|
+
Repository name.
|
|
43
|
+
|
|
44
|
+
owner
|
|
45
|
+
Owner of the repository. Defaults to the connection's owner.
|
|
46
|
+
"""
|
|
47
|
+
return conn.repo_query(owner, repo_name, "keys")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get(conn, repo_name, key_id, *, owner=None):
|
|
51
|
+
"""
|
|
52
|
+
Return the data of a single deploy key.
|
|
53
|
+
|
|
54
|
+
conn
|
|
55
|
+
:class:`~saltext.github.utils.gh.client.GitHubConnection` to use.
|
|
56
|
+
|
|
57
|
+
repo_name
|
|
58
|
+
Repository name.
|
|
59
|
+
|
|
60
|
+
key_id
|
|
61
|
+
Deploy key ID.
|
|
62
|
+
|
|
63
|
+
owner
|
|
64
|
+
Owner of the repository. Defaults to the connection's owner.
|
|
65
|
+
|
|
66
|
+
Raises :class:`~saltext.github.utils.gh.exceptions.GitHubNotFoundError`
|
|
67
|
+
if the deploy key does not exist.
|
|
68
|
+
"""
|
|
69
|
+
return conn.repo_query(owner, repo_name, "keys", key_id)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def create(conn, repo_name, title, key, *, read_only=False, owner=None):
|
|
73
|
+
"""
|
|
74
|
+
Add a deploy key to a repository. Returns the data of the created key.
|
|
75
|
+
|
|
76
|
+
Note that deploy keys are immutable, they can only be replaced.
|
|
77
|
+
|
|
78
|
+
conn
|
|
79
|
+
:class:`~saltext.github.utils.gh.client.GitHubConnection` to use.
|
|
80
|
+
|
|
81
|
+
repo_name
|
|
82
|
+
Repository name.
|
|
83
|
+
|
|
84
|
+
title
|
|
85
|
+
Display title of the deploy key.
|
|
86
|
+
|
|
87
|
+
key
|
|
88
|
+
SSH public key.
|
|
89
|
+
|
|
90
|
+
read_only
|
|
91
|
+
Deny write access to the repository for this key. Defaults to false.
|
|
92
|
+
|
|
93
|
+
owner
|
|
94
|
+
Owner of the repository. Defaults to the connection's owner.
|
|
95
|
+
"""
|
|
96
|
+
return conn.repo_query(
|
|
97
|
+
owner,
|
|
98
|
+
repo_name,
|
|
99
|
+
"keys",
|
|
100
|
+
method="POST",
|
|
101
|
+
data={"title": title, "key": key, "read_only": read_only},
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def delete(conn, repo_name, key_id, *, owner=None):
|
|
106
|
+
"""
|
|
107
|
+
Remove a deploy key from a repository.
|
|
108
|
+
|
|
109
|
+
conn
|
|
110
|
+
:class:`~saltext.github.utils.gh.client.GitHubConnection` to use.
|
|
111
|
+
|
|
112
|
+
repo_name
|
|
113
|
+
Repository name.
|
|
114
|
+
|
|
115
|
+
key_id
|
|
116
|
+
Deploy key ID.
|
|
117
|
+
|
|
118
|
+
owner
|
|
119
|
+
Owner of the repository. Defaults to the connection's owner.
|
|
120
|
+
"""
|
|
121
|
+
conn.repo_query(owner, repo_name, "keys", key_id, method="DELETE")
|