From b1533071207d9b20b53c70a4e4f631d4cc77f5d4 Mon Sep 17 00:00:00 2001 From: ricardojdsilva87 Date: Mon, 28 Oct 2024 12:30:08 +0000 Subject: [PATCH 1/8] feat: support github enterprise api --- .coveragerc | 4 +++ .env-example | 1 + README.md | 85 ++++++++++++++++++++++++++++++++++++++------ auth.py | 54 +++++++++++++++++++++++----- contributor_stats.py | 11 +++--- contributors.py | 44 +++++++++++++---------- env.py | 39 +++++++++++++------- markdown.py | 14 ++------ test_auth.py | 71 +++++++++++++++++++++++++++--------- test_contributors.py | 66 ++++++++++++++++++++++++++++++---- test_env.py | 45 ++++++++++++++++++++--- 11 files changed, 340 insertions(+), 94 deletions(-) create mode 100644 .coveragerc diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..366a2ce --- /dev/null +++ b/.coveragerc @@ -0,0 +1,4 @@ +[run] +omit = + # omit test files + test_*.py \ No newline at end of file diff --git a/.env-example b/.env-example index 580ff83..45fd8e0 100644 --- a/.env-example +++ b/.env-example @@ -9,3 +9,4 @@ START_DATE = "" GH_APP_ID = "" GH_INSTALLATION_ID = "" GH_PRIVATE_KEY = "" +GITHUB_APP_ENTERPRISE_ONLY = "" \ No newline at end of file diff --git a/README.md b/README.md index efab3d9..52e66dd 100644 --- a/README.md +++ b/README.md @@ -38,11 +38,19 @@ Find out more in the [GitHub API documentation](https://docs.github.com/en/rest/ 1. Create a repository to host this GitHub Action or select an existing repository. 1. Select a best fit workflow file from the [examples below](#example-workflows). 1. Copy that example into your repository (from step 1) and into the proper directory for GitHub Actions: `.github/workflows/` directory with the file extension `.yml` (ie. `.github/workflows/contributors.yml`) -1. Edit the values (`ORGANIZATION`, `REPOSITORY`, `START_DATE`, `END_DATE`) from the sample workflow with your information. - - If no start and end date are supplied, the action will consider the entire repository history and be unable to determine if contributors are new or returning. - - If running on a whole organization then no repository is needed. - - If running the action on just one repository or a list of repositories, then no organization is needed. -1. Also edit the value for `GH_ENTERPRISE_URL` if you are using a GitHub Server and not using github.com. For github.com users, don't put anything in here. +1. Edit the values below from the sample workflow with your information: + + - `ORGANIZATION` + - `REPOSITORY` + - `START_DATE` + - `END_DATE` + + If no **start and end date** are supplied, the action will consider the entire repository history and be unable to determine if contributors are new or returning. + If running on a whole **organization** then no repository is needed. + If running the action on just **one repository** or a **list of repositories**, then no organization is needed. + +1. Also edit the value for `GH_ENTERPRISE_URL` if you are using a GitHub Server and not using github.com. + For github.com users, leave it empty. 1. If you are running this action on an organization or repository other than the one where the workflow file is going to be, then update the value of `GH_TOKEN`. - Do this by creating a [GitHub API token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-personal-access-token-classic) with permissions to read the repository/organization and write issues. - Then take the value of the API token you just created, and [create a repository secret](https://docs.github.com/en/actions/security-guides/encrypted-secrets) where the name of the secret is `GH_TOKEN` and the value of the secret the API token. @@ -62,11 +70,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) @@ -143,6 +152,62 @@ jobs: assignees: ``` +#### Using GitHub app + +```yaml +name: Monthly contributor report +on: + workflow_dispatch: + schedule: + - cron: "3 2 1 * *" + +permissions: + contents: read + +jobs: + contributor_report: + name: contributor report + runs-on: ubuntu-latest + permissions: + issues: write + + steps: + - name: Get dates for last month + shell: bash + run: | + # Calculate the first day of the previous month + start_date=$(date -d "last month" +%Y-%m-01) + + # Calculate the last day of the previous month + end_date=$(date -d "$start_date +1 month -1 day" +%Y-%m-%d) + + #Set an environment variable with the date range + echo "START_DATE=$start_date" >> "$GITHUB_ENV" + echo "END_DATE=$end_date" >> "$GITHUB_ENV" + + - name: Run contributor action + uses: github/contributors@v1 + env: + 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 to true when created GHE App needs to communicate with GHE api + GH_ENTERPRISE_URL: ${{ github.server_url }} + # GH_TOKEN: ${{ steps.app-token.outputs.token }} --> the token input is not used if the github app inputs are set + START_DATE: ${{ env.START_DATE }} + END_DATE: ${{ env.END_DATE }} + ORGANIZATION: + SPONSOR_INFO: "true" + + - name: Create issue + uses: peter-evans/create-issue-from-file@v5 + with: + title: Monthly contributor report + token: ${{ secrets.GITHUB_TOKEN }} + content-filepath: ./contributors.md + assignees: +``` + ## Example Markdown output with `start_date` and `end_date` supplied ```markdown diff --git a/auth.py b/auth.py index 825ff14..6d848d1 100644 --- a/auth.py +++ b/auth.py @@ -1,36 +1,42 @@ """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: - gh_app_id (str): the GitHub App ID - gh_installation_id (int): the GitHub App Installation ID - gh_app_private_key (bytes): the GitHub App Private Key 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: - 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: @@ -41,3 +47,35 @@ def auth_to_github( if not github_connection: raise ValueError("Unable to authenticate to GitHub") return github_connection # type: ignore + + +def get_github_app_installation_token( + 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) + api_endpoint = f"{ghe}/api/v3" if ghe else "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) + response.raise_for_status() + except requests.exceptions.RequestException as e: + print(f"Request failed: {e}") + return None + return response.json().get("token") diff --git a/contributor_stats.py b/contributor_stats.py index 1d7b1db..4f553f0 100644 --- a/contributor_stats.py +++ b/contributor_stats.py @@ -114,9 +114,7 @@ def merge_contributors(contributors: list) -> list: ) # Merge the commit urls via concatenation merged_contributor.commit_url = ( - merged_contributor.commit_url - + ", " - + contributor.commit_url + f"{merged_contributor.commit_url}, {contributor.commit_url}" ) # Merge the new_contributor attribute via OR merged_contributor.new_contributor = ( @@ -130,7 +128,7 @@ def merge_contributors(contributors: list) -> list: return merged_contributors -def get_sponsor_information(contributors: list, token: str) -> list: +def get_sponsor_information(contributors: list, token: str, ghe: str) -> list: """ Get the sponsor information for each contributor @@ -155,9 +153,10 @@ def get_sponsor_information(contributors: list, token: str) -> list: variables = {"username": contributor.username} # Send the GraphQL request + api_endpoint = f"{ghe}/api/v3" if ghe else "api.github.com" headers = {"Authorization": f"Bearer {token}"} response = requests.post( - "https://api.github.com/graphql", + f"https://{api_endpoint}/graphql", json={"query": query, "variables": variables}, headers=headers, timeout=60, @@ -172,7 +171,7 @@ def get_sponsor_information(contributors: list, token: str) -> list: # if the user has a sponsor page, add it to the contributor object if data["repositoryOwner"]["hasSponsorsListing"]: contributor.sponsor_info = ( - f"https://github.com/sponsors/{contributor.username}" + f"https://{api_endpoint}/sponsors/{contributor.username}" ) return contributors diff --git a/contributors.py b/contributors.py index 6518fbc..220e5c1 100644 --- a/contributors.py +++ b/contributors.py @@ -19,7 +19,8 @@ def main(): repository_list, gh_app_id, gh_app_installation_id, - gh_app_private_key_bytes, + gh_app_private_key, + gh_app_enterprise_only, token, ghe, start_date, @@ -30,16 +31,22 @@ def main(): # Auth to GitHub.com github_connection = auth.auth_to_github( - gh_app_id, gh_app_installation_id, gh_app_private_key_bytes, token, ghe + token, + gh_app_id, + gh_app_installation_id, + gh_app_private_key, + ghe, + gh_app_enterprise_only, ) + if not token and gh_app_id and gh_app_installation_id and gh_app_private_key: + token = auth.get_github_app_installation_token( + ghe, gh_app_id, gh_app_private_key, gh_app_installation_id + ) + # Get the contributors contributors = get_all_contributors( - organization, - repository_list, - start_date, - end_date, - github_connection, + organization, repository_list, start_date, end_date, github_connection, ghe ) # Check for new contributor if user provided start_date and end_date @@ -52,6 +59,7 @@ def main(): start_date="2008-02-29", # GitHub was founded on 2008-02-29 end_date=start_date, github_connection=github_connection, + ghe=ghe, ) for contributor in contributors: contributor.new_contributor = contributor_stats.is_new_contributor( @@ -60,7 +68,9 @@ def main(): # Get sponsor information on the contributor if sponsor_info == "true": - contributors = contributor_stats.get_sponsor_information(contributors, token) + contributors = contributor_stats.get_sponsor_information( + contributors, token, ghe + ) # Output the contributors information # print(contributors) markdown.write_to_markdown( @@ -91,6 +101,7 @@ def get_all_contributors( start_date: str, end_date: str, github_connection: object, + ghe: str, ): """ Get all contributors from the organization or repository @@ -118,7 +129,7 @@ def get_all_contributors( all_contributors = [] if repos: for repo in repos: - repo_contributors = get_contributors(repo, start_date, end_date) + repo_contributors = get_contributors(repo, start_date, end_date, ghe) if repo_contributors: all_contributors.append(repo_contributors) @@ -128,11 +139,7 @@ def get_all_contributors( return all_contributors -def get_contributors( - repo: object, - start_date: str, - end_date: str, -): +def get_contributors(repo: object, start_date: str, end_date: str, ghe: str): """ Get contributors from a single repository and filter by start end dates if present. @@ -165,12 +172,11 @@ def get_contributors( continue # Store the contributor information in a ContributorStats object + api_endpoint = ghe if ghe else "github.com" if start_date and end_date: - commit_url = f"https://github.com/{repo.full_name}/commits?author={user.login}&since={start_date}&until={end_date}" + commit_url = f"https://{api_endpoint}/{repo.full_name}/commits?author={user.login}&since={start_date}&until={end_date}" else: - commit_url = ( - f"https://github.com/{repo.full_name}/commits?author={user.login}" - ) + commit_url = f"https://{api_endpoint}/{repo.full_name}/commits?author={user.login}" contributor = contributor_stats.ContributorStats( user.login, False, @@ -181,7 +187,7 @@ def get_contributors( ) contributors.append(contributor) except Exception as e: - print("Error getting contributors for repository: " + repo.full_name) + print(f"Error getting contributors for repository: {repo.full_name}") print(e) return None diff --git a/env.py b/env.py index 3842ada..b160dc2 100644 --- a/env.py +++ b/env.py @@ -73,7 +73,18 @@ def validate_date_format(env_var_name: str) -> str: def get_env_vars( test: bool = False, ) -> tuple[ - str | None, list[str], int | None, int | None, bytes, str, str, str, str, bool, bool + str | None, + list[str], + int | None, + int | None, + bytes, + bool, + str, + str, + str, + str, + bool, + bool, ]: """ Get the environment variables for use in the action. @@ -82,18 +93,18 @@ def get_env_vars( None Returns: - str: the organization to get contributor information for - List[str]: A list of the repositories to get contributor information for - int|None: the GitHub App ID to use for authentication - int|None: the GitHub App Installation ID to use for authentication - bytes: the GitHub App Private Key as bytes to use for authentication - str: the GitHub token to use for authentication - str: the GitHub Enterprise URL to use for authentication - str: the start date to get contributor information from - str: the end date to get contributor information to. - str: whether to get sponsor information on the contributor - str: whether to link username to Github profile in markdown output - + organization (str): the organization to get contributor information for + repository_list (list[str]): A list of the repositories to get contributor information for + gh_app_id (int | None): The GitHub App ID to use for authentication + gh_app_installation_id (int | None): The GitHub App Installation ID to use for authentication + gh_app_private_key_bytes (bytes): The GitHub App Private Key as bytes to use for authentication + 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 + token (str): The GitHub token to use for authentication + ghe (str): The GitHub Enterprise URL to use for authentication + start_date (str): The start date to get contributor information from + end_date (str): The end date to get contributor information to. + sponsor_info (str): Whether to get sponsor information on the contributor + link_to_profile (str): Whether to link username to Github profile in markdown output """ if not test: @@ -111,6 +122,7 @@ def get_env_vars( 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( @@ -147,6 +159,7 @@ def get_env_vars( gh_app_id, gh_app_installation_id, gh_app_private_key_bytes, + gh_app_enterprise_only, token, ghe, start_date, diff --git a/markdown.py b/markdown.py index 1e13cec..54ad927 100644 --- a/markdown.py +++ b/markdown.py @@ -116,19 +116,11 @@ def get_summary_table(collaborators, start_date, end_date, total_contributions): ) else: new_contributors_percentage = 0 - summary_table += ( - "| " - + str(len(collaborators)) - + " | " - + str(total_contributions) - + " | " - + str(new_contributors_percentage) - + "% |\n\n" - ) + summary_table += f"| {str(len(collaborators))} | {str(total_contributions)} | {str(new_contributors_percentage)}% |\n\n" else: summary_table = "| Total Contributors | Total Contributions |\n| --- | --- |\n" summary_table += ( - "| " + str(len(collaborators)) + " | " + str(total_contributions) + " |\n\n" + f"| {str(len(collaborators))} | {str(total_contributions)} |\n\n" ) return summary_table @@ -192,7 +184,7 @@ def get_contributor_table( # get the organization and repository name from the url ie. org1/repo2 from https://github.com/org1/repo2/commits?author-zkoppert org_repo_link_name = url.split("/commits")[0].split("github.com/")[1] url = f"[{org_repo_link_name}]({url})" - commit_urls += url + ", " + commit_urls += f"{url}, " new_contributor = collaborator.new_contributor row = f"| {'' if link_to_profile == 'false' else '@'}{username} | {contribution_count} |" diff --git a/test_auth.py b/test_auth.py index 69fc9f5..7a595fe 100644 --- a/test_auth.py +++ b/test_auth.py @@ -4,7 +4,6 @@ from unittest.mock import MagicMock, patch import auth -import github3.github class TestAuth(unittest.TestCase): @@ -12,17 +11,6 @@ class TestAuth(unittest.TestCase): Test case for the auth module. """ - @patch("github3.github.GitHub.login_as_app_installation") - def test_auth_to_github_with_github_app(self, mock_login): - """ - Test the auth_to_github function when GitHub app - parameters provided. - """ - mock_login.return_value = MagicMock() - result = auth.auth_to_github(12345, 678910, b"hello", "", "") - - self.assertIsInstance(result, github3.github.GitHub) - @patch("github3.login") def test_auth_to_github_with_token(self, mock_login): """ @@ -30,7 +18,7 @@ def test_auth_to_github_with_token(self, mock_login): """ mock_login.return_value = "Authenticated to GitHub.com" - result = auth.auth_to_github("", "", b"", "token", "") + result = auth.auth_to_github("token", "", "", b"", "", False) self.assertEqual(result, "Authenticated to GitHub.com") @@ -39,8 +27,13 @@ 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): - auth.auth_to_github("", "", b"", "", "") + 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): @@ -48,10 +41,56 @@ 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("", "", b"", "token", "https://github.example.com") + 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) + + @patch("github3.apps.create_jwt_headers", MagicMock(return_value="gh_token")) + @patch("requests.post") + def test_get_github_app_installation_token(self, mock_post): + """ + Test the get_github_app_installation_token function. + """ + dummy_token = "dummytoken" + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = {"token": dummy_token} + mock_post.return_value = mock_response + + result = auth.get_github_app_installation_token( + b"ghe", "gh_private_token", "gh_app_id", "gh_installation_id" + ) + + self.assertEqual(result, dummy_token) + if __name__ == "__main__": unittest.main() diff --git a/test_contributors.py b/test_contributors.py index ab75d0c..542c72b 100644 --- a/test_contributors.py +++ b/test_contributors.py @@ -25,7 +25,7 @@ def test_get_contributors(self, mock_contributor_stats): mock_repo.contributors.return_value = [mock_user] mock_repo.full_name = "owner/repo" - get_contributors(mock_repo, "2022-01-01", "2022-12-31") + get_contributors(mock_repo, "2022-01-01", "2022-12-31", "") mock_contributor_stats.assert_called_once_with( "user", @@ -56,9 +56,10 @@ def test_get_all_contributors_with_organization(self, mock_get_contributors): "sponsor_url_1", ), ] + ghe = "" result = get_all_contributors( - "org", "", "2022-01-01", "2022-12-31", mock_github_connection + "org", "", "2022-01-01", "2022-12-31", mock_github_connection, ghe ) self.assertEqual( @@ -74,8 +75,8 @@ def test_get_all_contributors_with_organization(self, mock_get_contributors): ), ], ) - mock_get_contributors.assert_any_call("repo1", "2022-01-01", "2022-12-31") - mock_get_contributors.assert_any_call("repo2", "2022-01-01", "2022-12-31") + mock_get_contributors.assert_any_call("repo1", "2022-01-01", "2022-12-31", ghe) + mock_get_contributors.assert_any_call("repo2", "2022-01-01", "2022-12-31", ghe) @patch("contributors.get_contributors") def test_get_all_contributors_with_repository(self, mock_get_contributors): @@ -94,9 +95,10 @@ def test_get_all_contributors_with_repository(self, mock_get_contributors): "sponsor_url_2", ) ] + ghe = "" result = get_all_contributors( - "", ["owner/repo"], "2022-01-01", "2022-12-31", mock_github_connection + "", ["owner/repo"], "2022-01-01", "2022-12-31", mock_github_connection, ghe ) self.assertEqual( @@ -113,7 +115,7 @@ def test_get_all_contributors_with_repository(self, mock_get_contributors): ], ) mock_get_contributors.assert_called_once_with( - "repo", "2022-01-01", "2022-12-31" + "repo", "2022-01-01", "2022-12-31", ghe ) @patch("contributors.contributor_stats.ContributorStats") @@ -134,8 +136,9 @@ def test_get_contributors_skip_users_with_no_commits(self, mock_contributor_stat mock_repo.contributors.return_value = [mock_user] mock_repo.full_name = "owner/repo" mock_repo.get_commits.side_effect = StopIteration + ghe = "" - get_contributors(mock_repo, "2022-01-01", "2022-12-31") + get_contributors(mock_repo, "2022-01-01", "2022-12-31", ghe) # Note that only user is returned and user2 is not returned here because there were no commits in the date range mock_contributor_stats.assert_called_once_with( @@ -147,6 +150,55 @@ def test_get_contributors_skip_users_with_no_commits(self, mock_contributor_stat "", ) + @patch("contributors.contributor_stats.ContributorStats") + def test_get_contributors_skip_bot(self, mock_contributor_stats): + """ + Test if the get_contributors function skips the bot user. + """ + mock_repo = MagicMock() + mock_user = MagicMock() + mock_user.login = "[bot]" + mock_user.avatar_url = "https://avatars.githubusercontent.com/u/12345678?v=4" + mock_user.contributions_count = 100 + + mock_repo.contributors.return_value = [mock_user] + mock_repo.full_name = "owner/repo" + mock_repo.get_commits.side_effect = StopIteration + ghe = "" + + get_contributors(mock_repo, "2022-01-01", "2022-12-31", ghe) + + # Note that only user is returned and user2 is not returned here because there were no commits in the date range + mock_contributor_stats.isEmpty() + + @patch("contributors.contributor_stats.ContributorStats") + def test_get_contributors_no_commit_end_date(self, mock_contributor_stats): + """ + Test the get_contributors does the search of commits only with start date + """ + mock_repo = MagicMock() + mock_user = MagicMock() + mock_user.login = "user" + mock_user.avatar_url = "https://avatars.githubusercontent.com/u/12345678?v=4" + mock_user.contributions_count = 100 + + mock_repo.contributors.return_value = [mock_user] + mock_repo.full_name = "owner/repo" + mock_repo.get_commits.side_effect = StopIteration + ghe = "" + + get_contributors(mock_repo, "2022-01-01", "", ghe) + + # Note that only user is returned and user2 is not returned here because there were no commits in the date range + mock_contributor_stats.assert_called_once_with( + "user", + False, + "https://avatars.githubusercontent.com/u/12345678?v=4", + 100, + "https://github.com/owner/repo/commits?author=user", + "", + ) + if __name__ == "__main__": unittest.main() diff --git a/test_env.py b/test_env.py index 7632c41..638e6e5 100644 --- a/test_env.py +++ b/test_env.py @@ -20,6 +20,7 @@ def setUp(self): "GH_ENTERPRISE_URL", "GH_APP_INSTALLATION_ID", "GH_APP_PRIVATE_KEY", + "GITHUB_APP_ENTERPRISE_ONLY", "GH_TOKEN", "ORGANIZATION", "REPOSITORY", @@ -56,7 +57,8 @@ def test_get_env_vars(self): repository_list, gh_app_id, gh_app_installation_id, - gh_app_private_key_bytes, + gh_app_private_key, + gh_app_enterprise_only, token, ghe, start_date, @@ -69,7 +71,8 @@ def test_get_env_vars(self): self.assertEqual(repository_list, ["repo", "repo2"]) self.assertIsNone(gh_app_id) self.assertIsNone(gh_app_installation_id) - self.assertEqual(gh_app_private_key_bytes, b"") + self.assertEqual(gh_app_private_key, b"") + self.assertFalse(gh_app_enterprise_only) self.assertEqual(token, "token") self.assertEqual(ghe, "") self.assertEqual(start_date, "2022-01-01") @@ -164,7 +167,8 @@ def test_get_env_vars_no_dates(self): repository_list, gh_app_id, gh_app_installation_id, - gh_app_private_key_bytes, + gh_app_private_key, + gh_app_enterprise_only, token, ghe, start_date, @@ -177,7 +181,8 @@ def test_get_env_vars_no_dates(self): self.assertEqual(repository_list, ["repo", "repo2"]) self.assertIsNone(gh_app_id) self.assertIsNone(gh_app_installation_id) - self.assertEqual(gh_app_private_key_bytes, b"") + self.assertEqual(gh_app_private_key, b"") + self.assertFalse(gh_app_enterprise_only) self.assertEqual(token, "token") self.assertEqual(ghe, "") self.assertEqual(start_date, "") @@ -185,6 +190,38 @@ def test_get_env_vars_no_dates(self): self.assertFalse(sponsor_info) self.assertTrue(link_to_profile) + @patch.dict(os.environ, {}) + def test_get_env_vars_missing_org_or_repo(self): + """Test that an error is raised if required environment variables are not set""" + with self.assertRaises(ValueError) as cm: + env.get_env_vars() + the_exception = cm.exception + self.assertEqual( + str(the_exception), + "ORGANIZATION and REPOSITORY environment variables were not set. Please set one", + ) + + @patch.dict( + os.environ, + { + "ORGANIZATION": "my_organization", + "GH_APP_ID": "12345", + "GH_APP_INSTALLATION_ID": "", + "GH_APP_PRIVATE_KEY": "", + "GH_TOKEN": "", + }, + 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: + env.get_env_vars() + 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() From 0a41710b46e97bc54716c4accd4ab6f796d76c79 Mon Sep 17 00:00:00 2001 From: ricardojdsilva87 Date: Mon, 28 Oct 2024 12:52:02 +0000 Subject: [PATCH 2/8] fix: markdown endpoint --- contributors.py | 1 + markdown.py | 8 +++++++- test_markdown.py | 6 ++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/contributors.py b/contributors.py index 220e5c1..102b854 100644 --- a/contributors.py +++ b/contributors.py @@ -82,6 +82,7 @@ def main(): repository_list, sponsor_info, link_to_profile, + ghe, ) json_writer.write_to_json( filename="contributors.json", diff --git a/markdown.py b/markdown.py index 54ad927..a32328a 100644 --- a/markdown.py +++ b/markdown.py @@ -11,6 +11,7 @@ def write_to_markdown( repository, sponsor_info, link_to_profile, + ghe, ): """ This function writes a list of collaborators to a markdown file in table format. @@ -40,6 +41,7 @@ def write_to_markdown( repository, sponsor_info, link_to_profile, + ghe, ) # Put together the summary table including # of new contributions, # of new contributors, % new contributors, % returning contributors @@ -134,6 +136,7 @@ def get_contributor_table( repository, sponsor_info, link_to_profile, + ghe, ): """ This function returns a string containing a markdown table of the contributors and the total contribution count. @@ -182,7 +185,10 @@ def get_contributor_table( for url in commit_url_list: url = url.strip() # get the organization and repository name from the url ie. org1/repo2 from https://github.com/org1/repo2/commits?author-zkoppert - org_repo_link_name = url.split("/commits")[0].split("github.com/")[1] + api_endpoint = ghe.removeprefix("https://") if ghe else "github.com" + org_repo_link_name = url.split("/commits")[0].split(f"{api_endpoint}/")[ + 1 + ] url = f"[{org_repo_link_name}]({url})" commit_urls += f"{url}, " new_contributor = collaborator.new_contributor diff --git a/test_markdown.py b/test_markdown.py index f4e46cb..e70ab78 100644 --- a/test_markdown.py +++ b/test_markdown.py @@ -39,6 +39,7 @@ def test_write_to_markdown(self, mock_file): person1, person2, ] + ghe = "" write_to_markdown( collaborators, @@ -49,6 +50,7 @@ def test_write_to_markdown(self, mock_file): "org/repo", "false", "true", + ghe, ) mock_file.assert_called_once_with("filename", "w", encoding="utf-8") @@ -93,6 +95,7 @@ def test_write_to_markdown_with_sponsors(self, mock_file): person1, person2, ] + ghe = "" write_to_markdown( collaborators, @@ -103,6 +106,7 @@ def test_write_to_markdown_with_sponsors(self, mock_file): "org/repo", "true", "true", + ghe, ) mock_file.assert_called_once_with("filename", "w", encoding="utf-8") @@ -147,6 +151,7 @@ def test_write_to_markdown_without_link_to_profile(self, mock_file): person1, person2, ] + ghe = "" write_to_markdown( collaborators, @@ -157,6 +162,7 @@ def test_write_to_markdown_without_link_to_profile(self, mock_file): "org/repo", "false", "false", + ghe, ) mock_file.assert_called_once_with("filename", "w", encoding="utf-8") From 9dc1ec3e7b427d57566488637546f3b57b96de64 Mon Sep 17 00:00:00 2001 From: ricardojdsilva87 Date: Mon, 28 Oct 2024 14:53:07 +0000 Subject: [PATCH 3/8] fix: api endpoint if ghe is not set --- auth.py | 2 +- contributor_stats.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/auth.py b/auth.py index 6d848d1..840d4eb 100644 --- a/auth.py +++ b/auth.py @@ -69,7 +69,7 @@ def get_github_app_installation_token( str: the GitHub App token """ jwt_headers = github3.apps.create_jwt_headers(gh_app_private_key_bytes, gh_app_id) - api_endpoint = f"{ghe}/api/v3" if ghe else "api.github.com" + 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: diff --git a/contributor_stats.py b/contributor_stats.py index 4f553f0..7ddb36d 100644 --- a/contributor_stats.py +++ b/contributor_stats.py @@ -153,10 +153,10 @@ def get_sponsor_information(contributors: list, token: str, ghe: str) -> list: variables = {"username": contributor.username} # Send the GraphQL request - api_endpoint = f"{ghe}/api/v3" if ghe else "api.github.com" + api_endpoint = f"{ghe}/api/v3" if ghe else "https://api.github.com" headers = {"Authorization": f"Bearer {token}"} response = requests.post( - f"https://{api_endpoint}/graphql", + f"{api_endpoint}/graphql", json={"query": query, "variables": variables}, headers=headers, timeout=60, From 3b6a323c432b8d34bed803ef4a979f788b9451d5 Mon Sep 17 00:00:00 2001 From: ricardojdsilva87 Date: Mon, 28 Oct 2024 15:10:44 +0000 Subject: [PATCH 4/8] fix: fix api endpoint --- contributor_stats.py | 6 ++++-- contributors.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/contributor_stats.py b/contributor_stats.py index 7ddb36d..1200f65 100644 --- a/contributor_stats.py +++ b/contributor_stats.py @@ -153,10 +153,12 @@ def get_sponsor_information(contributors: list, token: str, ghe: str) -> list: variables = {"username": contributor.username} # Send the GraphQL request - api_endpoint = f"{ghe}/api/v3" if ghe else "https://api.github.com" + api_endpoint = ( + f"{ghe}/api/v3".removeprefix("https://") if ghe else "api.github.com" + ) headers = {"Authorization": f"Bearer {token}"} response = requests.post( - f"{api_endpoint}/graphql", + f"https://{api_endpoint}/graphql", json={"query": query, "variables": variables}, headers=headers, timeout=60, diff --git a/contributors.py b/contributors.py index 102b854..42491c0 100644 --- a/contributors.py +++ b/contributors.py @@ -173,7 +173,7 @@ def get_contributors(repo: object, start_date: str, end_date: str, ghe: str): continue # Store the contributor information in a ContributorStats object - api_endpoint = ghe if ghe else "github.com" + api_endpoint = ghe.removeprefix("https://") if ghe else "github.com" if start_date and end_date: commit_url = f"https://{api_endpoint}/{repo.full_name}/commits?author={user.login}&since={start_date}&until={end_date}" else: From 4234b6a75d5ea9766dadfdb49935ec855706eddd Mon Sep 17 00:00:00 2001 From: ricardojdsilva87 Date: Mon, 28 Oct 2024 15:31:42 +0000 Subject: [PATCH 5/8] fix: add suggestions --- .coveragerc | 2 +- .env-example | 4 ++-- auth.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.coveragerc b/.coveragerc index 366a2ce..029c5d6 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,4 +1,4 @@ [run] omit = # omit test files - test_*.py \ No newline at end of file + test_*.py diff --git a/.env-example b/.env-example index 45fd8e0..9c25853 100644 --- a/.env-example +++ b/.env-example @@ -1,4 +1,4 @@ -GH_ENTERPRISE_URL="" +GH_ENTERPRISE_URL = "" GH_TOKEN = "" END_DATE = "" ORGANIZATION = "organization" @@ -9,4 +9,4 @@ START_DATE = "" GH_APP_ID = "" GH_INSTALLATION_ID = "" GH_PRIVATE_KEY = "" -GITHUB_APP_ENTERPRISE_ONLY = "" \ No newline at end of file +GITHUB_APP_ENTERPRISE_ONLY = "" diff --git a/auth.py b/auth.py index 840d4eb..6669f54 100644 --- a/auth.py +++ b/auth.py @@ -76,6 +76,6 @@ def get_github_app_installation_token( response = requests.post(url, headers=jwt_headers, json=None, timeout=5) response.raise_for_status() except requests.exceptions.RequestException as e: - print(f"Request failed: {e}") + print(f"Request to get GitHub App Installation Token failed: {e}") return None return response.json().get("token") From 7f806e2edf4165f451f12d52a955294482f85292 Mon Sep 17 00:00:00 2001 From: ricardojdsilva87 Date: Tue, 29 Oct 2024 09:23:40 +0000 Subject: [PATCH 6/8] fix: suggestions + add test to sponsorinfo --- contributor_stats.py | 11 +++---- contributors.py | 6 ++-- markdown.py | 6 ++-- test_contributor_stats.py | 66 +++++++++++++++++++++++++++++++++++++-- 4 files changed, 72 insertions(+), 17 deletions(-) diff --git a/contributor_stats.py b/contributor_stats.py index 1200f65..ffd960e 100644 --- a/contributor_stats.py +++ b/contributor_stats.py @@ -153,12 +153,10 @@ def get_sponsor_information(contributors: list, token: str, ghe: str) -> list: variables = {"username": contributor.username} # Send the GraphQL request - api_endpoint = ( - f"{ghe}/api/v3".removeprefix("https://") if ghe else "api.github.com" - ) + api_endpoint = f"{ghe}/api/v3" if ghe else "https://api.github.com" headers = {"Authorization": f"Bearer {token}"} response = requests.post( - f"https://{api_endpoint}/graphql", + f"{api_endpoint}/graphql", json={"query": query, "variables": variables}, headers=headers, timeout=60, @@ -170,10 +168,9 @@ def get_sponsor_information(contributors: list, token: str, ghe: str) -> list: data = response.json()["data"] + endpoint = ghe if ghe else "https://github.com" # if the user has a sponsor page, add it to the contributor object if data["repositoryOwner"]["hasSponsorsListing"]: - contributor.sponsor_info = ( - f"https://{api_endpoint}/sponsors/{contributor.username}" - ) + contributor.sponsor_info = f"{endpoint}/sponsors/{contributor.username}" return contributors diff --git a/contributors.py b/contributors.py index 42491c0..96cdd86 100644 --- a/contributors.py +++ b/contributors.py @@ -173,11 +173,11 @@ def get_contributors(repo: object, start_date: str, end_date: str, ghe: str): continue # Store the contributor information in a ContributorStats object - api_endpoint = ghe.removeprefix("https://") if ghe else "github.com" + endpoint = ghe if ghe else "https://github.com" if start_date and end_date: - commit_url = f"https://{api_endpoint}/{repo.full_name}/commits?author={user.login}&since={start_date}&until={end_date}" + commit_url = f"{endpoint}/{repo.full_name}/commits?author={user.login}&since={start_date}&until={end_date}" else: - commit_url = f"https://{api_endpoint}/{repo.full_name}/commits?author={user.login}" + commit_url = f"{endpoint}/{repo.full_name}/commits?author={user.login}" contributor = contributor_stats.ContributorStats( user.login, False, diff --git a/markdown.py b/markdown.py index a32328a..1acda35 100644 --- a/markdown.py +++ b/markdown.py @@ -185,10 +185,8 @@ def get_contributor_table( for url in commit_url_list: url = url.strip() # get the organization and repository name from the url ie. org1/repo2 from https://github.com/org1/repo2/commits?author-zkoppert - api_endpoint = ghe.removeprefix("https://") if ghe else "github.com" - org_repo_link_name = url.split("/commits")[0].split(f"{api_endpoint}/")[ - 1 - ] + endpoint = ghe.removeprefix("https://") if ghe else "github.com" + org_repo_link_name = url.split("/commits")[0].split(f"{endpoint}/")[1] url = f"[{org_repo_link_name}]({url})" commit_urls += f"{url}, " new_contributor = collaborator.new_contributor diff --git a/test_contributor_stats.py b/test_contributor_stats.py index adcd0a5..69ce9ce 100644 --- a/test_contributor_stats.py +++ b/test_contributor_stats.py @@ -1,8 +1,14 @@ """This module contains the tests for the ContributorStats class.""" import unittest +from unittest.mock import MagicMock, patch -from contributor_stats import ContributorStats, is_new_contributor, merge_contributors +from contributor_stats import ( + ContributorStats, + get_sponsor_information, + is_new_contributor, + merge_contributors, +) class TestContributorStats(unittest.TestCase): @@ -28,7 +34,7 @@ def test_init(self): Test the __init__ method of the ContributorStats class. """ self.assertEqual(self.contributor.username, "zkoppert") - self.assertEqual(self.contributor.new_contributor, False) + self.assertFalse(self.contributor.new_contributor) self.assertEqual( self.contributor.avatar_url, "https://avatars.githubusercontent.com/u/29484535?v=4", @@ -95,7 +101,7 @@ def test_merge_contributors(self): result = merge_contributors(all_contributors) - self.assertTrue(expected_result == result) + self.assertEqual(expected_result, result) def test_is_new_contributor_true(self): """ @@ -154,5 +160,59 @@ def test_is_new_contributor_false(self): self.assertFalse(result) +class TestSponsorInfo(unittest.TestCase): + @patch("requests.post") + def test_fetch_sponsor_info(self, mock_post): + # Mock response data + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": {"repositoryOwner": {"hasSponsorsListing": True}} + } + mock_post.return_value = mock_response + + # Mock contributors + user = "user1" + returning_contributors = [ + ContributorStats( + username=user, + new_contributor=False, + avatar_url="https://avatars.githubusercontent.com/u/", + contribution_count="100", + commit_url="url1", + sponsor_info="", + ), + ] + + # Test parameters + ghe = "" + token = "token" + + # Call the function + result = get_sponsor_information(returning_contributors, token, ghe) + + # Assertions + self.assertEqual(result[0].sponsor_info, "https://github.com/sponsors/user1") + + # Ensure the post request was called with the correct parameters + mock_post.assert_called_once_with( + "https://api.github.com/graphql", + json={ + "query": """ + query($username: String!){ + repositoryOwner(login: $username) { + ... on User { + hasSponsorsListing + } + } + } + """, + "variables": {"username": "user1"}, + }, + headers={"Authorization": "Bearer token"}, + timeout=60, + ) + + if __name__ == "__main__": unittest.main() From 089ec1828c39910c4ba5d82758a890ae7a3a10ff Mon Sep 17 00:00:00 2001 From: ricardojdsilva87 Date: Tue, 29 Oct 2024 09:34:19 +0000 Subject: [PATCH 7/8] fix: remove class from tests --- test_contributor_stats.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/test_contributor_stats.py b/test_contributor_stats.py index 69ce9ce..9686337 100644 --- a/test_contributor_stats.py +++ b/test_contributor_stats.py @@ -159,8 +159,6 @@ def test_is_new_contributor_false(self): self.assertFalse(result) - -class TestSponsorInfo(unittest.TestCase): @patch("requests.post") def test_fetch_sponsor_info(self, mock_post): # Mock response data From 80b8c21187381481673b0ca6f0cd7220da38c8d3 Mon Sep 17 00:00:00 2001 From: ricardojdsilva87 Date: Wed, 30 Oct 2024 11:46:51 +0000 Subject: [PATCH 8/8] fix: lint error,add docstring to test_fetch_sponsor_info test --- test_contributor_stats.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test_contributor_stats.py b/test_contributor_stats.py index 9686337..692edee 100644 --- a/test_contributor_stats.py +++ b/test_contributor_stats.py @@ -161,6 +161,9 @@ def test_is_new_contributor_false(self): @patch("requests.post") def test_fetch_sponsor_info(self, mock_post): + """ + Test the get_sponsor_information function. + """ # Mock response data mock_response = MagicMock() mock_response.status_code = 200