Skip to content

Commit c8770cb

Browse files
authored
Merge pull request #90 from github/report-as-issue-markdown
2 parents ab356ad + 0eeb3aa commit c8770cb

8 files changed

+263
-12
lines changed

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ This action can be configured to authenticate with GitHub App Installation or Pe
5656
| `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` |
5757
| `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` |
5858
| `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. |
59+
| `ISSUE_REPORT` | False | False | If set to true, this action will create an issue in the repository with the report on the repositories that had users removed from the `CODEOWNERS` file. |
5960

6061
### Example workflows
6162

@@ -114,6 +115,14 @@ jobs:
114115
GH_TOKEN: ${{ secrets.GH_TOKEN }}
115116
ORGANIZATION: <YOUR_ORGANIZATION_GOES_HERE>
116117
EXEMPT_REPOS: "org_name/repo_name_1, org_name/repo_name_2"
118+
ISSUE_REPORT: true
119+
- name: Create issue
120+
uses: peter-evans/create-issue-from-file@v5
121+
with:
122+
title: Cleanowners Report
123+
content-filepath: ./report.md
124+
assignees: <YOUR_GITHUB_HANDLE_HERE>
125+
token: ${{ secrets.GITHUB_TOKEN }}
117126

118127
```
119128

cleanowners.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import auth
66
import env
77
import github3
8+
from markdown_writer import write_to_markdown
89

910

1011
def get_org(github_connection, organization):
@@ -33,6 +34,7 @@ def main(): # pragma: no cover
3334
title,
3435
body,
3536
commit_message,
37+
issue_report,
3638
) = env.get_env_vars()
3739

3840
# Auth to GitHub.com or GHE
@@ -59,6 +61,7 @@ def main(): # pragma: no cover
5961
# Get the repositories from the organization or list of repositories
6062
repos = get_repos_iterator(organization, repository_list, github_connection)
6163

64+
repo_and_users_to_remove = {}
6265
for repo in repos:
6366
# Check if the repository is in the exempt_repositories_list
6467
if repo.full_name in exempt_repositories_list:
@@ -109,6 +112,8 @@ def main(): # pragma: no cover
109112
# Extract the usernames from the CODEOWNERS file
110113
usernames = get_usernames_from_codeowners(codeowners_file_contents)
111114

115+
usernames_to_remove = []
116+
codeowners_file_contents_new = None
112117
for username in usernames:
113118
org = organization if organization else repo.owner.login
114119
gh_org = get_org(github_connection, org)
@@ -122,6 +127,7 @@ def main(): # pragma: no cover
122127
f"\t{username} is not a member of {org}. Suggest removing them from {repo.full_name}"
123128
)
124129
users_count += 1
130+
usernames_to_remove.append(username)
125131
if not dry_run:
126132
# Remove that username from the codeowners_file_contents
127133
file_changed = True
@@ -130,9 +136,18 @@ def main(): # pragma: no cover
130136
codeowners_file_contents.decoded.replace(bytes_username, b"")
131137
)
132138

139+
# Store the repo and users to remove for reporting later
140+
if usernames_to_remove:
141+
repo_and_users_to_remove[repo] = usernames_to_remove
142+
133143
# Update the CODEOWNERS file if usernames were removed
134144
if file_changed:
135145
eligble_for_pr_count += 1
146+
new_usernames = get_usernames_from_codeowners(codeowners_file_contents_new)
147+
if len(new_usernames) == 0:
148+
print(
149+
f"\twarning: All usernames removed from CODEOWNERS in {repo.full_name}."
150+
)
136151
try:
137152
pull = commit_changes(
138153
title,
@@ -166,6 +181,15 @@ def main(): # pragma: no cover
166181
f"{round((codeowners_count / (codeowners_count + no_codeowners_count)) * 100, 2)}% of repositories had CODEOWNERS files"
167182
)
168183

184+
if issue_report:
185+
write_to_markdown(
186+
users_count,
187+
pull_count,
188+
no_codeowners_count,
189+
codeowners_count,
190+
repo_and_users_to_remove,
191+
)
192+
169193

170194
def get_repos_iterator(organization, repository_list, github_connection):
171195
"""Get the repositories from the organization or list of repositories"""
@@ -182,7 +206,7 @@ def get_repos_iterator(organization, repository_list, github_connection):
182206
return repos
183207

184208

185-
def get_usernames_from_codeowners(codeowners_file_contents):
209+
def get_usernames_from_codeowners(codeowners_file_contents, ignore_teams=True):
186210
"""Extract the usernames from the CODEOWNERS file"""
187211
usernames = []
188212
for line in codeowners_file_contents.decoded.splitlines():
@@ -201,7 +225,9 @@ def get_usernames_from_codeowners(codeowners_file_contents):
201225
handle = handle.split()[0]
202226
# Identify team handles by the presence of a slash.
203227
# Ignore teams because non-org members cannot be in a team.
204-
if "/" not in handle:
228+
if ignore_teams and "/" not in handle:
229+
usernames.append(handle)
230+
elif not ignore_teams:
205231
usernames.append(handle)
206232
return usernames
207233

