Skip to content

Commit f7ee4d1

Browse files
authored
Merge pull request #30 from jmeridth/jm-github-app-auth
feat: allow github app authentication
2 parents fa9994a + a6daac0 commit f7ee4d1

File tree

10 files changed

+309
-83
lines changed

10 files changed

+309
-83
lines changed

.env-example

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,16 @@
1-
GH_TOKEN = " "
2-
ORGANIZATION = "organization"
1+
DRY_RUN = "false" # true or false
2+
EXEMPT_REPOS = "" # comma separated list of repositories to exempt
3+
GH_ENTERPRISE_URL = ""
4+
GH_TOKEN = ""
5+
ORGANIZATION = ""
6+
REPOSITORY = "" # comma separated list of repositories in the format org/repo
7+
8+
# GITHUB APP
9+
GH_APP_ID = ""
10+
GH_INSTALLATION_ID = ""
11+
GH_PRIVATE_KEY = ""
12+
13+
# OPTIONAL SETTINGS
14+
BODY = ""
15+
COMMIT_MESSAGE = ""
16+
TITLE = ""

.github/workflows/python-ci.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,7 @@ jobs:
2727
- name: Install dependencies
2828
run: |
2929
python -m pip install --upgrade pip
30-
python -m pip install flake8 pylint pytest pytest-cov
31-
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
30+
pip install -r requirements.txt -r requirements-test.txt
3231
- name: Lint with flake8 and pylint
3332
run: |
3433
make lint

