From 0b7eaa954f2b36730da5ae7a9c846e35bfab4382 Mon Sep 17 00:00:00 2001 From: ricardojdsilva87 Date: Tue, 29 Oct 2024 11:00:37 +0000 Subject: [PATCH 1/5] feat: support github enterprise api --- .env-example | 1 + README.md | 14 ++-- auth.py | 48 ++++++++++++++ stale_repos.py | 45 +++++-------- test_auth.py | 78 ++++++++++++++++++++++ test_stale_repos.py | 155 -------------------------------------------- 6 files changed, 152 insertions(+), 189 deletions(-) create mode 100644 auth.py create mode 100644 test_auth.py diff --git a/.env-example b/.env-example index a6d6c81..c7854c4 100644 --- a/.env-example +++ b/.env-example @@ -2,6 +2,7 @@ ADDITIONAL_METRICS = "" GH_APP_ID = "" GH_APP_INSTALLATION_ID = "" GH_APP_PRIVATE_KEY = "" +GITHUB_APP_ENTERPRISE_ONLY = "" GH_ENTERPRISE_URL = "" GH_TOKEN = "" INACTIVE_DAYS = 365 diff --git a/README.md b/README.md index 2aa2bf3..dc370f6 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ All feedback regarding our GitHub Actions, as a whole, should be communicated th ## Use as a GitHub Action 1. Create a repository to host this GitHub Action or select an existing repository. -1. Create the env values from the sample workflow below (GH_TOKEN, ORGANIZATION, EXEMPT_TOPICS) with your information as plain text or repository secrets. More info on creating secrets can be found [here](https://docs.github.com/en/actions/security-guides/encrypted-secrets). +1. Create the env values from the sample workflow below (`GH_TOKEN`, `ORGANIZATION`, `EXEMPT_TOPICS`) with your information as plain text or repository secrets. More info on creating secrets can be found [here](https://docs.github.com/en/actions/security-guides/encrypted-secrets). Note: Your GitHub token will need to have read access to all the repositories in the organization that you want evaluated 1. Copy the below example workflow to your repository and put it in the `.github/workflows/` directory with the file extension `.yml` (ie. `.github/workflows/stale_repos.yml`) @@ -45,11 +45,12 @@ This action can be configured to authenticate with GitHub App Installation or Pe ##### GitHub App Installation -| field | required | default | description | -| ------------------------ | -------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GH_APP_ID` | True | `""` | 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` | True | `""` | 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` | True | `""` | 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. | +| field | required | default | description | +| ---------------------------- | -------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GH_APP_ID` | True | `""` | 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` | True | `""` | 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` | True | `""` | 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. | +| `GITHUB_APP_ENTERPRISE_ONLY` | False | `false` | Set this input to `true` if your app is created in GHE and communicates with GHE. | ##### Personal Access Token (PAT) @@ -243,6 +244,7 @@ jobs: 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 }} + #GITHUB_APP_ENTERPRISE_ONLY: true --> Set this if the gh app was created in GHE and the endpoint is also a GHE instance ORGANIZATION: ${{ secrets.ORGANIZATION }} EXEMPT_TOPICS: "keep,template" INACTIVE_DAYS: 365 diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..b472ade --- /dev/null +++ b/auth.py @@ -0,0 +1,48 @@ +"""This is the module that contains functions related to authenticating to GitHub with a personal access token.""" + +import github3 + + +def auth_to_github( + token: str, + gh_app_id: int | None, + gh_app_installation_id: int | None, + gh_app_private_key_bytes: bytes, + ghe: str, + gh_app_enterprise_only: bool, +) -> github3.GitHub: + """ + Connect to GitHub.com or GitHub Enterprise, depending on env variables. + + Args: + token (str): the GitHub personal access token + gh_app_id (int | None): the GitHub App ID + gh_app_installation_id (int | None): the GitHub App Installation ID + gh_app_private_key_bytes (bytes): the GitHub App Private Key + ghe (str): the GitHub Enterprise URL + gh_app_enterprise_only (bool): Set this to true if the GH APP is created on GHE and needs to communicate with GHE api only + + Returns: + github3.GitHub: the GitHub connection object + """ + if gh_app_id and gh_app_private_key_bytes and gh_app_installation_id: + if ghe and gh_app_enterprise_only: + gh = github3.github.GitHubEnterprise(url=ghe) + else: + 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(url=ghe, token=token) + 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") + return github_connection # type: ignore diff --git a/stale_repos.py b/stale_repos.py index 9771666..ec7dd39 100755 --- a/stale_repos.py +++ b/stale_repos.py @@ -7,6 +7,7 @@ from os.path import dirname, join import github3 +from auth import auth_to_github from dateutil.parser import parse from dotenv import load_dotenv @@ -32,8 +33,22 @@ def main(): # pragma: no cover dotenv_path = join(dirname(__file__), ".env") load_dotenv(dotenv_path) - # Auth to GitHub.com - github_connection = auth_to_github() + token = os.getenv("ORGANIZATION") + gh_app_id = os.getenv("GH_APP_ID") + gh_app_installation_id = os.getenv("GH_APP_INSTALLATION_ID") + gh_app_private_key = os.getenv("GH_APP_PRIVATE_KEY").encode("utf8") + ghe = os.getenv("GH_ENTERPRISE_URL") + gh_app_enterprise_only = os.getenv("GITHUB_APP_ENTERPRISE_ONLY") + + # Auth to GitHub.com or GHE + github_connection = auth_to_github( + token, + gh_app_id, + gh_app_installation_id, + gh_app_private_key, + ghe, + gh_app_enterprise_only, + ) # Set the threshold for inactive days inactive_days_threshold = os.getenv("INACTIVE_DAYS") @@ -346,32 +361,6 @@ def get_int_env_var(env_var_name): return None -def auth_to_github(): - """Connect to GitHub.com or GitHub Enterprise, depending on env variables.""" - 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") - ghe = os.getenv("GH_ENTERPRISE_URL", default="").strip() - token = os.getenv("GH_TOKEN") - - 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) - elif token: - github_connection = github3.login(token=os.getenv("GH_TOKEN")) - else: - raise ValueError("GH_TOKEN environment variable not set") - - if not github_connection: - raise ValueError("Unable to authenticate to GitHub") - return github_connection # type: ignore - - def set_repo_data( repo, days_inactive, active_date_disp, visibility, additional_metrics ): diff --git a/test_auth.py b/test_auth.py new file mode 100644 index 0000000..6133859 --- /dev/null +++ b/test_auth.py @@ -0,0 +1,78 @@ +"""Test cases for the auth module.""" + +import unittest +from unittest.mock import MagicMock, patch + +import auth + + +class TestAuth(unittest.TestCase): + """ + Test case for the auth module. + """ + + @patch("github3.login") + def test_auth_to_github_with_token(self, mock_login): + """ + Test the auth_to_github function when the token is provided. + """ + mock_login.return_value = "Authenticated to GitHub.com" + + result = auth.auth_to_github("token", "", "", b"", "", False) + + self.assertEqual(result, "Authenticated to GitHub.com") + + def test_auth_to_github_without_token(self): + """ + Test the auth_to_github function when the token is not provided. + Expect a ValueError to be raised. + """ + with self.assertRaises(ValueError) as context_manager: + auth.auth_to_github("", "", "", b"", "", False) + the_exception = context_manager.exception + self.assertEqual( + str(the_exception), + "GH_TOKEN or the set of [GH_APP_ID, GH_APP_INSTALLATION_ID, GH_APP_PRIVATE_KEY] environment variables are not set", + ) + + @patch("github3.github.GitHubEnterprise") + def test_auth_to_github_with_ghe(self, mock_ghe): + """ + 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", "", "", b"", "https://github.example.com", False + ) + + self.assertEqual(result, "Authenticated to GitHub Enterprise") + + @patch("github3.github.GitHubEnterprise") + def test_auth_to_github_with_ghe_and_ghe_app(self, mock_ghe): + """ + Test the auth_to_github function when the GitHub Enterprise URL is provided and the app was created in GitHub Enterprise URL. + """ + mock = mock_ghe.return_value + mock.login_as_app_installation = MagicMock(return_value=True) + result = auth.auth_to_github( + "", "123", "123", b"123", "https://github.example.com", True + ) + mock.login_as_app_installation.assert_called_once() + self.assertEqual(result, mock) + + @patch("github3.github.GitHub") + def test_auth_to_github_with_app(self, mock_gh): + """ + Test the auth_to_github function when app credentials are provided + """ + mock = mock_gh.return_value + mock.login_as_app_installation = MagicMock(return_value=True) + result = auth.auth_to_github( + "", "123", "123", b"123", "https://github.example.com", False + ) + mock.login_as_app_installation.assert_called_once() + self.assertEqual(result, mock) + + +if __name__ == "__main__": + unittest.main() diff --git a/test_stale_repos.py b/test_stale_repos.py index 376302d..d76ccc9 100644 --- a/test_stale_repos.py +++ b/test_stale_repos.py @@ -25,7 +25,6 @@ import github3.github from stale_repos import ( - auth_to_github, get_active_date, get_days_since_last_pr, get_days_since_last_release, @@ -37,160 +36,6 @@ ) -class AuthToGithubTestCase(unittest.TestCase): - """ - Unit test case for the auth_to_github() function. - - This test case class contains a set of individual test methods to verify the behavior - of the auth_to_github() function. The function is responsible for connecting to - GitHub.com or GitHub Enterprise based on environment variables. - - The test methods cover different scenarios, such as successful authentication with both - enterprise URL and token, authentication with only a token, missing environment variables, - and authentication failures. - - - """ - - @patch.dict( - os.environ, - { - "GH_APP_ID": "12345", - "GH_APP_PRIVATE_KEY": "FakePrivateKey", - "GH_APP_INSTALLATION_ID": "67890", - "GH_ENTERPRISE_URL": "", - "GH_TOKEN": "", - }, - ) - @patch("github3.github.GitHub.login_as_app_installation") - def test_auth_to_github_app_with_github_app_installation_env_vars(self, mock_login): - """ - Test authentication with both app id, app private key, and installation id. - - This test verifies that when GH_APP_ID, GH_APP_PRIVATE_KEY, and GH_INSTALLATION_ID - environment variables are set, the auth_to_github() function returns a connection - object of type github3.github.GitHub. - - """ - mock_login.return_value = MagicMock() - connection = auth_to_github() - self.assertIsInstance(connection, github3.github.GitHub) - - @patch.dict( - os.environ, - { - "GH_APP_ID": "", - "GH_APP_PRIVATE_KEY": "", - "GH_APP_INSTALLATION_ID": "", - "GH_ENTERPRISE_URL": "https://example.com", - "GH_TOKEN": "abc123", - }, - ) - def test_auth_to_github_with_enterprise_url_and_token(self): - """ - Test authentication with both enterprise URL and token. - - This test verifies that when both the GH_ENTERPRISE_URL and GH_TOKEN environment - variables are set, the auth_to_github() function returns a connection object of - type github3.github.GitHubEnterprise. - - """ - connection = auth_to_github() - self.assertIsInstance(connection, github3.github.GitHubEnterprise) - - @patch.dict( - os.environ, - { - "GH_APP_ID": "", - "GH_APP_PRIVATE_KEY": "", - "GH_APP_INSTALLATION_ID": "", - "GH_ENTERPRISE_URL": "", - "GH_TOKEN": "abc123", - }, - ) - def test_auth_to_github_with_token(self): - """ - Test authentication with only a token. - - This test verifies that when only the GH_TOKEN environment variable is set, - the auth_to_github() function returns a connection object of type github3.github.GitHub. - - """ - connection = auth_to_github() - self.assertIsInstance(connection, github3.github.GitHub) - - @patch.dict( - os.environ, - { - "GH_APP_ID": "", - "GH_APP_PRIVATE_KEY": "", - "GH_APP_INSTALLATION_ID": "", - "GH_ENTERPRISE_URL": "", - "GH_TOKEN": "", - }, - ) - def test_auth_to_github_without_environment_variables(self): - """ - Test authentication with missing environment variables. - - This test verifies that when both the GH_ENTERPRISE_URL and GH_TOKEN environment - variables are empty, the auth_to_github() function raises a ValueError. - - """ - with self.assertRaises(ValueError) as cm: - auth_to_github() - the_exception = cm.exception - self.assertEqual(str(the_exception), "GH_TOKEN environment variable not set") - - @patch.dict( - os.environ, - { - "GH_APP_ID": "", - "GH_APP_PRIVATE_KEY": "", - "GH_APP_INSTALLATION_ID": "", - "GH_ENTERPRISE_URL": "", - "GH_TOKEN": "abc123", - }, - ) - def test_auth_to_github_without_enterprise_url(self): - """ - Test authentication without an enterprise URL. - - This test verifies that when the GH_ENTERPRISE_URL environment variable is empty, - and the GH_TOKEN environment variable is set, the auth_to_github() function returns - a connection object of type github3.github.GitHub. - - """ - connection = auth_to_github() - self.assertIsInstance(connection, github3.github.GitHub) - - @patch("github3.login") - def test_auth_to_github_authentication_failure(self, mock_login): - """ - Test authentication failure. - - This test verifies that when the GH_ENTERPRISE_URL environment variable is empty, - the GH_TOKEN environment variable is set, and the authentication process fails, - the auth_to_github() function raises a ValueError. - - """ - mock_login.return_value = None - with patch.dict( - os.environ, - { - "GH_APP_ID": "", - "GH_APP_PRIVATE_KEY": "", - "GH_APP_INSTALLATION_ID": "", - "GH_ENTERPRISE_URL": "", - "GH_TOKEN": "abc123", - }, - ): - with self.assertRaises(ValueError) as cm: - auth_to_github() - the_exception = cm.exception - self.assertEqual(str(the_exception), "Unable to authenticate to GitHub") - - class TestGetIntFromEnv(unittest.TestCase): """ Test suite for the get_int_from_env function. From 09829ceed31bed2ca9e8446ead697e0a9b6090af Mon Sep 17 00:00:00 2001 From: ricardojdsilva87 Date: Tue, 29 Oct 2024 14:42:24 +0000 Subject: [PATCH 2/5] fix: python3.9 compile errors --- stale_repos.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/stale_repos.py b/stale_repos.py index ec7dd39..6486748 100755 --- a/stale_repos.py +++ b/stale_repos.py @@ -1,5 +1,7 @@ #!/usr/bin/env python """ Find stale repositories in a GitHub organization. """ +from __future__ import annotations + import fnmatch import json import os From 8c2fd8d78b9b9869e941866f303188b5a2e9af59 Mon Sep 17 00:00:00 2001 From: ricardojdsilva87 Date: Mon, 6 Jan 2025 15:17:49 +0000 Subject: [PATCH 3/5] fix: drop futures usage --- stale_repos.py | 1 - 1 file changed, 1 deletion(-) diff --git a/stale_repos.py b/stale_repos.py index 6486748..b98bd94 100755 --- a/stale_repos.py +++ b/stale_repos.py @@ -1,6 +1,5 @@ #!/usr/bin/env python """ Find stale repositories in a GitHub organization. """ -from __future__ import annotations import fnmatch import json From 175e1acb60c4a213ea3bf24f10728b3fe0a32011 Mon Sep 17 00:00:00 2001 From: ricardojdsilva87 Date: Mon, 6 Jan 2025 16:36:34 +0000 Subject: [PATCH 4/5] fix: solve pylint errors --- .github/linters/.isort.cfg | 2 ++ stale_repos.py | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/linters/.isort.cfg b/.github/linters/.isort.cfg index f238bf7..ed59415 100644 --- a/.github/linters/.isort.cfg +++ b/.github/linters/.isort.cfg @@ -1,2 +1,4 @@ [settings] profile = black +known_third_party = github3,dateutil,dotenv +known_first_party = auth \ No newline at end of file diff --git a/stale_repos.py b/stale_repos.py index b98bd94..0c7264e 100755 --- a/stale_repos.py +++ b/stale_repos.py @@ -8,10 +8,11 @@ from os.path import dirname, join import github3 -from auth import auth_to_github from dateutil.parser import parse from dotenv import load_dotenv +import auth + def main(): # pragma: no cover """ @@ -42,7 +43,7 @@ def main(): # pragma: no cover gh_app_enterprise_only = os.getenv("GITHUB_APP_ENTERPRISE_ONLY") # Auth to GitHub.com or GHE - github_connection = auth_to_github( + github_connection = auth.auth_to_github( token, gh_app_id, gh_app_installation_id, From 6613b90a0a126c3f1ab168fb81c8ec95c1ba1ff1 Mon Sep 17 00:00:00 2001 From: ricardojdsilva87 Date: Mon, 6 Jan 2025 17:11:49 +0000 Subject: [PATCH 5/5] fix: add ignore options to pylint --- .github/linters/.python-lint | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/linters/.python-lint b/.github/linters/.python-lint index cbaae7e..9656ee4 100644 --- a/.github/linters/.python-lint +++ b/.github/linters/.python-lint @@ -431,7 +431,10 @@ disable=raw-checker-failed, use-symbolic-message-instead, use-implicit-booleaness-not-comparison-to-string, use-implicit-booleaness-not-comparison-to-zero, - import-error + import-error, + line-too-long, + too-many-arguments, + too-many-positional-arguments # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option