# -*- coding:utf-8 -*- # Copyright 1999-2019 Gentoo Authors # Distributed under the terms of the GNU General Public License v2 from __future__ import print_function, unicode_literals import errno import io import logging import platform import re import shutil import signal import sys import tempfile import time from itertools import chain try: from urllib.parse import parse_qs, urlsplit, urlunsplit except ImportError: from urlparse import parse_qs, urlsplit, urlunsplit from _emerge.UserQuery import UserQuery from repoman._portage import portage from portage import os from portage import _encodings from portage import _unicode_encode from portage.output import ( bold, create_color_func, green, red) from portage.package.ebuild.digestgen import digestgen from portage.util import writemsg_level from repoman.copyrights import update_copyright from repoman.gpg import gpgsign, need_signature from repoman import utilities from repoman.modules.vcs.vcs import vcs_files_to_cps from repoman import VERSION bad = create_color_func("BAD") class Actions(object): '''Handles post check result output and performs the various vcs activities for committing the results''' def __init__(self, repo_settings, options, scanner, vcs_settings): self.repo_settings = repo_settings self.options = options self.scanner = scanner self.vcs_settings = vcs_settings self.repoman_settings = repo_settings.repoman_settings self.suggest = { 'ignore_masked': False, 'include_dev': False, } if scanner.have['pmasked'] and not (options.without_mask or options.ignore_masked): self.suggest['ignore_masked'] = True if scanner.have['dev_keywords'] and not options.include_dev: self.suggest['include_dev'] = True def inform(self, can_force, result): '''Inform the user of all the problems found''' if ((self.suggest['ignore_masked'] or self.suggest['include_dev']) and not self.options.quiet): self._suggest() if self.options.mode != 'commit': self._non_commit(result) return False else: self._fail(result, can_force) if self.options.pretend: utilities.repoman_sez( "\"So, you want to play it safe. Good call.\"\n") return True def perform(self, qa_output): myautoadd = self._vcs_autoadd() self._vcs_deleted() changes = self.get_vcs_changed() mynew, mychanged, myremoved, no_expansion, expansion = changes # Manifests need to be regenerated after all other commits, so don't commit # them now even if they have changed. mymanifests = set() myupdates = set() for f in mychanged + mynew: if "Manifest" == os.path.basename(f): mymanifests.add(f) else: myupdates.add(f) myupdates.difference_update(myremoved) myupdates = list(myupdates) mymanifests = list(mymanifests) myheaders = [] commitmessage = self.options.commitmsg if self.options.commitmsgfile: try: f = io.open( _unicode_encode( self.options.commitmsgfile, encoding=_encodings['fs'], errors='strict'), mode='r', encoding=_encodings['content'], errors='replace') commitmessage = f.read() f.close() del f except (IOError, OSError) as e: if e.errno == errno.ENOENT: portage.writemsg( "!!! File Not Found:" " --commitmsgfile='%s'\n" % self.options.commitmsgfile) else: raise if commitmessage[:9].lower() in ("cat/pkg: ",): commitmessage = self.msg_prefix() + commitmessage[9:] if commitmessage is not None and commitmessage.strip(): res, expl = self.verify_commit_message(commitmessage) if not res: print(bad("RepoMan does not like your commit message:")) print(expl) if self.options.force: print('(but proceeding due to --force)') else: sys.exit(1) else: commitmessage = None msg_qa_output = qa_output initial_message = None while True: commitmessage = self.get_new_commit_message( msg_qa_output, commitmessage) res, expl = self.verify_commit_message(commitmessage) if res: break else: full_expl = '''Issues with the commit message were found. Please fix it or remove the whole commit message to abort. ''' + expl msg_qa_output = ( [' %s\n' % x for x in full_expl.splitlines()] + qa_output) commitmessage = commitmessage.rstrip() # Update copyright for new and changed files year = time.strftime('%Y', time.gmtime()) updated_copyright = [] for fn in chain(mynew, mychanged): if fn.endswith('.diff') or fn.endswith('.patch'): continue if update_copyright(fn, year, pretend=self.options.pretend): updated_copyright.append(fn) if updated_copyright and not ( self.options.pretend or self.repo_settings.repo_config.thin_manifest): for cp in sorted(self._vcs_files_to_cps(iter(updated_copyright))): self._manifest_gen(cp) myupdates, broken_changelog_manifests = self.changelogs( myupdates, mymanifests, myremoved, mychanged, myautoadd, mynew, commitmessage) lines = commitmessage.splitlines() lastline = lines[-1] if len(lines) == 1 or re.match(r'^\S+:\s', lastline) is None: commitmessage += '\n' commit_footer = self.get_commit_footer() commitmessage += commit_footer print("* %s files being committed..." % green(str(len(myupdates))), end=' ') if not self.vcs_settings.needs_keyword_expansion: # With some VCS types there's never any keyword expansion, so # there's no need to regenerate manifests and all files will be # committed in one big commit at the end. logging.debug("VCS type doesn't need keyword expansion") print() elif not self.repo_settings.repo_config.thin_manifest: logging.debug("perform: Calling thick_manifest()") self.vcs_settings.changes.thick_manifest(myupdates, myheaders, no_expansion, expansion) logging.info("myupdates: %s", myupdates) logging.info("myheaders: %s", myheaders) uq = UserQuery(self.options) if self.options.ask and uq.query('Commit changes?', True) != 'Yes': print("* aborting commit.") sys.exit(128 + signal.SIGINT) # Handle the case where committed files have keywords which # will change and need a priming commit before the Manifest # can be committed. if (myupdates or myremoved) and myheaders: self.priming_commit(myupdates, myremoved, commitmessage) # When files are removed and re-added, the cvs server will put /Attic/ # inside the $Header path. This code detects the problem and corrects it # so that the Manifest will generate correctly. See bug #169500. # Use binary mode in order to avoid potential character encoding issues. self.vcs_settings.changes.clear_attic(myheaders) if self.scanner.repolevel == 1: utilities.repoman_sez( "\"You're rather crazy... " "doing the entire repository.\"\n") self.vcs_settings.changes.digest_regen(myupdates, myremoved, mymanifests, self.scanner, broken_changelog_manifests) if self.repo_settings.sign_manifests: self.sign_manifest(myupdates, myremoved, mymanifests) self.vcs_settings.changes.update_index(mymanifests, myupdates) self.add_manifest(mymanifests, myheaders, myupdates, myremoved, commitmessage) if self.options.quiet: return print() if self.vcs_settings.vcs: print("Commit complete.") else: print( "repoman was too scared" " by not seeing any familiar version control file" " that he forgot to commit anything") utilities.repoman_sez( "\"If everyone were like you, I'd be out of business!\"\n") return def _vcs_files_to_cps(self, vcs_file_iter): """ Iterate over the given modified file paths returned from the vcs, and return a frozenset containing category/pn strings for each modified package. @param vcs_file_iter: file paths from vcs module @type iter @rtype: frozenset @return: category/pn strings for each package. """ return vcs_files_to_cps( vcs_file_iter, self.repo_settings.repodir, self.scanner.repolevel, self.scanner.reposplit, self.scanner.categories) def _manifest_gen(self, cp): """ Generate manifest for a cp. @param cp: category/pn string @type str @rtype: bool @return: True if successful, False otherwise """ self.repoman_settings["O"] = os.path.join(self.repo_settings.repodir, cp) return bool(digestgen( mysettings=self.repoman_settings, myportdb=self.repo_settings.portdb)) def _suggest(self): print() if self.suggest['ignore_masked']: print(bold( "Note: use --without-mask to check " "KEYWORDS on dependencies of masked packages")) if self.suggest['include_dev']: print(bold( "Note: use --include-dev (-d) to check " "dependencies for 'dev' profiles")) print() def _non_commit(self, result): if result['full']: print(bold("Note: type \"repoman full\" for a complete listing.")) if result['warn'] and not result['fail']: if self.options.quiet: print(bold("Non-Fatal QA errors found")) else: utilities.repoman_sez( "\"You're only giving me a partial QA payment?\n" " I'll take it this time, but I'm not happy.\"" ) elif not result['fail']: if self.options.quiet: print("No QA issues found") else: utilities.repoman_sez( "\"If everyone were like you, I'd be out of business!\"") elif result['fail']: print(bad("Please fix these important QA issues first.")) if not self.options.quiet: utilities.repoman_sez( "\"Make your QA payment on time" " and you'll never see the likes of me.\"\n") sys.exit(1) def _fail(self, result, can_force): if result['fail'] and can_force and self.options.force and not self.options.pretend: utilities.repoman_sez( " \"You want to commit even with these QA issues?\n" " I'll take it this time, but I'm not happy.\"\n") elif result['fail']: if self.options.force and not can_force: print(bad( "The --force option has been disabled" " due to extraordinary issues.")) print(bad("Please fix these important QA issues first.")) utilities.repoman_sez( "\"Make your QA payment on time" " and you'll never see the likes of me.\"\n") sys.exit(1) def _vcs_autoadd(self): myunadded = self.vcs_settings.changes.unadded myautoadd = [] if myunadded: for x in range(len(myunadded) - 1, -1, -1): xs = myunadded[x].split("/") if (any(token.startswith('.') and token != '.' for token in xs) or self.repo_settings.repo_config.find_invalid_path_char(myunadded[x]) != -1): # The Manifest excludes this file, # so it's safe to ignore. del myunadded[x] elif xs[-1] == "files": print("!!! files dir is not added! Please correct this.") sys.exit(-1) elif xs[-1] == "Manifest": # It's a manifest... auto add myautoadd += [myunadded[x]] del myunadded[x] if myunadded: print(red( "!!! The following files are in your local tree" " but are not added to the master")) print(red( "!!! tree. Please remove them from the local tree" " or add them to the master tree.")) for x in myunadded: print(" ", x) print() print() sys.exit(1) return myautoadd def _vcs_deleted(self): if self.vcs_settings.changes.has_deleted: print(red( "!!! The following files are removed manually" " from your local tree but are not")) print(red( "!!! removed from the repository." " Please remove them, using \"%s remove [FILES]\"." % self.vcs_settings.vcs)) for x in self.vcs_settings.changes.deleted: print(" ", x) print() print() sys.exit(1) def get_vcs_changed(self): '''Holding function which calls the approriate VCS module for the data''' changes = self.vcs_settings.changes # re-run the scan to pick up a newly modified Manifest file logging.debug("RE-scanning for changes...") changes.scan() if not changes.has_changes: utilities.repoman_sez( "\"Doing nothing is not always good for QA.\"") print() print("(Didn't find any changed files...)") print() sys.exit(1) return (changes.new, changes.changed, changes.removed, changes.no_expansion, changes.expansion) https_bugtrackers = frozenset([ 'bitbucket.org', 'bugs.gentoo.org', 'github.com', 'gitlab.com', ]) def get_commit_footer(self): portage_version = getattr(portage, "VERSION", None) gpg_key = self.repoman_settings.get("PORTAGE_GPG_KEY", "") signoff = self.repoman_settings.get("SIGNED_OFF_BY", "") report_options = [] if self.options.force: report_options.append("--force") if self.options.ignore_arches: report_options.append("--ignore-arches") if self.scanner.include_arches is not None: report_options.append( "--include-arches=\"%s\"" % " ".join(sorted(self.scanner.include_arches))) if self.scanner.include_profiles is not None: report_options.append( "--include-profiles=\"%s\"" % " ".join(sorted(self.scanner.include_profiles))) if portage_version is None: sys.stderr.write("Failed to insert portage version in message!\n") sys.stderr.flush() portage_version = "Unknown" # Common part of commit footer commit_footer = "" for tag, bug in chain( (('Bug', x) for x in self.options.bug), (('Closes', x) for x in self.options.closes)): # case 1: pure number NNNNNN if bug.isdigit(): bug = 'https://bugs.gentoo.org/%s' % (bug, ) else: purl = urlsplit(bug) qs = parse_qs(purl.query) # case 2: long Gentoo bugzilla URL to shorten if (purl.netloc == 'bugs.gentoo.org' and purl.path == '/show_bug.cgi' and tuple(qs.keys()) == ('id',)): bug = urlunsplit(('https', purl.netloc, qs['id'][-1], '', purl.fragment)) # case 3: bug tracker w/ http -> https elif (purl.scheme == 'http' and purl.netloc in self.https_bugtrackers): bug = urlunsplit(('https',) + purl[1:]) commit_footer += "\n%s: %s" % (tag, bug) # Use new footer only for git (see bug #438364). if self.vcs_settings.vcs in ["git"]: commit_footer += "\nPackage-Manager: Portage-%s, Repoman-%s" % ( portage.VERSION, VERSION) if report_options: commit_footer += "\nRepoMan-Options: " + " ".join(report_options) if self.repo_settings.sign_manifests: commit_footer += "\nManifest-Sign-Key: %s" % (gpg_key, ) else: unameout = platform.system() + " " if platform.system() in ["Darwin", "SunOS"]: unameout += platform.processor() else: unameout += platform.machine() commit_footer += "\n(Portage version: %s/%s/%s" % \ (portage_version, self.vcs_settings.vcs, unameout) if report_options: commit_footer += ", RepoMan options: " + " ".join(report_options) if self.repo_settings.sign_manifests: commit_footer += ", signed Manifest commit with key %s" % \ (gpg_key, ) else: commit_footer += ", unsigned Manifest commit" commit_footer += ")" if signoff: commit_footer += "\nSigned-off-by: %s" % (signoff, ) return commit_footer def changelogs(self, myupdates, mymanifests, myremoved, mychanged, myautoadd, mynew, changelog_msg): broken_changelog_manifests = [] if self.options.echangelog in ('y', 'force'): logging.info("checking for unmodified ChangeLog files") committer_name = utilities.get_committer_name(env=self.repoman_settings) for x in sorted(vcs_files_to_cps( chain(myupdates, mymanifests, myremoved), self.repo_settings.repodir, self.scanner.repolevel, self.scanner.reposplit, self.scanner.categories)): catdir, pkgdir = x.split("/") checkdir = self.repo_settings.repodir + "/" + x checkdir_relative = "" if self.scanner.repolevel < 3: checkdir_relative = os.path.join(pkgdir, checkdir_relative) if self.scanner.repolevel < 2: checkdir_relative = os.path.join(catdir, checkdir_relative) checkdir_relative = os.path.join(".", checkdir_relative) changelog_path = os.path.join(checkdir_relative, "ChangeLog") changelog_modified = changelog_path in self.scanner.changed.changelogs if changelog_modified and self.options.echangelog != 'force': continue # get changes for this package cdrlen = len(checkdir_relative) check_relative = lambda e: e.startswith(checkdir_relative) split_relative = lambda e: e[cdrlen:] clnew = list(map(split_relative, filter(check_relative, mynew))) clremoved = list(map(split_relative, filter(check_relative, myremoved))) clchanged = list(map(split_relative, filter(check_relative, mychanged))) # Skip ChangeLog generation if only the Manifest was modified, # as discussed in bug #398009. nontrivial_cl_files = set() nontrivial_cl_files.update(clnew, clremoved, clchanged) nontrivial_cl_files.difference_update(['Manifest']) if not nontrivial_cl_files and self.options.echangelog != 'force': continue new_changelog = utilities.UpdateChangeLog( checkdir_relative, committer_name, changelog_msg, os.path.join(self.repo_settings.repodir, 'skel.ChangeLog'), catdir, pkgdir, new=clnew, removed=clremoved, changed=clchanged, pretend=self.options.pretend) if new_changelog is None: writemsg_level( "!!! Updating the ChangeLog failed\n", level=logging.ERROR, noiselevel=-1) sys.exit(1) # if the ChangeLog was just created, add it to vcs if new_changelog: myautoadd.append(changelog_path) # myautoadd is appended to myupdates below else: myupdates.append(changelog_path) if self.options.ask and not self.options.pretend: # regenerate Manifest for modified ChangeLog (bug #420735) self.repoman_settings["O"] = checkdir digestgen(mysettings=self.repoman_settings, myportdb=self.repo_settings.portdb) else: broken_changelog_manifests.append(x) if myautoadd: print(">>> Auto-Adding missing Manifest/ChangeLog file(s)...") self.vcs_settings.changes.add_items(myautoadd) myupdates += myautoadd return myupdates, broken_changelog_manifests def add_manifest(self, mymanifests, myheaders, myupdates, myremoved, commitmessage): myfiles = mymanifests[:] # If there are no header (SVN/CVS keywords) changes in # the files, this Manifest commit must include the # other (yet uncommitted) files. if not myheaders: myfiles += myupdates myfiles += myremoved myfiles.sort() commitmessagedir = tempfile.mkdtemp(".repoman.msg") commitmessagefile = os.path.join(commitmessagedir, "COMMIT_EDITMSG") with open(commitmessagefile, "wb") as mymsg: mymsg.write(_unicode_encode(commitmessage)) retval = self.vcs_settings.changes.commit(myfiles, commitmessagefile) # cleanup the commit message before possibly exiting try: shutil.rmtree(commitmessagedir) except OSError: pass if retval != os.EX_OK: writemsg_level( "!!! Exiting on %s (shell) " "error code: %s\n" % (self.vcs_settings.vcs, retval), level=logging.ERROR, noiselevel=-1) sys.exit(retval) def priming_commit(self, myupdates, myremoved, commitmessage): myfiles = myupdates + myremoved commitmessagedir = tempfile.mkdtemp(".repoman.msg") commitmessagefile = os.path.join(commitmessagedir, "COMMIT_EDITMSG") with open(commitmessagefile, "wb") as mymsg: mymsg.write(_unicode_encode(commitmessage)) separator = '-' * 78 print() print(green("Using commit message:")) print(green(separator)) print(commitmessage) print(green(separator)) print() # Having a leading ./ prefix on file paths can trigger a bug in # the cvs server when committing files to multiple directories, # so strip the prefix. myfiles = [f.lstrip("./") for f in myfiles] retval = self.vcs_settings.changes.commit(myfiles, commitmessagefile) # cleanup the commit message before possibly exiting try: shutil.rmtree(commitmessagedir) except OSError: pass if retval != os.EX_OK: writemsg_level( "!!! Exiting on %s (shell) " "error code: %s\n" % (self.vcs_settings.vcs, retval), level=logging.ERROR, noiselevel=-1) sys.exit(retval) def sign_manifest(self, myupdates, myremoved, mymanifests): try: for x in sorted(vcs_files_to_cps( chain(myupdates, myremoved, mymanifests), self.scanner.repo_settings.repodir, self.scanner.repolevel, self.scanner.reposplit, self.scanner.categories)): self.repoman_settings["O"] = os.path.join(self.repo_settings.repodir, x) manifest_path = os.path.join(self.repoman_settings["O"], "Manifest") if not need_signature(manifest_path): continue gpgsign(manifest_path, self.repoman_settings, self.options) except portage.exception.PortageException as e: portage.writemsg("!!! %s\n" % str(e)) portage.writemsg("!!! Disabled FEATURES='sign'\n") self.repo_settings.sign_manifests = False def msg_prefix(self): prefix = "" if self.scanner.repolevel > 1: prefix = "/".join(self.scanner.reposplit[1:]) + ": " return prefix def get_new_commit_message(self, qa_output, old_message=None): msg_prefix = old_message or self.msg_prefix() try: editor = os.environ.get("EDITOR") if editor and utilities.editor_is_executable(editor): commitmessage = utilities.get_commit_message_with_editor( editor, message=qa_output, prefix=msg_prefix) else: print("EDITOR is unset or invalid. Please set EDITOR to your preferred editor.") print(bad("* no EDITOR found for commit message, aborting commit.")) sys.exit(1) except KeyboardInterrupt: logging.fatal("Interrupted; exiting...") sys.exit(1) if (not commitmessage or not commitmessage.strip() or commitmessage.strip() == msg_prefix): print("* no commit message? aborting commit.") sys.exit(1) return commitmessage @staticmethod def verify_commit_message(msg): """ Check whether the message roughly complies with GLEP66. Returns (True, None) if it does, (False, ) if it does not. """ problems = [] paras = msg.strip().split('\n\n') summary = paras.pop(0) if ':' not in summary: problems.append('summary line must start with a logical unit name, e.g. "cat/pkg:"') if '\n' in summary.strip(): problems.append('commit message must start with a *single* line of summary, followed by empty line') # accept 69 overall or unit+50, in case of very long package names elif len(summary.strip()) > 69 and len(summary.split(':', 1)[-1]) > 50: problems.append('summary line is too long (max 69 characters)') multiple_footers = False gentoo_bug_used = False bug_closes_without_url = False body_too_long = False footer_re = re.compile(r'^[\w-]+:') found_footer = False for p in paras: lines = p.splitlines() # if all lines look like footer material, we assume it's footer # else, we assume it's body text if all(footer_re.match(l) for l in lines if l.strip()): # multiple footer-like blocks? if found_footer: multiple_footers = True found_footer = True for l in lines: if l.startswith('Gentoo-Bug'): gentoo_bug_used = True elif l.startswith('Bug:') or l.startswith('Closes:'): if 'http://' not in l and 'https://' not in l: bug_closes_without_url = True else: for l in lines: # we recommend wrapping at 72 but accept up to 80; # do not complain if we have single word (usually # it means very long URL) if len(l.strip()) > 80 and len(l.split()) > 1: body_too_long = True if multiple_footers: problems.append('multiple footer-style blocks found, please combine them into one') if gentoo_bug_used: problems.append('please replace Gentoo-Bug with GLEP 66-compliant Bug/Closes') if bug_closes_without_url: problems.append('Bug/Closes footers require full URL') if body_too_long: problems.append('body lines should be wrapped at 72 (max 80) characters') if problems: return (False, '\n'.join('- %s' % x for x in problems)) return (True, None)