From 423faaa76d15a5a2dadfb20bcf414a82786b3e5f Mon Sep 17 00:00:00 2001 From: Anas Nashif Date: Wed, 17 Sep 2025 15:02:49 -0400 Subject: [PATCH 1/7] 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 --- scripts/get_maintainer.py | 178 +++++++++++++++++++++++++++++++++++++- 1 file changed, 175 insertions(+), 3 deletions(-) diff --git a/scripts/get_maintainer.py b/scripts/get_maintainer.py index 7ca14d9a77395..afb287e819937 100755 --- a/scripts/get_maintainer.py +++ b/scripts/get_maintainer.py @@ -218,6 +218,26 @@ def __init__(self, filename=None): area.tags = area_dict.get("tags", []) area.description = area_dict.get("description") + # Initialize file groups if present + area.file_groups = [] + if "file-groups" in area_dict: + for group_dict in area_dict["file-groups"]: + file_group = FileGroup() + file_group.name = group_dict.get("name", "Unnamed Group") + file_group.description = group_dict.get("description") + file_group.collaborators = group_dict.get("collaborators", []) + + # Create match functions for this file group + file_group._match_fn = \ + _get_match_fn(group_dict.get("files"), + group_dict.get("files-regex")) + + file_group._exclude_match_fn = \ + _get_match_fn(group_dict.get("files-exclude"), + group_dict.get("files-regex-exclude")) + + area.file_groups.append(file_group) + # area._match_fn(path) tests if the path matches files and/or # files-regex area._match_fn = \ @@ -260,6 +280,32 @@ def path2areas(self, path): return [area for area in self.areas.values() if area._contains(path)] + def path2area_info(self, path): + """ + Returns a list of tuples (Area, FileGroup) for the areas that contain 'path'. + FileGroup will be None if the path matches the area's general files rather + than a specific file group. + """ + areas = self.path2areas(path) + result = [] + + # Make directory paths end in '/' so that foo/bar matches foo/bar/. + is_dir = os.path.isdir(path) + + # Make 'path' relative to the repository root and normalize it. + path = os.path.normpath(os.path.join( + os.path.relpath(os.getcwd(), self._toplevel), + path)) + + if is_dir: + path += "/" + + for area in areas: + file_group = area.get_file_group_for_path(path) + result.append((area, file_group)) + + return result + def commits2areas(self, commits): """ Returns a set() of Area instances for the areas that contain files that @@ -420,6 +466,30 @@ def _orphaned_cmd(self, args): print(path) # We get here if we never hit the 'break' +class FileGroup: + """ + Represents a file group within an area in MAINTAINERS.yml. + + These attributes are available: + + name: + The name of the file group, as specified in the 'name' key + + description: + Text from 'description' key, or None if the group has no 'description' + + collaborators: + List of collaborators specific to this file group + """ + def _contains(self, path): + # Returns True if the file group contains 'path', and False otherwise + return self._match_fn and self._match_fn(path) and not \ + (self._exclude_match_fn and self._exclude_match_fn(path)) + + def __repr__(self): + return "".format(self.name) + + class Area: """ Represents an entry for an area in MAINTAINERS.yml. @@ -447,13 +517,46 @@ class Area: description: Text from 'description' key, or None if the area has no 'description' key + + file_groups: + List of FileGroup instances for any file-groups defined in the area. + Empty if the area has no 'file-groups' key. """ def _contains(self, path): # Returns True if the area contains 'path', and False otherwise + # First check if path matches any file groups - they take precedence + for file_group in self.file_groups: + if file_group._contains(path): + return True + # If no file group matches, check area-level patterns return self._match_fn and self._match_fn(path) and not \ (self._exclude_match_fn and self._exclude_match_fn(path)) + def get_collaborators_for_path(self, path): + """ + Returns a list of collaborators for a specific path. + If the path matches a file group, returns the file group's collaborators. + Otherwise, returns the area's general collaborators. + """ + # Check file groups first + for file_group in self.file_groups: + if file_group._contains(path): + return file_group.collaborators + + # Return general area collaborators if no file group matches + return self.collaborators + + def get_file_group_for_path(self, path): + """ + Returns the FileGroup instance that contains the given path, + or None if the path doesn't match any file group. + """ + for file_group in self.file_groups: + if file_group._contains(path): + return file_group + return None + def __repr__(self): return "".format(self.name) @@ -484,6 +587,17 @@ def _print_areas(areas): ", ".join(area.tags), area.description or "")) + # Print file groups if any exist + if area.file_groups: + print("\tfile-groups:") + for file_group in area.file_groups: + print("\t\t{}: {}".format( + file_group.name, + ", ".join(file_group.collaborators) if file_group.collaborators else "no collaborators" + )) + if file_group.description: + print("\t\t description: {}".format(file_group.description)) + def _get_match_fn(globs, regexes): # Constructs a single regex that tests for matches against the globs in @@ -552,7 +666,7 @@ def ferr(msg): ok_keys = {"status", "maintainers", "collaborators", "inform", "files", "files-exclude", "files-regex", "files-regex-exclude", - "labels", "description", "tests", "tags"} + "labels", "description", "tests", "tags", "file-groups"} ok_status = {"maintained", "odd fixes", "unmaintained", "obsolete"} ok_status_s = ", ".join('"' + s + '"' for s in ok_status) # For messages @@ -572,8 +686,8 @@ def ferr(msg): ferr("bad 'status' key on area '{}', should be one of {}" .format(area_name, ok_status_s)) - if not area_dict.keys() & {"files", "files-regex"}: - ferr("either 'files' or 'files-regex' (or both) must be specified " + if not area_dict.keys() & {"files", "files-regex", "file-groups"}: + ferr("either 'files', 'files-regex', or 'file-groups' (or combinations) must be specified " "for area '{}'".format(area_name)) if not area_dict.get("maintainers") and area_dict.get("status") == "maintained": @@ -617,6 +731,64 @@ def ferr(msg): "'{}': {}".format(regex, files_regex_key, area_name, e.msg)) + # Validate file-groups structure + if "file-groups" in area_dict: + file_groups = area_dict["file-groups"] + if not isinstance(file_groups, list): + ferr("malformed 'file-groups' value for area '{}' -- should be a list" + .format(area_name)) + + ok_group_keys = {"name", "description", "collaborators", "files", + "files-exclude", "files-regex", "files-regex-exclude"} + + for i, group_dict in enumerate(file_groups): + if not isinstance(group_dict, dict): + ferr("malformed file group {} in area '{}' -- should be a dict" + .format(i, area_name)) + + for key in group_dict: + if key not in ok_group_keys: + ferr("unknown key '{}' in file group {} in area '{}'" + .format(key, i, area_name)) + + # Each file group must have either files or files-regex + if not group_dict.keys() & {"files", "files-regex"}: + ferr("file group {} in area '{}' must specify either 'files' or 'files-regex'" + .format(i, area_name)) + + # Validate string fields in file groups + for str_field in ["name", "description"]: + if str_field in group_dict and not isinstance(group_dict[str_field], str): + ferr("malformed '{}' in file group {} in area '{}' -- should be a string" + .format(str_field, i, area_name)) + + # Validate list fields in file groups + for list_field in ["collaborators", "files", "files-exclude", "files-regex", "files-regex-exclude"]: + if list_field in group_dict: + lst = group_dict[list_field] + if not (isinstance(lst, list) and all(isinstance(elm, str) for elm in lst)): + ferr("malformed '{}' in file group {} in area '{}' -- should be a list of strings" + .format(list_field, i, area_name)) + + # Validate file patterns in file groups + for files_key in "files", "files-exclude": + if files_key in group_dict: + for glob_pattern in group_dict[files_key]: + paths = tuple(root.glob(glob_pattern)) + if not paths: + ferr("glob pattern '{}' in '{}' in file group {} in area '{}' does not " + "match any files".format(glob_pattern, files_key, i, area_name)) + + # Validate regex patterns in file groups + for files_regex_key in "files-regex", "files-regex-exclude": + if files_regex_key in group_dict: + for regex in group_dict[files_regex_key]: + try: + re.compile(regex) + except re.error as e: + ferr("bad regular expression '{}' in '{}' in file group {} in area '{}': {}" + .format(regex, files_regex_key, i, area_name, e.msg)) + if "description" in area_dict and \ not isinstance(area_dict["description"], str): ferr("malformed 'description' value for area '{}' -- should be a " From 996c62b4db163e3be8863aee086033798319b491 Mon Sep 17 00:00:00 2001 From: Anas Nashif Date: Tue, 4 Nov 2025 07:18:35 -0500 Subject: [PATCH 2/7] scripts: set_assignee.py: Support file groups Deal with new section in the maintainer file defining file groups. Signed-off-by: Anas Nashif --- scripts/set_assignees.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/scripts/set_assignees.py b/scripts/set_assignees.py index 1cf2a29867ac0..436e5077ea6e9 100755 --- a/scripts/set_assignees.py +++ b/scripts/set_assignees.py @@ -128,6 +128,7 @@ def process_pr(gh, maintainer_file, number): # areas where assignment happens if only said areas are affected meta_areas = ['Release Notes', 'Documentation', 'Samples', 'Tests'] + collab_per_path = [] for changed_file in fn: num_files += 1 log(f"file: {changed_file.filename}") @@ -139,11 +140,14 @@ def process_pr(gh, maintainer_file, number): continue parsed_areas = process_manifest(old_manifest_file=args.updated_manifest) for _area in parsed_areas: + collab_per_path.extend(_area.get_collaborators_for_path(changed_file.filename)) area_match = maintainer_file.name2areas(_area) if area_match: areas.extend(area_match) else: areas = maintainer_file.path2areas(changed_file.filename) + for _area in areas: + collab_per_path.extend(_area.get_collaborators_for_path(changed_file.filename)) log(f"areas for {changed_file}: {areas}") @@ -173,6 +177,9 @@ def process_pr(gh, maintainer_file, number): if 'Platform' in area.name: is_instance = True + for _area in sorted_areas: + collab_per_path.extend(_area.get_collaborators_for_path(changed_file.filename)) + area_counter = dict(sorted(area_counter.items(), key=lambda item: item[1], reverse=True)) log(f"Area matches: {area_counter}") log(f"labels: {labels}") @@ -182,6 +189,8 @@ def process_pr(gh, maintainer_file, number): for area in area_counter: collab += maintainer_file.areas[area.name].maintainers collab += maintainer_file.areas[area.name].collaborators + collab += collab_per_path + collab = list(dict.fromkeys(collab)) log(f"collab: {collab}") From d48fdadde56ad78730a4b57db1dd2a2238a52dbb Mon Sep 17 00:00:00 2001 From: Anas Nashif Date: Tue, 4 Nov 2025 07:59:16 -0500 Subject: [PATCH 3/7] MAINTAINERS file: add documentation for file groups Document file groups and how they should be used. Signed-off-by: Anas Nashif --- MAINTAINERS.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/MAINTAINERS.yml b/MAINTAINERS.yml index d14a2e1973cfe..204509a86ed41 100644 --- a/MAINTAINERS.yml +++ b/MAINTAINERS.yml @@ -60,6 +60,28 @@ # Like 'files-regex', but any matching files will be excluded from the # area. # +# file-groups: +# A list of groups of files that are treated as a single unit. +# This is useful for areas where different collaborators are responsible for +# different parts of the area. +# Each group should have the following structure: +# - name: +# collaborators: +# - +# - +# files: +# - +# - +# files-regex: +# - +# - +# files-exclude: +# - +# - +# files-regex-exclude: +# - +# - +# # description: >- # Plain-English description. Describe what the system is about, from an # outsider's perspective. From 9622449636708b20e465f5bfa6883a17ec270bf4 Mon Sep 17 00:00:00 2001 From: Anas Nashif Date: Fri, 10 Oct 2025 10:14:14 -0400 Subject: [PATCH 4/7] ci: assigner: merge maintainer check into assigner workflow Merge two workflows into one for code sharing an efficiency. Signed-off-by: Anas Nashif --- .github/workflows/assigner.yml | 12 ++++++- .github/workflows/maintainer_check.yml | 43 -------------------------- 2 files changed, 11 insertions(+), 44 deletions(-) delete mode 100644 .github/workflows/maintainer_check.yml diff --git a/.github/workflows/assigner.yml b/.github/workflows/assigner.yml index f5bb122ceb4b2..47be3e2b6ae45 100644 --- a/.github/workflows/assigner.yml +++ b/.github/workflows/assigner.yml @@ -38,12 +38,13 @@ jobs: with: python-version: 3.12 - - name: Fetch west.yml from pull request + - name: Fetch west.yml/Maintainer.yml from pull request if: > github.event_name == 'pull_request_target' run: | git fetch origin pull/${{ github.event.pull_request.number }}/merge git show FETCH_HEAD:west.yml > pr_west.yml + git show FETCH_HEAD:MAINTAINERS.yml > pr_MAINTAINERS.yml - name: west setup if: > @@ -72,3 +73,12 @@ jobs: exit 1 fi python3 scripts/set_assignees.py $FLAGS + + - name: Check maintainer file changes + if: > + github.event_name == 'pull_request_target' + env: + GITHUB_TOKEN: ${{ secrets.ZB_PR_ASSIGNER_GITHUB_TOKEN }} + run: | + python ./scripts/ci/check_maintainer_changes.py \ + --repo zephyrproject-rtos/zephyr MAINTAINERS.yml pr_MAINTAINERS.yml diff --git a/.github/workflows/maintainer_check.yml b/.github/workflows/maintainer_check.yml deleted file mode 100644 index a44efeb1272ad..0000000000000 --- a/.github/workflows/maintainer_check.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Maintainer file check - -on: - pull_request_target: - branches: - - main - paths: - - MAINTAINERS.yml - -permissions: - contents: read - -jobs: - assignment: - name: Check MAINTAINERS.yml changes - runs-on: ubuntu-24.04 - - steps: - - name: Check out source code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - - - name: Set up Python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 - with: - python-version: 3.12 - cache: pip - cache-dependency-path: scripts/requirements-actions.txt - - - name: Install Python packages - run: | - pip install -r scripts/requirements-actions.txt --require-hashes - - - name: Fetch MAINTAINERS.yml from pull request - run: | - git fetch origin pull/${{ github.event.pull_request.number }}/merge - git show FETCH_HEAD:MAINTAINERS.yml > pr_MAINTAINERS.yml - - - name: Check maintainer file changes - env: - GITHUB_TOKEN: ${{ secrets.ZB_PR_ASSIGNER_GITHUB_TOKEN }} - run: | - python ./scripts/ci/check_maintainer_changes.py \ - --repo zephyrproject-rtos/zephyr MAINTAINERS.yml pr_MAINTAINERS.yml From 7d647214dd855bf4a89913426df5c45994f5392b Mon Sep 17 00:00:00 2001 From: Anas Nashif Date: Fri, 17 Oct 2025 07:36:32 -0400 Subject: [PATCH 5/7] scripts: move set_assignee.py into scripts/ci Scripts only used by CI, so move it into that directory. Signed-off-by: Anas Nashif --- .github/workflows/assigner.yml | 2 +- .ruff-excludes.toml | 1 + MAINTAINERS.yml | 4 ++-- scripts/{ => ci}/set_assignees.py | 0 scripts/ci/twister_ignore.txt | 2 +- 5 files changed, 5 insertions(+), 4 deletions(-) rename scripts/{ => ci}/set_assignees.py (100%) diff --git a/.github/workflows/assigner.yml b/.github/workflows/assigner.yml index 47be3e2b6ae45..c0963be64dc38 100644 --- a/.github/workflows/assigner.yml +++ b/.github/workflows/assigner.yml @@ -72,7 +72,7 @@ jobs: echo "Unknown event: ${{ github.event_name }}" exit 1 fi - python3 scripts/set_assignees.py $FLAGS + python3 scripts/ci/set_assignees.py $FLAGS - name: Check maintainer file changes if: > diff --git a/.ruff-excludes.toml b/.ruff-excludes.toml index d162d15d14074..7857825e007ce 100644 --- a/.ruff-excludes.toml +++ b/.ruff-excludes.toml @@ -1214,6 +1214,7 @@ exclude = [ "./scripts/ci/coverage/coverage_analysis.py", "./scripts/ci/errno.py", "./scripts/ci/guideline_check.py", + "./scripts/ci/set_assignees.py", "./scripts/ci/stats/merged_prs.py", "./scripts/ci/test_plan.py", "./scripts/ci/twister_report_analyzer.py", diff --git a/MAINTAINERS.yml b/MAINTAINERS.yml index 204509a86ed41..da88d774c2935 100644 --- a/MAINTAINERS.yml +++ b/MAINTAINERS.yml @@ -970,7 +970,7 @@ Continuous Integration: - scripts/make_bugs_pickle.py - .checkpatch.conf - scripts/gitlint/ - - scripts/set_assignees.py + - scripts/ci/set_assignees.py labels: - "area: Continuous Integration" @@ -3286,7 +3286,7 @@ MAINTAINERS file: files: - MAINTAINERS.yml - scripts/get_maintainer.py - - scripts/set_assignees.py + - scripts/ci/set_assignees.py - scripts/check_maintainers.py labels: - "area: MAINTAINER File" diff --git a/scripts/set_assignees.py b/scripts/ci/set_assignees.py similarity index 100% rename from scripts/set_assignees.py rename to scripts/ci/set_assignees.py diff --git a/scripts/ci/twister_ignore.txt b/scripts/ci/twister_ignore.txt index 9e5b417df538e..ee086b61f7825 100644 --- a/scripts/ci/twister_ignore.txt +++ b/scripts/ci/twister_ignore.txt @@ -52,7 +52,7 @@ scripts/checkpatch.pl scripts/ci/pylintrc scripts/footprint/* scripts/make_bugs_pickle.py -scripts/set_assignees.py +scripts/ci/set_assignees.py scripts/gitlint/zephyr_commit_rules.py scripts/west_commands/runners/canopen_program.py scripts/ci/check_maintainer_changes.py From d9f6c386b00d217e57ab77a594e5755284e8a716 Mon Sep 17 00:00:00 2001 From: Anas Nashif Date: Fri, 10 Oct 2025 10:43:00 -0400 Subject: [PATCH 6/7] scripts: set_assignee: request review from maintainers of changed areas Also request reviewes from maintainers of changes areas in the maintainer file. Signed-off-by: Anas Nashif --- .github/workflows/assigner.yml | 2 +- scripts/ci/set_assignees.py | 130 ++++++++++++++++++++++++++++++++- 2 files changed, 129 insertions(+), 3 deletions(-) diff --git a/.github/workflows/assigner.yml b/.github/workflows/assigner.yml index c0963be64dc38..1cadb71c04e52 100644 --- a/.github/workflows/assigner.yml +++ b/.github/workflows/assigner.yml @@ -63,7 +63,7 @@ jobs: FLAGS+=" -r ${{ github.event.repository.name }}" FLAGS+=" -M MAINTAINERS.yml" if [ "${{ github.event_name }}" = "pull_request_target" ]; then - FLAGS+=" -P ${{ github.event.pull_request.number }} --updated-manifest pr_west.yml" + FLAGS+=" -P ${{ github.event.pull_request.number }} --updated-manifest pr_west.yml --updated-maintainer-file pr_MAINTAINERS.yml" elif [ "${{ github.event_name }}" = "issues" ]; then FLAGS+=" -I ${{ github.event.issue.number }}" elif [ "${{ github.event_name }}" = "schedule" ]; then diff --git a/scripts/ci/set_assignees.py b/scripts/ci/set_assignees.py index 436e5077ea6e9..96f51f63daa65 100755 --- a/scripts/ci/set_assignees.py +++ b/scripts/ci/set_assignees.py @@ -9,16 +9,24 @@ import sys import time from collections import defaultdict +from pathlib import Path +import yaml from github import Auth, Github, GithubException from github.GithubException import UnknownObjectException from west.manifest import Manifest, ManifestProject TOP_DIR = os.path.join(os.path.dirname(__file__)) -sys.path.insert(0, os.path.join(TOP_DIR, "scripts")) +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from get_maintainer import Maintainers # noqa: E402 -zephyr_base = os.getenv('ZEPHYR_BASE', os.path.join(TOP_DIR, '..')) +ZEPHYR_BASE = os.environ.get('ZEPHYR_BASE') +if ZEPHYR_BASE: + ZEPHYR_BASE = Path(ZEPHYR_BASE) +else: + ZEPHYR_BASE = Path(__file__).resolve().parents[2] + # Propagate this decision to child processes. + os.environ['ZEPHYR_BASE'] = str(ZEPHYR_BASE) def log(s): @@ -71,10 +79,22 @@ def parse_args(): help="Updated manifest file to compare against current west.yml", ) + parser.add_argument( + "--updated-maintainer-file", + default=None, + help="Updated maintainer file to compare against current MAINTAINERS.yml", + ) + parser.add_argument("-v", "--verbose", action="count", default=0, help="Verbose Output") args = parser.parse_args() +def load_areas(filename: str): + with open(filename) as f: + doc = yaml.safe_load(f) + return { + k: v for k, v in doc.items() if isinstance(v, dict) and ("files" in v or "files-regex" in v) + } def process_manifest(old_manifest_file): log("Processing manifest changes") @@ -104,6 +124,93 @@ def process_manifest(old_manifest_file): log(f'manifest areas: {areas}') return areas +def set_or_empty(d, key): + return set(d.get(key, []) or []) + +def compare_areas(old, new, repo_fullname=None, token=None): + old_areas = set(old.keys()) + new_areas = set(new.keys()) + + changed_areas = set() + added_areas = new_areas - old_areas + removed_areas = old_areas - new_areas + common_areas = old_areas & new_areas + + print("=== Areas Added ===") + for area in sorted(added_areas): + print(f"+ {area}") + + print("\n=== Areas Removed ===") + for area in sorted(removed_areas): + print(f"- {area}") + + print("\n=== Area Changes ===") + for area in sorted(common_areas): + changes = [] + old_entry = old[area] + new_entry = new[area] + + # Compare maintainers + old_maint = set_or_empty(old_entry, "maintainers") + new_maint = set_or_empty(new_entry, "maintainers") + added_maint = new_maint - old_maint + removed_maint = old_maint - new_maint + if added_maint: + changes.append(f" Maintainers added: {', '.join(sorted(added_maint))}") + if removed_maint: + changes.append(f" Maintainers removed: {', '.join(sorted(removed_maint))}") + + # Compare collaborators + old_collab = set_or_empty(old_entry, "collaborators") + new_collab = set_or_empty(new_entry, "collaborators") + added_collab = new_collab - old_collab + removed_collab = old_collab - new_collab + if added_collab: + changes.append(f" Collaborators added: {', '.join(sorted(added_collab))}") + if removed_collab: + changes.append(f" Collaborators removed: {', '.join(sorted(removed_collab))}") + + # Compare status + old_status = old_entry.get("status") + new_status = new_entry.get("status") + if old_status != new_status: + changes.append(f" Status changed: {old_status} -> {new_status}") + + # Compare labels + old_labels = set_or_empty(old_entry, "labels") + new_labels = set_or_empty(new_entry, "labels") + added_labels = new_labels - old_labels + removed_labels = old_labels - new_labels + if added_labels: + changes.append(f" Labels added: {', '.join(sorted(added_labels))}") + if removed_labels: + changes.append(f" Labels removed: {', '.join(sorted(removed_labels))}") + + # Compare files + old_files = set_or_empty(old_entry, "files") + new_files = set_or_empty(new_entry, "files") + added_files = new_files - old_files + removed_files = old_files - new_files + if added_files: + changes.append(f" Files added: {', '.join(sorted(added_files))}") + if removed_files: + changes.append(f" Files removed: {', '.join(sorted(removed_files))}") + + # Compare files-regex + old_regex = set_or_empty(old_entry, "files-regex") + new_regex = set_or_empty(new_entry, "files-regex") + added_regex = new_regex - old_regex + removed_regex = old_regex - new_regex + if added_regex: + changes.append(f" files-regex added: {', '.join(sorted(added_regex))}") + if removed_regex: + changes.append(f" files-regex removed: {', '.join(sorted(removed_regex))}") + + if changes: + changed_areas.add(area) + print(f"area changed: {area}") + + return added_areas | removed_areas | changed_areas def process_pr(gh, maintainer_file, number): gh_repo = gh.get_repo(f"{args.org}/{args.repo}") @@ -129,6 +236,7 @@ def process_pr(gh, maintainer_file, number): meta_areas = ['Release Notes', 'Documentation', 'Samples', 'Tests'] collab_per_path = [] + additional_reviews = set() for changed_file in fn: num_files += 1 log(f"file: {changed_file.filename}") @@ -144,6 +252,22 @@ def process_pr(gh, maintainer_file, number): area_match = maintainer_file.name2areas(_area) if area_match: areas.extend(area_match) + elif changed_file.filename in ['MAINTAINERS.yml']: + areas = maintainer_file.path2areas(changed_file.filename) + if args.updated_maintainer_file: + log( + "cannot process MAINTAINERS.yml changes, skipping..." + ) + + old_areas = load_areas(args.updated_maintainer_file) + new_areas = load_areas('MAINTAINERS.yml') + changed_areas = compare_areas(old_areas, new_areas) + for _area in changed_areas: + area_match = maintainer_file.name2areas(_area) + if area_match: + # get list of maintainers for changed area + additional_reviews.update(maintainer_file.areas[_area].maintainers) + log(f"MAINTAINERS.yml changed, adding reviewrs: {additional_reviews}") else: areas = maintainer_file.path2areas(changed_file.filename) for _area in areas: @@ -192,6 +316,8 @@ def process_pr(gh, maintainer_file, number): collab += collab_per_path collab = list(dict.fromkeys(collab)) + # add more reviewers based on maintainer file changes. + collab += list(additional_reviews) log(f"collab: {collab}") _all_maintainers = dict( From fc32da08ea4f69585bdf0d435270236399b0c968 Mon Sep 17 00:00:00 2001 From: Anas Nashif Date: Fri, 7 Nov 2025 05:24:20 -0500 Subject: [PATCH 7/7] scripts: get_maintainer: file group pattern inherit top area patterns File groups inherit file patterns from their parent area. A file will only match a file group if it first matches the parent area's patterns, and then also matches the file group's own patterns. This allows file groups to further filter and subdivide files that are already covered by the area. Signed-off-by: Anas Nashif --- MAINTAINERS.yml | 6 ++++++ scripts/ci/set_assignees.py | 10 +++++----- scripts/get_maintainer.py | 25 +++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/MAINTAINERS.yml b/MAINTAINERS.yml index da88d774c2935..65c7b7b41f825 100644 --- a/MAINTAINERS.yml +++ b/MAINTAINERS.yml @@ -64,6 +64,12 @@ # A list of groups of files that are treated as a single unit. # This is useful for areas where different collaborators are responsible for # different parts of the area. +# +# File groups inherit file patterns from their parent area. A file will only +# match a file group if it first matches the parent area's patterns, and then +# also matches the file group's own patterns. This allows file groups to +# further filter and subdivide files that are already covered by the area. +# # Each group should have the following structure: # - name: # collaborators: diff --git a/scripts/ci/set_assignees.py b/scripts/ci/set_assignees.py index 96f51f63daa65..90f7da9bb42ad 100755 --- a/scripts/ci/set_assignees.py +++ b/scripts/ci/set_assignees.py @@ -235,7 +235,7 @@ def process_pr(gh, maintainer_file, number): # areas where assignment happens if only said areas are affected meta_areas = ['Release Notes', 'Documentation', 'Samples', 'Tests'] - collab_per_path = [] + collab_per_path = set() additional_reviews = set() for changed_file in fn: num_files += 1 @@ -248,7 +248,7 @@ def process_pr(gh, maintainer_file, number): continue parsed_areas = process_manifest(old_manifest_file=args.updated_manifest) for _area in parsed_areas: - collab_per_path.extend(_area.get_collaborators_for_path(changed_file.filename)) + collab_per_path.update(_area.get_collaborators_for_path(changed_file.filename)) area_match = maintainer_file.name2areas(_area) if area_match: areas.extend(area_match) @@ -271,9 +271,9 @@ def process_pr(gh, maintainer_file, number): else: areas = maintainer_file.path2areas(changed_file.filename) for _area in areas: - collab_per_path.extend(_area.get_collaborators_for_path(changed_file.filename)) + collab_per_path.update(_area.get_collaborators_for_path(changed_file.filename)) - log(f"areas for {changed_file}: {areas}") + log(f" areas: {areas}") if not areas: continue @@ -302,7 +302,7 @@ def process_pr(gh, maintainer_file, number): is_instance = True for _area in sorted_areas: - collab_per_path.extend(_area.get_collaborators_for_path(changed_file.filename)) + collab_per_path.update(_area.get_collaborators_for_path(changed_file.filename)) area_counter = dict(sorted(area_counter.items(), key=lambda item: item[1], reverse=True)) log(f"Area matches: {area_counter}") diff --git a/scripts/get_maintainer.py b/scripts/get_maintainer.py index afb287e819937..a396b7b128ba1 100755 --- a/scripts/get_maintainer.py +++ b/scripts/get_maintainer.py @@ -236,6 +236,9 @@ def __init__(self, filename=None): _get_match_fn(group_dict.get("files-exclude"), group_dict.get("files-regex-exclude")) + # Store reference to parent area for inheritance + file_group._parent_area = area + area.file_groups.append(file_group) # area._match_fn(path) tests if the path matches files and/or @@ -470,6 +473,11 @@ class FileGroup: """ Represents a file group within an area in MAINTAINERS.yml. + File groups inherit file patterns from their parent area. A file will only + match a file group if it first matches the parent area's patterns, and then + also matches the file group's own patterns. This allows file groups to + further filter and subdivide files that are already covered by the area. + These attributes are available: name: @@ -481,8 +489,25 @@ class FileGroup: collaborators: List of collaborators specific to this file group """ + def _parent_area_contains(self, path): + """ + Returns True if the parent area contains 'path', False otherwise. + """ + return (self._parent_area._match_fn and + self._parent_area._match_fn(path) and not + (self._parent_area._exclude_match_fn and + self._parent_area._exclude_match_fn(path))) + def _contains(self, path): # Returns True if the file group contains 'path', and False otherwise + # File groups inherit from their parent area - a file must match the + # parent area's patterns first, then the file group's patterns + + # First check if the path matches the parent area's patterns + if not self._parent_area_contains(path): + return False + + # Then check if it matches this file group's patterns return self._match_fn and self._match_fn(path) and not \ (self._exclude_match_fn and self._exclude_match_fn(path))