#!/usr/bin/env python3 ''' Create a merge request for a release branch. This script creates a merge request which updates the `release` branch. This action is only allowed when performed by a specific user for each project. This script tries to make the process as easy as possible. It will try an extract as much information as it can from your fork's current state, but options may be passed to provide the information instead. The only required flags are: - `-t`: the GitLab API token - `-m`: the commit which goes into `master` Optional flags include: - `-b`: the branch to use (by default, the name is extracted from the currently checked-out branch) - `-c`: the name of the configuration file which contains the release process metadata (defaults to `.kitware-release.json`) - `-r`: the name of the remote of your fork (defaults to `gitlab`) - `-f`: the name of the fork which contains the release branch (by default, extracted from the remote url) - `-i`: an issue to reference from the merge request - `-p`: the name of the project within the fork namespace (defaults to the name of the main repository) - `-g`: the GitLab instance to communicate with (defaults to `gitlab.kitware.com`) - `-v`: the version which is being created (by default, it is extracted from the branch name) The configuration file contains the following information in a JSON file: - `release_user`: the user that is to be used for creating the merge request - `maintainers`: a list of strings containing project maintainer usernames - `project`: the name of the project on the GitLab instance - `release_branch_prefix`: (optional) the prefix used for release preparation branch names (defaults to `update-to-v`) - `target_branch`: (optional) the branch to target in the merge request (defaults to `release`) Before submitting, the proposed information is provided on the output and a prompt is given before actually performing any actions. ''' import argparse import json import requests import subprocess import sys import textwrap try: from termcolor import colored except ImportError: def colored(text, color): return text def removeprefix(string, prefix): if hasattr(string, 'removeprefix'): return string.removeprefix(prefix) else: if string.startswith(prefix): return string[len(prefix):] else: return string def options(): p = argparse.ArgumentParser( description='Create a merge request for the release process') p.add_argument('-b', '--branch', type=str, help='the name of the branch to use') p.add_argument('-c', '--config', type=str, default='.kitware-release.json', help='the name of the configuration file to use') p.add_argument('-r', '--remote', type=str, help='the name of the remote of the fork') p.add_argument('-f', '--fork', type=str, help='the fork to open the merge request from') p.add_argument('-i', '--issue', type=str, help='mention an issue from the merge request') p.add_argument('-p', '--fork-project', type=str, help='the name of the project in the fork namespace') p.add_argument('-g', '--gitlab', type=str, default='gitlab.kitware.com', help='the gitlab instance to communicate with') p.add_argument('-m', '--for-master', type=str, required=True, help='the master to merge into `master`') p.add_argument('-t', '--token', type=str, required=True, help='API token for the release') p.add_argument('-v', '--version', type=str, help='The version that is being updated') return p class Gitlab(object): def __init__(self, gitlab_url, token): self.gitlab_url = f'https://{gitlab_url}/api/v4' self.headers = { 'PRIVATE-TOKEN': token, } def get(self, endpoint, **kwargs): rsp = requests.get(f'{self.gitlab_url}/{endpoint}', headers=self.headers, params=kwargs) if rsp.status_code != 200: raise RuntimeError(f'Failed request to {endpoint}: {rsp.content}') return rsp.json() def post(self, endpoint, **kwargs): rsp = requests.post(f'{self.gitlab_url}/{endpoint}', headers=self.headers, params=kwargs) if rsp.status_code != 201: raise RuntimeError(f'Failed post to {endpoint}: {rsp.content}') return rsp.json() def git(*args): return subprocess.check_output(('git',) + args, encoding='utf-8').strip() if __name__ == '__main__': p = options() opts = p.parse_args() gitlab = Gitlab(opts.gitlab, opts.token) data = git('cat-file', 'blob', f'master:{opts.config}') release_data = json.loads(data) release_user = release_data['release_user'] maintainers = release_data['maintainers'] project = release_data['project'] target_branch = release_data.get('target_branch', 'release') release_branch_prefix = \ release_data.get('release_branch_prefix', 'update-to-v') if not maintainers: raise RuntimeError('no release maintainers are provided') # Check that the token is for the intended user. current_user = gitlab.get('user') username = current_user['username'] if username != release_user: raise RuntimeError(f'The token is for {username}, not {release_user}') # Gather default information. if opts.remote is None: opts.remote = 'gitlab' if opts.fork is None: remote_url = git('remote', 'get-url', opts.remote) protocols = [ 'https://', 'http://', 'ssh://', ] if not any(map(lambda p: remote_url.startswith(p), protocols)): remote_url = remote_url.replace(':', '/') remote_url = f'ssh://{remote_url}' remote_url = remote_url.split('/') opts.fork = '/'.join(remote_url[3:-1]) if opts.fork_project is None: opts.fork_project = project.split("/")[-1] if opts.branch is None: head_ref = git('symbolic-ref', 'HEAD') opts.branch = removeprefix(head_ref, 'refs/heads/') if opts.version is None: opts.version = removeprefix(opts.branch, release_branch_prefix) # Check that the `master` commit is an ancestor. try: git('merge-base', '--is-ancestor', opts.for_master, opts.branch) except subprocess.CalledProcessError: raise RuntimeError(f'{opts.for_master} is not on the branch') # Make the backport spec for the `master` commit. short_name = git('name-rev', '--name-only', f'--refs=refs/heads/{opts.branch}', opts.for_master) if not short_name.startswith(opts.branch): raise RuntimeError('The name for the `master` commit is not suitable?') backport_spec = short_name.replace(opts.branch, 'HEAD') # Create the MR information we'll need. source_project = f'{opts.fork}/{opts.fork_project}' source_project_safe = source_project.replace('/', '%2f') source_branch = opts.branch title = f'Branch for the v{opts.version} release' release_maintainers = '' for maintainer in maintainers: release_maintainers += f' @{maintainer}' if opts.issue is not None: issue = f'See: #{opts.issue} ' else: issue = '' description = textwrap.dedent( f''' --- Cc:{release_maintainers} {issue} Fast-forward: true \n\ Backport: master:{backport_spec} ''') target_project_safe = project.replace('/', '%2f') target_project_id = gitlab.get(f'projects/{target_project_safe}')['id'] def green(t): return colored(t, 'green') def yellow(t): return colored(t, 'yellow') def info(key, value): print(f'{yellow(key)}: {green(value)}') # Get user verification that this is intended. print(yellow(f'Ready to make the MR for the {opts.version} release')) print() info('Version', opts.version) info('Source project', source_project) info('Source branch', source_branch) info('Title', title) info('Description', description) print() print(yellow('Commit for master:')) print(git('log', '--color', '-n1', opts.for_master)) print() print(yellow('Release-only diff:')) print(git('diff', '--color', opts.for_master, opts.branch)) print() if input(yellow('Is this information correct? [y/N] ')) not in ('y', 'Y'): sys.exit(1) # Push the branch. print(f'Pushing the branch to your fork (using the `{opts.remote}` remote)...') git('push', opts.remote, f'{opts.branch}:{opts.branch}') # Create the merge request. gitlab.post(f'projects/{source_project_safe}/merge_requests', source_branch=source_branch, target_branch=target_branch, title=title, description=description, target_project_id=target_project_id, remove_source_branch=True, # This is a privileged branch; don't allow developers to mess # with it. allow_collaboration=False)