Skip to content

Commit a9fdb66

Browse files
committed
scripts: get_maintainer: support file groups
This new section allows defining a group of files in an area and makes it possible to assign collaborators to the file group being defined. The purpose of this new section is to allow fine tuning who is added as reviewer when files change in a group. It is especially useful in large areas with hundreds of files, for example platform areas. Signed-off-by: Anas Nashif <anas.nashif@intel.com>
1 parent b38054b commit a9fdb66

File tree

1 file changed

+175
-3
lines changed

1 file changed

+175
-3
lines changed

scripts/get_maintainer.py

Lines changed: 175 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,26 @@ def __init__(self, filename=None):
218218
area.tags = area_dict.get("tags", [])
219219
area.description = area_dict.get("description")
220220

221+
# Initialize file groups if present
222+
area.file_groups = []
223+
if "file-groups" in area_dict:
224+
for group_dict in area_dict["file-groups"]:
225+
file_group = FileGroup()
226+
file_group.name = group_dict.get("name", "Unnamed Group")
227+
file_group.description = group_dict.get("description")
228+
file_group.collaborators = group_dict.get("collaborators", [])
229+
230+
# Create match functions for this file group
231+
file_group._match_fn = \
232+
_get_match_fn(group_dict.get("files"),
233+
group_dict.get("files-regex"))
234+
235+
file_group._exclude_match_fn = \
236+
_get_match_fn(group_dict.get("files-exclude"),
237+
group_dict.get("files-regex-exclude"))
238+
239+
area.file_groups.append(file_group)
240+
221241
# area._match_fn(path) tests if the path matches files and/or
222242
# files-regex
223243
area._match_fn = \
@@ -260,6 +280,32 @@ def path2areas(self, path):
260280
return [area for area in self.areas.values()
261281
if area._contains(path)]
262282

