# -*- encoding: utf-8 -*- # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or # implied. # See the License for the specific language governing permissions and # limitations under the License. import argparse import distutils.util import glob import itertools import logging import operator import os import re import subprocess import sys import tempfile from urllib import parse import attr import daiquiri import github from git_pull_request import pagure from git_pull_request import textparse LOG = daiquiri.getLogger("git-pull-request") @attr.s(eq=False, hash=False) class RepositoryId: hosttype = attr.ib(type=str) hostname = attr.ib(type=str) user = attr.ib(type=str) repository = attr.ib(type=str) def __eq__(self, other): return ( self.hosttype == other.hosttype and self.hostname.lower() == other.hostname.lower() and self.user.lower() == other.user.lower() and self.repository.lower() == other.repository.lower() ) def _run_shell_command(cmd, output=None, raise_on_error=True): if output is True: output = subprocess.PIPE LOG.debug("running %s", cmd) sub = subprocess.Popen(cmd, stdout=output, stderr=output) out = sub.communicate() if raise_on_error and sub.returncode: raise RuntimeError("%s returned %d" % (cmd, sub.returncode)) if out[0] is not None: return out[0].strip().decode() def get_login_password(protocol="https", host="github.com"): """Get login/password from git credential.""" subp = subprocess.Popen( ["git", "credential", "fill"], stdin=subprocess.PIPE, stdout=subprocess.PIPE ) # TODO add path support request = "protocol={}\nhost={}\n".format(protocol, host).encode() username = None password = None stdout, stderr = subp.communicate(input=request) ret = subp.wait() if ret != 0: LOG.error("git credential returned exited with status %d", ret) return None, None for line in stdout.split(b"\n"): key, _, value = line.partition(b"=") if key == b"username": username = value.decode() elif key == b"password": password = value.decode() if username and password: break return username, password def approve_login_password(user, password, host="github.com", protocol="https"): """Tell git to approve the credential.""" subp = subprocess.Popen( ["git", "credential", "approve"], stdin=subprocess.PIPE, stdout=subprocess.PIPE ) request = "protocol={}\nhost={}\nusername={}\npassword={}\n".format( protocol, host, user, password ).encode() subp.communicate(input=request) ret = subp.wait() if ret != 0: LOG.error("git credential returned exited with status %d", ret) def git_remote_matching_url(wanted_url): wanted_id = get_repository_id_from_url(wanted_url) remotes = _run_shell_command(["git", "remote", "-v"], output=True).split("\n") for remote in remotes: name, remote_url, push_pull = re.split(r"\s", remote) if push_pull != "(push)": continue remote_id = get_repository_id_from_url(remote_url) if wanted_id == remote_id: return name def git_remote_url(remote="origin", raise_on_error=True): return _run_shell_command( ["git", "config", "--get", "remote." + remote + ".url"], output=True, raise_on_error=raise_on_error, ) def git_get_config(option, default): try: return _run_shell_command( ["git", "config", "--get", "git-pull-request." + option], output=True ) except RuntimeError: return default def git_config_add_argument(parser, option, *args, **kwargs): default = kwargs.get("default") isboolean = kwargs.get("action") in ["store_true", "store_false"] if isboolean and default is None: default = False default = git_get_config(option[2:], default) if isboolean and isinstance(default, str): default = distutils.util.strtobool(default) kwargs["default"] = default return parser.add_argument(option, *args, **kwargs) def git_get_branch_name(): branch = _run_shell_command( ["git", "rev-parse", "--abbrev-ref", "HEAD"], output=True ) if branch == "HEAD": raise RuntimeError("Unable to determine current branch") return branch def git_get_remote_for_branch(branch): return _run_shell_command( ["git", "config", "--get", "branch." + branch + ".remote"], output=True, raise_on_error=False, ) def git_get_remote_branch_for_branch(branch): branch = _run_shell_command( ["git", "config", "--get", "branch." + branch + ".merge"], output=True, raise_on_error=False, ) if branch.startswith("refs/heads/"): return branch[11:] return branch def git_get_config_hosttype(): return _run_shell_command( ["git", "config", "git-pull-request.hosttype"], output=True, raise_on_error=False, ) def git_set_config_hosttype(hosttype): _run_shell_command(["git", "config", "git-pull-request.hosttype", hosttype]) def get_hosttype(host): hosttype = git_get_config_hosttype() if hosttype == "": if pagure.is_pagure(host): hosttype = "pagure" else: hosttype = "github" git_set_config_hosttype(hosttype) return hosttype def get_repository_id_from_url(url): """Return hostype, hostname, user and repository to fork from. :param url: The URL to parse :return: hosttype, hostname, user, repository """ parsed = parse.urlparse(url) if parsed.netloc == "": # Probably ssh host, sep, path = parsed.path.partition(":") if "@" in host: username, sep, host = host.partition("@") else: path = parsed.path[1:].rstrip("/") host = parsed.netloc if "@" in host: username, sep, host = host.partition("@") hosttype = get_hosttype(host) if hosttype == "pagure": user, repo = None, path else: user, repo = path.split("/", 1) if repo.endswith(".git"): repo = repo[:-4] return RepositoryId(hosttype, host, user, repo) def split_and_remove_empty_lines(s): return filter(operator.truth, s.split("\n")) def parse_pr_message(message): message = textparse.remove_ignore_marker(message) message_by_line = message.split("\n") if len(message) == 0: return None, None title = message_by_line[0] body = "\n".join(itertools.dropwhile(operator.not_, message_by_line[1:])) return title, body def git_get_commit_body(commit): return _run_shell_command( ["git", "show", "-q", "--format=%b", commit, "--"], output=True ) def git_get_log_titles(begin, end): log = _run_shell_command( ["git", "log", "--no-merges", "--format=%s", "%s..%s" % (begin, end)], output=True, ) return list(split_and_remove_empty_lines(log)) def git_get_log(begin, end): return _run_shell_command( [ "git", "log", "--no-merges", "--reverse", "--format=## %s%n%n%b", "%s..%s" % (begin, end), ], output=True, ) def git_get_title_and_message(begin, end): """Get title and message summary for patches between 2 commits. :param begin: first commit to look at :param end: last commit to look at :return: number of commits, title, message """ titles = git_get_log_titles(begin, end) if len(titles) == 1: title = titles[0] else: title = "Pull request for " + end pr_template = get_pull_request_template() if pr_template is not None: message = textparse.concat_with_ignore_marker( pr_template, git_get_log(begin, end) ) elif len(titles) == 1: message = git_get_commit_body(end) else: message = git_get_log(begin, end) return len(titles), title, message def git_pull_request( target_remote=None, target_branch=None, title=None, message=None, keep_message=None, comment=None, rebase=True, download=None, download_setup=False, fork=True, setup_only=False, branch_prefix=None, dry_run=False, labels=None, ): branch = git_get_branch_name() if not branch: LOG.critical("Unable to find current branch") return 10 LOG.debug("Local branch name is `%s'", branch) target_branch = target_branch or git_get_remote_branch_for_branch(branch) if not target_branch: target_branch = "master" LOG.info( "No target branch configured for local branch `%s', using `%s'.\n" "Use the --target-branch option to override.", branch, target_branch, ) target_remote = target_remote or git_get_remote_for_branch(target_branch) if not target_remote: LOG.critical( "Unable to find target remote for target branch `%s'", target_branch ) return 20 LOG.debug("Target remote for branch `%s' is `%s'", target_branch, target_remote) target_url = git_remote_url(target_remote) if not target_url: LOG.critical("Unable to find remote URL for remote `%s'", target_remote) return 30 LOG.debug("Remote URL for remote `%s' is `%s'", target_remote, target_url) hosttype, hostname, user_to_fork, reponame_to_fork = attr.astuple( get_repository_id_from_url(target_url) ) LOG.debug( "%s user and repository to fork: %s/%s on %s", hosttype.capitalize(), user_to_fork, reponame_to_fork, hostname, ) user, password = get_login_password(host=hostname) if not user and not password: LOG.critical( "Unable to find your credentials for %s.\n" "Make sure you have a git credential working.", hostname, ) return 35 LOG.debug("Found %s user: `%s' password: <redacted>", hostname, user) if hosttype == "pagure": g = pagure.Client(hostname, user, password, reponame_to_fork) repo = g.get_repo(reponame_to_fork) else: kwargs = {} if hostname != "github.com": kwargs["base_url"] = "https://" + hostname + "/api/v3" LOG.debug("Using API base url `%s'", kwargs["base_url"]) g = github.Github(user, password, **kwargs) repo = g.get_user(user_to_fork).get_repo(reponame_to_fork) if download is not None: retcode = download_pull_request( g, repo, target_remote, download, download_setup ) else: retcode = fork_and_push_pull_request( g, hosttype, repo, rebase, target_remote, target_branch, branch, user, title, message, keep_message, comment, fork, setup_only, branch_prefix, dry_run, labels, ) approve_login_password(host=hostname, user=user, password=password) return retcode def download_pull_request(g, repo, target_remote, pull_number, setup_remote): pull = repo.get_pull(pull_number) if setup_remote: local_branch_name = pull.head.ref else: local_branch_name = "pull/%d-%s-%s" % ( pull.number, pull.user.login, pull.head.ref, ) target_ref = "pull/%d/head" % pull.number _run_shell_command(["git", "fetch", target_remote, target_ref]) try: _run_shell_command(["git", "checkout", local_branch_name], output=True) except RuntimeError: _run_shell_command(["git", "checkout", "-b", local_branch_name, "FETCH_HEAD"]) else: _run_shell_command(["git", "reset", "--hard", "FETCH_HEAD"]) if setup_remote: remote_name = "github-%s" % pull.user.login remote = git_remote_url(remote_name, raise_on_error=False) if not remote: _run_shell_command( ["git", "remote", "add", remote_name, pull.head.repo.clone_url] ) _run_shell_command(["git", "fetch", remote_name]) _run_shell_command( ["git", "branch", "-u", "origin/%s" % pull.base.ref, local_branch_name] ) def edit_file_get_content_and_remove(filename): editor = _run_shell_command(["git", "var", "GIT_EDITOR"], output=True) if not editor: LOG.warning( "$EDITOR is unset, you will not be able to edit the pull-request message" ) editor = "cat" status = os.system(editor + " " + filename) if status != 0: raise RuntimeError("Editor exited with status code %d" % status) with open(filename, "r") as body: content = body.read().strip() os.unlink(filename) return content def get_pull_request_template(): filename = "PULL_REQUEST_TEMPLATE*" pr_template_paths = [ filename, ".github/PULL_REQUEST_TEMPLATE/*.md", ".github/PULL_REQUEST_TEMPLATE/*.txt", os.path.join(".github", filename), os.path.join("docs", filename), filename.lower(), ".github/pull_request_template/*.md", ".github/pull_request_template/*.txt", os.path.join(".github", filename.lower()), os.path.join("docs", filename.lower()), ] for path in pr_template_paths: templates = glob.glob(path) for template_path in templates: if os.path.isfile(template_path): with open(template_path) as t: return t.read() def edit_title_and_message(title, message): fd, bodyfilename = tempfile.mkstemp() os.close(fd) with open(bodyfilename, "w") as body: body.write(title + "\n\n") body.write(message + "\n") content = edit_file_get_content_and_remove(bodyfilename) return parse_pr_message(content) def fork_and_push_pull_request( g, hosttype, repo_to_fork, rebase, target_remote, target_branch, branch, user, title, message, keep_message, comment, fork, setup_only, branch_prefix, dry_run=False, labels=None, ): g_user = g.get_user() forked = False if fork in ["always", "auto"]: try: repo_forked = g_user.create_fork(repo_to_fork) except github.GithubException as e: if ( fork == "auto" and e.status == 403 and "forking is disabled" in e.data["message"] ): forked = False LOG.info( "Forking is disabled on target repository, " "using base repository" ) else: LOG.error( "Forking is disabled on target repository, " "can't fork", exc_info=True, ) sys.exit(1) else: forked = True LOG.info("Forked repository: %s", repo_forked.html_url) forked_repo_id = get_repository_id_from_url(repo_forked.clone_url) if branch_prefix is None and not forked: branch_prefix = g_user.login if branch_prefix: remote_branch = "{}/{}".format(branch_prefix, branch) else: remote_branch = branch if forked: remote_to_push = git_remote_matching_url(repo_forked.clone_url) if remote_to_push: LOG.debug( "Found forked repository already in remote as `%s'", remote_to_push ) else: remote_to_push = hosttype _run_shell_command( ["git", "remote", "add", remote_to_push, repo_forked.clone_url] ) LOG.info("Added forked repository as remote `%s'", remote_to_push) head = "{}:{}".format(forked_repo_id.user, branch) else: remote_to_push = target_remote head = "{}:{}".format(repo_to_fork.owner.login, remote_branch) if setup_only: LOG.info("Fetch existing branches of remote `%s`", remote_to_push) _run_shell_command(["git", "fetch", remote_to_push]) return if rebase: _run_shell_command(["git", "remote", "update", target_remote]) LOG.info( "Rebasing branch `%s' on branch `%s/%s'", branch, target_remote, target_branch, ) try: _run_shell_command( [ "git", "rebase", "remotes/%s/%s" % (target_remote, target_branch), branch, ] ) except RuntimeError: LOG.error( "It is likely that your change has a merge conflict.\n" "You may resolve it in the working tree now as described " "above.\n" "Once done run `git pull-request' again.\n\n" "If you want to abort conflict resolution, run " "`git rebase --abort'.\n\n" "Alternatively run `git pull-request -R' to upload the change " "without rebase.\n" "However the change won't able to merge until the conflict is " "resolved." ) return 37 if dry_run: LOG.info( "Would force-push branch `%s' to remote `%s/%s'", branch, remote_to_push, remote_branch, ) else: LOG.info( "Force-pushing branch `%s' to remote `%s/%s'", branch, remote_to_push, remote_branch, ) _run_shell_command( [ "git", "push", "--force", remote_to_push, "{}:{}".format(branch, remote_branch), ] ) pulls = list(repo_to_fork.get_pulls(base=target_branch, head=head)) nb_commits, git_title, git_message = git_get_title_and_message( "%s/%s" % (target_remote, target_branch), branch ) if pulls: for pull in pulls: if title is None: # If there's only one commit, it's very likely the new PR title # should be the actual current title. Otherwise, it's unlikely # the title we autogenerate is going to be better than one # might be in place now, so keep it. if nb_commits == 1: ptitle = git_title else: ptitle = pull.title else: ptitle = title if keep_message: ptitle = pull.title body = pull.body else: body = textparse.concat_with_ignore_marker( message or git_message, ">\n> Current pull request content:\n" + pull.title + "\n\n" + pull.body, ) ptitle, body = edit_title_and_message(ptitle, body) if ptitle is None: LOG.critical("Pull-request message is empty, aborting") return 40 if ptitle == pull.title and body == pull.body: LOG.debug("Pull-request title and body is already up to date") elif ptitle and body: if dry_run: LOG.info("Would edit title and body") LOG.info("%s\n", ptitle) LOG.info("%s", body) else: pull.edit(title=ptitle, body=body) LOG.debug("Updated pull-request title and body") elif ptitle: if dry_run: LOG.info("Would edit title") LOG.info("%s\n", ptitle) else: pull.edit(title=ptitle) LOG.debug("Updated pull-request title") elif body: if dry_run: LOG.info("Would edit body") LOG.info("%s\n", body) else: pull.edit(body=body) LOG.debug("Updated pull-request body") if comment: if dry_run: LOG.info('Would comment: "%s"', comment) else: # FIXME(jd) we should be able to comment directly on a PR # without getting it as an issue but pygithub does not # allow that yet repo_to_fork.get_issue(pull.number).create_comment(comment) LOG.debug('Commented: "%s"', comment) if labels: if dry_run: LOG.info("Would add labels %s", labels) else: LOG.debug("Adding labels %s", labels) pull.add_to_labels(*labels) LOG.info("Pull-request updated: %s", pull.html_url) else: # Create a pull request if not title or not message: title = title or git_title message = message or git_message title, message = edit_title_and_message(title, message) if title is None: LOG.critical("Pull-request message is empty, aborting") return 40 if dry_run: LOG.info("Pull-request would be created.") LOG.info("Title: %s", title) LOG.info("Body: %s", message) return try: pull = repo_to_fork.create_pull( base=target_branch, head=head, title=title, body=message ) except github.GithubException as e: LOG.critical(_format_github_exception("create pull request", e)) return 50 else: LOG.info("Pull-request created: %s", pull.html_url) if labels: LOG.debug("Adding labels %s", labels) pull.add_to_labels(*labels) def _format_github_exception(action, exc): url = exc.data.get("documentation_url", "GitHub documentation") errors_msg = "\n".join( error.get("message", "") for error in exc.data.get("errors", []) ) return ( "Unable to %s: %s (%s)\n" "%s\n" "Check %s for more information." % (action, exc.data.get("message"), exc.status, errors_msg, url) ) class DownloadAndSetupAction(argparse.Action): def __call__(self, parser, namespace, values, option_strings=None): setattr(namespace, "download", values) if self.dest == "download": setattr(namespace, "download_setup", False) else: setattr(namespace, "download_setup", True) def build_parser(): parser = argparse.ArgumentParser(description="Creates a GitHub pull-request.") parser.add_argument( "--download", "-d", type=int, action=DownloadAndSetupAction, help="Checkout a pull request", ) parser.add_argument( "--download-and-setup", "-D", type=int, dest="download_setup", action=DownloadAndSetupAction, help=("Checkout a pull request and setup remote " "to be able to repush it"), ) parser.add_argument("--debug", action="store_true", help="Enabled debugging.") parser.add_argument( "--dry-run", "-n", action="store_true", help="Do not push nor create the pull request.", ) git_config_add_argument( parser, "--target-remote", help="Remote to send a pull-request to. " "Default is auto-detected from .git/config.", ) git_config_add_argument( parser, "--target-branch", help="Branch to send a pull-request to. " "Default is auto-detected from .git/config.", ) parser.add_argument("--title", help="Title of the pull request.") parser.add_argument("--message", "-m", help="Message of the pull request.") parser.add_argument( "--keep-message", "-k", action="store_true", help="Don't open an editor to change the pull request message. " "Useful when just refreshing an already-open pull request.", ) parser.add_argument( "--label", "-l", action="append", help="The labels to add to the pull request. " "Can be used multiple times.", ) git_config_add_argument(parser, "--branch-prefix", help="Prefix remote branch") git_config_add_argument( parser, "--no-rebase", "-R", action="store_true", help="Don't rebase branch before pushing.", ) parser.add_argument( "--comment", "-C", help="Comment to publish when updating the pull-request" ) group = parser.add_mutually_exclusive_group() git_config_add_argument( group, "--fork", default="auto", choices=["always", "never", "auto"], help=( "Fork behavior to create the pull-request " "(auto: when repository can't be cloned, " "always: always try to fork it " "never: always use base repository)" ), ) git_config_add_argument( group, "--no-fork", dest="fork", action="store_const", const="never", help="Don't fork to create the pull-request", ) git_config_add_argument( parser, "--setup-only", action="store_true", default=False, help="Just setup the fork repo", ) return parser def main(): args = build_parser().parse_args() daiquiri.setup( outputs=( daiquiri.output.Stream( sys.stdout, formatter=daiquiri.formatter.ColorFormatter( fmt="%(color)s%(message)s%(color_stop)s" ), ), ), level=logging.DEBUG if args.debug else logging.INFO, ) try: return git_pull_request( target_remote=args.target_remote, target_branch=args.target_branch, title=args.title, message=args.message, keep_message=args.keep_message, comment=args.comment, rebase=not args.no_rebase, download=args.download, download_setup=args.download_setup, fork=args.fork, setup_only=args.setup_only, branch_prefix=args.branch_prefix, dry_run=args.dry_run, labels=args.label, ) except Exception: LOG.error("Unable to send pull request", exc_info=True) return 128 if __name__ == "__main__": sys.exit(main())