Skip to content

Commit 7cdf1b9

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 - [ ] tests - [ ] GitHub App tests failling (401 Unauthorized) - [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 7cdf1b9

File tree

5 files changed

+214
-25
lines changed

5 files changed

+214
-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: 136 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.
@@ -58,8 +61,52 @@ class AuthToGithubTestCase(unittest.TestCase):
5861
5962
"""
6063

64+
TEST_GH_APP_PRIVATE_KEY = """-----BEGIN RSA PRIVATE KEY-----
65+
MIICXAIBAAKBgHmZAJRIm4BBokmjJqYvowHNTnr1LvET//lvmTbGrDXjqSiMgkPx
66+
rR5ncet/HBEQvmXEqm1XJR4/3OxU07xuPii7doy3IzcMsKTzEcEms18EjS6r2uUW
67+
r2p0eBTgWaJlTw78ZWmQAoC3/+EdharqzC6ka71Uh/RGN/DIRj9pPlnzAgMBAAEC
68+
gYBbK77lb3M4PP3jXHK0E++SgE8hngguNaKtcWFdUqT4WtQQVRmuu9vjeghOXCW9
69+
HeLEBysJhFeOUK/ies+u7rGnZ1HWalinYqG4o2wilyTLAKkNKsr+mOYIWWkb276G
70+
kWQ0LT0fqx5UpsC6mTG9R1aOinrl1Ye9M1qzWqT2UNswkQJBANV7G4UDHPu8f/18
71+
bFqcrCrd3PVDwqzy7PgCtzj2U85QBW8OIaUVSa3A7Vvu3zDdXnxEjf0bwMAmaDbg
72+
RkYsBB0CQQCR0PvJUbNfwGopGrksXoqPedYLHRT+O8/F3huZbxxeHIbx5OEIb14r
73+
p4w/ejECjFPNtAEw0fSisRXslDM6N1lPAkEA00q6jSbsq9gBEgHxOKny2aanyHUd
74+
nJH/quT9NbrQbdXT2vwwnrT4LKpUA3bknverSfGMW2T5nPUmlpHZ3CA6nQJAXXfB
75+
Pc3CFhmHsytvERLU7J0jZ+JPZ5u9Vk9GD7caTvUoRvv4h2ijy4XNr47KxaBfw5aj
76+
wMCEeJjcvdZNc/6wnwJBALaWefIgHbp9Lu8r4f5BKr0cKbAW5PgAqrO8dX+gdssq
77+
vGWZ8qHnIzKR8gqcmBE8penf+vd/jgZPislARsEc+n8=
78+
-----END RSA PRIVATE KEY-----
79+
"""
80+
81+
@patch.dict(
82+
os.environ, {
83+
"GH_APP_ID": "12345",
84+
"GH_APP_PRIVATE_KEY": TEST_GH_APP_PRIVATE_KEY,
85+
"GH_APP_INSTALLATION_ID": "67890",
86+
"GH_ENTERPRISE_URL": "",
87+
"GH_TOKEN": ""
88+
}
89+
)
90+
def test_auth_to_github_app_with_github_app_installation_env_vars(self):
91+
"""
92+
Test authentication with both app id, app private key, and installation id.
93+
94+
This test verifies that when GH_APP_ID, GH_APP_PRIVATE_KEY, and GH_INSTALLATION_ID
95+
environment variables are set, the auth_to_github() function returns a connection
96+
object of type github3.github.GitHub.
97+
98+
"""
99+
connection = auth_to_github()
100+
self.assertIsInstance(connection, github3.github.GitHub)
101+
61102
@patch.dict(
62-
os.environ, {"GH_ENTERPRISE_URL": "https://example.com", "GH_TOKEN": "abc123"}
103+
os.environ, {
104+
"GH_APP_ID": "",
105+
"GH_APP_PRIVATE_KEY": "",
106+
"GH_APP_INSTALLATION_ID": "",
107+
"GH_ENTERPRISE_URL": "https://example.com",
108+
"GH_TOKEN": "abc123"
109+
}
63110
)
64111
def test_auth_to_github_with_enterprise_url_and_token(self):
65112
"""
@@ -73,7 +120,15 @@ def test_auth_to_github_with_enterprise_url_and_token(self):
73120
connection = auth_to_github()
74121
self.assertIsInstance(connection, github3.github.GitHubEnterprise)
75122

76-
@patch.dict(os.environ, {"GH_TOKEN": "abc123"})
123+
@patch.dict(
124+
os.environ, {
125+
"GH_APP_ID": "",
126+
"GH_APP_PRIVATE_KEY": "",
127+
"GH_APP_INSTALLATION_ID": "",
128+
"GH_ENTERPRISE_URL": "",
129+
"GH_TOKEN": "abc123"
130+
}
131+
)
77132
def test_auth_to_github_with_token(self):
78133
"""
79134
Test authentication with only a token.
@@ -85,7 +140,15 @@ def test_auth_to_github_with_token(self):
85140
connection = auth_to_github()
86141
self.assertIsInstance(connection, github3.github.GitHub)
87142

88-
@patch.dict(os.environ, {"GH_ENTERPRISE_URL": "", "GH_TOKEN": ""})
143+
@patch.dict(
144+
os.environ, {
145+
"GH_APP_ID": "",
146+
"GH_APP_PRIVATE_KEY": "",
147+
"GH_APP_INSTALLATION_ID": "",
148+
"GH_ENTERPRISE_URL": "",
149+
"GH_TOKEN": ""
150+
}
151+
)
89152
def test_auth_to_github_without_environment_variables(self):
90153
"""
91154
Test authentication with missing environment variables.
@@ -94,11 +157,21 @@ def test_auth_to_github_without_environment_variables(self):
94157
variables are empty, the auth_to_github() function raises a ValueError.
95158
96159
"""
97-
with self.assertRaises(ValueError):
160+
with self.assertRaises(ValueError) as cm:
98161
auth_to_github()
162+
the_exception = cm.exception
163+
self.assertEqual(str(the_exception), 'GH_TOKEN environment variable not set')
99164

100-
@patch("github3.login")
101-
def test_auth_to_github_without_enterprise_url(self, mock_login):
165+
@patch.dict(
166+
os.environ, {
167+
"GH_APP_ID": "",
168+
"GH_APP_PRIVATE_KEY": "",
169+
"GH_APP_INSTALLATION_ID": "",
170+
"GH_ENTERPRISE_URL": "",
171+
"GH_TOKEN": "abc123"
172+
}
173+
)
174+
def test_auth_to_github_without_enterprise_url(self):
102175
"""
103176
Test authentication without an enterprise URL.
104177
@@ -107,10 +180,8 @@ def test_auth_to_github_without_enterprise_url(self, mock_login):
107180
a connection object of type github3.github.GitHub.
108181
109182
"""
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()
183+
connection = auth_to_github()
184+
self.assertIsInstance(connection, github3.github.GitHub)
114185

115186
@patch("github3.login")
116187
def test_auth_to_github_authentication_failure(self, mock_login):
@@ -123,9 +194,62 @@ def test_auth_to_github_authentication_failure(self, mock_login):
123194
124195
"""
125196
mock_login.return_value = None
126-
with patch.dict(os.environ, {"GH_ENTERPRISE_URL": "", "GH_TOKEN": "abc123"}):
127-
with self.assertRaises(ValueError):
197+
with patch.dict(
198+
os.environ, {
199+
"GH_APP_ID": "",
200+
"GH_APP_PRIVATE_KEY": "",
201+
"GH_APP_INSTALLATION_ID": "",
202+
"GH_ENTERPRISE_URL": "",
203+
"GH_TOKEN": "abc123"
204+
}
205+
):
206+
with self.assertRaises(ValueError) as cm:
128207
auth_to_github()
208+
the_exception = cm.exception
209+
self.assertEqual(str(the_exception), 'Unable to authenticate to GitHub')
210+
211+
212+
class TestGetIntFromEnv(unittest.TestCase):
213+
"""
214+
Test suite for the get_int_from_env function.
215+
216+
...
217+
218+
Test methods:
219+
- test_get_int_env_var: Test returns the expected integer value.
220+
- test_get_int_env_var_with_empty_env_var: Test returns None when environment variable
221+
is empty.
222+
- test_get_int_env_var_with_non_integer: Test returns None when environment variable
223+
is a non-integer.
224+
"""
225+
226+
@patch.dict(os.environ, {"INT_ENV_VAR": "12345"})
227+
def test_get_int_env_var(self):
228+
"""
229+
Test that get_int_env_var returns the expected integer value.
230+
"""
231+
result = get_int_env_var("INT_ENV_VAR")
232+
self.assertEqual(result, 12345)
233+
234+
@patch.dict(os.environ, {"INT_ENV_VAR": ""})
235+
def test_get_int_env_var_with_empty_env_var(self):
236+
"""
237+
This test verifies that the get_int_env_var function returns None
238+
when the environment variable is empty.
239+
240+
"""
241+
result = get_int_env_var("INT_ENV_VAR")
242+
self.assertIsNone(result)
243+
244+
@patch.dict(os.environ, {"INT_ENV_VAR": "not_an_int"})
245+
def test_get_int_env_var_with_non_integer(self):
246+
"""
247+
Test that get_int_env_var returns None when the environment variable is
248+
a non-integer.
249+
250+
"""
251+
result = get_int_env_var("INT_ENV_VAR")
252+
self.assertIsNone(result)
129253

130254

131255
class GetInactiveReposTestCase(unittest.TestCase):

0 commit comments

Comments
 (0)