diff --git a/hacking/create_deprecation_bug_reports.py b/hacking/create-bulk-issues.py similarity index 59% rename from hacking/create_deprecation_bug_reports.py rename to hacking/create-bulk-issues.py index e14df4be389..d2651415df1 100755 --- a/hacking/create_deprecation_bug_reports.py +++ b/hacking/create-bulk-issues.py @@ -8,11 +8,14 @@ import abc import argparse import dataclasses import os +import pathlib import re import subprocess import sys import typing as t +import yaml + try: # noinspection PyPackageRequirements import argcomplete @@ -31,19 +34,83 @@ class Issue: summary: str body: str project: str + labels: list[str] | None = None def create(self) -> str: cmd = ['gh', 'issue', 'create', '--title', self.title, '--body', self.body, '--project', self.project] + + if self.labels: + for label in self.labels: + cmd.extend(('--label', label)) + process = subprocess.run(cmd, capture_output=True, check=True) url = process.stdout.decode().strip() return url +@dataclasses.dataclass(frozen=True) +class Feature: + title: str + summary: str + component: str + labels: list[str] | None = None + + @staticmethod + def from_dict(data: dict[str, t.Any]) -> Feature: + title = data.get('title') + summary = data.get('summary') + component = data.get('component') + labels = data.get('labels') + + if not isinstance(title, str): + raise RuntimeError(f'`title` is not `str`: {title}') + + if not isinstance(summary, str): + raise RuntimeError(f'`summary` is not `str`: {summary}') + + if not isinstance(component, str): + raise RuntimeError(f'`component` is not `str`: {component}') + + if not isinstance(labels, list) or not all(isinstance(item, str) for item in labels): + raise RuntimeError(f'`labels` is not `list[str]`: {labels}') + + return Feature( + title=title, + summary=summary, + component=component, + labels=labels, + ) + + def create_issue(self, project: str) -> Issue: + body = f''' +### Summary + +{self.summary} + +### Issue Type + +Feature Idea + +### Component Name + +`{self.component}` +''' + + return Issue( + title=self.title, + summary=self.summary, + body=body.strip(), + project=project, + labels=self.labels, + ) + + @dataclasses.dataclass(frozen=True) class BugReport: title: str summary: str component: str + labels: list[str] | None = None def create_issue(self, project: str) -> Issue: body = f''' @@ -89,6 +156,7 @@ N/A summary=self.summary, body=body.strip(), project=project, + labels=self.labels, ) @@ -174,14 +242,49 @@ TEST_OPTIONS = { @dataclasses.dataclass(frozen=True) class Args: - tests: list[str] create: bool verbose: bool + def run(self) -> None: + raise NotImplementedError() + + +@dataclasses.dataclass(frozen=True) +class DeprecationArgs(Args): + tests: list[str] + + def run(self) -> None: + deprecated_command(self) + + +@dataclasses.dataclass(frozen=True) +class FeatureArgs(Args): + source: pathlib.Path + + def run(self) -> None: + feature_command(self) + def parse_args() -> Args: parser = argparse.ArgumentParser() + create_common_arguments(parser) + + subparser = parser.add_subparsers(required=True) + + create_deprecation_parser(subparser) + create_feature_parser(subparser) + + args = invoke_parser(parser) + + return args + + +def create_deprecation_parser(subparser) -> None: + parser: argparse.ArgumentParser = subparser.add_parser('deprecation') + parser.set_defaults(type=DeprecationArgs) + parser.set_defaults(command=deprecated_command) + parser.add_argument( '--test', dest='tests', @@ -190,6 +293,25 @@ def parse_args() -> Args: help='sanity test name', ) + create_common_arguments(parser) + + +def create_feature_parser(subparser) -> None: + parser: argparse.ArgumentParser = subparser.add_parser('feature') + parser.set_defaults(type=FeatureArgs) + parser.set_defaults(command=feature_command) + + parser.add_argument( + '--source', + type=pathlib.Path, + default=pathlib.Path('issues.yml'), + help='YAML file containing issue details (default: %(default)s)', + ) + + create_common_arguments(parser) + + +def create_common_arguments(parser: argparse.ArgumentParser) -> None: parser.add_argument( '--create', action='store_true', @@ -203,20 +325,20 @@ def parse_args() -> Args: help='verbose output', ) + +def invoke_parser(parser: argparse.ArgumentParser) -> Args: if argcomplete: argcomplete.autocomplete(parser) parsed_args = parser.parse_args() - if not parsed_args.tests: - parsed_args.tests = list(TEST_OPTIONS) - kvp = {} + args_type = parsed_args.type - for field in dataclasses.fields(Args): + for field in dataclasses.fields(args_type): kvp[field.name] = getattr(parsed_args, field.name) - args = Args(**kvp) + args = args_type(**kvp) return args @@ -238,7 +360,7 @@ def run_sanity_test(test_name: str) -> list[str]: return messages -def create_issues(test_type: t.Type[Deprecation], messages: list[str]) -> list[Issue]: +def create_issues_from_deprecation_messages(test_type: t.Type[Deprecation], messages: list[str]) -> list[Issue]: deprecations = [test_type.parse(message) for message in messages] bug_reports = [deprecation.create_bug_report() for deprecation in deprecations] issues = [bug_report.create_issue(PROJECT) for bug_report in bug_reports] @@ -251,14 +373,47 @@ def info(message: str) -> None: def main() -> None: args = parse_args() + args.run() + + +def deprecated_command(args: DeprecationArgs) -> None: issues: list[Issue] = [] - for test in args.tests: + for test in args.tests or list(TEST_OPTIONS): test_type = TEST_OPTIONS[test] info(f'Running "{test}" sanity test...') messages = run_sanity_test(test) - issues.extend(create_issues(test_type, messages)) + issues.extend(create_issues_from_deprecation_messages(test_type, messages)) + + create_issues(args, issues) + + +def feature_command(args: FeatureArgs) -> None: + with args.source.open() as source_file: + source = yaml.safe_load(source_file) + + default: dict[str, t.Any] = source.get('default', {}) + features: list[dict[str, t.Any]] = source.get('features', []) + + if not isinstance(default, dict): + raise RuntimeError('`default` must be `dict[str, ...]`') + + if not isinstance(features, list): + raise RuntimeError('`features` must be `list[dict[str, ...]]`') + + issues: list[Issue] = [] + + for feature in features: + data = default.copy() + data.update(feature) + + feature = Feature.from_dict(data) + issues.append(feature.create_issue(PROJECT)) + + create_issues(args, issues) + +def create_issues(args: Args, issues: list[Issue]) -> None: if not issues: info('No issues found.') return diff --git a/test/sanity/code-smell/package-data.py b/test/sanity/code-smell/package-data.py index ddfdda09a23..97214efdcda 100644 --- a/test/sanity/code-smell/package-data.py +++ b/test/sanity/code-smell/package-data.py @@ -49,7 +49,7 @@ def assemble_files_to_ship(complete_file_list): 'hacking/cgroup_perf_recap_graph.py', 'hacking/create_deprecated_issues.py', 'hacking/deprecated_issue_template.md', - 'hacking/create_deprecation_bug_reports.py', + 'hacking/create-bulk-issues.py', 'hacking/fix_test_syntax.py', 'hacking/get_library.py', 'hacking/metadata-tool.py',