Introduce git-rpm-ch tool
authorMarkus Lehtonen <markus.lehtonen@linux.intel.com>
Tue, 4 Feb 2014 15:54:36 +0000 (17:54 +0200)
committerMarkus Lehtonen <markus.lehtonen@linux.intel.com>
Tue, 3 Mar 2015 08:07:47 +0000 (10:07 +0200)
Initial version of the git-rpm-ch tool which is intended for maintaining
RPM changelogs. Supports both spec files and separate "OBS style"
changelog files.

Signed-off-by: Markus Lehtonen <markus.lehtonen@linux.intel.com>
gbp-rpm.conf
gbp/config.py
gbp/rpm/policy.py
gbp/scripts/rpm_ch.py [new file with mode: 0755]
tests/component/rpm/test_rpm_ch.py [new file with mode: 0644]

index 6a4c3dc4d9d8e610e4b7a90c8d6a7955fb75c06d..c9d07bebfcc19d22df66c5fc853872a8a90e15ec 100644 (file)
 [gbp-create-remote-repo]
 # Disable remote branch tracking
 #track = False
+
+# Options only affecting git-rpm-changelog
+[git-rpm-changelog]
+# Changelog filename, relative to the git topdir
+#changelog-file = git-buildpackage.changelog
+# Format string for the revision part of the changelog header
+#changelog-revision = %(tagname)s
+# Preferred editor
+#editor-cmd = vim
index 2428977adb95eb76f28ff03e957449f46eda7682..ff0fba9ae7af6d244dbe80244d28260fbdcb0184 100644 (file)
@@ -629,6 +629,10 @@ class GbpOptionParserRpm(GbpOptionParser):
             'merge'                     : 'False',
             'pristine-tarball-name'     : 'auto',
             'orig-prefix'               : 'auto',
+            'changelog-file'            : 'auto',
+            'changelog-revision'        : '',
+            'spawn-editor'              : 'always',
+            'editor-cmd'                : 'vim',
                     })
 
     help = dict(GbpOptionParser.help)
@@ -693,6 +697,17 @@ class GbpOptionParserRpm(GbpOptionParser):
             'orig-prefix':
                 "Prefix (dir) to be used when generating/importing tarballs, "
                 "default is '%(orig-prefix)s'",
+            'changelog-file':
+                "Changelog file to be used, default is '%(changelog-file)s'",
+            'changelog-revision':
+                "Format string for the revision field in the changelog header. "
+                "If empty or not defined the default from packaging policy is "
+                "used.",
+            'editor-cmd':
+                "Editor command to use",
+            'git-author':
+                "Use name and email from git-config for the changelog header, "
+                "default is '%(git-author)s'",
                  })
 
 # vim:et:ts=4:sw=4:et:sts=4:ai:set list listchars=tab\:»·,trail\:·:
index e0682e6bc08a59c6032deff9a40bbd069c89aca8..2fa2423dd68d3bc723d000a780c5ff54fff04f6f 100644 (file)
@@ -17,7 +17,9 @@
 """Default packaging policy for RPM"""
 
 import re
+
 from gbp.pkg import PkgPolicy, parse_archive_filename
+from gbp.scripts.common.pq import parse_gbp_commands
 
 class RpmPkgPolicy(PkgPolicy):
     """Packaging policy for RPM"""
@@ -89,3 +91,114 @@ class RpmPkgPolicy(PkgPolicy):
         header_time_format = "%a %b %d %Y"
         header_rev_format = "%(version)s"
 
