Skip to content

Commit

Permalink
Support creation of Dependabot Organization and Repository Secrets (#…
Browse files Browse the repository at this point in the history
…2874)

Co-authored-by: Thomas Crowley <15927917+thomascrowley@users.noreply.github.com>
Co-authored-by: Sean Killen <sean@killens.co.uk>
Co-authored-by: Enrico Minack <github@enrico.minack.dev>
  • Loading branch information
3 people committed Mar 21, 2024
1 parent 9e09245 commit 0784f83
Show file tree
Hide file tree
Showing 16 changed files with 439 additions and 85 deletions.
13 changes: 12 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,18 @@ app_private_key = "my_app_private_key" # Can be left empty if not used
```

If you use 2 factor authentication on your Github account, tests that require a login/password authentication will fail.
You can use `pytest Issue139.testCompletion --record --auth_with_token` to use the `oauth_token` field specified in `GithubCredentials.py` when recording a unit test interaction. Note that the `password = ""` (empty string is ok) must still be present in `GithubCredentials.py` to run the tests even when the `--auth_with_token` arg is used. (Also note that if you record your test data with `--auth_with_token` then you also need to be in token authentication mode when running the test. A simple alternative is to replace `token private_token_removed` with `Basic login_and_password_removed` in all your newly generated ReplayData files.)
You can use `pytest Issue139.testCompletion --record --auth_with_token` to use the `oauth_token` field specified in `GithubCredentials.py` when recording a unit test interaction. Note that the `password = ""` (empty string is ok) must still be present in `GithubCredentials.py` to run the tests even when the `--auth_with_token` arg is used.

Also note that if you record your test data with `--auth_with_token` then you also need to be in token authentication mode when running the test. You can do this by setting `tokenAuthMode` to be true like so:

```python
def setUp(self):
self.tokenAuthMode = True
super().setUp()
...
```

A simple alternative is to replace `token private_token_removed` with `Basic login_and_password_removed` in all your newly generated ReplayData files.

Similarly, you can use `pytest Issue139.testCompletion --record --auth_with_jwt` to use the `jwt` field specified in `GithubCredentials.py` to access endpoints that require JWT.

Expand Down
36 changes: 27 additions & 9 deletions github/Organization.py
Original file line number Diff line number Diff line change
Expand Up @@ -583,32 +583,48 @@ def create_secret(
unencrypted_value: str,
visibility: str = "all",
selected_repositories: Opt[list[github.Repository.Repository]] = NotSet,
secret_type: str = "actions",
) -> github.OrganizationSecret.OrganizationSecret:
"""
:calls: `PUT /orgs/{org}/actions/secrets/{secret_name} <https://docs.github.com/en/rest/actions/secrets#create-or-update-an-organization-secret>`_
:param secret_name: string name of the secret
:param unencrypted_value: string plain text value of the secret
:param visibility: string options all or selected
:param selected_repositories: list of repositrories that the secret will be available in
:param secret_type: string options actions or dependabot
:calls: `PUT /orgs/{org}/{secret_type}/secrets/{secret_name} <https://docs.github.com/en/rest/actions/secrets#create-or-update-an-organization-secret>`_
"""
assert isinstance(secret_name, str), secret_name
assert isinstance(unencrypted_value, str), unencrypted_value
assert isinstance(visibility, str), visibility
assert is_optional_list(selected_repositories, github.Repository.Repository), selected_repositories
assert secret_type in ["actions", "dependabot"], "secret_type should be actions or dependabot"

if visibility == "selected":
assert isinstance(selected_repositories, list) and all(
isinstance(element, github.Repository.Repository) for element in selected_repositories
), selected_repositories
else:
assert selected_repositories is NotSet

public_key = self.get_public_key()
public_key = self.get_public_key(secret_type=secret_type)
payload = public_key.encrypt(unencrypted_value)
put_parameters: dict[str, Any] = {
"key_id": public_key.key_id,
"encrypted_value": payload,
"visibility": visibility,
}
if is_defined(selected_repositories):
put_parameters["selected_repository_ids"] = [element.id for element in selected_repositories]
# Dependbot and Actions endpoint expects different types
# https://docs.github.com/en/rest/dependabot/secrets?apiVersion=2022-11-28#create-or-update-an-organization-secret
# https://docs.github.com/en/rest/actions/secrets?apiVersion=2022-11-28#create-or-update-an-organization-secret
if secret_type == "actions":
put_parameters["selected_repository_ids"] = [element.id for element in selected_repositories]
if secret_type == "dependabot":
put_parameters["selected_repository_ids"] = [str(element.id) for element in selected_repositories]

self._requester.requestJsonAndCheck(
"PUT", f"{self.url}/actions/secrets/{urllib.parse.quote(secret_name)}", input=put_parameters
"PUT", f"{self.url}/{secret_type}/secrets/{urllib.parse.quote(secret_name)}", input=put_parameters
)

return github.OrganizationSecret.OrganizationSecret(
Expand All @@ -617,8 +633,8 @@ def create_secret(
attributes={
"name": secret_name,
"visibility": visibility,
"selected_repositories_url": f"{self.url}/actions/secrets/{urllib.parse.quote(secret_name)}/repositories",
"url": f"{self.url}/actions/secrets/{urllib.parse.quote(secret_name)}",
"selected_repositories_url": f"{self.url}/{secret_type}/secrets/{urllib.parse.quote(secret_name)}/repositories",
"url": f"{self.url}/{secret_type}/secrets/{urllib.parse.quote(secret_name)}",
},
completed=False,
)
Expand Down Expand Up @@ -647,6 +663,7 @@ def get_secret(self, secret_name: str, secret_type: str = "actions") -> Organiza
:rtype: github.OrganizationSecret.OrganizationSecret
"""
assert isinstance(secret_name, str), secret_name
assert secret_type in ["actions", "dependabot"], "secret_type should be actions or dependabot"
return github.OrganizationSecret.OrganizationSecret(
requester=self._requester,
headers={},
Expand Down Expand Up @@ -1010,12 +1027,13 @@ def convert_to_outside_collaborator(self, member: NamedUser) -> None:
"PUT", f"{self.url}/outside_collaborators/{member._identity}"
)

