Skip to content

Commit 12b774f

Browse files
authored
Merge pull request #86 from legendu-net/push-suxtktroxqux
Merge push-suxtktroxqux Into main
2 parents e853aa5 + b3290b1 commit 12b774f

6 files changed

Lines changed: 282 additions & 19 deletions

File tree

.claude/settings.json

Lines changed: 0 additions & 9 deletions
This file was deleted.

github_rest_api/__init__.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
"""GitHub REST APIs."""
22

3-
from .github import Organization, Repository, RepositoryType, User
3+
from .github import (
4+
Organization,
5+
Repository,
6+
RepositoryType,
7+
SecretVisibility,
8+
User,
9+
)
410

5-
__all__ = ["Organization", "Repository", "RepositoryType", "User"]
11+
__all__ = ["Organization", "Repository", "RepositoryType", "SecretVisibility", "User"]

github_rest_api/github.py

Lines changed: 159 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,25 @@
11
"""Simple wrapper of GitHub REST APIs."""
22

33
from abc import ABCMeta, abstractmethod
4+
from base64 import b64encode
5+
from collections.abc import Sequence
46
from enum import StrEnum
57
from typing import Any, Callable
68
from pathlib import Path
79
import requests
10+
from nacl import encoding, public
11+
12+
URL_API = "https://api.github.com"
13+
14+
15+
def _encrypt_secret(public_key: str, value: str) -> str:
16+
"""Encrypt a secret value using a LibSodium sealed box.
17+
:param public_key: The base64-encoded public key to encrypt against.
18+
:param value: The plaintext secret value to encrypt.
19+
"""
20+
pkey = public.PublicKey(public_key.encode(), encoding.Base64Encoder)
21+
encrypted = public.SealedBox(pkey).encrypt(value.encode())
22+
return b64encode(encrypted).decode()
823

924

