Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support github enterprise api #418

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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"
jmeridth marked this conversation as resolved.
Show resolved Hide resolved
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} |")
jmeridth marked this conversation as resolved.
Show resolved Hide resolved
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