@@ -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 )
0 commit comments