README.md

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,17 @@ If you need support using this project or have questions about it, please [open
2525

2626
Below are the allowed configuration options:
2727

28-
| field | required | default | description |
29-
|-----------------------|----------|---------|-------------|
30-
| `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. |
31-
| `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. |
32-
| `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` |
33-
| `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` |
34-
| `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` |
35-
| `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. |
28+
| field | required | default | description |
29+
|---------------------------|----------|---------|-------------|
30+
| `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. |
31+
| `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. |
32+
| `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. |
33+
| `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. |
34+
| `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. |
35+
| `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` |
36+
| `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` |
37+
| `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` |
38+
| `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. |
3639

3740
### Example workflows
3841

@@ -90,6 +93,37 @@ jobs:
9093

9194
```
9295
96+
### Authenticating with a GitHub App and Installation
97+
98+
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.
99+
100+
```yaml
101+
---
102+
name: Weekly codeowners cleanup via GitHub App
103+
on:
104+
workflow_dispatch:
105+
schedule:
106+
- cron: '3 2 1 * *'
107+
108+
permissions:
109+
issues: write
110+
111+
jobs:
112+
cleanowners:
113+
name: cleanowners
114+
runs-on: ubuntu-latest
115+
116+
steps:
117+
- name: Run cleanowners action
118+
uses: github/cleanowners@v1
119+
env:
120+
GH_APP_ID: ${{ secrets.GH_APP_ID }}
121+
GH_APP_INSTALLATION_ID: ${{ secrets.GH_APP_INSTALLATION_ID }}
122+
GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }}
123+
ORGANIZATION: <YOUR_ORGANIZATION_GOES_HERE>
124+
EXEMPT_REPOS: "org_name/repo_name_1, org_name/repo_name_2"
125+
```
126+
93127
## Local usage without Docker
94128

95129
1. Make sure you have at least Python3.11 installed

auth.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,41 @@
33
import github3
44

55

6-
def auth_to_github(token: str, ghe: str) -> github3.GitHub:
6+
def auth_to_github(
7+
gh_app_id: str,
8+
gh_app_installation_id: int,
9+
gh_app_private_key_bytes: bytes,
10+
token: str,
11+
ghe: str,
12+
) -> github3.GitHub:
713
"""
814
Connect to GitHub.com or GitHub Enterprise, depending on env variables.
915
1016
Args:
17+
gh_app_id (str): the GitHub App ID
18+
gh_installation_id (int): the GitHub App Installation ID
19+
gh_app_private_key (bytes): the GitHub App Private Key
1120
token (str): the GitHub personal access token
1221
ghe (str): the GitHub Enterprise URL
1322
1423
Returns:
1524
github3.GitHub: the GitHub connection object
1625
"""
17-
if not token:
18-
raise ValueError("GH_TOKEN environment variable not set")
1926

20-
if ghe:
27+
if gh_app_id and gh_app_private_key_bytes and gh_app_installation_id:
28+
gh = github3.github.GitHub()
29+
gh.login_as_app_installation(
30+
gh_app_private_key_bytes, gh_app_id, gh_app_installation_id
31+
)
32+
github_connection = gh
33+
elif ghe and token:
2134
github_connection = github3.github.GitHubEnterprise(ghe, token=token)
22-
else:
35+
elif token:
2336
github_connection = github3.login(token=token)
37+
else:
38+
raise ValueError(
39+
"GH_TOKEN or the set of [GH_APP_ID, GH_APP_INSTALLATION_ID, GH_APP_PRIVATE_KEY] environment variables are not set"
40+
)
2441

2542
if not github_connection:
2643
raise ValueError("Unable to authenticate to GitHub")

cleanowners.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ def main(): # pragma: no cover
1414
(
1515
organization,
1616
repository_list,
17+
gh_app_id,
18+
gh_app_installation_id,
19+
gh_app_private_key_bytes,
1720
token,
1821
ghe,
1922
exempt_repositories_list,
@@ -24,7 +27,9 @@ def main(): # pragma: no cover
2427
) = env.get_env_vars()
2528

2629
# Auth to GitHub.com or GHE
27-
github_connection = auth.auth_to_github(token, ghe)
30+
github_connection = auth.auth_to_github(
31+
gh_app_id, gh_app_installation_id, gh_app_private_key_bytes, token, ghe
32+
)
2833
pull_count = 0
2934
eligble_for_pr_count = 0
3035
no_codeowners_count = 0

env.py

Lines changed: 62 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,53 @@
88
from dotenv import load_dotenv
99

1010

11-
def get_env_vars() -> (
12-
tuple[str | None, list[str], str, str, list[str], bool, str, str, str]
13-
):
11+
def get_int_env_var(env_var_name: str) -> int | None:
12+
"""Get an integer environment variable.
13+
14+
Args:
15+
env_var_name: The name of the environment variable to retrieve.
16+
17+
Returns:
18+
The value of the environment variable as an integer or None.
19+
"""
20+
env_var = os.environ.get(env_var_name)
21+
if env_var is None or not env_var.strip():
22+
return None
23+
try:
24+
return int(env_var)
25+
except ValueError:
26+
return None
27+
28+
29+
def get_env_vars(
30+
test: bool = False,
31+
) -> tuple[
32+
str | None,
33+
list[str],
34+
int | None,
35+
int | None,
36+
bytes,
37+
str | None,
38+
str,
39+
list[str],
40+
bool,
41+
str,
42+
str,
43+
str,
44+
]:
1445
"""
1546
Get the environment variables for use in the action.
1647
1748
Args:
18-
None
49+
test (bool): Whether or not to load the environment variables from a .env file (default: False)
1950
2051
Returns:
21-
organization (str): The organization to search for repositories in
52+
organization (str | None): The organization to search for repositories in
2253
repository_list (list[str]): A list of repositories to search for
23-
token (str): The GitHub token to use for authentication
54+
gh_app_id (int | None): The GitHub App ID to use for authentication
55+
gh_app_installation_id (int | None): The GitHub App Installation ID to use for authentication
56+
gh_app_private_key_bytes (bytes): The GitHub App Private Key as bytes to use for authentication
57+
token (str | None): The GitHub token to use for authentication
2458
ghe (str): The GitHub Enterprise URL to use for authentication
2559
exempt_repositories_list (list[str]): A list of repositories to exempt from the action
2660
dry_run (bool): Whether or not to actually open issues/pull requests
@@ -29,9 +63,10 @@ def get_env_vars() -> (
2963
message (str): Commit message to use
3064
3165
"""
32-
# Load from .env file if it exists
33-
dotenv_path = join(dirname(__file__), ".env")
34-
load_dotenv(dotenv_path)
66+
if not test:
67+
# Load from .env file if it exists
68+
dotenv_path = join(dirname(__file__), ".env")
69+
load_dotenv(dotenv_path)
3570

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

91+
gh_app_id = get_int_env_var("GH_APP_ID")
92+
gh_app_private_key_bytes = os.environ.get("GH_APP_PRIVATE_KEY", "").encode("utf8")
93+
gh_app_installation_id = get_int_env_var("GH_APP_INSTALLATION_ID")
94+
95+
if gh_app_id and (not gh_app_private_key_bytes or not gh_app_installation_id):
96+
raise ValueError(
97+
"GH_APP_ID set and GH_APP_INSTALLATION_ID or GH_APP_PRIVATE_KEY variable not set"
98+
)
99+
56100
token = os.getenv("GH_TOKEN")
57-
# required env variable
58-
if not token:
101+
if (
102+
not gh_app_id
103+
and not gh_app_private_key_bytes
104+
and not gh_app_installation_id
105+
and not token
106+
):
59107
raise ValueError("GH_TOKEN environment variable not set")
60108

61109
ghe = os.getenv("GH_ENTERPRISE_URL", default="").strip()
@@ -110,6 +158,9 @@ def get_env_vars() -> (
110158
return (
111159
organization,
112160
repositories_list,
161+
gh_app_id,
162+
gh_app_installation_id,
163+
gh_app_private_key_bytes,
113164
token,
114165
ghe,
115166
exempt_repositories_list,

requirements-test.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
flake8==7.0.0
2+
pylint==3.1.0
3+
pytest==8.1.1
4+
pytest-cov==4.1.0

requirements.txt

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,2 @@
11
github3.py==4.0.1
22
python-dotenv==1.0.1
3-
pytest==8.1.1
4-
pytest-cov==4.1.0

test_auth.py

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,52 @@
11
"""Test cases for the auth module."""
22
import unittest
3-
from unittest.mock import patch
3+
from unittest.mock import MagicMock, patch
44

55
import auth
6+
import github3.github
67

78

89
class TestAuth(unittest.TestCase):
910
"""
1011
Test case for the auth module.
1112
"""
1213

13-
@patch("github3.login")
14-
def test_auth_to_github_with_token(self, mock_login):
14+
@patch("github3.github.GitHub.login_as_app_installation")
15+
def test_auth_to_github_with_github_app(self, mock_login):
1516
"""
16-
Test the auth_to_github function when the token is provided.
17+
Test the auth_to_github function when GitHub app
18+
parameters provided.
1719
"""
18-
mock_login.return_value = "Authenticated to GitHub.com"
20+
mock_login.return_value = MagicMock()
21+
result = auth.auth_to_github(12345, 678910, b"hello", "", "")
22+
23+
self.assertIsInstance(result, github3.github.GitHub)
1924

20-
result = auth.auth_to_github("token", "")
25+
def test_auth_to_github_with_token(self):
26+
"""
27+
Test the auth_to_github function when the token is provided.
28+
"""
29+
result = auth.auth_to_github(None, None, b"", "token", "")
2130

22-
self.assertEqual(result, "Authenticated to GitHub.com")
31+
self.assertIsInstance(result, github3.github.GitHub)
2332

2433
def test_auth_to_github_without_token(self):
2534
"""
2635
Test the auth_to_github function when the token is not provided.
2736
Expect a ValueError to be raised.
2837
"""
2938
with self.assertRaises(ValueError):
30-
auth.auth_to_github("", "")
39+
auth.auth_to_github(None, None, b"", "", "")
3140

32-
@patch("github3.github.GitHubEnterprise")
33-
def test_auth_to_github_with_ghe(self, mock_ghe):
41+
def test_auth_to_github_with_ghe(self):
3442
"""
3543
Test the auth_to_github function when the GitHub Enterprise URL is provided.
3644
"""
37-
mock_ghe.return_value = "Authenticated to GitHub Enterprise"
38-
result = auth.auth_to_github("token", "https://github.example.com")
45+
result = auth.auth_to_github(
46+
None, None, b"", "token", "https://github.example.com"
47+
)
3948

40-
self.assertEqual(result, "Authenticated to GitHub Enterprise")
49+
self.assertIsInstance(result, github3.github.GitHubEnterprise)
4150

4251

4352
if __name__ == "__main__":

0 commit comments

Comments
 (0)