+
+    class ChangelogEntryFormatter(object):
+        """Helper class for generating changelog entries from git commits"""
+
+        # Maximum length for a changelog entry line
+        max_entry_line_length = 76
+        # Bug tracking system related meta tags recognized from git commit msg
+        bts_meta_tags = ("Close", "Closes", "Fixes", "Fix")
+        # Regexp for matching bug tracking system ids (e.g. "bgo#123")
+        bug_id_re = r'[A-Za-z0-9#_\-]+'
+
+        @classmethod
+        def _parse_bts_tags(cls, lines, meta_tags):
+            """
+            Parse and filter out bug tracking system related meta tags from
+            commit message.
+
+            @param lines: commit message
+            @type lines: C{list} of C{str}
+            @param meta_tags: meta tags to look for
+            @type meta_tags: C{tuple} of C{str}
+            @return: bts-ids per meta tag and the non-mathced lines
+            @rtype: (C{dict}, C{list} of C{str})
+            """
+            tags = {}
+            other_lines = []
+            bts_re = re.compile(r'^(?P<tag>%s):\s*(?P<ids>.*)' %
+                                ('|'.join(meta_tags)), re.I)
+            bug_id_re = re.compile(cls.bug_id_re)
+            for line in lines:
+                match = bts_re.match(line)
+                if match:
+                    tag = match.group('tag')
+                    ids_str = match.group('ids')
+                    bug_ids = [bug_id.strip() for bug_id in
+                                bug_id_re.findall(ids_str)]
+                    if tag in tags:
+                        tags[tag] += bug_ids
+                    else:
+                        tags[tag] = bug_ids
+                else:
+                    other_lines.append(line)
+            return (tags, other_lines)
+
+        @classmethod
+        def _extra_filter(cls, lines, ignore_re):
+            """
+            Filter out specific lines from the commit message.
+
+            @param lines: commit message
+            @type lines: C{list} of C{str}
+            @param ignore_re: regexp for matching ignored lines
+            @type ignore_re: C{str}
+            @return: filtered commit message
+            @rtype: C{list} of C{str}
+            """
+            if ignore_re:
+                match = re.compile(ignore_re)
+                return [line for line in lines if not match.match(line)]
+            else:
+                return lines
+
+        @classmethod
+        def compose(cls, commit_info, **kwargs):
+            """
+            Generate a changelog entry from a git commit.
+
+            @param commit_info: info about the commit
+            @type commit_info: C{commit_info} object from
+                L{gbp.git.repository.GitRepository.get_commit_info()}.
+            @param kwargs: additional arguments to the compose() method,
+                currently we recognize 'full', 'id_len' and 'ignore_re'
+            @type kwargs: C{dict}
+            @return: formatted changelog entry
+            @rtype: C{list} of C{str}
+            """
+            # Parse and filter out gbp command meta-tags
+            cmds, body = parse_gbp_commands(commit_info, 'gbp-rpm-ch',
+                                            ('ignore', 'short', 'full'), ())
+            body = body.splitlines()
+            if 'ignore' in cmds:
+                return None
+
+            # Parse and filter out bts-related meta-tags
+            bts_tags, body = cls._parse_bts_tags(body, cls.bts_meta_tags)
+
+            # Additional filtering
+            body = cls._extra_filter(body, kwargs['ignore_re'])
+
+            # Generate changelog entry
+            subject = commit_info['subject']
+            commitid = commit_info['id']
+            if kwargs['id_len']:
+                text = ["- [%s] %s" % (commitid[0:kwargs['id_len']], subject)]
+            else:
+                text = ["- %s" % subject]
+
+            # Add all non-filtered-out lines from commit message, unless 'short'
+            if (kwargs['full'] or 'full' in cmds) and not 'short' in cmds:
+                # Add all non-blank body lines.
+                text.extend(["  " + line for line in body if line.strip()])
+
+            # Add bts tags and ids in the end
+            for tag, ids in bts_tags.iteritems():
+                bts_msg = " (%s: %s)" % (tag, ', '.join(ids))
+                if len(text[-1]) + len(bts_msg) >= cls.max_entry_line_length:
+                    text.append(" ")
+                text[-1] += bts_msg
+
+            return text
+
diff --git a/gbp/scripts/rpm_ch.py b/gbp/scripts/rpm_ch.py
new file mode 100755 (executable)
index 0000000..4e283fc
--- /dev/null
@@ -0,0 +1,449 @@
+# vim: set fileencoding=utf-8 :
+#
+# (C) 2007, 2008, 2009, 2010, 2013 Guido Guenther <agx@sigxcpu.org>
+# (C) 2014 Intel Corporation <markus.lehtonen@linux.intel.com>
+#    This program is free software; you can redistribute it and/or modify
+#    it under the terms of the GNU General Public License as published by
+#    the Free Software Foundation; either version 2 of the License, or
+#    (at your option) any later version.
+#
+#    This program is distributed in the hope that it will be useful,
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#    GNU General Public License for more details.
+#
+#    You should have received a copy of the GNU General Public License
+#    along with this program; if not, write to the Free Software
+#    Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+#
+"""Generate RPM changelog entries from git commit messages"""
+
+import ConfigParser
+from datetime import datetime
+import os.path
+import pwd
+import re
+import sys
+import socket
+
+import gbp.command_wrappers as gbpc
+import gbp.log
+from gbp.config import GbpOptionParserRpm, GbpOptionGroup
+from gbp.errors import GbpError
+from gbp.rpm import (guess_spec, NoSpecError, SpecFile, split_version_str,
+                     compose_version_str)
+from gbp.rpm.changelog import Changelog, ChangelogParser, ChangelogError
+from gbp.rpm.git import GitRepositoryError, RpmGitRepository
+from gbp.rpm.policy import RpmPkgPolicy
+from gbp.tmpfile import init_tmpdir, del_tmpdir
+
+
+ChangelogEntryFormatter = RpmPkgPolicy.ChangelogEntryFormatter
+
+
+class ChangelogFile(object):
+    """Container for changelog file, whether it be a standalone changelog
+       or a spec file"""
+
+    def __init__(self, file_path):
+        parser = ChangelogParser(RpmPkgPolicy)
+
+        if os.path.splitext(file_path)[1] == '.spec':
+            gbp.log.debug("Using spec file '%s' as changelog" % file_path)
+            self._file = SpecFile(file_path)
+            self.changelog = parser.raw_parse_string(self._file.get_changelog())
+        else:
+            self._file = os.path.abspath(file_path)
+            if not os.path.exists(file_path):
+                gbp.log.info("Changelog '%s' not found, creating new "
+                             "changelog file" % file_path)
+                self.changelog = Changelog(RpmPkgPolicy)
+            else:
+                gbp.log.debug("Using changelog file '%s'" % file_path)
+                self.changelog = parser.raw_parse_file(self._file)
+
+        # Parse topmost section and try to determine the start commit
+        if self.changelog.sections:
+            self.changelog.sections[0] = parser.parse_section(
+                    self.changelog.sections[0])
+
+    def write(self):
+        """Write changelog file to disk"""
+        if isinstance(self._file, SpecFile):
+            self._file.set_changelog(str(self.changelog))
+            self._file.write_spec_file()
+        else:
+            with open(self._file, 'w') as fobj:
+                fobj.write(str(self.changelog))
+
+    @property
+    def path(self):
+        """File path"""
+        if isinstance(self._file, SpecFile):
+            return self._file.specpath
+        else:
+            return self._file
+
+def load_customizations(customization_file):
+    """Load user defined customizations file"""
+    # Load customization file
+    if not customization_file:
+        return
+    customizations = {}
+    try:
+        execfile(customization_file, customizations, customizations)
+    except Exception as err:
+        raise GbpError("Failed to load customization file: %s" % err)
+
+    # Set customization classes / functions
+    global ChangelogEntryFormatter
+    if 'ChangelogEntryFormatter' in customizations:
+        ChangelogEntryFormatter = customizations.get('ChangelogEntryFormatter')
+
+
+def determine_editor(options):
+    """Determine text editor"""
+
+    # Check if we need to spawn an editor
+    states = ['always']
+    if options.release:
+        states.append('release')
+    if options.spawn_editor not in states:
+        return None
+
+    # Determine the correct editor
+    if options.editor_cmd:
+        return options.editor_cmd
+    elif 'EDITOR' in os.environ:
+        return os.environ['EDITOR']
+    else:
+        return 'vi'
+
+
+def check_branch(repo, options):
+    """Check the current git branch"""
+    try:
+        branch = repo.get_branch()
+    except GitRepositoryError:
+        branch = None
+    if options.packaging_branch != branch and not options.ignore_branch:
+        gbp.log.err("You are not on branch '%s' but on '%s'" %
+                    (options.packaging_branch, branch))
+        raise GbpError("Use --ignore-branch to ignore or "
+                       "--packaging-branch to set the branch name.")
+
+
+def parse_spec_file(repo, options):
+    """Find and parse spec file"""
+    if options.spec_file:
+        spec_path = os.path.join(repo.path, options.spec_file)
+        spec = SpecFile(spec_path)
+    else:
+        spec = guess_spec(os.path.join(repo.path, options.packaging_dir),
+                          True, os.path.basename(repo.path) + '.spec')
+    options.packaging_dir = spec.specdir
+    return spec
+
+
+def parse_changelog_file(repo, spec, options):
+    """Find and parse changelog file"""
+    changes_file_name = os.path.splitext(spec.specfile)[0] + '.changes'
+    changes_file_path = os.path.join(options.packaging_dir, changes_file_name)
+
+    # Determine changelog file path
+    if options.changelog_file == "SPEC":
+        changelog_path = spec.specpath
+    elif options.changelog_file == "CHANGES":
+        changelog_path = changes_file_path
+    elif options.changelog_file == 'auto':
+        if os.path.exists(changes_file_path):
+            changelog_path = changes_file_path
+        else:
+            changelog_path = spec.specpath
+    else:
+        changelog_path = os.path.join(repo.path, options.changelog_file)
+
+    return ChangelogFile(changelog_path)
+
+
+def guess_commit(section, repo, options):
+    """Guess the last commit documented in a changelog header"""
+
+    if not section:
+        return None
+    header = section.header
+
+    # Try to parse the fields from the header revision
+    rev_re = '^%s$' % re.sub(r'%\((\S+?)\)s', r'(?P<\1>\S+)',
+                             options.changelog_revision)
+    match = re.match(rev_re, header['revision'], re.I)
+    fields = match.groupdict() if match else {}
+
+    # First, try to find tag-name, if present
+    if 'tagname' in fields:
+        gbp.log.debug("Trying to find tagname %s" % fields['tagname'])
+        try:
+            return repo.rev_parse("%s^0" % fields['tagname'])
+        except GitRepositoryError:
+            gbp.log.warn("Changelog points to tagname '%s' which is not found "
+                         "in the git repository" % fields['tagname'])
+
+    # Next, try to find packaging tag matching the version
+    tag_str_fields = {'vendor': options.vendor}
+    if 'version' in fields:
+        gbp.log.debug("Trying to find packaging tag for version '%s'" %
+                      fields['version'])
+        full_version = fields['version']
+        tag_str_fields.update(split_version_str(full_version))
+    elif 'upstreamversion' in fields:
+        gbp.log.debug("Trying to find packaging tag for version '%s'" %
+                      fields['upstreamversion'])
+        tag_str_fields['upstreamversion'] = fields['upstreamversion']
+        if 'release' in fields:
+            tag_str_fields['release'] = fields['release']
+    commit = repo.find_version(options.packaging_tag,
+                                   tag_str_fields)
+    if commit:
+        return commit
+    else:
+        gbp.log.info("Couldn't find packaging tag for version %s" %
+                     header['revision'])
+
+    # As a last resort we look at the timestamp
+    timestamp = header['time'].isoformat()
+    last = repo.get_commits(num=1, options="--until='%s'" % timestamp)
+    if last:
+        gbp.log.info("Using commit (%s) before the last changelog timestamp "
+                     "(%s)" % (last, timestamp))
+        return last[0]
+    return None
+
+
+def get_start_commit(changelog, repo, options):
+    """Get the start commit from which to generate new entries"""
+    if options.since:
+        since = options.since
+    else:
+        if changelog.sections:
+            since = guess_commit(changelog.sections[0], repo, options)
+        else:
+            since = None
+        if not since:
+            raise GbpError("Couldn't determine starting point from "
+                           "changelog, please use the '--since' option")
+        gbp.log.info("Continuing from commit '%s'" % since)
+    return since
+
+
+def get_author(repo, use_git_config):
+    """Get author and email from git configuration"""
+    author = email = None
+
+    if use_git_config:
+        modifier = repo.get_author_info()
+        author = modifier.name
+        email = modifier.email
+
+    passwd_data = pwd.getpwuid(os.getuid())
+    if not author:
+        # On some distros (Ubuntu, at least) the gecos field has it's own
+        # internal structure of comma-separated fields
+        author = passwd_data.pw_gecos.split(',')[0].strip()
+        if not author:
+            author = passwd_data.pw_name
+    if not email:
+        if 'EMAIL' in os.environ:
+            email = os.environ['EMAIL']
+        else:
+            email = "%s@%s" % (passwd_data.pw_name, socket.getfqdn())
+
+    return author, email
+
+
+def entries_from_commits(changelog, repo, commits, options):
+    """Generate a list of formatted changelog entries from a list of commits"""
+    entries = []
+    for commit in commits:
+        info = repo.get_commit_info(commit)
+        entry_text = ChangelogEntryFormatter.compose(info, full=options.full,
+                        ignore_re=options.ignore_regex, id_len=options.idlen)
+        if entry_text:
+            entries.append(changelog.create_entry(author=info['author'].name,
+                                                  text=entry_text))
+    return entries
+
+
+def update_changelog(changelog, entries, repo, spec, options):
+    """Update the changelog with a range of commits"""
+    # Get info for section header
+    now = datetime.now()
+    name, email = get_author(repo, options.git_author)
+    rev_str_fields = dict(spec.version,
+                          version=compose_version_str(spec.version),
+                          vendor=options.vendor,
+                          tagname=repo.describe('HEAD', longfmt=True,
+                                                always=True))
+    try:
+        revision = options.changelog_revision % rev_str_fields
+    except KeyError as err:
+        raise GbpError("Unable to construct revision field: unknown key "
+                "%s, only %s are accepted" % (err, rev_str_fields.keys()))
+
+    # Add a new changelog section if new release or an empty changelog
+    if options.release or not changelog.sections:
+        top_section = changelog.add_section(time=now, name=name,
+                                            email=email, revision=revision)
+    else:
+        # Re-use already parsed top section
+        top_section = changelog.sections[0]
+        top_section.set_header(time=now, name=name,
+                               email=email, revision=revision)
+
+    # Add new entries to the topmost section
+    for entry in entries:
+        top_section.append_entry(entry)
+
+
+def build_parser(name):
+    """Construct command line parser"""
+    try:
+        parser = GbpOptionParserRpm(command=os.path.basename(name),
+                                    prefix='', usage='%prog [options] paths')
+    except ConfigParser.ParsingError as err:
+        gbp.log.error('invalid config file: %s' % err)
+        return None
+
+    range_grp = GbpOptionGroup(parser, "commit range options",
+                    "which commits to add to the changelog")
+    format_grp = GbpOptionGroup(parser, "changelog entry formatting",
+                    "how to format the changelog entries")
+    naming_grp = GbpOptionGroup(parser, "naming",
+                    "branch names, tag formats, directory and file naming")
+    parser.add_option_group(range_grp)
+    parser.add_option_group(format_grp)
+    parser.add_option_group(naming_grp)
+
+    # Non-grouped options
+    parser.add_option("-v", "--verbose", action="store_true", dest="verbose",
+                    help="verbose command execution")
+    parser.add_config_file_option(option_name="color", dest="color",
+                    type='tristate')
+    parser.add_config_file_option(option_name="color-scheme",
+                    dest="color_scheme")
+    parser.add_config_file_option(option_name="tmp-dir", dest="tmp_dir")
+    parser.add_config_file_option(option_name="vendor", action="store",
+                    dest="vendor")
+    parser.add_config_file_option(option_name="git-log", dest="git_log",
+                    help="options to pass to git-log, default is '%(git-log)s'")
+    parser.add_boolean_config_file_option(option_name="ignore-branch",
+                    dest="ignore_branch")
+    parser.add_config_file_option(option_name="customizations",
+                    dest="customization_file",
+                    help="Load Python code from CUSTOMIZATION_FILE. At the "
+                    "moment, the only useful thing the code can do is define a "
+                    "custom ChangelogEntryFormatter class.")
+
+    # Naming group options
+    naming_grp.add_config_file_option(option_name="packaging-branch",
+                    dest="packaging_branch")
+    naming_grp.add_config_file_option(option_name="packaging-tag",
+                    dest="packaging_tag")
+    naming_grp.add_config_file_option(option_name="packaging-dir",
+                    dest="packaging_dir")
+    naming_grp.add_config_file_option(option_name="changelog-file",
+                    dest="changelog_file")
+    naming_grp.add_config_file_option(option_name="spec-file", dest="spec_file")
+    # Range group options
+    range_grp.add_option("-s", "--since", dest="since",
+                    help="commit to start from (e.g. HEAD^^^, release/0.1.2)")
+    # Formatting group options
+    format_grp.add_option("--no-release", action="store_false", default=True,
+                    dest="release",
+                    help="no release, just update the last changelog section")
+    format_grp.add_boolean_config_file_option(option_name="git-author",
+                    dest="git_author")
+    format_grp.add_boolean_config_file_option(option_name="full", dest="full")
+    format_grp.add_config_file_option(option_name="id-length", dest="idlen",
+                    help="include N digits of the commit id in the changelog "
+                         "entry, default is '%(id-length)s'",
+                    type="int", metavar="N")
+    format_grp.add_config_file_option(option_name="ignore-regex",
+                    dest="ignore_regex",
+                    help="Ignore lines in commit message matching regex, "
+                         "default is '%(ignore-regex)s'")
+    format_grp.add_config_file_option(option_name="changelog-revision",
+                    dest="changelog_revision")
+    format_grp.add_config_file_option(option_name="spawn-editor",
+                    dest="spawn_editor")
+    format_grp.add_config_file_option(option_name="editor-cmd",
+                    dest="editor_cmd")
+    return parser
+
+def parse_args(argv):
+    """Parse command line and config file options"""
+    parser = build_parser(argv[0])
+    if not parser:
+        return None, None
+
+    options, args = parser.parse_args(argv[1:])
+
+    if not options.changelog_revision:
+        options.changelog_revision = RpmPkgPolicy.Changelog.header_rev_format
+
+    gbp.log.setup(options.color, options.verbose, options.color_scheme)
+
+    return options, args
+
+def main(argv):
+    """Script main function"""
+    options, args = parse_args(argv)
+    if not options:
+        return 1
+
+    try:
+        init_tmpdir(options.tmp_dir, prefix='rpm-ch_')
+
+        load_customizations(options.customization_file)
+        editor_cmd = determine_editor(options)
+
+        repo = RpmGitRepository('.')
+        check_branch(repo, options)
+
+        # Find and parse spec file
+        spec = parse_spec_file(repo, options)
+
+        # Find and parse changelog file
+        ch_file = parse_changelog_file(repo, spec, options)
+        since = get_start_commit(ch_file.changelog, repo, options)
+
+        # Get range of commits from where to generate changes
+        if args:
+            gbp.log.info("Only looking for changes in '%s'" % ", ".join(args))
+        commits = repo.get_commits(since=since, until='HEAD', paths=args,
+                                   options=options.git_log.split(" "))
+        commits.reverse()
+        if not commits:
+            gbp.log.info("No changes detected from %s to %s." % (since, 'HEAD'))
+
+        # Do the actual update
+        entries = entries_from_commits(ch_file.changelog, repo, commits,
+                                       options)
+        update_changelog(ch_file.changelog, entries, repo, spec, options)
+
+        # Write to file
+        ch_file.write()
+
+        if editor_cmd:
+            gbpc.Command(editor_cmd, [ch_file.path])()
+
+    except (GbpError, GitRepositoryError, ChangelogError, NoSpecError) as err:
+        if len(err.__str__()):
+            gbp.log.err(err)
+        return 1
+    finally:
+        del_tmpdir()
+
+    return 0
+
+if __name__ == "__main__":
+    sys.exit(main(sys.argv))
diff --git a/tests/component/rpm/test_rpm_ch.py b/tests/component/rpm/test_rpm_ch.py
new file mode 100644 (file)
index 0000000..9076b29
--- /dev/null
@@ -0,0 +1,338 @@
+# vim: set fileencoding=utf-8 :
+#
+# (C) 2013 Intel Corporation <markus.lehtonen@linux.intel.com>
+#    This program is free software; you can redistribute it and/or modify
+#    it under the terms of the GNU General Public License as published by
+#    the Free Software Foundation; either version 2 of the License, or
+#    (at your option) any later version.
+#
+#    This program is distributed in the hope that it will be useful,
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#    GNU General Public License for more details.
+#
+#    You should have received a copy of the GNU General Public License
+#    along with this program; if not, write to the Free Software
+#    Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+"""Tests for the git-rpm-ch tool"""
+
+import os
+import re
+from nose.tools import assert_raises, eq_, ok_ # pylint: disable=E0611
+
+from gbp.scripts.rpm_ch import main as rpm_ch
+from gbp.git import GitRepository
+
+from tests.component.rpm import RpmRepoTestBase
+
+# Disable "Method could be a function warning"
+# pylint: disable=R0201
+
+
+def mock_ch(args):
+    """Wrapper for git-rpm-ch"""
+
+    return rpm_ch(['arg0', '--packaging-branch=master',
+                   '--spawn-editor=never'] + args)
+
+class TestRpmCh(RpmRepoTestBase):
+    """Basic tests for git-rpm-ch"""
+
+    def setup(self):
+        """Test case setup"""
+        super(TestRpmCh, self).setup()
+        # Set environment so that commits succeed without git config
+        os.environ['GIT_AUTHOR_NAME'] = 'My Name'
+        os.environ['GIT_COMMITTER_NAME'] = 'My Name'
+        os.environ['EMAIL'] = 'me@example.com'
+
+    @staticmethod
+    def read_file(filename):
+        """Read file to a list"""
+        with open(filename) as fobj:
+            return fobj.readlines()
+
+    def test_invalid_args(self):
+        """See that git-rpm-ch fails gracefully when called with invalid args"""
+        GitRepository.create('.')
+
+        with assert_raises(SystemExit):
+            mock_ch(['--invalid-opt'])
+
+    def test_import_outside_repo(self):
+        """Run git-rpm-ch when not in a git repository"""
+        eq_(mock_ch([]), 1)
+        self._check_log(0, 'gbp:error: No Git repository at ')
+
+    def test_invalid_config_file(self):
+        """Test invalid config file"""
+        # Create dummy invalid config file and run git-rpm-ch
+        GitRepository.create('.')
+        with open('.gbp.conf', 'w') as conffd:
+            conffd.write('foobar\n')
+        eq_(mock_ch([]), 1)
+        self._check_log(0, 'gbp:error: invalid config file: File contains no '
+                           'section headers.')
+
+    def test_update_spec_changelog(self):
+        """Test updating changelog in spec"""
+        repo = self.init_test_repo('gbp-test')
+        eq_(mock_ch([]), 0)
+        eq_(repo.status(), {' M': ['gbp-test.spec']})
+
+    def test_update_changes_file(self):
+        """Test updating a separate changes file"""
+        repo = self.init_test_repo('gbp-test-native')
+        eq_(mock_ch([]), 0)
+        eq_(repo.status(), {' M': ['packaging/gbp-test-native.changes']})
+
+    def test_create_spec_changelog(self):
+        """Test creating changelog in spec file"""
+        repo = self.init_test_repo('gbp-test2')
+        orig_content = self.read_file('packaging/gbp-test2.spec')
+
+        # Fails if no starting point is given
+        eq_(mock_ch([]), 1)
+        self._check_log(-1, "gbp:error: Couldn't determine starting point")
+
+        # Give starting point
+        eq_(mock_ch(['--since=HEAD^']), 0)
+        eq_(repo.status(), {' M': ['packaging/gbp-test2.spec']})
+        content = self.read_file('packaging/gbp-test2.spec')
+        # Should contain 4 lines (%changelog, header, 1 entry and an empty line)
+        eq_(len(content), len(orig_content) + 4)
+
+    def test_create_changes_file(self):
+        """Test creating a separate changes file"""
+        repo = self.init_test_repo('gbp-test2')
+
+        # Fails if no starting point is given
+        eq_(mock_ch(['--changelog-file=CHANGES']), 1)
+        self._check_log(-1, "gbp:error: Couldn't determine starting point")
+
+        # Give starting point
+        eq_(mock_ch(['--since=HEAD^', '--changelog-file=CHANGES']), 0)
+        eq_(repo.status(), {'??': ['packaging/gbp-test2.changes']})
+        content = self.read_file('packaging/gbp-test2.changes')
+        # Should contain 3 lines (header, 1 entry and an empty line)
+        eq_(len(content), 3)
+
+    def test_option_changelog_file(self):
+        """Test the --changelog-file cmdline option"""
+        repo = self.init_test_repo('gbp-test-native')
+
+        # Guess changelog file
+        eq_(mock_ch(['--changelog-file=CHANGES']), 0)
+        eq_(repo.status(), {' M': ['packaging/gbp-test-native.changes']})
+
+        # Use spec file as changelog
+        eq_(mock_ch(['--changelog-file=SPEC', '--since=HEAD^']), 0)
+        eq_(repo.status(), {' M': ['packaging/gbp-test-native.changes',
+                                   'packaging/gbp-test-native.spec']})
+
+        # Arbitrary name
+        eq_(mock_ch(['--changelog-file=foo.changes', '--since=HEAD^']), 0)
+        eq_(repo.status(), {' M': ['packaging/gbp-test-native.changes',
+                                   'packaging/gbp-test-native.spec'],
+                            '??': ['foo.changes']})
+
+    def test_option_spec_file(self):
+        """Test the --spec-file cmdline option"""
+        repo = self.init_test_repo('gbp-test2')
+
+        eq_(mock_ch(['--spec-file=foo.spec']), 1)
+        self._check_log(-1, "gbp:error: Unable to read spec file")
+
+        eq_(mock_ch(['--spec-file=']), 1)
+        self._check_log(-1, "gbp:error: Multiple spec files found")
+
+        eq_(mock_ch(['--spec-file=packaging/gbp-test2.spec', '--since=HEAD^']),
+            0)
+        eq_(repo.status(), {' M': ['packaging/gbp-test2.spec']})
+
+    def test_option_packaging_dir(self):
+        """Test the --packaging-dir cmdline option"""
+        repo = self.init_test_repo('gbp-test-native')
+
+        eq_(mock_ch(['--packaging-dir=foo']), 1)
+        self._check_log(-1, "gbp:error: No spec file found")
+
+        # Packaging dir should be taken from spec file if it is defined
+        eq_(mock_ch(['--packaging-dir', 'foo', '--spec-file',
+                     'packaging/gbp-test-native.spec']), 0)
+        eq_(repo.status(), {' M': ['packaging/gbp-test-native.changes']})
+
+    def test_branch_options(self):
+        """Test the --packaging-branch and --ignore-branch cmdline options"""
+        self.init_test_repo('gbp-test-native')
+
+        eq_(mock_ch(['--packaging-branch=foo']), 1)
+        self._check_log(-2, "gbp:error: You are not on branch 'foo'")
+
+        eq_(mock_ch(['--packaging-branch=foo', '--ignore-branch']), 0)
+
+    def test_option_no_release(self):
+        """Test the --no-release cmdline option"""
+        self.init_test_repo('gbp-test-native')
+        orig_content = self.read_file('packaging/gbp-test-native.changes')
+
+        eq_(mock_ch(['--no-release']), 0)
+        content = self.read_file('packaging/gbp-test-native.changes')
+        # Only one line (entry) added
+        eq_(len(content), len(orig_content) + 1)
+
+    def test_author(self):
+        """Test determining the author name/email"""
+        repo = self.init_test_repo('gbp-test-native')
+
+        # Test taking email address from env
+        os.environ['EMAIL'] = 'user@host.com'
+        eq_(mock_ch([]), 0)
+        header = self.read_file('packaging/gbp-test-native.changes')[0]
+        ok_(re.match(r'.+ <user@host\.com> .+', header))
+
+        # Missing git config setting should not cause a failure
+        del os.environ['EMAIL']
+        del os.environ['GIT_AUTHOR_NAME']
+        os.environ['GIT_CONFIG_NOSYSTEM'] = '1'
+        os.environ['HOME'] = os.path.abspath('.')
+        eq_(mock_ch(['--git-author', '--since=HEAD^1']), 0)
+
+        # Test the --git-author option
+        with open(os.path.join(repo.git_dir, 'config'), 'a') as fobj:
+            fobj.write('[user]\n  name=John Doe\n  email=jd@host.com\n')
+        eq_(mock_ch(['--git-author', '--since=HEAD^']), 0)
+        header = self.read_file('packaging/gbp-test-native.changes')[0]
+        ok_(re.match(r'.+ John Doe <jd@host\.com> .+', header), header)
+
+    def test_option_full(self):
+        """Test the --full cmdline option"""
+        repo = self.init_test_repo('gbp-test-native')
+        orig_content = self.read_file('packaging/gbp-test-native.changes')
+
+        eq_(mock_ch(['--full', '--since=HEAD^']), 0)
+        commit_msg_body = repo.get_commit_info('HEAD')['body']
+        full_msg = [line for line in commit_msg_body.splitlines() if line]
+        content = self.read_file('packaging/gbp-test-native.changes')
+        # New lines: header, 1 entry "header", entry "body" from commit message
+        # and one empty line
+        eq_(len(content), len(orig_content) + 3 + len(full_msg))
+
+    def test_option_ignore_regex(self):
+        """Test the --ignore-regex cmdline option"""
+        repo = self.init_test_repo('gbp-test-native')
+        orig_content = self.read_file('packaging/gbp-test-native.changes')
+
+        eq_(mock_ch(['--full', '--since', 'HEAD^', '--ignore-regex',
+                     'Signed-off-by:.*']), 0)
+        commit_msg_body = repo.get_commit_info('HEAD')['body']
+        full_msg = [line for line in commit_msg_body.splitlines() if
+                        (line and not line.startswith('Signed-off-by:'))]
+        content = self.read_file('packaging/gbp-test-native.changes')
+        # New lines: header, 1 entry "header", filtered entry "body" from
+        # commit message and one empty line
+        eq_(len(content), len(orig_content) + 3 + len(full_msg))
+
+    def test_option_id_len(self):
+        """Test the --id-len cmdline option"""
+        repo = self.init_test_repo('gbp-test-native')
+
+        eq_(mock_ch(['--id-len=10']), 0)
+        commit_id = repo.rev_parse('HEAD', 10)
+        content = self.read_file('packaging/gbp-test-native.changes')
+        ok_(content[1].startswith('- [%s] ' % commit_id))
+
+    def test_option_changelog_revision(self):
+        """Test the --id-len cmdline option"""
+        self.init_test_repo('gbp-test-native')
+
+        # Test invalid format (unknown field)
+        eq_(mock_ch(['--changelog-revision=%(unknown_field)s']), 1)
+        self._check_log(-1, 'gbp:error: Unable to construct revision field')
+
+        # Test acceptable format
+        eq_(mock_ch(['--changelog-revision=foobar']), 0)
+        header = self.read_file('packaging/gbp-test-native.changes')[0]
+        ok_(re.match(r'.+ foobar$', header))
+
+    def test_option_editor_cmd(self):
+        """Test the --editor-cmd and --spawn-editor cmdline options"""
+        repo = self.init_test_repo('gbp-test-native')
+        eq_(mock_ch(['--spawn-editor=release', '--editor-cmd=rm']), 0)
+        eq_(repo.status(), {' D': ['packaging/gbp-test-native.changes']})
+
+        repo.force_head('HEAD', hard=True)
+        ok_(repo.is_clean())
+
+        os.environ['EDITOR'] = 'rm'
+        eq_(mock_ch(['--spawn-editor=always', '--editor-cmd=']),
+            0)
+
+    def test_user_customizations(self):
+        """Test the user customizations"""
+        repo = self.init_test_repo('gbp-test-native')
+
+        # Non-existent customization file
+        eq_(mock_ch(['--customizations=customizations.py']), 1)
+
+        # Create user customizations file
+        with open('customizations.py', 'w') as fobj:
+            fobj.write("class ChangelogEntryFormatter(object):\n")
+            fobj.write("    @classmethod\n")
+            fobj.write("    def compose(cls, commit_info, **kwargs):\n")
+            fobj.write("        return ['- %s' % commit_info['id']]\n")
+
+        eq_(mock_ch(['--customizations=customizations.py']), 0)
+        entry = self.read_file('packaging/gbp-test-native.changes')[1]
+        sha = repo.rev_parse('HEAD')
+        eq_(entry, '- %s\n' % sha)
+
+    def test_paths(self):
+        """Test tracking of certain paths only"""
+        repo = self.init_test_repo('gbp-test-native')
+        orig_content = self.read_file('packaging/gbp-test-native.changes')
+
+        # Add new commit with known content
+        with open('new-file.txt', 'w') as fobj:
+            fobj.write('this is new content\n')
+        repo.add_files('new-file.txt')
+        repo.commit_staged('Add new file')
+
+        # Only track a non-existent file
+        eq_(mock_ch(['--since=HEAD^', 'non-existent-path']), 0)
+        content = self.read_file('packaging/gbp-test-native.changes')
+        # New lines: header and one empty line, no entries
+        eq_(len(content), len(orig_content) + 2)
+
+        # Track existing file
+        repo.force_head('HEAD', hard=True)
+        eq_(mock_ch(['--since=HEAD^', 'new-file.txt']), 0)
+        content = self.read_file('packaging/gbp-test-native.changes')
+        # New lines: header, one entry line and one empty line
+        eq_(len(content), len(orig_content) + 3)
+
+    def test_commit_guessing(self):
+        """Basic tests for guessing the starting point"""
+        repo = self.init_test_repo('gbp-test-native')
+
+        # Check 'tagname' that is not found
+        eq_(mock_ch(['--changelog-revision=%(tagname)s']), 0)
+        self._check_log(0, 'gbp:warning: Changelog points to tagname')
+
+        # Check 'upstreamversion' and 'release' fields
+        repo.force_head('HEAD', hard=True)
+        eq_(mock_ch(['--changelog-revision=%(upstreamversion)s-%(release)s']),
+            0)
+
+    def test_commit_guessing_fail(self):
+        """Test for failure of start commit guessing"""
+        repo = self.init_test_repo('gbp-test-native')
+
+        # Add "very old" header to changelog
+        with open('packaging/gbp-test-native.changes', 'w') as ch_fp:
+            ch_fp.write('* Sat Jan 01 2000 User <user@host.com> 123\n- foo\n')
+        # rpm-ch should fail by not being able to find any commits before the
+        # last changelog section
+        eq_(mock_ch([]), 1)
+        self._check_log(-1, "gbp:error: Couldn't determine starting point")
+