From f17004d5e6b388173c896edd31074551a5ec69ad Mon Sep 17 00:00:00 2001 From: Stephan Merker Date: Fri, 23 May 2025 18:48:55 +0200 Subject: [PATCH] OrgGenerator support for multiple managed orgs - hardcoded list of managed github orgs - added 'org' property to WG charter, defaults to cloudfoundry - TOC members get org admins in all managed orgs - WG leads of all managed orgs get write access to community repo using additional wg-leads- teams in cloudfoundry org - name of org where the community repo lives is derived from TOC WG (default: cloudfoundry) --- .gitignore | 2 +- orgs/org_management.py | 289 +++++++++++++++++++------------- orgs/org_user_management.py | 10 +- orgs/readme.md | 7 +- orgs/test_org_management.py | 326 +++++++++++++++++++++++++++++++++--- 5 files changed, 485 insertions(+), 149 deletions(-) diff --git a/.gitignore b/.gitignore index 0a391eee9..3072c1ffe 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,6 @@ toc/elections/2021/private.csv /.secrets .vscode __pycache__ -cloudfoundry.out.yml +orgs.out.yml branchprotection.out.yml /.idea \ No newline at end of file diff --git a/orgs/org_management.py b/orgs/org_management.py index c8912b9ca..b219682cb 100644 --- a/orgs/org_management.py +++ b/orgs/org_management.py @@ -31,6 +31,10 @@ def construct_mapping(self, node, deep=False): class OrgGenerator: + # list of managed orgs, should match ./ORGS.md + _MANAGED_ORGS = ["cloudfoundry"] + _DEFAULT_ORG = "cloudfoundry" + # parameters intended for testing only, all params are yaml docs def __init__( self, @@ -40,111 +44,166 @@ def __init__( working_groups: Optional[List[str]] = None, branch_protection: Optional[str] = None, ): - self.org_cfg = ( - OrgGenerator._yaml_load(static_org_cfg) - if static_org_cfg - else {"orgs": {"cloudfoundry": {"admins": [], "members": [], "teams": {}, "repos": {}}}} - ) - OrgGenerator._validate_github_org_cfg(self.org_cfg) - self.contributors = ( - set(OrgGenerator._validate_contributors(OrgGenerator._yaml_load(contributors))["orgs"]["cloudfoundry"]["contributors"]) - if contributors - else set() - ) - self.toc = OrgGenerator._yaml_load(toc) if toc else OrgGenerator._empty_wg_config("TOC") - OrgGenerator._validate_wg(self.toc) - self.working_groups = [OrgGenerator._validate_wg(OrgGenerator._yaml_load(wg)) for wg in working_groups] if working_groups else [] + self.org_cfg = OrgGenerator._validate_github_org_cfg(OrgGenerator._yaml_load(static_org_cfg)) if static_org_cfg else {"orgs": {}} + self.contributors = dict[str, set[str]]() + self.working_groups = {} self.branch_protection = ( - OrgGenerator._yaml_load(branch_protection) + OrgGenerator._validate_branch_protection(OrgGenerator._yaml_load(branch_protection)) if branch_protection - else {"branch-protection": {"orgs": {"cloudfoundry": {"repos": {}}}}} + else {"branch-protection": {"orgs": {}}} ) - OrgGenerator._validate_branch_protection(self.branch_protection) + for org in OrgGenerator._MANAGED_ORGS: + if org not in self.org_cfg["orgs"]: + self.org_cfg["orgs"][org] = {"admins": [], "members": [], "teams": {}, "repos": {}} + self.contributors[org] = set() + self.working_groups[org] = [] + if org not in self.branch_protection["branch-protection"]["orgs"]: + self.branch_protection["branch-protection"]["orgs"][org] = {"repos": {}} + if contributors: + contributors_yaml = OrgGenerator._yaml_load(contributors) + OrgGenerator._validate_contributors(contributors_yaml) + for org in contributors_yaml["orgs"]: + self.contributors[org] = set(contributors_yaml["orgs"][org]["contributors"]) + + self.toc = OrgGenerator._yaml_load(toc) if toc else OrgGenerator._empty_wg_config("TOC") + OrgGenerator._validate_wg(self.toc) + self.toc_org = self.toc["org"] + wgs = [OrgGenerator._validate_wg(OrgGenerator._yaml_load(wg)) for wg in working_groups] if working_groups else [] + for wg in wgs: + org = wg["org"] + if org not in OrgGenerator._MANAGED_ORGS: + raise ValueError(f"Invalid org {org} in WG {wg['name']}, expected one of {OrgGenerator._MANAGED_ORGS}") + self.working_groups[org].append(wg) def load_from_project(self): path = f"{_SCRIPT_PATH}/orgs.yml" print(f"Reading static org configuration from {path}") self.org_cfg = OrgGenerator._validate_github_org_cfg(OrgGenerator._read_yml_file(path)) + for org in OrgGenerator._MANAGED_ORGS: + if org not in self.org_cfg["orgs"]: + self.org_cfg["orgs"][org] = {"admins": [], "members": [], "teams": {}, "repos": {}} path = f"{_SCRIPT_PATH}/contributors.yml" if os.path.exists(path): print(f"Reading contributors from {path}") contributors_yaml = OrgGenerator._read_yml_file(path) OrgGenerator._validate_contributors(contributors_yaml) - self.contributors = set(contributors_yaml["orgs"]["cloudfoundry"]["contributors"]) + for org in contributors_yaml["orgs"]: + self.contributors[org] = set(contributors_yaml["orgs"][org]["contributors"]) path = f"{_SCRIPT_PATH}/branchprotection.yml" print(f"Reading branch protection configuration from {path}") self.branch_protection = OrgGenerator._validate_branch_protection(OrgGenerator._read_yml_file(path)) + for org in OrgGenerator._MANAGED_ORGS: + if org not in self.branch_protection["branch-protection"]["orgs"]: + self.branch_protection["branch-protection"]["orgs"][org] = {"repos": {}} # working group charters (including TOC and ADMIN), ignore WGs without yaml block - self.toc = OrgGenerator._read_wg_charter(f"{_SCRIPT_PATH}/../toc/TOC.md") + toc = OrgGenerator._read_wg_charter(f"{_SCRIPT_PATH}/../toc/TOC.md") + if toc: + self.toc = toc + self.toc_org = toc["org"] + wg_files = glob.glob(f"{_SCRIPT_PATH}/../toc/working-groups/*.md") wg_files += glob.glob(f"{_SCRIPT_PATH}/../toc/ADMIN.md") for wg_file in wg_files: if not wg_file.endswith("/WORKING-GROUPS.md"): wg = OrgGenerator._read_wg_charter(wg_file) if wg: - self.working_groups.append(wg) + org = wg["org"] + if org not in OrgGenerator._MANAGED_ORGS: + raise ValueError(f"Invalid org {org} in WG {wg['name']}, expected one of {OrgGenerator._MANAGED_ORGS}") + self.working_groups[org].append(wg) - # rfc-0007-repository-ownership: a repo can't be owned by multiple WGs + # rfc-0007-repository-ownership: a repo can't be owned by multiple WGs, scope is github org def validate_repo_ownership(self) -> bool: valid = True - repo_owners = {} - for wg in self.working_groups: - wg_name = wg["name"] - wg_repos = set(r for a in wg["areas"] for r in a["repositories"]) - for repo in wg_repos: - if repo in repo_owners: - print(f"ERROR: Repository {repo} is owned by multiple WGs: {repo_owners[repo]}, {wg_name}") - valid = False - else: - repo_owners[repo] = wg_name + for org in OrgGenerator._MANAGED_ORGS: + repo_owners = {} + for wg in self.working_groups[org]: + wg_name = wg["name"] + wg_repos = set(r for a in wg["areas"] for r in a["repositories"]) + for repo in wg_repos: + if repo in repo_owners: + print(f"ERROR: Repository {repo} is owned by multiple WGs: {repo_owners[repo]}, {wg_name}") + valid = False + else: + repo_owners[repo] = wg_name return valid - def get_contributors(self) -> Set[str]: - return set(self.contributors) + def get_contributors(self, org: str) -> Set[str]: + return set(self.contributors[org]) if org in self.contributors else set() - def get_community_members_with_role_by_wg(self) -> Dict[str, Set[str]]: - result = {"toc": set(self.toc)} - for wg in self.working_groups: + def get_community_members_with_role_by_wg(self, org: str) -> Dict[str, Set[str]]: + # TOC is always added + result = {"toc": set(OrgGenerator._wg_github_users(self.toc))} + for wg in self.working_groups[org]: result[wg["name"]] = OrgGenerator._wg_github_users(wg) return result def generate_org_members(self): - org_members = set(self.org_cfg["orgs"]["cloudfoundry"]["members"]) # just in case, should be empty list - org_members |= self.contributors - for wg in self.working_groups: - org_members |= OrgGenerator._wg_github_users(wg) - org_admins = set(self.org_cfg["orgs"]["cloudfoundry"]["admins"]) - org_admins |= OrgGenerator._wg_github_users_leads(self.toc) - org_members = org_members - org_admins - self.org_cfg["orgs"]["cloudfoundry"]["members"] = sorted(org_members) - self.org_cfg["orgs"]["cloudfoundry"]["admins"] = sorted(org_admins) + for org in OrgGenerator._MANAGED_ORGS: + org_members = set(self.org_cfg["orgs"][org]["members"]) # just in case, should be empty list + org_members |= self.get_contributors(org) + for wg in self.working_groups[org]: + org_members |= OrgGenerator._wg_github_users(wg) + # wg-leads of all WGs shall be members in cloudfoundy org for access to community repo + if org == self.toc_org: + for _org in OrgGenerator._MANAGED_ORGS: + for wg in self.working_groups[_org]: + org_members |= OrgGenerator._wg_github_users_leads(wg) + org_admins = set(self.org_cfg["orgs"][org]["admins"]) + org_admins |= OrgGenerator._wg_github_users_leads(self.toc) + org_members = org_members - org_admins + self.org_cfg["orgs"][org]["members"] = sorted(org_members) + self.org_cfg["orgs"][org]["admins"] = sorted(org_admins) def generate_teams(self): # overwrites any teams in orgs.yml that match a generated team name according to RFC-0005 - # working group teams - for wg in self.working_groups: - (name, team) = OrgGenerator._generate_wg_teams(wg) - self.org_cfg["orgs"]["cloudfoundry"]["teams"][name] = team - # toc team + # toc team, only in cloudfoundry org + # TOC members have org admin access in all managed orgs (name, team) = OrgGenerator._generate_toc_team(self.toc) - self.org_cfg["orgs"]["cloudfoundry"]["teams"][name] = team - # wg-leads team - (name, team) = OrgGenerator._generate_wg_leads_team(self.working_groups) - self.org_cfg["orgs"]["cloudfoundry"]["teams"][name] = team + self.org_cfg["orgs"][self.toc["org"]]["teams"][name] = team + # wg teams for all orgs + for org in OrgGenerator._MANAGED_ORGS: + # working group teams + for wg in self.working_groups[org]: + if wg["org"] == org: + (name, team) = OrgGenerator._generate_wg_teams(wg) + self.org_cfg["orgs"][org]["teams"][name] = team + # wg-leads team + (name, team) = OrgGenerator._generate_wg_leads_team(self.working_groups[org]) + self.org_cfg["orgs"][org]["teams"][name] = team + + # wg-leads get write access to community repo which is in cloudfoundry org + # RFC-0005 lists community repo explicitly (not all TOC repos) + self.org_cfg["orgs"][self.toc_org]["teams"]["wg-leads"]["repos"] = {"community": "write"} + # wg leads of other orgs -> create extra wg-leads- team in cloudfoundry org + for org in OrgGenerator._MANAGED_ORGS: + if org != self.toc_org: + wg_leads_other_org = self.org_cfg["orgs"][org]["teams"]["wg-leads"] + team = { + "description": f"Technical and Execution Leads for all WGs in organization {org}", + "privacy": "closed", + "members": wg_leads_other_org["members"], + "repos": {"community": "write"}, + } + self.org_cfg["orgs"][self.toc_org]["teams"][f"wg-leads-{org}"] = team def generate_branch_protection(self): # basis is static config in self.branch_protection which is never overwritten # generate RFC0015 branch protection rules for every WG+TOC that opted in - branch_protection_repos = self.branch_protection["branch-protection"]["orgs"]["cloudfoundry"]["repos"] - for wg in self.working_groups + [self.toc]: - if wg.get("config", {}).get("generate_rfc0015_branch_protection_rules", False): # config is optional - repo_rules = self._generate_wb_branch_protection(wg) - for repo in repo_rules: - if repo not in branch_protection_repos: - branch_protection_repos[repo] = repo_rules[repo] + for org in OrgGenerator._MANAGED_ORGS: + branch_protection_repos = self.branch_protection["branch-protection"]["orgs"][org]["repos"] + wgs = self.working_groups[org] + if org == self.toc["org"]: + wgs.append(self.toc) + for wg in wgs: + if wg.get("config", {}).get("generate_rfc0015_branch_protection_rules", False): # config is optional + repo_rules = self._generate_wg_branch_protection(wg) + for repo in repo_rules: + if repo not in branch_protection_repos: + branch_protection_repos[repo] = repo_rules[repo] def write_org_config(self, path: str): print(f"Writing org configuration to {path}") @@ -157,7 +216,7 @@ def write_branch_protection(self, path: str): return yaml.safe_dump(self.branch_protection, stream) @staticmethod - def _yaml_load(stream): + def _yaml_load(stream) -> dict[str, Any]: # safe_load + reject unique keys return yaml.load(stream, UniqueKeyLoader) @@ -189,6 +248,7 @@ def _extract_wg_config(wg_charter: str): def _empty_wg_config(name: str): return { "name": name, + "org": OrgGenerator._DEFAULT_ORG, "execution_leads": [], "technical_leads": [], "bots": [], @@ -219,15 +279,14 @@ def _wg_github_users_leads(wg) -> Set[str]: "properties": { "orgs": { "type": "object", - "properties": { - "cloudfoundry": { + "patternProperties": { + "^\\w+$": { "type": "object", "properties": {"contributors": {"type": "array", "items": {"type": "string"}}}, "required": ["contributors"], "additionalProperties": False, } }, - "required": ["cloudfoundry"], "additionalProperties": False, } }, @@ -236,14 +295,19 @@ def _wg_github_users_leads(wg) -> Set[str]: } @staticmethod - def _validate_contributors(contributors): + def _validate_contributors(contributors) -> dict[str, Any]: jsonschema.validate(contributors, OrgGenerator._CONTRIBUTORS_SCHEMA) + # check that orgs are in _ORGS + for org in contributors["orgs"]: + if org not in OrgGenerator._MANAGED_ORGS: + raise ValueError(f"Invalid org {org} in orgs.yml, expected one of {OrgGenerator._MANAGED_ORGS}") return contributors _WG_SCHEMA = { "type": "object", "properties": { "name": {"type": "string"}, + "org": {"type": "string", "default": "cloudfoundry"}, "execution_leads": {"type": "array", "items": {"$ref": "#/$defs/githubUser"}}, "technical_leads": {"type": "array", "items": {"$ref": "#/$defs/githubUser"}}, "bots": {"type": "array", "items": {"$ref": "#/$defs/githubUser"}}, @@ -277,8 +341,13 @@ def _validate_contributors(contributors): } @staticmethod - def _validate_wg(wg): + def _validate_wg(wg) -> dict[str, Any]: jsonschema.validate(wg, OrgGenerator._WG_SCHEMA) + # validate org and use 'cloudfoundry' if missing + if "org" not in wg: + wg["org"] = OrgGenerator._DEFAULT_ORG + if wg["org"] not in OrgGenerator._MANAGED_ORGS: + raise ValueError(f"Invalid org {wg['org']} in {wg['name']}, expected one of {OrgGenerator._MANAGED_ORGS}") return wg # schema for referenced fields only, not for complete config @@ -287,8 +356,8 @@ def _validate_wg(wg): "properties": { "orgs": { "type": "object", - "properties": { - "cloudfoundry": { + "patternProperties": { + "^\\w+$": { "type": "object", "properties": { "admins": {"type": "array", "items": {"type": "string"}}, @@ -298,7 +367,6 @@ def _validate_wg(wg): "required": ["admins", "members", "teams", "repos"], }, }, - "required": ["cloudfoundry"], }, }, "required": ["orgs"], @@ -307,6 +375,10 @@ def _validate_wg(wg): @staticmethod def _validate_github_org_cfg(cfg): jsonschema.validate(cfg, OrgGenerator._GITHUB_ORG_CFG_SCHEMA) + # check that orgs are in _ORGS + for org in cfg["orgs"]: + if org not in OrgGenerator._MANAGED_ORGS: + raise ValueError(f"Invalid org {org} in orgs.yml, expected one of {OrgGenerator._MANAGED_ORGS}") return cfg # schema for referenced fields only, not for complete config @@ -318,8 +390,8 @@ def _validate_github_org_cfg(cfg): "properties": { "orgs": { "type": "object", - "properties": { - "cloudfoundry": { + "patternProperties": { + "^\\w+$": { "type": "object", "properties": { "repos": {"type": "object"}, @@ -327,7 +399,6 @@ def _validate_github_org_cfg(cfg): "required": ["repos"], }, }, - "required": ["cloudfoundry"], }, }, "required": ["orgs"], @@ -339,24 +410,23 @@ def _validate_github_org_cfg(cfg): @staticmethod def _validate_branch_protection(cfg): jsonschema.validate(cfg, OrgGenerator._BRANCH_PROTECTION_SCHEMA) + # check that orgs are in _ORGS + for org in cfg["branch-protection"]["orgs"]: + if org not in OrgGenerator._MANAGED_ORGS: + raise ValueError(f"Invalid org {org} in branchprotection.yml, expected one of {OrgGenerator._MANAGED_ORGS}") return cfg - _CF_ORG_PREFIX = "cloudfoundry/" - _CF_ORG_PREFIX_LEN = len(_CF_ORG_PREFIX) - # https://github.com/cloudfoundry/community/blob/main/toc/rfc/rfc-0005-github-teams-and-access.md @staticmethod def _generate_wg_teams(wg) -> Tuple[str, Dict[str, Any]]: + org = wg["org"] + org_prefix = org + "/" + org_prefix_len = len(org_prefix) name = OrgGenerator._kebab_case(f"wg-{wg['name']}") maintainers = {u["github"] for u in wg["execution_leads"]} maintainers |= {u["github"] for u in wg["technical_leads"]} approvers = {u["github"] for a in wg["areas"] for u in a["approvers"]} - repositories = { - r[OrgGenerator._CF_ORG_PREFIX_LEN :] - for a in wg["areas"] - for r in a["repositories"] - if r.startswith(OrgGenerator._CF_ORG_PREFIX) - } + repositories = {r[org_prefix_len:] for a in wg["areas"] for r in a["repositories"] if r.startswith(org_prefix)} # WG team and teams for WG areas team = { "description": f"Leads and approvers for {wg['name']} WG", @@ -386,9 +456,7 @@ def _generate_wg_teams(wg) -> Tuple[str, Dict[str, Any]]: "privacy": "closed", "maintainers": sorted(maintainers), "members": sorted({u["github"] for u in a["approvers"]} - maintainers), - "repos": { - r[OrgGenerator._CF_ORG_PREFIX_LEN :]: "write" for r in a["repositories"] if r.startswith(OrgGenerator._CF_ORG_PREFIX) - }, + "repos": {r[org_prefix_len:]: "write" for r in a["repositories"] if r.startswith(org_prefix)}, } for a in wg["areas"] } @@ -399,9 +467,7 @@ def _generate_wg_teams(wg) -> Tuple[str, Dict[str, Any]]: "privacy": "closed", "maintainers": sorted(maintainers), "members": sorted({u["github"] for u in a["reviewers"]} - maintainers), - "repos": { - r[OrgGenerator._CF_ORG_PREFIX_LEN :]: "read" for r in a["repositories"] if r.startswith(OrgGenerator._CF_ORG_PREFIX) - }, + "repos": {r[org_prefix_len:]: "read" for r in a["repositories"] if r.startswith(org_prefix)}, } for a in wg["areas"] if "reviewers" in a @@ -413,9 +479,7 @@ def _generate_wg_teams(wg) -> Tuple[str, Dict[str, Any]]: "privacy": "closed", "maintainers": sorted(maintainers), "members": sorted({u["github"] for u in a["bots"]} - maintainers), - "repos": { - r[OrgGenerator._CF_ORG_PREFIX_LEN :]: "write" for r in a["repositories"] if r.startswith(OrgGenerator._CF_ORG_PREFIX) - }, + "repos": {r[org_prefix_len:]: "write" for r in a["repositories"] if r.startswith(org_prefix)}, } for a in wg["areas"] if "bots" in a @@ -424,13 +488,11 @@ def _generate_wg_teams(wg) -> Tuple[str, Dict[str, Any]]: @staticmethod def _generate_toc_team(wg) -> Tuple[str, Dict[str, Any]]: + org = wg["org"] + org_prefix = org + "/" + org_prefix_len = len(org_prefix) # assumption: TOC members are execution_leads - repositories = { - r[OrgGenerator._CF_ORG_PREFIX_LEN :] - for a in wg["areas"] - for r in a["repositories"] - if r.startswith(OrgGenerator._CF_ORG_PREFIX) - } + repositories = {r[org_prefix_len:] for a in wg["areas"] for r in a["repositories"] if r.startswith(org_prefix)} team = { "description": wg["name"], "privacy": "closed", @@ -441,27 +503,22 @@ def _generate_toc_team(wg) -> Tuple[str, Dict[str, Any]]: @staticmethod def _generate_wg_leads_team(wgs: List[Any]) -> Tuple[str, Dict[str, Any]]: - # RFC-0005 lists community repo explicitly (not all TOC repos) - repositories = {"community"} # without cloudfoundry/ prefix members = {u for wg in wgs for u in OrgGenerator._wg_github_users_leads(wg)} team = { "description": "Technical and Execution Leads for all WGs", "privacy": "closed", "members": sorted(members), - "repos": {r: "write" for r in repositories}, } return ("wg-leads", team) # https://github.com/cloudfoundry/community/blob/main/toc/rfc/rfc-0015-branch-protection.md # returns hash with branch protection rules per repo - def _generate_wb_branch_protection(self, wg) -> Dict[str, Any]: + def _generate_wg_branch_protection(self, wg) -> Dict[str, Any]: + org = wg["org"] + org_prefix = org + "/" + org_prefix_len = len(org_prefix) # count approvers per repo over all WG areas, TODO: repos shared between WGs? - repos = { - r[OrgGenerator._CF_ORG_PREFIX_LEN :] - for a in wg["areas"] - for r in a["repositories"] - if r.startswith(OrgGenerator._CF_ORG_PREFIX) - } + repos = {r[org_prefix_len:] for a in wg["areas"] for r in a["repositories"] if r.startswith(org_prefix)} wg_name = f"wg-{wg['name']}" wg_bots = OrgGenerator._kebab_case(f"{wg_name}-bots") return { @@ -471,21 +528,13 @@ def _generate_wb_branch_protection(self, wg) -> Dict[str, Any]: "allow_force_pushes": False, "allow_deletions": False, "allow_disabled_policies": True, # needed to allow branches w/o branch protection - "include": [f"^{self._get_default_branch(repo)}$", "^v[0-9]*$"], + "include": [f"^{self._get_default_branch(org, repo)}$", "^v[0-9]*$"], "required_pull_request_reviews": { "dismiss_stale_reviews": True, "require_code_owner_reviews": True, "required_approving_review_count": ( 0 - if len( - { - u["github"] - for a in wg["areas"] - if OrgGenerator._CF_ORG_PREFIX + repo in a["repositories"] - for u in a["approvers"] - } - ) - < 4 + if len({u["github"] for a in wg["areas"] if org_prefix + repo in a["repositories"] for u in a["approvers"]}) < 4 else 1 ), "bypass_pull_request_allowances": { @@ -493,7 +542,7 @@ def _generate_wb_branch_protection(self, wg) -> Dict[str, Any]: + [ OrgGenerator._kebab_case(f"{wg_name}-{a['name']}-bots") for a in wg["areas"] - if OrgGenerator._CF_ORG_PREFIX + repo in a["repositories"] and "bots" in a and len(a["bots"]) > 0 + if org_prefix + repo in a["repositories"] and "bots" in a and len(a["bots"]) > 0 ] # area bot teams }, }, @@ -501,12 +550,12 @@ def _generate_wb_branch_protection(self, wg) -> Dict[str, Any]: for repo in repos } - def _get_default_branch(self, repo: str) -> str: + def _get_default_branch(self, org: str, repo: str) -> str: # https://github.com/organizations/cloudfoundry/settings/repository-defaults - Repository default branch = main (for new repos) # But in orgs.yml: all repos w/o default_branch use master (data was generated by peribolos) # https://github.com/kubernetes/test-infra/blob/master/prow/config/org/org.go#L173 # Looks like trouble ahead. Should not create new repos w/o default_branch setting. - return self.org_cfg["orgs"]["cloudfoundry"]["repos"].get(repo, {}).get("default_branch", "master") + return self.org_cfg["orgs"][org]["repos"].get(repo, {}).get("default_branch", "master") _KEBAB_CASE_RE = re.compile(r"[\W_]+") diff --git a/orgs/org_user_management.py b/orgs/org_user_management.py index e170a3bb3..b1f50217d 100644 --- a/orgs/org_user_management.py +++ b/orgs/org_user_management.py @@ -13,10 +13,10 @@ class InactiveUserHandler: def __init__( self, - github_org: [str], - github_org_id: [str], - activity_date: [str], - github_token: [str], + github_org: str, + github_org_id: str, + activity_date: str, + github_token: str, ): self.github_org = github_org self.github_org_id = github_org_id @@ -170,7 +170,7 @@ def _get_bool_env_var(env_var_name, default): print("Get information about community users") generator = OrgGenerator() generator.load_from_project() - community_members_with_role_by_wg = generator.get_community_members_with_role_by_wg() + community_members_with_role_by_wg = generator.get_community_members_with_role_by_wg(args.githuborg) community_members_with_role = set() for members in community_members_with_role_by_wg.values(): community_members_with_role |= set(members) diff --git a/orgs/readme.md b/orgs/readme.md index 1cfa08fd1..4108c9c1f 100644 --- a/orgs/readme.md +++ b/orgs/readme.md @@ -19,8 +19,9 @@ Once approved and merged, the github action [org-management.yml](https://github. Organization members are generated according to [rfc-0002-github-members](https://github.com/cloudfoundry/community/blob/main/toc/rfc/rfc-0002-github-members.md) and [rfc-0008-role-change-process](https://github.com/cloudfoundry/community/blob/main/toc/rfc/rfc-0008-role-change-process.md): - any members specified in [orgs.yml](https://github.com/cloudfoundry/community/blob/main/orgs/orgs.yml) (should be none) - all contributors from [contributors.yml](https://github.com/cloudfoundry/community/blob/main/orgs/contributors.yml) -- all working group leads and approvers specified in the [Working Group Charters](https://github.com/cloudfoundry/community/tree/main/toc/working-groups) +- all working group leads, approvers, reviewers and bots specified in the [Working Group Charters](https://github.com/cloudfoundry/community/tree/main/toc/working-groups) - org admins and TOC members must not be added to org member list +- WG leads of all github orgs are added as members to the `cloudfoundy` org to get access to the `community` repo ### Organization Admins Organization admins are: @@ -29,7 +30,7 @@ Organization admins are: ### Github Teams for Working Group Areas Github Teams for the TOC, all Working Group Leads, Working Groups and Working Group Areas are generated according to [rfc-0014-github-teams-and-access.md](https://github.com/cloudfoundry/community/blob/main/toc/rfc/rfc-0014-github-teams-and-access.md). -Repositories listed in the working group yaml block that belong to github organizations other than `cloudfoundry` are ignored. +Repositories listed in the working group yaml block that belong to github organizations other than the one specified in the working group yaml (default github organization is `cloudfoundy`) are ignored. ### Branch Protection Rules @@ -86,7 +87,7 @@ Requires Python 3.9. How to run locally: ``` -cd ./org +cd ./orgs python -m venv source /bin/activate pip install -r requirements.txt diff --git a/orgs/test_org_management.py b/orgs/test_org_management.py index a02691883..282723423 100644 --- a/orgs/test_org_management.py +++ b/orgs/test_org_management.py @@ -19,6 +19,33 @@ default_branch: defbranch """ +org_cfg_multiple = """ +--- +orgs: + cloudfoundry: + admins: + - admin1 + members: + - member1 + teams: {} + repos: + repo1: + default_branch: main + repo3: + default_branch: defbranch + cloudfoundry2: + admins: + - admin2 + members: + - member2 + teams: {} + repos: + repo1: + default_branch: main + repo3: + default_branch: defbranch +""" + wg1 = """ name: WG1 Name execution_leads: @@ -141,6 +168,48 @@ generate_rfc0015_branch_protection_rules: true """ +wg4_other_org = """ +name: WG4 Name +org: cloudfoundry2 +execution_leads: +- name: Execution Lead WG4 + github: execution-lead-wg4 +technical_leads: +- name: Technical Lead WG4 + github: technical-lead-wg4 +bots: +- name: WG4 CI Bot + github: bot1-wg4 +areas: +- name: Area 1 + approvers: + - github: approver1-wg4-a1 + name: User 1 + - github: approver2-wg4-a1-a2 + name: User 2 + repositories: + - cloudfoundry2/repo1 + - cloudfoundry2/repo2 +- name: Area 2 + approvers: + - github: approver2-wg4-a1-a2 + name: User 2 + - github: approver3-wg4-a2 + name: User 3 + reviewers: + - github: reviewer1-wg4-a2 + name: User 4 + bots: + - github: bot2-wg4-a2 + name: WG4 Area2 Bot + repositories: + - cloudfoundry2/repo3 + - cloudfoundry2/repo4 + - cloudfoundry/repo5 +config: + generate_rfc0015_branch_protection_rules: true +""" + toc = """ name: Technical Oversight Committee execution_leads: @@ -176,6 +245,19 @@ - Contributor2 """ +contributors_multiple_orgs = """ +orgs: + cloudfoundry: + contributors: + - contributor1 + - contributor2 + cloudfoundry2: + contributors: + - contributor2 + - contributor3 + - contributor4 +""" + branch_protection = """ branch-protection: orgs: @@ -185,8 +267,24 @@ protect: true """ +branch_protection_multiple_orgs = """ +branch-protection: + orgs: + cloudfoundry: + repos: + repo1: + protect: true + cloudfoundry2: + repos: + repo1: + protect: true +""" + class TestOrgGenerator(unittest.TestCase): + def setUp(self) -> None: + OrgGenerator._MANAGED_ORGS = ["cloudfoundry"] + def test_empty_org(self): o = OrgGenerator() o.generate_org_members() @@ -240,6 +338,20 @@ def test_toc_members_are_org_admins(self): # being org admins, toc members can't be org members self.assertListEqual(["Contributor2", "contributor1"], o.org_cfg["orgs"]["cloudfoundry"]["members"]) + def test_org_members_multiple_orgs(self): + OrgGenerator._MANAGED_ORGS = ["cloudfoundry", "cloudfoundry2"] + o = OrgGenerator(contributors=contributors_multiple_orgs, toc=toc, working_groups=[wg1, wg2, wg4_other_org]) + o.generate_org_members() + # 2 contributors, 8 wg1, 3 wg2, 2 wg-leads of cloudfoundry2 + self.assertEqual(2 + 8 + 3 + 2, len(o.org_cfg["orgs"]["cloudfoundry"]["members"])) + # 3 toc + self.assertEqual(3, len(o.org_cfg["orgs"]["cloudfoundry"]["admins"])) + + # 3 contributors, 8 wg4_other_org + self.assertEqual(3 + 8, len(o.org_cfg["orgs"]["cloudfoundry2"]["members"])) + # 3 toc + self.assertEqual(3, len(o.org_cfg["orgs"]["cloudfoundry2"]["admins"])) + def test_extract_wg_config(self): self.assertIsNone(OrgGenerator._extract_wg_config("")) wg = OrgGenerator._extract_wg_config(f"bla bla ```yaml {wg1} ```") @@ -278,11 +390,20 @@ def test_validate_contributors(self): with self.assertRaises(jsonschema.ValidationError): OrgGenerator._validate_contributors({"contributors": 1}) + # multiple orgs + with self.assertRaises(ValueError): + OrgGenerator._validate_contributors(OrgGenerator._yaml_load(contributors_multiple_orgs)) + OrgGenerator._MANAGED_ORGS = ["cloudfoundry", "cloudfoundry2"] + OrgGenerator._validate_contributors(OrgGenerator._yaml_load(contributors_multiple_orgs)) + def test_validate_wg(self): - OrgGenerator._validate_wg(OrgGenerator._empty_wg_config("wg")) - OrgGenerator._validate_wg(OrgGenerator._yaml_load(wg1)) - OrgGenerator._validate_wg(OrgGenerator._yaml_load(wg2)) - OrgGenerator._validate_wg(OrgGenerator._yaml_load(toc)) + wg = OrgGenerator._validate_wg(OrgGenerator._empty_wg_config("wg")) + self.assertEqual("wg", wg["name"]) + self.assertEqual("cloudfoundry", wg["org"]) + wg = OrgGenerator._validate_wg(OrgGenerator._yaml_load(wg1)) + self.assertEqual("cloudfoundry", wg["org"]) + wg = OrgGenerator._validate_wg(OrgGenerator._yaml_load(wg2)) + wg = OrgGenerator._validate_wg(OrgGenerator._yaml_load(toc)) with self.assertRaises(jsonschema.ValidationError): OrgGenerator._validate_wg({}) with self.assertRaises(jsonschema.ValidationError): @@ -309,11 +430,24 @@ def test_validate_wg(self): """ OrgGenerator._validate_wg(OrgGenerator._yaml_load(wg)) + # multiple orgs + with self.assertRaises(ValueError): + OrgGenerator._validate_wg(OrgGenerator._yaml_load(wg4_other_org)) + OrgGenerator._MANAGED_ORGS = ["cloudfoundry", "cloudfoundry2"] + wg = OrgGenerator._validate_wg(OrgGenerator._yaml_load(wg4_other_org)) + self.assertEqual("cloudfoundry2", wg["org"]) + def test_validate_github_org_cfg(self): OrgGenerator._validate_github_org_cfg(OrgGenerator._yaml_load(org_cfg)) with self.assertRaises(jsonschema.ValidationError): OrgGenerator._validate_github_org_cfg({}) + # multiple orgs + with self.assertRaises(ValueError): + OrgGenerator._validate_github_org_cfg(OrgGenerator._yaml_load(org_cfg_multiple)) + OrgGenerator._MANAGED_ORGS = ["cloudfoundry", "cloudfoundry2"] + OrgGenerator._validate_github_org_cfg(OrgGenerator._yaml_load(org_cfg_multiple)) + def test_kebab_case(self): self.assertEqual("", OrgGenerator._kebab_case("")) self.assertEqual("wg-a-b-c-d-e", OrgGenerator._kebab_case("wg-a b_c-d e")) @@ -340,6 +474,7 @@ def test_validate_repo_ownership(self): def test_generate_wg_teams(self): _wg1 = OrgGenerator._yaml_load(wg1) + OrgGenerator._validate_wg(_wg1) (name, wg_team) = OrgGenerator._generate_wg_teams(_wg1) self.assertEqual("wg-wg1-name", name) @@ -378,8 +513,10 @@ def test_generate_wg_teams(self): self.assertListEqual(["bot2-wg1-a2"], team["members"]) self.assertDictEqual({"repo3": "write", "repo4": "write"}, team["repos"]) - def test_generate_wg_teams_exclude_non_cf_repos(self): + def test_generate_wg_teams_exclude_non_org_repos(self): _wg2 = OrgGenerator._yaml_load(wg2) + OrgGenerator._validate_wg(_wg2) + (name, wg_team) = OrgGenerator._generate_wg_teams(_wg2) self.assertEqual("wg-wg2-name", name) @@ -403,8 +540,28 @@ def test_generate_wg_teams_exclude_non_cf_repos(self): self.assertNotIn("wg-wg2-name-area-1-reviewers", wg_team["teams"]) self.assertNotIn("wg-wg2-name-area-1-bots", wg_team["teams"]) + def test_generate_wg_teams_multiple_orgs(self): + OrgGenerator._MANAGED_ORGS = ["cloudfoundry", "cloudfoundry2"] + _wg4 = OrgGenerator._yaml_load(wg4_other_org) + OrgGenerator._validate_wg(_wg4) + + (name, wg_team) = OrgGenerator._generate_wg_teams(_wg4) + + self.assertEqual("wg-wg4-name", name) + self.assertListEqual(["execution-lead-wg4", "technical-lead-wg4"], wg_team["maintainers"]) + self.assertListEqual(["approver1-wg4-a1", "approver2-wg4-a1-a2", "approver3-wg4-a2"], wg_team["members"]) + + team = wg_team["teams"]["wg-wg4-name-area-1-approvers"] + self.assertListEqual(["execution-lead-wg4", "technical-lead-wg4"], team["maintainers"]) + self.assertListEqual(["approver1-wg4-a1", "approver2-wg4-a1-a2"], team["members"]) + self.assertDictEqual({"repo1": "write", "repo2": "write"}, team["repos"]) + + team = wg_team["teams"]["wg-wg4-name-area-2-approvers"] + self.assertDictEqual({"repo3": "write", "repo4": "write"}, team["repos"]) + def test_generate_toc_team(self): _toc = OrgGenerator._yaml_load(toc) + OrgGenerator._validate_wg(_toc) (name, team) = OrgGenerator._generate_toc_team(_toc) self.assertEqual("toc", name) @@ -415,7 +572,9 @@ def test_generate_toc_team(self): def test_generate_wg_leads_team(self): _wg1 = OrgGenerator._yaml_load(wg1) + OrgGenerator._validate_wg(_wg1) _wg2 = OrgGenerator._yaml_load(wg2) + OrgGenerator._validate_wg(_wg2) (name, team) = OrgGenerator._generate_wg_leads_team([_wg1, _wg2]) @@ -423,26 +582,121 @@ def test_generate_wg_leads_team(self): self.assertNotIn("maintainers", team) self.assertListEqual(["execution-lead-wg1", "execution-lead-wg2", "technical-lead-wg1", "technical-lead-wg2"], team["members"]) self.assertNotIn("teams", team) - self.assertDictEqual({"community": "write"}, team["repos"]) + self.assertNotIn("repos", team) + + def test_generate_teams(self): + o = OrgGenerator(static_org_cfg=org_cfg, contributors=contributors, toc=toc, working_groups=[wg1, wg2]) + o.generate_org_members() + o.generate_teams() + + self.assertEqual("cloudfoundry", o.toc_org) + + teams = o.org_cfg["orgs"]["cloudfoundry"]["teams"] + # toc, wg-leads, 2 WGs + self.assertEqual(2 + 2, len(teams)) + self.assertIn("wg-wg1-name", teams) + self.assertEqual(2, len(teams["wg-wg1-name"]["maintainers"])) # wg1 leads + self.assertEqual(3, len(teams["wg-wg1-name"]["members"])) # wg1 approvers + self.assertEqual(6, len(teams["wg-wg1-name"]["teams"])) # leads, bots, area1 appr, area2 appr, reviewers, bots + self.assertIn("wg-wg2-name", teams) + self.assertEqual(2, len(teams["wg-wg2-name"]["maintainers"])) # wg2 leads + self.assertEqual(1, len(teams["wg-wg2-name"]["members"])) # wg2 approvers + self.assertEqual(3, len(teams["wg-wg2-name"]["teams"])) # leads, bots, area1 appr + + self.assertIn("toc", teams) + self.assertEqual(2, len(teams["toc"]["maintainers"])) + self.assertNotIn("members", teams["toc"]) + self.assertEqual(1, len(teams["wg-leads"]["repos"])) # community + self.assertIn("community", teams["toc"]["repos"]) + + self.assertIn("wg-leads", teams) + self.assertEqual(2 + 2, len(teams["wg-leads"]["members"])) # wg1 and wg2 leads + self.assertEqual(1, len(teams["wg-leads"]["repos"])) # community + self.assertIn("community", teams["wg-leads"]["repos"]) + + def test_generate_teams_multiple_orgs(self): + OrgGenerator._MANAGED_ORGS = ["cloudfoundry", "cloudfoundry2"] + o = OrgGenerator( + static_org_cfg=org_cfg_multiple, contributors=contributors_multiple_orgs, toc=toc, working_groups=[wg1, wg2, wg4_other_org] + ) + o.generate_org_members() + o.generate_teams() + + self.assertEqual("cloudfoundry", o.toc_org) + + teams = o.org_cfg["orgs"]["cloudfoundry"]["teams"] + # toc, wg-leads, 2 WGs, wg-leads-cloudfoundry2 + self.assertEqual(2 + 2 + 1, len(teams)) + self.assertIn("wg-wg1-name", teams) + self.assertEqual(2, len(teams["wg-wg1-name"]["maintainers"])) # wg1 leads + self.assertEqual(3, len(teams["wg-wg1-name"]["members"])) # wg1 approvers + self.assertEqual(6, len(teams["wg-wg1-name"]["teams"])) # leads, bots, area1 appr, area2 appr, reviewers, bots + self.assertIn("wg-wg2-name", teams) + self.assertEqual(2, len(teams["wg-wg2-name"]["maintainers"])) # wg2 leads + self.assertEqual(1, len(teams["wg-wg2-name"]["members"])) # wg2 approvers + self.assertEqual(3, len(teams["wg-wg2-name"]["teams"])) # leads, bots, area1 appr + + self.assertIn("toc", teams) + self.assertEqual(2, len(teams["toc"]["maintainers"])) + self.assertNotIn("members", teams["toc"]) + self.assertEqual(1, len(teams["wg-leads"]["repos"])) # community + self.assertIn("community", teams["toc"]["repos"]) + + self.assertIn("toc", teams) + self.assertEqual(2, len(teams["toc"]["maintainers"])) + self.assertNotIn("members", teams["toc"]) + self.assertEqual(1, len(teams["wg-leads"]["repos"])) # community + self.assertIn("community", teams["toc"]["repos"]) + + # wg-leads in cloudfoundry + self.assertIn("wg-leads", teams) + self.assertEqual(2 + 2, len(teams["wg-leads"]["members"])) # wg1, wg2 + self.assertEqual(1, len(teams["wg-leads"]["repos"])) # community + self.assertIn("community", teams["wg-leads"]["repos"]) + + # wg-leads in cloudfoundry2 + self.assertIn("wg-leads-cloudfoundry2", teams) + self.assertEqual(2, len(teams["wg-leads-cloudfoundry2"]["members"])) # wg4 leads + self.assertEqual(1, len(teams["wg-leads-cloudfoundry2"]["repos"])) # community + self.assertIn("community", teams["wg-leads-cloudfoundry2"]["repos"]) + + teams = o.org_cfg["orgs"]["cloudfoundry2"]["teams"] + # wg-leads, 1 WG + self.assertEqual(1 + 1, len(teams)) + self.assertIn("wg-wg4-name", teams) + self.assertEqual(2, len(teams["wg-wg4-name"]["maintainers"])) # wg4 leads + self.assertEqual(3, len(teams["wg-wg4-name"]["members"])) # wg4 approvers + self.assertEqual(6, len(teams["wg-wg4-name"]["teams"])) # leads, bots, area1 appr, area2 appr, reviewers, bots + + self.assertIn("wg-leads", teams) + self.assertEqual(2, len(teams["wg-leads"]["members"])) # wg4 leads + self.assertNotIn("repos", teams["wg-leads"]) # community repo is in cf org not in cf2 def test_validate_branch_protection(self): OrgGenerator._validate_branch_protection(OrgGenerator._yaml_load(branch_protection)) with self.assertRaises(jsonschema.ValidationError): OrgGenerator._validate_branch_protection({}) + # multiple orgs + with self.assertRaises(ValueError): + OrgGenerator._validate_branch_protection(OrgGenerator._yaml_load(branch_protection_multiple_orgs)) + OrgGenerator._MANAGED_ORGS = ["cloudfoundry", "cloudfoundry2"] + OrgGenerator._validate_branch_protection(OrgGenerator._yaml_load(branch_protection_multiple_orgs)) + def test_get_default_branch(self): o = OrgGenerator(static_org_cfg=org_cfg) - self.assertEqual("main", o._get_default_branch("repo1")) - self.assertEqual("defbranch", o._get_default_branch("repo3")) + self.assertEqual("main", o._get_default_branch("cloudfoundry", "repo1")) + self.assertEqual("defbranch", o._get_default_branch("cloudfoundry", "repo3")) # trouble ahead: new repos get main as default branch (github config) # peribolos assumes master as default branch, at least when reading repo config - self.assertEqual("master", o._get_default_branch("repo5")) - self.assertEqual("master", o._get_default_branch("unknown")) + self.assertEqual("master", o._get_default_branch("cloudfoundry", "repo5")) + self.assertEqual("master", o._get_default_branch("cloudfoundry", "unknown")) def test_generate_wg_branch_protection(self): o = OrgGenerator(static_org_cfg=org_cfg, branch_protection=branch_protection) _wg1 = OrgGenerator._yaml_load(wg1) - repos_bp = o._generate_wb_branch_protection(_wg1) + OrgGenerator._validate_wg(_wg1) + repos_bp = o._generate_wg_branch_protection(_wg1) self.assertEqual(4, len(repos_bp)) self.assertSetEqual({"repo1", "repo2", "repo3", "repo4"}, set(repos_bp.keys())) pr_reviews = repos_bp["repo1"]["required_pull_request_reviews"] @@ -453,7 +707,8 @@ def test_generate_wg_branch_protection(self): self.assertListEqual(["^defbranch$", "^v[0-9]*$"], repos_bp["repo3"]["include"]) _wg3 = OrgGenerator._yaml_load(wg3) - repos_bp = o._generate_wb_branch_protection(_wg3) + OrgGenerator._validate_wg(_wg3) + repos_bp = o._generate_wg_branch_protection(_wg3) self.assertEqual(5, len(repos_bp)) pr_reviews = repos_bp["repo1"]["required_pull_request_reviews"] self.assertEqual(0, pr_reviews["required_approving_review_count"]) @@ -482,20 +737,50 @@ def test_generate_branch_protection(self): self.assertTrue(bp_repos["repo1"]["protect"]) self.assertNotIn("required_pull_request_reviews", bp_repos["repo1"]) - # integration test, depends on data in this repo which may change + def test_generate_branch_protection_multiple_orgs(self): + OrgGenerator._MANAGED_ORGS = ["cloudfoundry", "cloudfoundry2"] + o = OrgGenerator( + static_org_cfg=org_cfg_multiple, + toc=toc, + working_groups=[wg1, wg2, wg3, wg4_other_org], + branch_protection=branch_protection_multiple_orgs, + ) + o.generate_branch_protection() + bp_repos = o.branch_protection["branch-protection"]["orgs"]["cloudfoundry"]["repos"] + # TOC and wg3 opted in, wg1 and wg2 not + # note: repo1..4 are shared between wg1 (opt out) and wg3 (opt in) - wg3 wins + self.assertSetEqual({f"repo{i}" for i in range(1, 6)} | {"community"}, set(bp_repos.keys())) + # repo1 has static config that wins over generated branch protection rules + self.assertTrue(bp_repos["repo1"]["protect"]) + self.assertNotIn("required_pull_request_reviews", bp_repos["repo1"]) + + bp_repos = o.branch_protection["branch-protection"]["orgs"]["cloudfoundry2"]["repos"] + # wg4 opted in, repo5 is ignored because of wrong org + self.assertSetEqual({f"repo{i}" for i in range(1, 5)}, set(bp_repos.keys())) + # repo1 has static config that wins over generated branch protection rules + self.assertTrue(bp_repos["repo1"]["protect"]) + self.assertNotIn("required_pull_request_reviews", bp_repos["repo1"]) + + +# integration test, depends on data in this repo which may change +class TestOrgGeneratorIntegrationTest(unittest.TestCase): def test_cf_org(self): + self.assertEqual(["cloudfoundry"], OrgGenerator._MANAGED_ORGS) + o = OrgGenerator() o.load_from_project() - assert o.toc is not None + self.assertEqual(1, len(o.org_cfg["orgs"])) + self.assertEqual("cloudfoundry", o.toc_org) self.assertEqual("Technical Oversight Committee", o.toc["name"]) - self.assertGreater(len(o.contributors), 100) - self.assertGreater(len(o.working_groups), 5) - self.assertEqual(1, len([wg for wg in o.working_groups if "Admin" in wg["name"]])) - self.assertEqual(1, len([wg for wg in o.working_groups if "Deployments" in wg["name"]])) + self.assertGreater(len(o.contributors["cloudfoundry"]), 100) + cf_wgs = o.working_groups["cloudfoundry"] + self.assertGreater(len(cf_wgs), 5) + self.assertEqual(1, len([wg for wg in cf_wgs if "Admin" in wg["name"]])) + self.assertEqual(1, len([wg for wg in cf_wgs if "Deployments" in wg["name"]])) # packeto WG charter has no yaml block - self.assertEqual(0, len([wg for wg in o.working_groups if "packeto" in wg["name"].lower()])) + self.assertEqual(0, len([wg for wg in cf_wgs if "packeto" in wg["name"].lower()])) # no WGs without execution leads - self.assertEqual(0, len([wg for wg in o.working_groups if len(wg["execution_leads"]) == 0])) + self.assertEqual(0, len([wg for wg in cf_wgs if len(wg["execution_leads"]) == 0])) # branch protection self.assertIn("cloudfoundry", o.branch_protection["branch-protection"]["orgs"]) @@ -520,6 +805,7 @@ def test_cf_org(self): self.assertEqual(5, len(teams["toc"]["maintainers"])) self.assertIn("community", teams["toc"]["repos"]) self.assertIn("wg-leads", teams) + self.assertIn("community", teams["wg-leads"]["repos"]) o.generate_branch_protection() bp_repos = o.branch_protection["branch-protection"]["orgs"]["cloudfoundry"]["repos"]