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