# vim: set fileencoding=utf-8 : # # (C) 2007, 2008, 2009, 2010, 2013 Guido Guenther # (C) 2014 Intel Corporation # 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.git.modifier import GitModifier from gbp.rpm import guess_spec, NoSpecError, SpecFile from gbp.rpm.changelog import Changelog, ChangelogParser, ChangelogError from gbp.rpm.git import GitRepositoryError, RpmGitRepository from gbp.rpm.policy import RpmPkgPolicy from gbp.scripts.buildpackage_rpm import packaging_tag_name from gbp.scripts.buildpackage_rpm import create_packaging_tag 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_repo_state(repo, options): """Check that the repository is in good state""" # Check branch branch = repo.get_branch() 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.") # Check unstaged changes if options.commit: unstaged = [] status = repo.status() for group, files in status.iteritems(): if group != '??' and group[1] != ' ': unstaged.extend(files) if unstaged: gbp.log.error("Unstaged changes in:\n %s" % '\n '.join(unstaged)) raise GbpError("Please commit or stage your changes before using " "the --commit or --tag option") def parse_spec_file(repo, options): """Find and parse spec file""" if options.spec_file != 'auto': 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(RpmPkgPolicy.split_full_version(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.all: since = None elif 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' or '--all'") 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, meta_bts=options.meta_bts) if entry_text: entries.append(changelog.create_entry(author=info['author'].name, text=entry_text)) return entries def entries_from_text(changelog, text, author): """Generate a list of changelog entries from a string""" entries = [] # Use current user as the author for all entries for line in text.splitlines(): if line.strip(): entry_text = "- %s" % line.strip() entries.append(changelog.create_entry(author=author, text=entry_text)) return entries def generate_new_entries(changelog, repo, options, args): """Generate new entries to be appended to changelog""" if options.message: author = get_author(repo, options.git_author)[0] entries = entries_from_text(changelog, options.message, author) else: # Get range of commits from where to generate changes since = get_start_commit(changelog, repo, options) 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')) entries = entries_from_commits(changelog, repo, commits, options) 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=RpmPkgPolicy.compose_full_version(spec.version), vendor=options.vendor) if options.commit: # Get fake information for the to-be-created git commit commit_info = {'author': GitModifier(date=now), 'committer': GitModifier(date=now)} tag = packaging_tag_name(repo, spec, commit_info, options) else: commit_info = {'author': None, 'committer': None} tag = repo.describe('HEAD', longfmt=True, always=True) rev_str_fields['tagname'] = tag 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) return (tag, commit_info['author'], commit_info['committer']) def create_commit_message(spec, options): """Generate commit message""" fields = spec.version fields['version'] = version=RpmPkgPolicy.compose_full_version(spec.version) fields['vendor'] = options.vendor try: return options.commit_msg % fields except KeyError as err: raise GbpError("Unknown key %s in commit-msg string, " "only %s are accepted" % (err, fields.keys())) def commit_changelog(repo, changelog, message, author, committer, edit): """Commit changelog and create a packaging/release tag""" repo.add_files(changelog.path) repo.commit_staged(message, author_info=author, committer_info=committer, edit=edit) def parse_args(argv): """Parse command line and config file options""" try: parser = GbpOptionParserRpm(command=os.path.basename(argv[0]), prefix='', usage='%prog [options] paths') except ConfigParser.ParsingError as err: gbp.log.error('invalid config file: %s' % err) return None, 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") commit_grp = GbpOptionGroup(parser, "commit", "automatic committing and tagging") parser.add_option_group(range_grp) parser.add_option_group(format_grp) parser.add_option_group(naming_grp) parser.add_option_group(commit_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="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)") range_grp.add_option("--all", action="store_true", help="use all commits from the Git history, overrides " "--since") # Formatting group options format_grp.add_config_file_option(option_name="meta-bts", dest="meta_bts") 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") format_grp.add_option("-m", '--message', help="text to use as new changelog entries - git commit " "messages and the --since are ignored in this case") # Commit/tag group options commit_grp.add_option("-c", "--commit", action="store_true", help="commit changes") commit_grp.add_config_file_option(option_name="commit-msg", dest="commit_msg") commit_grp.add_option("--tag", action="store_true", help="commit the changes and create a packaging/release" "tag") commit_grp.add_option("--retag", action="store_true", help="Overwrite packaging tag if it already exists") commit_grp.add_boolean_config_file_option(option_name="sign-tags", dest="sign_tags") commit_grp.add_config_file_option(option_name="keyid", dest="keyid") options, args = parser.parse_args(argv[1:]) if options.tag: options.commit = True 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: load_customizations(options.customization_file) editor_cmd = determine_editor(options) repo = RpmGitRepository('.') check_repo_state(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) # Get new entries entries = generate_new_entries(ch_file.changelog, repo, options, args) # Do the actual update tag, author, committer = update_changelog(ch_file.changelog, entries, repo, spec, options) # Write to file ch_file.write() if editor_cmd and not options.message: gbpc.Command(editor_cmd, [ch_file.path])() if options.commit: edit = True if editor_cmd else False msg = create_commit_message(spec, options) commit_changelog(repo, ch_file, msg, author, committer, edit) if options.tag: if options.retag and repo.has_tag(tag): repo.delete_tag(tag) create_packaging_tag(repo, tag, 'HEAD', spec.version, options) except (GbpError, GitRepositoryError, ChangelogError, NoSpecError) as err: if len(err.__str__()): gbp.log.err(err) return 1 return 0 if __name__ == "__main__": sys.exit(main(sys.argv))