11"""Simple wrapper of GitHub REST APIs."""
22
33from abc import ABCMeta , abstractmethod
4+ from base64 import b64encode
5+ from collections .abc import Sequence
46from enum import StrEnum
57from typing import Any , Callable
68from pathlib import Path
79import 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
1025def 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
407459class 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+ )
0 commit comments