Skip to content

Commit 884a0eb

Browse files
authored
Merge pull request #325 from vchrombie/feat/suggest-codeowners-pr-23
feat: propose CODEOWNERS file when missing
2 parents 1f6eba0 + 0748f3e commit 884a0eb

File tree

5 files changed

+156
-51
lines changed

5 files changed

+156
-51
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
Cleanowners is a GitHub Action that is designed to help keep `CODEOWNERS` files current by removing users that are no longer a part of the organization. This is helpful for companies that are looking to remove outdated information in the `CODEOWNERS` file. This action can be paired with other `CODEOWNERS` related actions to suggest new owners or lint `CODEOWNERS` files to ensure accuracy.
66

7+
If a repository is missing a `CODEOWNERS` file (or it is empty), the action will open a pull request that adds a placeholder `CODEOWNERS` file for maintainers to update.
8+
79
This action was developed by the GitHub OSPO for our own use and developed in a way that we could open source it that it might be useful to you as well! If you want to know more about how we use it, reach out in an issue in this repository.
810

911
## Support

cleanowners.py

Lines changed: 74 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -84,11 +84,42 @@ def main(): # pragma: no cover
8484
# Check to see if repository has a CODEOWNERS file
8585
file_changed = False
8686
codeowners_file_contents, codeowners_filepath = get_codeowners_file(repo)
87+
has_codeowners = codeowners_file_contents is not None
88+
codeowners_size = (
89+
getattr(codeowners_file_contents, "size", None) if has_codeowners else None
90+
)
91+
is_empty_codeowners = has_codeowners and codeowners_size == 0
8792

88-
if not codeowners_file_contents:
89-
print(f"Skipping {repo.full_name} as it does not have a CODEOWNERS file")
93+
if not has_codeowners or is_empty_codeowners:
94+
repo_name = repo.full_name
9095
no_codeowners_count += 1
91-
repos_missing_codeowners.append(repo)
96+
repos_missing_codeowners.append(repo_name)
97+
98+
if not has_codeowners:
99+
print(f"{repo_name} does not have a CODEOWNERS file")
100+
else:
101+
print(f"{repo_name} has an empty CODEOWNERS file")
102+
103+
if dry_run:
104+
continue
105+
106+
suggested_codeowners = build_default_codeowners(repo)
107+
target_path = codeowners_filepath or ".github/CODEOWNERS"
108+
eligble_for_pr_count += 1
109+
try:
110+
pull = commit_changes(
111+
title,
112+
body,
113+
repo,
114+
suggested_codeowners,
115+
commit_message,
116+
target_path,
117+
create_new=not has_codeowners,
118+
)
119+
pull_count += 1
120+
print(f"\tCreated pull request {pull.html_url}")
121+
except github3.exceptions.NotFoundError:
122+
print("\tFailed to create pull request. Check write permissions.")
92123
continue
93124

