Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .env-example
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
GH_APP_ID=' '
GH_APP_INSTALLATION_ID=' '
GH_APP_PRIVATE_KEY=' '
GH_ENTERPRISE_URL=' '
GH_TOKEN=' '
INACTIVE_DAYS=365
ORGANIZATION=' '
GH_ENTERPRISE_URL=' '
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@ clean:
lint:
pylint --rcfile=.pylintrc --fail-under=9.0 *.py
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
flake8 . --count --select=E9,F63,F7,F82 --exclude .venv --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
flake8 . --count --exclude .venv --exit-zero --max-complexity=10 --max-line-length=127 --statistics
51 changes: 42 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,18 @@ Note: Your GitHub token will need to have read access to all the repositories in

Below are the allowed configuration options:

| field | required | default | description |
|-----------------------|----------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `GH_TOKEN` | true | | The GitHub Token used to scan repositories. Must have read access to all repositories you are interested in scanning |
| `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 |
| `INACTIVE_DAYS` | true | | The number of days used to determine if repository is stale, based on `push` events |
| `EXEMPT_TOPICS` | false | | Comma separated list of topics to exempt from being flagged as stale |
| `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-*"` |
| `GH_ENTERPRISE_URL` | false | `""` | URL of GitHub Enterprise instance to use for auth instead of github.com |
| `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 ) |
| field | required | default | description |
|---------------------------|----------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `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 ) |
| `GH_APP_ID` | false | `""` | GitHub Application ID. |
| `GH_APP_INSTALLATION_ID` | false | `""` | GitHub Application Installation ID. |
| `GH_APP_PRIVATE_KEY` | false | `""` | GitHub Application Private Key |
| `GH_ENTERPRISE_URL` | false | `""` | URL of GitHub Enterprise instance to use for auth instead of github.com |
| `GH_TOKEN` | true | | The GitHub Token used to scan repositories. Must have read access to all repositories you are interested in scanning |
| `INACTIVE_DAYS` | true | | The number of days used to determine if repository is stale, based on `push` events |
| `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-*"` |
| `EXEMPT_TOPICS` | false | | Comma separated list of topics to exempt from being flagged as stale |
| `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 |

### Example workflow

Expand Down Expand Up @@ -171,6 +174,36 @@ jobs:
INACTIVE_DAYS: 365
```

### 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
on:
- workflow_dispatch

name: Run the report

jobs:
build:
name: stale repo identifier
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3

