#!/usr/bin/env python # PYTHON_ARGCOMPLETE_OK """Create GitHub issues for deprecated features.""" from __future__ import annotations 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 except ImportError: argcomplete = None from ansible.release import __version__ MAJOR_MINOR_VERSION = '.'.join(__version__.split('.')[:2]) PROJECT = f'ansible-core {MAJOR_MINOR_VERSION}' @dataclasses.dataclass(frozen=True) class Issue: title: str summary: str body: str project: str labels: list[str] | None = None assignee: 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)) if self.assignee: cmd.extend(('--assignee', self.assignee)) try: process = subprocess.run(cmd, capture_output=True, check=True, text=True) except subprocess.CalledProcessError as ex: print('>>> Note') print(f"You may need to run 'gh auth refresh -s project' if 'gh' reports it cannot find the project {self.project!r} when it exists.") print(f'>>> Standard Output\n{ex.stdout.strip()}\n>>> Standard Error\n{ex.stderr.strip()}\n>>> Exception') raise url = process.stdout.strip() return url @dataclasses.dataclass(frozen=True) class Feature: title: str summary: str component: str labels: list[str] | None = None assignee: 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') assignee = data.get('assignee') 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(assignee, (str, type(None))): raise RuntimeError(f'`assignee` is not `str`: {assignee}') 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, assignee=assignee, ) 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, assignee=self.assignee, ) @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''' ### Summary {self.summary} ### Issue Type Bug Report ### Component Name `{self.component}` ### Ansible Version {MAJOR_MINOR_VERSION} ### Configuration N/A ### OS / Environment N/A ### Steps to Reproduce N/A ### Expected Results N/A ### Actual Results N/A ''' return Issue( title=self.title, summary=self.summary, body=body.strip(), project=project, labels=self.labels, ) @dataclasses.dataclass(frozen=True) class Deprecation(metaclass=abc.ABCMeta): @staticmethod @abc.abstractmethod def parse(message: str) -> Deprecation: pass @abc.abstractmethod def create_bug_report(self) -> BugReport: pass @dataclasses.dataclass(frozen=True) class DeprecatedConfig(Deprecation): path: str config: str version: str @staticmethod def parse(message: str) -> DeprecatedConfig: match = re.search('^(?P.*):[0-9]+:[0-9]+: (?P.*) is scheduled for removal in (?P[0-9.]+)$', message) if not match: raise Exception(f'Unable to parse: {message}') return DeprecatedConfig( path=match.group('path'), config=match.group('config'), version=match.group('version'), ) def create_bug_report(self) -> BugReport: return BugReport( title=f'Remove deprecated {self.config}', summary=f'The config option `{self.config}` should be removed from `{self.path}`. It was scheduled for removal in {self.version}.', component=self.path, ) @dataclasses.dataclass(frozen=True) class UpdateBundled(Deprecation): path: str package: str old_version: str new_version: str json_link: str link: str = dataclasses.field(default='', init=False) def __post_init__(self) -> None: object.__setattr__(self, 'link', re.sub('/json$', '', self.json_link)) @staticmethod def parse(message: str) -> UpdateBundled: match = re.search('^(?P.*):[0-9]+:[0-9]+: UPDATE (?P.*) from (?P[0-9.]+) to (?P[0-9.]+) (?Phttps://.*)$', message) if not match: raise Exception(f'Unable to parse: {message}') return UpdateBundled( path=match.group('path'), package=match.group('package'), old_version=match.group('old'), new_version=match.group('new'), json_link=match.group('link'), ) def create_bug_report(self) -> BugReport: return BugReport( title=f'Update bundled {self.package} to {self.new_version}', summary=f'Update the bundled package [{self.package}]({self.link}) from `{self.old_version}` to `{self.new_version}`.', component=self.path, ) TEST_OPTIONS = { 'update-bundled': UpdateBundled, 'deprecated-config': DeprecatedConfig, } @dataclasses.dataclass(frozen=True) class Args: 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', choices=tuple(TEST_OPTIONS), action='append', help='sanity test name', ) create_common_arguments(parser) def create_feature_parser(subparser) -> None: epilog = """ Example source YAML: default: component: ansible-test labels: - ansible-test - feature assignee: "@me" features: - title: Some title goes here summary: A summary goes here. """ parser: argparse.ArgumentParser = subparser.add_parser('feature', epilog=epilog, formatter_class=argparse.RawDescriptionHelpFormatter) 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', help='create issues on GitHub', ) parser.add_argument( '-v', dest='verbose', action='store_true', help='verbose output', ) def invoke_parser(parser: argparse.ArgumentParser) -> Args: if argcomplete: argcomplete.autocomplete(parser) parsed_args = parser.parse_args() kvp = {} args_type = parsed_args.type for field in dataclasses.fields(args_type): kvp[field.name] = getattr(parsed_args, field.name) args = args_type(**kvp) return args def run_sanity_test(test_name: str) -> list[str]: cmd = ['ansible-test', 'sanity', '--test', test_name, '--lint', '--failure-ok'] skip_path = 'test/sanity/code-smell/skip.txt' skip_temp_path = skip_path + '.tmp' os.rename(skip_path, skip_temp_path) # make sure ansible-test isn't configured to skip any tests try: process = subprocess.run(cmd, capture_output=True, check=True) finally: os.rename(skip_temp_path, skip_path) # restore the skip entries messages = process.stdout.decode().splitlines() return messages 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] return issues def info(message: str) -> None: print(message, file=sys.stderr) def main() -> None: args = parse_args() args.run() def deprecated_command(args: DeprecationArgs) -> None: issues: list[Issue] = [] 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_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 info(f'Found {len(issues)} issue(s) to report:') for issue in issues: info(f'[{issue.title}] {issue.summary}') if args.verbose: info('>>>') info(issue.body) info('>>>') if args.create: url = issue.create() info(url) if not args.create: info('Pass the "--create" option to create these issues on GitHub.') if __name__ == '__main__': main()