Skip to content

Commit b92e789

Browse files
authored
Merge pull request #1 from github/prs
Add main and pr env vars
2 parents a280910 + fbf02e6 commit b92e789

File tree

7 files changed

+357
-9
lines changed

7 files changed

+357
-9
lines changed

.github/linters/.isort.cfg

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
[isort]
1+
[settings]
22
profile = black

.pylintrc

+2-1
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@ disable=
1212
duplicate-code,
1313
too-many-branches,
1414
too-many-statements,
15-
too-many-locals,
15+
too-many-locals,
16+
wrong-import-order

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ If you need support using this project or have questions about it, please [open
1313
1. Create a repository to host this GitHub Action or select an existing repository.
1414
1. Select a best fit workflow file from the [examples below](#example-workflows).
1515
1. Copy that example into your repository (from step 1) and into the proper directory for GitHub Actions: `.github/workflows/` directory with the file extension `.yml` (ie. `.github/workflows/cleanowners.yml`)
16-
1. Edit the values (`ORGANIZATION`, `EXEMPT_REPOS`) from the sample workflow with your information.
16+
1. Edit the values (`ORGANIZATION`, `EXEMPT_REPOS`) from the sample workflow with your information.
1717
1. Also edit the value for `GH_ENTERPRISE_URL` if you are using a GitHub Server and not using github.com. For github.com users, don't put anything in here.
1818
1. Update the value of `GH_TOKEN`. Do this by creating a [GitHub API token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-personal-access-token-classic) with permissions to read the repository/organization and write issues or pull requests. Then take the value of the API token you just created, and [create a repository secret](https://docs.github.com/en/actions/security-guides/encrypted-secrets) where the name of the secret is `GH_TOKEN` and the value of the secret the API token. It just needs to match between when you create the secret name and when you refer to it in the workflow file.
1919
1. Commit the workflow file to the default branch (often `master` or `main`)

cleanowners.py

+166
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
"""A GitHub Action to suggest removal of non-organization members from CODEOWNERS files."""
2+
3+
import uuid
4+
5+
import auth
6+
import env
7+
import github3
8+
9+
10+
def main(): # pragma: no cover
11+
"""Run the main program"""
12+
13+
# Get the environment variables
14+
(
15+
organization,
16+
repository_list,
17+
token,
18+
ghe,
19+
exempt_repositories_list,
20+
dry_run,
21+
title,
22+
body,
23+
commit_message,
24+
) = env.get_env_vars()
25+
26+
# Auth to GitHub.com or GHE
27+
github_connection = auth.auth_to_github(token, ghe)
28+
pull_count = 0
29+
eligble_for_pr_count = 0
30+
31+
# Get the repositories from the organization or list of repositories
32+
repos = get_repos_iterator(organization, repository_list, github_connection)
33+
34+
for repo in repos:
35+
# Check if the repository is in the exempt_repositories_list
36+
if repo.full_name in exempt_repositories_list:
37+
print(f"Skipping {repo.full_name} as it is in the exempt_repositories_list")
38+
continue
39+
40+
# Check to see if repository is archived
41+
if repo.archived:
42+
print(f"Skipping {repo.full_name} as it is archived")
43+
continue
44+
45+
# Check to see if repository has a CODEOWNERS file
46+
file_changed = False
47+
codeowners_file_contents = None
48+
codeowners_filepath = None
49+
try:
50+
if repo.file_contents(".github/CODEOWNERS").size > 0:
51+
codeowners_file_contents = repo.file_contents(".github/CODEOWNERS")
52+
codeowners_filepath = ".github/CODEOWNERS"
53+
except github3.exceptions.NotFoundError:
54+
pass
55+
try:
56+
if repo.file_contents("CODEOWNERS").size > 0:
57+
codeowners_file_contents = repo.file_contents("CODEOWNERS")
58+
codeowners_filepath = "CODEOWNERS"
59+
except github3.exceptions.NotFoundError:
60+
pass
61+
try:
62+
if repo.file_contents("docs/CODEOWNERS").size > 0:
63+
codeowners_file_contents = repo.file_contents("docs/CODEOWNERS")
64+
codeowners_filepath = "docs/CODEOWNERS"
65+
except github3.exceptions.NotFoundError:
66+
pass
67+
68+
if not codeowners_file_contents:
69+
print(f"Skipping {repo.full_name} as it does not have a CODEOWNERS file")
70+
continue
71+
72+
# Extract the usernames from the CODEOWNERS file
73+
usernames = get_usernames_from_codeowners(codeowners_file_contents)
74+
75+
for username in usernames:
76+
# Check to see if the username is a member of the organization
77+
if not github_connection.organization(organization).has_member(username):
78+
print(
79+
f"\t{username} is not a member of {organization}. Suggest removing them from {repo.full_name}"
80+
)
81+
if not dry_run:
82+
# Remove that username from the codeowners_file_contents
83+
file_changed = True
84+
codeowners_file_contents = codeowners_file_contents.decoded.replace(
85+
f"@{username}", ""
86+
)
87+
88+
# Update the CODEOWNERS file if usernames were removed
89+
if file_changed:
90+
eligble_for_pr_count += 1
91+
try:
92+
pull = commit_changes(
93+
title,
94+
body,
95+
repo,
96+
codeowners_file_contents,
97+
commit_message,
98+
codeowners_filepath,
99+
)
100+
pull_count += 1
101+
print(f"\tCreated pull request {pull.html_url}")
102+
except github3.exceptions.NotFoundError:
103+
print("\tFailed to create pull request. Check write permissions.")
104+
continue
105+
106+
# Report the statistics from this run
107+
print(f"Found {eligble_for_pr_count} users to remove")
108+
print(f"Created {pull_count} pull requests successfully")
109+
110+
111+
def get_repos_iterator(organization, repository_list, github_connection):
112+
"""Get the repositories from the organization or list of repositories"""
113+
repos = []
114+
if organization and not repository_list:
115+
repos = github_connection.organization(organization).repositories()
116+
else:
117+
# Get the repositories from the repository_list
118+
for repo in repository_list:
119+
repos.append(
120+
github_connection.repository(repo.split("/")[0], repo.split("/")[1])
121+
)
122+
123+
return repos
124+
125+
126+
def get_usernames_from_codeowners(codeowners_file_contents):
127+
"""Extract the usernames from the CODEOWNERS file"""
128+
usernames = []
129+
for line in codeowners_file_contents.decoded.splitlines():
130+
# skip comments
131+
if line.startswith("#"):
132+
continue
133+
# skip empty lines
134+
if not line.strip():
135+
continue
136+
# If the line has an @ symbol, grab the word with the @ in it and add it to the list
137+
if "@" in line:
138+
usernames.append(line.split("@")[1].split()[0])
139+
return usernames
140+
141+
142+
def commit_changes(
143+
title, body, repo, codeowners_file_contents, commit_message, codeowners_filepath
144+
):
145+
"""Commit the changes to the repo and open a pull reques and return the pull request object"""
146+
default_branch = repo.default_branch
147+
# Get latest commit sha from default branch
148+
default_branch_commit = repo.ref("heads/" + default_branch).object.sha
149+
front_matter = "refs/heads/"
150+
branch_name = "codeowners-" + str(uuid.uuid4())
151+
repo.create_ref(front_matter + branch_name, default_branch_commit)
152+
repo.create_file(
153+
path=codeowners_filepath,
154+
message=commit_message,
155+
content=codeowners_file_contents.encode(), # Convert to bytes object
156+
branch=branch_name,
157+
)
158+
159+
pull = repo.create_pull(
160+
title=title, body=body, head=branch_name, base=repo.default_branch
161+
)
162+
return pull
163+
164+
165+
if __name__ == "__main__": # pragma: no cover
166+
main()

env.py

+40-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
from dotenv import load_dotenv
99

1010

11-
def get_env_vars() -> tuple[str | None, list[str], str, str, list[str], bool]:
11+
def get_env_vars() -> (
12+
tuple[str | None, list[str], str, str, list[str], bool, str, str, str]
13+
):
1214
"""
1315
Get the environment variables for use in the action.
1416
@@ -22,6 +24,9 @@ def get_env_vars() -> tuple[str | None, list[str], str, str, list[str], bool]:
2224
ghe (str): The GitHub Enterprise URL to use for authentication
2325
exempt_repositories_list (list[str]): A list of repositories to exempt from the action
2426
dry_run (bool): Whether or not to actually open issues/pull requests
27+
title (str): The title to use for the pull request
28+
body (str): The body to use for the pull request
29+
message (str): Commit message to use
2530
2631
"""
2732
# Load from .env file if it exists
@@ -71,11 +76,45 @@ def get_env_vars() -> tuple[str | None, list[str], str, str, list[str], bool]:
7176
else:
7277
dry_run_bool = False
7378

79+
title = os.getenv("TITLE")
80+
# make sure that title is a string with less than 70 characters
81+
if title:
82+
if len(title) > 70:
83+
raise ValueError(
84+
"TITLE environment variable is too long. Max 70 characters"
85+
)
86+
else:
87+
title = "Clean up CODEOWNERS file"
88+
89+
body = os.getenv("BODY")
90+
# make sure that body is a string with less than 65536 characters
91+
if body:
92+
if len(body) > 65536:
93+
raise ValueError(
94+
"BODY environment variable is too long. Max 65536 characters"
95+
)
96+
else:
97+
body = "Consider these updates to the CODEOWNERS file to remove users no longer in this organization."
98+
99+
commit_message = os.getenv("COMMIT_MESSAGE")
100+
if commit_message:
101+
if len(commit_message) > 65536:
102+
raise ValueError(
103+
"COMMIT_MESSAGE environment variable is too long. Max 65536 characters"
104+
)
105+
else:
106+
commit_message = (
107+
"Remove users no longer in this organization from CODEOWNERS file"
108+
)
109+
74110
return (
75111
organization,
76112
repositories_list,
77113
token,
78114
ghe,
79115
exempt_repositories_list,
80116
dry_run_bool,
117+
title,
118+
body,
119+
commit_message,
81120
)

test_cleanowners.py

+132
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
"""Test the functions in the cleanowners module."""
2+
3+
import unittest
4+
import uuid
5+
from unittest.mock import MagicMock, patch
6+
7+
from cleanowners import (
8+
commit_changes,
9+
get_repos_iterator,
10+
get_usernames_from_codeowners,
11+
)
12+
13+
14+
class TestCommitChanges(unittest.TestCase):
15+
"""Test the commit_changes function in cleanowners.py"""
16+
17+
@patch("uuid.uuid4")
18+
def test_commit_changes(self, mock_uuid):
19+
"""Test the commit_changes function."""
20+
mock_uuid.return_value = uuid.UUID(
21+
"12345678123456781234567812345678"
22+
) # Mock UUID generation
23+
mock_repo = MagicMock() # Mock repo object
24+
mock_repo.default_branch = "main"
25+
mock_repo.ref.return_value.object.sha = "abc123" # Mock SHA for latest commit
26+
mock_repo.create_ref.return_value = True
27+
mock_repo.create_file.return_value = True
28+
mock_repo.create_pull.return_value = "MockPullRequest"
29+
30+
title = "Test Title"
31+
body = "Test Body"
32+
dependabot_file = "testing!"
33+
branch_name = "codeowners-12345678-1234-5678-1234-567812345678"
34+
commit_message = "Test commit message"
35+
result = commit_changes(
36+
title,
37+
body,
38+
mock_repo,
39+
dependabot_file,
40+
commit_message,
41+
"CODEOWNERS",
42+
)
43+
44+
# Assert that the methods were called with the correct arguments
45+
mock_repo.create_ref.assert_called_once_with(
46+
f"refs/heads/{branch_name}", "abc123"
47+
)
48+
mock_repo.create_file.assert_called_once_with(
49+
path="CODEOWNERS",
50+
message=commit_message,
51+
content=dependabot_file.encode(),
52+
branch=branch_name,
53+
)
54+
mock_repo.create_pull.assert_called_once_with(
55+
title=title,
56+
body=body,
57+
head=branch_name,
58+
base="main",
59+
)
60+
61+
# Assert that the function returned the expected result
62+
self.assertEqual(result, "MockPullRequest")
63+
64+
65+
class TestGetUsernamesFromCodeowners(unittest.TestCase):
66+
"""Test the get_usernames_from_codeowners function in cleanowners.py"""
67+
68+
def test_get_usernames_from_codeowners(self):
69+
"""Test the get_usernames_from_codeowners function."""
70+
codeowners_file_contents = MagicMock()
71+
codeowners_file_contents.decoded = """
72+
# Comment
73+
@user1
74+
@user2
75+
# Another comment
76+
@user3
77+
"""
78+
expected_usernames = ["user1", "user2", "user3"]
79+
80+
result = get_usernames_from_codeowners(codeowners_file_contents)
81+
82+
self.assertEqual(result, expected_usernames)
83+
84+
85+
class TestGetReposIterator(unittest.TestCase):
86+
"""Test the get_repos_iterator function in evergreen.py"""
87+
88+
@patch("github3.login")
89+
def test_get_repos_iterator_with_organization(self, mock_github):
90+
"""Test the get_repos_iterator function with an organization"""
91+
organization = "my_organization"
92+
repository_list = []
93+
github_connection = mock_github.return_value
94+
95+
mock_organization = MagicMock()
96+
mock_repositories = MagicMock()
97+
mock_organization.repositories.return_value = mock_repositories
98+
github_connection.organization.return_value = mock_organization
99+
100+
result = get_repos_iterator(organization, repository_list, github_connection)
101+
102+
# Assert that the organization method was called with the correct argument
103+
github_connection.organization.assert_called_once_with(organization)
104+
105+
# Assert that the repositories method was called on the organization object
106+
mock_organization.repositories.assert_called_once()
107+
108+
# Assert that the function returned the expected result
109+
self.assertEqual(result, mock_repositories)
110+
111+
@patch("github3.login")
112+
def test_get_repos_iterator_with_repository_list(self, mock_github):
113+
"""Test the get_repos_iterator function with a repository list"""
114+
organization = None
115+
repository_list = ["org/repo1", "org/repo2"]
116+
github_connection = mock_github.return_value
117+
118+
mock_repository = MagicMock()
119+
mock_repository_list = [mock_repository, mock_repository]
120+
github_connection.repository.side_effect = mock_repository_list
121+
122+
result = get_repos_iterator(organization, repository_list, github_connection)
123+
124+
# Assert that the repository method was called with the correct arguments for each repository in the list
125+
expected_calls = [
126+
unittest.mock.call("org", "repo1"),
127+
unittest.mock.call("org", "repo2"),
128+
]
129+
github_connection.repository.assert_has_calls(expected_calls)
130+
131+
# Assert that the function returned the expected result
132+
self.assertEqual(result, mock_repository_list)

0 commit comments

Comments
 (0)