Skip to content

Commit

Permalink
Merge pull request #418 from ricardojdsilva87/feat/support-github-ent…
Browse files Browse the repository at this point in the history
…erprise-api

feat: support github enterprise api
  • Loading branch information
jmeridth authored Oct 29, 2024
2 parents aba98f6 + d92a46c commit baa4523
Show file tree
Hide file tree
Showing 11 changed files with 131 additions and 45 deletions.
1 change: 1 addition & 0 deletions .env-example
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
48 changes: 34 additions & 14 deletions auth.py
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
9 changes: 7 additions & 2 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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},"
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions discussions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
20 changes: 12 additions & 8 deletions issue_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down
14 changes: 10 additions & 4 deletions markdown_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
33 changes: 25 additions & 8 deletions test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,33 +25,49 @@ 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):
"""
Test the auth_to_github function when authentication information is not provided.
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")
Expand All @@ -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)
Loading

0 comments on commit baa4523

Please sign in to comment.