- name: Run stale_repos tool
uses: github/stale-repos@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: ${{ secrets.ORGANIZATION }}
EXEMPT_TOPICS: "keep,template"
INACTIVE_DAYS: 365
ACTIVITY_METHOD: "pushed"
```

## Local usage without Docker

1. Have Python v3.9 or greater installed
Expand Down
30 changes: 29 additions & 1 deletion stale_repos.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,11 +250,39 @@ def output_to_json(inactive_repos, file=None):
return inactive_repos_json


def get_int_env_var(env_var_name):
"""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 auth_to_github():
"""Connect to GitHub.com or GitHub Enterprise, depending on env variables."""
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")
ghe = os.getenv("GH_ENTERPRISE_URL", default="").strip()
token = os.getenv("GH_TOKEN")
if ghe and token:

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)
elif token:
github_connection = github3.login(token=os.getenv("GH_TOKEN"))
Expand Down
139 changes: 127 additions & 12 deletions test_stale_repos.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
auth_to_github,
get_active_date,
get_inactive_repos,
get_int_env_var,
is_repo_exempt,
output_to_json,
write_to_markdown,
Expand All @@ -47,6 +48,8 @@ class AuthToGithubTestCase(unittest.TestCase):
and authentication failures.

Test methods:
- test_auth_to_github_app_with_github_app_installation_env_vars: Tests authencation
to GitHub application with app ID, app private key, and app installation ID.
- test_auth_to_github_with_enterprise_url_and_token: Tests authentication with both
enterprise URL and token.
- test_auth_to_github_with_token: Tests authentication with only a token.
Expand All @@ -59,7 +62,38 @@ class AuthToGithubTestCase(unittest.TestCase):
"""

@patch.dict(
os.environ, {"GH_ENTERPRISE_URL": "https://example.com", "GH_TOKEN": "abc123"}
os.environ,
{
"GH_APP_ID": "12345",
"GH_APP_PRIVATE_KEY": "FakePrivateKey",
"GH_APP_INSTALLATION_ID": "67890",
"GH_ENTERPRISE_URL": "",
"GH_TOKEN": "",
},
)
@patch("github3.github.GitHub.login_as_app_installation")
def test_auth_to_github_app_with_github_app_installation_env_vars(self, mock_login):
"""
Test authentication with both app id, app private key, and installation id.

This test verifies that when GH_APP_ID, GH_APP_PRIVATE_KEY, and GH_INSTALLATION_ID
environment variables are set, the auth_to_github() function returns a connection
object of type github3.github.GitHub.

"""
mock_login.return_value = MagicMock()
connection = auth_to_github()
self.assertIsInstance(connection, github3.github.GitHub)

@patch.dict(
os.environ,
{
"GH_APP_ID": "",
"GH_APP_PRIVATE_KEY": "",
"GH_APP_INSTALLATION_ID": "",
"GH_ENTERPRISE_URL": "https://example.com",
"GH_TOKEN": "abc123",
},
)
def test_auth_to_github_with_enterprise_url_and_token(self):
"""
Expand All @@ -73,7 +107,16 @@ def test_auth_to_github_with_enterprise_url_and_token(self):
connection = auth_to_github()
self.assertIsInstance(connection, github3.github.GitHubEnterprise)

@patch.dict(os.environ, {"GH_TOKEN": "abc123"})
@patch.dict(
os.environ,
{
"GH_APP_ID": "",
"GH_APP_PRIVATE_KEY": "",
"GH_APP_INSTALLATION_ID": "",
"GH_ENTERPRISE_URL": "",
"GH_TOKEN": "abc123",
},
)
def test_auth_to_github_with_token(self):
"""
Test authentication with only a token.
Expand All @@ -85,7 +128,16 @@ def test_auth_to_github_with_token(self):
connection = auth_to_github()
self.assertIsInstance(connection, github3.github.GitHub)

@patch.dict(os.environ, {"GH_ENTERPRISE_URL": "", "GH_TOKEN": ""})
@patch.dict(
os.environ,
{
"GH_APP_ID": "",
"GH_APP_PRIVATE_KEY": "",
"GH_APP_INSTALLATION_ID": "",
"GH_ENTERPRISE_URL": "",
"GH_TOKEN": "",
},
)
def test_auth_to_github_without_environment_variables(self):
"""
Test authentication with missing environment variables.
Expand All @@ -94,11 +146,22 @@ def test_auth_to_github_without_environment_variables(self):
variables are empty, the auth_to_github() function raises a ValueError.

"""
with self.assertRaises(ValueError):
with self.assertRaises(ValueError) as cm:
auth_to_github()
the_exception = cm.exception
self.assertEqual(str(the_exception), "GH_TOKEN environment variable not set")

@patch("github3.login")
def test_auth_to_github_without_enterprise_url(self, mock_login):
@patch.dict(
os.environ,
{
"GH_APP_ID": "",
"GH_APP_PRIVATE_KEY": "",
"GH_APP_INSTALLATION_ID": "",
"GH_ENTERPRISE_URL": "",
"GH_TOKEN": "abc123",
},
)
def test_auth_to_github_without_enterprise_url(self):
"""
Test authentication without an enterprise URL.

Expand All @@ -107,10 +170,8 @@ def test_auth_to_github_without_enterprise_url(self, mock_login):
a connection object of type github3.github.GitHub.

"""
mock_login.return_value = None
with patch.dict(os.environ, {"GH_ENTERPRISE_URL": "", "GH_TOKEN": "abc123"}):
with self.assertRaises(ValueError):
auth_to_github()
connection = auth_to_github()
self.assertIsInstance(connection, github3.github.GitHub)

@patch("github3.login")
def test_auth_to_github_authentication_failure(self, mock_login):
Expand All @@ -123,9 +184,63 @@ def test_auth_to_github_authentication_failure(self, mock_login):

"""
mock_login.return_value = None
with patch.dict(os.environ, {"GH_ENTERPRISE_URL": "", "GH_TOKEN": "abc123"}):
with self.assertRaises(ValueError):
with patch.dict(
os.environ,
{
"GH_APP_ID": "",
"GH_APP_PRIVATE_KEY": "",
"GH_APP_INSTALLATION_ID": "",
"GH_ENTERPRISE_URL": "",
"GH_TOKEN": "abc123",
},
):
with self.assertRaises(ValueError) as cm:
auth_to_github()
the_exception = cm.exception
self.assertEqual(str(the_exception), "Unable to authenticate to GitHub")


class TestGetIntFromEnv(unittest.TestCase):
"""
Test suite for the get_int_from_env function.

...

Test methods:
- test_get_int_env_var: Test returns the expected integer value.
- test_get_int_env_var_with_empty_env_var: Test returns None when environment variable
is empty.
- test_get_int_env_var_with_non_integer: Test returns None when environment variable
is a non-integer.
"""

@patch.dict(os.environ, {"INT_ENV_VAR": "12345"})
def test_get_int_env_var(self):
"""
Test that get_int_env_var returns the expected integer value.
"""
result = get_int_env_var("INT_ENV_VAR")
self.assertEqual(result, 12345)

@patch.dict(os.environ, {"INT_ENV_VAR": ""})
def test_get_int_env_var_with_empty_env_var(self):
"""
This test verifies that the get_int_env_var function returns None
when the environment variable is empty.

"""
result = get_int_env_var("INT_ENV_VAR")
self.assertIsNone(result)

@patch.dict(os.environ, {"INT_ENV_VAR": "not_an_int"})
def test_get_int_env_var_with_non_integer(self):
"""
Test that get_int_env_var returns None when the environment variable is
a non-integer.

"""
result = get_int_env_var("INT_ENV_VAR")
self.assertIsNone(result)


class GetInactiveReposTestCase(unittest.TestCase):
Expand Down