Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support creation of Dependabot Organization and Repository Secrets #2874

Merged
merged 25 commits into from Mar 21, 2024
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
13ca617
All creation of github dependabot secrets
thomascrowley Jan 17, 2024
832201e
Add some tests
thomascrowley Jan 17, 2024
089505f
encrypt the secret
thomascrowley Jan 17, 2024
e69fb82
fix assertion
thomascrowley Jan 17, 2024
4161453
create repo secrets too
thomascrowley Jan 17, 2024
b9af2e1
fix linting
thomascrowley Jan 17, 2024
99c7b98
add replay files
thomascrowley Jan 17, 2024
c9b1db4
Fixing tests , updating Repository Public Key config
smkillen Jan 17, 2024
353c33e
Refactoring Tests to isolate to issue and specific ReplayData
smkillen Jan 17, 2024
f02213f
Refactoring Tests , tidying up and making PyTest pass
smkillen Jan 17, 2024
337afb1
Update Repository.py
thomascrowley Jan 18, 2024
14f3cea
add more tests to increase code coverage
thomascrowley Jan 18, 2024
1bb3d91
address review comments
thomascrowley Jan 22, 2024
b4ddf7e
address review comments
thomascrowley Jan 31, 2024
9a81ceb
Moving tests to respective files (#3)
smkillen Feb 5, 2024
b4b4eaf
Apply suggestions from code review
thomascrowley Feb 8, 2024
fad4a7d
Apply suggestions from code review
thomascrowley Feb 8, 2024
b25a10d
Review Comments: Removing redundant ReplayData and unneeded self. ref…
smkillen Feb 8, 2024
d829899
Review Comments: Changing Testing of secret_type assertion (#4)
smkillen Mar 13, 2024
f49df36
Merge branch 'main' into main
thomascrowley Mar 13, 2024
106c27e
run pre-commit
thomascrowley Mar 13, 2024
76a6b0b
update delete secret option
thomascrowley Mar 13, 2024
64eda25
Merge branch 'main' into main
thomascrowley Mar 20, 2024
c72f22b
Fix error assertion
EnricoMi Mar 21, 2024
8ad1bf7
Merge branch 'main' into main
EnricoMi Mar 21, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
13 changes: 12 additions & 1 deletion CONTRIBUTING.md
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
thomascrowley marked this conversation as resolved.
Show resolved Hide resolved
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
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>`_
"""
thomascrowley marked this conversation as resolved.
Show resolved Hide resolved
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`
"""
thomascrowley marked this conversation as resolved.
Show resolved Hide resolved
thomascrowley marked this conversation as resolved.
Show resolved Hide resolved
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
Expand Up @@ -70,16 +70,19 @@ def edit(
self,
value: str,
visibility: str = "all",
secret_type: str = "actions",
thomascrowley marked this conversation as resolved.
Show resolved Hide resolved
) -> 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
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
"""
thomascrowley marked this conversation as resolved.
Show resolved Hide resolved
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.
"""
thomascrowley marked this conversation as resolved.
Show resolved Hide resolved
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
"""
thomascrowley marked this conversation as resolved.
Show resolved Hide resolved
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`
thomascrowley marked this conversation as resolved.
Show resolved Hide resolved
"""
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
75 changes: 67 additions & 8 deletions tests/Organization.py
Expand Up @@ -429,19 +429,19 @@ def testCreateRepoFromTemplateWithAllArguments(self):
self.assertEqual(repo.description, description)
self.assertTrue(repo.private)

@mock.patch("github.PublicKey.encrypt")
thomascrowley marked this conversation as resolved.
Show resolved Hide resolved
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,62 @@ 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,
)

try:
secret.edit(value="newvalue", secret_type="supersecret")
except AssertionError:
assert True
EnricoMi marked this conversation as resolved.
Show resolved Hide resolved