def get_public_key(self) -> PublicKey:
def get_public_key(self, secret_type: str = "actions") -> PublicKey:
"""
:calls: `GET /orgs/{org}/actions/secrets/public-key <https://docs.github.com/en/rest/reference/actions#get-an-organization-public-key>`_
:calls: `GET /orgs/{org}/{secret_type}/secrets/public-key <https://docs.github.com/en/rest/reference/actions#get-an-organization-public-key>`_
:param secret_type: string options actions or dependabot
:rtype: :class:`github.PublicKey.PublicKey`
"""
headers, data = self._requester.requestJsonAndCheck("GET", f"{self.url}/actions/secrets/public-key")
headers, data = self._requester.requestJsonAndCheck("GET", f"{self.url}/{secret_type}/secrets/public-key")
return github.PublicKey.PublicKey(self._requester, headers, data, completed=True)

def get_repo(self, name: str) -> Repository:
Expand Down
7 changes: 5 additions & 2 deletions github/OrganizationSecret.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,16 +70,19 @@ def edit(
self,
value: str,
visibility: str = "all",
secret_type: str = "actions",
) -> bool:
"""
:calls: `PATCH /orgs/{org}/actions/secrets/{variable_name} <https://docs.github.com/en/rest/reference/actions/secrets#update-an-organization-variable>`_
:calls: `PATCH /orgs/{org}/{secret_type}/secrets/{variable_name} <https://docs.github.com/en/rest/reference/actions/secrets#update-an-organization-variable>`_
:param variable_name: string
:param value: string
:param visibility: string
:param secret_type: string options actions or dependabot
:rtype: bool
"""
assert isinstance(value, str), value
assert isinstance(visibility, str), visibility
assert secret_type in ["actions", "dependabot"], "secret_type should be actions or dependabot"

