Skip to content

Commit f4a77be

Browse files
committed
feat(github): add method to delete repository/org secrets
1 parent e853aa5 commit f4a77be

4 files changed

Lines changed: 211 additions & 16 deletions

File tree

.claude/settings.json

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

github_rest_api/github.py

Lines changed: 107 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]:
@@ -67,11 +82,12 @@ def _delete(self, url, raise_for_status: bool = True) -> requests.Response:
6782
resp.raise_for_status()
6883
return resp
6984

70-
def _put(self, url, raise_for_status: bool = True) -> requests.Response:
85+
def _put(self, url, raise_for_status: bool = True, **kwargs) -> requests.Response:
7186
resp = requests.put(
7287
url=url,
7388
headers=self._headers,
7489
timeout=10,
90+
**kwargs,
7591
)
7692
if raise_for_status:
7793
resp.raise_for_status()
@@ -118,15 +134,15 @@ def __init__(self, token: str, repo: str):
118134
"""
119135
super().__init__(token)
120136
self._repo = repo
121-
self._url = "https://api.github.com/repos"
122-
self._url_repo = f"{self._url}/{repo}"
137+
self._url_repo = f"{URL_API}/repos/{repo}"
123138
self._url_tags = f"{self._url_repo}/tags"
124139
self._url_transfer = f"{self._url_repo}/transfer"
125140
self._url_pull = f"{self._url_repo}/pulls"
126141
self._url_branches = f"{self._url_repo}/branches"
127142
self._url_refs = f"{self._url_repo}/git/refs"
128143
self._url_issues = f"{self._url_repo}/issues"
129144
self._url_releases = f"{self._url_repo}/releases"
145+
self._url_secrets = f"{self._url_repo}/actions/secrets"
130146

131147
def get_releases(self, n: int = 0) -> list[dict[str, Any]]:
132148
"""List releases in this repository."""
@@ -171,7 +187,9 @@ def upload_release_asset(
171187
path = Path(path)
172188
with path.open(mode="rb") as fin:
173189
return self._post(
174-
url=f"{self._url_releases.replace('api', 'uploads', 1)}/{release}/assets",
190+
url=f"{self._url_releases.replace('api', 'uploads', 1)}/{
191+
release
192+
}/assets",
175193
params={
176194
"name": name,
177195
},
@@ -284,6 +302,38 @@ def delete_branch(self, branch: str) -> requests.Response:
284302
"""
285303
return self.delete_ref(ref=f"heads/{branch}")
286304

305+
def delete_secret(self, name: str) -> requests.Response:
306+
"""Delete a secret from this repository.
307+
:param name: The name of the secret to delete.
308+
"""
309+
if not isinstance(name, str):
310+
raise ValueError("A string value is required for `name`.")
311+
return self._delete(
312+
url=f"{self._url_secrets}/{name}",
313+
)
314+
315+
def get_secret_public_key(self) -> dict[str, Any]:
316+
"""Get the public key for encrypting secrets in this repository."""
317+
return self._get(url=f"{self._url_secrets}/public-key").json()
318+
319+
def create_or_update_secret(self, name: str, value: str) -> requests.Response:
320+
"""Create or update a secret in this repository.
321+
:param name: The name of the secret.
322+
:param value: The plaintext value of the secret.
323+
"""
324+
if not isinstance(name, str):
325+
raise ValueError("A string value is required for `name`.")
326+
if not isinstance(value, str):
327+
raise ValueError("A string value is required for `value`.")
328+
key = self.get_secret_public_key()
329+
return self._put(
330+
url=f"{self._url_secrets}/{name}",
331+
json={
332+
"encrypted_value": _encrypt_secret(key["key"], value),
333+
"key_id": key["key_id"],
334+
},
335+
)
336+
287337
def pr_has_change(
288338
self, pr_number: int, pred: Callable[[str], bool] = lambda _: True
289339
) -> bool:
@@ -354,6 +404,7 @@ def __init__(self, token: str, owner: str):
354404
"""
355405
super().__init__(token)
356406
self._owner = owner
407+
self._url_owner = ""
357408
self._url_repos = ""
358409
self._url_create_repo = ""
359410

@@ -400,8 +451,9 @@ def __init__(self, token: str, user: str):
400451
self._set_urls()
401452

402453
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"
454+
self._url_owner = f"{URL_API}/users/{self._owner}"
455+
self._url_repos = f"{self._url_owner}/repos"
456+
self._url_create_repo = f"{URL_API}/user/repos"
405457

406458

407459
class Organization(Owner):
@@ -416,5 +468,53 @@ def __init__(self, token: str, org: str):
416468
self._set_urls()
417469

418470
def _set_urls(self) -> None:
419-
self._url_repos = f"https://api.github.com/orgs/{self._owner}/repos"
471+
self._url_owner = f"{URL_API}/orgs/{self._owner}"
472+
self._url_repos = f"{self._url_owner}/repos"
420473
self._url_create_repo = self._url_repos
474+
self._url_secrets = f"{self._url_owner}/actions/secrets"
475+
476+
def delete_secret(self, name: str) -> requests.Response:
477+
"""Delete an organization secret.
478+
:param name: The name of the secret to delete.
479+
"""
480+
if not isinstance(name, str):
481+
raise ValueError("A string value is required for `name`.")
482+
return self._delete(
483+
url=f"{self._url_secrets}/{name}",
484+
)
485+
486+
def get_secret_public_key(self) -> dict[str, Any]:
487+
"""Get the public key for encrypting secrets in this organization."""
488+
return self._get(url=f"{self._url_secrets}/public-key").json()
489+
490+
def create_or_update_secret(
491+
self,
492+
name: str,
493+
value: str,
494+
visibility: str = "all",
495+
selected_repository_ids: Sequence[int] = (),
496+
) -> requests.Response:
497+
"""Create or update an organization secret.
498+
:param name: The name of the secret.
499+
:param value: The plaintext value of the secret.
500+
:param visibility: Which repositories can access the secret
501+
(all, private, or selected).
502+
:param selected_repository_ids: Repository IDs that can access the secret
503+
when visibility is `selected`.
504+
"""
505+
if not isinstance(name, str):
506+
raise ValueError("A string value is required for `name`.")
507+
if not isinstance(value, str):
508+
raise ValueError("A string value is required for `value`.")
509+
key = self.get_secret_public_key()
510+
json: dict[str, Any] = {
511+
"encrypted_value": _encrypt_secret(key["key"], value),
512+
"key_id": key["key_id"],
513+
"visibility": visibility,
514+
}
515+
if selected_repository_ids:
516+
json["selected_repository_ids"] = list(selected_repository_ids)
517+
return self._put(
518+
url=f"{self._url_secrets}/{name}",
519+
json=json,
520+
)

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",

0 commit comments

Comments
 (0)