Skip to content

Commit 1bd6d38

Browse files
author
shiro
authored
feat: Ability to configure repo Collaborators (teams + users) (#232)
* Create collaborators.py * Create collaborator.py * Update settings.yml * Update main.py * Update main.py * Update main.py * Update main.py * Update __init__.py * Update collaborators.py * Update main.py * Update collaborator.py * fix: Merge issue from source * chore: Linting * chore: Linting ruff-format * fix: Expose github api client (top level) * fix: Not sure why empty_list was created... * fix: Remove default value for user * chore: Linting by ruff * chore: Linting * fix: Use pygithub (#13) * chore: Testing pygithub methods * fix: Use pygithub methods * chore: Linting * Update branch_protections.py * Update branch_protections.py * Update branch_protections.py * chore: Update diffs logging * fix: Debugging issues with token permissions * fix: Bad Code * fix: Oauth checks for certain settings * fix: Add info if oauth scope exists but still unable to access settings * chore: Linting * chore: Linting * fix: Include error message on failure to get branch protections
1 parent d7fd61b commit 1bd6d38

File tree

8 files changed

+391
-33
lines changed

8 files changed

+391
-33
lines changed

examples/settings.yml

+14
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,20 @@
66
# You can run Action from each repo, acting on that repo's settings.yml, or
77
# from a central repo, using a single settings.yml to control many repos.
88

9+
# For users, it is the login id
10+
# For teams, it is the slug id
11+
# permission can be 'push','pull','triage','admin','maintain', or any custom role you have defined
12+
# for either users or teams, set exists to false to remove their permissions
13+
collaborators:
14+
# - name: andrewthetechie
15+
# type: user
16+
# permission: admin
17+
# exists: false
18+
# - name: <org>/<team>
19+
# type: team
20+
# permission: admin
21+
# exists: false
22+
923
# Which method you choose is up to you. See README.md for more info and example
1024
# Workflows to implement these strategies.
1125
settings:

repo_manager/gh/branch_protections.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from copy import deepcopy
22
from typing import Any
33

4+
from actions_toolkit import core as actions_toolkit
5+
46
from github.Consts import mediaTypeRequireMultipleApprovingReviews
57
from github.GithubException import GithubException
68
from github.GithubObject import NotSet
@@ -309,7 +311,12 @@ def check_repo_branch_protections(
309311
diff_protections[config_bp.name] = ["Branch is not protected"]
310312
continue
311313

312-
this_protection = repo_bp.get_protection()
314+
try:
315+
this_protection = repo_bp.get_protection()
316+
except Exception as exc:
317+
actions_toolkit.info(f"Repo {repo.full_name} does not currently have any branch protections defined?")
318+
actions_toolkit.info(f"error: {exc}")
319+
continue
313320
if config_bp.protection.pr_options is not None:
314321
diffs.append(
315322
diff_option(

repo_manager/gh/collaborators.py

+199
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
from typing import Any
2+
from actions_toolkit import core as actions_toolkit
3+
4+
from github.Repository import Repository
5+
6+
from repo_manager.utils import get_organization
7+
from repo_manager.schemas.collaborator import Collaborator
8+
9+
10+
def diff_option(key: str, expected: Any, repo_value: Any) -> str | None:
11+
if expected is not None:
12+
if expected != repo_value:
13+
return f"{key} -- Expected: {expected} Found: {repo_value}"
14+
return None
15+
16+
17+
def check_collaborators(
18+
repo: Repository, collaborators: list[Collaborator]
19+
) -> tuple[bool, dict[str, list[str] | dict[str, Any]]]:
20+
"""Checks a repo's environments vs our expected settings
21+
22+
Args:
23+
repo (Repository): [description]
24+
environments (List[Environment]): [description]
25+
26+
Returns:
27+
Tuple[bool, Optional[List[str]]]: [description]
28+
"""
29+
30+
diff = {}
31+
32+
expected_collab_usernames = {
33+
collaborator.name
34+
for collaborator in filter(
35+
lambda collaborator: collaborator.type == "User" and collaborator.exists,
36+
collaborators,
37+
)
38+
}
39+
expected_collab_teamnames = {
40+
collaborator.name
41+
for collaborator in filter(
42+
lambda collaborator: collaborator.type == "Team" and collaborator.exists,
43+
collaborators,
44+
)
45+
}
46+
repo_collab_users = repo.get_collaborators()
47+
repo_collab_teams = repo.get_teams() # __get_teams__(repo)
48+
repo_collab_usernames = {collaborator.login for collaborator in repo_collab_users}
49+
repo_collab_teamnames = {collaborator.slug for collaborator in repo_collab_teams}
50+
51+
missing_users = list(expected_collab_usernames - repo_collab_usernames)
52+
missing_teams = list(expected_collab_teamnames - repo_collab_teamnames)
53+
if len(missing_users) + len(missing_teams) > 0:
54+
diff["missing"] = {}
55+
if len(missing_users) > 0:
56+
diff["missing"]["Users"] = missing_users
57+
if len(missing_teams) > 0:
58+
diff["missing"]["Teams"] = missing_teams
59+
60+
extra_users = list(
61+
repo_collab_usernames.intersection(
62+
collaborator.name
63+
for collaborator in filter(
64+
lambda collaborator: collaborator.type == "User" and not collaborator.exists,
65+
collaborators,
66+
)
67+
)
68+
)
69+
extra_teams = list(
70+
repo_collab_teamnames.intersection(
71+
collaborator.name
72+
for collaborator in filter(
73+
lambda collaborator: collaborator.type == "Team" and not collaborator.exists,
74+
collaborators,
75+
)
76+
)
77+
)
78+
if len(extra_users) + len(extra_teams) > 0:
79+
diff["extra"] = {}
80+
if len(extra_users) > 0:
81+
diff["extra"]["Users"] = extra_users
82+
if len(extra_teams) > 0:
83+
diff["extra"]["Teams"] = extra_teams
84+
85+
collaborators_to_check_values_on = {}
86+
collaborators_to_check_values_on["Users"] = list(expected_collab_usernames.intersection(repo_collab_usernames))
87+
collaborators_to_check_values_on["Teams"] = list(expected_collab_teamnames.intersection(repo_collab_teamnames))
88+
config_collaborator_dict = {collaborator.name: collaborator for collaborator in collaborators}
89+
repo_collab_dict = {"Users": {}, "Teams": {}}
90+
repo_collab_dict["Users"] = {collaborator.login: collaborator for collaborator in repo_collab_users}
91+
repo_collab_dict["Teams"] = {collaborator.slug: collaborator for collaborator in repo_collab_teams}
92+
perm_diffs = {"Users": {}, "Teams": {}}
93+
for collaborator_type in collaborators_to_check_values_on.keys():
94+
for collaborator_name in collaborators_to_check_values_on[collaborator_type]:
95+
if collaborator_type == "Users":
96+
repo_value = getattr(
97+
repo_collab_dict[collaborator_type][collaborator_name].permissions,
98+
config_collaborator_dict[collaborator_name].permission,
99+
None,
100+
)
101+
else:
102+
repo_value = (
103+
getattr(
104+
repo_collab_dict[collaborator_type][collaborator_name],
105+
"permission",
106+
None,
107+
)
108+
== config_collaborator_dict[collaborator_name].permission
109+
)
110+
if repo_value is not True:
111+
perm_diffs[collaborator_type][collaborator_name] = diff_option(
112+
config_collaborator_dict[collaborator_name].permission,
113+
True,
114+
repo_value,
115+
)
116+
117+
if len(perm_diffs["Users"]) == 0:
118+
perm_diffs.pop("Users")
119+
120+
if len(perm_diffs["Teams"]) == 0:
121+
perm_diffs.pop("Teams")
122+
123+
if len(perm_diffs) > 0:
124+
diff["diff"] = perm_diffs
125+
126+
if len(diff) > 0:
127+
return False, diff
128+
129+
return True, None
130+
131+
132+
def update_collaborators(repo: Repository, collaborators: list[Collaborator], diffs: dict[str, Any]) -> set[str]:
133+
"""Updates a repo's environments to match the expected settings
134+
135+
Args:
136+
repo (Repository): [description]
137+
environments (List[environment]): [description]
138+
diffs (Dictionary[string, Any]): List of all the summarized differences by environment name
139+
140+
Returns:
141+
set[str]: [description]
142+
"""
143+
errors = []
144+
users_dict = {
145+
collaborator.name: collaborator
146+
for collaborator in filter(
147+
lambda collaborator: collaborator.type == "User" and collaborator.name,
148+
collaborators,
149+
)
150+
}
151+
teams_dict = {
152+
collaborator.name: collaborator
153+
for collaborator in filter(
154+
lambda collaborator: collaborator.type == "Team" and collaborator.name,
155+
collaborators,
156+
)
157+
}
158+
159+
def switch(collaborator: Collaborator, diff_type: str) -> None:
160+
if diff_type == "missing":
161+
if collaborator.type == "User":
162+
repo.add_to_collaborators(collaborator.name, collaborator.permission)
163+
elif collaborator.type == "Team":
164+
get_organization().get_team_by_slug(collaborator.name).update_team_repository(
165+
repo, collaborator.permission
166+
)
167+
actions_toolkit.info(f"Added collaborator {collaborator.name} with permission {collaborator.permission}.")
168+
elif diff_type == "extra":
169+
if collaborator.type == "User":
170+
repo.remove_from_collaborators(collaborator.name)
171+
elif collaborator.type == "Team":
172+
get_organization().get_team_by_slug(collaborator.name).remove_from_repos(repo)
173+
else:
174+
raise Exception(f"Modifying collaborators of type {collaborator.type} not currently supported")
175+
actions_toolkit.info(f"Removed collaborator {collaborator.name}.")
176+
elif diff_type == "diff":
177+
if collaborator.type == "User":
178+
repo.add_to_collaborators(collaborator.name, collaborator.permission)
179+
elif collaborator.type == "Team":
180+
get_organization().get_team_by_slug(collaborator.name).update_team_repository(
181+
repo, collaborator.permission
182+
)
183+
else:
184+
raise Exception(f"Modifying collaborators of type {collaborator.type} not currently supported")
185+
actions_toolkit.info(f"Updated collaborator {collaborator.name} with permission {collaborator.permission}.")
186+
else:
187+
errors.append(f"Collaborator {collaborator} not found in expected collaborators")
188+
189+
for diff_type in diffs.keys():
190+
for collaborator_type in diffs[diff_type]:
191+
for collaborator in diffs[diff_type][collaborator_type]:
192+
if collaborator_type == "Users":
193+
switch(users_dict[collaborator], diff_type)
194+
elif collaborator_type == "Teams":
195+
switch(teams_dict[collaborator], diff_type)
196+
else:
197+
raise Exception(f"Modifying collaborators of type {collaborator_type} not currently supported")
198+
199+
return errors

repo_manager/gh/settings.py

+15-6
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from typing import Any
22

3+
from actions_toolkit import core as actions_toolkit
4+
35
from github.Repository import Repository
46

57
from repo_manager.schemas.settings import Settings
@@ -66,14 +68,21 @@ def get_repo_value(setting_name: str, repo: Repository) -> Any | None:
6668
for setting_name in settings.dict().keys():
6769
repo_value = get_repo_value(setting_name, repo)
6870
settings_value = getattr(settings, setting_name)
69-
# These don't seem to update if changed; may need to explore a different API call
70-
if (setting_name == "enable_automated_security_fixes") | (setting_name == "enable_vulnerability_alerts"):
71-
continue
71+
if setting_name in [
72+
"allow_squash_merge",
73+
"allow_merge_commit",
74+
"allow_rebase_merge",
75+
"delete_branch_on_merge",
76+
"enable_automated_security_fixes",
77+
"enable_vulnerability_alerts",
78+
]:
79+
if repo._requester.oauth_scopes is None:
80+
continue
81+
elif repo_value is None:
82+
actions_toolkit.info(f"Unable to access {setting_name} with OAUTH of {repo._requester.oauth_scopes}")
7283
# We don't want to flag description being different if the YAML is None
73-
if (setting_name == "description") & (not settings_value):
84+
if settings_value is None:
7485
continue
75-
elif (setting_name == "topics") & (settings_value is None):
76-
settings_value = []
7786
if repo_value != settings_value:
7887
drift.append(f"{setting_name} -- Expected: '{settings_value}' Found: '{repo_value}'")
7988
checked &= False if (settings_value is not None) else True

repo_manager/main.py

+27-1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
from repo_manager.gh.secrets import check_repo_secrets
1515
from repo_manager.gh.secrets import create_secret
1616
from repo_manager.gh.secrets import delete_secret
17+
from repo_manager.gh.collaborators import check_collaborators
18+
from repo_manager.gh.collaborators import update_collaborators
1719
from repo_manager.gh.settings import check_repo_settings
1820
from repo_manager.gh.settings import update_settings
1921
from repo_manager.schemas import load_config
@@ -57,6 +59,7 @@ def main(): # noqa: C901
5759
"branch_protections",
5860
config.branch_protections,
5961
),
62+
check_collaborators: ("collaborators", config.collaborators),
6063
}.items():
6164
check_name, to_check = to_check
6265
if to_check is not None:
@@ -65,18 +68,41 @@ def main(): # noqa: C901
6568
if this_diffs is not None:
6669
diffs[check_name] = this_diffs
6770

68-
actions_toolkit.debug(json_diff := json.dumps({}))
71+
actions_toolkit.debug(json_diff := json.dumps(diffs))
6972
actions_toolkit.set_output("diff", json_diff)
7073

7174
if inputs["action"] == "check":
7275
if not check_result:
76+
actions_toolkit.info(inputs["repo_object"].full_name)
77+
actions_toolkit.info(json.dumps(diffs))
7378
actions_toolkit.set_output("result", "Check failed, diff detected")
7479
actions_toolkit.set_failed("Diff detected")
7580
actions_toolkit.set_output("result", "Check passed")
7681
sys.exit(0)
7782

7883
if inputs["action"] == "apply":
7984
errors = []
85+
for update, to_update in {
86+
# TODO: Implement these functions to reduce length and complexity of code
87+
# update_settings: ("settings", config.settings),
88+
# update_secrets: ("secrets", config.secrets),
89+
# check_repo_labels: ("labels", config.labels),
90+
# check_repo_branch_protections: (
91+
# "branch_protections",
92+
# config.branch_protections,
93+
# ),
94+
update_collaborators: ("collaborators", config.collaborators, diffs.get("collaborators", None)),
95+
}.items():
96+
update_name, to_update, categorical_diffs = to_update
97+
if categorical_diffs is not None:
98+
try:
99+
application_errors = update(inputs["repo_object"], to_update, categorical_diffs)
100+
if len(application_errors) > 0:
101+
errors.append(application_errors)
102+
else:
103+
actions_toolkit.info(f"Synced {update_name}")
104+
except Exception as exc:
105+
errors.append({"type": f"{update_name}-update", "error": f"{exc}"})
80106

81107
# Because we cannot diff secrets, just apply it every time
82108
if config.secrets is not None:

repo_manager/schemas/__init__.py

+17-12
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,23 @@
11
import yaml
2-
from pydantic import BaseModel # pylint: disable=E0611
2+
from pydantic import BaseModel, Field # pylint: disable=E0611
33

44
from .branch_protection import BranchProtection
55
from .file import FileConfig
66
from .label import Label
77
from .secret import Secret
88
from .settings import Settings
9-
from pydantic import Field
10-
from copy import copy
11-
12-
13-
def empty_list():
14-
this_list = list()
15-
return copy(this_list)
9+
from .collaborator import Collaborator
1610

1711

1812
class RepoManagerConfig(BaseModel):
1913
settings: Settings | None
20-
branch_protections: list[BranchProtection] = Field(default_factory=empty_list)
21-
secrets: list[Secret] = Field(default_factory=empty_list)
22-
labels: list[Label] = Field(default_factory=empty_list)
23-
files: list[FileConfig] = Field(default_factory=empty_list)
14+
branch_protections: list[BranchProtection] | None = Field(
15+
None, description="Branch protections in the repo to manage"
16+
)
17+
secrets: list[Secret] | None = Field(None, description="Secrets in the repo to manage")
18+
labels: list[Label] | None = Field(None, description="Labels in the repo to manage")
19+
files: list[FileConfig] | None = Field(None, description="Files in the repo to manage")
20+
collaborators: list[Collaborator] | None = Field(None, description="Collaborators in the repo to manage")
2421

2522
@property
2623
def secrets_dict(self):
@@ -38,6 +35,14 @@ def branch_protections_dict(self):
3835
else {}
3936
)
4037

38+
@property
39+
def collaborators_dict(self):
40+
return (
41+
{collaborator.name: collaborator for collaborator in self.collaborators}
42+
if self.collaborators is not None
43+
else {}
44+
)
45+
4146

4247
def load_config(filename: str) -> RepoManagerConfig:
4348
"""Loads a yaml file into a RepoManagerconfig"""

0 commit comments

Comments
 (0)