Skip to content

Commit 2fc75a8

Browse files
committed
feat: allow user to use github app to authenticate
This feature allows users to authenticate with either just a GitHub app or a GitHub App Installation. - [x] setup authentication - [x] helper function to get integer environment variables without ValueError - [x] tests - [x] GitHub App Installation passing - [x] helper function to get integer environment variables - [x] added .venv to flake8 exlusion list (may need to address this another way) - [x] alphabetized lists in places (README, etc) Signed-off-by: jmeridth <jmeridth@gmail.com>
1 parent 8625600 commit 2fc75a8

File tree

5 files changed

+199
-25
lines changed

5 files changed

+199
-25
lines changed

.env-example

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
GH_APP_ID=' '
2+
GH_APP_INSTALLATION_ID=' '
3+
GH_APP_PRIVATE_KEY=' '
4+
GH_ENTERPRISE_URL=' '
15
GH_TOKEN=' '
26
INACTIVE_DAYS=365
37
ORGANIZATION=' '
4-
GH_ENTERPRISE_URL=' '

Makefile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,6 @@ clean:
1010
lint:
1111
pylint --rcfile=.pylintrc --fail-under=9.0 *.py
1212
# stop the build if there are Python syntax errors or undefined names
13-
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
13+
flake8 . --count --select=E9,F63,F7,F82 --exclude .venv --show-source --statistics
1414
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
15-
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
15+
flake8 . --count --exclude .venv --exit-zero --max-complexity=10 --max-line-length=127 --statistics

README.md

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,18 @@ Note: Your GitHub token will need to have read access to all the repositories in
2929

3030
Below are the allowed configuration options:
3131

32-
| field | required | default | description |
33-
|-----------------------|----------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
34-
| `GH_TOKEN` | true | | The GitHub Token used to scan repositories. Must have read access to all repositories you are interested in scanning |
35-
| `ORGANIZATION` | false | | The organization to scan for stale repositories. If no organization is provided, this tool will search through repositories owned by the GH_TOKEN owner |
36-
| `INACTIVE_DAYS` | true | | The number of days used to determine if repository is stale, based on `push` events |
37-
| `EXEMPT_TOPICS` | false | | Comma separated list of topics to exempt from being flagged as stale |
38-
| `EXEMPT_REPOS` | false | | Comma separated list of repositories to exempt from being flagged as stale. Supports Unix shell-style wildcards. ie. `EXEMPT_REPOS = "stale-repos,test-repo,conf-*"` |
39-
| `GH_ENTERPRISE_URL` | false | `""` | URL of GitHub Enterprise instance to use for auth instead of github.com |
40-
| `ACTIVITY_METHOD` | false | `"pushed"` | How to get the last active date of the repository. Defaults to `pushed`, which is the last time any branch had a push. Can also be set to `default_branch_updated` to instead measure from the latest commit on the default branch (good for filtering out dependabot ) |
32+
| field | required | default | description |
33+
|---------------------------|----------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
34+
| `ACTIVITY_METHOD` | false | `"pushed"` | How to get the last active date of the repository. Defaults to `pushed`, which is the last time any branch had a push. Can also be set to `default_branch_updated` to instead measure from the latest commit on the default branch (good for filtering out dependabot ) |
35+
| `GH_APP_ID` | false | `""` | GitHub Application ID. |
36+
| `GH_APP_INSTALLATION_ID` | false | `""` | GitHub Application Installation ID. |
37+
| `GH_APP_PRIVATE_KEY` | false | `""` | GitHub Application Private Key |
38+
| `GH_ENTERPRISE_URL` | false | `""` | URL of GitHub Enterprise instance to use for auth instead of github.com |
39+
| `GH_TOKEN` | true | | The GitHub Token used to scan repositories. Must have read access to all repositories you are interested in scanning |
40+
| `INACTIVE_DAYS` | true | | The number of days used to determine if repository is stale, based on `push` events |
41+
| `EXEMPT_REPOS` | false | | Comma separated list of repositories to exempt from being flagged as stale. Supports Unix shell-style wildcards. ie. `EXEMPT_REPOS = "stale-repos,test-repo,conf-*"` |
42+
| `EXEMPT_TOPICS` | false | | Comma separated list of topics to exempt from being flagged as stale |
43+
| `ORGANIZATION` | false | | The organization to scan for stale repositories. If no organization is provided, this tool will search through repositories owned by the GH_TOKEN owner |
4144

