Skip to content

Commit

Permalink
Merge pull request #30 from jmeridth/jm-github-app-auth
Browse files Browse the repository at this point in the history
feat: allow github app authentication
  • Loading branch information
zkoppert authored Mar 14, 2024
2 parents fa9994a + a6daac0 commit f7ee4d1
Show file tree
Hide file tree
Showing 10 changed files with 309 additions and 83 deletions.
18 changes: 16 additions & 2 deletions .env-example
Original file line number Diff line number Diff line change
@@ -1,2 +1,16 @@
GH_TOKEN = " "
ORGANIZATION = "organization"
DRY_RUN = "false" # true or false
EXEMPT_REPOS = "" # comma separated list of repositories to exempt
GH_ENTERPRISE_URL = ""
GH_TOKEN = ""
ORGANIZATION = ""
REPOSITORY = "" # comma separated list of repositories in the format org/repo

# GITHUB APP
GH_APP_ID = ""
GH_INSTALLATION_ID = ""
GH_PRIVATE_KEY = ""

# OPTIONAL SETTINGS
BODY = ""
COMMIT_MESSAGE = ""
TITLE = ""
3 changes: 1 addition & 2 deletions .github/workflows/python-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install flake8 pylint pytest pytest-cov
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
pip install -r requirements.txt -r requirements-test.txt
- name: Lint with flake8 and pylint
run: |
make lint
Expand Down
50 changes: 42 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,17 @@ If you need support using this project or have questions about it, please [open

Below are the allowed configuration options:

| field | required | default | description |
|-----------------------|----------|---------|-------------|
| `GH_TOKEN` | True | "" | The GitHub Token used to scan the repository or organization. Must have write access to all repository you are interested in scanning so that an issue or pull request can be created. |
| `GH_ENTERPRISE_URL` | False | "" | The `GH_ENTERPRISE_URL` is used to connect to an enterprise server instance of GitHub. github.com users should not enter anything here. |
| `ORGANIZATION` | Required to have `ORGANIZATION` or `REPOSITORY` | | The name of the GitHub organization which you want this action to work from. ie. github.com/github would be `github` |
| `REPOSITORY` | Required to have `ORGANIZATION` or `REPOSITORY` | | The name of the repository and organization which you want this action to work from. ie. `github/cleanowners` or a comma separated list of multiple repositories `github/cleanowners,super-linter/super-linter` |
| `EXEMPT_REPOS` | False | "" | These repositories will be exempt from this action. ex: If my org is set to `github` then I might want to exempt a few of the repos but get the rest by setting `EXEMPT_REPOS` to `github/cleanowners,github/contributors` |
| `DRY_RUN` | False | false | If set to true, this action will not create any pull requests. It will only log the repositories that could have the `CODEOWNERS` file updated. This is useful for testing or discovering the scope of this issue in your organization. |
| field | required | default | description |
|---------------------------|----------|---------|-------------|
| `GH_TOKEN` | True | "" | The GitHub Token used to scan the repository or organization. Must have write access to all repository you are interested in scanning so that an issue or pull request can be created. |
| `GH_APP_ID` | False | `""` | 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` | False | `""` | 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` | False | `""` | 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. |
| `GH_ENTERPRISE_URL` | False | "" | The `GH_ENTERPRISE_URL` is used to connect to an enterprise server instance of GitHub. github.com users should not enter anything here. |
| `ORGANIZATION` | Required to have `ORGANIZATION` or `REPOSITORY` | | The name of the GitHub organization which you want this action to work from. ie. github.com/github would be `github` |
| `REPOSITORY` | Required to have `ORGANIZATION` or `REPOSITORY` | | The name of the repository and organization which you want this action to work from. ie. `github/cleanowners` or a comma separated list of multiple repositories `github/cleanowners,super-linter/super-linter` |
| `EXEMPT_REPOS` | False | "" | These repositories will be exempt from this action. ex: If my org is set to `github` then I might want to exempt a few of the repos but get the rest by setting `EXEMPT_REPOS` to `github/cleanowners,github/contributors` |
| `DRY_RUN` | False | False | If set to true, this action will not create any pull requests. It will only log the repositories that could have the `CODEOWNERS` file updated. This is useful for testing or discovering the scope of this issue in your organization. |

### Example workflows

Expand Down Expand Up @@ -90,6 +93,37 @@ jobs:

```
### Authenticating with a GitHub App and Installation
You can authenticate as a GitHub App Installation by providing additional environment variables. If `GH_TOKEN` is set alongside these GitHub App Installation variables, the `GH_TOKEN` will be ignored and not used.

```yaml
---
name: Weekly codeowners cleanup via GitHub App
on:
workflow_dispatch:
schedule:
- cron: '3 2 1 * *'
permissions:
issues: write
jobs:
cleanowners:
name: cleanowners
runs-on: ubuntu-latest
steps:
- name: Run cleanowners action
uses: github/cleanowners@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 }}
ORGANIZATION: <YOUR_ORGANIZATION_GOES_HERE>
EXEMPT_REPOS: "org_name/repo_name_1, org_name/repo_name_2"
```

## Local usage without Docker

1. Make sure you have at least Python3.11 installed
Expand Down
27 changes: 22 additions & 5 deletions auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,41 @@
import github3


def auth_to_github(token: str, ghe: str) -> github3.GitHub:
def auth_to_github(
gh_app_id: str,
gh_app_installation_id: int,
gh_app_private_key_bytes: bytes,
token: str,
ghe: str,
) -> 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
ghe (str): the GitHub Enterprise URL
Returns:
github3.GitHub: the GitHub connection object
"""
if not token:
raise ValueError("GH_TOKEN environment variable not set")

if ghe:
if gh_app_id and gh_app_private_key_bytes and gh_app_installation_id:
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)
else:
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"
)

if not github_connection:
raise ValueError("Unable to authenticate to GitHub")
Expand Down
7 changes: 6 additions & 1 deletion cleanowners.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ def main(): # pragma: no cover
(
organization,
repository_list,
gh_app_id,
gh_app_installation_id,
gh_app_private_key_bytes,
token,
ghe,
exempt_repositories_list,
Expand All @@ -24,7 +27,9 @@ def main(): # pragma: no cover
) = env.get_env_vars()

# Auth to GitHub.com or GHE
github_connection = auth.auth_to_github(token, ghe)
github_connection = auth.auth_to_github(
gh_app_id, gh_app_installation_id, gh_app_private_key_bytes, token, ghe
)
pull_count = 0
eligble_for_pr_count = 0
no_codeowners_count = 0
Expand Down
73 changes: 62 additions & 11 deletions env.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,53 @@
from dotenv import load_dotenv


def get_env_vars() -> (
tuple[str | None, list[str], str, str, list[str], bool, str, str, str]
):
def get_int_env_var(env_var_name: str) -> int | None:
"""Get an integer environment variable.
Args:
env_var_name: The name of the environment variable to retrieve.
Returns:
The value of the environment variable as an integer or None.
"""
env_var = os.environ.get(env_var_name)
if env_var is None or not env_var.strip():
return None
try:
return int(env_var)
except ValueError:
return None


def get_env_vars(
test: bool = False,
) -> tuple[
str | None,
list[str],
int | None,
int | None,
bytes,
str | None,
str,
list[str],
bool,
str,
str,
str,
]:
"""
Get the environment variables for use in the action.
Args:
None
test (bool): Whether or not to load the environment variables from a .env file (default: False)
Returns:
organization (str): The organization to search for repositories in
organization (str | None): The organization to search for repositories in
repository_list (list[str]): A list of repositories to search for
token (str): The GitHub token to use for authentication
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
token (str | None): The GitHub token to use for authentication
ghe (str): The GitHub Enterprise URL to use for authentication
exempt_repositories_list (list[str]): A list of repositories to exempt from the action
dry_run (bool): Whether or not to actually open issues/pull requests
Expand All @@ -29,9 +63,10 @@ def get_env_vars() -> (
message (str): Commit message to use
"""
# Load from .env file if it exists
dotenv_path = join(dirname(__file__), ".env")
load_dotenv(dotenv_path)
if not test:
# Load from .env file if it exists
dotenv_path = join(dirname(__file__), ".env")
load_dotenv(dotenv_path)

organization = os.getenv("ORGANIZATION")
repositories_str = os.getenv("REPOSITORY")
Expand All @@ -53,9 +88,22 @@ def get_env_vars() -> (
repository.strip() for repository in repositories_str.split(",")
]

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")

if gh_app_id and (not gh_app_private_key_bytes or not gh_app_installation_id):
raise ValueError(
"GH_APP_ID set and GH_APP_INSTALLATION_ID or GH_APP_PRIVATE_KEY variable not set"
)

token = os.getenv("GH_TOKEN")
# required env variable
if not token:
if (
not gh_app_id
and not gh_app_private_key_bytes
and not gh_app_installation_id
and not token
):
raise ValueError("GH_TOKEN environment variable not set")

ghe = os.getenv("GH_ENTERPRISE_URL", default="").strip()
Expand Down Expand Up @@ -110,6 +158,9 @@ def get_env_vars() -> (
return (
organization,
repositories_list,
gh_app_id,
gh_app_installation_id,
gh_app_private_key_bytes,
token,
ghe,
exempt_repositories_list,
Expand Down
4 changes: 4 additions & 0 deletions requirements-test.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
flake8==7.0.0
pylint==3.1.0
pytest==8.1.1
pytest-cov==4.1.0
2 changes: 0 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,2 @@
github3.py==4.0.1
python-dotenv==1.0.1
pytest==8.1.1
pytest-cov==4.1.0
35 changes: 22 additions & 13 deletions test_auth.py
Original file line number Diff line number Diff line change
@@ -1,43 +1,52 @@
"""Test cases for the auth module."""
import unittest
from unittest.mock import patch
from unittest.mock import MagicMock, patch

import auth
import github3.github


class TestAuth(unittest.TestCase):
"""
Test case for the auth module.
"""

@patch("github3.login")
def test_auth_to_github_with_token(self, mock_login):
@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 the token is provided.
Test the auth_to_github function when GitHub app
parameters provided.
"""
mock_login.return_value = "Authenticated to GitHub.com"
mock_login.return_value = MagicMock()
result = auth.auth_to_github(12345, 678910, b"hello", "", "")

self.assertIsInstance(result, github3.github.GitHub)

result = auth.auth_to_github("token", "")
def test_auth_to_github_with_token(self):
"""
Test the auth_to_github function when the token is provided.
"""
result = auth.auth_to_github(None, None, b"", "token", "")

self.assertEqual(result, "Authenticated to GitHub.com")
self.assertIsInstance(result, github3.github.GitHub)

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("", "")
auth.auth_to_github(None, None, b"", "", "")

@patch("github3.github.GitHubEnterprise")
def test_auth_to_github_with_ghe(self, mock_ghe):
def test_auth_to_github_with_ghe(self):
"""
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("token", "https://github.example.com")
result = auth.auth_to_github(
None, None, b"", "token", "https://github.example.com"
)

self.assertEqual(result, "Authenticated to GitHub Enterprise")
self.assertIsInstance(result, github3.github.GitHubEnterprise)


if __name__ == "__main__":
Expand Down
Loading

0 comments on commit f7ee4d1

Please sign in to comment.