#!/usr/bin/env python3 """ Generate Debian package changelog from git """ import argparse import logging import os import re import sys import textwrap import git VERSION = '0.0' DEFAULT_GIT_PATCH = './' DEFAULT_CODE_NAME = 'unstable' DEFAULT_URGENCY = 'medium' parser = argparse.ArgumentParser( description='{0} (version: {1})'.format(__doc__, VERSION) ) parser.add_argument( '-d', '--debug', action='store_true', help='Show debug messages' ) parser.add_argument( '-v', '--verbose', action='store_true', help='Show verbose messages' ) parser.add_argument( '-w', '--warning', action='store_true', help='Show warning messages' ) parser.add_argument( '-l', '--log-file', action="store", type=str, dest="logfile", help="Log file path" ) parser.add_argument( '-q', '--quiet', action='store_true', help='Quiet mode: do not log on console (only if log file is provided)' ) parser.add_argument( '-p', '--path', type=str, dest='git_path', help='Git repository path (default: %s)' % DEFAULT_GIT_PATCH, default=DEFAULT_GIT_PATCH ) parser.add_argument( '-o', '--output', type=str, dest='output', help='Generated Debian changelog output path (default: stdout)', ) parser.add_argument( '-A', '--append', action='store_true', dest='append', help=( 'Append mode: if the output changelog file already exists, append ' 'generated changelog lines at the begining of the file (optional, ' 'default: overwriting the file)' ) ) parser.add_argument( '-n', '--package-name', type=str, dest='package_name', help='Package name' ) parser.add_argument( '-V', '--version', type=str, dest='version', help=( 'Currrent version (default: autodetected using git describe ' '--always --tags)') ) parser.add_argument( '--version-suffix', type=str, dest='version_suffix', help='Suffix for autodetected version' ) parser.add_argument( '-c', '--code-name', type=str, dest='code_name', help='Debian code name (default: %s)' % DEFAULT_CODE_NAME, default=DEFAULT_CODE_NAME ) parser.add_argument( '-u', '--urgency', type=str, dest='urgency', help='Package urgency (default: %s)' % DEFAULT_URGENCY, default=DEFAULT_URGENCY ) parser.add_argument( '-N', '--maintainer-name', type=str, dest='maintainer_name', help='Maintainer name (default: last commit author name)' ) parser.add_argument( '-E', '--maintainer-email', type=str, dest='maintainer_email', help='Maintainer email (default: last commit author email)' ) parser.add_argument( '-R', '--release-notes', type=str, dest='release_notes', help='Specify an optional Markdown release notes output path' ) parser.add_argument( '--revision', type=str, dest='revision', help=( 'Specify the revision to use to generate the changelog (see ' 'git-rev-parse for viable options, optional, default: generate the ' 'changelog with all commits of the current branch) ' ), default=None ) parser.add_argument( '-C', '--clean-tags-regex', action='append', type=re.compile, dest='clean_tags_regex', help=( 'Clean tags regex: you could specify regex to clean tag names when ' 'computing package versions. For instance, to drop a "-eeXXX" suffix ' 'of tag names, specify -C "\\-ee[0-9]{3}$" (optional, multiple regex ' 'allowed)' ), default=[] ) parser.add_argument( '-x', '--exclude', action='append', type=re.compile, dest='exclude', help=( 'Commit exclusion regex: you could specify regex to exclude some ' 'commits from generated changelog entries. For instance, to exclude ' 'commits with message starting with "CI: ", specify -x "^CI: " ' '(optional, multiple regex allowed)' ), default=[] ) options = parser.parse_args() if not options.package_name: parser.error( 'You must provide package name using -n/--package-name parameter') # Initialize log log = logging.getLogger() logformat = logging.Formatter( '%(asctime)s - {} - %(levelname)s : %(message)s'.format( os.path.basename(sys.argv[0]) ) ) # Set root logger to DEBUG (filtering done by handlers) log.setLevel(logging.DEBUG) log_level = None if options.debug: log_level = logging.DEBUG elif options.verbose: log_level = logging.INFO elif options.warning: log_level = logging.WARNING if options.logfile: logfile = logging.FileHandler(options.logfile) logfile.setFormatter(logformat) logfile.setLevel(log_level if log_level is not None else logging.INFO) log.addHandler(logfile) if not options.quiet or not options.logfile: logconsole = logging.StreamHandler() logconsole.setLevel(log_level if log_level is not None else logging.FATAL) logconsole.setFormatter(logformat) log.addHandler(logconsole) repo = git.Repo(options.git_path) log.info('Generate changelog from git commits') versions = [] tag_commits = dict( (tag.commit.binsha, tag) for tag in repo.tags ) def clean_deb_version(version_name): """ Clean debian version name """ version_name = re.sub('^[^0-9]*', '', version_name) for clean_regex in options.clean_tags_regex: version_name = clean_regex.sub('', version_name) if options.version_suffix: version_name += options.version_suffix return version_name def add_version(): """ Add version info """ if messages: log.info('Add version %s:\n - %s', version, '\n - '.join(messages)) versions.append({ 'name': version, 'tag': tag, 'commit': version_commit, 'messages': messages, }) tag = None version_commit = None version = ( options.version or clean_deb_version( repo.git.describe('--always', '--tags') ) ) messages = [] for commit in repo.iter_commits(rev=options.revision): if version_commit is None: version_commit = commit log.debug('Commit %s (%s)', commit, commit.summary) excluded = False for regex in options.exclude: if regex.search(commit.summary): excluded = True log.debug( 'Exclude commit %s ("%s", match with "%s")', commit, commit.summary, regex.pattern) if excluded: continue if commit.binsha in tag_commits: new_tag = tag_commits[commit.binsha] log.debug('Reach new tag %s', new_tag) add_version() tag = new_tag version = clean_deb_version(tag.name) version_commit = commit messages = [] log.debug('Iter commits for version %s', version) messages.append(commit.summary) add_version() log.info('%d versions found', len(versions)) changelog_lines = [] for version in versions: # pylint: disable=consider-using-f-string changelog_lines.append( '{package} ({version}) {code_name}; urgency={urgency}\n\n'.format( package=options.package_name, version=version['name'], code_name=options.code_name, urgency=options.urgency ) ) for message in version['messages']: for idx, line in enumerate( textwrap.wrap(message, 76, break_long_words=True) ): # pylint: disable=consider-using-f-string changelog_lines.append( '{0}{1}\n'.format( ' * ' if not idx else ' ', line ) ) # pylint: disable=consider-using-f-string changelog_lines.append( "\n -- {name} <{email}> {date}\n\n".format( name=( options.maintainer_name if options.maintainer_name else version['commit'].author.name), email=( options.maintainer_email if options.maintainer_email else version['commit'].author.email), date=version['commit'].committed_datetime.strftime( "%a, %d %b %Y %H:%M:%S %z") ) ) if options.output: log.info('Write generated Debian changelog in file %s', options.output) if options.append and os.path.exists(options.output): with open(options.output, 'r', encoding='utf8') as fd: changelog_lines += [""] changelog_lines += fd.readlines() with open(options.output, 'w', encoding='utf8') as fd: fd.writelines(changelog_lines) else: print(''.join(changelog_lines)) if options.release_notes: log.info('Generate Markdown release notes') release_notes_lines = ['# Changelog:\n\n'] release_notes_lines.extend([ '* {0}\n'.format(message) for message in versions[0]['messages'] ]) log.info( 'Write generated Markdown release notes in file %s', options.release_notes) with open(options.release_notes, 'w', encoding='utf8') as fd: fd.writelines(release_notes_lines)