diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..029c5d6 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,4 @@ +[run] +omit = + # omit test files + test_*.py diff --git a/.env-example b/.env-example index 580ff83..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,3 +9,4 @@ START_DATE = "" GH_APP_ID = "" GH_INSTALLATION_ID = "" GH_PRIVATE_KEY = "" +GITHUB_APP_ENTERPRISE_ONLY = "" diff --git a/.github/workflows/contributors_report.yaml b/.github/workflows/contributors_report.yaml index b41e1a4..e77cd36 100644 --- a/.github/workflows/contributors_report.yaml +++ b/.github/workflows/contributors_report.yaml @@ -30,7 +30,7 @@ jobs: echo "END_DATE=$end_date" >> "$GITHUB_ENV" - name: Run contributor action - uses: github/contributors@135b0430e856ade27175cbd1d4e1e11b0dd8ef95 + uses: github/contributors@1286dc8d6904a9a7f735e28b7503be164fb7d4b9 env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} START_DATE: ${{ env.START_DATE }} @@ -39,7 +39,7 @@ jobs: SPONSOR_INFO: "true" - name: Create issue - uses: peter-evans/create-issue-from-file@24452a72d85239eacf1468b0f1982a9f3fec4c94 + uses: peter-evans/create-issue-from-file@e8ef132d6df98ed982188e460ebb3b5d4ef3a9cd with: title: Monthly contributor report token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/docker-ci.yml b/.github/workflows/docker-ci.yml index 9b196b3..90fe96b 100644 --- a/.github/workflows/docker-ci.yml +++ b/.github/workflows/docker-ci.yml @@ -14,6 +14,6 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Build the Docker image run: docker build . --file Dockerfile --platform linux/amd64 diff --git a/.github/workflows/major-version-updater.yml b/.github/workflows/major-version-updater.yml index 23271bb..066d389 100644 --- a/.github/workflows/major-version-updater.yml +++ b/.github/workflows/major-version-updater.yml @@ -15,7 +15,7 @@ jobs: contents: write steps: - name: Checkout Repo - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: version id: version diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index d997950..ff0f30e 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -20,9 +20,9 @@ jobs: matrix: python-version: [3.11, 3.12] steps: - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@39cd14951b08e74b54015e9e001cdefcf80e669f + uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b with: python-version: ${{ matrix.python-version }} - name: Install dependencies diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a5eeeef..e681c61 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,17 +53,17 @@ jobs: IMAGE_NAME: ${{ github.repository }} steps: - name: Set up Docker Buildx - uses: docker/setup-buildx-action@988b5a0280414f521da01fcc63a27aeeb4b104db + uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349 - name: Log in to the Container registry uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Push Docker Image if: ${{ success() }} - uses: docker/build-push-action@16ebe778df0e7752d2cfcbd924afdbbd89c1a755 + uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75 with: context: . file: ./Dockerfile diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 281794e..b0f9265 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -25,7 +25,7 @@ jobs: steps: - name: "Checkout code" - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false @@ -36,12 +36,12 @@ jobs: results_format: sarif publish_results: true - name: "Upload artifact" - uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 with: name: SARIF file path: results.sarif retention-days: 5 - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@429e1977040da7a23b6822b13c129cd1ba93dbb2 # v3.26.2 + uses: github/codeql-action/upload-sarif@662472033e021d55d94146f66f6058822b0b39fd # v3.27.0 with: sarif_file: results.sarif diff --git a/.github/workflows/super-linter.yaml b/.github/workflows/super-linter.yaml index fe4f9da..b41f192 100644 --- a/.github/workflows/super-linter.yaml +++ b/.github/workflows/super-linter.yaml @@ -18,7 +18,7 @@ jobs: statuses: write steps: - name: Checkout Code - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 - name: Install dependencies @@ -26,7 +26,7 @@ jobs: python -m pip install --upgrade pip pip install -r requirements.txt -r requirements-test.txt - name: Lint Code Base - uses: super-linter/super-linter@02a1172d274f021e4c70f66e23f1085eadd1064b + uses: super-linter/super-linter@b92721f792f381cedc002ecdbb9847a15ece5bb8 env: DEFAULT_BRANCH: main GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Dockerfile b/Dockerfile index 721dcd4..5fdb73b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ #checkov:skip=CKV_DOCKER_2 #checkov:skip=CKV_DOCKER_3 -FROM python:3.12-slim@sha256:59c7332a4a24373861c4a5f0eec2c92b87e3efeb8ddef011744ef9a751b1d11c +FROM python:3.13-slim@sha256:751d8bece269ba9e672b3f2226050e7e6fb3f3da3408b5dcb5d415a054fcb061 LABEL com.github.actions.name="contributors" \ com.github.actions.description="GitHub Action that given an organization or repository, produces information about the contributors over the specified time period." \ com.github.actions.icon="users" \ @@ -17,7 +17,7 @@ COPY requirements.txt *.py /action/workspace/ RUN python3 -m pip install --no-cache-dir -r requirements.txt \ && apt-get -y update \ - && apt-get -y install --no-install-recommends git-all=1:2.39.2-1.1 \ + && apt-get -y install --no-install-recommends git=1:2.39.5-0+deb12u1 \ && rm -rf /var/lib/apt/lists/* CMD ["/action/workspace/contributors.py"] 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..6669f54 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 "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) + response.raise_for_status() + except requests.exceptions.RequestException as e: + print(f"Request to get GitHub App Installation Token failed: {e}") + return None + return response.json().get("token") diff --git a/contributor_stats.py b/contributor_stats.py index 1d7b1db..ffd960e 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 "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, @@ -169,10 +168,9 @@ def get_sponsor_information(contributors: list, token: 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://github.com/sponsors/{contributor.username}" - ) + contributor.sponsor_info = f"{endpoint}/sponsors/{contributor.username}" return contributors diff --git a/contributors.py b/contributors.py index 6518fbc..96cdd86 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( @@ -72,6 +82,7 @@ def main(): repository_list, sponsor_info, link_to_profile, + ghe, ) json_writer.write_to_json( filename="contributors.json", @@ -91,6 +102,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 +130,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 +140,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 +173,11 @@ def get_contributors( continue # Store the contributor information in a ContributorStats object + endpoint = ghe if ghe else "https://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"{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"{endpoint}/{repo.full_name}/commits?author={user.login}" contributor = contributor_stats.ContributorStats( user.login, False, @@ -181,7 +188,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..1acda35 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 @@ -116,19 +118,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 @@ -142,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. @@ -190,9 +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] + 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 += url + ", " + commit_urls += f"{url}, " new_contributor = collaborator.new_contributor row = f"| {'' if link_to_profile == 'false' else '@'}{username} | {contribution_count} |" diff --git a/requirements-test.txt b/requirements-test.txt index 6914185..1be36d2 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,8 +1,8 @@ -black==24.8.0 +black==24.10.0 flake8==7.1.1 -mypy==1.11.1 +mypy==1.13.0 mypy-extensions==1.0.0 -pylint==3.2.6 -pytest==8.3.2 +pylint==3.3.1 +pytest==8.3.3 pytest-cov==5.0.0 -types-requests==2.32.0.20240712 +types-requests==2.32.0.20241016 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_contributor_stats.py b/test_contributor_stats.py index adcd0a5..692edee 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): """ @@ -153,6 +159,61 @@ def test_is_new_contributor_false(self): self.assertFalse(result) + @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 + 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() 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() 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")