PK܉M1bZZcherry_picker/__init__.py"""Backport CPython changes from master to maintenance branches.""" __version__ = '1.2.1' PK܉Mh7$]]cherry_picker/__main__.pyfrom .cherry_picker import cherry_pick_cli if __name__ == '__main__': cherry_pick_cli() PK܉M8~G~Gcherry_picker/cherry_picker.py#!/usr/bin/env python3 # -*- coding: utf-8 -*- import click import collections import os import pathlib import subprocess import webbrowser import re import sys import requests import toml from gidgethub import sansio from . import __version__ CREATE_PR_URL_TEMPLATE = ("https://api.github.com/repos/" "{config[team]}/{config[repo]}/pulls") DEFAULT_CONFIG = collections.ChainMap({ 'team': 'python', 'repo': 'cpython', 'check_sha': '7f777ed95a19224294949e1b4ce56bbffcb1fe9f', 'fix_commit_msg': True, 'default_branch': 'master', }) class BranchCheckoutException(Exception): pass class CherryPickException(Exception): pass class InvalidRepoException(Exception): pass class CherryPicker: def __init__(self, pr_remote, commit_sha1, branches, *, dry_run=False, push=True, prefix_commit=True, config=DEFAULT_CONFIG, ): self.config = config self.check_repo() # may raise InvalidRepoException if dry_run: click.echo("Dry run requested, listing expected command sequence") self.pr_remote = pr_remote self.commit_sha1 = commit_sha1 self.branches = branches self.dry_run = dry_run self.push = push self.prefix_commit = prefix_commit @property def upstream(self): """Get the remote name to use for upstream branches Uses "upstream" if it exists, "origin" otherwise """ cmd = ['git', 'remote', 'get-url', 'upstream'] try: subprocess.check_output(cmd, stderr=subprocess.DEVNULL) except subprocess.CalledProcessError: return "origin" return "upstream" @property def sorted_branches(self): """Return the branches to cherry-pick to, sorted by version.""" return sorted( self.branches, reverse=True, key=version_from_branch) @property def username(self): cmd = ['git', 'config', '--get', f'remote.{self.pr_remote}.url'] raw_result = subprocess.check_output(cmd, stderr=subprocess.STDOUT) result = raw_result.decode('utf-8') # implicit ssh URIs use : to separate host from user, others just use / username = result.replace(':', '/').split('/')[-2] return username def get_cherry_pick_branch(self, maint_branch): return f"backport-{self.commit_sha1[:7]}-{maint_branch}" def get_pr_url(self, base_branch, head_branch): return f"https://github.com/{self.config['team']}/{self.config['repo']}/compare/{base_branch}...{self.username}:{head_branch}?expand=1" def fetch_upstream(self): """ git fetch """ cmd = ['git', 'fetch', self.upstream] self.run_cmd(cmd) def run_cmd(self, cmd): assert not isinstance(cmd, str) if self.dry_run: click.echo(f" dry-run: {' '.join(cmd)}") return output = subprocess.check_output(cmd, stderr=subprocess.STDOUT) click.echo(output.decode('utf-8')) def checkout_branch(self, branch_name): """ git checkout -b """ cmd = ['git', 'checkout', '-b', self.get_cherry_pick_branch(branch_name), f'{self.upstream}/{branch_name}'] try: self.run_cmd(cmd) except subprocess.CalledProcessError as err: click.echo(f"Error checking out the branch {self.get_cherry_pick_branch(branch_name)}.") click.echo(err.output) raise BranchCheckoutException(f"Error checking out the branch {self.get_cherry_pick_branch(branch_name)}.") def get_commit_message(self, commit_sha): """ Return the commit message for the current commit hash, replace # with GH- """ cmd = ['git', 'show', '-s', '--format=%B', commit_sha] output = subprocess.check_output(cmd, stderr=subprocess.STDOUT) message = output.strip().decode('utf-8') if self.config['fix_commit_msg']: return message.replace('#', 'GH-') else: return message def checkout_default_branch(self): """ git checkout default branch """ cmd = 'git', 'checkout', self.config['default_branch'] self.run_cmd(cmd) def status(self): """ git status :return: """ cmd = ['git', 'status'] self.run_cmd(cmd) def cherry_pick(self): """ git cherry-pick -x """ cmd = ['git', 'cherry-pick', '-x', self.commit_sha1] try: self.run_cmd(cmd) except subprocess.CalledProcessError as err: click.echo(f"Error cherry-pick {self.commit_sha1}.") click.echo(err.output) raise CherryPickException(f"Error cherry-pick {self.commit_sha1}.") def get_exit_message(self, branch): return \ f""" Failed to cherry-pick {self.commit_sha1} into {branch} \u2639 ... Stopping here. To continue and resolve the conflict: $ cherry_picker --status # to find out which files need attention # Fix the conflict $ cherry_picker --status # should now say 'all conflict fixed' $ cherry_picker --continue To abort the cherry-pick and cleanup: $ cherry_picker --abort """ def amend_commit_message(self, cherry_pick_branch): """ prefix the commit message with (X.Y) """ commit_prefix = "" if self.prefix_commit: commit_prefix = f"[{get_base_branch(cherry_pick_branch)}] " updated_commit_message = f"""{commit_prefix}{self.get_commit_message(self.commit_sha1)} (cherry picked from commit {self.commit_sha1}) Co-authored-by: {get_author_info_from_short_sha(self.commit_sha1)}""" if self.dry_run: click.echo(f" dry-run: git commit --amend -m '{updated_commit_message}'") else: cmd = ['git', 'commit', '--amend', '-m', updated_commit_message] try: subprocess.check_output(cmd, stderr=subprocess.STDOUT) except subprocess.CalledProcessError as cpe: click.echo("Failed to amend the commit message \u2639") click.echo(cpe.output) return updated_commit_message def push_to_remote(self, base_branch, head_branch, commit_message=""): """ git push """ cmd = ['git', 'push', self.pr_remote, f'{head_branch}:{head_branch}'] try: self.run_cmd(cmd) except subprocess.CalledProcessError: click.echo(f"Failed to push to {self.pr_remote} \u2639") else: gh_auth = os.getenv("GH_AUTH") if gh_auth: self.create_gh_pr(base_branch, head_branch, commit_message=commit_message, gh_auth=gh_auth) else: self.open_pr(self.get_pr_url(base_branch, head_branch)) def create_gh_pr(self, base_branch, head_branch, *, commit_message, gh_auth): """ Create PR in GitHub """ request_headers = sansio.create_headers( self.username, oauth_token=gh_auth) title, body = normalize_commit_message(commit_message) if not self.prefix_commit: title = f"[{base_branch}] {title}" data = { "title": title, "body": body, "head": f"{self.username}:{head_branch}", "base": base_branch, "maintainer_can_modify": True } url = CREATE_PR_URL_TEMPLATE.format(config=self.config) response = requests.post(url, headers=request_headers, json=data) if response.status_code == requests.codes.created: click.echo(f"Backport PR created at {response.json()['html_url']}") else: click.echo(response.status_code) click.echo(response.text) def open_pr(self, url): """ open url in the web browser """ if self.dry_run: click.echo(f" dry-run: Create new PR: {url}") else: click.echo("Backport PR URL:") click.echo(url) webbrowser.open_new_tab(url) def delete_branch(self, branch): cmd = ['git', 'branch', '-D', branch] self.run_cmd(cmd) def cleanup_branch(self, branch): self.checkout_default_branch() try: self.delete_branch(branch) except subprocess.CalledProcessError: click.echo(f"branch {branch} NOT deleted.") else: click.echo(f"branch {branch} has been deleted.") def backport(self): if not self.branches: raise click.UsageError("At least one branch must be specified.") self.fetch_upstream() for maint_branch in self.sorted_branches: click.echo(f"Now backporting '{self.commit_sha1}' into '{maint_branch}'") cherry_pick_branch = self.get_cherry_pick_branch(maint_branch) self.checkout_branch(maint_branch) commit_message = "" try: self.cherry_pick() commit_message = self.amend_commit_message(cherry_pick_branch) except subprocess.CalledProcessError as cpe: click.echo(cpe.output) click.echo(self.get_exit_message(maint_branch)) except CherryPickException: click.echo(self.get_exit_message(maint_branch)) raise else: if self.push: self.push_to_remote(maint_branch, cherry_pick_branch, commit_message) self.cleanup_branch(cherry_pick_branch) else: click.echo(\ f""" Finished cherry-pick {self.commit_sha1} into {cherry_pick_branch} \U0001F600 --no-push option used. ... Stopping here. To continue and push the changes: $ cherry_picker --continue To abort the cherry-pick and cleanup: $ cherry_picker --abort """) def abort_cherry_pick(self): """ run `git cherry-pick --abort` and then clean up the branch """ cmd = ['git', 'cherry-pick', '--abort'] try: self.run_cmd(cmd) except subprocess.CalledProcessError as cpe: click.echo(cpe.output) # only delete backport branch created by cherry_picker.py if get_current_branch().startswith('backport-'): self.cleanup_branch(get_current_branch()) def continue_cherry_pick(self): """ git push origin open the PR clean up branch """ cherry_pick_branch = get_current_branch() if cherry_pick_branch.startswith('backport-'): # amend the commit message, prefix with [X.Y] base = get_base_branch(cherry_pick_branch) short_sha = cherry_pick_branch[cherry_pick_branch.index('-')+1:cherry_pick_branch.index(base)-1] full_sha = get_full_sha_from_short(short_sha) commit_message = self.get_commit_message(short_sha) co_author_info = f"Co-authored-by: {get_author_info_from_short_sha(short_sha)}" updated_commit_message = f"""[{base}] {commit_message}. (cherry picked from commit {full_sha}) {co_author_info}""" if self.dry_run: click.echo(f" dry-run: git commit -a -m '{updated_commit_message}' --allow-empty") else: cmd = ['git', 'commit', '-a', '-m', updated_commit_message, '--allow-empty'] subprocess.check_output(cmd, stderr=subprocess.STDOUT) self.push_to_remote(base, cherry_pick_branch) self.cleanup_branch(cherry_pick_branch) click.echo("\nBackport PR:\n") click.echo(updated_commit_message) else: click.echo(f"Current branch ({cherry_pick_branch}) is not a backport branch. Will not continue. \U0001F61B") def check_repo(self): """ Check that the repository is for the project we're configured to operate on. This function performs the check by making sure that the sha specified in the config is present in the repository that we're operating on. """ try: validate_sha(self.config['check_sha']) except ValueError: raise InvalidRepoException() CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) @click.command(context_settings=CONTEXT_SETTINGS) @click.version_option(version=__version__) @click.option('--dry-run', is_flag=True, help="Prints out the commands, but not executed.") @click.option('--pr-remote', 'pr_remote', metavar='REMOTE', help='git remote to use for PR branches', default='origin') @click.option('--abort', 'abort', flag_value=True, default=None, help="Abort current cherry-pick and clean up branch") @click.option('--continue', 'abort', flag_value=False, default=None, help="Continue cherry-pick, push, and clean up branch") @click.option('--status', 'status', flag_value=True, default=None, help="Get the status of cherry-pick") @click.option('--push/--no-push', 'push', is_flag=True, default=True, help="Changes won't be pushed to remote") @click.option('--config-path', 'config_path', metavar='CONFIG-PATH', help=("Path to config file, .cherry_picker.toml " "from project root by default"), default=None) @click.argument('commit_sha1', 'The commit sha1 to be cherry-picked', nargs=1, default = "") @click.argument('branches', 'The branches to backport to', nargs=-1) def cherry_pick_cli(dry_run, pr_remote, abort, status, push, config_path, commit_sha1, branches): click.echo("\U0001F40D \U0001F352 \u26CF") config = load_config(config_path) try: cherry_picker = CherryPicker(pr_remote, commit_sha1, branches, dry_run=dry_run, push=push, config=config) except InvalidRepoException: click.echo(f"You're not inside a {config['repo']} repo right now! \U0001F645") sys.exit(-1) if abort is not None: if abort: cherry_picker.abort_cherry_pick() else: cherry_picker.continue_cherry_pick() elif status: click.echo(cherry_picker.status()) else: try: cherry_picker.backport() except BranchCheckoutException: sys.exit(-1) except CherryPickException: sys.exit(-1) def get_base_branch(cherry_pick_branch): """ return '2.7' from 'backport-sha-2.7' raises ValueError if the specified branch name is not of a form that cherry_picker would have created """ prefix, sha, base_branch = cherry_pick_branch.split('-', 2) if prefix != 'backport': raise ValueError('branch name is not prefixed with "backport-". Is this a cherry_picker branch?') if not re.match('[0-9a-f]{7,40}', sha): raise ValueError(f'branch name has an invalid sha: {sha}') # Validate that the sha refers to a valid commit within the repo # Throws a ValueError if the sha is not present in the repo validate_sha(sha) # Subject the parsed base_branch to the same tests as when we generated it # This throws a ValueError if the base_branch doesn't meet our requirements version_from_branch(base_branch) return base_branch def validate_sha(sha): """ Validate that a hexdigest sha is a valid commit in the repo raises ValueError if the sha does not reference a commit within the repo """ cmd = ['git', 'log', '-r', sha] try: subprocess.check_output(cmd, stderr=subprocess.STDOUT) except subprocess.SubprocessError: raise ValueError(f'The sha listed in the branch name, {sha}, is not present in the repository') def version_from_branch(branch): """ return version information from a git branch name """ try: return tuple(map(int, re.match(r'^.*(?P\d+(\.\d+)+).*$', branch).groupdict()['version'].split('.'))) except AttributeError as attr_err: raise ValueError(f'Branch {branch} seems to not have a version in its name.') from attr_err def get_current_branch(): """ Return the current branch """ cmd = ['git', 'rev-parse', '--abbrev-ref', 'HEAD'] output = subprocess.check_output(cmd, stderr=subprocess.STDOUT) return output.strip().decode('utf-8') def get_full_sha_from_short(short_sha): cmd = ['git', 'log', '-1', '--format=%H', short_sha] output = subprocess.check_output(cmd, stderr=subprocess.STDOUT) full_sha = output.strip().decode('utf-8') return full_sha def get_author_info_from_short_sha(short_sha): cmd = ['git', 'log', '-1', '--format=%aN <%ae>', short_sha] output = subprocess.check_output(cmd, stderr=subprocess.STDOUT) author = output.strip().decode('utf-8') return author def normalize_commit_message(commit_message): """ Return a tuple of title and body from the commit message """ split_commit_message = commit_message.split("\n") title = split_commit_message[0] body = "\n".join(split_commit_message[1:]) return title, body.lstrip("\n") def find_project_root(): cmd = ['git', 'rev-parse', '--show-toplevel'] output = subprocess.check_output(cmd, stderr=subprocess.STDOUT) return pathlib.Path(output.decode('utf-8').strip()) def find_config(): root = find_project_root() if root is not None: child = root / '.cherry_picker.toml' if child.exists() and not child.is_dir(): return child return None def load_config(path): if path is None: path = find_config() if path is None: return DEFAULT_CONFIG else: path = pathlib.Path(path) # enforce a cast to pathlib datatype with path.open() as f: d = toml.load(f) return DEFAULT_CONFIG.new_child(d) if __name__ == '__main__': cherry_pick_cli() PK܉M~+Q)Q)cherry_picker/test.pyimport os import pathlib import subprocess from collections import ChainMap from unittest import mock import pytest from .cherry_picker import get_base_branch, get_current_branch, \ get_full_sha_from_short, get_author_info_from_short_sha, \ CherryPicker, InvalidRepoException, \ normalize_commit_message, DEFAULT_CONFIG, \ find_project_root, find_config, load_config @pytest.fixture def config(): check_sha = 'dc896437c8efe5a4a5dfa50218b7a6dc0cbe2598' return ChainMap(DEFAULT_CONFIG).new_child({'check_sha': check_sha}) @pytest.fixture def cd(): cwd = os.getcwd() def changedir(d): os.chdir(d) yield changedir # restore CWD back os.chdir(cwd) @mock.patch('subprocess.check_output') def test_get_base_branch(subprocess_check_output): # The format of cherry-pick branches we create are:: # backport-{SHA}-{base_branch} subprocess_check_output.return_value = b'22a594a0047d7706537ff2ac676cdc0f1dcb329c' cherry_pick_branch = 'backport-22a594a-2.7' result = get_base_branch(cherry_pick_branch) assert result == '2.7' @mock.patch('subprocess.check_output') def test_get_base_branch_which_has_dashes(subprocess_check_output): subprocess_check_output.return_value = b'22a594a0047d7706537ff2ac676cdc0f1dcb329c' cherry_pick_branch = 'backport-22a594a-baseprefix-2.7-basesuffix' result = get_base_branch(cherry_pick_branch) assert result == 'baseprefix-2.7-basesuffix' @pytest.mark.parametrize('cherry_pick_branch', ['backport-22a594a', # Not enough fields 'prefix-22a594a-2.7', # Not the prefix we were expecting 'backport-22a594a-base', # No version info in the base branch ] ) @mock.patch('subprocess.check_output') def test_get_base_branch_invalid(subprocess_check_output, cherry_pick_branch): subprocess_check_output.return_value = b'22a594a0047d7706537ff2ac676cdc0f1dcb329c' with pytest.raises(ValueError): get_base_branch(cherry_pick_branch) @mock.patch('subprocess.check_output') def test_get_current_branch(subprocess_check_output): subprocess_check_output.return_value = b'master' assert get_current_branch() == 'master' @mock.patch('subprocess.check_output') def test_get_full_sha_from_short(subprocess_check_output): mock_output = b"""22a594a0047d7706537ff2ac676cdc0f1dcb329c""" subprocess_check_output.return_value = mock_output assert get_full_sha_from_short('22a594a') == '22a594a0047d7706537ff2ac676cdc0f1dcb329c' @mock.patch('subprocess.check_output') def test_get_author_info_from_short_sha(subprocess_check_output): mock_output = b"Armin Rigo " subprocess_check_output.return_value = mock_output assert get_author_info_from_short_sha('22a594a') == 'Armin Rigo ' @pytest.mark.parametrize('input_branches,sorted_branches', [ (['3.1', '2.7', '3.10', '3.6'], ['3.10', '3.6', '3.1', '2.7']), (['stable-3.1', 'lts-2.7', '3.10-other', 'smth3.6else'], ['3.10-other', 'smth3.6else', 'stable-3.1', 'lts-2.7']), ]) @mock.patch('os.path.exists') def test_sorted_branch(os_path_exists, config, input_branches, sorted_branches): os_path_exists.return_value = True cp = CherryPicker('origin', '22a594a0047d7706537ff2ac676cdc0f1dcb329c', input_branches, config=config) assert cp.sorted_branches == sorted_branches @pytest.mark.parametrize('input_branches', [ (['3.1', '2.7', '3.x10', '3.6', '']), (['stable-3.1', 'lts-2.7', '3.10-other', 'smth3.6else', 'invalid']), ]) @mock.patch('os.path.exists') def test_invalid_branches(os_path_exists, config, input_branches): os_path_exists.return_value = True cp = CherryPicker('origin', '22a594a0047d7706537ff2ac676cdc0f1dcb329c', input_branches, config=config) with pytest.raises(ValueError): cp.sorted_branches @mock.patch('os.path.exists') def test_get_cherry_pick_branch(os_path_exists, config): os_path_exists.return_value = True branches = ["3.6"] cp = CherryPicker('origin', '22a594a0047d7706537ff2ac676cdc0f1dcb329c', branches, config=config) assert cp.get_cherry_pick_branch("3.6") == "backport-22a594a-3.6" @mock.patch('os.path.exists') @mock.patch('subprocess.check_output') def test_get_pr_url(subprocess_check_output, os_path_exists, config): os_path_exists.return_value = True subprocess_check_output.return_value = b'https://github.com/mock_user/cpython.git' branches = ["3.6"] cp = CherryPicker('origin', '22a594a0047d7706537ff2ac676cdc0f1dcb329c', branches, config=config) assert cp.get_pr_url("3.6", cp.get_cherry_pick_branch("3.6")) \ == "https://github.com/python/cpython/compare/3.6...mock_user:backport-22a594a-3.6?expand=1" @pytest.mark.parametrize('url', [ b'git@github.com:mock_user/cpython.git', b'git@github.com:mock_user/cpython', b'ssh://git@github.com/mock_user/cpython.git', b'ssh://git@github.com/mock_user/cpython', b'https://github.com/mock_user/cpython.git', b'https://github.com/mock_user/cpython', ]) def test_username(url, config): with mock.patch('subprocess.check_output', return_value=url): branches = ["3.6"] cp = CherryPicker('origin', '22a594a0047d7706537ff2ac676cdc0f1dcb329c', branches, config=config) assert cp.username == 'mock_user' @mock.patch('os.path.exists') @mock.patch('subprocess.check_output') def test_get_updated_commit_message(subprocess_check_output, os_path_exists, config): os_path_exists.return_value = True subprocess_check_output.return_value = b'bpo-123: Fix Spam Module (#113)' branches = ["3.6"] cp = CherryPicker('origin', '22a594a0047d7706537ff2ac676cdc0f1dcb329c', branches, config=config) assert cp.get_commit_message('22a594a0047d7706537ff2ac676cdc0f1dcb329c') \ == 'bpo-123: Fix Spam Module (GH-113)' @mock.patch('os.path.exists') @mock.patch('subprocess.check_output') def test_get_updated_commit_message_without_links_replacement( subprocess_check_output, os_path_exists, config): os_path_exists.return_value = True subprocess_check_output.return_value = b'bpo-123: Fix Spam Module (#113)' config['fix_commit_msg'] = False branches = ["3.6"] cp = CherryPicker('origin', '22a594a0047d7706537ff2ac676cdc0f1dcb329c', branches, config=config) assert cp.get_commit_message('22a594a0047d7706537ff2ac676cdc0f1dcb329c') \ == 'bpo-123: Fix Spam Module (#113)' @mock.patch('subprocess.check_output') def test_is_cpython_repo(subprocess_check_output, config): subprocess_check_output.return_value = """commit 7f777ed95a19224294949e1b4ce56bbffcb1fe9f Author: Guido van Rossum Date: Thu Aug 9 14:25:15 1990 +0000 Initial revision """ # should not raise an exception CherryPicker('origin', '22a594a0047d7706537ff2ac676cdc0f1dcb329c', ["3.6"], config=config) def test_is_not_cpython_repo(): # use default CPython sha to fail on this repo with pytest.raises(InvalidRepoException): CherryPicker('origin', '22a594a0047d7706537ff2ac676cdc0f1dcb329c', ["3.6"]) def test_find_project_root(): here = pathlib.Path(__file__) root = here.parent.parent.parent assert find_project_root() == root def test_find_config(tmpdir, cd): cd(tmpdir) subprocess.run('git init .'.split(), check=True) cfg = tmpdir.join('.cherry_picker.toml') cfg.write('param = 1') assert str(find_config()) == str(cfg) def test_find_config_not_found(tmpdir, cd): cd(tmpdir) subprocess.run('git init .'.split(), check=True) assert find_config() is None def test_load_full_config(tmpdir, cd): cd(tmpdir) subprocess.run('git init .'.split(), check=True) cfg = tmpdir.join('.cherry_picker.toml') cfg.write('''\ team = "python" repo = "core-workfolow" check_sha = "5f007046b5d4766f971272a0cc99f8461215c1ec" default_branch = "devel" ''') cfg = load_config(None) assert cfg == {'check_sha': '5f007046b5d4766f971272a0cc99f8461215c1ec', 'repo': 'core-workfolow', 'team': 'python', 'fix_commit_msg': True, 'default_branch': 'devel', } def test_load_partial_config(tmpdir, cd): cfg = tmpdir.join('.cherry_picker.toml') cfg.write('''\ repo = "core-workfolow" ''') cfg = load_config(pathlib.Path(str(cfg))) assert cfg == {'check_sha': '7f777ed95a19224294949e1b4ce56bbffcb1fe9f', 'repo': 'core-workfolow', 'team': 'python', 'fix_commit_msg': True, 'default_branch': 'master', } def test_normalize_long_commit_message(): commit_message = """[3.6] Fix broken `Show Source` links on documentation pages (GH-3113) The `Show Source` was broken because of a change made in sphinx 1.5.1 In Sphinx 1.4.9, the sourcename was "index.txt". In Sphinx 1.5.1+, it is now "index.rst.txt". (cherry picked from commit b9ff498793611d1c6a9b99df464812931a1e2d69) Co-authored-by: Elmar Ritsch <35851+elritsch@users.noreply.github.com>""" title, body = normalize_commit_message(commit_message) assert title == "[3.6] Fix broken `Show Source` links on documentation pages (GH-3113)" assert body == """The `Show Source` was broken because of a change made in sphinx 1.5.1 In Sphinx 1.4.9, the sourcename was "index.txt". In Sphinx 1.5.1+, it is now "index.rst.txt". (cherry picked from commit b9ff498793611d1c6a9b99df464812931a1e2d69) Co-authored-by: Elmar Ritsch <35851+elritsch@users.noreply.github.com>""" def test_normalize_short_commit_message(): commit_message = """[3.6] Fix broken `Show Source` links on documentation pages (GH-3113) (cherry picked from commit b9ff498793611d1c6a9b99df464812931a1e2d69) Co-authored-by: Elmar Ritsch <35851+elritsch@users.noreply.github.com>""" title, body = normalize_commit_message(commit_message) assert title == "[3.6] Fix broken `Show Source` links on documentation pages (GH-3113)" assert body == """(cherry picked from commit b9ff498793611d1c6a9b99df464812931a1e2d69) Co-authored-by: Elmar Ritsch <35851+elritsch@users.noreply.github.com>""" PK!HMj/M.cherry_picker-1.2.1.dist-info/entry_points.txtN+I/N.,()JH-*/LN-Ex9\\PK!HNO#cherry_picker-1.2.1.dist-info/WHEEL HM K-*ϳR03rOK-J,/RH,zd&Y)r$[)T&UrPK!H)7K7&5&cherry_picker-1.2.1.dist-info/METADATAZrȕxUPDfJdykUd)ߪBY:`轓k5JSU -y_ײLK~γr&ӥbQdkT(3|R2 R:dkrVe`U5l= _gE]B;]TwITE,ROr#s_d fI|-`XNxTHk7 +ޥCJT8&+"K0?͈GaRݔХwHE̘ٲu.wui!3HTQHX"2V?'y^dW؊'lQ^K.m{ H x1 ,hH/ Tlx(eǩ[a'@_uV*qHbU,]Ka^}_|xt)JW9xyyWxfGOz%ScS2F$ ?ѣGBtEJQd)vVFk( 0B3PwlR3-%F]5P\`VM|d,ױVCY/㔬Z<)^,ñ:pp1>x69=ѳT DZZ/}!TZD?<ـ*S8F1eR 0,x**PJ"c-P|Ǟ ,O >2z+:Ql7C- Fs"s9 D]S ]H-GhqttgЭg!P'9To>\glTy">6ɷo|v97Q–1AJg4 u18D\ry\2u2҈IRHvlkۡyR4|2*h{P=}W^FPY&6.@LgF٢;53bM9\p;mSymYiqۯf&o)My6wpI@2@/|ms·&|7koD PǑayO (xSrA|yV: iYSĸ+TTHS9Ϯ;33T8+ &MӄP\rs`#S0 c`ɞƟ@Bg3]vMe1$t6{\wl!N {x̉1:fJZ]m8E@x1a%0յt$qo&[;+e:JZv&&3)ͦZxJ\r.ejr}pjBb[ B6|gF@faj-#x4>X7&iVlsV8M"5=-|boS(ɡm1K^e̷dvK uHE_G:%w?DTQ/7>r;kx|9:2i e $bݠޥD+NK]u .UB<{uZ@=j_In!e@.'k6voeG6Omɱ(7׽ܓbn" EFh{pv!(tÖ؝!wy##FPi$'oߞiy$p5 h^{֐_hG]rAepI߄ UVECiU5뙁ʌf`V~!~ QlV߃-~;s(>x0}k}ak\2 54Ztr%{|ȌQq7w$mKsiu젭cpݾR+SVY#\GVUKXEF.cDf VM|#Z6e#=؎F./PK!Hi:=H$cherry_picker-1.2.1.dist-info/RECORDIs@{~ F ,"b!C^^gðHlxGh -Ę̆Ӎʍ'ͺDsղŝ֚#D-}qAJHj-Xp8 yH W?_!PK܉M1bZZcherry_picker/__init__.pyPK܉Mh7$]]cherry_picker/__main__.pyPK܉M8~G~G%cherry_picker/cherry_picker.pyPK܉M~+Q)Q)Hcherry_picker/test.pyPK!HMj/M.crcherry_picker-1.2.1.dist-info/entry_points.txtPK!HNO#rcherry_picker-1.2.1.dist-info/WHEELPK!H)7K7&5&mscherry_picker-1.2.1.dist-info/METADATAPK!Hi:=H$cherry_picker-1.2.1.dist-info/RECORDPKp