Skip to content

Commit 536c27b

Browse files
Add team_permissions support to repository (#612)
* add team_permissions to repository * fix: updated changelog, added example for team_permissions, added patch
1 parent 4f2a579 commit 536c27b

12 files changed

Lines changed: 356 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
### Added
66

7+
- Add team_permissions support to repository ([#623](https://github.com/eclipse-csi/otterdog/pull/612))
78
- Add tests for workflow validation settings ([#633](https://github.com/eclipse-csi/otterdog/pull/633))
89
- Add support for `OTTERDOG_CONFIG_ROOT` environment variable ([#632](https://github.com/eclipse-csi/otterdog/pull/632))
910

examples/template/otterdog-defaults.libsonnet

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,10 @@ local newRepo(name) = {
109109
branch_protection_rules: [],
110110

111111
# rulesets
112-
rulesets: []
112+
rulesets: [],
113+
114+
# team permissions
115+
team_permissions: {},
113116
};
114117

115118
# Function to extend an existing repo with the same name.

otterdog/models/github_organization.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
from otterdog.models.repo_workflow_settings import RepositoryWorkflowSettings
4747
from otterdog.models.repository import Repository
4848
from otterdog.models.team import Team
49-
from otterdog.utils import IndentingPrinter, associate_by_key, debug_times, jsonnet_evaluate_file
49+
from otterdog.utils import IndentingPrinter, associate_by_key, debug_times, is_set_and_present, jsonnet_evaluate_file
5050

5151
if TYPE_CHECKING:
5252
from collections.abc import AsyncIterator, Callable, Iterator
@@ -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]]] | None,
675676
app_installations: dict[str, str],
676677
) -> tuple[str, Repository]:
677678
rest_api = gh_client.rest_api
@@ -682,6 +683,11 @@ 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+
if repo_permissions is not None:
687+
repo_permission = repo_permissions.get(repo_name, [])
688+
repo.set_team_permissions({entry["name"]: entry["permission"] for entry in repo_permission})
689+
else:
690+
repo.unset_team_permissions()
685691

686692
if jsonnet_config.default_branch_protection_rule_config is not None:
687693
# get branch protection rules of the repo
@@ -759,6 +765,30 @@ async def _process_single_repo(
759765
return repo_name, repo
760766

761767

768+
def build_repo_permissions(teams: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
769+
"""
770+
Convert the output from the graphql call, which is team-centric, to a repository
771+
centric structure.
772+
"""
773+
774+
repo_permissions: dict[str, list[dict[str, Any]]] = {}
775+
for team in teams:
776+
team_slug = team["slug"]
777+
778+
# List of repo edges of this team
779+
edges = team.get("repositories", {}).get("edges", [])
780+
781+
for edge in edges:
782+
repo_name = edge["node"]["name"]
783+
permission = edge["permission"]
784+
785+
if repo_name not in repo_permissions:
786+
repo_permissions[repo_name] = []
787+
788+
repo_permissions[repo_name].append({"name": team_slug, "permission": permission})
789+
return repo_permissions
790+
791+
762792
async def _load_repos_from_provider(
763793
github_id: str,
764794
org_settings: OrganizationSettings,
@@ -777,6 +807,12 @@ async def _load_repos_from_provider(
777807

778808
teams = {str(team["id"]): f"{github_id}/{team['slug']}" for team in await provider.get_org_teams(github_id)}
779809

810+
default_org_repo = Repository.from_model_data(jsonnet_config.default_repo_config)
811+
if is_set_and_present(default_org_repo.team_permissions):
812+
repo_permissions = build_repo_permissions(await provider.get_team_permissions(github_id))
813+
else:
814+
repo_permissions = None
815+
780816
# limit the number of repos that are processed concurrently to avoid hitting secondary rate limits
781817
sem = asyncio.Semaphore(50 if concurrency is None else concurrency)
782818

@@ -789,6 +825,7 @@ async def safe_process(repo_name):
789825
repo_name,
790826
jsonnet_config,
791827
teams,
828+
repo_permissions,
792829
app_installations,
793830
)
794831

otterdog/models/repository.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
UNSET,
2929
Change,
3030
IndentingPrinter,
31+
_Unset,
3132
associate_by_key,
3233
is_set_and_present,
3334
is_set_and_valid,
@@ -107,6 +108,7 @@ class Repository(ModelObject):
107108
fork_default_branch_only: bool = dataclasses.field(metadata={"model_only": True})
108109

109110
workflows: RepositoryWorkflowSettings = dataclasses.field(metadata={"embedded_model": True})
111+
team_permissions: dict[str, str] | None | _Unset
110112

111113
# model only fields
112114
aliases: list[str] = dataclasses.field(metadata={"model_only": True}, default_factory=list)
@@ -190,6 +192,19 @@ class Repository(ModelObject):
190192
"rust",
191193
}
192194

195+
_valid_team_permissions: ClassVar[dict[str, str]] = {
196+
"pull": "pull",
197+
"triage": "triage",
198+
"push": "push",
199+
"maintain": "maintain",
200+
"admin": "admin",
201+
"READ": "pull",
202+
"TRIAGE": "triage",
203+
"WRITE": "push",
204+
"MAINTAIN": "maintain",
205+
"ADMIN": "admin",
206+
}
207+
193208
@property
194209
def model_object_name(self) -> str:
195210
return "repository"
@@ -245,6 +260,14 @@ def add_environment(self, environment: Environment) -> None:
245260
def set_environments(self, environments: list[Environment]) -> None:
246261
self.environments = environments
247262

263+
def set_team_permissions(self, permissions: dict[str, str]) -> None:
264+
self.team_permissions = {
265+
team: self._valid_team_permissions.get(perm, perm) for team, perm in permissions.items()
266+
}
267+
268+
def unset_team_permissions(self) -> None:
269+
self.team_permissions = UNSET
270+
248271
def coerce_from_org_settings(self, org_settings: OrganizationSettings, for_patch: bool = False) -> Repository:
249272
copy = dataclasses.replace(self)
250273

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

480+
if is_set_and_present(self.team_permissions) and isinstance(self.team_permissions, dict):
481+
for k, v in self.team_permissions.items():
482+
if v not in self._valid_team_permissions:
483+
context.add_failure(
484+
FailureType.ERROR,
485+
f"invalid permission '{v}' "
486+
f"for team '{k}', allowed values are "
487+
f"('read/pull' | 'triage' | 'write/push' | 'maintain' | 'admin').",
488+
)
489+
457490
for webhook in self.webhooks:
458491
webhook.validate(context, self)
459492

@@ -854,9 +887,13 @@ def property_list_to_map(properties):
854887

855888
return output
856889

890+
def transform_perm(permissions):
891+
return {team: cls._valid_team_permissions[perm] for team, perm in permissions}
892+
857893
mapping.update(
858894
{
859895
"custom_properties": OptionalS("custom_properties", default={}) >> F(property_list_to_map),
896+
"team_permissions": OptionalS("team_permissions", default={}) >> F(transform_perm),
860897
"webhooks": K([]),
861898
"secrets": K([]),
862899
"variables": K([]),
@@ -1318,6 +1355,102 @@ def _include_gh_pages_patch_required_properties(
13181355

13191356
return patch
13201357

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

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

@@ -1369,3 +1511,11 @@ async def apply_live_patch(
13691511
github_data = await RepositoryWorkflowSettings.dict_to_provider_data(org_id, data, provider)
13701512

13711513
await provider.update_repo_workflow_settings(org_id, expected_object.name, github_data)
1514+
1515+
# Team permissions sit at the intersection of repositories and teams.
1516+
# A change in `team_permissions` can represent three different operations:
1517+
# - removing a team's permission (delete)
1518+
# - modifying an existing permission (update)
1519+
# - assigning a new permission (add)
1520+
# The patch is therefore decomposed into these three categories and applied here.
1521+
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)

0 commit comments

Comments
 (0)