This is an automated email from the ASF dual-hosted git repository.

aw pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/yetus.git


The following commit(s) were added to refs/heads/main by this push:
     new cbaf425  YETUS-1124. Cleanup releasedocmaker source for newer pylint 
(#221)
cbaf425 is described below

commit cbaf4259eb1611ebb131c430600ce053ea99dd40
Author: Allen Wittenauer <[email protected]>
AuthorDate: Thu Oct 7 12:05:04 2021 -0700

    YETUS-1124. Cleanup releasedocmaker source for newer pylint (#221)
---
 .../src/main/python/releasedocmaker/__init__.py    | 578 +++------------------
 .../src/main/python/releasedocmaker/getversions.py |  76 +++
 .../src/main/python/releasedocmaker/jira.py        | 434 ++++++++++++++++
 .../src/main/python/releasedocmaker/utils.py       |  25 +-
 4 files changed, 590 insertions(+), 523 deletions(-)

diff --git a/releasedocmaker/src/main/python/releasedocmaker/__init__.py 
b/releasedocmaker/src/main/python/releasedocmaker/__init__.py
index e73ff8b..0ef8545 100755
--- a/releasedocmaker/src/main/python/releasedocmaker/__init__.py
+++ b/releasedocmaker/src/main/python/releasedocmaker/__init__.py
@@ -16,12 +16,12 @@
 # limitations under the License.
 """ Generate releasenotes based upon JIRA """
 
-# pylint: disable=too-many-lines
-
 import errno
 import http.client
 import json
+import logging
 import os
+import pathlib
 import re
 import shutil
 import sys
@@ -29,54 +29,23 @@ import urllib.error
 import urllib.parse
 import urllib.request
 
-from pprint import pprint
 from glob import glob
 from argparse import ArgumentParser
 from time import gmtime, strftime, sleep
 
 sys.dont_write_bytecode = True
 # pylint: disable=wrong-import-position
+from .getversions import GetVersions, PythonVersion
+from .jira import (Jira, JiraIter, Linter, RELEASE_VERSION, SORTTYPE,
+                   SORTORDER, BACKWARD_INCOMPATIBLE_LABEL, NUM_RETRIES)
 from .utils import get_jira, to_unicode, sanitize_text, processrelnote, Outputs
 # pylint: enable=wrong-import-position
 
-try:
-    import dateutil.parser
-except ImportError:
-    print("This script requires python-dateutil module to be installed. " \
-          "You can install it using:\n\t pip install python-dateutil")
-    sys.exit(1)
-
 # These are done in order of preference as to which one seems to be
 # more up-to-date at any given point in time.  And yes, it is
 # ironic that packaging is usually the last one to be
 # correct.
 
-try:
-    from pip._vendor.packaging.version import LegacyVersion as PythonVersion
-except ImportError:
-    try:
-        from setuptools._vendor.packaging.version import LegacyVersion as 
PythonVersion
-    except ImportError:
-        try:
-            from pkg_resources._vendor.packaging.version import LegacyVersion 
as PythonVersion
-        except ImportError:
-            try:
-                from packaging.version import LegacyVersion as PythonVersion
-            except ImportError:
-                print(
-                    "This script requires a packaging module to be installed.")
-                sys.exit(1)
-
-RELEASE_VERSION = {}
-
-JIRA_BASE_URL = "https://issues.apache.org/jira";
-SORTTYPE = 'resolutiondate'
-SORTORDER = 'older'
-NUM_RETRIES = 5
-
-# label to be used to mark an issue as Incompatible change.
-BACKWARD_INCOMPATIBLE_LABEL = 'backward-incompatible'
-
 EXTENSION = '.md'
 
 ASF_LICENSE = '''
@@ -104,11 +73,11 @@ def indexbuilder(title, asf_license, format_string):
     """Write an index file for later conversion using mvn site"""
     versions = glob("[0-9]*.[0-9]*")
     versions = sorted(versions, reverse=True, key=PythonVersion)
-    with open("index" + EXTENSION, "w") as indexfile:
+    with open("index" + EXTENSION, "w", encoding='utf-8') as indexfile:
         if asf_license is True:
             indexfile.write(ASF_LICENSE)
         for version in versions:
-            indexfile.write("* %s v%s\n" % (title, version))
+            indexfile.write(f"* {title} v{version}\n")
             for k in ("Changelog", "Release Notes"):
                 indexfile.write(
                     format_string %
@@ -129,447 +98,35 @@ def buildreadme(title, asf_license):
     """Write an index file for Github using README.md"""
     versions = glob("[0-9]*.[0-9]*")
     versions = sorted(versions, reverse=True, key=PythonVersion)
-    with open("README.md", "w") as indexfile:
+    with open("README.md", "w", encoding='utf-8') as indexfile:
         if asf_license is True:
             indexfile.write(ASF_LICENSE)
         for version in versions:
-            indexfile.write("* %s v%s\n" % (title, version))
+            indexfile.write(f"* {title} v{version}\n")
             for k in ("Changelog", "Release Notes"):
-                indexfile.write("    * [%s](%s/%s.%s%s)\n" %
-                                (k, version, k.upper().replace(
-                                    " ", ""), version, EXTENSION))
-
-
-class GetVersions:  # pylint: disable=too-few-public-methods
-    """ List of version strings """
-    def __init__(self, versions, projects):
-        self.newversions = []
-        versions = sorted(versions, key=PythonVersion)
-        print("Looking for %s through %s" % (versions[0], versions[-1]))
-        newversions = set()
-        for project in projects:
-            url = JIRA_BASE_URL + \
-              "/rest/api/2/project/%s/versions" % project.upper()
-            try:
-                resp = get_jira(url)
-            except (urllib.error.HTTPError, urllib.error.URLError,
-                    http.client.BadStatusLine):
-                sys.exit(1)
-
-            datum = json.loads(resp.read())
-            for data in datum:
-                newversions.add(PythonVersion(data['name']))
-        newlist = list(newversions.copy())
-        newlist.append(PythonVersion(versions[0]))
-        newlist.append(PythonVersion(versions[-1]))
-        newlist = sorted(newlist)
-        start_index = newlist.index(PythonVersion(versions[0]))
-        end_index = len(newlist) - 1 - newlist[::-1].index(
-            PythonVersion(versions[-1]))
-        for newversion in newlist[start_index + 1:end_index]:
-            if newversion in newversions:
-                print("Adding %s to the list" % newversion)
-                self.newversions.append(newversion)
-
-    def getlist(self):
-        """ Get the list of versions """
-        return self.newversions
-
-
-class Jira:
-    """A single JIRA"""
-    def __init__(self, data, parent):
-        self.key = data['key']
-        self.fields = data['fields']
-        self.parent = parent
-        self.notes = None
-        self.incompat = None
-        self.reviewed = None
-        self.important = None
-
-    def get_id(self):
-        """ get the Issue ID """
-        return to_unicode(self.key)
-
-    def get_description(self):
-        """ get the description """
-        return to_unicode(self.fields['description'])
-
-    def get_release_note(self):
-        """ get the release note field """
-        if self.notes is None:
-            field = self.parent.field_id_map['Release Note']
-            if field in self.fields:
-                self.notes = to_unicode(self.fields[field])
-            elif self.get_incompatible_change() or self.get_important():
-                self.notes = self.get_description()
-            else:
-                self.notes = ""
-        return self.notes
-
-    def get_priority(self):
-        """ Get the priority """
-        ret = ""
-        pri = self.fields['priority']
-        if pri is not None:
-            ret = pri['name']
-        return to_unicode(ret)
-
-    def get_assignee(self):
-        """ Get the assignee """
-        ret = ""
-        mid = self.fields['assignee']
-        if mid is not None:
-            ret = mid['displayName']
-        return to_unicode(ret)
-
-    def get_components(self):
-        """ Get the component(s) """
-        if self.fields['components']:
-            return ", ".join(
-                [comp['name'] for comp in self.fields['components']])
-        return ""
-
-    def get_summary(self):
-        """ Get the summary """
-        return self.fields['summary']
-
-    def get_type(self):
-        """ Get the Issue type """
-        ret = ""
-        mid = self.fields['issuetype']
-        if mid is not None:
-            ret = mid['name']
-        return to_unicode(ret)
-
-    def get_reporter(self):
-        """ Get the issue reporter """
-        ret = ""
-        mid = self.fields['reporter']
-        if mid is not None:
-            ret = mid['displayName']
-        return to_unicode(ret)
-
-    def get_project(self):
-        """ get the project """
-        ret = ""
-        mid = self.fields['project']
-        if mid is not None:
-            ret = mid['key']
-        return to_unicode(ret)
-
-    def __lt__(self, other):
-
-        if SORTTYPE == 'issueid':
-            # compare by issue name-number
-            selfsplit = self.get_id().split('-')
-            othersplit = other.get_id().split('-')
-            result = selfsplit[0] < othersplit[0]
-            if not result:
-                result = int(selfsplit[1]) < int(othersplit[1])
-                # dec is supported for backward compatibility
-                if SORTORDER in ['dec', 'desc']:
-                    result = not result
-
-        elif SORTTYPE == 'resolutiondate':
-            dts = dateutil.parser.parse(self.fields['resolutiondate'])
-            dto = dateutil.parser.parse(other.fields['resolutiondate'])
-            result = dts < dto
-            if SORTORDER == 'newer':
-                result = not result
-
-        return result
-
-    def get_incompatible_change(self):
-        """ get incompatible flag """
-        if self.incompat is None:
-            field = self.parent.field_id_map['Hadoop Flags']
-            self.reviewed = False
-            self.incompat = False
-            if field in self.fields:
-                if self.fields[field]:
-                    for flag in self.fields[field]:
-                        if flag['value'] == "Incompatible change":
-                            self.incompat = True
-                        if flag['value'] == "Reviewed":
-                            self.reviewed = True
-            else:
-                # Custom field 'Hadoop Flags' is not defined,
-                # search for 'backward-incompatible' label
-                field = self.parent.field_id_map['Labels']
-                if field in self.fields and self.fields[field]:
-                    if BACKWARD_INCOMPATIBLE_LABEL in self.fields[field]:
-                        self.incompat = True
-                        self.reviewed = True
-        return self.incompat
-
-    def get_important(self):
-        """ get important flag """
-        if self.important is None:
-            field = self.parent.field_id_map['Flags']
-            self.important = False
-            if field in self.fields:
-                if self.fields[field]:
-                    for flag in self.fields[field]:
-                        if flag['value'] == "Important":
-                            self.important = True
-        return self.important
-
-
-class JiraIter:
-    """An Iterator of JIRAs"""
-    @staticmethod
-    def collect_fields():
-        """send a query to JIRA and collect field-id map"""
-        try:
-            resp = get_jira(JIRA_BASE_URL + "/rest/api/2/field")
-            data = json.loads(resp.read())
-        except (urllib.error.HTTPError, urllib.error.URLError,
-                http.client.BadStatusLine, ValueError):
-            sys.exit(1)
-        field_id_map = {}
-        for part in data:
-            field_id_map[part['name']] = part['id']
-        return field_id_map
-
-    @staticmethod
-    def query_jira(ver, projects, pos):
-        """send a query to JIRA and collect
-        a certain number of issue information"""
-        count = 100
-        pjs = "','".join(projects)
-        jql = "project in ('%s') and \
-               fixVersion in ('%s') and \
-               resolution = Fixed" % (pjs, ver)
-        params = urllib.parse.urlencode({
-            'jql': jql,
-            'startAt': pos,
-            'maxResults': count
-        })
-        return JiraIter.load_jira(params, 0)
-
-    @staticmethod
-    def load_jira(params, fail_count):
-        """send query to JIRA and collect with retries"""
-        try:
-            resp = get_jira(JIRA_BASE_URL + "/rest/api/2/search?%s" % params)
-        except (urllib.error.URLError, http.client.BadStatusLine) as err:
-            return JiraIter.retry_load(err, params, fail_count)
-
-        try:
-            data = json.loads(resp.read())
-        except http.client.IncompleteRead as err:
-            return JiraIter.retry_load(err, params, fail_count)
-        return data
-
-    @staticmethod
-    def retry_load(err, params, fail_count):
-        """Retry connection up to NUM_RETRIES times."""
-        print(err)
-        fail_count += 1
-        if fail_count <= NUM_RETRIES:
-            print("Connection failed %d times. Retrying." % (fail_count))
-            sleep(1)
-            return JiraIter.load_jira(params, fail_count)
-        print("Connection failed %d times. Aborting." % (fail_count))
-        sys.exit(1)
+                indexfile.write(
+                    f"    * [{k}]({version}/{k.upper().replace(' ', 
'')}.{version}{EXTENSION})\n"
+                )
 
-    @staticmethod
-    def collect_jiras(ver, projects):
-        """send queries to JIRA and collect all issues
-        that belongs to given version and projects"""
-        jiras = []
-        pos = 0
-        end = 1
-        while pos < end:
-            data = JiraIter.query_jira(ver, projects, pos)
-            if 'error_messages' in data:
-                print("JIRA returns error message: %s" %
-                      data['error_messages'])
-                sys.exit(1)
-            pos = data['startAt'] + data['maxResults']
-            end = data['total']
-            jiras.extend(data['issues'])
-
-            if ver not in RELEASE_VERSION:
-                for issue in data['issues']:
-                    for fix_version in issue['fields']['fixVersions']:
-                        if 'releaseDate' in fix_version:
-                            RELEASE_VERSION[fix_version['name']] = fix_version[
-                                'releaseDate']
-        return jiras
-
-    def __init__(self, version, projects):
-        self.version = version
-        self.projects = projects
-        self.field_id_map = JiraIter.collect_fields()
-        ver = str(version).replace("-SNAPSHOT", "")
-        self.jiras = JiraIter.collect_jiras(ver, projects)
-        self.iter = self.jiras.__iter__()
-
-    def __iter__(self):
-        return self
-
-    def __next__(self):
-        """ get next """
-        data = next(self.iter)
-        j = Jira(data, self)
-        return j
-
-
-class Linter:
-    """Encapsulates lint-related functionality.
-    Maintains running lint statistics about JIRAs."""
-
-    _valid_filters = [
-        "incompatible", "important", "version", "component", "assignee"
-    ]
-
-    def __init__(self, version, options):
-        self._warning_count = 0
-        self._error_count = 0
-        self._lint_message = ""
-        self._version = version
-
-        self._filters = dict(
-            list(zip(self._valid_filters, [False] * len(self._valid_filters))))
-
-        self.enabled = False
-        self._parse_options(options)
-
-    @staticmethod
-    def add_parser_options(parser):
-        """Add Linter options to passed optparse parser."""
-        filter_string = ", ".join("'" + f + "'" for f in Linter._valid_filters)
-        parser.add_argument(
-            "-n",
-            "--lint",
-            dest="lint",
-            action="append",
-            type=str,
-            help="Specify lint filters. Valid filters are " + filter_string +
-            ". " + "'all' enables all lint filters. " +
-            "Multiple filters can be specified comma-delimited and " +
-            "filters can be negated, e.g. 'all,-component'.")
-
-    def _parse_options(self, options):
-        """Parse options from optparse."""
-
-        if options.lint is None or not options.lint:
-            return
-        self.enabled = True
-
-        # Valid filter specifications are
-        # self._valid_filters, negations, and "all"
-        valid_list = self._valid_filters
-        valid_list += ["-" + v for v in valid_list]
-        valid_list += ["all"]
-        valid = set(valid_list)
-
-        enabled = []
-        disabled = []
-
-        for opt in options.lint:
-            for token in opt.split(","):
-                if token not in valid:
-                    print("Unknown lint filter '%s', valid options are: %s" % \
-                            (token, ", ".join(v for v in sorted(valid))))
-                    sys.exit(1)
-                if token.startswith("-"):
-                    disabled.append(token[1:])
-                else:
-                    enabled.append(token)
 
-        for eopt in enabled:
-            if eopt == "all":
-                for filt in self._valid_filters:
-                    self._filters[filt] = True
-            else:
-                self._filters[eopt] = True
-        for disopt in disabled:
-            self._filters[disopt] = False
-
-    def had_errors(self):
-        """Returns True if a lint error was encountered, else False."""
-        return self._error_count > 0
-
-    def message(self):
-        """Return summary lint message suitable for printing to stdout."""
-        if not self.enabled:
-            return None
-        return self._lint_message + \
-               "\n=======================================" + \
-               "\n%s: Error:%d, Warning:%d \n" % \
-               (self._version, self._error_count, self._warning_count)
-
-    def _check_missing_component(self, jira):
-        """Return if JIRA has a 'missing component' lint error."""
-        if not self._filters["component"]:
-            return False
-
-        if jira.fields['components']:
-            return False
-        return True
-
-    def _check_missing_assignee(self, jira):
-        """Return if JIRA has a 'missing assignee' lint error."""
-        if not self._filters["assignee"]:
-            return False
-
-        if jira.fields['assignee'] is not None:
-            return False
-        return True
-
-    def _check_version_string(self, jira):
-        """Return if JIRA has a version string lint error."""
-        if not self._filters["version"]:
-            return False
-
-        field = jira.parent.field_id_map['Fix Version/s']
-        for ver in jira.fields[field]:
-            found = re.match(r'^((\d+)(\.\d+)*).*$|^(\w+\-\d+)$', ver['name'])
-            if not found:
-                return True
-        return False
-
-    def lint(self, jira):
-        """Run lint check on a JIRA."""
-        if not self.enabled:
-            return
-        if not jira.get_release_note():
-            if self._filters["incompatible"] and jira.get_incompatible_change(
-            ):
-                self._warning_count += 1
-                self._lint_message += "\nWARNING: incompatible change %s lacks 
release notes." % \
-                                (sanitize_text(jira.get_id()))
-            if self._filters["important"] and jira.get_important():
-                self._warning_count += 1
-                self._lint_message += "\nWARNING: important issue %s lacks 
release notes." % \
-                                (sanitize_text(jira.get_id()))
-
-        if self._check_version_string(jira):
-            self._warning_count += 1
-            self._lint_message += "\nWARNING: Version string problem for %s " 
% jira.get_id(
-            )
+def getversion():
+    """ print the version file"""
+    basepath = pathlib.Path(__file__).parent.resolve()
+    for versionfile in [basepath.resolve().joinpath('VERSION'),
+                        basepath.parent.parent.resolve().joinpath('VERSION')]:
+        if versionfile.exists():
+            with open(versionfile, encoding='utf-8') as ver_file:
+                version = ver_file.read()
+            return version
 
-        if self._check_missing_component(jira) or self._check_missing_assignee(
-                jira):
-            self._error_count += 1
-            error_message = []
-            if self._check_missing_component(jira):
-                error_message.append("component")
-            if self._check_missing_assignee(jira):
-                error_message.append("assignee")
-            self._lint_message += "\nERROR: missing %s for %s " \
-                            % (" and ".join(error_message), jira.get_id())
+    return 'Unknown'
 
 
 def parse_args():  # pylint: disable=too-many-branches
     """Parse command-line arguments with optparse."""
     parser = ArgumentParser(
         prog='releasedocmaker',
-        epilog=
-        "--project and --version may be given multiple times.")
+        epilog="--project and --version may be given multiple times.")
     parser.add_argument("--dirversions",
                         dest="versiondirs",
                         action="store_true",
@@ -628,14 +185,13 @@ def parse_args():  # pylint: disable=too-many-branches
         default=SORTORDER,
         # dec is supported for backward compatibility
         choices=["asc", "dec", "desc", "newer", "older"],
-        help="Sorting order for sort type (default: %s)" % SORTORDER)
+        help=f"Sorting order for sort type (default: {SORTORDER})")
     parser.add_argument("--sorttype",
                         dest="sorttype",
                         metavar="TYPE",
                         default=SORTTYPE,
                         choices=["resolutiondate", "issueid"],
-                        help="Sorting type for issues (default: %s)" %
-                        SORTTYPE)
+                        help=f"Sorting type for issues (default: {SORTTYPE})")
     parser.add_argument(
         "-t",
         "--projecttitle",
@@ -673,6 +229,7 @@ def parse_args():  # pylint: disable=too-many-branches
                         dest="base_url",
                         action="append",
                         type=str,
+                        default='https://issues.apache.org/jira',
                         help="specify base URL of the JIRA instance.")
     parser.add_argument(
         "--retries",
@@ -706,9 +263,7 @@ def parse_args():  # pylint: disable=too-many-branches
 
     # Handle the version string right away and exit
     if options.release_version:
-        with open(os.path.join(os.path.dirname(__file__), "../VERSION"),
-                  'r') as ver_file:
-            print(ver_file.read())
+        logging.info(getversion())
         sys.exit(0)
 
     # Validate options
@@ -717,11 +272,8 @@ def parse_args():  # pylint: disable=too-many-branches
             parser.error("At least one version needs to be supplied")
         if options.projects is None:
             parser.error("At least one project needs to be supplied")
-        if options.base_url is not None:
-            if len(options.base_url) > 1:
-                parser.error("Only one base URL should be given")
-            else:
-                options.base_url = options.base_url[0]
+        if options.base_url is None:
+            parser.error("Base URL must be defined")
         if options.output_directory is not None:
             if len(options.output_directory) > 1:
                 parser.error("Only one output directory should be given")
@@ -737,35 +289,38 @@ def parse_args():  # pylint: disable=too-many-branches
     return options
 
 
+def generate_changelog_line_md(base_url, jira):
+    ''' take a jira object and generate the changelog line in md'''
+    sani_jira_id = sanitize_text(jira.get_id())
+    sani_prio = sanitize_text(jira.get_priority())
+    sani_summ = sanitize_text(jira.get_summary())
+    line = f'* [{sani_jira_id}](' + f'{base_url}/browse/{sani_jira_id})'
+    line += f'| *{sani_prio}* | **{sani_summ}**\n'
+    return line
+
+
 def main():  # pylint: disable=too-many-statements, too-many-branches, 
too-many-locals
     """ hey, it's main """
-    global JIRA_BASE_URL  #pylint: disable=global-statement
     global BACKWARD_INCOMPATIBLE_LABEL  #pylint: disable=global-statement
     global SORTTYPE  #pylint: disable=global-statement
     global SORTORDER  #pylint: disable=global-statement
     global NUM_RETRIES  #pylint: disable=global-statement
     global EXTENSION  #pylint: disable=global-statement
 
+    logging.basicConfig(format='%(message)s', level=logging.DEBUG)
     options = parse_args()
 
     if options.output_directory is not None:
         # Create the output directory if it does not exist.
         try:
-            if not os.path.exists(options.output_directory):
-                os.makedirs(options.output_directory)
+            outputpath = pathlib.Path(options.output_directory).resolve()
+            outputpath.mkdir(parents=True, exist_ok=True)
         except OSError as exc:
-            if exc.errno == errno.EEXIST and os.path.isdir(
-                    options.output_directory):
-                pass
-            else:
-                print("Unable to create output directory %s: %u, %s" % \
-                        (options.output_directory, exc.errno, exc.strerror))
-                sys.exit(1)
+            logging.error("Unable to create output directory %s: %s, %s",
+                          options.output_directory, exc.errno, exc.strerror)
+            sys.exit(1)
         os.chdir(options.output_directory)
 
-    if options.base_url is not None:
-        JIRA_BASE_URL = options.base_url
-
     if options.incompatible_label is not None:
         BACKWARD_INCOMPATIBLE_LABEL = options.incompatible_label
 
@@ -775,7 +330,8 @@ def main():  # pylint: disable=too-many-statements, 
too-many-branches, too-many-
     projects = options.projects
 
     if options.range is True:
-        versions = GetVersions(options.versions, projects).getlist()
+        versions = GetVersions(options.versions, projects,
+                               options.base_url).getlist()
     else:
         versions = [PythonVersion(v) for v in options.versions]
     versions = sorted(versions)
@@ -796,10 +352,11 @@ def main():  # pylint: disable=too-many-statements, 
too-many-branches, too-many-
     for version in versions:
         vstr = str(version)
         linter = Linter(vstr, options)
-        jlist = sorted(JiraIter(vstr, projects))
+        jlist = sorted(JiraIter(options.base_url, vstr, projects))
         if not jlist and not options.empty:
-            print("There is no issue which has the specified version: %s" %
-                  version)
+            logging.warning(
+                "There is no issue which has the specified version: %s",
+                version)
             continue
 
         if vstr in RELEASE_VERSION:
@@ -807,7 +364,7 @@ def main():  # pylint: disable=too-many-statements, 
too-many-branches, too-many-
         elif options.usetoday:
             reldate = strftime("%Y-%m-%d", gmtime())
         else:
-            reldate = "Unreleased (as of %s)" % strftime("%Y-%m-%d", gmtime())
+            reldate = f"Unreleased (as of {strftime('%Y-%m-%d', gmtime())})"
 
         if not os.path.exists(vstr) and options.versiondirs:
             os.mkdir(vstr)
@@ -919,10 +476,7 @@ def main():  # pylint: disable=too-many-statements, 
too-many-branches, too-many-
             else:
                 otherlist.append(jira)
 
-            line = '* [%s](' % (sanitize_text(jira.get_id())) + JIRA_BASE_URL 
+ \
-                   '/browse/%s) | *%s* | **%s**\n' \
-                   % (sanitize_text(jira.get_id()),
-                      sanitize_text(jira.get_priority()), 
sanitize_text(jira.get_summary()))
+            line = generate_changelog_line_md(options.base_url, jira)
 
             if jira.get_release_note() or \
                jira.get_incompatible_change() or jira.get_important():
@@ -931,15 +485,14 @@ def main():  # pylint: disable=too-many-statements, 
too-many-branches, too-many-
                 if not jira.get_release_note():
                     line = '\n**WARNING: No release note provided for this 
change.**\n\n'
                 else:
-                    line = '\n%s\n\n' % (processrelnote(
-                        jira.get_release_note()))
+                    line = f'\n{processrelnote(jira.get_release_note())}\n\n'
                 reloutputs.write_key_raw(jira.get_project(), line)
 
             linter.lint(jira)
 
         if linter.enabled:
-            print(linter.message())
             if linter.had_errors():
+                logging.error(linter.message())
                 haderrors = True
                 if os.path.exists(vstr):
                     shutil.rmtree(vstr)
@@ -962,55 +515,58 @@ def main():  # pylint: disable=too-many-statements, 
too-many-branches, too-many-
             choutputs.write_all(change_header21)
             choutputs.write_all(change_header22)
             choutputs.write_list(incompatlist, options.skip_credits,
-                                 JIRA_BASE_URL)
+                                 options.base_url)
 
         if importantlist:
             choutputs.write_all("\n\n### IMPORTANT ISSUES:\n\n")
             choutputs.write_all(change_header21)
             choutputs.write_all(change_header22)
             choutputs.write_list(importantlist, options.skip_credits,
-                                 JIRA_BASE_URL)
+                                 options.base_url)
 
         if newfeaturelist:
             choutputs.write_all("\n\n### NEW FEATURES:\n\n")
             choutputs.write_all(change_header21)
             choutputs.write_all(change_header22)
             choutputs.write_list(newfeaturelist, options.skip_credits,
-                                 JIRA_BASE_URL)
+                                 options.base_url)
 
         if improvementlist:
             choutputs.write_all("\n\n### IMPROVEMENTS:\n\n")
             choutputs.write_all(change_header21)
             choutputs.write_all(change_header22)
             choutputs.write_list(improvementlist, options.skip_credits,
-                                 JIRA_BASE_URL)
+                                 options.base_url)
 
         if buglist:
             choutputs.write_all("\n\n### BUG FIXES:\n\n")
             choutputs.write_all(change_header21)
             choutputs.write_all(change_header22)
-            choutputs.write_list(buglist, options.skip_credits, JIRA_BASE_URL)
+            choutputs.write_list(buglist, options.skip_credits,
+                                 options.base_url)
 
         if testlist:
             choutputs.write_all("\n\n### TESTS:\n\n")
             choutputs.write_all(change_header21)
             choutputs.write_all(change_header22)
-            choutputs.write_list(testlist, options.skip_credits, JIRA_BASE_URL)
+            choutputs.write_list(testlist, options.skip_credits,
+                                 options.base_url)
 
         if subtasklist:
             choutputs.write_all("\n\n### SUB-TASKS:\n\n")
             choutputs.write_all(change_header21)
             choutputs.write_all(change_header22)
             choutputs.write_list(subtasklist, options.skip_credits,
-                                 JIRA_BASE_URL)
+                                 options.base_url)
 
         if tasklist or otherlist:
             choutputs.write_all("\n\n### OTHER:\n\n")
             choutputs.write_all(change_header21)
             choutputs.write_all(change_header22)
             choutputs.write_list(otherlist, options.skip_credits,
-                                 JIRA_BASE_URL)
-            choutputs.write_list(tasklist, options.skip_credits, JIRA_BASE_URL)
+                                 options.base_url)
+            choutputs.write_list(tasklist, options.skip_credits,
+                                 options.base_url)
 
         choutputs.write_all("\n\n")
         choutputs.close()
diff --git a/releasedocmaker/src/main/python/releasedocmaker/getversions.py 
b/releasedocmaker/src/main/python/releasedocmaker/getversions.py
new file mode 100755
index 0000000..8d2fc62
--- /dev/null
+++ b/releasedocmaker/src/main/python/releasedocmaker/getversions.py
@@ -0,0 +1,76 @@
+#!/usr/bin/env python3
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+""" Handle versions in JIRA """
+
+import http.client
+import json
+import logging
+import sys
+import urllib.error
+from .utils import get_jira
+
+try:
+    from pip._vendor.packaging.version import LegacyVersion as PythonVersion
+except ImportError:
+    try:
+        from setuptools._vendor.packaging.version import LegacyVersion as 
PythonVersion
+    except ImportError:
+        try:
+            from pkg_resources._vendor.packaging.version import LegacyVersion 
as PythonVersion
+        except ImportError:
+            try:
+                from packaging.version import LegacyVersion as PythonVersion
+            except ImportError:
+                logging.error(
+                    "This script requires a packaging module to be installed.")
+                sys.exit(1)
+
+
+class GetVersions:  # pylint: disable=too-few-public-methods
+    """ List of version strings """
+    def __init__(self, versions, projects, jira_base_url):
+        self.newversions = []
+        versions = sorted(versions, key=PythonVersion)
+        logging.info("Looking for %s through %s", {versions[0]},
+                     {versions[-1]})
+        newversions = set()
+        for project in projects:
+            url = 
f"{jira_base_url}/rest/api/2/project/{project.upper()}/versions"
+            try:
+                resp = get_jira(url)
+            except (urllib.error.HTTPError, urllib.error.URLError,
+                    http.client.BadStatusLine):
+                sys.exit(1)
+
+            datum = json.loads(resp.read())
+            for data in datum:
+                newversions.add(PythonVersion(data['name']))
+        newlist = list(newversions.copy())
+        newlist.append(PythonVersion(versions[0]))
+        newlist.append(PythonVersion(versions[-1]))
+        newlist = sorted(newlist)
+        start_index = newlist.index(PythonVersion(versions[0]))
+        end_index = len(newlist) - 1 - newlist[::-1].index(
+            PythonVersion(versions[-1]))
+        for newversion in newlist[start_index + 1:end_index]:
+            if newversion in newversions:
+                logging.info("Adding %s to the list", newversion)
+                self.newversions.append(newversion)
+
+    def getlist(self):
+        """ Get the list of versions """
+        return self.newversions
diff --git a/releasedocmaker/src/main/python/releasedocmaker/jira.py 
b/releasedocmaker/src/main/python/releasedocmaker/jira.py
new file mode 100755
index 0000000..7f4380f
--- /dev/null
+++ b/releasedocmaker/src/main/python/releasedocmaker/jira.py
@@ -0,0 +1,434 @@
+#!/usr/bin/env python3
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+""" Handle JIRA Issues """
+
+import http.client
+import json
+import logging
+import re
+import sys
+import urllib.parse
+import urllib.error
+import time
+
+try:
+    import dateutil.parser
+except ImportError:
+    logging.error(
+        ("This script requires python-dateutil module to be installed. "
+         "You can install it using:\n\t pip install python-dateutil"))
+    sys.exit(1)
+
+from .utils import get_jira, to_unicode, sanitize_text
+
+RELEASE_VERSION = {}
+
+SORTTYPE = 'resolutiondate'
+SORTORDER = 'older'
+NUM_RETRIES = 5
+
+# label to be used to mark an issue as Incompatible change.
+BACKWARD_INCOMPATIBLE_LABEL = 'backward-incompatible'
+
+
+class Jira:
+    """A single JIRA"""
+    def __init__(self, data, parent):
+        self.key = data['key']
+        self.fields = data['fields']
+        self.parent = parent
+        self.notes = None
+        self.incompat = None
+        self.reviewed = None
+        self.important = None
+
+    def get_id(self):
+        """ get the Issue ID """
+        return to_unicode(self.key)
+
+    def get_description(self):
+        """ get the description """
+        return to_unicode(self.fields['description'])
+
+    def get_release_note(self):
+        """ get the release note field """
+        if self.notes is None:
+            field = self.parent.field_id_map['Release Note']
+            if field in self.fields:
+                self.notes = to_unicode(self.fields[field])
+            elif self.get_incompatible_change() or self.get_important():
+                self.notes = self.get_description()
+            else:
+                self.notes = ""
+        return self.notes
+
+    def get_priority(self):
+        """ Get the priority """
+        ret = ""
+        pri = self.fields['priority']
+        if pri is not None:
+            ret = pri['name']
+        return to_unicode(ret)
+
+    def get_assignee(self):
+        """ Get the assignee """
+        ret = ""
+        mid = self.fields['assignee']
+        if mid is not None:
+            ret = mid['displayName']
+        return to_unicode(ret)
+
+    def get_components(self):
+        """ Get the component(s) """
+        if self.fields['components']:
+            return ", ".join(
+                [comp['name'] for comp in self.fields['components']])
+        return ""
+
+    def get_summary(self):
+        """ Get the summary """
+        return self.fields['summary']
+
+    def get_type(self):
+        """ Get the Issue type """
+        ret = ""
+        mid = self.fields['issuetype']
+        if mid is not None:
+            ret = mid['name']
+        return to_unicode(ret)
+
+    def get_reporter(self):
+        """ Get the issue reporter """
+        ret = ""
+        mid = self.fields['reporter']
+        if mid is not None:
+            ret = mid['displayName']
+        return to_unicode(ret)
+
+    def get_project(self):
+        """ get the project """
+        ret = ""
+        mid = self.fields['project']
+        if mid is not None:
+            ret = mid['key']
+        return to_unicode(ret)
+
+    def __lt__(self, other):
+
+        if SORTTYPE == 'issueid':
+            # compare by issue name-number
+            selfsplit = self.get_id().split('-')
+            othersplit = other.get_id().split('-')
+            result = selfsplit[0] < othersplit[0]
+            if not result:
+                result = int(selfsplit[1]) < int(othersplit[1])
+                # dec is supported for backward compatibility
+                if SORTORDER in ['dec', 'desc']:
+                    result = not result
+
+        elif SORTTYPE == 'resolutiondate':
+            dts = dateutil.parser.parse(self.fields['resolutiondate'])
+            dto = dateutil.parser.parse(other.fields['resolutiondate'])
+            result = dts < dto
+            if SORTORDER == 'newer':
+                result = not result
+
+        return result
+
+    def get_incompatible_change(self):
+        """ get incompatible flag """
+        if self.incompat is None:
+            field = self.parent.field_id_map['Hadoop Flags']
+            self.reviewed = False
+            self.incompat = False
+            if field in self.fields:
+                if self.fields[field]:
+                    for flag in self.fields[field]:
+                        if flag['value'] == "Incompatible change":
+                            self.incompat = True
+                        if flag['value'] == "Reviewed":
+                            self.reviewed = True
+            else:
+                # Custom field 'Hadoop Flags' is not defined,
+                # search for 'backward-incompatible' label
+                field = self.parent.field_id_map['Labels']
+                if field in self.fields and self.fields[field]:
+                    if BACKWARD_INCOMPATIBLE_LABEL in self.fields[field]:
+                        self.incompat = True
+                        self.reviewed = True
+        return self.incompat
+
+    def get_important(self):
+        """ get important flag """
+        if self.important is None:
+            field = self.parent.field_id_map['Flags']
+            self.important = False
+            if field in self.fields:
+                if self.fields[field]:
+                    for flag in self.fields[field]:
+                        if flag['value'] == "Important":
+                            self.important = True
+        return self.important
+
+
+class JiraIter:
+    """An Iterator of JIRAs"""
+    @staticmethod
+    def collect_fields(jira_base_url):
+        """send a query to JIRA and collect field-id map"""
+        try:
+            resp = get_jira(f"{jira_base_url}/rest/api/2/field")
+            data = json.loads(resp.read())
+        except (urllib.error.HTTPError, urllib.error.URLError,
+                http.client.BadStatusLine, ValueError) as error:
+            logging.error('Blew up trying to get a response: %s', error)
+            sys.exit(1)
+        field_id_map = {}
+        for part in data:
+            field_id_map[part['name']] = part['id']
+        return field_id_map
+
+    @staticmethod
+    def query_jira(jira_base_url, ver, projects, pos):
+        """send a query to JIRA and collect
+        a certain number of issue information"""
+        count = 100
+        pjs = "','".join(projects)
+        jql = f"project in ('{pjs}') and fixVersion in ('{ver}') and 
resolution = Fixed"
+        params = urllib.parse.urlencode({
+            'jql': jql,
+            'startAt': pos,
+            'maxResults': count
+        })
+        return JiraIter.load_jira(jira_base_url, params, 0)
+
+    @staticmethod
+    def load_jira(jira_base_url, params, fail_count):
+        """send query to JIRA and collect with retries"""
+        try:
+            resp = get_jira(f"{jira_base_url}/rest/api/2/search?{params}")
+        except (urllib.error.URLError, http.client.BadStatusLine) as err:
+            return JiraIter.retry_load(jira_base_url, err, params, fail_count)
+
+        try:
+            data = json.loads(resp.read())
+        except http.client.IncompleteRead as err:
+            return JiraIter.retry_load(jira_base_url, err, params, fail_count)
+        return data
+
+    @staticmethod
+    def retry_load(jira_base_url, err, params, fail_count):
+        """Retry connection up to NUM_RETRIES times."""
+        logging.error(err)
+        fail_count += 1
+        if fail_count <= NUM_RETRIES:
+            logging.warning("Connection failed %s times. Retrying.",
+                            fail_count)
+            time.sleep(1)
+            return JiraIter.load_jira(jira_base_url, params, fail_count)
+        logging.error("Connection failed %s times. Aborting.", fail_count)
+        sys.exit(1)
+
+    @staticmethod
+    def collect_jiras(jira_base_url, ver, projects):
+        """send queries to JIRA and collect all issues
+        that belongs to given version and projects"""
+        jiras = []
+        pos = 0
+        end = 1
+        while pos < end:
+            data = JiraIter.query_jira(jira_base_url, ver, projects, pos)
+            if 'error_messages' in data:
+                logging.error("JIRA returns error message: %s",
+                              data['error_messages'])
+                sys.exit(1)
+            pos = data['startAt'] + data['maxResults']
+            end = data['total']
+            jiras.extend(data['issues'])
+
+            if ver not in RELEASE_VERSION:
+                for issue in data['issues']:
+                    for fix_version in issue['fields']['fixVersions']:
+                        if 'releaseDate' in fix_version:
+                            RELEASE_VERSION[fix_version['name']] = fix_version[
+                                'releaseDate']
+        return jiras
+
+    def __init__(self, jira_base_url, version, projects):
+        self.version = version
+        self.projects = projects
+        self.jira_base_url = jira_base_url
+        self.field_id_map = JiraIter.collect_fields(jira_base_url)
+        ver = str(version).replace("-SNAPSHOT", "")
+        self.jiras = JiraIter.collect_jiras(jira_base_url, ver, projects)
+        self.iter = self.jiras.__iter__()
+
+    def __iter__(self):
+        return self
+
+    def __next__(self):
+        """ get next """
+        data = next(self.iter)
+        j = Jira(data, self)
+        return j
+
+
+class Linter:
+    """Encapsulates lint-related functionality.
+    Maintains running lint statistics about JIRAs."""
+
+    _valid_filters = [
+        "incompatible", "important", "version", "component", "assignee"
+    ]
+
+    def __init__(self, version, options):
+        self._warning_count = 0
+        self._error_count = 0
+        self._lint_message = ""
+        self._version = version
+
+        self._filters = dict(
+            list(zip(self._valid_filters, [False] * len(self._valid_filters))))
+
+        self.enabled = False
+        self._parse_options(options)
+
+    @staticmethod
+    def add_parser_options(parser):
+        """Add Linter options to passed optparse parser."""
+        filter_string = ", ".join("'" + f + "'" for f in Linter._valid_filters)
+        parser.add_argument(
+            "-n",
+            "--lint",
+            dest="lint",
+            action="append",
+            type=str,
+            help="Specify lint filters. Valid filters are " + filter_string +
+            ". " + "'all' enables all lint filters. " +
+            "Multiple filters can be specified comma-delimited and " +
+            "filters can be negated, e.g. 'all,-component'.")
+
+    def _parse_options(self, options):
+        """Parse options from optparse."""
+
+        if options.lint is None or not options.lint:
+            return
+        self.enabled = True
+
+        # Valid filter specifications are
+        # self._valid_filters, negations, and "all"
+        valid_list = self._valid_filters
+        valid_list += ["-" + v for v in valid_list]
+        valid_list += ["all"]
+        valid = set(valid_list)
+
+        enabled = []
+        disabled = []
+
+        for opt in options.lint:
+            for token in opt.split(","):
+                if token not in valid:
+                    logging.error(
+                        "Unknown lint filter '%s', valid options are: %s",
+                        token, ', '.join(v for v in sorted(valid)))
+                    sys.exit(1)
+                if token.startswith("-"):
+                    disabled.append(token[1:])
+                else:
+                    enabled.append(token)
+
+        for eopt in enabled:
+            if eopt == "all":
+                for filt in self._valid_filters:
+                    self._filters[filt] = True
+            else:
+                self._filters[eopt] = True
+        for disopt in disabled:
+            self._filters[disopt] = False
+
+    def had_errors(self):
+        """Returns True if a lint error was encountered, else False."""
+        return self._error_count > 0
+
+    def message(self):
+        """Return summary lint message suitable for printing to stdout."""
+        if not self.enabled:
+            return None
+        msg = self._lint_message
+        msg += "\n======================================="
+        msg += f"\n{self._version}: Error:{self._error_count}, 
Warning:{self._warning_count} \n"
+        return msg
+
+    def _check_missing_component(self, jira):
+        """Return if JIRA has a 'missing component' lint error."""
+        if not self._filters["component"]:
+            return False
+
+        if jira.fields['components']:
+            return False
+        return True
+
+    def _check_missing_assignee(self, jira):
+        """Return if JIRA has a 'missing assignee' lint error."""
+        if not self._filters["assignee"]:
+            return False
+
+        if jira.fields['assignee'] is not None:
+            return False
+        return True
+
+    def _check_version_string(self, jira):
+        """Return if JIRA has a version string lint error."""
+        if not self._filters["version"]:
+            return False
+
+        field = jira.parent.field_id_map['Fix Version/s']
+        for ver in jira.fields[field]:
+            found = re.match(r'^((\d+)(\.\d+)*).*$|^(\w+\-\d+)$', ver['name'])
+            if not found:
+                return True
+        return False
+
+    def lint(self, jira):
+        """Run lint check on a JIRA."""
+        if not self.enabled:
+            return
+        if not jira.get_release_note():
+            jiraid = sanitize_text(jira.get_id())
+            if self._filters["incompatible"] and jira.get_incompatible_change(
+            ):
+                self._warning_count += 1
+                self._lint_message += f"\nWARNING: incompatible change 
{jiraid} lacks release notes."  #pylint: disable=line-too-long
+
+            if self._filters["important"] and jira.get_important():
+                self._warning_count += 1
+                self._lint_message += f"\nWARNING: important issue {jiraid} 
lacks release notes."
+
+        if self._check_version_string(jira):
+            self._warning_count += 1
+            self._lint_message += f"\nWARNING: Version string problem for 
{jira.get_id()} "
+
+        if self._check_missing_component(jira) or self._check_missing_assignee(
+                jira):
+            self._error_count += 1
+            error_message = []
+            if self._check_missing_component(jira):
+                error_message.append("component")
+            if self._check_missing_assignee(jira):
+                error_message.append("assignee")
+            multimessage = ' and '.join(error_message)
+            self._lint_message += f"\nERROR: missing {multimessage} for 
{jira.get_id()} "
diff --git a/releasedocmaker/src/main/python/releasedocmaker/utils.py 
b/releasedocmaker/src/main/python/releasedocmaker/utils.py
index f3f93ae..54e253b 100755
--- a/releasedocmaker/src/main/python/releasedocmaker/utils.py
+++ b/releasedocmaker/src/main/python/releasedocmaker/utils.py
@@ -26,6 +26,7 @@ import urllib.parse
 import sys
 import json
 import http.client
+
 sys.dont_write_bytecode = True
 
 NAME_PATTERN = re.compile(r' \([0-9]+\)')
@@ -41,16 +42,15 @@ def get_jira(jira_url):
 
     req = urllib.request.Request(jira_url)
     if username and password:
-        basicauth = base64.b64encode("%s:%s" % (username, password)).replace(
+        basicauth = base64.b64encode(f"{username}:{password}").replace(
             '\n', '')
-        req.add_header('Authorization', 'Basic %s' % basicauth)
+        req.add_header('Authorization', f'Basic {basicauth}')
 
     try:
-        response = urllib.request.urlopen(req)
+        response = urllib.request.urlopen(req)  # pylint: 
disable=consider-using-with
     except urllib.error.HTTPError as http_err:
         code = http_err.code
-        print("JIRA returns HTTP error %d: %s. Aborting." % \
-              (code, http_err.msg))
+        print(f"JIRA returns HTTP error {code}: {http_err.msg}. Aborting.")
         error_response = http_err.read()
         try:
             error_response = json.loads(error_response)
@@ -62,8 +62,8 @@ def get_jira(jira_url):
             print("FATAL: Could not parse json response from server.")
         sys.exit(1)
     except urllib.error.URLError as url_err:
-        print("Error contacting JIRA: %s\n" % jira_url)
-        print("Reason: %s" % url_err.reason)
+        print(f"Error contacting JIRA: {jira_url}\n")
+        print(f"Reason: {url_err.reason}")
         raise url_err
     except http.client.BadStatusLine as err:
         raise err
@@ -99,7 +99,7 @@ def sanitize_text(input_string):
 
       Calls sanitize_markdown at the end as a final pass.
     """
-    escapes = dict()
+    escapes = {}
     # See: https://daringfireball.net/projects/markdown/syntax#backslash
     # We only escape a subset of special characters. We ignore characters
     # that only have significance at the start of a line.
@@ -148,22 +148,23 @@ class Outputs:
         if params is None:
             params = {}
         self.params = params
-        self.base = open(base_file_name % params, 'w')
+        self.base = open(base_file_name % params, 'w', encoding='utf-8')  # 
pylint: disable=consider-using-with
         self.others = {}
         for key in keys:
             both = dict(params)
             both['key'] = key
-            self.others[key] = open(file_name_pattern % both, 'w')
+            filename = file_name_pattern % both
+            self.others[key] = open(filename, 'w', encoding='utf-8')  # 
pylint: disable=consider-using-with
 
     def write_all(self, pattern):
         """ write everything given a pattern """
         both = dict(self.params)
         both['key'] = ''
         self.base.write(pattern % both)
-        for key in self.others:
+        for key, filehandle in self.others.items():
             both = dict(self.params)
             both['key'] = key
-            self.others[key].write(pattern % both)
+            filehandle.write(pattern % both)
 
     def write_key_raw(self, key, input_string):
         """ write everything without changes """

Reply via email to