Repository: github/issue-metrics
Pull request #418 (2024-10-28 16:02:53)

feat: support github enterprise api
# Pull Request

<!--
PR title needs to be prefixed with a conventional commit type
(build,chore,ci,docs,feat,fix,perf,refactor,revert,style,test)

It should also be brief and descriptive for a good changelog entry

examples: "feat: add new logger" or "fix: remove unused imports"
-->

## Proposed Changes

<!-- Describe what the changes are and link to a GitHub Issue if one exists -->

This Pull request is based on the already merged https://github.com/github/evergreen/pull/256 to give support to GitHub Enterprise login using a GitHub App also created on GitHub Enterprise

**Code coverage**

```
---------- coverage: platform darwin, python 3.13.0-final-0 ----------
Name                          Stmts   Miss  Cover   Missing
-----------------------------------------------------------
auth.py                          28      4    86%   50, 80-82
classes.py                       11      0   100%
config.py                        86      0   100%
discussions.py                   15      0   100%
issue_metrics.py                 65     10    85%   92, 110, 124-127, 139, 149, 154, 366
json_writer.py                   64      3    95%   95, 198-199
labels.py                        71      5    93%   55, 78, 94, 114, 118
markdown_helpers.py              11      0   100%
markdown_writer.py              105      5    95%   149, 263, 273, 283, 293
most_active_mentors.py           46     13    72%   97-113, 116-130
search.py                        66     23    65%   46-56, 81-105, 134
time_in_draft.py                 28      0   100%
time_to_answer.py                28      0   100%
time_to_close.py                 38      2    95%   49, 55
time_to_first_response.py        71      3    96%   88-89, 121
time_to_merge.py                 12      0   100%
time_to_ready_for_review.py      15      3    80%   45-49
-----------------------------------------------------------
TOTAL                           760     71    91%

Required test coverage of 80% reached. Total coverage: 90.66%
```

## Readiness Checklist

### Author/Contributor

- [x] If documentation is needed for this change, has that been included in this pull request
- [x] run `make lint` and fix any issues that you have introduced
- [x] run `make test` and ensure you have test coverage for the lines you are introducing
- [ ] If publishing new data to the public (scorecards, security scan results, code quality results, live dashboards, etc.), please request review from `@jeffrey-luszcz`

### Reviewer

- [ ] Label as either `fix`, `documentation`, `enhancement`, `infrastructure`, `maintenance`, or `breaking`



Patch:
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 @@ class EnvVars:
         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 @@ class EnvVars:
         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 @@ class EnvVars:
             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 @@ Functions:
 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:


Test patch:
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 @@ class TestAuthToGithub(unittest.TestCase):
         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 @@ class TestAuthToGithub(unittest.TestCase):
         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 @@ class TestAuthToGithub(unittest.TestCase):
         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 @@ class TestGetEnvVars(unittest.TestCase):
             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 @@ class TestGetEnvVars(unittest.TestCase):
             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 @@ class TestGetEnvVars(unittest.TestCase):
             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 @@ class TestGetEnvVars(unittest.TestCase):
             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 @@ class TestGetEnvVars(unittest.TestCase):
         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 @@ class TestGetDiscussions(unittest.TestCase):
         }
         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 @@ class TestGetDiscussions(unittest.TestCase):
         """
         # 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 @@ class TestWriteToMarkdown(unittest.TestCase):
             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