patch_parameters: Dict[str, Any] = {
"name": self.name,
Expand All @@ -89,7 +92,7 @@ def edit(

status, _, _ = self._requester.requestJson(
"PATCH",
f"{self.url}/actions/secrets/{self.name}",
f"{self.url}/{secret_type}/secrets/{self.name}",
input=patch_parameters,
)
return status == 204
Expand Down
57 changes: 41 additions & 16 deletions github/Repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -1695,53 +1695,73 @@ def create_repository_dispatch(self, event_type: str, client_payload: Opt[dict[s
status, headers, data = self._requester.requestJson("POST", f"{self.url}/dispatches", input=post_parameters)
return status == 204

def create_secret(self, secret_name: str, unencrypted_value: str) -> github.Secret.Secret:
def create_secret(
self,
secret_name: str,
unencrypted_value: str,
secret_type: str = "actions",
) -> github.Secret.Secret:
"""
:calls: `PUT /repos/{owner}/{repo}/actions/secrets/{secret_name} <https://docs.github.com/en/rest/actions/secrets#get-a-repository-secret>`_
:calls: `PUT /repos/{owner}/{repo}/{secret_type}/secrets/{secret_name} <https://docs.github.com/en/rest/actions/secrets#get-a-repository-secret>`_
:param secret_type: string options actions or dependabot
"""
assert isinstance(secret_name, str), secret_name
assert isinstance(unencrypted_value, str), unencrypted_value
assert secret_type in ["actions", "dependabot"], "secret_type should be actions or dependabot"

secret_name = urllib.parse.quote(secret_name)
public_key = self.get_public_key()
public_key = self.get_public_key(secret_type=secret_type)
payload = public_key.encrypt(unencrypted_value)
put_parameters = {
"key_id": public_key.key_id,
"encrypted_value": payload,
}
self._requester.requestJsonAndCheck("PUT", f"{self.url}/actions/secrets/{secret_name}", input=put_parameters)
self._requester.requestJsonAndCheck(
"PUT", f"{self.url}/{secret_type}/secrets/{secret_name}", input=put_parameters
)
return github.Secret.Secret(
requester=self._requester,
headers={},
attributes={
"name": secret_name,
"url": f"{self.url}/actions/secrets/{secret_name}",
"url": f"{self.url}/{secret_type}/secrets/{secret_name}",
},
completed=False,
)

def get_secrets(self) -> PaginatedList[github.Secret.Secret]:
def get_secrets(
self,
secret_type: str = "actions",
) -> PaginatedList[github.Secret.Secret]:
"""
Gets all repository secrets.
Gets all repository secrets :param secret_type: string options actions or dependabot.
"""
assert secret_type in ["actions", "dependabot"], "secret_type should be actions or dependabot"

return PaginatedList(
github.Secret.Secret,
self._requester,
f"{self.url}/actions/secrets",
f"{self.url}/{secret_type}/secrets",
None,
attributesTransformer=PaginatedList.override_attributes({"secrets_url": f"{self.url}/actions/secrets"}),
attributesTransformer=PaginatedList.override_attributes(
{"secrets_url": f"{self.url}/{secret_type}/secrets"}
),
list_item="secrets",
)

def get_secret(self, secret_name: str) -> github.Secret.Secret:
def get_secret(self, secret_name: str, secret_type: str = "actions") -> github.Secret.Secret:
"""
:calls: 'GET /repos/{owner}/{repo}/actions/secrets/{secret_name} <https://docs.github.com/en/rest/actions/secrets#get-an-organization-secret>`_
:param secret_type: string options actions or dependabot
"""
assert isinstance(secret_name, str), secret_name
assert secret_type in ["actions", "dependabot"], "secret_type should be actions or dependabot"

secret_name = urllib.parse.quote(secret_name)
return github.Secret.Secret(
requester=self._requester,
headers={},
attributes={"url": f"{self.url}/actions/secrets/{secret_name}"},
attributes={"url": f"{self.url}/{secret_type}/secrets/{secret_name}"},
completed=False,
)

Expand Down Expand Up @@ -1795,15 +1815,17 @@ def get_variable(self, variable_name: str) -> github.Variable.Variable:
completed=False,
)

def delete_secret(self, secret_name: str) -> bool:
def delete_secret(self, secret_name: str, secret_type: str = "actions") -> bool:
"""
:calls: `DELETE /repos/{owner}/{repo}/actions/secrets/{secret_name} <https://docs.github.com/en/rest/reference/actions#delete-a-repository-secret>`_
:calls: `DELETE /repos/{owner}/{repo}/{secret_type}/secrets/{secret_name} <https://docs.github.com/en/rest/reference/actions#delete-a-repository-secret>`_
:param secret_name: string
:param secret_type: string options actions or dependabot
:rtype: bool
"""
assert isinstance(secret_name, str), secret_name
assert secret_type in ["actions", "dependabot"], "secret_type should be actions or dependabot"
secret_name = urllib.parse.quote(secret_name)
status, headers, data = self._requester.requestJson("DELETE", f"{self.url}/actions/secrets/{secret_name}")
status, headers, data = self._requester.requestJson("DELETE", f"{self.url}/{secret_type}/secrets/{secret_name}")
return status == 204

def delete_variable(self, variable_name: str) -> bool:
Expand Down Expand Up @@ -3004,12 +3026,15 @@ def get_network_events(self) -> PaginatedList[Event]:
None,
)

def get_public_key(self) -> PublicKey:
def get_public_key(self, secret_type: str = "actions") -> PublicKey:
"""
:calls: `GET /repos/{owner}/{repo}/actions/secrets/public-key <https://docs.github.com/en/rest/reference/actions#get-a-repository-public-key>`_
:param secret_type: string options actions or dependabot
:rtype: :class:`github.PublicKey.PublicKey`
"""
headers, data = self._requester.requestJsonAndCheck("GET", f"{self.url}/actions/secrets/public-key")
assert secret_type in ["actions", "dependabot"], "secret_type should be actions or dependabot"

headers, data = self._requester.requestJsonAndCheck("GET", f"{self.url}/{secret_type}/secrets/public-key")
return github.PublicKey.PublicKey(self._requester, headers, data, completed=True)

def get_pull(self, number: int) -> PullRequest:
Expand Down
74 changes: 66 additions & 8 deletions tests/Organization.py
Original file line number Diff line number Diff line change
Expand Up @@ -429,19 +429,19 @@ def testCreateRepoFromTemplateWithAllArguments(self):
self.assertEqual(repo.description, description)
self.assertTrue(repo.private)

@mock.patch("github.PublicKey.encrypt")
def testCreateSecret(self, encrypt):
# encrypt returns a non-deterministic value, we need to mock it so the replay data matches
encrypt.return_value = "M+5Fm/BqTfB90h3nC7F3BoZuu3nXs+/KtpXwxm9gG211tbRo0F5UiN0OIfYT83CKcx9oKES9Va4E96/b"
secret = self.org.create_secret("secret-name", "secret-value", "all")
self.assertIsNotNone(secret)

@mock.patch("github.PublicKey.encrypt")
def testCreateSecretSelected(self, encrypt):
repos = [self.org.get_repo("TestPyGithub"), self.org.get_repo("FatherBeaver")]
# encrypt returns a non-deterministic value, we need to mock it so the replay data matches
encrypt.return_value = "M+5Fm/BqTfB90h3nC7F3BoZuu3nXs+/KtpXwxm9gG211tbRo0F5UiN0OIfYT83CKcx9oKES9Va4E96/b"
secret = self.org.create_secret("secret-name", "secret-value", "selected", repos)
secret = self.org.create_secret(
secret_name="secret-name",
unencrypted_value="secret-value",
visibility="selected",
secret_type="actions",
selected_repositories=repos,
)

self.assertIsNotNone(secret)
self.assertEqual(secret.visibility, "selected")
self.assertEqual(list(secret.selected_repositories), repos)
Expand Down Expand Up @@ -569,3 +569,61 @@ def testGetVariable(self):
def testGetVariables(self):
variables = self.org.get_variables()
self.assertEqual(len(list(variables)), 1)

@mock.patch("github.PublicKey.encrypt")
def testCreateActionsSecret(self, encrypt):
org = self.g.get_organization("demoorg")
# encrypt returns a non-deterministic value, we need to mock it so the replay data matches
encrypt.return_value = "M+5Fm/BqTfB90h3nC7F3BoZuu3nXs+/KtpXwxm9gG211tbRo0F5UiN0OIfYT83CKcx9oKES9Va4E96/b"
secret = org.create_secret("secret_name", "secret-value", visibility="all")
self.assertIsNotNone(secret)

@mock.patch("github.PublicKey.encrypt")
def testCreateDependabotSecret(self, encrypt):
org = self.g.get_organization("demoorg")
# encrypt returns a non-deterministic value, we need to mock it so the replay data matches
encrypt.return_value = "M+5Fm/BqTfB90h3nC7F3BoZuu3nXs+/KtpXwxm9gG211tbRo0F5UiN0OIfYT83CKcx9oKES9Va4E96/b"
secret = org.create_secret("secret_name", "secret-value", secret_type="dependabot", visibility="all")
self.assertIsNotNone(secret)

def testOrgGetSecretAssertion(self):
org = self.g.get_organization("demoorg")
with self.assertRaises(AssertionError) as exc:
org.get_secret(secret_name="splat", secret_type="supersecret")
self.assertEqual(str(exc.exception), "secret_type should be actions or dependabot")

@mock.patch("github.PublicKey.encrypt")
def testCreateDependabotSecretSelected(self, encrypt):
org = self.g.get_organization("demoorg")
repos = [org.get_repo("demo-repo-1"), org.get_repo("demo-repo-2")]
# encrypt returns a non-deterministic value, we need to mock it so the replay data matches
encrypt.return_value = "M+5Fm/BqTfB90h3nC7F3BoZuu3nXs+/KtpXwxm9gG211tbRo0F5UiN0OIfYT83CKcx9oKES9Va4E96/b"
secret = org.create_secret(
secret_name="SECRET_DEP_NAME",
unencrypted_value="secret-value",
visibility="selected",
secret_type="dependabot",
selected_repositories=repos,
)

self.assertIsNotNone(secret)
self.assertEqual(secret.visibility, "selected")
self.assertEqual(list(secret.selected_repositories), repos)

@mock.patch("github.PublicKey.encrypt")
def testOrgSecretEdit(self, encrypt):
org = self.g.get_organization("demoorg")
repos = [org.get_repo("demo-repo-1"), org.get_repo("demo-repo-2")]
# encrypt returns a non-deterministic value, we need to mock it so the replay data matches
encrypt.return_value = "M+5Fm/BqTfB90h3nC7F3BoZuu3nXs+/KtpXwxm9gG211tbRo0F5UiN0OIfYT83CKcx9oKES9Va4E96/b"
secret = org.create_secret(
secret_name="secret_act_name",
unencrypted_value="secret-value",
visibility="selected",
secret_type="actions",
selected_repositories=repos,
)

with self.assertRaises(AssertionError) as exc:
secret.edit(value="newvalue", secret_type="supersecret")
self.assertEqual(str(exc.exception), "secret_type should be actions or dependabot")

0 comments on commit 0784f83

Please sign in to comment.