1025
def build_http_headers(token: str) -> dict[str, str]:
@@ -36,6 +51,12 @@ def __init__(self, token: str):
3651
def _get(
3752
self, url: str, raise_for_status: bool = True, **kwargs
3853
) -> requests.Response:
54+
"""Send a GET request to a GitHub REST API endpoint.
55+
:param url: The endpoint URL to request.
56+
:param raise_for_status: Whether to raise on a non-2xx response.
57+
:param kwargs: Additional keyword arguments (e.g. `params`) forwarded
58+
to `requests.get`.
59+
"""
3960
resp = requests.get(
4061
url=url,
4162
headers=self._headers,
@@ -49,6 +70,13 @@ def _get(
4970
def _post(
5071
self, url: str, headers=None, raise_for_status: bool = True, **kwargs
5172
) -> requests.Response:
73+
"""Send a POST request to a GitHub REST API endpoint.
74+
:param url: The endpoint URL to request.
75+
:param headers: Request headers; defaults to the standard auth headers.
76+
:param raise_for_status: Whether to raise on a non-2xx response.
77+
:param kwargs: Additional keyword arguments (e.g. `json`) forwarded
78+
to `requests.post`.
79+
"""
5280
if headers is None:
5381
headers = self._headers
5482
resp = requests.post(
@@ -67,17 +95,32 @@ def _delete(self, url, raise_for_status: bool = True) -> requests.Response:
6795
resp.raise_for_status()
6896
return resp
6997

70-
def _put(self, url, raise_for_status: bool = True) -> requests.Response:
98+
def _put(
99+
self, url: str, raise_for_status: bool = True, **kwargs
100+
) -> requests.Response:
101+
"""Send a PUT request to a GitHub REST API endpoint.
102+
:param url: The endpoint URL to request.
103+
:param raise_for_status: Whether to raise on a non-2xx response.
104+
:param kwargs: Additional keyword arguments (e.g. `json`) forwarded
105+
to `requests.put`.
106+
"""
71107
resp = requests.put(
72108
url=url,
73109
headers=self._headers,
74110
timeout=10,
111+
**kwargs,
75112
)
76113
if raise_for_status:
77114
resp.raise_for_status()
78115
return resp
79116

80117
def _patch(self, url, raise_for_status: bool = True, **kwargs) -> requests.Response:
118+
"""Send a PATCH request to a GitHub REST API endpoint.
119+
:param url: The endpoint URL to request.
120+
:param raise_for_status: Whether to raise on a non-2xx response.
121+
:param kwargs: Additional keyword arguments (e.g. `json`) forwarded
122+
to `requests.patch`.
123+
"""
81124
resp = requests.patch(
82125
url=url,
83126
headers=self._headers,
@@ -118,15 +161,15 @@ def __init__(self, token: str, repo: str):
118161
"""
119162
super().__init__(token)
120163
self._repo = repo
121-
self._url = "https://api.github.com/repos"
122-
self._url_repo = f"{self._url}/{repo}"
164+
self._url_repo = f"{URL_API}/repos/{repo}"
123165
self._url_tags = f"{self._url_repo}/tags"
124166
self._url_transfer = f"{self._url_repo}/transfer"
125167
self._url_pull = f"{self._url_repo}/pulls"
126168
self._url_branches = f"{self._url_repo}/branches"
127169
self._url_refs = f"{self._url_repo}/git/refs"
128170
self._url_issues = f"{self._url_repo}/issues"
129171
self._url_releases = f"{self._url_repo}/releases"
172+
self._url_secrets = f"{self._url_repo}/actions/secrets"
130173

131174
def get_releases(self, n: int = 0) -> list[dict[str, Any]]:
132175
"""List releases in this repository."""
@@ -171,7 +214,9 @@ def upload_release_asset(
171214
path = Path(path)
172215
with path.open(mode="rb") as fin:
173216
return self._post(
174-
url=f"{self._url_releases.replace('api', 'uploads', 1)}/{release}/assets",
217+
url=f"{self._url_releases.replace('api', 'uploads', 1)}/{
218+
release
219+
}/assets",
175220
params={
176221
"name": name,
177222
},
@@ -284,6 +329,42 @@ def delete_branch(self, branch: str) -> requests.Response:
284329
"""
285330
return self.delete_ref(ref=f"heads/{branch}")
286331

332+
def delete_secret(self, name: str) -> requests.Response:
333+
"""Delete a secret from this repository.
334+
:param name: The name of the secret to delete.
335+
"""
336+
if not isinstance(name, str):
337+
raise ValueError("A string value is required for `name`.")
338+
return self._delete(
339+
url=f"{self._url_secrets}/{name}",
340+
)
341+
342+
def get_secret_public_key(self) -> dict[str, Any]:
343+
"""Get the public key for encrypting secrets in this repository."""
344+
return self._get(url=f"{self._url_secrets}/public-key").json()
345+
346+
def create_or_update_secret(
347+
self, name: str, value: str, public_key: dict[str, Any]
348+
) -> requests.Response:
349+
"""Create or update a secret in this repository.
350+
:param name: The name of the secret.
351+
:param value: The plaintext value of the secret.
352+
:param public_key: A public key (as returned by `get_secret_public_key`)
353+
to encrypt the secret with. Fetch it once and reuse it to avoid a
354+
redundant request when creating or updating multiple secrets.
355+
"""
356+
if not isinstance(name, str):
357+
raise ValueError("A string value is required for `name`.")
358+
if not isinstance(value, str):
359+
raise ValueError("A string value is required for `value`.")
360+
return self._put(
361+
url=f"{self._url_secrets}/{name}",
362+
json={
363+
"encrypted_value": _encrypt_secret(public_key["key"], value),
364+
"key_id": public_key["key_id"],
365+
},
366+
)
367+
287368
def pr_has_change(
288369
self, pr_number: int, pred: Callable[[str], bool] = lambda _: True
289370
) -> bool:
@@ -344,6 +425,12 @@ class RepositoryType(StrEnum):
344425
PRIVATE = "private"
345426

346427

428+
class SecretVisibility(StrEnum):
429+
ALL = "all"
430+
PRIVATE = "private"
431+
SELECTED = "selected"
432+
433+
347434
class Owner(GitHub, metaclass=ABCMeta):
348435
"""An abstract owner class representing an organization or user."""
349436

@@ -354,6 +441,7 @@ def __init__(self, token: str, owner: str):
354441
"""
355442
super().__init__(token)
356443
self._owner = owner
444+
self._url_owner = ""
357445
self._url_repos = ""
358446
self._url_create_repo = ""
359447

@@ -376,6 +464,14 @@ def instantiate_repository(self, repo: str) -> Repository:
376464
def create_repository(
377465
self, name: str, description: str = "", private: bool = True, **kwargs
378466
) -> dict[str, Any]:
467+
"""Create a repository for this owner.
468+
:param name: The name of the repository.
469+
:param description: A short description of the repository.
470+
:param private: Whether the repository is private.
471+
:param kwargs: Additional keyword arguments forwarded to `_post`
472+
(e.g. `params` or `raise_for_status`). Note `json` is already set
473+
from the other parameters and must not be passed here.
474+
"""
379475
data = {
380476
"name": name,
381477
"description": description,
@@ -400,8 +496,9 @@ def __init__(self, token: str, user: str):
400496
self._set_urls()
401497

402498
def _set_urls(self) -> None:
403-
self._url_repos = f"https://api.github.com/users/{self._owner}/repos"
404-
self._url_create_repo = "https://api.github.com/user/repos"
499+
self._url_owner = f"{URL_API}/users/{self._owner}"
500+
self._url_repos = f"{self._url_owner}/repos"
501+
self._url_create_repo = f"{URL_API}/user/repos"
405502

406503

407504
class Organization(Owner):
@@ -416,5 +513,60 @@ def __init__(self, token: str, org: str):
416513
self._set_urls()
417514

418515
def _set_urls(self) -> None:
419-
self._url_repos = f"https://api.github.com/orgs/{self._owner}/repos"
516+
self._url_owner = f"{URL_API}/orgs/{self._owner}"
517+
self._url_repos = f"{self._url_owner}/repos"
420518
self._url_create_repo = self._url_repos
519+
self._url_secrets = f"{self._url_owner}/actions/secrets"
520+
521+
def delete_secret(self, name: str) -> requests.Response:
522+
"""Delete an organization secret.
523+
:param name: The name of the secret to delete.
524+
"""
525+
if not isinstance(name, str):
526+
raise ValueError("A string value is required for `name`.")
527+
return self._delete(
528+
url=f"{self._url_secrets}/{name}",
529+
)
530+
531+
def get_secret_public_key(self) -> dict[str, Any]:
532+
"""Get the public key for encrypting secrets in this organization."""
533+
return self._get(url=f"{self._url_secrets}/public-key").json()
534+
535+
def create_or_update_secret(
536+
self,
537+
name: str,
538+
value: str,
539+
public_key: dict[str, Any],
540+
visibility: SecretVisibility = SecretVisibility.ALL,
541+
selected_repository_ids: Sequence[int] = (),
542+
) -> requests.Response:
543+
"""Create or update an organization secret.
544+
:param name: The name of the secret.
545+
:param value: The plaintext value of the secret.
546+
:param public_key: A public key (as returned by `get_secret_public_key`)
547+
to encrypt the secret with. Fetch it once and reuse it to avoid a
548+
redundant request when creating or updating multiple secrets.
549+
:param visibility: Which repositories can access the secret
550+
(all, private, or selected).
551+
:param selected_repository_ids: Repository IDs that can access the secret
552+
when visibility is `selected`.
553+
"""
554+
if not isinstance(name, str):
555+
raise ValueError("A string value is required for `name`.")
556+
if not isinstance(value, str):
557+
raise ValueError("A string value is required for `value`.")
558+
if selected_repository_ids and visibility != SecretVisibility.SELECTED:
559+
raise ValueError(
560+
"`selected_repository_ids` can only be provided when `visibility` is 'selected'."
561+
)
562+
json: dict[str, Any] = {
563+
"encrypted_value": _encrypt_secret(public_key["key"], value),
564+
"key_id": public_key["key_id"],
565+
"visibility": visibility,
566+
}
567+
if selected_repository_ids:
568+
json["selected_repository_ids"] = list(selected_repository_ids)
569+
return self._put(
570+
url=f"{self._url_secrets}/{name}",
571+
json=json,
572+
)

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ classifiers = [
1818
dependencies = [
1919
"dulwich>=0.25.1",
2020
"psutil>=5.9.4",
21+
"pynacl>=1.5",
2122
"pyyaml>=6",
2223
"requests>=2.28.2",
2324
"tenacity>=9.1.4",

tests/test_github.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
11
import os
2-
from github_rest_api.github import User, Organization, Repository
2+
from base64 import b64decode
3+
from nacl import encoding, public
4+
from github_rest_api.github import User, Organization, Repository, _encrypt_secret
35

46
TOKEN = os.environ.get("GITHUB_TOKEN", "")
57

68

9+
def test_encrypt_secret_roundtrip():
10+
private_key = public.PrivateKey.generate()
11+
public_key = private_key.public_key.encode(encoding.Base64Encoder).decode()
12+
encrypted = _encrypt_secret(public_key, "s3cret-value")
13+
decrypted = public.SealedBox(private_key).decrypt(b64decode(encrypted))
14+
assert decrypted == b"s3cret-value"
15+
16+
717
def test_user_get_repositories():
818
user = User(TOKEN, "dclong")
919
repos = user.get_repositories()

0 commit comments

Comments
 (0)