1111from typing import Any
1212
1313import yaml
14+ from jsonschema import FormatChecker , validate
15+ from jsonschema .exceptions import ValidationError
1416
1517# Global files with settings for the app and org, e.g. GitHub token and org name
1618ORG_CONFIG_FILE = r"org\.ya?ml"
1719APP_CONFIG_FILE = r"app\.ya?ml"
1820TEAM_CONFIG_DIR = "teams"
1921TEAM_CONFIG_FILES = r".+\.ya?ml"
2022
23+ # Schemas for config validation
24+ APP_CONFIG_SCHEMA = {
25+ "type" : "object" ,
26+ "properties" : {
27+ "github_token" : {"type" : "string" },
28+ "github_app_id" : {"type" : "integer" },
29+ "github_app_private_key" : {"type" : "string" },
30+ "remove_members_without_team" : {"type" : "boolean" },
31+ "delete_unconfigured_teams" : {"type" : "boolean" },
32+ },
33+ "additionalProperties" : False ,
34+ }
35+ ORG_CONFIG_SCHEMA = {
36+ "type" : "object" ,
37+ "properties" : {
38+ "org_name" : {"type" : "string" },
39+ "org_owners" : {
40+ "type" : "array" ,
41+ "items" : {"type" : "string" },
42+ "minItems" : 1 ,
43+ },
44+ "defaults" : {
45+ "type" : "object" ,
46+ "properties" : {
47+ "team" : {
48+ "type" : "object" ,
49+ "properties" : {
50+ "description" : {"type" : "string" },
51+ "privacy" : {"type" : "string" , "enum" : ["secret" , "closed" ]},
52+ "notification_setting" : {
53+ "type" : "string" ,
54+ "enum" : ["notifications_enabled" , "notifications_disabled" ],
55+ },
56+ },
57+ "additionalProperties" : False ,
58+ }
59+ },
60+ "additionalProperties" : False ,
61+ },
62+ },
63+ "additionalProperties" : False ,
64+ "required" : ["org_name" , "org_owners" ],
65+ }
66+ TEAM_CONFIG_SCHEMA = {
67+ "type" : "object" ,
68+ "patternProperties" : {
69+ "^[a-zA-Z0-9 _\\ -]+$" : {
70+ "type" : "object" ,
71+ "properties" : {
72+ "description" : {"type" : "string" },
73+ "privacy" : {"type" : "string" , "enum" : ["secret" , "closed" ]},
74+ "notification_setting" : {
75+ "type" : "string" ,
76+ "enum" : ["notifications_enabled" , "notifications_disabled" ],
77+ },
78+ "maintainer" : {
79+ "oneOf" : [{"type" : "null" }, {"type" : "array" , "items" : {"type" : "string" }}]
80+ },
81+ "member" : {
82+ "oneOf" : [{"type" : "null" }, {"type" : "array" , "items" : {"type" : "string" }}]
83+ },
84+ "parent" : {"type" : "string" },
85+ "repos" : {
86+ "type" : "object" ,
87+ "propertyNames" : {"type" : "string" },
88+ "additionalProperties" : {
89+ "type" : "string" ,
90+ "enum" : ["pull" , "triage" , "push" , "maintain" , "admin" ],
91+ },
92+ },
93+ },
94+ "additionalProperties" : False ,
95+ }
96+ },
97+ "additionalProperties" : False ,
98+ "required" : [],
99+ }
100+
21101
22102def _find_matching_files (directory : str , pattern : str , only_one : bool = False ) -> list [str ]:
23103 """
@@ -85,6 +165,16 @@ def _read_config_file(file: str) -> dict:
85165 return config
86166
87167
168+ def _validate_config_schema (file : str , cfg : dict , schema : dict ) -> None :
169+ """Validate the config against a JSON schema"""
170+ try :
171+ validate (instance = cfg , schema = schema , format_checker = FormatChecker ())
172+ except ValidationError as e :
173+ logging .critical ("Config validation of file %s failed: %s" , file , e .message )
174+ raise ValueError (e ) from None
175+ logging .debug ("Config in file %s validated successfully against schema." , file )
176+
177+
88178def parse_config_files (path : str ) -> tuple [dict [str , str | dict [str , str ]], dict , dict ]:
89179 """Parse all relevant files in the configuration directory. Returns a tuple
90180 of org config, app config, and merged teams config"""
@@ -95,14 +185,17 @@ def parse_config_files(path: str) -> tuple[dict[str, str | dict[str, str]], dict
95185
96186 # Read and parse config files for app and org
97187 cfg_app = _read_config_file (cfg_app_files [0 ])
188+ _validate_config_schema (file = cfg_app_files [0 ], cfg = cfg_app , schema = APP_CONFIG_SCHEMA )
98189 cfg_org = _read_config_file (cfg_org_files [0 ])
190+ _validate_config_schema (file = cfg_org_files [0 ], cfg = cfg_org , schema = ORG_CONFIG_SCHEMA )
99191
100192 # For the teams config files, we parse and combine them as there may be multiple
101193 cfg_teams : dict [str , Any ] = {}
102194 # For this, merge the resulting dicts of the previously read files, and the current file
103195 # Compare their keys (team names). They must not be defined multiple times!
104196 for cfg_team_file in cfg_teams_files :
105197 cfg = _read_config_file (cfg_team_file )
198+ _validate_config_schema (file = cfg_team_file , cfg = cfg , schema = TEAM_CONFIG_SCHEMA )
106199 if overlap := set (cfg_teams .keys ()) & set (cfg .keys ()):
107200 logging .critical (
108201 "The config file '%s' contains keys that are also defined in "
0 commit comments