diff --git a/.github/workflows/assigner.yml b/.github/workflows/assigner.yml index f5bb122ceb4b2..1cadb71c04e52 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: > @@ -62,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 @@ -71,4 +72,13 @@ 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: > + 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 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 d14a2e1973cfe..9618fab963a42 100644 --- a/MAINTAINERS.yml +++ b/MAINTAINERS.yml @@ -60,6 +60,34 @@ # 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. +# +# 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: +# - +# - +# files: +# - +# - +# files-regex: +# - +# - +# files-exclude: +# - +# - +# files-regex-exclude: +# - +# - +# # description: >- # Plain-English description. Describe what the system is about, from an # outsider's perspective. @@ -948,7 +976,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" @@ -3264,7 +3292,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" @@ -3446,13 +3474,13 @@ NXP Platform Drivers: - decsny - manuargue - dbaluta - - Raymond0225 + - Holt-Sun + - zejiang0jason files-regex: - ^drivers/.*nxp.* - ^drivers/.*mcux.* - drivers\/.*[_\/]+lpc[_\.\d]+.*c files: - - drivers/*/*imx* - drivers/*/*mcux*.c - drivers/*/*.mcux - drivers/*/*.nxp @@ -3463,7 +3491,6 @@ NXP Platform Drivers: - include/zephyr/dt-bindings/*/*nxp* - include/zephyr/dt-bindings/*/*mcux* - include/zephyr/dt-bindings/inputmux/ - - include/zephyr/dt-bindings/rdc/ - include/zephyr/drivers/*/*nxp* - include/zephyr/drivers/*/*nxp*/ - include/zephyr/drivers/*/*mcux* @@ -3473,33 +3500,23 @@ NXP Platform Drivers: files-exclude: - drivers/wifi/ - drivers/bluetooth/ - - drivers/usb/ files-regex-exclude: - .*s32.* + file-groups: + - name: NXP USB + collaborators: + - mmahadevan108 + - MarkWangChinese + files: + - drivers/usb/ labels: - "platform: NXP" description: NXP Drivers -NXP Platform MCUX USB: - status: maintained - maintainers: - - mmahadevan108 - - MarkWangChinese - files: - - drivers/usb/*/*mcux* - - boards/nxp/usb_kw24d512/ - labels: - - "platform: NXP" - description: NXP MCUX USB shim drivers - NXP Platform Wireless: status: maintained maintainers: - dleach02 - collaborators: - - MaochenWang1 - - axelnxp - - George-Stefan files: - boards/nxp/*mcxw*/ - boards/nxp/*rw*/ @@ -3514,6 +3531,43 @@ NXP Platform Wireless: - samples/net/**/*rw* - soc/nxp/mcx/mcxw/ - soc/nxp/rw/ + file-groups: + - name: NXP BLE + collaborators: + - axelnxp + - yeaissa + files: + - drivers/bluetooth/ + - samples/bluetooth/ + - name: NXP Wifi + collaborators: + - MaochenWang1 + files: + - drivers/wifi/ + - samples/net/ + - name: NXP IEEE802.15.4 + collaborators: + - George-Stefan + files: + - drivers/hdlc_rcp_if/ + - drivers/ieee802154/ + - soc/ + - name: MCXW platform + collaborators: + - EmilioCBen + - decsny + - axelnxp + files: + - boards/nxp/*mcxw*/ + - soc/nxp/mcx/mcxw/ + - name: RW6xx platform + collaborators: + - decsny + - MaochenWang1 + - axelnxp + files: + - soc/nxp/rw/ + - boards/nxp/*rw*/ labels: - "platform: NXP" @@ -3523,8 +3577,6 @@ NXP Platforms (MCU): - dleach02 - mmahadevan108 collaborators: - - DerekSnell - - EmilioCBen - decsny - butok files: @@ -3545,11 +3597,76 @@ NXP Platforms (MCU): - soc/nxp/mcx/ - dts/arm/nxp/ - samples/boards/nxp*/ + - boards/nxp/vmu*/ + - boards/nxp/rddrone_fmuk66/ + - tests/boards/vmu_rt1170/ files-exclude: - dts/arm/nxp/nxp_imx* - boards/nxp/frdm_imx*/ files-regex-exclude: - .*s32.* + file-groups: + - name: NXP RT + collaborators: + - lucien-nxp + - Raymond0225 + files: + - boards/nxp/mimxrt*/ + - soc/nxp/imxrt/ + - dts/arm/nxp/*rt* + - name: NXP MCX + collaborators: + - peterwangsz + - NeilChen93 + - jacob-wienecke-nxp + files: + - boards/nxp/frdm_mcx*/ + - soc/nxp/mcx/ + - dts/arm/nxp/*mcx* + - boards/nxp/mcx_*/ + - name: NXP Kinetis + collaborators: + - EmilioCBen + files: + - boards/nxp/frdm_k*/ + - soc/nxp/kinetis/ + - dts/arm/nxp/nxp_k* + - name: NXP LPC + collaborators: + - EmilioCBen + files: + - boards/nxp/lpc*/ + - soc/nxp/lpc/ + - dts/arm/nxp/nxp_lpc* + - name: NXP MCU Xtensa + collaborators: + - iuliana-prodan + - TomasBarakNXP + - VitekST + files: + - soc/nxp/imxrt/*/f1/ + - soc/nxp/imxrt/*/hifi*/ + - soc/nxp/imxrt/imxrt[567]xx/CMakeLists.txt + - soc/nxp/imxrt/imxrt[567]xx/Kconfig* + - name: NXP MCU UX + collaborators: + - DerekSnell + - jacob-wienecke-nxp + files-regex: + - \.rst$ + - .*Kconfig.* + - \.conf$ + files: + - dts/bindings/ + - samples/ + - name: NXP MCU Robotics + collaborators: + - bperseghetti + - PetervdPerk-NXP + files: + - boards/nxp/vmu*/ + - boards/nxp/rddrone_fmuk66/ + - tests/boards/vmu_rt1170/ labels: - "platform: NXP MCU" - "platform: NXP" @@ -3560,12 +3677,13 @@ NXP Platforms (MPU): maintainers: - JiafeiPan collaborators: - - dleach02 - dbaluta - iuliana-prodan - yangbolu1991 - Zhiqiang-Hou files: + - drivers/*/*imx* + - include/zephyr/dt-bindings/rdc/ - dts/arm64/nxp/ - dts/arm/nxp/nxp_imx* - soc/nxp/imx/ @@ -3578,21 +3696,6 @@ NXP Platforms (MPU): - "platform: NXP" description: NXP MPU platforms -NXP Platforms (Robotics Products): - status: maintained - maintainers: - - bperseghetti - - PetervdPerk-NXP - collaborators: - - manuargue - files: - - boards/nxp/vmu*/ - - boards/nxp/rddrone_fmuk66/ - - boards/nxp/mr_canhubk3/ - - boards/nxp/ucans32k1sic/ - - tests/boards/vmu_rt1170/ - description: NXP Robotics Module Platform Products - NXP Platforms (S32): status: maintained maintainers: @@ -3612,31 +3715,20 @@ NXP Platforms (S32): - include/zephyr/dt-bindings/*/nxp-s32* - include/zephyr/dt-bindings/*/nxp_s32* - include/zephyr/drivers/*/*nxp_s32* - files-exclude: - - boards/nxp/ucans32k1sic/ + - boards/nxp/mr_canhubk3/ + file-groups: + - name: NXP S32 Robotics + collaborators: + - bperseghetti + - PetervdPerk-NXP + files: + - boards/nxp/mr_canhubk3/ + - boards/nxp/ucans32k1sic/ labels: - "platform: NXP S32" - "platform: NXP" description: NXP S32 platforms and S32-specific drivers -NXP Platforms (Xtensa): - status: maintained - maintainers: - - dbaluta - collaborators: - - iuliana-prodan - - TomasBarakNXP - files: - - soc/nxp/imx/*/adsp/ - - soc/nxp/imxrt/*/f1/ - - soc/nxp/imxrt/*/hifi*/ - - soc/nxp/imxrt/imxrt[567]xx/CMakeLists.txt - - soc/nxp/imxrt/imxrt[567]xx/Kconfig* - labels: - - "platform: NXP Xtensa" - - "platform: NXP" - description: NXP Xtensa platforms - Native_sim and POSIX arch: status: maintained maintainers: diff --git a/scripts/set_assignees.py b/scripts/ci/set_assignees.py similarity index 74% rename from scripts/set_assignees.py rename to scripts/ci/set_assignees.py index 1cf2a29867ac0..90f7da9bb42ad 100755 --- a/scripts/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}") @@ -128,6 +235,8 @@ 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 = set() + additional_reviews = set() for changed_file in fn: num_files += 1 log(f"file: {changed_file.filename}") @@ -139,13 +248,32 @@ 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.update(_area.get_collaborators_for_path(changed_file.filename)) 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: + 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 @@ -173,6 +301,9 @@ def process_pr(gh, maintainer_file, number): if 'Platform' in area.name: is_instance = True + for _area in sorted_areas: + 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}") log(f"labels: {labels}") @@ -182,7 +313,11 @@ 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)) + # add more reviewers based on maintainer file changes. + collab += list(additional_reviews) log(f"collab: {collab}") _all_maintainers = dict( 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 diff --git a/scripts/get_maintainer.py b/scripts/get_maintainer.py index 7ca14d9a77395..a396b7b128ba1 100755 --- a/scripts/get_maintainer.py +++ b/scripts/get_maintainer.py @@ -218,6 +218,29 @@ 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")) + + # 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 # files-regex area._match_fn = \ @@ -260,6 +283,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 +469,52 @@ 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. + + 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: + 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 _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)) + + def __repr__(self): + return "".format(self.name) + + class Area: """ Represents an entry for an area in MAINTAINERS.yml. @@ -447,13 +542,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 +612,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 +691,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 +711,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 +756,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 "