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.
Files changed (73) hide show
  1. saltext/github/__init__.py +31 -0
  2. saltext/github/modules/__init__.py +0 -0
  3. saltext/github/modules/github.py +1873 -0
  4. saltext/github/modules/github_org.py +667 -0
  5. saltext/github/modules/github_repo.py +1658 -0
  6. saltext/github/modules/github_user.py +75 -0
  7. saltext/github/resources/__init__.py +0 -0
  8. saltext/github/resources/github/__init__.py +251 -0
  9. saltext/github/resources/github/modules/__init__.py +0 -0
  10. saltext/github/resources/github/modules/actions.py +175 -0
  11. saltext/github/resources/github/modules/config.py +69 -0
  12. saltext/github/resources/github/modules/deploy_key.py +122 -0
  13. saltext/github/resources/github/modules/environment.py +241 -0
  14. saltext/github/resources/github/modules/grains.py +18 -0
  15. saltext/github/resources/github/modules/issue.py +157 -0
  16. saltext/github/resources/github/modules/label.py +161 -0
  17. saltext/github/resources/github/modules/milestone.py +103 -0
  18. saltext/github/resources/github/modules/pages.py +149 -0
  19. saltext/github/resources/github/modules/pull.py +90 -0
  20. saltext/github/resources/github/modules/repo.py +155 -0
  21. saltext/github/resources/github/modules/ruleset.py +144 -0
  22. saltext/github/resources/github/modules/saltutil.py +24 -0
  23. saltext/github/resources/github/modules/state.py +116 -0
  24. saltext/github/resources/github/modules/test.py +29 -0
  25. saltext/github/resources/github/states/__init__.py +0 -0
  26. saltext/github/resources/github/states/actions.py +117 -0
  27. saltext/github/resources/github/states/deploy_key.py +130 -0
  28. saltext/github/resources/github/states/environment.py +99 -0
  29. saltext/github/resources/github/states/label.py +71 -0
  30. saltext/github/resources/github/states/pages.py +92 -0
  31. saltext/github/resources/github/states/repo.py +108 -0
  32. saltext/github/resources/github/states/ruleset.py +81 -0
  33. saltext/github/states/__init__.py +0 -0
  34. saltext/github/states/github.py +966 -0
  35. saltext/github/states/github_org.py +369 -0
  36. saltext/github/states/github_repo.py +722 -0
  37. saltext/github/utils/__init__.py +72 -0
  38. saltext/github/utils/gh/__init__.py +14 -0
  39. saltext/github/utils/gh/actions.py +195 -0
  40. saltext/github/utils/gh/cache.py +126 -0
  41. saltext/github/utils/gh/client.py +534 -0
  42. saltext/github/utils/gh/deploy_keys.py +121 -0
  43. saltext/github/utils/gh/environments.py +271 -0
  44. saltext/github/utils/gh/exceptions.py +28 -0
  45. saltext/github/utils/gh/grains_files.py +366 -0
  46. saltext/github/utils/gh/issues.py +193 -0
  47. saltext/github/utils/gh/labels.py +142 -0
  48. saltext/github/utils/gh/members.py +157 -0
  49. saltext/github/utils/gh/milestones.py +122 -0
  50. saltext/github/utils/gh/pages.py +128 -0
  51. saltext/github/utils/gh/profile.py +458 -0
  52. saltext/github/utils/gh/pulls.py +99 -0
  53. saltext/github/utils/gh/repos.py +465 -0
  54. saltext/github/utils/gh/resource.py +35 -0
  55. saltext/github/utils/gh/rulesets.py +191 -0
  56. saltext/github/utils/gh/statecores/__init__.py +70 -0
  57. saltext/github/utils/gh/statecores/actions.py +180 -0
  58. saltext/github/utils/gh/statecores/deploy_keys.py +241 -0
  59. saltext/github/utils/gh/statecores/environments.py +212 -0
  60. saltext/github/utils/gh/statecores/labels.py +116 -0
  61. saltext/github/utils/gh/statecores/pages.py +177 -0
  62. saltext/github/utils/gh/statecores/repo.py +249 -0
  63. saltext/github/utils/gh/statecores/rulesets.py +115 -0
  64. saltext/github/utils/gh/teams.py +375 -0
  65. saltext/github/utils/gh/users.py +87 -0
  66. saltext/github/version.py +1 -0
  67. saltext_github-1.1.0.dist-info/METADATA +151 -0
  68. saltext_github-1.1.0.dist-info/RECORD +73 -0
  69. saltext_github-1.1.0.dist-info/WHEEL +5 -0
  70. saltext_github-1.1.0.dist-info/entry_points.txt +2 -0
  71. saltext_github-1.1.0.dist-info/licenses/LICENSE +201 -0
  72. saltext_github-1.1.0.dist-info/licenses/NOTICE +10 -0
  73. 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")