|
|
|
#!/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<path>.*):[0-9]+:[0-9]+: (?P<config>.*) is scheduled for removal in (?P<version>[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<path>.*):[0-9]+:[0-9]+: UPDATE (?P<package>.*) from (?P<old>[0-9.]+) to (?P<new>[0-9.]+) (?P<link>https://.*)$', 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()
|