#!/usr/bin/python3 from MgaRepo import Error, config, layout from MgaRepo.svn import SVN from MgaRepo.util import execcmd from MgaRepo.util import get_output_exec from io import StringIO import sys import os import re import time import locale import glob import tempfile import shutil import subprocess locale.setlocale(locale.LC_ALL, "C") def getrelease(pkgdirurl, rev=None, macros=[], exported=None, create=False): """Tries to obtain the version-release of the package for a yet-not-markrelease revision of the package. Is here where things should be changed if "automatic release increasing" will be used. """ from MgaRepo.rpmutil import rpm_macros_defs svn = SVN() pkgcurrenturl = os.path.join(pkgdirurl, "current") if exported is None: tmpdir = tempfile.mktemp() svn.export(pkgcurrenturl, tmpdir, rev=rev) topdir = tmpdir else: topdir = exported try: options = rpm_macros_defs(macros) if not create: found = glob.glob(os.path.join(topdir, "SPECS", "*.spec")) if not found: raise Error("no .spec file found inside %s" % specurl) specpath = found[0] command = (("rpm -q --qf '%%{EPOCH}:%%{VERSION}-%%{RELEASE}\n' " "--specfile %s %s --define '_topdir %s'") % (specpath, options, topdir)) else: found = glob.glob(os.path.join(topdir, "SRPMS", "*.src.rpm")) if not found: raise Error("no .src.rpm file found inside %s" % srpmurl) srpmpath = found[0] command = (("rpm -qp --qf '%%{EPOCH}:%%{VERSION}-%%{RELEASE}\n' " " %s %s --define '_topdir %s'") % (srpmpath, options, topdir)) pipe = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True) pipe.wait() output = pipe.stdout.read().decode('utf8') error = pipe.stderr.read().decode('utf8') if pipe.returncode != 0: raise Error("Error in command %s: %s" % (command, error)) releases = output.split() try: epoch, vr = releases[0].split(":", 1) version, release = vr.split("-", 1) except ValueError: raise Error("Invalid command output: %s: %s" % \ (command, output)) #XXX check if this is the right way: if epoch == "(none)": ev = version else: ev = epoch + ":" + version return ev, release finally: if exported is None and os.path.isdir(tmpdir): shutil.rmtree(tmpdir) class _Revision: lines = [] date = None raw_date = None revision = None author_name = None author_email = None def __init__(self, **kwargs): self.__dict__.update(kwargs) def __repr__(self): lines = repr(self.lines)[:30] + "...]" line = "<_Revision %d author=%r date=%r lines=%s>" % \ (self.revision, self.author, self.date, lines) return line class _Release(_Revision): version = None release = None revisions = [] release_revisions = [] authors = [] visible = False def __init__(self, **kwargs): self.revisions = [] _Revision.__init__(self, **kwargs) def __repr__(self): line = "<_Release v=%s r=%s revs=%r>" % \ (self.version, self.release, self.revisions) return line unescaped_macro_pat = re.compile(r"(^|[^%])%([^%])") def escape_macros(text): escaped = unescaped_macro_pat.sub("\\1%%\\2", text) return escaped def format_lines(lines): first = 1 entrylines = [] perexpr = re.compile(r"([^%])%([^%])") for line in lines: if line: line = escape_macros(line) if first: first = 0 line = line.lstrip() if line[0] != "-": nextline = "- " + line else: nextline = line elif line[0] != " " and line[0] != "-": nextline = " " + line else: nextline = line if nextline not in entrylines: entrylines.append(nextline) return entrylines class _Author: name = None email = None revisions = None visible = False def group_releases_by_author(releases): allauthors = [] grouped = [] for release in releases: # group revisions of the release by author authors = {} latest = None for revision in release.revisions: authors.setdefault(revision.author, []).append(revision) # create _Authors and sort them by their latest revisions decorated = [] for authorname, revs in authors.items(): author = _Author() author.name = revs[0].author_name author.email = revs[0].author_email author.revisions = revs # #41117: mark those authors without visible messages author.visible = bool(sum(len(rev.lines) for rev in revs)) revlatest = author.revisions[0] # keep the latest revision even for completely invisible # authors (below) if latest is None or revlatest.revision > latest.revision: latest = revlatest if not author.visible: # only sort those visible authors, invisible ones are used # only in "latest" continue decorated.append((revlatest.revision, author)) decorated.sort(reverse=1) if release.visible: release.authors = [t[1] for t in decorated] firstrel, release.authors = release.authors[0], release.authors[1:] release.author_name = firstrel.name release.author_email = firstrel.email release.release_revisions = firstrel.revisions else: # we don't care about other possible authors in completely # invisible releases firstrev = release.revisions[0] release.author_name = firstrev.author_name release.author_email = firstrev.author_email release.raw_date = firstrev.raw_date release.date = firstrev.date release.date = latest.date release.raw_date = latest.raw_date release.revision = latest.revision grouped.append(release) return grouped def group_revisions_by_author(currentlog): revisions = [] last_author = None for entry in currentlog: revision = _Revision() revision.lines = format_lines(entry.lines) revision.raw_date = entry.date revision.date = parse_raw_date(entry.date) revision.revision = entry.revision if entry.author == last_author: revisions[-1].revisions.append(revision) else: author = _Author() author.name, author.email = get_author_name(entry.author) author.revisions = [revision] revisions.append(author) last_author = entry.author return revisions emailpat = re.compile("(?P.*?)\s*<(?P.*?)>") def get_author_name(author): found = emailpat.match(config.get("users", author, author)) name = ((found and found.group("name")) or author) email = ((found and found.group("email")) or author) return name, email def parse_raw_date(rawdate): return time.strftime("%a %b %d %Y", rawdate) def filter_log_lines(lines): # Lines in commit messages beginning with CLOG will be the only shown # in the changelog. These lines will have the CLOG token and blanks # stripped from the beginning. onlylines = None clogstr = config.get("log", "unignore-string") if clogstr: clogre = re.compile(r"(^%s[^ \t]?[ \t])" % clogstr) onlylines = [clogre.sub("", line) for line in lines if line.startswith(clogstr)] if onlylines: filtered = onlylines else: # Lines in commit messages containing SILENT at any position will be # skipped; commits with their log messages beggining with SILENT in the # first positionj of the first line will have all lines ignored. ignstr = config.get("log", "ignore-string", "SILENT") if len(lines) and lines[0].startswith(ignstr): return [] filtered = [line for line in lines if ignstr not in line] return filtered def make_release(author=None, revision=None, date=None, lines=None, entries=[], released=True, version=None, release=None): rel = _Release() rel.author = author if author: rel.author_name, rel.author_email = get_author_name(author) rel.revision = revision rel.version = version rel.release = release rel.date = (date and parse_raw_date(date)) or None rel.lines = lines rel.released = released rel.visible = False for entry in entries: lines = filter_log_lines(entry.lines) revision = _Revision() revision.revision = entry.revision revision.lines = format_lines(lines) if revision.lines: rel.visible = True revision.date = parse_raw_date(entry.date) revision.raw_date = entry.date revision.author = entry.author (revision.author_name, revision.author_email) = \ get_author_name(entry.author) rel.revisions.append(revision) return rel def dump_file(releases, currentlog=None, template=None): ''' Template cheetah suppressed and replaced by hard code. The template is selectable with the "name" in "template" section of config file.''' templname = template or config.get("template", "name", "default") draft="" releases_author = group_releases_by_author(releases) revisions_author = group_revisions_by_author(currentlog) if templname == 'revno': ''' a specific template''' for rel in releases_author: if not rel.released: draft = " (not released yet)\n" draft = draft + "* {0} {1} <{2}> {3}-{4}\n\n".format(rel.date, rel.author_name, rel.author_email, rel.version, rel.release) for rev in rel.release_revisions: first=True spaces = " " * (len(str(rev.revision)) +3) for line in rev.lines: if first: draft = draft +"[{0}] {1}\n".format(rev.revision, line) first = False else: draft = draft + spaces + line + "\n" for author in rel.authors: if not author.visible: continue draft += "+ {0} <{1}>\n".format(author.name, author.email) for rev in author.revisions: first=True spaces = " " * (len(str(rev.revision)) + 3) for line in rev.lines: if first: draft = draft +"[{0}] {1}\n".format(rev.revision, line) first = False else: draft = draft + spaces + line + "\n" draft += '\n' else: # default template if not releases_author[-1].visible: releases_author = releases_author[:-1] for rel in releases_author: draft = draft + "* {0} {1} <{2}> {3}-{4}\n+ Revision: {5}\n".format(rel.date, rel.author_name, rel.author_email, rel.version, rel.release, rel.revision) if not rel.visible: draft = draft + "+ rebuild (emptylog)\n" for rev in rel.release_revisions: for line in rev.lines: draft = draft + line + "\n" for author in rel.authors: if not author.visible: continue draft += "+ {0} <{1}>\n".format(author.name, author.email) for rev in author.revisions: for line in rev.lines: draft = draft + line + "\n" draft += "\n" return draft class InvalidEntryError(Exception): pass def parse_repsys_entry(revlog): # parse entries in the format: # %repsys # key: value # .. # # # if len(revlog.lines) == 0 or not revlog.lines[0].startswith("%repsys"): raise InvalidEntryError try: data = {"operation" : revlog.lines[0].split()[1]} except IndexError: raise InvalidEntryError for line in revlog.lines[1:]: if not line: break try: key, value = line.split(":", 1) except ValueError: raise InvalidEntryError data[key.strip().lower()] = value.strip() # ??? return data def get_revision_offset(): try: revoffset = config.getint("log", "revision-offset", 0) except (ValueError, TypeError): raise Error("Invalid revision-offset number in configuration " "file(s).") return revoffset or 0 oldmsgpat = re.compile( r"Copying release (?P[^\s]+) to (?P[^\s]+) directory\.") def parse_markrelease_log(relentry): if not ((relentry.lines and oldmsgpat.match(relentry.lines[0]) \ or parse_repsys_entry(relentry))): raise InvalidEntryError from_rev = None path = None for changed in relentry.changed: if changed["action"] == "A" and changed["from_rev"]: from_rev = changed["from_rev"] path = changed["path"] break else: raise InvalidEntryError # get the version and release from the names in the path, do not relay # on log messages version, release = path.rsplit(os.path.sep, 3)[-2:] return version, release, from_rev def svn2rpm(pkgdirurl, rev=None, size=None, submit=False, template=None, macros=[], exported=None, create=False): concat = config.get("log", "concat", "").split() revoffset = get_revision_offset() svn = SVN() pkgreleasesurl = layout.checkout_url(pkgdirurl, releases=True) pkgcurrenturl = layout.checkout_url(pkgdirurl) releaseslog = svn.log(pkgreleasesurl, noerror=1) currentlog = svn.log(pkgcurrenturl, limit=size, start=rev, end=revoffset) # sort releases by copyfrom-revision, so that markreleases for same # revisions won't look empty releasesdata = [] if releaseslog: for relentry in releaseslog[::-1]: try: (version, release, relrevision) = \ parse_markrelease_log(relentry) except InvalidEntryError: continue releasesdata.append((relrevision, -relentry.revision, relentry, version, release)) releasesdata.sort() # collect valid releases using the versions provided by the changes and # the packages prevrevision = 0 releases = [] for (relrevision, dummy, relentry, version, release) in releasesdata: if prevrevision == relrevision: # ignore older markrelease of the same revision, since they # will have no history continue entries = [entry for entry in currentlog if relrevision >= entry.revision and (prevrevision < entry.revision)] if not entries: #XXX probably a forced release, without commits in current/, # check if this is the right behavior sys.stderr.write("warning: skipping (possible) release " "%s-%s@%s, no commits since previous markrelease (r%r)\n" % (version, release, relrevision, prevrevision)) continue release = make_release(author=relentry.author, revision=relentry.revision, date=relentry.date, lines=relentry.lines, entries=entries, version=version, release=release) releases.append(release) prevrevision = relrevision # look for commits that have been not submitted (released) yet # this is done by getting all log entries newer (greater revision no.) # than releasesdata[-1] (in the case it exists) if releasesdata: latest_revision = releasesdata[-1][0] # the latest copied rev else: latest_revision = 0 notsubmitted = [entry for entry in currentlog if entry.revision > latest_revision] if notsubmitted: # if they are not submitted yet, what we have to do is to add # a release/version number from getrelease() version, release = getrelease(pkgdirurl, macros=macros, exported=exported, create=create) toprelease = make_release(entries=notsubmitted, released=False, version=version, release=release) releases.append(toprelease) data = dump_file(releases[::-1], currentlog=currentlog, template=template) return data def _split_changelog(stream): current = None count = 0 def finish(entry): lines = entry[2] # strip newlines at the end for i in range(len(lines)-1, -1, -1): if lines[i] != "\n": break del lines[i] return entry for line in stream: if line.startswith("*"): if current: yield finish(current) fields = line.split() rawdate = " ".join(fields[:5]) try: date = time.strptime(rawdate, "* %a %b %d %Y") except ValueError as e: raise Error("failed to parse spec changelog: %s" % e) curlines = [line] current = (date, count, curlines) # count used to ensure stable sorting when changelog entries # have the same date, otherwise it would also compare the # changelog lines count -= 1 elif current: curlines.append(line) else: pass # not good, but ignore if current: yield finish(current) def sort_changelog(stream): entries = _split_changelog(stream) log = StringIO() for time, count, elines in sorted(entries, reverse=True): log.writelines(elines) log.write("\n") return log def split_spec_changelog(stream): chlog = StringIO() spec = StringIO() found = 0 visible = 0 for line in stream: if line.startswith("%changelog"): found = 1 elif not found: spec.write(line) elif found: if line.strip(): visible = 1 chlog.write(line) elif line.startswith("%"): found = 0 spec.write(line) spec.seek(0) if not visible: # when there are only blanks in the changelog, make it empty chlog = StringIO() return spec, chlog def get_old_log(pkgdirurl): chlog = StringIO() oldurl = config.get("log", "oldurl") if oldurl: svn = SVN() tmpdir = tempfile.mktemp() try: if oldurl == '.' or oldurl.startswith('./'): pkgoldurl = os.path.join(pkgdirurl, oldurl) else: pkgname = layout.package_name(pkgdirurl) pkgoldurl = os.path.join(oldurl, pkgname) try: # we're using HEAD here because fixes in misc/ (oldurl) may # be newer than packages' last changed revision. svn.export(pkgoldurl, tmpdir) except Error: pass else: logfile = os.path.join(tmpdir, "log") if os.path.isfile(logfile): with open(logfile, 'r', encoding = 'utf-8') as lf: chlog.write("\n") # TODO needed? log = lf.read() log = escape_macros(log) chlog.write(log) finally: if os.path.isdir(tmpdir): shutil.rmtree(tmpdir) chlog.seek(0) return chlog def get_changelog(pkgdirurl, another=None, svn=True, rev=None, size=None, submit=False, sort=False, template=None, macros=[], exported=None, oldlog=False, create=False): """Generates the changelog for a given package URL @another: a stream with the contents of a changelog to be merged with the one generated @svn: enable changelog from svn @rev: generate the changelog with the changes up to the given revision @size: the number of revisions to be used (as in svn log --limit) @submit: defines whether the latest unreleased log entries should have the version parsed from the spec file @sort: should changelog entries be reparsed and sorted after appending the oldlog? @template: the path to the cheetah template used to generate the changelog from svn @macros: a list of tuples containing macros to be defined when parsing the version in the changelog @exported: the path of a directory containing an already existing checkout of the package, so that the spec file can be parsed from there @oldlog: if set it will try to append the old changelog file defined in oldurl in mgarepo.conf @create: if set, will use rpm -qp rpm instead of --specfile to get release number """ newlog = StringIO() if svn: rawsvnlog = svn2rpm(pkgdirurl, rev=rev, size=size, submit=submit, template=template, macros=macros, exported=exported, create=create) newlog.write(rawsvnlog) if another: newlog.writelines(another) if oldlog: newlog.writelines(get_old_log(pkgdirurl)) if sort: newlog.seek(0) newlog = sort_changelog(newlog) newlog.seek(0) return newlog def specfile_svn2rpm(pkgdirurl, specfile, rev=None, size=None, submit=False, sort=False, template=None, macros=[], exported=None, create=False): with open(specfile, encoding = 'utf-8') as fi: spec, oldchlog = split_spec_changelog(fi) another = None if config.getbool("log", "merge-spec", False): another = oldchlog sort = sort or config.getbool("log", "sort", False) chlog = get_changelog(pkgdirurl, another=another, rev=rev, size=size, submit=submit, sort=sort, template=template, macros=macros, exported=exported, oldlog=True, create=create) with open(specfile, "w", encoding='utf-8') as fo: fo.writelines(spec) fo.write("\n\n%changelog\n") fo.writelines(chlog) if __name__ == "__main__": l = svn2rpm(sys.argv[1], create=True) print(l) # vim:et:ts=4:sw=4