Skip to content

Commit ae6b7a5

Browse files
add team_permissions to repository
1 parent 45025b3 commit ae6b7a5

10 files changed

Lines changed: 338 additions & 3 deletions

File tree

otterdog/models/github_organization.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -672,6 +672,7 @@ async def _process_single_repo(
672672
repo_name: str,
673673
jsonnet_config: JsonnetConfig,
674674
teams: dict[str, Any],
675+
repo_permissions: dict[str, list[dict[str, Any]]],
675676
app_installations: dict[str, str],
676677
) -> tuple[str, Repository]:
677678
rest_api = gh_client.rest_api
@@ -682,6 +683,8 @@ async def _process_single_repo(
682683

683684
github_repo_workflow_data = await rest_api.repo.get_workflow_settings(github_id, repo_name)
684685
repo.workflows = RepositoryWorkflowSettings.from_provider_data(github_id, github_repo_workflow_data)
686+
repo_permission = repo_permissions.get(repo_name, [])
687+
repo.set_team_permissions({entry["name"]: entry["permission"] for entry in repo_permission})
685688

686689
if jsonnet_config.default_branch_protection_rule_config is not None:
687690
# get branch protection rules of the repo
@@ -759,6 +762,30 @@ async def _process_single_repo(
759762
return repo_name, repo
760763

761764

765+
def build_repo_permissions(teams: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
766+
"""
767+
Convert the output from the graphql call, which is team-centric, to a repository
768+
centric structure.
769+
"""
770+
771+
repo_permissions: dict[str, list[dict[str, Any]]] = {}
772+
for team in teams:
773+
team_slug = team["slug"]
774+
775+
# List of repo edges of this team
776+
edges = team.get("repositories", {}).get("edges", [])
777+
778+
for edge in edges:
779+
repo_name = edge["node"]["name"]
780+
permission = edge["permission"]
781+
782+
if repo_name not in repo_permissions:
783+
repo_permissions[repo_name] = []
784+
785+
repo_permissions[repo_name].append({"name": team_slug, "permission": permission})
786+
return repo_permissions
787+
788+
762789
async def _load_repos_from_provider(
763790
github_id: str,
764791
org_settings: OrganizationSettings,
@@ -776,6 +803,7 @@ async def _load_repos_from_provider(
776803
repo_names = fnmatch.filter(repo_names, repo_filter)
777804

778805
teams = {str(team["id"]): f"{github_id}/{team['slug']}" for team in await provider.get_org_teams(github_id)}
806+
repo_permissions = build_repo_permissions(await provider.get_team_permissions(github_id))
779807

780808
# limit the number of repos that are processed concurrently to avoid hitting secondary rate limits
781809
sem = asyncio.Semaphore(50 if concurrency is None else concurrency)
@@ -789,6 +817,7 @@ async def safe_process(repo_name):
789817
repo_name,
790818
jsonnet_config,
791819
teams,
820+
repo_permissions,
792821
app_installations,
793822
)
794823

otterdog/models/repository.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ class Repository(ModelObject):
107107
fork_default_branch_only: bool = dataclasses.field(metadata={"model_only": True})
108108

109109
workflows: RepositoryWorkflowSettings = dataclasses.field(metadata={"embedded_model": True})
110+
team_permissions: dict[str, str] | None
110111

111112
# model only fields
112113
aliases: list[str] = dataclasses.field(metadata={"model_only": True}, default_factory=list)
@@ -190,6 +191,19 @@ class Repository(ModelObject):
190191
"rust",
191192
}
192193

194+
_valid_team_permissions: ClassVar[dict[str, str]] = {
195+
"pull": "pull",
196+
"triage": "triage",
197+
"push": "push",
198+
"maintain": "maintain",
199+
"admin": "admin",
200+
"READ": "pull",
201+
"TRIAGE": "triage",
202+
"WRITE": "push",
203+
"MAINTAIN": "maintain",
204+
"ADMIN": "admin",
205+
}
206+
193207
@property
194208
def model_object_name(self) -> str:
195209
return "repository"
@@ -245,6 +259,11 @@ def add_environment(self, environment: Environment) -> None:
245259
def set_environments(self, environments: list[Environment]) -> None:
246260
self.environments = environments
247261

262+
def set_team_permissions(self, permissions: dict[str, str]) -> None:
263+
self.team_permissions = {
264+
team: self._valid_team_permissions.get(perm, perm) for team, perm in permissions.items()
265+
}
266+
248267
def coerce_from_org_settings(self, org_settings: OrganizationSettings, for_patch: bool = False) -> Repository:
249268
copy = dataclasses.replace(self)
250269

@@ -454,6 +473,16 @@ def validate(self, context: ValidationContext, parent_object: Any) -> None:
454473
f"{self.get_model_header()} defines an unknown custom property with key '{k}'.",
455474
)
456475

476+
if is_set_and_present(self.team_permissions):
477+
for k, v in self.team_permissions.items():
478+
if v not in self._valid_team_permissions:
479+
context.add_failure(
480+
FailureType.ERROR,
481+
f"invalid permission '{v}' "
482+
f"for team '{k}', allowed values are "
483+
f"('read/pull' | 'triage' | 'write/push' | 'maintain' | 'admin').",
484+
)
485+
457486
for webhook in self.webhooks:
458487
webhook.validate(context, self)
459488

@@ -854,9 +883,13 @@ def property_list_to_map(properties):
854883

855884
return output
856885

886+
def transform_perm(permissions):
887+
return {team: cls._valid_team_permissions[perm] for team, perm in permissions}
888+
857889
mapping.update(
858890
{
859891
"custom_properties": OptionalS("custom_properties", default={}) >> F(property_list_to_map),
892+
"team_permissions": OptionalS("team_permissions", default={}) >> F(transform_perm),
860893
"webhooks": K([]),
861894
"secrets": K([]),
862895
"variables": K([]),
@@ -1318,6 +1351,102 @@ def _include_gh_pages_patch_required_properties(
13181351

13191352
return patch
13201353

1354+
@classmethod
1355+
def _calculate_team_permissions(
1356+
cls, patch: LivePatch[Repository]
1357+
) -> tuple[list[str], dict[str, str], dict[str, str]]:
1358+
"""
1359+
Computes the differences between the current and desired team permissions
1360+
for a repository based on the provided patch object.
1361+
1362+
The patch contains two dictionaries for team_permissions:
1363+
- 'from_perms': the current team-permission mapping in the system
1364+
- 'to_perms': the desired target mapping
1365+
1366+
By comparing these two states, the function determines three categories
1367+
of changes that must be applied:
1368+
1369+
Returns:
1370+
deletes (list[str]):
1371+
A list of team names that exist in the current state but not in
1372+
the target state. These teams must have their permissions removed.
1373+
1374+
updates (dict[str, str]):
1375+
A mapping of team names to new permissions for teams that exist
1376+
in both states but whose permission value has changed.
1377+
1378+
adds (dict[str, str]):
1379+
A mapping of team names to permissions for teams that appear only
1380+
in the target state and therefore must be newly assigned.
1381+
"""
1382+
1383+
changes = patch.changes
1384+
# No changes exist, so there is no "from" state.
1385+
# All permissions in expected_object.team_permissions must be added.
1386+
if changes is None:
1387+
expected = getattr(patch.expected_object, "team_permissions", None)
1388+
if isinstance(expected, dict):
1389+
return [], {}, dict(expected) # only adds
1390+
return [], {}, {}
1391+
1392+
# Normal diff-based update
1393+
tp_change = changes.get("team_permissions")
1394+
if tp_change is None:
1395+
return [], {}, {}
1396+
1397+
from_value = tp_change.from_value
1398+
to_value = tp_change.to_value
1399+
1400+
if not isinstance(from_value, dict) or not isinstance(to_value, dict):
1401+
return [], {}, {}
1402+
1403+
# Now mypy knows these are dictionaries
1404+
from_perms: dict[str, str] = from_value
1405+
to_perms: dict[str, str] = to_value
1406+
1407+
deletes: list[str] = []
1408+
updates: dict[str, str] = {}
1409+
adds: dict[str, str] = {}
1410+
1411+
# Keys only in "from": delete
1412+
for team in from_perms.keys() - to_perms.keys():
1413+
deletes.append(team)
1414+
1415+
# Keys in both: update if permission changed
1416+
for team in from_perms.keys() & to_perms.keys():
1417+
if from_perms[team] != to_perms[team]:
1418+
updates[team] = to_perms[team]
1419+
1420+
# Keys only in "to": add
1421+
for team in to_perms.keys() - from_perms.keys():
1422+
adds[team] = to_perms[team]
1423+
1424+
return deletes, updates, adds
1425+
1426+
@classmethod
1427+
async def _apply_team_permission_changes(
1428+
cls,
1429+
provider,
1430+
org_id: str,
1431+
repo_name: str,
1432+
patch: LivePatch[Repository],
1433+
) -> None:
1434+
"""
1435+
Applies the calculated team-permission changes (delete, update, add) to the
1436+
given repository.
1437+
"""
1438+
1439+
deletes, updates, adds = cls._calculate_team_permissions(patch)
1440+
1441+
for team in deletes:
1442+
await provider.delete_team_permission(org_id, repo_name, team)
1443+
1444+
for team, perm in updates.items():
1445+
await provider.update_team_permission(org_id, repo_name, team, perm)
1446+
1447+
for team, perm in adds.items():
1448+
await provider.add_team_permission(org_id, repo_name, team, perm)
1449+
13211450
@classmethod
13221451
async def apply_live_patch(
13231452
cls,
@@ -1346,6 +1475,15 @@ async def apply_live_patch(
13461475
await expected_object.workflows.dict_to_provider_data(org_id, workflow_data, provider),
13471476
)
13481477

1478+
# Team permissions are defined on the repository but originate from the team side.
1479+
# A newly created repository always starts without any team permissions, even if
1480+
# the desired state already specifies them. After creation we therefore reconcile
1481+
# the permissions explicitly: remove teams that should not have access, update
1482+
# teams whose permission differs, and add teams that should be granted access.
1483+
# All three cases are kept to maintain a consistent reconciliation flow and to
1484+
# safely handle any unexpected initial state.
1485+
await cls._apply_team_permission_changes(provider, org_id, expected_object.name, patch)
1486+
13491487
case LivePatchType.REMOVE:
13501488
await provider.delete_repo(org_id, unwrap(patch.current_object).name)
13511489

@@ -1369,3 +1507,11 @@ async def apply_live_patch(
13691507
github_data = await RepositoryWorkflowSettings.dict_to_provider_data(org_id, data, provider)
13701508

13711509
await provider.update_repo_workflow_settings(org_id, expected_object.name, github_data)
1510+
1511+
# Team permissions sit at the intersection of repositories and teams.
1512+
# A change in `team_permissions` can represent three different operations:
1513+
# - removing a team's permission (delete)
1514+
# - modifying an existing permission (update)
1515+
# - assigning a new permission (add)
1516+
# The patch is therefore decomposed into these three categories and applied here.
1517+
await cls._apply_team_permission_changes(provider, org_id, expected_object.name, patch)

otterdog/providers/github/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,18 @@ async def add_repo_variable(self, org_id: str, repo_name: str, data: dict[str, s
422422
async def delete_repo_variable(self, org_id: str, repo_name: str, variable_name: str) -> None:
423423
await self.rest_api.repo.delete_variable(org_id, repo_name, variable_name)
424424

425+
async def get_team_permissions(self, org_id: str) -> list[dict[str, Any]]:
426+
return await self.graphql_client.get_team_permissions(org_id)
427+
428+
async def update_team_permission(self, org_id: str, repo_name: str, team_name: str, team_permission: str) -> None:
429+
await self.rest_api.repo.update_team_permission(org_id, repo_name, team_name, team_permission)
430+
431+
async def add_team_permission(self, org_id: str, repo_name: str, team_name: str, team_permission: str) -> None:
432+
await self.rest_api.repo.add_team_permission(org_id, repo_name, team_name, team_permission)
433+
434+
async def delete_team_permission(self, org_id: str, repo_name: str, team_name: str) -> None:
435+
await self.rest_api.repo.delete_team_permission(org_id, repo_name, team_name)
436+
425437
async def dispatch_workflow(self, org_id: str, repo_name: str, workflow_name: str) -> bool:
426438
return await self.rest_api.repo.dispatch_workflow(org_id, repo_name, workflow_name)
427439

otterdog/providers/github/graphql.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,11 +255,46 @@ async def get_team_membership(self, org_id: str, user_login: str) -> list[dict[s
255255
variables = {"owner": org_id, "user": user_login}
256256
return await self._run_paged_query(variables, "get-team-membership.gql", "data.organization.teams")
257257

258+
async def get_team_permissions(self, org_id: str) -> list[dict[str, Any]]:
259+
_logger.debug(f"retrieving team permissions in org '{org_id}'")
260+
261+
variables = {"org": org_id}
262+
# Run the graphql query with a limit of 100 for teams and repositories. If there are more than
263+
# 100 teams available this gets handled in _run_paged_query. If there are more than 100
264+
# permissions to repositories available then these are handled in the subsequent loop, where the
265+
# pageInfo is used to get the missing repository entries.
266+
teams = await self._run_paged_query(
267+
input_variables=variables,
268+
query_file="get-team-permissions-repositories.gql",
269+
prefix_selector="data.organization.teams",
270+
)
271+
for team in teams:
272+
repos = team["repositories"]["edges"]
273+
page_info = team["repositories"]["pageInfo"]
274+
if not page_info["hasNextPage"]:
275+
continue
276+
repo_cursor = page_info["endCursor"]
277+
sub_vars = {
278+
"org": org_id,
279+
"teamSlug": team["slug"],
280+
"endCursor": repo_cursor,
281+
}
282+
sub_result = await self._run_paged_query(
283+
input_variables=sub_vars,
284+
query_file="get-repository-permissions-of-team.gql",
285+
prefix_selector="data.organization.team.repositories",
286+
selector_type=".edges",
287+
)
288+
repos.extend(sub_result)
289+
290+
return teams
291+
258292
async def _run_paged_query(
259293
self,
260294
input_variables: dict[str, Any],
261295
query_file: str,
262296
prefix_selector: str = "data.repository.branchProtectionRules",
297+
selector_type: str = ".nodes",
263298
) -> list[dict[str, Any]]:
264299
_logger.debug(f"running graphql query '{query_file}' with input '{json.dumps(input_variables)}'")
265300

@@ -280,7 +315,7 @@ async def _run_paged_query(
280315
_logger.trace("graphql result = %s", json.dumps(json_data, indent=2))
281316

282317
if status < 400 and "data" in json_data:
283-
rules_result = query_json(prefix_selector + ".nodes", json_data)
318+
rules_result = query_json(prefix_selector + selector_type, json_data)
284319

285320
for rule in rules_result:
286321
result.append(rule)

otterdog/providers/github/rest/repo_client.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -741,6 +741,43 @@ async def delete_environment(self, org_id: str, repo_name: str, env_name: str) -
741741

742742
_logger.debug("removed repo environment '%s'", env_name)
743743

744+
async def get_team_permissions(self, org_id: str, repo_name: str) -> list[dict[str, Any]]:
745+
_logger.debug("retrieving teams with permissions for repo '%s/%s'", org_id, repo_name)
746+
747+
try:
748+
return await self.requester.request_json("GET", f"/repos/{org_id}/{repo_name}/teams")
749+
except GitHubException as ex:
750+
raise RuntimeError(f"failed getting team permissions for repo '{org_id}/{repo_name}':\n{ex}") from ex
751+
752+
async def update_team_permission(self, org_id: str, repo_name: str, team_name: str, team_permission: str) -> None:
753+
754+
status, _ = await self.requester.request_raw(
755+
"PUT",
756+
f"/orgs/{org_id}/teams/{team_name}/repos/{org_id}/{repo_name}",
757+
data=json.dumps({"permission": team_permission}),
758+
)
759+
760+
if status != 204:
761+
raise RuntimeError(f"failed to update team permission for team {team_name} on repo {repo_name}")
762+
763+
_logger.debug(f"updated team permission for team {team_name} on repo {repo_name}")
764+
765+
async def add_team_permission(self, org_id: str, repo_name: str, team_name: str, team_permission: str) -> None:
766+
_logger.debug(f"adding team permission for team {team_name} on repo {repo_name}")
767+
await self.update_team_permission(org_id, repo_name, team_name, team_permission)
768+
_logger.debug("added team permisson for team {team_name} on repo {repo_name}")
769+
770+
async def delete_team_permission(self, org_id: str, repo_name: str, team_name: str) -> None:
771+
_logger.debug(f"deleting team permission for team {team_name} on repo {repo_name}")
772+
status, _ = await self.requester.request_raw(
773+
"DELETE", f"/orgs/{org_id}/teams/{team_name}/repos/{org_id}/{repo_name}"
774+
)
775+
776+
if status != 204:
777+
raise RuntimeError(f"failed to delete team permission for team {team_name} on repo {repo_name}")
778+
779+
_logger.debug(f"removed team permission for team {team_name} on repo {repo_name}")
780+
744781
async def _get_deployment_branch_policies(self, org_id: str, repo_name: str, env_name: str) -> list[dict[str, Any]]:
745782
_logger.debug("retrieving deployment branch policies for env '%s'", env_name)
746783

0 commit comments

Comments
 (0)