diff --git a/.env-example b/.env-example index c7d8193..ebc938d 100644 --- a/.env-example +++ b/.env-example @@ -1,6 +1,7 @@ GH_APP_ID="" GH_APP_INSTALLATION_ID="" GH_APP_PRIVATE_KEY="" +GITHUB_APP_ENTERPRISE_ONLY="" GH_ENTERPRISE_URL = "" GH_TOKEN = "" HIDE_AUTHOR = "false" diff --git a/README.md b/README.md index 603bcda..494708d 100644 --- a/README.md +++ b/README.md @@ -128,11 +128,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) diff --git a/auth.py b/auth.py index 25a37cb..5357419 100644 --- a/auth.py +++ b/auth.py @@ -1,58 +1,78 @@ -""" -This is the module that contains functions related to authenticating -to GitHub. -""" +"""This is the module that contains functions related to authenticating to GitHub with a personal access token.""" import github3 import requests def auth_to_github( - gh_app_id: str, - gh_app_installation_id: int, - gh_app_private_key_bytes: bytes, 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: A github api connection. + github3.GitHub: the GitHub connection object """ - if gh_app_id and gh_app_private_key_bytes and gh_app_installation_id: - gh = github3.github.GitHub() + 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(ghe, token=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" + "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 def get_github_app_installation_token( - gh_app_id: str, gh_app_private_key_bytes: bytes, gh_app_installation_id: str + ghe: str, + gh_app_id: str, + gh_app_private_key_bytes: bytes, + gh_app_installation_id: str, ) -> str | None: """ Get a GitHub App Installation token. + API: https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation + Args: + ghe (str): the GitHub Enterprise endpoint gh_app_id (str): the GitHub App ID gh_app_private_key_bytes (bytes): the GitHub App Private Key gh_app_installation_id (str): the GitHub App Installation ID + Returns: str: the GitHub App token """ jwt_headers = github3.apps.create_jwt_headers(gh_app_private_key_bytes, gh_app_id) - url = f"https://api.github.com/app/installations/{gh_app_installation_id}/access_tokens" + api_endpoint = f"{ghe}/api/v3" if ghe else "https://api.github.com" + url = f"{api_endpoint}/app/installations/{gh_app_installation_id}/access_tokens" try: response = requests.post(url, headers=jwt_headers, json=None, timeout=5) diff --git a/config.py b/config.py index 5de753f..ba68ddd 100644 --- a/config.py +++ b/config.py @@ -39,7 +39,7 @@ class EnvVars: hide_time_to_first_response (bool): If true, the time to first response metric is hidden in the output ignore_users (List[str]): List of usernames to ignore when calculating metrics - labels_to_measure (List[str]): List of labels to measure how much time the lable is applied + labels_to_measure (List[str]): List of labels to measure how much time the label is applied enable_mentor_count (bool): If set to TRUE, compute number of mentors min_mentor_comments (str): If set, defines the minimum number of comments for mentors max_comments_eval (str): If set, defines the maximum number of comments to look @@ -48,7 +48,7 @@ class EnvVars: involved commentors in search_query (str): Search query used to filter issues/prs/discussions on GitHub non_mentioning_links (bool): If set to TRUE, links do not cause a notification - in the desitnation repository + in the destination repository report_title (str): The title of the report output_file (str): The name of the file to write the report to rate_limit_bypass (bool): If set to TRUE, bypass the rate limit for the GitHub API @@ -61,6 +61,7 @@ def __init__( gh_app_id: int | None, gh_app_installation_id: int | None, gh_app_private_key_bytes: bytes, + gh_app_enterprise_only: bool, gh_token: str | None, ghe: str | None, hide_author: bool, @@ -85,6 +86,7 @@ def __init__( self.gh_app_id = gh_app_id self.gh_app_installation_id = gh_app_installation_id self.gh_app_private_key_bytes = gh_app_private_key_bytes + self.gh_app_enterprise_only = gh_app_enterprise_only self.gh_token = gh_token self.ghe = ghe self.ignore_users = ignore_user @@ -112,6 +114,7 @@ def __repr__(self): f"{self.gh_app_id}," f"{self.gh_app_installation_id}," f"{self.gh_app_private_key_bytes}," + f"{self.gh_app_enterprise_only}," f"{self.gh_token}," f"{self.ghe}," f"{self.hide_author}," @@ -186,6 +189,7 @@ def get_env_vars(test: bool = False) -> EnvVars: 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") + gh_app_enterprise_only = get_bool_env_var("GITHUB_APP_ENTERPRISE_ONLY") if gh_app_id and (not gh_app_private_key_bytes or not gh_app_installation_id): raise ValueError( @@ -235,6 +239,7 @@ def get_env_vars(test: bool = False) -> EnvVars: gh_app_id, gh_app_installation_id, gh_app_private_key_bytes, + gh_app_enterprise_only, gh_token, ghe, hide_author, diff --git a/discussions.py b/discussions.py index 8cc3d99..c6700b4 100644 --- a/discussions.py +++ b/discussions.py @@ -10,7 +10,7 @@ import requests -def get_discussions(token: str, search_query: str): +def get_discussions(token: str, search_query: str, ghe: str): """Get a list of discussions in a GitHub repository that match the search query. Args: @@ -51,9 +51,10 @@ def get_discussions(token: str, search_query: str): variables = {"query": search_query} # Send the GraphQL request + api_endpoint = f"{ghe}/api/v3" if ghe else "https://api.github.com" headers = {"Authorization": f"Bearer {token}"} response = requests.post( - "https://api.github.com/graphql", + f"{api_endpoint}/graphql", json={"query": query, "variables": variables}, headers=headers, timeout=60, diff --git a/issue_metrics.py b/issue_metrics.py index 1228654..cdbaef9 100644 --- a/issue_metrics.py +++ b/issue_metrics.py @@ -192,23 +192,27 @@ def main(): # pragma: no cover output_file = env_vars.output_file rate_limit_bypass = env_vars.rate_limit_bypass + ghe = env_vars.ghe gh_app_id = env_vars.gh_app_id gh_app_installation_id = env_vars.gh_app_installation_id gh_app_private_key_bytes = env_vars.gh_app_private_key_bytes - - if not token and gh_app_id and gh_app_installation_id and gh_app_private_key_bytes: - token = get_github_app_installation_token( - gh_app_id, gh_app_private_key_bytes, gh_app_installation_id - ) + gh_app_enterprise_only = env_vars.gh_app_enterprise_only # Auth to GitHub.com github_connection = auth_to_github( + token, gh_app_id, gh_app_installation_id, gh_app_private_key_bytes, - token, - env_vars.ghe, + ghe, + gh_app_enterprise_only, ) + + if not token and gh_app_id and gh_app_installation_id and gh_app_private_key_bytes: + token = get_github_app_installation_token( + ghe, gh_app_id, gh_app_private_key_bytes, gh_app_installation_id + ) + enable_mentor_count = env_vars.enable_mentor_count min_mentor_count = int(env_vars.min_mentor_comments) max_comments_eval = int(env_vars.max_comments_eval) @@ -236,7 +240,7 @@ def main(): # pragma: no cover raise ValueError( "The search query for discussions cannot include labels to measure" ) - issues = get_discussions(token, search_query) + issues = get_discussions(token, search_query, ghe) if len(issues) <= 0: print("No discussions found") write_to_markdown( diff --git a/markdown_writer.py b/markdown_writer.py index bcebc7e..ded4c11 100644 --- a/markdown_writer.py +++ b/markdown_writer.py @@ -100,6 +100,7 @@ def write_to_markdown( non_mentioning_links=False, report_title="", output_file="", + ghe="", ) -> None: """Write the issues with metrics to a markdown file. @@ -114,7 +115,7 @@ def write_to_markdown( file (file object, optional): The file object to write to. If not provided, a file named "issue_metrics.md" will be created. num_issues_opened (int): The Number of items that remain opened. - num_issues_closed (int): The number of issues that were closedi. + num_issues_closed (int): The number of issues that were closed. num_mentor_count (int): The number of very active commentors. labels (List[str]): A list of the labels that are used in the issues. search_query (str): The search query used to find the issues. @@ -126,6 +127,7 @@ def write_to_markdown( in the destination repository report_title (str): The title of the report output_file (str): The name of the file to write the report to + ghe (str): the GitHub Enterprise endpoint Returns: None. @@ -185,15 +187,19 @@ def write_to_markdown( # Replace any whitespace issue.title = issue.title.strip() + endpoint = ghe.removeprefix("https://") if ghe else "github.com" if non_mentioning_links: file.write( f"| {issue.title} | " - f"{issue.html_url.replace('https://github.com', 'https://www.github.com')} |" + f"{issue.html_url}".replace( + f"https://{endpoint}", f"https://www.{endpoint}" + ) + + " |" ) else: - file.write(f"| {issue.title} | " f"{issue.html_url} |") + file.write(f"| {issue.title} | {issue.html_url} |") if "Author" in columns: - file.write(f" [{issue.author}](https://github.com/{issue.author}) |") + file.write(f" [{issue.author}](https://{endpoint}/{issue.author}) |") if "Time to first response" in columns: file.write(f" {issue.time_to_first_response} |") if "Time to close" in columns: diff --git a/test_auth.py b/test_auth.py index 7057ff7..e264938 100644 --- a/test_auth.py +++ b/test_auth.py @@ -25,17 +25,17 @@ def test_auth_to_github_with_github_app(self, mock_login): parameters provided. """ mock_login.return_value = MagicMock() - result = auth_to_github(12345, 678910, b"hello", "", "") + result = auth_to_github("", 12345, 678910, b"hello", "", False) - self.assertIsInstance(result, github3.github.GitHub) + self.assertIsInstance(result, github3.github.GitHub, False) def test_auth_to_github_with_token(self): """ Test the auth_to_github function when the token is provided. """ - result = auth_to_github(None, None, b"", "token", "") + result = auth_to_github("token", None, None, b"", "", False) - self.assertIsInstance(result, github3.github.GitHub) + self.assertIsInstance(result, github3.github.GitHub, False) def test_auth_to_github_without_authentication_information(self): """ @@ -43,15 +43,31 @@ def test_auth_to_github_without_authentication_information(self): Expect a ValueError to be raised. """ with self.assertRaises(ValueError): - auth_to_github(None, None, b"", "", "") + auth_to_github("", None, None, b"", "", False) def test_auth_to_github_with_ghe(self): """ Test the auth_to_github function when the GitHub Enterprise URL is provided. """ - result = auth_to_github(None, None, b"", "token", "https://github.example.com") + result = auth_to_github( + "token", None, None, b"", "https://github.example.com", False + ) + + self.assertIsInstance(result, github3.github.GitHubEnterprise, False) - self.assertIsInstance(result, github3.github.GitHubEnterprise) + @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_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.apps.create_jwt_headers", MagicMock(return_value="gh_token")) @patch("requests.post") @@ -64,9 +80,10 @@ def test_get_github_app_installation_token(self, mock_post): mock_response.raise_for_status.return_value = None mock_response.json.return_value = {"token": dummy_token} mock_post.return_value = mock_response + mock_ghe = "" result = get_github_app_installation_token( - b"gh_private_token", "gh_app_id", "gh_installation_id" + mock_ghe, b"gh_private_token", "gh_app_id", "gh_installation_id" ) self.assertEqual(result, dummy_token) diff --git a/test_config.py b/test_config.py index dab4e7d..9197dd1 100644 --- a/test_config.py +++ b/test_config.py @@ -119,6 +119,7 @@ def test_get_env_vars_with_github_app(self): gh_app_id=12345, gh_app_installation_id=678910, gh_app_private_key_bytes=b"hello", + gh_app_enterprise_only=False, gh_token="", ghe="", hide_author=False, @@ -171,6 +172,7 @@ def test_get_env_vars_with_token(self): gh_app_id=None, gh_app_installation_id=None, gh_app_private_key_bytes=b"", + gh_app_enterprise_only=False, gh_token=TOKEN, ghe="", hide_author=False, @@ -258,6 +260,7 @@ def test_get_env_vars_optional_values(self): gh_app_id=None, gh_app_installation_id=None, gh_app_private_key_bytes=b"", + gh_app_enterprise_only=False, gh_token=TOKEN, ghe="", hide_author=True, @@ -299,6 +302,7 @@ def test_get_env_vars_optionals_are_defaulted(self): gh_app_id=None, gh_app_installation_id=None, gh_app_private_key_bytes=b"", + gh_app_enterprise_only=False, gh_token="TOKEN", ghe="", hide_author=False, @@ -323,6 +327,28 @@ def test_get_env_vars_optionals_are_defaulted(self): result = get_env_vars(True) self.assertEqual(str(result), str(expected_result)) + @patch.dict( + os.environ, + { + "ORGANIZATION": "my_organization", + "GH_APP_ID": "12345", + "GH_APP_INSTALLATION_ID": "", + "GH_APP_PRIVATE_KEY": "", + "GH_TOKEN": "", + "SEARCH_QUERY": SEARCH_QUERY, + }, + clear=True, + ) + def test_get_env_vars_auth_with_github_app_installation_missing_inputs(self): + """Test that an error is raised there are missing inputs for the gh app""" + with self.assertRaises(ValueError) as context_manager: + get_env_vars(True) + the_exception = context_manager.exception + self.assertEqual( + str(the_exception), + "GH_APP_ID set and GH_APP_INSTALLATION_ID or GH_APP_PRIVATE_KEY variable not set", + ) + if __name__ == "__main__": unittest.main() diff --git a/test_discussions.py b/test_discussions.py index ffea435..22b247f 100644 --- a/test_discussions.py +++ b/test_discussions.py @@ -57,9 +57,12 @@ def test_get_discussions(self, mock_post): } mock_post.return_value.status_code = 200 mock_post.return_value.json.return_value = mock_response + mock_ghe = "" # Call the function with mock arguments - discussions = get_discussions("token", "repo:user/repo type:discussions query") + discussions = get_discussions( + "token", "repo:user/repo type:discussions query", mock_ghe + ) # Check that the function returns the expected discussions self.assertEqual(len(discussions), 2) @@ -75,7 +78,8 @@ def test_get_discussions_error(self, mock_post): """ # Mock a failed GraphQL response mock_post.return_value.status_code = 500 + mock_ghe = "" # Call the function with mock arguments and check that it raises an error with self.assertRaises(ValueError): - get_discussions("token", "repo:user/repo type:discussions query") + get_discussions("token", "repo:user/repo type:discussions query", mock_ghe) diff --git a/test_markdown_writer.py b/test_markdown_writer.py index 7f69abd..25aec52 100644 --- a/test_markdown_writer.py +++ b/test_markdown_writer.py @@ -106,6 +106,7 @@ def test_write_to_markdown(self): search_query="is:issue is:open label:bug", report_title="Issue Metrics", output_file="issue_metrics.md", + ghe="", ) # Check that the function writes the correct markdown file