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 ]:
@@ -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+
347434class 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
407504class 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+ )
0 commit comments