94125
codeowners_count += 1
@@ -179,35 +210,14 @@ def get_codeowners_file(repo):
179210
the file contents and file path or None if it doesn't exist
180211
"""
181212
codeowners_file_contents = None
182-
codeowners_filepath = None
183-
try:
184-
if (
185-
repo.file_contents(".github/CODEOWNERS")
186-
and repo.file_contents(".github/CODEOWNERS").size > 0
187-
):
188-
codeowners_file_contents = repo.file_contents(".github/CODEOWNERS")
189-
codeowners_filepath = ".github/CODEOWNERS"
190-
except github3.exceptions.NotFoundError:
191-
pass
192-
try:
193-
if (
194-
repo.file_contents("CODEOWNERS")
195-
and repo.file_contents("CODEOWNERS").size > 0
196-
):
197-
codeowners_file_contents = repo.file_contents("CODEOWNERS")
198-
codeowners_filepath = "CODEOWNERS"
199-
except github3.exceptions.NotFoundError:
200-
pass
201-
try:
202-
if (
203-
repo.file_contents("docs/CODEOWNERS")
204-
and repo.file_contents("docs/CODEOWNERS").size > 0
205-
):
206-
codeowners_file_contents = repo.file_contents("docs/CODEOWNERS")
207-
codeowners_filepath = "docs/CODEOWNERS"
208-
except github3.exceptions.NotFoundError:
209-
pass
210-
return codeowners_file_contents, codeowners_filepath
213+
for path in (".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"):
214+
try:
215+
codeowners_file_contents = repo.file_contents(path)
216+
if codeowners_file_contents:
217+
return codeowners_file_contents, path
218+
except github3.exceptions.NotFoundError:
219+
continue
220+
return None, None
211221

212222

213223
def print_stats(
@@ -216,7 +226,7 @@ def print_stats(
216226
"""Print the statistics from this run to the terminal output"""
217227
print(f"Found {users_count} users to remove")
218228
print(f"Created {pull_count} pull requests successfully")
219-
print(f"Skipped {no_codeowners_count} repositories without a CODEOWNERS file")
229+
print(f"Found {no_codeowners_count} repositories missing or empty CODEOWNERS files")
220230
print(f"Processed {codeowners_count} repositories with a CODEOWNERS file")
221231
if eligble_for_pr_count == 0:
222232
print("No pull requests were needed")
@@ -273,13 +283,31 @@ def get_usernames_from_codeowners(codeowners_file_contents, ignore_teams=True):
273283
return usernames
274284

275285

286+
def build_default_codeowners(repo):
287+
"""Build a placeholder CODEOWNERS file for repositories without one."""
288+
owner_login = repo.owner.login
289+
owner_type = getattr(repo.owner, "type", "")
290+
if owner_type == "Organization":
291+
owner_handle = f"{owner_login}/REPLACE_WITH_TEAM"
292+
else:
293+
owner_handle = owner_login
294+
295+
contents = (
296+
"# CODEOWNERS\n"
297+
"# Replace the placeholder with the appropriate owner(s) for this repository.\n"
298+
f"* @{owner_handle}\n"
299+
)
300+
return contents.encode("ASCII")
301+
302+
276303
def commit_changes(
277304
title,
278305
body,
279306
repo,
280307
codeowners_file_contents_new,
281308
commit_message,
282309
codeowners_filepath,
310+
create_new=False,
283311
):
284312
"""Commit the changes to the repo and open a pull request and return the pull request object"""
285313
default_branch = repo.default_branch
@@ -288,11 +316,19 @@ def commit_changes(
288316
front_matter = "refs/heads/"
289317
branch_name = f"codeowners-{str(uuid.uuid4())}"
290318
repo.create_ref(front_matter + branch_name, default_branch_commit)
291-
repo.file_contents(codeowners_filepath).update(
292-
message=commit_message,
293-
content=codeowners_file_contents_new,
294-
branch=branch_name,
295-
)
319+
if create_new:
320+
repo.create_file(
321+
codeowners_filepath,
322+
commit_message,
323+
codeowners_file_contents_new,
324+
branch=branch_name,
325+
)
326+
else:
327+
repo.file_contents(codeowners_filepath).update(
328+
message=commit_message,
329+
content=codeowners_file_contents_new,
330+
branch=branch_name,
331+
)
296332

297333
pull = repo.create_pull(
298334
title=title, body=body, head=branch_name, base=repo.default_branch

markdown_writer.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ def write_to_markdown(
1616
"## Overall Stats\n"
1717
f"{users_count} Users to Remove\n"
1818
f"{pull_count} Pull Requests created\n"
19-
f"{no_codeowners_count} Repositories with no CODEOWNERS file\n"
19+
f"{no_codeowners_count} Repositories missing or empty CODEOWNERS files\n"
2020
f"{codeowners_count} Repositories with CODEOWNERS file\n"
2121
)
2222
if repo_and_users_to_remove:
@@ -27,7 +27,7 @@ def write_to_markdown(
2727
file.write(f"- {user}\n")
2828
file.write("\n")
2929
if repos_missing_codeowners:
30-
file.write("## Repositories Missing CODEOWNERS\n")
30+
file.write("## Repositories Missing or Empty CODEOWNERS\n")
3131
for repo in repos_missing_codeowners:
3232
file.write(f"- {repo}\n")
3333
file.write("\n")

test_cleanowners.py

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import github3
99
from cleanowners import (
10+
build_default_codeowners,
1011
commit_changes,
1112
get_codeowners_file,
1213
get_org,
@@ -62,6 +63,40 @@ def test_commit_changes(self, mock_uuid):
6263
# Assert that the function returned the expected result
6364
self.assertEqual(result, "MockPullRequest")
6465

66+
@patch("uuid.uuid4")
67+
def test_commit_changes_create_new_file(self, mock_uuid):
68+
"""Test the commit_changes function when creating a new file."""
69+
mock_uuid.return_value = uuid.UUID("12345678123456781234567812345678")
70+
mock_repo = MagicMock()
71+
mock_repo.default_branch = "main"
72+
mock_repo.ref.return_value.object.sha = "abc123"
73+
mock_repo.create_ref.return_value = True
74+
mock_repo.create_file.return_value = True
75+
mock_repo.create_pull.return_value = "MockPullRequest"
76+
77+
result = commit_changes(
78+
"Test Title",
79+
"Test Body",
80+
mock_repo,
81+
b"new content",
82+
"Test commit message",
83+
"CODEOWNERS",
84+
create_new=True,
85+
)
86+
87+
branch_name = "codeowners-12345678-1234-5678-1234-567812345678"
88+
mock_repo.create_ref.assert_called_once_with(
89+
f"refs/heads/{branch_name}", "abc123"
90+
)
91+
mock_repo.create_file.assert_called_once_with(
92+
"CODEOWNERS",
93+
"Test commit message",
94+
b"new content",
95+
branch=branch_name,
96+
)
97+
mock_repo.file_contents.assert_not_called()
98+
self.assertEqual(result, "MockPullRequest")
99+
65100

66101
class TestGetUsernamesFromCodeowners(unittest.TestCase):
67102
"""Test the get_usernames_from_codeowners function in cleanowners.py"""
@@ -197,7 +232,7 @@ def test_print_stats_all_counts(self, mock_stdout):
197232
expected_output = (
198233
"Found 4 users to remove\n"
199234
"Created 5 pull requests successfully\n"
200-
"Skipped 2 repositories without a CODEOWNERS file\n"
235+
"Found 2 repositories missing or empty CODEOWNERS files\n"
201236
"Processed 3 repositories with a CODEOWNERS file\n"
202237
"50.0% of eligible repositories had pull requests created\n"
203238
"60.0% of repositories had CODEOWNERS files\n"
@@ -211,7 +246,7 @@ def test_print_stats_no_pull_requests_needed(self, mock_stdout):
211246
expected_output = (
212247
"Found 4 users to remove\n"
213248
"Created 0 pull requests successfully\n"
214-
"Skipped 2 repositories without a CODEOWNERS file\n"
249+
"Found 2 repositories missing or empty CODEOWNERS files\n"
215250
"Processed 3 repositories with a CODEOWNERS file\n"
216251
"No pull requests were needed\n"
217252
"60.0% of repositories had CODEOWNERS files\n"
@@ -225,7 +260,7 @@ def test_print_stats_no_repositories_processed(self, mock_stdout):
225260
expected_output = (
226261
"Found 0 users to remove\n"
227262
"Created 0 pull requests successfully\n"
228-
"Skipped 0 repositories without a CODEOWNERS file\n"
263+
"Found 0 repositories missing or empty CODEOWNERS files\n"
229264
"Processed 0 repositories with a CODEOWNERS file\n"
230265
"No pull requests were needed\n"
231266
"No repositories were processed\n"
@@ -274,8 +309,40 @@ def test_codeowners_not_found(self):
274309
self.assertIsNone(path)
275310

276311
def test_codeowners_empty_file(self):
277-
"""Test that an empty CODEOWNERS file is not considered valid because it is empty."""
312+
"""Test that an empty CODEOWNERS file is returned for further handling."""
278313
self.repo.file_contents.side_effect = lambda path: MagicMock(size=0)
279314
contents, path = get_codeowners_file(self.repo)
280-
self.assertIsNone(contents)
281-
self.assertIsNone(path)
315+
self.assertIsNotNone(contents)
316+
self.assertEqual(path, ".github/CODEOWNERS")
317+
318+
def test_codeowners_not_found_then_found(self):
319+
"""Test that a later path is used when earlier ones are not found."""
320+
not_found = github3.exceptions.NotFoundError(resp=MagicMock(status_code=404))
321+
self.repo.file_contents.side_effect = [not_found, MagicMock(size=1)]
322+
contents, path = get_codeowners_file(self.repo)
323+
self.assertIsNotNone(contents)
324+
self.assertEqual(path, "CODEOWNERS")
325+
326+
327+
class TestBuildDefaultCodeowners(unittest.TestCase):
328+
"""Test the build_default_codeowners function in cleanowners.py"""
329+
330+
def test_build_default_codeowners_for_org(self):
331+
"""Test placeholder uses org team handle."""
332+
repo = MagicMock()
333+
repo.owner.login = "my-org"
334+
repo.owner.type = "Organization"
335+
336+
result = build_default_codeowners(repo)
337+
338+
self.assertIn(b"@my-org/REPLACE_WITH_TEAM", result)
339+
340+
def test_build_default_codeowners_for_user(self):
341+
"""Test placeholder uses user handle."""
342+
repo = MagicMock()
343+
repo.owner.login = "my-user"
344+
repo.owner.type = "User"
345+
346+
result = build_default_codeowners(repo)
347+
348+
self.assertIn(b"@my-user", result)

test_markdown_writer.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ def test_write_with_all_counts_and_no_users_to_remove(self):
1919
"## Overall Stats\n"
2020
"0 Users to Remove\n"
2121
"0 Pull Requests created\n"
22-
"2 Repositories with no CODEOWNERS file\n"
22+
"2 Repositories missing or empty CODEOWNERS files\n"
2323
"3 Repositories with CODEOWNERS file\n"
2424
)
2525

@@ -35,7 +35,7 @@ def test_write_with_repos_and_users_with_users_to_remove(self):
3535
"## Overall Stats\n"
3636
"1 Users to Remove\n"
3737
"2 Pull Requests created\n"
38-
"3 Repositories with no CODEOWNERS file\n"
38+
"3 Repositories missing or empty CODEOWNERS files\n"
3939
"4 Repositories with CODEOWNERS file\n"
4040
),
4141
call("## Repositories and Users to Remove\n"),
@@ -61,10 +61,10 @@ def test_write_with_repos_missing_codeowners(self):
6161
"## Overall Stats\n"
6262
"0 Users to Remove\n"
6363
"0 Pull Requests created\n"
64-
"2 Repositories with no CODEOWNERS file\n"
64+
"2 Repositories missing or empty CODEOWNERS files\n"
6565
"0 Repositories with CODEOWNERS file\n"
6666
),
67-
call("## Repositories Missing CODEOWNERS\n"),
67+
call("## Repositories Missing or Empty CODEOWNERS\n"),
6868
call("- repo1\n"),
6969
call("- repo2\n"),
7070
call("\n"),
@@ -81,7 +81,7 @@ def test_write_with_empty_inputs(self):
8181
"## Overall Stats\n"
8282
"0 Users to Remove\n"
8383
"0 Pull Requests created\n"
84-
"0 Repositories with no CODEOWNERS file\n"
84+
"0 Repositories missing or empty CODEOWNERS files\n"
8585
"0 Repositories with CODEOWNERS file\n"
8686
)
8787

0 commit comments

Comments
 (0)