env.py

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,22 @@
88
from dotenv import load_dotenv
99

1010

11+
def get_bool_env_var(env_var_name: str, default: bool = False) -> bool:
12+
"""Get a boolean environment variable.
13+
14+
Args:
15+
env_var_name: The name of the environment variable to retrieve.
16+
default: The default value to return if the environment variable is not set.
17+
18+
Returns:
19+
The value of the environment variable as a boolean.
20+
"""
21+
ev = os.environ.get(env_var_name, "")
22+
if ev == "" and default:
23+
return default
24+
return ev.strip().lower() == "true"
25+
26+
1127
def get_int_env_var(env_var_name: str) -> int | None:
1228
"""Get an integer environment variable.
1329
@@ -41,6 +57,7 @@ def get_env_vars(
4157
str,
4258
str,
4359
str,
60+
bool,
4461
]:
4562
"""
4663
Get the environment variables for use in the action.
@@ -61,6 +78,7 @@ def get_env_vars(
6178
title (str): The title to use for the pull request
6279
body (str): The body to use for the pull request
6380
message (str): Commit message to use
81+
issue_report (bool): Whether or not to create an issue report with the results
6482
6583
"""
6684
if not test:
@@ -115,14 +133,7 @@ def get_env_vars(
115133
repository.strip() for repository in exempt_repos.split(",")
116134
]
117135

118-
dry_run = os.getenv("DRY_RUN")
119-
dry_run = dry_run.lower() if dry_run else None
120-
if dry_run:
121-
if dry_run not in ("true", "false"):
122-
raise ValueError("DRY_RUN environment variable not 'true' or 'false'")
123-
dry_run_bool = dry_run == "true"
124-
else:
125-
dry_run_bool = False
136+
dry_run = get_bool_env_var("DRY_RUN")
126137

127138
title = os.getenv("TITLE")
128139
# make sure that title is a string with less than 70 characters
@@ -155,6 +166,8 @@ def get_env_vars(
155166
"Remove users no longer in this organization from CODEOWNERS file"
156167
)
157168

169+
issue_report = get_bool_env_var("ISSUE_REPORT")
170+
158171
return (
159172
organization,
160173
repositories_list,
@@ -164,8 +177,9 @@ def get_env_vars(
164177
token,
165178
ghe,
166179
exempt_repositories_list,
167-
dry_run_bool,
180+
dry_run,
168181
title,
169182
body,
170183
commit_message,
184+
issue_report,
171185
)

markdown_writer.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""Write the results to a markdown file"""
2+
3+
4+
def write_to_markdown(
5+
users_count,
6+
pull_count,
7+
no_codeowners_count,
8+
codeowners_count,
9+
repo_and_users_to_remove,
10+
):
11+
"""Write the results to a markdown file"""
12+
with open("report.md", "w", encoding="utf-8") as file:
13+
file.write(
14+
"# Cleanowners Report\n\n"
15+
"## Overall Stats\n"
16+
f"{users_count} Users to Remove\n"
17+
f"{pull_count} Pull Requests created\n"
18+
f"{no_codeowners_count} Repositories with no CODEOWNERS file\n"
19+
f"{codeowners_count} Repositories with CODEOWNERS file\n"
20+
)
21+
if repo_and_users_to_remove:
22+
file.write("## Repositories and Users to Remove\n")
23+
for repo, users in repo_and_users_to_remove.items():
24+
file.write(f"{repo}\n")
25+
for user in users:
26+
file.write(f"- {user}\n")
27+
file.write("\n")

test_cleanowners.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ def test_commit_changes(self, mock_uuid):
6363
class TestGetUsernamesFromCodeowners(unittest.TestCase):
6464
"""Test the get_usernames_from_codeowners function in cleanowners.py"""
6565

66-
def test_get_usernames_from_codeowners(self):
66+
def test_get_usernames_from_codeowners_ignore_teams(self):
6767
"""Test the get_usernames_from_codeowners function."""
6868
codeowners_file_contents = MagicMock()
6969
codeowners_file_contents.decoded = """
@@ -82,6 +82,25 @@ def test_get_usernames_from_codeowners(self):
8282

8383
self.assertEqual(result, expected_usernames)
8484

85+
def test_get_usernames_from_codeowners_with_teams(self):
86+
"""Test the get_usernames_from_codeowners function."""
87+
codeowners_file_contents = MagicMock()
88+
codeowners_file_contents.decoded = """
89+
# Comment
90+
@user1
91+
@user2
92+
@org/team
93+
# Another comment
94+
@user3 @user4
95+
""".encode(
96+
"ASCII"
97+
)
98+
expected_usernames = ["user1", "user2", "org/team", "user3", "user4"]
99+
100+
result = get_usernames_from_codeowners(codeowners_file_contents, False)
101+
102+
self.assertEqual(result, expected_usernames)
103+
85104

86105
class TestGetOrganization(unittest.TestCase):
87106
"""Test the get_org function in cleanowners.py"""

test_env.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ def setUp(self):
3030
"ORGANIZATION",
3131
"REPOSITORY",
3232
"TITLE",
33+
"ISSUE_REPORT",
3334
]
3435
for key in env_keys:
3536
if key in os.environ:
@@ -67,6 +68,7 @@ def test_get_env_vars_with_org(self):
6768
TITLE,
6869
BODY,
6970
COMMIT_MESSAGE,
71+
False,
7072
)
7173
result = get_env_vars(True)
7274
self.assertEqual(result, expected_result)
@@ -104,6 +106,7 @@ def test_get_env_vars_with_github_app_and_repos(self):
104106
TITLE,
105107
BODY,
106108
COMMIT_MESSAGE,
109+
False,
107110
)
108111
result = get_env_vars(True)
109112
self.assertEqual(result, expected_result)
@@ -141,6 +144,7 @@ def test_get_env_vars_with_token_and_repos(self):
141144
TITLE,
142145
BODY,
143146
COMMIT_MESSAGE,
147+
False,
144148
)
145149
result = get_env_vars(True)
146150
self.assertEqual(result, expected_result)
@@ -156,6 +160,7 @@ def test_get_env_vars_with_token_and_repos(self):
156160
"GH_TOKEN": TOKEN,
157161
"ORGANIZATION": ORGANIZATION,
158162
"TITLE": TITLE,
163+
"ISSUE_REPORT": "true",
159164
},
160165
)
161166
def test_get_env_vars_optional_values(self):
@@ -173,6 +178,7 @@ def test_get_env_vars_optional_values(self):
173178
TITLE,
174179
BODY,
175180
COMMIT_MESSAGE,
181+
True,
176182
)
177183
result = get_env_vars(True)
178184
self.assertEqual(result, expected_result)
@@ -221,6 +227,7 @@ def test_get_env_vars_with_repos_no_dry_run(self):
221227
"Clean up CODEOWNERS file",
222228
"Consider these updates to the CODEOWNERS file to remove users no longer in this organization.",
223229
"Remove users no longer in this organization from CODEOWNERS file",
230+
False,
224231
)
225232
result = get_env_vars(True)
226233
self.assertEqual(result, expected_result)

test_env_get_bool.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""Test the get_bool_env_var function"""
2+
3+
import os
4+
import unittest
5+
from unittest.mock import patch
6+
7+
from env import get_bool_env_var
8+
9+
10+
class TestEnv(unittest.TestCase):
11+
"""Test the get_bool_env_var function"""
12+
13+
@patch.dict(
14+
os.environ,
15+
{
16+
"TEST_BOOL": "true",
17+
},
18+
clear=True,
19+
)
20+
def test_get_bool_env_var_that_exists_and_is_true(self):
21+
"""Test that gets a boolean environment variable that exists and is true"""
22+
result = get_bool_env_var("TEST_BOOL", False)
23+
self.assertTrue(result)
24+
25+
@patch.dict(
26+
os.environ,
27+
{
28+
"TEST_BOOL": "false",
29+
},
30+
clear=True,
31+
)
32+
def test_get_bool_env_var_that_exists_and_is_false(self):
33+
"""Test that gets a boolean environment variable that exists and is false"""
34+
result = get_bool_env_var("TEST_BOOL", False)
35+
self.assertFalse(result)
36+
37+
@patch.dict(
38+
os.environ,
39+
{
40+
"TEST_BOOL": "nope",
41+
},
42+
clear=True,
43+
)
44+
def test_get_bool_env_var_that_exists_and_is_false_due_to_invalid_value(self):
45+
"""Test that gets a boolean environment variable that exists and is false
46+
due to an invalid value
47+
"""
48+
result = get_bool_env_var("TEST_BOOL", False)
49+
self.assertFalse(result)
50+
51+
@patch.dict(
52+
os.environ,
53+
{
54+
"TEST_BOOL": "false",
55+
},
56+
clear=True,
57+
)
58+
def test_get_bool_env_var_that_does_not_exist_and_default_value_returns_true(self):
59+
"""Test that gets a boolean environment variable that does not exist
60+
and default value returns: true
61+
"""
62+
result = get_bool_env_var("DOES_NOT_EXIST", True)
63+
self.assertTrue(result)
64+
65+
@patch.dict(
66+
os.environ,
67+
{
68+
"TEST_BOOL": "true",
69+
},
70+
clear=True,
71+
)
72+
def test_get_bool_env_var_that_does_not_exist_and_default_value_returns_false(self):
73+
"""Test that gets a boolean environment variable that does not exist
74+
and default value returns: false
75+
"""
76+
result = get_bool_env_var("DOES_NOT_EXIST", False)
77+
self.assertFalse(result)
78+
79+
80+
if __name__ == "__main__":
81+
unittest.main()

0 commit comments

Comments
 (0)