diff --git a/.env-example b/.env-example index dfb184d..6baf9c9 100644 --- a/.env-example +++ b/.env-example @@ -1,2 +1,16 @@ -GH_TOKEN = " " -ORGANIZATION = "organization" +DRY_RUN = "false" # true or false +EXEMPT_REPOS = "" # comma separated list of repositories to exempt +GH_ENTERPRISE_URL = "" +GH_TOKEN = "" +ORGANIZATION = "" +REPOSITORY = "" # comma separated list of repositories in the format org/repo + +# GITHUB APP +GH_APP_ID = "" +GH_INSTALLATION_ID = "" +GH_PRIVATE_KEY = "" + +# OPTIONAL SETTINGS +BODY = "" +COMMIT_MESSAGE = "" +TITLE = "" diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 336198d..b00ee6a 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -27,8 +27,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install flake8 pylint pytest pytest-cov - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + pip install -r requirements.txt -r requirements-test.txt - name: Lint with flake8 and pylint run: | make lint diff --git a/README.md b/README.md index 1ef331b..103cf14 100644 --- a/README.md +++ b/README.md @@ -25,14 +25,17 @@ If you need support using this project or have questions about it, please [open Below are the allowed configuration options: -| field | required | default | description | -|-----------------------|----------|---------|-------------| -| `GH_TOKEN` | True | "" | The GitHub Token used to scan the repository or organization. Must have write access to all repository you are interested in scanning so that an issue or pull request can be created. | -| `GH_ENTERPRISE_URL` | False | "" | The `GH_ENTERPRISE_URL` is used to connect to an enterprise server instance of GitHub. github.com users should not enter anything here. | -| `ORGANIZATION` | Required to have `ORGANIZATION` or `REPOSITORY` | | The name of the GitHub organization which you want this action to work from. ie. github.com/github would be `github` | -| `REPOSITORY` | Required to have `ORGANIZATION` or `REPOSITORY` | | The name of the repository and organization which you want this action to work from. ie. `github/cleanowners` or a comma separated list of multiple repositories `github/cleanowners,super-linter/super-linter` | -| `EXEMPT_REPOS` | False | "" | These repositories will be exempt from this action. ex: If my org is set to `github` then I might want to exempt a few of the repos but get the rest by setting `EXEMPT_REPOS` to `github/cleanowners,github/contributors` | -| `DRY_RUN` | False | false | If set to true, this action will not create any pull requests. It will only log the repositories that could have the `CODEOWNERS` file updated. This is useful for testing or discovering the scope of this issue in your organization. | +| field | required | default | description | +|---------------------------|----------|---------|-------------| +| `GH_TOKEN` | True | "" | The GitHub Token used to scan the repository or organization. Must have write access to all repository you are interested in scanning so that an issue or pull request can be created. | +| `GH_APP_ID` | False | `""` | GitHub Application ID. See [documentation](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/about-authentication-with-a-github-app) for more details. | +| `GH_APP_INSTALLATION_ID` | False | `""` | GitHub Application Installation ID. See [documentation](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/about-authentication-with-a-github-app) for more details. | +| `GH_APP_PRIVATE_KEY` | False | `""` | GitHub Application Private Key. See [documentation](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/about-authentication-with-a-github-app) for more details. | +| `GH_ENTERPRISE_URL` | False | "" | The `GH_ENTERPRISE_URL` is used to connect to an enterprise server instance of GitHub. github.com users should not enter anything here. | +| `ORGANIZATION` | Required to have `ORGANIZATION` or `REPOSITORY` | | The name of the GitHub organization which you want this action to work from. ie. github.com/github would be `github` | +| `REPOSITORY` | Required to have `ORGANIZATION` or `REPOSITORY` | | The name of the repository and organization which you want this action to work from. ie. `github/cleanowners` or a comma separated list of multiple repositories `github/cleanowners,super-linter/super-linter` | +| `EXEMPT_REPOS` | False | "" | These repositories will be exempt from this action. ex: If my org is set to `github` then I might want to exempt a few of the repos but get the rest by setting `EXEMPT_REPOS` to `github/cleanowners,github/contributors` | +| `DRY_RUN` | False | False | If set to true, this action will not create any pull requests. It will only log the repositories that could have the `CODEOWNERS` file updated. This is useful for testing or discovering the scope of this issue in your organization. | ### Example workflows @@ -90,6 +93,37 @@ jobs: ``` +### Authenticating with a GitHub App and Installation + +You can authenticate as a GitHub App Installation by providing additional environment variables. If `GH_TOKEN` is set alongside these GitHub App Installation variables, the `GH_TOKEN` will be ignored and not used. + +```yaml +--- +name: Weekly codeowners cleanup via GitHub App +on: + workflow_dispatch: + schedule: + - cron: '3 2 1 * *' + +permissions: + issues: write + +jobs: + cleanowners: + name: cleanowners + runs-on: ubuntu-latest + + steps: + - name: Run cleanowners action + uses: github/cleanowners@v1 + env: + GH_APP_ID: ${{ secrets.GH_APP_ID }} + GH_APP_INSTALLATION_ID: ${{ secrets.GH_APP_INSTALLATION_ID }} + GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} + ORGANIZATION: + EXEMPT_REPOS: "org_name/repo_name_1, org_name/repo_name_2" +``` + ## Local usage without Docker 1. Make sure you have at least Python3.11 installed diff --git a/auth.py b/auth.py index 7d39446..217d8a2 100644 --- a/auth.py +++ b/auth.py @@ -3,24 +3,41 @@ import github3 -def auth_to_github(token: str, ghe: str) -> github3.GitHub: +def auth_to_github( + gh_app_id: str, + gh_app_installation_id: int, + gh_app_private_key_bytes: bytes, + token: str, + ghe: str, +) -> github3.GitHub: """ Connect to GitHub.com or GitHub Enterprise, depending on env variables. Args: + gh_app_id (str): the GitHub App ID + gh_installation_id (int): the GitHub App Installation ID + gh_app_private_key (bytes): the GitHub App Private Key token (str): the GitHub personal access token ghe (str): the GitHub Enterprise URL Returns: github3.GitHub: the GitHub connection object """ - if not token: - raise ValueError("GH_TOKEN environment variable not set") - if ghe: + if gh_app_id and gh_app_private_key_bytes and gh_app_installation_id: + gh = github3.github.GitHub() + gh.login_as_app_installation( + gh_app_private_key_bytes, gh_app_id, gh_app_installation_id + ) + github_connection = gh + elif ghe and token: github_connection = github3.github.GitHubEnterprise(ghe, token=token) - else: + elif token: github_connection = github3.login(token=token) + else: + raise ValueError( + "GH_TOKEN or the set of [GH_APP_ID, GH_APP_INSTALLATION_ID, GH_APP_PRIVATE_KEY] environment variables are not set" + ) if not github_connection: raise ValueError("Unable to authenticate to GitHub") diff --git a/cleanowners.py b/cleanowners.py index f7fc22f..b5891ad 100644 --- a/cleanowners.py +++ b/cleanowners.py @@ -14,6 +14,9 @@ def main(): # pragma: no cover ( organization, repository_list, + gh_app_id, + gh_app_installation_id, + gh_app_private_key_bytes, token, ghe, exempt_repositories_list, @@ -24,7 +27,9 @@ def main(): # pragma: no cover ) = env.get_env_vars() # Auth to GitHub.com or GHE - github_connection = auth.auth_to_github(token, ghe) + github_connection = auth.auth_to_github( + gh_app_id, gh_app_installation_id, gh_app_private_key_bytes, token, ghe + ) pull_count = 0 eligble_for_pr_count = 0 no_codeowners_count = 0 diff --git a/env.py b/env.py index ce65aeb..6f36d98 100644 --- a/env.py +++ b/env.py @@ -8,19 +8,53 @@ from dotenv import load_dotenv -def get_env_vars() -> ( - tuple[str | None, list[str], str, str, list[str], bool, str, str, str] -): +def get_int_env_var(env_var_name: str) -> int | None: + """Get an integer environment variable. + + Args: + env_var_name: The name of the environment variable to retrieve. + + Returns: + The value of the environment variable as an integer or None. + """ + env_var = os.environ.get(env_var_name) + if env_var is None or not env_var.strip(): + return None + try: + return int(env_var) + except ValueError: + return None + + +def get_env_vars( + test: bool = False, +) -> tuple[ + str | None, + list[str], + int | None, + int | None, + bytes, + str | None, + str, + list[str], + bool, + str, + str, + str, +]: """ Get the environment variables for use in the action. Args: - None + test (bool): Whether or not to load the environment variables from a .env file (default: False) Returns: - organization (str): The organization to search for repositories in + organization (str | None): The organization to search for repositories in repository_list (list[str]): A list of repositories to search for - token (str): The GitHub token to use for authentication + gh_app_id (int | None): The GitHub App ID to use for authentication + gh_app_installation_id (int | None): The GitHub App Installation ID to use for authentication + gh_app_private_key_bytes (bytes): The GitHub App Private Key as bytes to use for authentication + token (str | None): The GitHub token to use for authentication ghe (str): The GitHub Enterprise URL to use for authentication exempt_repositories_list (list[str]): A list of repositories to exempt from the action dry_run (bool): Whether or not to actually open issues/pull requests @@ -29,9 +63,10 @@ def get_env_vars() -> ( message (str): Commit message to use """ - # Load from .env file if it exists - dotenv_path = join(dirname(__file__), ".env") - load_dotenv(dotenv_path) + if not test: + # Load from .env file if it exists + dotenv_path = join(dirname(__file__), ".env") + load_dotenv(dotenv_path) organization = os.getenv("ORGANIZATION") repositories_str = os.getenv("REPOSITORY") @@ -53,9 +88,22 @@ def get_env_vars() -> ( repository.strip() for repository in repositories_str.split(",") ] + gh_app_id = get_int_env_var("GH_APP_ID") + gh_app_private_key_bytes = os.environ.get("GH_APP_PRIVATE_KEY", "").encode("utf8") + gh_app_installation_id = get_int_env_var("GH_APP_INSTALLATION_ID") + + if gh_app_id and (not gh_app_private_key_bytes or not gh_app_installation_id): + raise ValueError( + "GH_APP_ID set and GH_APP_INSTALLATION_ID or GH_APP_PRIVATE_KEY variable not set" + ) + token = os.getenv("GH_TOKEN") - # required env variable - if not token: + if ( + not gh_app_id + and not gh_app_private_key_bytes + and not gh_app_installation_id + and not token + ): raise ValueError("GH_TOKEN environment variable not set") ghe = os.getenv("GH_ENTERPRISE_URL", default="").strip() @@ -110,6 +158,9 @@ def get_env_vars() -> ( return ( organization, repositories_list, + gh_app_id, + gh_app_installation_id, + gh_app_private_key_bytes, token, ghe, exempt_repositories_list, diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 0000000..d950ff9 --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1,4 @@ +flake8==7.0.0 +pylint==3.1.0 +pytest==8.1.1 +pytest-cov==4.1.0 diff --git a/requirements.txt b/requirements.txt index 66304ed..820c2d0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,2 @@ github3.py==4.0.1 python-dotenv==1.0.1 -pytest==8.1.1 -pytest-cov==4.1.0 \ No newline at end of file diff --git a/test_auth.py b/test_auth.py index 9c8786c..760cad4 100644 --- a/test_auth.py +++ b/test_auth.py @@ -1,8 +1,9 @@ """Test cases for the auth module.""" import unittest -from unittest.mock import patch +from unittest.mock import MagicMock, patch import auth +import github3.github class TestAuth(unittest.TestCase): @@ -10,16 +11,24 @@ class TestAuth(unittest.TestCase): Test case for the auth module. """ - @patch("github3.login") - def test_auth_to_github_with_token(self, mock_login): + @patch("github3.github.GitHub.login_as_app_installation") + def test_auth_to_github_with_github_app(self, mock_login): """ - Test the auth_to_github function when the token is provided. + Test the auth_to_github function when GitHub app + parameters provided. """ - mock_login.return_value = "Authenticated to GitHub.com" + mock_login.return_value = MagicMock() + result = auth.auth_to_github(12345, 678910, b"hello", "", "") + + self.assertIsInstance(result, github3.github.GitHub) - result = auth.auth_to_github("token", "") + def test_auth_to_github_with_token(self): + """ + Test the auth_to_github function when the token is provided. + """ + result = auth.auth_to_github(None, None, b"", "token", "") - self.assertEqual(result, "Authenticated to GitHub.com") + self.assertIsInstance(result, github3.github.GitHub) def test_auth_to_github_without_token(self): """ @@ -27,17 +36,17 @@ def test_auth_to_github_without_token(self): Expect a ValueError to be raised. """ with self.assertRaises(ValueError): - auth.auth_to_github("", "") + auth.auth_to_github(None, None, b"", "", "") - @patch("github3.github.GitHubEnterprise") - def test_auth_to_github_with_ghe(self, mock_ghe): + def test_auth_to_github_with_ghe(self): """ Test the auth_to_github function when the GitHub Enterprise URL is provided. """ - mock_ghe.return_value = "Authenticated to GitHub Enterprise" - result = auth.auth_to_github("token", "https://github.example.com") + result = auth.auth_to_github( + None, None, b"", "token", "https://github.example.com" + ) - self.assertEqual(result, "Authenticated to GitHub Enterprise") + self.assertIsInstance(result, github3.github.GitHubEnterprise) if __name__ == "__main__": diff --git a/test_env.py b/test_env.py index 28d3558..264ea36 100644 --- a/test_env.py +++ b/test_env.py @@ -6,120 +6,215 @@ from env import get_env_vars +BODY = "Consider these updates to the CODEOWNERS file to remove users no longer in this organization." +COMMIT_MESSAGE = "Remove users no longer in this organization from CODEOWNERS file" +ORGANIZATION = "Organization01" +TITLE = "Clean up CODEOWNERS file" +TOKEN = "Token01" + class TestEnv(unittest.TestCase): """Test the get_env_vars function""" + def setUp(self): + env_keys = [ + "BODY", + "COMMIT_MESSAGE", + "DRY_RUN", + "EXEMPT_REPOS", + "GH_APP_ID", + "GH_ENTERPRISE_URL", + "GH_APP_INSTALLATION_ID", + "GH_APP_PRIVATE_KEY", + "GH_TOKEN", + "ORGANIZATION", + "REPOSITORY", + "TITLE", + ] + for key in env_keys: + if key in os.environ: + del os.environ[key] + @patch.dict( os.environ, { - "ORGANIZATION": "my_organization", - "GH_TOKEN": "my_token", + "BODY": BODY, + "COMMIT_MESSAGE": COMMIT_MESSAGE, + "DRY_RUN": "false", "EXEMPT_REPOS": "repo4,repo5", - "DRY_RUN": "False", - "TITLE": "Title01", - "BODY": "Body01", - "COMMIT_MESSAGE": "Commit01", + "GH_APP_ID": "", + "GH_ENTERPRISE_URL": "", + "GH_APP_INSTALLATION_ID": "", + "GH_APP_PRIVATE_KEY": "", + "GH_TOKEN": TOKEN, + "ORGANIZATION": ORGANIZATION, + "REPOSITORY": "org/repo1,org2/repo2", + "TITLE": TITLE, }, ) def test_get_env_vars_with_org(self): """Test that all environment variables are set correctly using an organization""" expected_result = ( - "my_organization", - [], - "my_token", + ORGANIZATION, + ["org/repo1", "org2/repo2"], + None, + None, + b"", + TOKEN, "", ["repo4", "repo5"], False, - "Title01", - "Body01", - "Commit01", + TITLE, + BODY, + COMMIT_MESSAGE, ) - result = get_env_vars() + result = get_env_vars(True) self.assertEqual(result, expected_result) @patch.dict( os.environ, { - "REPOSITORY": "org/repo1,org2/repo2", - "GH_TOKEN": "my_token", + "BODY": BODY, + "COMMIT_MESSAGE": COMMIT_MESSAGE, + "DRY_RUN": "true", "EXEMPT_REPOS": "repo4,repo5", + "GH_APP_ID": "12345", + "GH_ENTERPRISE_URL": "", + "GH_APP_INSTALLATION_ID": "678910", + "GH_APP_PRIVATE_KEY": "hello", + "GH_TOKEN": "", + "ORGANIZATION": "", + "REPOSITORY": "org/repo1,org2/repo2", + "TITLE": TITLE, + }, + clear=True, + ) + def test_get_env_vars_with_github_app_and_repos(self): + """Test that all environment variables are set correctly using a list of repositories""" + expected_result = ( + "", + ["org/repo1", "org2/repo2"], + 12345, + 678910, + b"hello", + "", + "", + ["repo4", "repo5"], + True, + TITLE, + BODY, + COMMIT_MESSAGE, + ) + result = get_env_vars(True) + self.assertEqual(result, expected_result) + + @patch.dict( + os.environ, + { + "BODY": BODY, + "COMMIT_MESSAGE": COMMIT_MESSAGE, "DRY_RUN": "true", + "EXEMPT_REPOS": "repo4,repo5", + "GH_APP_ID": "", + "GH_ENTERPRISE_URL": "", + "GH_APP_INSTALLATION_ID": "", + "GH_APP_PRIVATE_KEY": "", + "GH_TOKEN": TOKEN, + "ORGANIZATION": "", + "REPOSITORY": "org/repo1,org2/repo2", + "TITLE": TITLE, }, clear=True, ) - def test_get_env_vars_with_repos(self): + def test_get_env_vars_with_token_and_repos(self): """Test that all environment variables are set correctly using a list of repositories""" expected_result = ( - None, + "", ["org/repo1", "org2/repo2"], - "my_token", + None, + None, + b"", + TOKEN, "", ["repo4", "repo5"], True, - "Clean up CODEOWNERS file", - "Consider these updates to the CODEOWNERS file to remove users no longer in this organization.", - "Remove users no longer in this organization from CODEOWNERS file", + TITLE, + BODY, + COMMIT_MESSAGE, ) - result = get_env_vars() + result = get_env_vars(True) self.assertEqual(result, expected_result) @patch.dict( os.environ, { - "ORGANIZATION": "my_organization", - "GH_TOKEN": "my_token", + "BODY": BODY, + "COMMIT_MESSAGE": COMMIT_MESSAGE, + "GH_APP_ID": "", + "GH_APP_INSTALLATION_ID": "", + "GH_APP_PRIVATE_KEY": "", + "GH_TOKEN": TOKEN, + "ORGANIZATION": ORGANIZATION, + "TITLE": TITLE, }, ) def test_get_env_vars_optional_values(self): """Test that optional values are set to their default values if not provided""" expected_result = ( - "my_organization", + ORGANIZATION, [], - "my_token", + None, + None, + b"", + TOKEN, "", [], False, - "Clean up CODEOWNERS file", - "Consider these updates to the CODEOWNERS file to remove users no longer in this organization.", - "Remove users no longer in this organization from CODEOWNERS file", + TITLE, + BODY, + COMMIT_MESSAGE, ) - result = get_env_vars() + result = get_env_vars(True) self.assertEqual(result, expected_result) @patch.dict(os.environ, {}) def test_get_env_vars_missing_org_or_repo(self): """Test that an error is raised if required environment variables are not set""" with self.assertRaises(ValueError): - get_env_vars() + get_env_vars(True) @patch.dict( os.environ, { - "ORGANIZATION": "my_organization", + "ORGANIZATION": ORGANIZATION, }, clear=True, ) def test_get_env_vars_missing_token(self): """Test that an error is raised if required environment variables are not set""" with self.assertRaises(ValueError): - get_env_vars() + get_env_vars(True) @patch.dict( os.environ, { - "ORGANIZATION": "my_organization", - "GH_TOKEN": "my_token", - "DRY_RUN": "false", + "GH_APP_ID": "", + "GH_APP_INSTALLATION_ID": "", + "GH_APP_PRIVATE_KEY": "", + "GH_TOKEN": TOKEN, + "ORGANIZATION": ORGANIZATION, }, clear=True, ) def test_get_env_vars_with_repos_no_dry_run(self): """Test that all environment variables are set correctly when DRY_RUN is false""" expected_result = ( - "my_organization", + ORGANIZATION, [], - "my_token", + None, + None, + b"", + TOKEN, "", [], False, @@ -127,7 +222,7 @@ def test_get_env_vars_with_repos_no_dry_run(self): "Consider these updates to the CODEOWNERS file to remove users no longer in this organization.", "Remove users no longer in this organization from CODEOWNERS file", ) - result = get_env_vars() + result = get_env_vars(True) self.assertEqual(result, expected_result)