Revamp of automatically applied PR labels (#5954)

Co-authored-by: Jakub Kuczys <6032823+jack1142@users.noreply.github.com>
This commit is contained in:
Jakub Kuczys 2023-02-12 23:34:00 +01:00 committed by GitHub
parent 7e7d5322b7
commit 6c32ff58e4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 513 additions and 134 deletions

391
.github/labeler.yml vendored
View File

@ -1,188 +1,319 @@
"Category: Admin": "Category: CI":
- .github/workflows/**/*
"Category: Cogs - Admin":
# Source # Source
- redbot/cogs/admin/* - redbot/cogs/admin/*
# Docs # Docs
- docs/cog_guides/admin.rst - docs/cog_guides/admin.rst
"Category: Alias": - docs/.resources/admin/**/*
"Category: Cogs - Alias":
# Source # Source
- redbot/cogs/alias/* - redbot/cogs/alias/*
# Docs # Docs
- docs/cog_guides/alias.rst - docs/cog_guides/alias.rst
"Category: Audio Cog": # Tests
- any: - redbot/pytest/alias.py
- tests/cogs/test_alias.py
- docs/.resources/alias/**/*
"Category: Cogs - Audio":
# Source # Source
- any:
- redbot/cogs/audio/**/* - redbot/cogs/audio/**/*
- "!redbot/cogs/audio/**/locales/*"
# Docs # Docs
- docs/cog_guides/audio.rst - docs/cog_guides/audio.rst
all: "Category: Cogs - Bank": [] # historical label for a removed cog
- "!redbot/cogs/audio/**/locales/*" "Category: Cogs - Cleanup":
"Category: Bank API":
# Source
- redbot/core/bank.py
# Docs
- docs/framework_bank.rst
"Category: Bot Core":
# Source
- redbot/*
- redbot/core/__init__.py
- redbot/core/_debuginfo.py
- redbot/core/_diagnoser.py
- redbot/core/_sharedlibdeprecation.py
- redbot/core/bot.py
- redbot/core/checks.py
- redbot/core/cli.py
- redbot/core/cog_manager.py
- redbot/core/core_commands.py
- redbot/core/data_manager.py
- redbot/core/errors.py
- redbot/core/events.py
- redbot/core/global_checks.py
- redbot/core/settings_caches.py
# Docs
- docs/framework_apikeys.rst
- docs/framework_bot.rst
- docs/framework_cogmanager.rst
- docs/framework_datamanager.rst
- docs/framework_events.rst
- docs/cog_guides/cog_manager_ui.rst
- docs/cog_guides/core.rst
"Category: CI":
- .github/workflows/*
"Category: Cleanup Cog":
# Source # Source
- redbot/cogs/cleanup/* - redbot/cogs/cleanup/*
# Docs # Docs
- docs/cog_guides/cleanup.rst - docs/cog_guides/cleanup.rst
"Category: Command Module": "Category: Cogs - CustomCommands":
# Source
- any:
# Source
- redbot/core/commands/*
# Docs
- docs/framework_checks.rst
- docs/framework_commands.rst
all:
- "!redbot/core/commands/help.py"
"Category: Config":
# Source
- redbot/core/drivers/*
- redbot/core/config.py
# Docs
- docs/framework_config.rst
"Category: CustomCom":
# Source # Source
- redbot/cogs/customcom/* - redbot/cogs/customcom/*
# Docs # Docs
- docs/cog_customcom.rst - docs/cog_customcom.rst
- docs/cog_guides/customcommands.rst - docs/cog_guides/customcommands.rst
"Category: Dev Cog": "Category: Cogs - Dev":
# Source # Source
- redbot/core/dev_commands.py - redbot/core/dev_commands.py
# Docs # Docs
- docs/cog_guides/dev.rst - docs/cog_guides/dev.rst
"Category: Docs": "Category: Cogs - Downloader":
- docs/**/*
"Category: Downloader":
# Source # Source
- redbot/cogs/downloader/* - redbot/cogs/downloader/*
# Docs # Docs
- docs/cog_guides/downloader.rst - docs/cog_guides/downloader.rst
"Category: Economy Cog": # Tests
- redbot/pytest/downloader.py
- redbot/pytest/downloader_testrepo.*
- tests/cogs/downloader/**/*
"Category: Cogs - Economy":
# Source # Source
- redbot/cogs/economy/* - redbot/cogs/economy/*
# Docs # Docs
- docs/cog_guides/economy.rst - docs/cog_guides/economy.rst
"Category: Filter": # Tests
- redbot/pytest/economy.py
- tests/cogs/test_economy.py
"Category: Cogs - Filter":
# Source # Source
- redbot/cogs/filter/* - redbot/cogs/filter/*
# Docs # Docs
- docs/cog_guides/filter.rst - docs/cog_guides/filter.rst
"Category: General Cog": "Category: Cogs - General":
# Source # Source
- redbot/cogs/general/* - redbot/cogs/general/*
# Docs # Docs
- docs/cog_guides/general.rst - docs/cog_guides/general.rst
"Category: Help": "Category: Cogs - Image":
# Source
- redbot/cogs/image/*
# Docs
- docs/cog_guides/image.rst
"Category: Cogs - Mod":
# Source
- redbot/cogs/mod/*
# Docs
- docs/cog_guides/mod.rst
# Tests
- redbot/pytest/mod.py
- tests/cogs/test_mod.py
"Category: Cogs - Modlog":
# Source
- redbot/cogs/modlog/*
# Docs
- docs/cog_guides/modlog.rst
"Category: Cogs - Mutes":
# Source
- redbot/cogs/mutes/*
# Docs
- docs/cog_guides/mutes.rst
"Category: Cogs - Permissions":
# Source
- redbot/cogs/permissions/*
# Docs
- docs/cog_guides/permissions.rst
- docs/cog_permissions.rst
# Tests
- redbot/pytest/permissions.py
- tests/cogs/test_permissions.py
"Category: Cogs - Reports":
# Source
- redbot/cogs/reports/*
# Docs
- docs/cog_guides/reports.rst
"Category: Cogs - Streams":
# Source
- redbot/cogs/streams/*
# Docs
- docs/cog_guides/streams.rst
"Category: Cogs - Trivia":
# Source
- redbot/cogs/trivia/*
# Docs
- docs/cog_guides/trivia.rst
- docs/guide_trivia_list_creation.rst
- docs/.resources/trivia/**/*
# Tests
- tests/cogs/test_trivia.py
"Category: Cogs - Trivia - Lists":
- redbot/cogs/trivia/data/lists/*
"Category: Cogs - Warnings":
# Source
- redbot/cogs/warnings/*
# Docs
- docs/cog_guides/warnings.rst
"Category: Core - API - Audio": [] # potential future feature
"Category: Core - API - Bank":
# Source
- redbot/core/bank.py
# Docs
- docs/framework_bank.rst
"Category: Core - API - Commands Package":
# Source
- any:
- redbot/core/commands/*
- "!redbot/core/commands/help.py"
# this isn't in commands package but it just re-exports things from it
- redbot/core/checks.py
# Docs
- docs/framework_checks.rst
- docs/framework_commands.rst
# Tests
- tests/core/test_commands.py
"Category: Core - API - Config":
# Source
- any:
- redbot/core/drivers/**/*
- "!redbot/core/drivers/**/locales/*"
- redbot/core/config.py
# Docs
- docs/framework_config.rst
# Tests
- tests/core/test_config.py
"Category: Core - API - Other":
# Source
- redbot/__init__.py
- redbot/core/__init__.py
- redbot/core/cog_manager.py # TODO: privatize cog manager module
- redbot/core/data_manager.py
- redbot/core/errors.py
# Docs
- docs/framework_cogmanager.rst # TODO: privatize cog manager module
- docs/framework_datamanager.rst
# Tests
- redbot/pytest/cog_manager.py # TODO: privatize cog manager module
- redbot/pytest/data_manager.py
- tests/core/test_cog_manager.py
- tests/core/test_data_manager.py
- tests/core/test_version.py
"Category: Core - API - Utils Package":
# Source
- any:
- redbot/core/utils/*
- "!redbot/core/utils/_internal_utils.py"
# Docs
- docs/framework_utils.rst
# Tests
- tests/core/test_utils.py
"Category: Core - Bot Class":
# Source
- redbot/core/bot.py
# Docs
- docs/framework_apikeys.rst
- docs/framework_bot.rst
"Category: Core - Bot Commands":
# Source
- redbot/core/core_commands.py
- redbot/core/_diagnoser.py
# Docs
- docs/.resources/cog_manager_ui/**/*
- docs/cog_guides/cog_manager_ui.rst
- docs/cog_guides/core.rst
"Category: Core - Command-line Interfaces":
- redbot/__main__.py
- redbot/launcher.py
- redbot/logging.py
- redbot/core/_debuginfo.py
- redbot/core/cli.py
- redbot/setup.py
"Category: Core - Help":
- redbot/core/commands/help.py - redbot/core/commands/help.py
"Category: i18n": "Category: Core - i18n":
# Source # Source
- redbot/core/i18n.py - redbot/core/i18n.py
# Locale files # Locale files
- redbot/**/locales/* - redbot/**/locales/*
# Docs # Docs
- docs/framework_i18n.rst - docs/framework_i18n.rst
"Category: Image": "Category: Core - Modlog":
# Source
- redbot/cogs/image/*
# Docs
- docs/cog_guides/image.rst
"Category: Meta":
- ./*
- .github/*
- .github/ISSUE_TEMPLATE/*
- .github/PULL_REQUEST_TEMPLATE/*
- schema/*
- tools/*
"Category: Mod Cog":
# Source
- redbot/cogs/mod/*
# Docs
- docs/cog_guides/mod.rst
"Category: Modlog API":
# Source # Source
- redbot/core/generic_casetypes.py - redbot/core/generic_casetypes.py
- redbot/core/modlog.py - redbot/core/modlog.py
# Docs # Docs
- docs/framework_modlog.rst - docs/framework_modlog.rst
"Category: Modlog Cog": "Category: Core - Other Internals":
# Source # Source
- redbot/cogs/modlog/* - redbot/core/_sharedlibdeprecation.py
# Docs - redbot/core/events.py
- docs/cog_guides/modlog.rst - redbot/core/global_checks.py
"Category: Mutes Cog": - redbot/core/settings_caches.py
# Source - redbot/core/utils/_internal_utils.py
- redbot/cogs/mutes/* # Tests
# Docs - redbot/pytest/__init__.py
- docs/cog_guides/mutes.rst - redbot/pytest/core.py
"Category: Permissions": - tests/core/test_installation.py
# Source "Category: Core - RPC/ZMQ":
- redbot/cogs/permissions/*
# Docs
- docs/cog_guides/permissions.rst
- docs/cog_permissions.rst
"Category: Reports Cog":
# Source
- redbot/cogs/reports/*
# Docs
- docs/cog_guides/reports.rst
"Category: RPC/ZMQ API":
# Source # Source
- redbot/core/rpc.py - redbot/core/rpc.py
# Docs # Docs
- docs/framework_rpc.rst - docs/framework_rpc.rst
"Category: Streams": # Tests
# Source - redbot/pytest/rpc.py
- redbot/cogs/streams/* - tests/core/test_rpc.py
# Docs - tests/rpc_test.html
- docs/cog_guides/streams.rst
"Category: Tests":
- redbot/pytest/* "Category: Docker": [] # potential future feature
- tests/**/*
"Category: Trivia Cog":
# Source "Category: Docs - Changelogs":
- redbot/cogs/trivia/* - docs/changelog_*.rst
# Docs - docs/release_notes_*.rst
- docs/cog_guides/trivia.rst "Category: Docs - For Developers":
- docs/guide_trivia_list_creation.rst - docs/framework_events.rst
"Category: Trivia Lists": - docs/guide_cog_creation.rst
- redbot/cogs/trivia/data/lists/* - docs/guide_cog_creators.rst
"Category: Utility Functions": - docs/guide_migration.rst
# Source - docs/guide_publish_cogs.rst
- redbot/core/utils/* "Category: Docs - Install Guides":
# Docs - docs/about_venv.rst
- docs/framework_utils.rst - docs/autostart_*.rst
"Category: Warnings": - docs/.resources/bot-guide/**/*
# Source - docs/bot_application_guide.rst
- redbot/cogs/warnings/* - docs/install_guides/**/*
# Docs - docs/update_red.rst
- docs/cog_guides/warnings.rst "Category: Docs - Other":
- docs/host-list.rst
- docs/index.rst
- docs/version_guarantees.rst
- README.md
"Category: Docs - User Guides":
- docs/getting_started.rst
- docs/intents.rst
- docs/red_core_data_statement.rst
# TODO: move these to `docs/.resources/getting_started` subfolder
- docs/.resources/red-console.png
- docs/.resources/code-grant.png
- docs/.resources/instances-ssh-button.png
- docs/.resources/ssh-output.png
"Category: Meta":
# top-level files
- any:
- '*'
- '!README.md'
# .gitattributes files
- '**/.gitattributes'
# GitHub configuration files, with the exception of CI configuration
- .github/*
- .github/ISSUE_TEMPLATE/*
- .github/PULL_REQUEST_TEMPLATE/*
# documentation configuration, extensions, scripts, templates, etc.
- docs/conf.py
- docs/_ext/**/*
- docs/_html/**/*
- docs/make.bat
- docs/Makefile
- docs/prolog.txt
- docs/_templates/**/*
# empty file
- redbot/cogs/__init__.py
# can't go more meta than that :)
# TODO: remove this useless file
- redbot/meta.py
# py.typed file
- redbot/py.typed
# requirements files
- requirements/*
# schema files
- schema/*
# tests configuration, global fixtures, etc.
- tests/conftest.py
- tests/__init__.py
- tests/*/__init__.py
# repository tools
- tools/*
# "Category: RPC/ZMQ methods": [] # can't be matched by file patterns
"Category: Vendored Packages":
- redbot/vendored/**/*

View File

@ -7,8 +7,7 @@ permissions:
issues: write issues: write
jobs: jobs:
build: apply_triage_label_to_issues:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Apply Triage Label - name: Apply Triage Label

View File

@ -1,16 +1,27 @@
name: Auto Labeler - PRs name: Auto Labeler - PRs
on: on:
pull_request_target: pull_request_target:
types:
- opened
- synchronize
- reopened
- labeled
- unlabeled
permissions: permissions:
pull-requests: write pull-requests: write
jobs: jobs:
build: label_pull_requests:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Apply Type Label - name: Apply Type Label
uses: actions/labeler@v4 uses: actions/labeler@v4
with: with:
repo-token: "${{ secrets.GITHUB_TOKEN }}" repo-token: "${{ secrets.GITHUB_TOKEN }}"
sync-labels: "" # this is a temporary workaround, see #4844 sync-labels: true
- name: Label documentation-only changes.
uses: Jackenmen/label-doconly-changes@v1
env:
LDC_LABELS: Docs-only

View File

@ -0,0 +1,23 @@
name: Check label pattern exhaustiveness
on:
pull_request:
push:
jobs:
check_label_pattern_exhaustiveness:
name: Check label pattern exhaustiveness
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.8"
- name: Install script's pre-requirements
run: |
python -m pip install -U pip
python -m pip install -U pathspec pyyaml rich
- name: Check label pattern exhaustiveness
run: |
python .github/workflows/scripts/check_label_pattern_exhaustiveness.py

View File

@ -0,0 +1,215 @@
import itertools
import operator
import os
import subprocess
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
from typing_extensions import Self
import rich
import yaml
from rich.console import Console, ConsoleOptions, RenderResult
from rich.tree import Tree
from pathspec import PathSpec
from pathspec.patterns.gitwildmatch import GitWildMatchPattern
ROOT_PATH = Path(__file__).resolve().parents[3]
class Matcher:
def __init__(self, *, any: Iterable[str] = (), all: Iterable[str] = ()) -> None:
self.any_patterns = tuple(any)
self.any_specs = self._get_pathspecs(self.any_patterns)
self.all_patterns = tuple(all)
self.all_specs = self._get_pathspecs(self.all_patterns)
def __repr__(self) -> str:
return f"Matcher(any={self.any_patterns!r}, all={self.all_patterns!r})"
def __eq__(self, other: Any) -> bool:
if isinstance(other, self.__class__):
return (
self.any_patterns == other.any_patterns and self.all_patterns == other.all_patterns
)
return NotImplemented
def __hash__(self) -> int:
return hash((self.any_patterns, self.all_patterns))
@classmethod
def _get_pathspecs(cls, patterns: Iterable[str]) -> List[PathSpec]:
return tuple(
PathSpec.from_lines(GitWildMatchPattern, cls._get_pattern_lines(pattern))
for pattern in patterns
)
@staticmethod
def _get_pattern_lines(pattern: str) -> List[str]:
# an approximation of actions/labeler's minimatch globs
if pattern.startswith("!"):
pattern_lines = ["*", f"!/{pattern[1:]}"]
else:
pattern_lines = [f"/{pattern}"]
if pattern.endswith("*") and "**" not in pattern:
pattern_lines.append(f"!/{pattern}/")
return pattern_lines
@classmethod
def get_label_matchers(cls) -> Dict[str, List[Self]]:
with open(ROOT_PATH / ".github/labeler.yml", encoding="utf-8") as fp:
label_definitions = yaml.safe_load(fp)
label_matchers: Dict[str, List[Matcher]] = {}
for label_name, matcher_definitions in label_definitions.items():
matchers = label_matchers[label_name] = []
for idx, matcher_data in enumerate(matcher_definitions):
if isinstance(matcher_data, str):
matchers.append(cls(any=[matcher_data]))
elif isinstance(matcher_data, dict):
matchers.append(
cls(any=matcher_data.pop("any", []), all=matcher_data.pop("all", []))
)
if matcher_data:
raise RuntimeError(
f"Unexpected keys at index {idx} for label {label_name!r}: "
+ ", ".join(map(repr, matcher_data))
)
elif matcher_data is not None:
raise RuntimeError(f"Unexpected type at index {idx} for label {label_name!r}")
return label_matchers
class PathNode:
def __init__(self, parent_tree: Tree, path: Path, *, label: Optional[str] = None) -> None:
self.parent_tree = parent_tree
self.path = path
self.label = label
def __rich__(self) -> str:
if self.label is not None:
return self.label
return self.path.name
class DirectoryTree:
def __init__(self, label: str) -> None:
self.root = Tree(PathNode(Tree(""), Path(), label=label))
self._previous = self.root
def __bool__(self) -> bool:
return bool(self.root.children)
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
yield from self.root.__rich_console__(console, options)
def add(self, file: Path) -> Tree:
common_path = Path(os.path.commonpath([file.parent, self._previous.label.path]))
parent_tree = self._previous
while parent_tree != self.root and parent_tree.label.path != common_path:
parent_tree = parent_tree.label.parent_tree
for part in file.relative_to(common_path).parts:
if parent_tree.label.path.name == "locales":
if not parent_tree.children:
parent_tree.add(PathNode(parent_tree, parent_tree.label.path / "*.po"))
continue
parent_tree = parent_tree.add(PathNode(parent_tree, parent_tree.label.path / part))
self._previous = parent_tree
return parent_tree
class App:
def __init__(self) -> None:
self.exit_code = 0
self.label_matchers = Matcher.get_label_matchers()
self.tracked_files = [
Path(filename)
for filename in subprocess.check_output(
("git", "ls-tree", "-r", "HEAD", "--name-only"), encoding="utf-8", cwd=ROOT_PATH
).splitlines()
]
self.matches_per_label = {label_name: set() for label_name in self.label_matchers}
self.matches_per_file = []
self.used_matchers = set()
def run(self) -> int:
old_cwd = os.getcwd()
try:
os.chdir(ROOT_PATH)
self._run()
finally:
os.chdir(old_cwd)
return self.exit_code
def _run(self) -> None:
self._collect_match_information()
self._show_matches_per_label()
self._show_files_without_labels()
self._show_files_with_multiple_labels()
self._show_unused_matchers()
def _collect_match_information(self) -> None:
tmp_matches_per_file = {file: [] for file in self.tracked_files}
for file in self.tracked_files:
for label_name, matchers in self.label_matchers.items():
matched = False
for matcher in matchers:
if all(
path_spec.match_file(file)
for path_spec in itertools.chain(matcher.all_specs, matcher.any_specs)
):
self.matches_per_label[label_name].add(file)
matched = True
self.used_matchers.add(matcher)
if matched:
tmp_matches_per_file[file].append(label_name)
self.matches_per_file = sorted(tmp_matches_per_file.items(), key=operator.itemgetter(0))
def _show_matches_per_label(self) -> None:
for label_name, files in self.matches_per_label.items():
top_tree = DirectoryTree(f"{label_name}:")
for file in sorted(files):
top_tree.add(file)
rich.print(top_tree)
print()
def _show_files_without_labels(self) -> None:
top_tree = DirectoryTree("\n--- Not matched ---")
for file, labels in self.matches_per_file:
if not labels:
top_tree.add(file)
if top_tree:
self.exit_code = 1
rich.print(top_tree)
else:
print("--- All files match at least one label's patterns ---")
def _show_files_with_multiple_labels(self) -> None:
top_tree = DirectoryTree("\n--- Matched by more than one label ---")
for file, labels in self.matches_per_file:
if len(labels) > 1:
tree = top_tree.add(file)
for label_name in labels:
tree.add(label_name)
if top_tree:
rich.print(top_tree)
else:
print("--- None of the files are matched by more than one label's patterns ---")
def _show_unused_matchers(self) -> None:
for label_name, matchers in self.label_matchers.items():
for idx, matcher in enumerate(matchers):
if matcher not in self.used_matchers:
print(
f"--- Matcher {idx} for label {label_name!r} does not match any files! ---"
)
self.exit_code = 1
if __name__ == "__main__":
raise SystemExit(App().run())