#!/usr/bin/env python3 """ Generate Debian package changelog from git """ import argparse import logging import os import re import sys import textwrap import git from git.exc import GitCommandError VERSION = "0.0" DEFAULT_GIT_PATCH = "./" DEFAULT_CODE_NAME = "unstable" DEFAULT_URGENCY = "medium" parser = argparse.ArgumentParser(description=f"{__doc__} (version: {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 beginning 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=("Current 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( f"%(asctime)s - {os.path.basename(sys.argv[0])} - %(levelname)s : %(message)s" ) # 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) 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 if not options.version: log.info("Detect current version from git tags & commits") options.version = clean_deb_version(repo.git.describe("--always", "--tags")) log.info("Current version detected: %s", options.version) if options.output and options.append and not options.revision: log.info( "Append mode enabled but no revision specify, try to detect it from " "last modification of the changelog file" ) try: last_change_commit = next(repo.iter_commits(paths=options.output)) # pylint: disable=consider-using-f-string options.revision = f"{last_change_commit}..HEAD" log.info( 'Last change commit of the output file is "%s": use revision "%s"', last_change_commit, options.revision, ) except StopIteration: log.warning( "Fail to auto-detect last change commit of changelog file: it " "seem not tracked. Continue without revision." ) except GitCommandError: log.warning( "Fail to auto-detect last change commit of changelog file. May " "be it's outside of the git repository. Continue without " "revision." ) # Reset repo object of to avoid BrokenPipeError repo = git.Repo(options.git_path) log.info("Generate changelog from git commits") versions = [] tag_commits = {tag.commit.binsha: tag for tag in repo.tags} def add_version(): """Add version info""" global messages # pylint: disable=global-statement if not version_commit: return if not messages: messages = [f"Release version {version}"] 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 messages = [] for commit in repo.iter_commits(rev=options.revision): log.debug("Commit %s (%s)", commit, commit.summary) 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) if version_commit is None: version_commit = commit 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 not excluded: 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("{}{}\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, 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"] if versions: release_notes_lines.extend([f"* {message}\n" for message in versions[0]["messages"]]) else: release_notes_lines.extend([f"* Release version {options.version}\n"]) 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)