283+
def path2area_info(self, path):
284+
"""
285+
Returns a list of tuples (Area, FileGroup) for the areas that contain 'path'.
286+
FileGroup will be None if the path matches the area's general files rather
287+
than a specific file group.
288+
"""
289+
areas = self.path2areas(path)
290+
result = []
291+
292+
# Make directory paths end in '/' so that foo/bar matches foo/bar/.
293+
is_dir = os.path.isdir(path)
294+
295+
# Make 'path' relative to the repository root and normalize it.
296+
path = os.path.normpath(os.path.join(
297+
os.path.relpath(os.getcwd(), self._toplevel),
298+
path))
299+
300+
if is_dir:
301+
path += "/"
302+
303+
for area in areas:
304+
file_group = area.get_file_group_for_path(path)
305+
result.append((area, file_group))
306+
307+
return result
308+
263309
def commits2areas(self, commits):
264310
"""
265311
Returns a set() of Area instances for the areas that contain files that
@@ -420,6 +466,30 @@ def _orphaned_cmd(self, args):
420466
print(path) # We get here if we never hit the 'break'
421467

422468

469+
class FileGroup:
470+
"""
471+
Represents a file group within an area in MAINTAINERS.yml.
472+
473+
These attributes are available:
474+
475+
name:
476+
The name of the file group, as specified in the 'name' key
477+
478+
description:
479+
Text from 'description' key, or None if the group has no 'description'
480+
481+
collaborators:
482+
List of collaborators specific to this file group
483+
"""
484+
def _contains(self, path):
485+
# Returns True if the file group contains 'path', and False otherwise
486+
return self._match_fn and self._match_fn(path) and not \
487+
(self._exclude_match_fn and self._exclude_match_fn(path))
488+
489+
def __repr__(self):
490+
return "<FileGroup {}>".format(self.name)
491+
492+
423493
class Area:
424494
"""
425495
Represents an entry for an area in MAINTAINERS.yml.
@@ -447,13 +517,46 @@ class Area:
447517
description:
448518
Text from 'description' key, or None if the area has no 'description'
449519
key
520+
521+
file_groups:
522+
List of FileGroup instances for any file-groups defined in the area.
523+
Empty if the area has no 'file-groups' key.
450524
"""
451525
def _contains(self, path):
452526
# Returns True if the area contains 'path', and False otherwise
527+
# First check if path matches any file groups - they take precedence
528+
for file_group in self.file_groups:
529+
if file_group._contains(path):
530+
return True
453531

532+
# If no file group matches, check area-level patterns
454533
return self._match_fn and self._match_fn(path) and not \
455534
(self._exclude_match_fn and self._exclude_match_fn(path))
456535

536+
def get_collaborators_for_path(self, path):
537+
"""
538+
Returns a list of collaborators for a specific path.
539+
If the path matches a file group, returns the file group's collaborators.
540+
Otherwise, returns the area's general collaborators.
541+
"""
542+
# Check file groups first
543+
for file_group in self.file_groups:
544+
if file_group._contains(path):
545+
return file_group.collaborators
546+
547+
# Return general area collaborators if no file group matches
548+
return self.collaborators
549+
550+
def get_file_group_for_path(self, path):
551+
"""
552+
Returns the FileGroup instance that contains the given path,
553+
or None if the path doesn't match any file group.
554+
"""
555+
for file_group in self.file_groups:
556+
if file_group._contains(path):
557+
return file_group
558+
return None
559+
457560
def __repr__(self):
458561
return "<Area {}>".format(self.name)
459562

@@ -484,6 +587,17 @@ def _print_areas(areas):
484587
", ".join(area.tags),
485588
area.description or ""))
486589

590+
# Print file groups if any exist
591+
if area.file_groups:
592+
print("\tfile-groups:")
593+
for file_group in area.file_groups:
594+
print("\t\t{}: {}".format(
595+
file_group.name,
596+
", ".join(file_group.collaborators) if file_group.collaborators else "no collaborators"
597+
))
598+
if file_group.description:
599+
print("\t\t description: {}".format(file_group.description))
600+
487601

488602
def _get_match_fn(globs, regexes):
489603
# Constructs a single regex that tests for matches against the globs in
@@ -552,7 +666,7 @@ def ferr(msg):
552666

553667
ok_keys = {"status", "maintainers", "collaborators", "inform", "files",
554668
"files-exclude", "files-regex", "files-regex-exclude",
555-
"labels", "description", "tests", "tags"}
669+
"labels", "description", "tests", "tags", "file-groups"}
556670

557671
ok_status = {"maintained", "odd fixes", "unmaintained", "obsolete"}
558672
ok_status_s = ", ".join('"' + s + '"' for s in ok_status) # For messages
@@ -572,8 +686,8 @@ def ferr(msg):
572686
ferr("bad 'status' key on area '{}', should be one of {}"
573687
.format(area_name, ok_status_s))
574688

575-
if not area_dict.keys() & {"files", "files-regex"}:
576-
ferr("either 'files' or 'files-regex' (or both) must be specified "
689+
if not area_dict.keys() & {"files", "files-regex", "file-groups"}:
690+
ferr("either 'files', 'files-regex', or 'file-groups' (or combinations) must be specified "
577691
"for area '{}'".format(area_name))
578692

579693
if not area_dict.get("maintainers") and area_dict.get("status") == "maintained":
@@ -617,6 +731,64 @@ def ferr(msg):
617731
"'{}': {}".format(regex, files_regex_key,
618732
area_name, e.msg))
619733

734+
# Validate file-groups structure
735+
if "file-groups" in area_dict:
736+
file_groups = area_dict["file-groups"]
737+
if not isinstance(file_groups, list):
738+
ferr("malformed 'file-groups' value for area '{}' -- should be a list"
739+
.format(area_name))
740+
741+
ok_group_keys = {"name", "description", "collaborators", "files",
742+
"files-exclude", "files-regex", "files-regex-exclude"}
743+
744+
for i, group_dict in enumerate(file_groups):
745+
if not isinstance(group_dict, dict):
746+
ferr("malformed file group {} in area '{}' -- should be a dict"
747+
.format(i, area_name))
748+
749+
for key in group_dict:
750+
if key not in ok_group_keys:
751+
ferr("unknown key '{}' in file group {} in area '{}'"
752+
.format(key, i, area_name))
753+
754+
# Each file group must have either files or files-regex
755+
if not group_dict.keys() & {"files", "files-regex"}:
756+
ferr("file group {} in area '{}' must specify either 'files' or 'files-regex'"
757+
.format(i, area_name))
758+
759+
# Validate string fields in file groups
760+
for str_field in ["name", "description"]:
761+
if str_field in group_dict and not isinstance(group_dict[str_field], str):
762+
ferr("malformed '{}' in file group {} in area '{}' -- should be a string"
763+
.format(str_field, i, area_name))
764+
765+
# Validate list fields in file groups
766+
for list_field in ["collaborators", "files", "files-exclude", "files-regex", "files-regex-exclude"]:
767+
if list_field in group_dict:
768+
lst = group_dict[list_field]
769+
if not (isinstance(lst, list) and all(isinstance(elm, str) for elm in lst)):
770+
ferr("malformed '{}' in file group {} in area '{}' -- should be a list of strings"
771+
.format(list_field, i, area_name))
772+
773+
# Validate file patterns in file groups
774+
for files_key in "files", "files-exclude":
775+
if files_key in group_dict:
776+
for glob_pattern in group_dict[files_key]:
777+
paths = tuple(root.glob(glob_pattern))
778+
if not paths:
779+
ferr("glob pattern '{}' in '{}' in file group {} in area '{}' does not "
780+
"match any files".format(glob_pattern, files_key, i, area_name))
781+
782+
# Validate regex patterns in file groups
783+
for files_regex_key in "files-regex", "files-regex-exclude":
784+
if files_regex_key in group_dict:
785+
for regex in group_dict[files_regex_key]:
786+
try:
787+
re.compile(regex)
788+
except re.error as e:
789+
ferr("bad regular expression '{}' in '{}' in file group {} in area '{}': {}"
790+
.format(regex, files_regex_key, i, area_name, e.msg))
791+
620792
if "description" in area_dict and \
621793
not isinstance(area_dict["description"], str):
622794
ferr("malformed 'description' value for area '{}' -- should be a "

0 commit comments

Comments
 (0)