4245
### Example workflow
4346

@@ -171,6 +174,36 @@ jobs:
171174
INACTIVE_DAYS: 365
172175
```
173176
177+
### Authenticating with a GitHub App and Installation
178+
179+
You can authenticate as a GitHub App Installation by providing additional environment variables.
180+
181+
```yaml
182+
on:
183+
- workflow_dispatch
184+
185+
name: Run the report
186+
187+
jobs:
188+
build:
189+
name: stale repo identifier
190+
runs-on: ubuntu-latest
191+
192+
steps:
193+
- uses: actions/checkout@v3
194+
195+
- name: Run stale_repos tool
196+
uses: github/stale-repos@v1
197+
env:
198+
GH_APP_ID: ${{ secrets.GH_APP_ID }}
199+
GH_APP_INSTALLATION_ID: ${{ secrets.GH_APP_INSTALLATION_ID }}
200+
GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }}
201+
ORGANIZATION: ${{ secrets.ORGANIZATION }}
202+
EXEMPT_TOPICS: "keep,template"
203+
INACTIVE_DAYS: 365
204+
ACTIVITY_METHOD: "pushed"
205+
```
206+
174207
## Local usage without Docker
175208
176209
1. Have Python v3.9 or greater installed

stale_repos.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,11 +250,39 @@ def output_to_json(inactive_repos, file=None):
250250
return inactive_repos_json
251251

252252

253+
def get_int_env_var(env_var_name):
254+
"""Get an integer environment variable.
255+
256+
Args:
257+
env_var_name: The name of the environment variable to retrieve.
258+
259+
Returns:
260+
The value of the environment variable as an integer or None.
261+
"""
262+
env_var = os.environ.get(env_var_name)
263+
if env_var is None or not env_var.strip():
264+
return None
265+
try:
266+
return int(env_var)
267+
except ValueError:
268+
return None
269+
270+
253271
def auth_to_github():
254272
"""Connect to GitHub.com or GitHub Enterprise, depending on env variables."""
273+
gh_app_id = get_int_env_var("GH_APP_ID")
274+
gh_app_private_key_bytes = os.environ.get("GH_APP_PRIVATE_KEY", "").encode("utf8")
275+
gh_app_installation_id = get_int_env_var("GH_APP_INSTALLATION_ID")
255276
ghe = os.getenv("GH_ENTERPRISE_URL", default="").strip()
256277
token = os.getenv("GH_TOKEN")
257-
if ghe and token:
278+
279+
if gh_app_id and gh_app_private_key_bytes and gh_app_installation_id:
280+
gh = github3.github.GitHub()
281+
gh.login_as_app_installation(
282+
gh_app_private_key_bytes, gh_app_id, gh_app_installation_id
283+
)
284+
github_connection = gh
285+
elif ghe and token:
258286
github_connection = github3.github.GitHubEnterprise(ghe, token=token)
259287
elif token:
260288
github_connection = github3.login(token=os.getenv("GH_TOKEN"))
@@ -263,6 +291,7 @@ def auth_to_github():
263291

264292
if not github_connection:
265293
raise ValueError("Unable to authenticate to GitHub")
294+
print(f"github_connection type: {type(github_connection)}")
266295
return github_connection # type: ignore
267296

268297

test_stale_repos.py

Lines changed: 121 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
auth_to_github,
2929
get_active_date,
3030
get_inactive_repos,
31+
get_int_env_var,
3132
is_repo_exempt,
3233
output_to_json,
3334
write_to_markdown,
@@ -47,6 +48,8 @@ class AuthToGithubTestCase(unittest.TestCase):
4748
and authentication failures.
4849
4950
Test methods:
51+
- test_auth_to_github_app_with_github_app_installation_env_vars: Tests authencation
52+
to GitHub application with app ID, app private key, and app installation ID.
5053
- test_auth_to_github_with_enterprise_url_and_token: Tests authentication with both
5154
enterprise URL and token.
5255
- test_auth_to_github_with_token: Tests authentication with only a token.
@@ -59,7 +62,36 @@ class AuthToGithubTestCase(unittest.TestCase):
5962
"""
6063

6164
@patch.dict(
62-
os.environ, {"GH_ENTERPRISE_URL": "https://example.com", "GH_TOKEN": "abc123"}
65+
os.environ, {
66+
"GH_APP_ID": "12345",
67+
"GH_APP_PRIVATE_KEY": "FakePrivateKey",
68+
"GH_APP_INSTALLATION_ID": "67890",
69+
"GH_ENTERPRISE_URL": "",
70+
"GH_TOKEN": ""
71+
}
72+
)
73+
@patch("github3.github.GitHub.login_as_app_installation")
74+
def test_auth_to_github_app_with_github_app_installation_env_vars(self, mock_login):
75+
"""
76+
Test authentication with both app id, app private key, and installation id.
77+
78+
This test verifies that when GH_APP_ID, GH_APP_PRIVATE_KEY, and GH_INSTALLATION_ID
79+
environment variables are set, the auth_to_github() function returns a connection
80+
object of type github3.github.GitHub.
81+
82+
"""
83+
mock_login.return_value = MagicMock()
84+
connection = auth_to_github()
85+
self.assertIsInstance(connection, github3.github.GitHub)
86+
87+
@patch.dict(
88+
os.environ, {
89+
"GH_APP_ID": "",
90+
"GH_APP_PRIVATE_KEY": "",
91+
"GH_APP_INSTALLATION_ID": "",
92+
"GH_ENTERPRISE_URL": "https://example.com",
93+
"GH_TOKEN": "abc123"
94+
}
6395
)
6496
def test_auth_to_github_with_enterprise_url_and_token(self):
6597
"""
@@ -73,7 +105,15 @@ def test_auth_to_github_with_enterprise_url_and_token(self):
73105
connection = auth_to_github()
74106
self.assertIsInstance(connection, github3.github.GitHubEnterprise)
75107

76-
@patch.dict(os.environ, {"GH_TOKEN": "abc123"})
108+
@patch.dict(
109+
os.environ, {
110+
"GH_APP_ID": "",
111+
"GH_APP_PRIVATE_KEY": "",
112+
"GH_APP_INSTALLATION_ID": "",
113+
"GH_ENTERPRISE_URL": "",
114+
"GH_TOKEN": "abc123"
115+
}
116+
)
77117
def test_auth_to_github_with_token(self):
78118
"""
79119
Test authentication with only a token.
@@ -85,7 +125,15 @@ def test_auth_to_github_with_token(self):
85125
connection = auth_to_github()
86126
self.assertIsInstance(connection, github3.github.GitHub)
87127

88-
@patch.dict(os.environ, {"GH_ENTERPRISE_URL": "", "GH_TOKEN": ""})
128+
@patch.dict(
129+
os.environ, {
130+
"GH_APP_ID": "",
131+
"GH_APP_PRIVATE_KEY": "",
132+
"GH_APP_INSTALLATION_ID": "",
133+
"GH_ENTERPRISE_URL": "",
134+
"GH_TOKEN": ""
135+
}
136+
)
89137
def test_auth_to_github_without_environment_variables(self):
90138
"""
91139
Test authentication with missing environment variables.
@@ -94,11 +142,21 @@ def test_auth_to_github_without_environment_variables(self):
94142
variables are empty, the auth_to_github() function raises a ValueError.
95143
96144
"""
97-
with self.assertRaises(ValueError):
145+
with self.assertRaises(ValueError) as cm:
98146
auth_to_github()
147+
the_exception = cm.exception
148+
self.assertEqual(str(the_exception), 'GH_TOKEN environment variable not set')
99149

100-
@patch("github3.login")
101-
def test_auth_to_github_without_enterprise_url(self, mock_login):
150+
@patch.dict(
151+
os.environ, {
152+
"GH_APP_ID": "",
153+
"GH_APP_PRIVATE_KEY": "",
154+
"GH_APP_INSTALLATION_ID": "",
155+
"GH_ENTERPRISE_URL": "",
156+
"GH_TOKEN": "abc123"
157+
}
158+
)
159+
def test_auth_to_github_without_enterprise_url(self):
102160
"""
103161
Test authentication without an enterprise URL.
104162
@@ -107,10 +165,8 @@ def test_auth_to_github_without_enterprise_url(self, mock_login):
107165
a connection object of type github3.github.GitHub.
108166
109167
"""
110-
mock_login.return_value = None
111-
with patch.dict(os.environ, {"GH_ENTERPRISE_URL": "", "GH_TOKEN": "abc123"}):
112-
with self.assertRaises(ValueError):
113-
auth_to_github()
168+
connection = auth_to_github()
169+
self.assertIsInstance(connection, github3.github.GitHub)
114170

115171
@patch("github3.login")
116172
def test_auth_to_github_authentication_failure(self, mock_login):
@@ -123,9 +179,62 @@ def test_auth_to_github_authentication_failure(self, mock_login):
123179
124180
"""
125181
mock_login.return_value = None
126-
with patch.dict(os.environ, {"GH_ENTERPRISE_URL": "", "GH_TOKEN": "abc123"}):
127-
with self.assertRaises(ValueError):
182+
with patch.dict(
183+
os.environ, {
184+
"GH_APP_ID": "",
185+
"GH_APP_PRIVATE_KEY": "",
186+
"GH_APP_INSTALLATION_ID": "",
187+
"GH_ENTERPRISE_URL": "",
188+
"GH_TOKEN": "abc123"
189+
}
190+
):
191+
with self.assertRaises(ValueError) as cm:
128192
auth_to_github()
193+
the_exception = cm.exception
194+
self.assertEqual(str(the_exception), 'Unable to authenticate to GitHub')
195+
196+
197+
class TestGetIntFromEnv(unittest.TestCase):
198+
"""
199+
Test suite for the get_int_from_env function.
200+
201+
...
202+
203+
Test methods:
204+
- test_get_int_env_var: Test returns the expected integer value.
205+
- test_get_int_env_var_with_empty_env_var: Test returns None when environment variable
206+
is empty.
207+
- test_get_int_env_var_with_non_integer: Test returns None when environment variable
208+
is a non-integer.
209+
"""
210+
211+
@patch.dict(os.environ, {"INT_ENV_VAR": "12345"})
212+
def test_get_int_env_var(self):
213+
"""
214+
Test that get_int_env_var returns the expected integer value.
215+
"""
216+
result = get_int_env_var("INT_ENV_VAR")
217+
self.assertEqual(result, 12345)
218+
219+
@patch.dict(os.environ, {"INT_ENV_VAR": ""})
220+
def test_get_int_env_var_with_empty_env_var(self):
221+
"""
222+
This test verifies that the get_int_env_var function returns None
223+
when the environment variable is empty.
224+
225+
"""
226+
result = get_int_env_var("INT_ENV_VAR")
227+
self.assertIsNone(result)
228+
229+
@patch.dict(os.environ, {"INT_ENV_VAR": "not_an_int"})
230+
def test_get_int_env_var_with_non_integer(self):
231+
"""
232+
Test that get_int_env_var returns None when the environment variable is
233+
a non-integer.
234+
235+
"""
236+
result = get_int_env_var("INT_ENV_VAR")
237+
self.assertIsNone(result)
129238

130239

131240
class GetInactiveReposTestCase(unittest.TestCase):

0 commit comments

Comments
 (0)