#!/usr/bin/env python2 # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/. # coding=utf-8 """ Wrapper around Phabricator's `arc` cli to support submission of a series of commits. Goals: - must only use standard libraries - must be a single file for easy deployment - should work on python 2.7 and python 3.5+ """ import argparse import calendar import ConfigParser import datetime import errno import io import json import logging import os import re import ssl import stat import subprocess import sys import tempfile import time import traceback import urllib2 import uuid from contextlib import contextmanager from distutils.version import LooseVersion # Known Issues # - reordering, folding, etc commits doesn't result in the stack being updated # correctly on phabricator, or may outright fail due to dependency loops. # to address this we'll need to query phabricator's api directly and clear # dependencies prior to calling arc. we'd probably also have to # abandon revisions that are no longer part of the stack. unfortunately # phabricator's api currently doesn't expose calls to do this. # - commits with a description already modified by arc (ie. the follow the arc commit # description template with 'test plan', subscribers, etc) are not handled by this # script. commits in this format should be detected and result in the commit being # rejected. ideally this should extract the title, body, reviewers, and bug-id # from the arc template and reformat to the standard mozilla format. # Environment Vars DEBUG = bool(os.getenv("DEBUG")) IS_WINDOWS = sys.platform == "win32" HAS_ANSI = not IS_WINDOWS and ( (hasattr(sys.stdout, "isatty") and sys.stdout.isatty()) or os.getenv("TERM", "") == "ANSI" or os.getenv("PYCHARM_HOSTED", "") == "1" ) SELF_FILE = os.getenv("UPDATE_FILE") if os.getenv("UPDATE_FILE") else __file__ # Constants and Globals logger = logging.getLogger("moz-phab") config = None # Where to direct people when `arc` isn't installed. GUIDE_URL = ( "https://moz-conduit.readthedocs.io/en/latest/phabricator-user.html#quick-start" ) # Auto-update SELF_REPO = "mozilla-conduit/review" SELF_UPDATE_FREQUENCY = 24 * 3 # hours ARC_UPDATE_FREQUENCY = 24 * 7 # hours # Environment names (display purposes only) PHABRICATOR_URLS = { "https://phabricator.services.mozilla.com/": "Phabricator", "https://phabricator-dev.allizom.org/": "Phabricator-Dev", } # arc related consts. ARC_COMMIT_DESC_TEMPLATE = """ {title} Summary: {body} {depends_on} Test Plan: Reviewers: {reviewers} Subscribers: Bug #: {bug_id} """.strip() ARC_OUTPUT_REV_URL_RE = re.compile(r"^\s*Revision URI: (http.+)$", flags=re.MULTILINE) ARC_DIFF_REV_RE = re.compile( r"^\s*Differential Revision:\s*https?://.+/D(\d+)\s*$", flags=re.MULTILINE ) # If a commit body matches **all** of these, reject it. This is to avoid the # necessity to merge arc-style fields across an existing commit description # and what we need to set. ARC_REJECT_RE_LIST = [ re.compile(r"^Summary:", flags=re.MULTILINE), re.compile(r"^Reviewers:", flags=re.MULTILINE), ] # Bug and review regexs (from vct's commitparser) BUG_ID_RE = re.compile(r"(?:(?:bug|b=)(?:\s*)(\d+)(?=\b))", flags=re.IGNORECASE) LIST = r"[;,\/\\]\s*" LIST_RE = re.compile(LIST) IRC_NICK = ( r"#?[a-zA-Z0-9\-\_!]+" ) # Note this includes !, which is different from commitparser REVIEWERS_RE = ( r"([\s(.\[;,])(r%s)(" + IRC_NICK + r"(?:" + LIST + r"(?![a-z0-9.\-]+[=?])" + IRC_NICK + r")*)?" ) ALL_REVIEWERS_RE = re.compile(REVIEWERS_RE % r"[=?]") REQUEST_REVIEWERS_RE = re.compile(REVIEWERS_RE % r"\?") GRANTED_REVIEWERS_RE = re.compile(REVIEWERS_RE % r"=") R_SPECIFIER_RE = re.compile(r"\br[=?]") MINIMUM_MERCURIAL_VERSION = LooseVersion("4.3.3") # # Utilities # def which_path(path): """Check if an executable is provided. Fall back to which if not. Args: path: (str) filename or path to check for an executable command Returns: The full path of a command or None. """ if ( os.path.exists(path) and os.access(path, os.F_OK | os.X_OK) and not os.path.isdir(path) ): return path return which(path) def which(filename): # backport of shutil.which from py3 seen = set() for path in os.environ.get("PATH", os.defpath).split(os.pathsep): path = os.path.expanduser(path) norm_path = os.path.normcase(path) if norm_path not in seen: seen.add(norm_path) fn = os.path.join(path, filename) if ( os.path.exists(fn) and os.access(fn, os.F_OK | os.X_OK) and not os.path.isdir(fn) ): return fn return None def shell_quote(s): # backport of shutil.quote from py3 # used for debugging output only _find_unsafe = re.compile(r"[^\w@%+=:,./-]").search if not s: return "''" if _find_unsafe(s) is None: return s return "'" + s.replace("'", "'\"'\"'").replace("\n", "\\n") + "'" def parse_zulu_time(timestamp): """Parse YYYY-MM-DDTHH:mm:SSZ date string, return as epoch seconds in local tz.""" return calendar.timegm(time.strptime(timestamp, "%Y-%m-%dT%H:%M:%SZ")) def check_call(command, **kwargs): # wrapper around subprocess.check_call with debug output logger.debug("$ %s" % " ".join(shell_quote(s) for s in command)) subprocess.check_call(command, **kwargs) def check_call_by_line(command, cwd=None, never_log=False): # similar to check_call, yields for line-by-line processing logger.debug("$ %s" % " ".join(shell_quote(s) for s in command)) process = subprocess.Popen(command, stdout=subprocess.PIPE, cwd=cwd) try: for line in iter(process.stdout.readline, ""): line = line.rstrip() if not never_log: logger.debug("> %s" % line) yield line finally: process.stdout.close() process.wait() if process.returncode: raise subprocess.CalledProcessError(process.returncode, command) def check_output( command, cwd=None, split=True, strip=True, never_log=False, stdin=None, env=None ): # wrapper around subprocess.check_output with debug output and splitting logger.debug("$ %s" % " ".join(shell_quote(s) for s in command)) kwargs = dict(cwd=cwd, stdin=stdin) if env: kwargs["env"] = env try: output = subprocess.check_output(command, **kwargs) except subprocess.CalledProcessError as e: logger.debug(e.output) raise CommandError( "command '%s' failed to complete successfully" % command[0], e.returncode ) if strip: output = output.rstrip() if output and not never_log: logger.debug(output) return output.splitlines() if split else output def read_json_field(files, field_path): """Parses json files in turn returning value as per field_path, or None.""" for filename in files: try: with open(filename) as f: rc = json.load(f) for field_name in field_path: if field_name not in rc: rc = None break rc = rc[field_name] if not rc: continue return rc except IOError as e: if e.errno == errno.ENOENT: continue raise except ValueError: continue return None def prompt(question, options): if HAS_ANSI: question = "\033[33m%s\033[0m" % question prompt_options = list(options) prompt_options[0] = prompt_options[0].upper() prompt_str = "%s (%s)? " % (question, "/".join(prompt_options)) options_map = {o[0].lower(): o for o in options} options_map[""] = options[0] while True: res = raw_input(prompt_str) if len(res) > 1: res = res[0].lower() if res == chr(27): # escape sys.exit(1) if res in options_map: return options_map[res] def parse_config(config_list, filter_func=None): """Parses list with "name=value" strings. Args: config_list: A list of "name=value" strings filter_func: A function taking the parsing config name and value for each line. If the function returns True the config value will be included in the final dict. Returns: A dict containing parsed data. """ result = dict() for line in config_list: # On Windows config file is likely to be cp1252 encoded, not UTF-8. if IS_WINDOWS: try: line = line.decode("cp1252").encode("UTF-8") except UnicodeDecodeError: pass name, value = line.split("=", 1) name = name.strip() value = value.strip() if filter_func is None or (callable(filter_func) and filter_func(name, value)): result[name] = value return result def normalise_reviewer(reviewer, strip_group=True): """This provide a canonical form of the reviewer for comparison.""" reviewer = reviewer.rstrip("!").lower() if strip_group: reviewer = reviewer.lstrip("#") return reviewer @contextmanager def TemporaryFileName(content): f = tempfile.NamedTemporaryFile(delete=False) try: f.write(content) f.flush() f.close() yield f.name finally: os.remove(f.name) class Error(Exception): """Errors thrown explictly by this script; won't generate a stack trace.""" class CommandError(Exception): status = None def __init__(self, msg="", status=1): self.status = status super(CommandError, self).__init__(msg) class ConduitAPIError(Error): """Raised when the Phabricator Conduit API returns an error response.""" # # Configuration # class Config(object): def __init__(self, should_access_file=True): self._filename = os.path.join(os.path.expanduser("~"), ".moz-phab-config") self.name = "~/.moz-phab-config" # human-readable name # Default values. defaults = u""" [ui] no_ansi=false [arc] arc_command=arc [submit] auto_submit=false always_blocking=false warn_untracked=true [updater] self_last_check=0 arc_last_check=0 """ self._config = ConfigParser.SafeConfigParser() self._config.readfp( io.StringIO("\n".join([l.strip() for l in defaults.splitlines()])) ) # the `arc_command` default value depends on the operating system if IS_WINDOWS: self._set("arc", "arc_command", "arc.bat") if should_access_file: self._config.read([self._filename]) self.no_ansi = self._config.getboolean("ui", "no_ansi") self.arc_command = self._config.get("arc", "arc_command") self.auto_submit = self._config.getboolean("submit", "auto_submit") self.always_blocking = self._config.getboolean("submit", "always_blocking") self.warn_untracked = self._config.getboolean("submit", "warn_untracked") self.self_last_check = self._config.getint("updater", "self_last_check") self.arc_last_check = self._config.getint("updater", "arc_last_check") if should_access_file and not os.path.exists(self._filename): self.write() self.arc = None def _set(self, section, option, value): if not self._config.has_section(section): self._config.add_section(section) self._config.set(section, option, str(value)) def write(self): if os.path.exists(self._filename): logger.debug("updating %s" % self._filename) self._set("submit", "auto_submit", self.auto_submit) self._set("updater", "self_last_check", self.self_last_check) self._set("updater", "arc_last_check", self.arc_last_check) else: logger.debug("creating %s" % self._filename) self._set("ui", "no_ansi", self.no_ansi) self._set("arc", "arc_command", self.arc_command) self._set("submit", "auto_submit", self.auto_submit) self._set("submit", "always_blocking", self.always_blocking) self._set("submit", "warn_untracked", self.warn_untracked) with open(self._filename, "w") as f: self._config.write(f) # # Repository # def find_repo_root(path): """Lightweight check for a repo in/under the specified path.""" path = os.path.abspath(path) while os.path.split(path)[1]: if Mercurial.is_repo(path) or Git.is_repo(path): return path path = os.path.abspath(os.path.join(path, os.path.pardir)) return None def probe_repo(path): try: return Mercurial(path) except ValueError: pass try: return Git(path) except ValueError: pass return None def repo_from_args(args): """Returns a Repository object from either args.path or the cwd""" repo = None # This allows users to override the below sanity checks. if hasattr(args, "path") and args.path: repo = probe_repo(args.path) if not repo: raise Error("%s: Not a repository: .hg / .git" % args.path) else: # Walk parents to find repository root. path = find_repo_root(os.getcwd()) if path: repo = probe_repo(path) if not repo: raise Error( "Not a repository (or any of the parent directories): .hg / .git" ) repo.set_args(args) return repo class Repository(object): def __init__(self, path, dot_path, phab_url=None): self.path = path # base repository directory self.dot_path = dot_path # .hg/.git directory self.args = None self.phab_url = phab_url or self._phab_url() def _phab_url(self): """Determine the phab/conduit URL.""" # In order of priority as per arc # FIXME: This should also check {.hg|.git}/arc/config, which is where # `arc set-config --local` writes to. See bug 1497786. arcconfig_files = [ os.path.join(self.dot_path, ".arcconfig"), os.path.join(self.path, ".arcconfig"), ] if IS_WINDOWS: defaults_files = [ os.path.join(os.getenv("APPDATA", ""), ".arcrc"), os.path.join( os.getenv("ProgramData", ""), "Phabricator", "Arcanist", "config" ), ] else: defaults_files = ["/etc/arcconfig", os.path.expanduser("~/.arcrc")] phab_url = read_json_field( arcconfig_files, ["phabricator.uri"] ) or read_json_field(defaults_files, ["config", "default"]) if not phab_url: raise Error("Failed to determine Phabricator URL (missing .arcconfig?)") return phab_url def cleanup(self): """Perform any repo-related cleanup tasks. May be called multiple times. If an exception is raised this is NOT called (to avoid dataloss).""" def finalize(self, commits): """Update the history after node changed.""" def set_args(self, args): self.args = args def untracked(self): """Return a list of untracked files.""" def commit_stack(self): """Return list of commits. List of dicts with the following keys: name human readable identifier of commit (eg. short sha) node sha/hash title first line of commit description (unaltered) body commit description, excluding first line title-preview title with bug-id and reviewer modifications bug-id bmo bug-id bug-id-orig original bug-id from commit desc reviewers list of reviewers rev-id phabricator revision id """ def refresh_commit_stack(self, commits): """Update the stack following an altering change (eg rebase).""" def checkout(self, node): """Checkout/Update to specified node.""" def amend_commit(self, commit, commits): """Amend commit description from `title` and `desc` fields""" def rebase_commit(self, source_commit, dest_commit): """Rebase source onto destination.""" def check_commits_for_submit(self, commits): """Validate the list of commits (from commit_stack) are ok to submit""" errors = [] warnings = [] # Extract a set of reviewers and verify first; they will be displayed # with other commit errors. all_reviewers = {} reviewer_commit_map = {} commit_invalid_reviewers = {} # Flatten and deduplicate reviewer list, keeping track of the associated commit for commit in commits: commit_invalid_reviewers[commit["node"]] = [] for group in commit["reviewers"].keys(): for reviewer in commit["reviewers"][group]: all_reviewers.setdefault(group, set()) all_reviewers[group].add(reviewer) reviewer = normalise_reviewer(reviewer) reviewer_commit_map.setdefault(reviewer, []) reviewer_commit_map[reviewer].append(commit["node"]) # Verify all reviewers in a single call for invalid_reviewer in check_for_invalid_reviewers(all_reviewers, self.path): for node in reviewer_commit_map[ normalise_reviewer(invalid_reviewer["name"]) ]: commit_invalid_reviewers[node].append(invalid_reviewer) unavailable_reviewers_warning = False for commit in commits: commit_errors = [] commit_warnings = [] # TODO allow NOBUG in commit message (not in arg) # TODO be more relaxed if we're updating an existing rev? if not commit["bug-id"]: commit_errors.append("missing bug-id") if has_arc_rejections(commit["body"]): commit_errors.append("contains arc fields") for reviewer in commit_invalid_reviewers[commit["node"]]: if "until" in reviewer: unavailable_reviewers_warning = True msg = "%s is not available until %s" % ( reviewer["name"], reviewer["until"], ) if self.args.force: commit_warnings.append(msg) else: commit_errors.append(msg) else: commit_errors.append( "%s is not a valid reviewer's name" % reviewer["name"] ) if commit_errors: errors.append( "%s %s\n- %s" % (commit["name"], commit["title"], "\n- ".join(commit_errors)) ) if commit_warnings: warnings.append( "%s %s\n- %s" % (commit["name"], commit["title"], "\n- ".join(commit_warnings)) ) if errors: raise Error("\n\n".join(errors)) if warnings: logger.warning("\n\n".join(warnings)) if unavailable_reviewers_warning: logger.warning( "\nYou're forcing unavailable reviewer(s).\nIf stuck, please read the " "discussion under https://bugzilla.mozilla.org/show_bug.cgi?id=1499205" ) def check_arc(self): """Check if arc can communicate with Phabricator.""" # Check if the cache file exists path = os.path.join(self.dot_path, ".moz-phab_arc-configured") if os.path.isfile(path): return True if arc_ping(self.path): # Create the cache file with open(path, "a"): os.utime(path, None) return True return False # # Mercurial # class Mercurial(Repository): def __init__(self, path): dot_path = os.path.join(path, ".hg") if not os.path.isdir(dot_path): raise ValueError("%s: not a hg repository" % path) logger.debug("found hg repo in %s" % path) super(Mercurial, self).__init__(path, dot_path) self._hg = ["hg.exe" if IS_WINDOWS else "hg"] self.revset = None self.strip_nodes = [] self.status = None # Normalise/standardise Mercurial's output. os.environ["HGPLAIN"] = "1" os.environ["HGENCODING"] = "UTF-8" # Check for `hg`, and mercurial version. if not which_path(self._hg[0]): raise Error("Failed to find 'hg' executable") m = re.search( r"\(version ([^)]+)\)", self.hg_out(["--version", "--quiet"], split=False) ) if not m: raise Error("Failed to determine Mercurial version.") if LooseVersion(m.group(1)) < MINIMUM_MERCURIAL_VERSION: raise Error( "You are currently running Mercurial %s. " "Mercurial %s or newer is required." % (m.group(1), MINIMUM_MERCURIAL_VERSION) ) # Load hg config into hg_config. We'll specify specific settings on # the command line when calling hg; all other user settings are ignored. # Do not parse shell alias extensions. hg_config = parse_config( self.hg_out(["config"], never_log=True), lambda name, value: not ( name.startswith("extensions.") and value.startswith("!") ), ) # Need to use the correct username. if "ui.username" not in hg_config: raise Error("ui.username is not configured in your hgrc") self._hg.extend(["--config", "ui.username=%s" % hg_config["ui.username"]]) # Always need rebase. self._hg.extend(["--config", "extensions.rebase="]) # Enable evolve if the user's currently using it. evolve makes amending # commits with children trivial (amongst other things). ext_evolve = self._get_extension("evolve", hg_config) if ext_evolve is not None: self._hg.extend(["--config", "extensions.evolve=%s" % ext_evolve]) self.use_evolve = True # Otherwise just enable obsolescence markers, and when we're done remove # the obsstore we created. else: self._hg.extend(["--config", "experimental.evolution.createmarkers=true"]) self._hg.extend(["--config", "extensions.strip="]) self.use_evolve = False self.obsstore = os.path.join(self.path, ".hg", "store", "obsstore") self.unlink_obsstore = not os.path.exists(self.obsstore) # This script interacts poorly with mq. ext_mq = self._get_extension("mq", hg_config) self.has_mq = ext_mq is not None if self.has_mq: self._hg.extend(["--config", "extensions.mq=%s" % ext_mq]) # `shelve` is useful for dealing with uncommitted changes; track if it's # currently enabled so we can tailor our error accordingly. self.has_shelve = self._get_extension("shelve", hg_config) is not None # Disable the user's hgrc file, to ensure we run without rogue extensions. os.environ["HGRCPATH"] = "" @classmethod def is_repo(cls, path): """Quick check for repository at specified path.""" return os.path.exists(os.path.join(path, ".hg")) @staticmethod def _get_extension(extension, hg_config): for prefix in ("extensions.%s", "extensions.hgext.%s"): field = prefix % extension if field in hg_config: return hg_config.get(field, "") return None def hg(self, command, **kwargs): check_call(self._hg + command, cwd=self.path, **kwargs) def hg_out(self, command, **kwargs): return check_output(self._hg + command, cwd=self.path, **kwargs) def hg_log(self, revset, split=True, select="node"): return self.hg_out(["log", "-T", "{%s}\n" % select, "-r", revset], split=split) def cleanup(self): # Remove the store of obsolescence markers; if the user doesn't have evolve # installed mercurial will warn if this exists. if not self.use_evolve and self.unlink_obsstore: try: os.unlink(self.obsstore) except OSError as e: if e.errno != errno.ENOENT: raise if self.strip_nodes: # With the obsstore deleted the amended nodes are no longer hidden, so # we need to strip them completely from the repo. self.hg(["strip", "--hidden"] + self.strip_nodes) self.strip_nodes = [] def _status(self): # `hg status` is slow on large repos. As we'll need both uncommitted changes # and untracked files separately, run it once and cache results. if self.status is None: self.status = dict(T=[], U=[]) for line in self.hg_out( ["status", "--added", "--deleted", "--modified", "--unknown"], split=True, ): status, path = line.split(" ", 1) self.status["U" if status == "?" else "T"].append(path) return self.status def untracked(self): return self._status()["U"] def _refresh_commit(self, commit, node, rev=None): """Update commit's node and name from node and rev.""" if not rev: rev = self.hg_log(node, select="rev", split=False) commit["node"] = node commit["name"] = "%s:%s" % (rev, node[:12]) def _get_successor(self, node): """Get the successor of the commit represented by its node. Returns: a tuple containing rev and node""" hg_log = self.hg_out( ["log"] + ["-T", "{rev} {node}\n"] + ["--hidden"] + ["-r", "successors(%s) and not obsolete()" % node] ) if not hg_log: return None, None # Not sure the best way to handle multiple successors, so just bail out. if len(hg_log) > 1: raise Error("Multiple successors found for %s, unable to continue" % node) return hg_log[0].split(" ", 1) def refresh_commit_stack(self, commits): """Update all commits to point to their superseded commit.""" for commit in commits: (rev, node) = self._get_successor(commit["node"]) if rev and node: self._refresh_commit(commit, node, rev) self.revset = "%s::%s" % (commits[0]["node"], commits[-1]["node"]) super(Mercurial, self).refresh_commit_stack(commits) def set_args(self, args): super(Mercurial, self).set_args(args) # Set the default start revision. if self.args.start_rev == "(auto)": start = "ancestors(.) and not public() and not obsolete()" else: start = self.args.start_rev # Resolve to nodes as that's nicer to read. try: start = self.hg_log(start)[0] except IndexError: if self.args.start_rev == "(auto)": raise Error("Failed to find draft commits to submit") else: raise Error("Failed to start of commit range: %s" % self.args.start_rev) try: end = self.hg_log(self.args.end_rev)[0] except IndexError: raise Error("Failed to end of commit range: %s" % self.args.end_rev) self.revset = "%s::%s" % (start[:12], end[:12]) def commit_stack(self): # Grab all the info we need about the commits, using randomness as a delimiter. boundary = "--%s--\n" % uuid.uuid4().get_hex() hg_log = self.hg_out( ["log", "-T", "{rev} {node} {desc}%s" % boundary, "-r", self.revset], split=False, strip=False, )[: -len(boundary)] commits = [] nodes = [] branching_children = [] for log_line in hg_log.split(boundary): rev, node, desc = log_line.split(" ", 2) desc = desc.splitlines() children = self.hg_log("children(%s)" % node) if len(children) > 1 and not self.use_evolve: branching_children.extend(children) commits.append( { "name": "%s:%s" % (rev, node[:12]), "node": node, "orig-node": node, "children": children, "title": desc[0], "title-preview": desc[0], "body": "\n".join(desc[1:]).rstrip(), "bug-id": None, "reviewers": [], "rev-id": None, } ) nodes.append(node) if branching_children: will_be_deleted = [c[:12] for c in branching_children if c not in nodes] msg = "following commits will be DELETED:\n%s" % will_be_deleted if not self.args.force_delete: raise Error( "DAG branch point detected. Please install the evolve extension.\n" "(https://www.mercurial-scm.org/doc/evolution/)\n" "If you continue with `--force-delete` the %s" % msg ) else: logger.warning("`--force-delete` used. The %s" % msg) return commits def checkout(self, node): self.hg(["update", "--quiet", node]) def _amend_commit_body(self, node, body): with TemporaryFileName(body) as body_file: self.checkout(node) self.hg(["commit", "--amend", "--logfile", body_file]) def _get_parent(self, node): return self.hg_out( ["log", "-T", "{node}", "-r", "parents(%s)" % node], split=False ) def _find_forks_to_rebase(self, commit, original_nodes): """Returns a list of split commits to rebase.""" if commit["node"] == commit["orig-node"]: return [] return [ch for ch in commit["children"] if ch not in original_nodes] def finalize(self, commits): """Rebase stack children commits if needed.""" # Currently we do all rebases in `amend_commit` if the evolve extension # is not installed. if not self.use_evolve: return original_nodes = [c["orig-node"] for c in commits] parent = None for commit in commits: commit_parent = self._get_parent(commit["node"]) if parent and parent["node"] not in commit_parent: self.rebase_commit(commit, parent) (rev, node) = self._get_successor(commit["node"]) if rev and node: self._refresh_commit(commit, node, rev) for fork in self._find_forks_to_rebase(commit, original_nodes): self.rebase_commit(dict(node=fork), commit) parent = commit def amend_commit(self, commit, commits): updated_body = "%s\n%s" % (commit["title"], commit["body"]) current_body = self.hg_out( ["log", "-T", "{desc}", "-r", commit["node"]], split=False ) if current_body == updated_body: logger.debug("not amending commit %s, unchanged" % commit["name"]) return False # Find our position in the stack. parent_node = None first_child = None is_parent = True for c in commits: if c["node"] == commit["node"]: is_parent = False elif is_parent: parent_node = c["node"] elif not first_child: first_child = c break # Track children of the last commit in the stack. post_stack_children = [] if not first_child: post_stack_children = self.hg_log("children(%s)" % commit["node"]) if self.use_evolve: # If evolve is installed this is trivial. # Amend and refresh the stack to get the new commit node. self._amend_commit_body(commit["node"], updated_body) self.refresh_commit_stack(commits) # Rebase will happen in `finalize` elif not first_child and not post_stack_children: # Without evolve things are much more exciting. # If there's no children we can just amend. self._amend_commit_body(commit["node"], updated_body) # This should always result in an amended node, but we need to be # extra careful not to strip the original node. amended_node = self.hg_log(".", split=False) if amended_node != commit["node"]: self.strip_nodes.append(commit["node"]) else: # Brace yourself. We need to create a dummy commit with the same parent as # the commit, rebase a copy of the commit onto the dummy, amend, rebase the # amended commit back onto the original parent, rebase the children onto # that, then strip the original commit and dummy commits. # Find a parent for the first commit in the stack if not parent_node: parent_node = self.hg_log("parents(%s)" % commit["node"])[0] # Create the dummy commit. self.checkout(parent_node) self.hg( ["commit"] + ["--message", "dummy"] + ["--config", "ui.allowemptycommit=true"] ) dummy_node = self.hg_log(".", split=False) # Rebase a copy of this commit onto the dummy. self.hg(["rebase", "--keep", "--rev", commit["node"], "--dest", dummy_node]) rebased_node = self.hg_log("children(.)", split=False) # Amend. self._amend_commit_body(rebased_node, updated_body) amended_node = self.hg_log(".", split=False) # Rebase back onto parent self.hg(["rebase"] + ["--source", amended_node] + ["--dest", parent_node]) rebased_amended_node = self.hg_log(".", split=False) # Update the commit object now. original_node = commit["node"] self._refresh_commit(commit, rebased_amended_node) # Note what nodes need to be stripped when we're all done. self.strip_nodes.extend([original_node, dummy_node]) # And rebase children. if first_child: self.rebase_commit(first_child, commit) # Ensure our view of the stack is up to date. self.refresh_commit_stack(commits) # Commits that are descendants of the stack need to be rebased too. for node in post_stack_children: self.hg(["rebase", "--source", node, "--dest", commit["node"]]) def rebase_commit(self, source_commit, dest_commit): self.hg( ["rebase"] + ["--source", source_commit["node"]] + ["--dest", dest_commit["node"]] ) def check_commits_for_submit(self, commits): # 'Greatest Common Ancestor'/'Merge Base' should be included in the revset. ancestor = self.hg_log("ancestor(%s)" % self.revset, split=False) if ancestor not in [c["node"] for c in commits]: raise Error( "Non-linear commit stack (common ancestor %s missing from stack)" % ancestor[:12] ) # Merge base needs to have a public parent. parent_phases = self.hg_out( ["log", "-T", "{phase} {node}\n", "-r", "parents(%s)" % ancestor] ) for parent in parent_phases: (phase, node) = parent.split(" ", 1) if phase != "public": logger.warning( "%s is based off non-public commit %s" % (ancestor[:12], node[:12]) ) # Can't submit merge requests. if self.hg_log("%s and merge()" % self.revset): raise Error("Commit stack contains a merge commit") # mq isn't currently supported. if self.has_mq and self.hg_out(["qapplied"]): raise Error("Found patches applied with `mq`, unable to continue") # Uncommitted changes can interact poorly when we update to a different commit. status = self._status() if status["T"]: err = [ "%s uncommitted change%s present" % (len(status["T"]), " is" if len(status["T"]) == 1 else "s are") ] err.extend( [ "Commit changes, or use `hg shelve` to store uncommitted changes,", "restoring with `hg unshelve` after submission", ] ) if not self.has_shelve: err.append("You can enable the shelve extension via `hg config --edit`") raise Error("\n".join(err)) super(Mercurial, self).check_commits_for_submit(commits) # # Git # class Git(Repository): def __init__(self, path): dot_path = os.path.join(path, ".git") if not os.path.exists(dot_path): raise ValueError("%s: not a git repository" % path) logger.debug("found git repo in %s" % path) self._git = ["git.exe" if IS_WINDOWS else "git"] if not which_path(self._git[0]): raise Error("Failed to find 'git' executable") # `self._env` is a dict representing environment used in all git commands. self._env = os.environ.copy() if os.path.isfile(dot_path): # We're working from a worktree. Let's find the dot_path directory. dot_path = self.git_out( ["rev-parse", "--git-common-dir"], path=path, split=False ) super(Git, self).__init__(path, dot_path) self.revset = None git_config = parse_config(self.git_out(["config", "--list"])) # Need to use the correct username. if "user.email" not in git_config: raise Error("user.email is not configured in your gitconfig") self._git.extend(["-c", "user.email=%s" % git_config["user.email"]]) if "user.name" in git_config: self._git.extend(["-c", "user.name=%s" % git_config["user.name"]]) if "cinnabar.helper" in git_config: self._git.extend( ["-c", "cinnabar.helper=%s" % git_config["cinnabar.helper"]] ) # Ignore the user's Git config # To make Git not read the `~/.gitconfig` we need to temporarily change the # `$HOME` variable. self._env["HOME"] = "" self._env["XDG_CONFIG_HOME"] = "" # Store current branch (fails if HEAD in detached state) try: self.branch = self._get_current_head() except Exception: raise Error( "Git failed to read the branch name.\n" "The repository is in a detached HEAD state.\n" "You need to run the *git checkout * command." ) @classmethod def is_repo(cls, path): """Quick check for repository at specified path.""" return os.path.exists(os.path.join(path, ".git")) def git(self, command, **kwargs): """Call git from the repository path.""" check_call(self._git + command, cwd=self.path, env=self._env, **kwargs) def git_out(self, command, path=None, env={}, **kwargs): """Call git from the repository path and return the result.""" env = dict(self._env, **env) return check_output( self._git + command, cwd=path or self.path, env=env, **kwargs ) def cleanup(self): self.git(["gc", "--auto", "--quiet"]) self.checkout(self.branch) def _find_branches_to_rebase(self, commits): """Create a list of branches to rebase.""" branches_to_rebase = dict() for commit in commits: if commit["node"] == commit["orig-node"]: continue branches = self.git_out(["branch", "--contains", commit["orig-node"]]) for branch in branches: if branch.startswith("* ("): # Omit `* (detached from {SHA1})` continue branch = branch.lstrip("* ") # Rebase the branch to the last commit from the stack . branches_to_rebase[branch] = [commit["node"], commit["orig-node"]] return branches_to_rebase def finalize(self, commits): """Rebase all branches based on changed commits from the stack.""" branches_to_rebase = self._find_branches_to_rebase(commits) for branch, nodes in branches_to_rebase.iteritems(): self.checkout(branch) self._rebase(*nodes) self.checkout(self.branch) def refresh_commit_stack(self, commits): """Update revset and names of the commits.""" for commit in commits: commit["name"] = commit["node"][:12] self.revset = (commits[0]["node"], commits[-1]["node"]) super(Git, self).refresh_commit_stack(commits) def _cherry(self, command, remotes): """Run command and try all the remotes until success.""" if not remotes: return self.git_out(command) for remote in remotes: logger.info('Determining the commit range using upstream "%s"' % remote) try: response = self.git_out(command + [remote]) except CommandError: continue return response def _get_first_unpublished_node(self): """Check which commits should be pushed and return the oldest one.""" cherry = ["cherry", "--abbrev=12"] remotes = [] if self.args.upstream: cherry += self.args.upstream else: remotes = self.git_out(["remote"]) if len(remotes) > 1: logger.warning( "!! Found multiple upstreams (%s)." % (", ".join(remotes)) ) unpublished = self._cherry(cherry, remotes) if unpublished is None: raise Error( "Unable to detect the start commit. Please provide its SHA-1 or\n" "specify the upstream branch with `--upstream `." ) if not unpublished: return None for line in unpublished: # `git cherry` is producing the output in reverse order - oldest # commit is the first one. That is the *opposite* of what we can find # in the documentation. if line.startswith("+"): return line.split("+ ")[1] else: logger.warning( "!! Diff from commit %s found in upstream - omitting." % line.split("- ")[1] ) def set_args(self, args): """Store moz-phab command line args and set the revset.""" super(Git, self).set_args(args) if self.args.start_rev == "(auto)": start = self._get_first_unpublished_node() else: start = self.args.start_rev if start is None: return None # We want inclusive range of commits if start commit is detected if self.args.start_rev == "(auto)": start = "%s^" % start self.revset = (start, self.args.end_rev) def _git_get_children(self, node): """Get commits SHA1 with their children. Args: node: The SHA1 of a node to check for all children Returns: A list of "aaaa bbbb cccc"" strings, where bbbb and cccc are SHA1 of direct children of aaaa """ return self.git_out(["rev-list", "--all", "--children", "--not", "%s^@" % node]) @staticmethod def _get_direct_children(node, rev_list): """ Return direct children of the commit. Args: node: The SHA1 of a node to check for direct children rev_list: A list of SHA1 strings - result of the _git_get_children method Returns: A list of SHA1 representing direct children of a commit """ # Find the line containing the node to extract its commit's children for line in rev_list: if line.startswith(node): children = line.split(" ") children.remove(node) return children return [] def _get_commits_info(self, start, end): """Log useful info about the commits within the desired range. Returns a list of strings An example of a list item: Tue, 22 Jan 2019 13:42:48 +0000 Conduit User conduit@mozilla.bugs 4912923 b18312ffe929d3482f1d7b1e9716a1885c7a61b8 5f161c70fef9e59d1966bab693a0a68a9336af80 Update code references Fixes: $ moz-phab self-update > Failed to download update: HTTP Error 404: Not Found """ boundary = "--%s--\n" % uuid.uuid4().get_hex() log = self.git_out( [ "log", "--reverse", "--ancestry-path", "--quiet", "--format=%aD%n%an%n%ae%n%p%n%T%n%H%n%s%n%n%b{}".format(boundary), "{}..{}".format(start, end), ], split=False, strip=False, )[: -len(boundary) - 1] return log.split("%s\n" % boundary) def _is_child(self, parent, node, rev_list): """Check if `node` is a direct or indirect child of the `parent`. Args: parent: The parent node whose children will be searched node: The string we check if it's in parent-child relation to the `parent` rev_list: A response from the git _git_get_children method - a list of "aaaa bbbb cccc"" strings, where "bbbb" and cccc" are SHA1 of direct children of "aaaa" Returns: a Boolean True if the `node` represents a child of the `parent`. """ direct_children = self._get_direct_children(parent, rev_list) if node in direct_children: return True for child in direct_children: if self._is_child(child, node, rev_list): return True return False def commit_stack(self): """Collect all the info about commits.""" if not self.revset: # No commits found to submit return None commits = [] rev_list = None first_node = None for log_line in self._get_commits_info(*self.revset): if not log_line: continue ( author_date, author_name, author_email, parents, tree_hash, node, desc, ) = log_line.split("\n", 6) desc = desc.splitlines() # Check if the commit is a child of the first one if not rev_list: rev_list = self._git_get_children(node) first_node = node elif not self._is_child(first_node, node, rev_list): raise Error( "Commit %s is not a child of %s, unable to continue" % (node[:12], first_node[:12]) ) # Check if commit has multiple parents, if so - raise an Error # We may push the merging commit if it's the first one parents = parents.split(" ") if node[:12] != first_node[:12] and len(parents) > 1: raise Error( "Multiple parents found for commit %s, unable to continue" % node[:12] ) commits.append( { "name": node[:12], "node": node, "orig-node": node, "title": desc[0], "title-preview": desc[0], "body": "\n".join(desc[1:]).rstrip(), "bug-id": None, "reviewers": [], "rev-id": None, "parent": parents[0], "tree-hash": tree_hash, "author-date": author_date, "author-name": author_name, "author-email": author_email, } ) return commits def checkout(self, node): self.git(["checkout", "--quiet", node]) def _get_current_head(self): """Return current's HEAD symbolic link.""" symbolic = self.git_out(["symbolic-ref", "HEAD"], split=False) return symbolic.split("refs/heads/")[1] def _get_current_hash(self): """Return the SHA1 of the current commit.""" return self._revparse("HEAD") def _revparse(self, branch): """Return the SHA1 of given branch.""" return self.git_out(["rev-parse", branch], split=False) def _commit_tree( self, parent, tree_hash, message, author_name, author_email, author_date ): """Prepare and run `commit-tree` command. Creates a new commit for the tree_hash. Args: parent: SHA1 of the parent commit tree_hash: SHA1 of the tree_hash to use for the commit message: commit message Returns: str: SHA1 of the new commit. """ with TemporaryFileName(message) as message_file: return self.git_out( ["commit-tree", "-p", parent, "-F", message_file, tree_hash], split=False, env={ "GIT_AUTHOR_NAME": author_name, "GIT_AUTHOR_EMAIL": author_email, "GIT_AUTHOR_DATE": author_date, }, ) def amend_commit(self, commit, commits): """Amend the commit with an updated message. Changing commit's message changes also its SHA1. All the children within the stack and branches are then updated to keep the history. Args: commit: Information about the commit to be amended commits: List of commits within the stack """ updated_body = "%s\n%s" % (commit["title"], commit["body"]) current_body = self.git_out( ["show", "-s", "--format=%s%n%b", commit["node"]], split=False ) if current_body == updated_body: logger.debug("not amending commit %s, unchanged" % commit["name"]) return # Create a new commit with the updated body. new_parent_sha = self._commit_tree( commit["parent"], commit["tree-hash"], updated_body, commit["author-name"], commit["author-email"], commit["author-date"], ) # Update commit info commit["node"] = new_parent_sha # Update parent for all the children of the `commit` within the stack has_children = False for c in commits: if not has_children: # Find the amended commit info in the list of all commits in the stack. # Next commits are children of this one. has_children = c == commit continue # Update parent information and create a new commit c["parent"] = new_parent_sha new_parent_sha = self._commit_tree( new_parent_sha, c["tree-hash"], "%s\n%s" % (c["title"], c["body"]), c["author-name"], c["author-email"], c["author-date"], ) c["node"] = new_parent_sha def rebase_commit(self, source_commit, dest_commit): self._rebase(dest_commit["node"], source_commit["node"]) def _rebase(self, newbase, upstream): self.git(["rebase", "--quiet", "--onto", newbase, upstream]) # # Commit helpers # def parse_arc_diff_rev(body): m = ARC_DIFF_REV_RE.search(body) return m.group(1) if m else None def parse_bugs(title): return BUG_ID_RE.findall(title) def parse_reviewers(title): """Extract reviewers information from first line of the commit message. Returns a dictionary containing reviewers divided by the type: "r?" reviewers under the "request" key "r=" reviewers under the "granted" key """ request_reviewers = [] for match in re.finditer(REQUEST_REVIEWERS_RE, title): if not match.group(3): continue request_reviewers.extend(re.split(LIST_RE, match.group(3))) granted_reviewers = [] for match in re.finditer(GRANTED_REVIEWERS_RE, title): if not match.group(3): continue granted_reviewers.extend(re.split(LIST_RE, match.group(3))) return dict(request=request_reviewers, granted=granted_reviewers) def strip_differential_revision(body): return ARC_DIFF_REV_RE.sub("", body).rstrip() def amend_revision_url(body, new_url): """Append or replace the Differential Revision URL in a commit body.""" body = strip_differential_revision(body) if body: body += "\n" body += "\nDifferential Revision: %s" % new_url return body def has_arc_rejections(body): return all(r.search(body) for r in ARC_REJECT_RE_LIST) def augment_commits_from_body(commits): """Extract metadata from commit body as fields. Adds: rev-id, bug-id, reviewers """ for commit in commits: commit["rev-id"] = parse_arc_diff_rev(commit["body"]) # bug-id bug_ids = parse_bugs(commit["title"]) if bug_ids: if len(bug_ids) > 1: logger.warning("Multiple bug-IDs found, using %s" % bug_ids[0]) commit["bug-id"] = bug_ids[0] else: commit["bug-id"] = None if "bug-id-orig" not in commit: commit["bug-id-orig"] = commit["bug-id"] # reviewers commit["reviewers"] = parse_reviewers(commit["title"]) update_commit_title_previews(commits) def build_commit_title(commit): """Build/update title from commit metadata""" # Reviewers. title = replace_reviewers(commit["title"], commit["reviewers"]) # Bug-ID. if commit["bug-id"]: if BUG_ID_RE.search(title): title = BUG_ID_RE.sub("Bug %s" % commit["bug-id"], title, count=1) else: title = "Bug %s - %s" % (commit["bug-id"], title) else: # This is likely to result in unappealing results. title = BUG_ID_RE.sub("", title) return title def update_commit_title_previews(commits): """Update title-preview from commit metadata for all commits in stack""" for commit in commits: commit["title-preview"] = build_commit_title(commit) def replace_reviewers(commit_description, reviewers): """From vct's commitparser""" reviewers_lst = [] if reviewers["request"]: reviewers_lst.append("r?" + ",".join(reviewers["request"])) if reviewers["granted"]: reviewers_lst.append("r=" + ",".join(reviewers["granted"])) reviewers_str = " ".join(reviewers_lst) if commit_description == "": return reviewers_str commit_description = commit_description.splitlines() commit_title = commit_description.pop(0) commit_description = "\n".join(commit_description) if not R_SPECIFIER_RE.search(commit_title): commit_title += " " + reviewers_str else: # replace the first r? with the reviewer list, and all subsequent # occurrences with a marker to mark the blocks we need to remove # later. d = {"first": True} def replace_first_reviewer(matchobj): if R_SPECIFIER_RE.match(matchobj.group(2)): if d["first"]: d["first"] = False return matchobj.group(1) + reviewers_str else: return "\0" else: return matchobj.group(0) commit_title = re.sub(ALL_REVIEWERS_RE, replace_first_reviewer, commit_title) # remove marker values as well as leading separators. this allows us # to remove runs of multiple reviewers and retain the trailing # separator. commit_title = re.sub(LIST + "\0", "", commit_title) commit_title = re.sub("\0", "", commit_title) if commit_description == "": return commit_title.strip() else: return commit_title.strip() + "\n" + commit_description def show_commit_stack(repo, commits, show_rev_urls=False, show_warnings=False): """Log the commit stack in a human readable form.""" # keep output in columns by sizing the action column to the longest rev + 1 ("D") max_len = ( max(len(c.get("rev-id", "") or "") for c in commits) + 1 if commits else "" ) action_template = "(%" + str(max_len) + "s)" for commit in reversed(commits): if commit.get("rev-id"): action = action_template % ("D" + commit["rev-id"]) else: action = action_template % "New" logger.info("%s %s %s" % (action, commit["name"], commit["title-preview"])) if show_warnings: if commit["bug-id-orig"] and commit["bug-id"] != commit["bug-id-orig"]: logger.warning( "!! Bug ID changed from %s to %s" % (commit["bug-id-orig"], commit["bug-id"]) ) if not commit["reviewers"]: logger.warning("!! Missing reviewers") if show_rev_urls and commit["rev-id"]: logger.warning("-> %s/D%s" % (repo.phab_url.rstrip("/"), commit["rev-id"])) def check_for_invalid_reviewers(reviewers, cwd): """Return a list of invalid reviewer names. Args: reviewers: A commit reviewers dict of granted and requested reviewers. cwd: The directory to run the arc command in. """ # Combine the lists of requested reviewers and granted reviewers. all_reviewers = [] found_names = [] for sublist in reviewers.values(): all_reviewers.extend( [ normalise_reviewer(r, strip_group=False) for r in sublist if not r.startswith("#") ] ) users = [] if all_reviewers: # We're using the deprecated user.query API as the user.search does not # provide the user availability information. # See https://phabricator.services.mozilla.com/conduit/method/user.query/ api_call_args = {"usernames": all_reviewers} users = arc_call_conduit("user.query", api_call_args, cwd) found_names = [ normalise_reviewer(data["userName"], strip_group=False) for data in users ] # Group reviewers are represented by a "#" prefix all_groups = [] found_groups = [] for sublist in reviewers.values(): all_groups.extend( [ normalise_reviewer(r, strip_group=False) for r in sublist if r.startswith("#") ] ) if all_groups: # See https://phabricator.services.mozilla.com/conduit/method/project.search/ api_call_args = {"queryKey": "active", "constraints": {"slugs": all_groups}} result = arc_call_conduit("project.search", api_call_args, cwd) found_groups = [ "#%s" % normalise_reviewer(data["fields"]["slug"]) for data in result["data"] ] # We might be searching by the hashtag. if "maps" in result and "slugMap" in result["maps"]: found_groups.extend( [ "#%s" % normalise_reviewer(r) for r in result["maps"]["slugMap"].keys() ] ) all_reviewers.extend(all_groups) found_names.extend(found_groups) invalid_reviewers = list(set(all_reviewers) - set(found_names)) # Find users availability: unavailable_reviewers = [ dict( name=r["userName"], until=datetime.datetime.fromtimestamp(r["currentStatusUntil"]).strftime( "%Y-%m-%d %H:%M" ), ) for r in users if r.get("currentStatus") == "away" ] return unavailable_reviewers + [dict(name=r) for r in invalid_reviewers] # # Arc helpers # def arc_out(args, cwd, stdin=None, log_output_to_console=True): """arc wrapper that logs output to the console. Args: args: A list of arguments for the arc command. cwd: The directory to run the arc command in. stdin: Optionally overrides the standard input pipe to use for the arc subprocess call. log_output_to_console: Defaults to True. If set to False, don't log the arc standard output to the console (stderr prints to console as normal). Returns: The list of lines arc printed to the console. """ arc_output = check_output(config.arc + args, cwd=cwd, split=False, stdin=stdin) if logger.level != logging.DEBUG and log_output_to_console: logger.info(arc_output) return arc_output def arc_call_conduit(api_method, api_call_args, cwd): """Run 'arc call-conduit' and return the JSON API call result. Args: api_method: The API method name to call, like 'differential.revision.edit'. api_call_args: JSON dict of call args to send. cwd: The directory to run the arc command in. Raises: ConduitAPIError if the API threw an error back at us. """ arc_args = ["call-conduit", api_method] # 'arc call-conduit' only accepts its args from STDIN. with TemporaryFileName(json.dumps(api_call_args)) as args_file: logger.debug("Arc stdin: %s", api_call_args) with open(args_file, "rb") as temp_f: output = arc_out( arc_args, cwd=cwd, stdin=temp_f, log_output_to_console=False ) # We expect arc output to be a JSON. However, in DEBUG mode, a `--trace` is used and # the reponse becomes a multiline string with some messages in plain text. output = "\n".join([line for line in output.splitlines() if line.startswith("{")]) maybe_error = parse_api_error(output) if maybe_error: raise ConduitAPIError(maybe_error) return json.loads(output)["response"] def parse_api_error(api_response): """Parse the string output from 'arc call-conduit' and return any errors found. Args: api_response: stdout string captured from 'arc call-conduit'. It should contain only valid JSON. Returns: A string error description if an error occurred or None if no error occurred. """ # Example error response from running # $ echo '{}' | arc call-conduit differential.revision.edit | jq . # # { # "error": "ERR-CONDUIT-CORE", # "errorMessage": "ERR-CONDUIT-CORE: Parameter \"transactions\" is not a list "\ # "of transactions.", # "response": null # } response = json.loads(api_response) if response["error"] and response["errorMessage"]: return response["errorMessage"] def arc_ping(cwd): """Sends a ping to the Phabricator server using `conduit.ping` API. Returns: `True` if no error, otherwise - `False` """ try: arc_call_conduit("conduit.ping", {}, cwd) except ConduitAPIError as err: logger.error(err) return False except CommandError: return False return True # # "submit" Command # def arc_message(template_vars): """Build arc commit desc message from the template""" # Map `None` to an empty string. for name in template_vars.keys(): if template_vars[name] is None: template_vars[name] = "" # `depends_on` is optional. if "depends_on" not in template_vars: template_vars["depends_on"] = "" message = ARC_COMMIT_DESC_TEMPLATE.format(**template_vars) logger.debug("--- arc message\n%s\n---" % message) return message def make_blocking(reviewers): return ["%s!" % r.rstrip("!") for r in reviewers] def remove_duplicates(reviewers): """Remove all duplicate items from the list. Args: reviewers: list of strings representing IRC nicks of reviewers. Returns: list of unique reviewers. Duplicates with excalamation mark are prefered. """ unique = [] nicks = [] for reviewer in reviewers: nick = reviewer.lower().strip("!") if reviewer.endswith("!") and nick in nicks: nicks.remove(nick) unique = [r for r in unique if r.lower().strip("!") != nick] if nick not in nicks: nicks.append(nick) unique.append(reviewer) return unique def update_commits_from_args(commits, args): """Modify commit description based on args and configuration. Args: commits: list of dicts representing the commits in commit stack args: argparse.ArgumentParser object. In this function we're using following attributes: * reviewer - list of strings representing reviewers * blocker - list of string representing blocking reviewers * bug - an integer representing bug id in Bugzilla Command args always overwrite commit desc. Overwriting reviewers rules (The "-r" are loaded into args.reviewer and "-R" into args.blocker): a) Recognize `r=` and `r?` from commit message title. b) Modify reviewers only if `-r` or `-R` is used in call arguments. c) Add new reviewers using `r=`. d) Do not modify reviewers already provided in the title (`r?` or `r=`). e) Delete reviewer if `-r` or `-R` is used and the reviewer is not mentioned. If no reviewers are provided by args and an `always_blocking` flag is set change all reviewers in title to blocking. """ # Build list of reviewers from args. # WARN: Order might be changed here `list(set(["one", "two"])) == ['two', 'one']` reviewers = list(set(args.reviewer)) if args.reviewer else [] blockers = [r.rstrip("!") for r in args.blocker] if args.blocker else [] # User might use "-r !" to provide a blocking reviewer. # Add all such blocking reviewers to blockers list. blockers += [r.rstrip("!") for r in reviewers if r.endswith("!")] blockers = list(set(blockers)) # Remove blocking reviewers from reviewers list reviewers_no_flag = [r.strip("!") for r in reviewers] reviewers = [r for r in reviewers_no_flag if r.lower() not in blockers] # Add all blockers to reviewers list. Reviewers list will contain a list # of all reviewers where blocking ones are marked with the "!" suffix. reviewers += make_blocking(blockers) reviewers = remove_duplicates(reviewers) lowercase_reviewers = [r.lower() for r in reviewers] lowercase_blockers = [r.lower() for r in blockers] for commit in commits: if reviewers: # Only the reviewers mentioned in command line args will remain. # New reviewers will be marked as "granted" (r=). granted = reviewers[:] requested = [] # commit["reviewers"]["request|"] is a list containing reviewers # provided in commit's title with an r? mark. for reviewer in commit["reviewers"]["request"]: r = reviewer.strip("!") # check which request reviewers are provided also via -r if r.lower() in lowercase_reviewers: requested.append(r) granted.remove(r) # check which request reviewers are provided also via -R elif r.lower() in lowercase_blockers: requested.append("%s!" % r) granted.remove("%s!" % r) else: granted = commit["reviewers"].get("granted", []) requested = commit["reviewers"].get("request", []) commit["reviewers"] = dict(granted=granted, request=requested) if args.bug: # Bug ID command arg used. commit["bug-id"] = args.bug # Otherwise honour config setting to always use blockers if not reviewers and config.always_blocking: for commit in commits: commit["reviewers"] = dict( request=make_blocking(commit["reviewers"]["request"]), granted=make_blocking(commit["reviewers"]["granted"]), ) update_commit_title_previews(commits) def build_api_call_to_update_commit_title_and_summary(commit): """Build a Conduit API transaction to update a commit title and summary. See https://phabricator.services.mozilla.com/api/differential.revision.edit for the returned JSON object format. Args: commit: A VCS commit data dict to use for the call args. Returns: A JSON dict that can be passed to a Conduit differential.revision.edit API call. """ # The Phabricator API will refuse the new summary value if we include the # "Differential Revision:" keyword in the summary body. transactions = [ dict(type="title", value=commit["title"]), dict(type="summary", value=strip_differential_revision(commit["body"])), ] return {"transactions": transactions, "objectIdentifier": commit["rev-id"]} def update_phabricator_commit_summary(commit, repo): """Send a new commit summary and description to Phabricator. Args: commit: A VCS commit data dict to use for the call args. repo: The Repository that the commit lives in. """ # From https://phabricator.services.mozilla.com/api/differential.revision.edit # # Example call format we are aiming for: # # $ echo '{ # "transactions": [ # { # "type": "title", # "value": "Remove unnecessary branch statement" # } # { # "type": "summary", # "value": "Blah" # } # ], # "objectIdentifier": "D8095" # }' | arc call-conduit --conduit-uri https://phabricator.services.mozilla.com/ \ # --conduit-token differential.revision.edit logger.debug("updating revision title and summary in Phabricator") api_call_args = build_api_call_to_update_commit_title_and_summary(commit) try: arc_call_conduit("differential.revision.edit", api_call_args, repo.path) except (ConduitAPIError, CommandError) as err: logger.warn( "Error attempting to update revision summary and title in Phabricator" ) logger.warn("Error was: %s", err) def submit(repo, args): if DEBUG: config.arc.append("--trace") # Find and preview commits to submits. commits = repo.commit_stack() if not commits: raise Error("Failed to find any commits to submit") logger.warning( "Submitting %s commit%s:" % (len(commits), "" if len(commits) == 1 else "s") ) # Pre-process to load metadata. augment_commits_from_body(commits) update_commits_from_args(commits, args) # Check if arc is configured if not repo.check_arc(): raise Error("Failed to run %s." % config.arc_command) # Validate commit stack is suitable for review. show_commit_stack(repo, commits, show_warnings=True) try: repo.check_commits_for_submit(commits) except Error as e: if not args.force: raise Error("Unable to submit commits:\n\n%s" % e) logger.error("Ignoring issues found with commits:\n\n%s" % e) # Show a warning if there are untracked files. if config.warn_untracked: untracked = repo.untracked() if untracked: logger.warning( "Warning: found %s untracked file%s (will not be submitted for review):" % (len(untracked), "" if len(untracked) == 1 else "s") ) if len(untracked) <= 5: for filename in untracked: logger.info(" %s" % filename) # Confirmation prompt. if args.yes: pass elif config.auto_submit and not args.interactive: logger.info( "Automatically submitting (as per submit.auto_submit in %s)" % config.name ) else: res = prompt( "Submit to %s" % PHABRICATOR_URLS.get(repo.phab_url, repo.phab_url), ["Yes", "No", "Always"], ) if res == "No": return if res == "Always": config.auto_submit = True config.write() # Process. previous_commit = None for commit in commits: # Only revisions being updated have an ID. Newly created ones don't. is_update = bool(commit["rev-id"]) # Let the user know something's happening. if is_update: logger.info("\nUpdating revision D%s:" % commit["rev-id"]) else: logger.info("\nCreating new revision:") logger.info("%s %s" % (commit["name"], commit["title-preview"])) repo.checkout(commit["node"]) # Create arc-annotated commit description. template_vars = dict( title=commit["title-preview"], body=commit["body"], reviewers=", ".join( commit["reviewers"]["granted"] + commit["reviewers"]["request"] ), bug_id=commit["bug-id"], ) if previous_commit and not args.no_stack: template_vars["depends_on"] = "Depends on D%s" % previous_commit["rev-id"] message = arc_message(template_vars) # Run arc. with TemporaryFileName(message) as message_file: arc_args = ( ["diff"] + ["--base", "arc:this"] + ["--allow-untracked", "--no-amend", "--no-ansi"] + ["--message-file", message_file] ) if args.nolint: arc_args.append("--nolint") if args.wip: arc_args.append("--plan-changes") if is_update: arc_args.extend( ["--message", "Revision updated."] + ["--update", commit["rev-id"]] ) else: arc_args.append("--create") revision_url = None for line in check_call_by_line( config.arc + arc_args, cwd=repo.path, never_log=True ): print(line) # Extract Revision URL. m = ARC_OUTPUT_REV_URL_RE.search(line) if m: revision_url = m.group(1) if not revision_url: raise Error("Failed to find 'Revision URL' in arc output") if is_update: try: update_phabricator_commit_summary(commit, repo) except Exception as e: logger.warning( "Warning: failed to update commit summary in Phabricator" ) logger.debug(e) # Append/replace div rev url to/in commit description. body = amend_revision_url(commit["body"], revision_url) # Amend the commit if required. if commit["title-preview"] != commit["title"] or body != commit["body"]: commit["title"] = commit["title-preview"] commit["body"] = body commit["rev-id"] = parse_arc_diff_rev(commit["body"]) repo.amend_commit(commit, commits) previous_commit = commit # Cleanup (eg. strip nodes) and refresh to ensure the stack is right for the # final showing. repo.finalize(commits) repo.cleanup() repo.refresh_commit_stack(commits) logger.warning("\nCompleted") show_commit_stack(repo, commits, show_rev_urls=True) # # Self-Updater # def get_self_release(): """Queries github for the most recent release's tag and timestamp""" releases_api_url = "https://api.github.com/repos/%s/releases/latest" % SELF_REPO logger.debug("fetching %s" % releases_api_url) # Grab release json from github's api, using a very short timeout. try: release = json.load(urllib2.urlopen(releases_api_url, timeout=5)) except (urllib2.HTTPError, urllib2.URLError) as e: raise Error("Failed to check for updates: %s" % e) except ValueError: raise Error("Malformed response from GitHub when checking for updates") logger.debug(release) # Grab the published and last-modified timestamps. published_at = parse_zulu_time(release["published_at"]) try: m_time = os.path.getmtime(SELF_FILE) except OSError as e: if e.errno != errno.ENOENT: raise m_time = 0 logger.debug("published_at: %s" % datetime.datetime.fromtimestamp(published_at)) logger.debug("m_time: %s" % datetime.datetime.fromtimestamp(m_time)) return dict( published_at=published_at, update_required=published_at > m_time, tag=str(release["tag_name"]), ) def update_arc(): """Write the las check and update arc.""" try: check_call(config.arc + ["upgrade"]) except subprocess.CalledProcessError: logger.warning( "arc failed to upgrade, which may be caused by using an arcanist " "package from\nyour system package manager." ) result = prompt( "Would you like to skip arc upgrades in the future?", ["Yes", "No"] ) if result == "YES": config.arc_last_check = -1 config.write() else: config.arc_last_check = int(time.time()) config.write() def check_for_updates(): """Log a message if an update is required/available""" # Notify for self updates. if ( config.self_last_check >= 0 and time.time() - config.self_last_check > SELF_UPDATE_FREQUENCY * 60 * 60 ): config.self_last_check = int(time.time()) config.write() try: release = get_self_release() except Error as e: logger.warning(e.message) return if release["update_required"]: logger.warning("Version %s of `moz-phab` is now available" % release["tag"]) logger.info("Run `moz-phab self-update` to update") else: logger.debug("update check not required") # Update arc. if ( config.arc_last_check >= 0 and time.time() - config.arc_last_check > ARC_UPDATE_FREQUENCY * 60 * 60 ): update_arc() def self_update(args): """`self-update` command, updates arc and this script""" # Update arc. if config.arc_last_check >= 0: update_arc() # Update moz-phab release = get_self_release() if not release["update_required"] and not args.force: logger.warning("Update of `moz-phab` not required") return logger.warning("Updating `moz-phab` to v%s" % release["tag"]) url = "https://raw.githubusercontent.com/%s/%s/moz-phab" % ( SELF_REPO, release["tag"], ) logger.debug("updating '%s' from %s" % (SELF_FILE, url)) try: gh = urllib2.urlopen(url) with open(SELF_FILE, "wb") as fh: fh.write(gh.read()) os.chmod(SELF_FILE, stat.S_IRWXU) except (urllib2.HTTPError, urllib2.URLError) as e: raise Error("Failed to download update: %s" % e) logger.info("%s updated" % SELF_FILE) # # Main # class ColourFormatter(logging.Formatter): def __init__(self): if DEBUG: fmt = "%(levelname)-8s %(asctime)-13s %(message)s" else: fmt = "%(message)s" super(ColourFormatter, self).__init__(fmt) self.log_colours = {"WARNING": 34, "ERROR": 31} # blue, red def format(self, record): result = super(ColourFormatter, self).format(record) if HAS_ANSI and record.levelname in self.log_colours: result = "\033[%sm%s\033[0m" % (self.log_colours[record.levelname], result) return result def init_logging(): stdout_handler = logging.StreamHandler(sys.stdout) stdout_handler.setFormatter(ColourFormatter()) logger.addHandler(stdout_handler) logger.setLevel(logging.DEBUG if DEBUG else logging.INFO) def parse_args(argv): parser = argparse.ArgumentParser() commands = parser.add_subparsers(dest="command", metavar="COMMAND") commands.required = True # submit submit_parser = commands.add_parser( "submit", help="Submit commits(s) to Phabricator" ) submit_parser.add_argument( "--path", "-p", help="Set path to repository (default: detected)" ) submit_parser.add_argument( "--yes", "-y", action="store_true", help="Submit without confirmation (default: %s)" % config.auto_submit, ) submit_parser.add_argument( "--interactive", "-i", action="store_true", help="Submit with confirmation (default: %s)" % (not config.auto_submit), ) submit_parser.add_argument( "--force", "-f", action="store_true", help="Override sanity checks and force submission; a tool of last resort", ) submit_parser.add_argument( "--force-delete", action="store_true", help=( "Mercurial only. Override the fail if a DAG branch point detected " "and no evolve installed", ), ) submit_parser.add_argument( "--bug", "-b", help="Set Bug ID for all commits (default: from commit)" ) submit_parser.add_argument( "--reviewer", "--reviewers", "-r", action="append", help="Set review(s) for all commits (default: from commit)", ) submit_parser.add_argument( "--blocker", "--blockers", "-R", action="append", help="Set blocking review(s) for all commits (default: from commit)", ) submit_parser.add_argument( "--nolint", "--no-lint", action="store_true", help="Do not run lint (default: lint changed files if configured)", ) submit_parser.add_argument( "--wip", "--plan-changes", action="store_true", help="Create or update a revision without requesting a code review", ) submit_parser.add_argument( "--no-stack", action="store_true", help="Submit multiple commits, but do not mark them as dependant", ) submit_parser.add_argument( "--upstream", "--upstream", "-u", action="append", help='Set upstream branch to detect the starting commit. (default: "")', ) submit_parser.add_argument( "start_rev", nargs="?", default="(auto)", help="Start revision of range to submit (default: detected)", ) submit_parser.add_argument( "end_rev", nargs="?", default=".", help="End revision of range to submit (default: current commit)", ) # arc informs users to pass --trace for more output, so we need to accept it. submit_parser.add_argument("--trace", action="store_true", help=argparse.SUPPRESS) submit_parser.set_defaults(func=submit, needs_repo=True) # self-update update_parser = commands.add_parser("self-update", help="Update review script") update_parser.add_argument( "--force", "-f", action="store_true", help="Force update even if not necessary" ) update_parser.set_defaults(func=self_update, needs_repo=False) # if we're called without a command and from within a repository, default to submit. if not argv or ( not (set(argv) & {"-h", "--help"}) and argv[0] not in ["submit", "self-update"] and find_repo_root(os.getcwd()) ): logger.debug("defaulting to `submit`") argv.insert(0, "submit") return parser.parse_args(argv) def main(argv): global config, HAS_ANSI, DEBUG try: init_logging() config = Config() if config.no_ansi: HAS_ANSI = False arc_path = which_path(config.arc_command) if not arc_path: raise Error( "Failed to find '%s' on the system path.\n" "Please follow the Phabricator setup guide:\n" "%s" % (config.arc_command, GUIDE_URL) ) config.arc = [arc_path] # Ensure ssl certificates are validated (see PEP 476). if hasattr(ssl, "_https_verify_certificates"): # PEP 493: "private" API to configure HTTPS defaults without monkeypatching # noinspection PyProtectedMember ssl._https_verify_certificates() else: logger.warning( "Your version of Python does not validate HTTPS " "certificates. Please consider upgrading to Python 2.7.9 " "or later." ) args = parse_args(argv) if hasattr(args, "trace") and args.trace: DEBUG = True if args.command != "self-update": check_for_updates() if args.needs_repo: repo = repo_from_args(args) args.func(repo, args) repo.cleanup() else: args.func(args) except KeyboardInterrupt: pass except Error as e: logger.error(e) sys.exit(1) except Exception as e: logger.error( traceback.format_exc() if DEBUG else "%s: %s" % (e.__class__.__name__, e) ) sys.exit(1) if __name__ == "__main__": main(sys.argv[1:])