release-mr.py 9.19 KB
Newer Older
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#!/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`)
23
24
25
  - `-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)
26
  - `-i`: an issue to reference from the merge request
27
28
29
30
31
32
33
34
35
36
37
38
39
40
  - `-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`)
41
42
  - `target_branch`: (optional) the branch to target in the merge request
    (defaults to `release`)
43
44
45
46
47
48
49
50
51
52
53
54

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

55
56
57
58
59
try:
    from termcolor import colored
except ImportError:
    def colored(text, color):
        return text
60
61


62
63
64
65
66
67
68
69
70
71
def removeprefix(string, prefix):
    if hasattr(string, 'removeprefix'):
        return string.removeprefix(prefix)
    else:
        if string.startswith(prefix):
            return string[len(prefix):]
        else:
            return string


72
73
74
75
76
77
78
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')
79
80
    p.add_argument('-r', '--remote', type=str,
                   help='the name of the remote of the fork')
81
82
    p.add_argument('-f', '--fork', type=str,
                   help='the fork to open the merge request from')
83
84
    p.add_argument('-i', '--issue', type=str,
                   help='mention an issue from the merge request')
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
    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']
142
    target_branch = release_data.get('target_branch', 'release')
143
144
145
146
147
148
149
150
151
152
153
154
155
    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.
156
157
158
    if opts.remote is None:
        opts.remote = 'gitlab'

159
    if opts.fork is None:
160
        remote_url = git('remote', 'get-url', opts.remote)
161
162
163
164
165
166
167
168
        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}'
169
170
171
172
173
174
175
176
        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')
177
        opts.branch = removeprefix(head_ref, 'refs/heads/')
178
179

    if opts.version is None:
180
        opts.version = removeprefix(opts.branch, release_branch_prefix)
181
182
183
184
185

    # Check that the `master` commit is an ancestor.
    try:
        git('merge-base', '--is-ancestor', opts.for_master, opts.branch)
    except subprocess.CalledProcessError:
186
        raise RuntimeError(f'{opts.for_master} is not on the branch')
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204

    # 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}'
205
    if opts.issue is not None:
206
        issue = f'See: #{opts.issue}  '
207
208
    else:
        issue = ''
209
210
211
212
213
    description = textwrap.dedent(
        f'''
        ---
        Cc:{release_maintainers}

214
        {issue}
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
        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.
250
251
    print(f'Pushing the branch to your fork (using the `{opts.remote}` remote)...')
    git('push', opts.remote, f'{opts.branch}:{opts.branch}')
252
253

    # Create the merge request.
254
    gitlab.post(f'projects/{source_project_safe}/merge_requests',
255
256
257
258
259
260
261
262
263
                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)