summaryrefslogtreecommitdiff
path: root/util/update_release_branch.py
blob: 222c7b01bad614b1b6044d62578d372fe1ffa169 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
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
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
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
250
251
252
253
254
255
256
257
258
259
260
#!/usr/bin/env python3
# Copyright 2021 The Chromium OS Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Release branch updater tool.

This is a tool to merge from the main branch into a release branch.

Inspired by the fingerprint release process:
http://go/cros-fingerprint-firmware-branching-and-signing and now used by other
boards.
"""
from __future__ import print_function
import argparse
import os
import re
import subprocess
import sys
import textwrap

BUG_NONE_PATTERN = re.compile('none', flags=re.IGNORECASE)


def git_commit_msg(branch, head, merge_head, rel_paths):
    """Generates a merge commit message based off of relevant changes.

    This function obtains the relevant commits from the given relative paths in
    order to extract the bug numbers. It constructs the git commit message
    showing the command used to find the relevant commits.

    Args:
        branch: String indicating the release branch name
        head: String indicating the HEAD refspec
        merge_head: String indicating the merge branch refspec.
        rel_paths: String containing all the relevant paths for this particular
                   baseboard or board.

    Returns:
        A String containing the git commit message with the exception of the
        Signed-Off-By field and Change-ID field.
    """
    relevant_commits_cmd, relevant_commits = get_relevant_commits(head,
                                                                  merge_head,
                                                                  '--oneline',
                                                                  rel_paths)

    _, relevant_bugs = get_relevant_commits(head, merge_head, '', rel_paths)
    relevant_bugs = set(re.findall('BUG=(.*)', relevant_bugs))
    # Filter out "none" from set of bugs
    filtered = []
    for bug_line in relevant_bugs:
        bug_line = bug_line.replace(',', ' ')
        bugs = bug_line.split(' ')
        for bug in bugs:
            if bug and not BUG_NONE_PATTERN.match(bug):
                filtered.append(bug)
    relevant_bugs = filtered

    # TODO(b/179509333): remove Cq-Include-Trybots line when regular CQ and
    # firmware CQ do not behave differently.
    COMMIT_MSG_TEMPLATE = """
Merge remote-tracking branch cros/main into {BRANCH}

Relevant changes:

{RELEVANT_COMMITS_CMD}

{RELEVANT_COMMITS}

BRANCH=None
{BUG_FIELD}
TEST=`make -j buildall`

Cq-Include-Trybots: chromeos/cq:cq-orchestrator
"""
    # Wrap the relevant commits command and bug field such that we don't exceed
    # 72 cols.
    relevant_commits_cmd = textwrap.fill(relevant_commits_cmd, width=72)
    # Wrap at 68 cols to save room for 'BUG='
    bugs = textwrap.wrap(' '.join(relevant_bugs), width=68)
    bug_field = ''
    for line in bugs:
        bug_field += 'BUG=' + line + '\n'
    # Remove the final newline since the template adds it for us.
    bug_field = bug_field[:-1]

    return COMMIT_MSG_TEMPLATE.format(BRANCH=branch,
                                      RELEVANT_COMMITS_CMD=relevant_commits_cmd,
                                      RELEVANT_COMMITS=relevant_commits,
                                      BUG_FIELD=bug_field)


def get_relevant_boards(baseboard):
    """Searches through the EC repo looking for boards that use the given
    baseboard.

    Args:
        baseboard: String containing the baseboard to consider

    Returns:
        A list of strings containing the boards based off of the baseboard.
    """
    proc = subprocess.run(['git', 'grep', 'BASEBOARD:=' + baseboard, '--',
                           'board/'],
                          stdout=subprocess.PIPE,
                          encoding='utf-8',
                          check=True)
    boards = []
    res = proc.stdout.splitlines()
    for line in res:
        boards.append(line.split('/')[1])
    return boards


def get_relevant_commits(head, merge_head, fmt, relevant_paths):
    """Searches git history to find changes since the last merge which modify
    files present in relevant_paths.

    Args:
        head: String indicating the HEAD refspec
        merge_head: String indicating the merge branch refspec.
        fmt: An optional string containing the format option for `git log`
        relevant_paths: String containing all the relevant paths for this
                        particular baseboard or board.

    Returns:
        A tuple containing the arguments passed to the git log command and
        stdout.
    """
    if fmt:
        cmd = ['git', 'log', fmt, head + '..' + merge_head, '--',
               relevant_paths]
    else:
        cmd = ['git', 'log', head + '..' + merge_head, '--', relevant_paths]

    # Pass cmd as a string to subprocess.run() since we need to run with shell
    # equal to True.  The reason we are using shell equal to True is to take
    # advantage of the glob expansion for the relevant paths.
    cmd = ' '.join(cmd)
    proc = subprocess.run(cmd,
                          stdout=subprocess.PIPE,
                          encoding='utf-8',
                          check=True,
                          shell=True)
    return ''.join(proc.args), proc.stdout


def main(argv):
    """Generates a merge commit from ToT to a desired release branch.

    For the commit message, it finds all the commits that have modified a
    relevant path. By default this is the baseboard or board directory.  The
    user may optionally specify a path to a text file which contains a longer
    list of relevant files.  The format should be in the glob syntax that git
    log expects.

    Args:
        argv: A list of the command line arguments passed to this script.
    """
    # Set up argument parser.
    parser = argparse.ArgumentParser(description=("A script that generates a "
                                                  "merge commit from cros/main"
                                                  " to a desired release "
                                                  "branch.  By default, the "
                                                  "'recursive' merge strategy "
                                                  "with the 'theirs' strategy "
                                                  "option is used."))
    parser.add_argument('--baseboard')
    parser.add_argument('--board')
    parser.add_argument('release_branch', help=('The name of the target release'
                                                ' branch'))
    parser.add_argument('--relevant_paths_file',
                        help=('A path to a text file which includes other '
                              'relevant paths of interest for this board '
                              'or baseboard'))
    parser.add_argument('--merge_strategy', '-s', default='recursive',
                        help='The merge strategy to pass to `git merge -s`')
    parser.add_argument('--strategy_option', '-X',
                        help=('The strategy option for the chosen merge '
                              'strategy'))

    opts = parser.parse_args(argv)

    baseboard_dir = ''
    board_dir = ''

    if opts.baseboard:
        # Dereference symlinks so "git log" works as expected.
        baseboard_dir = os.path.relpath('baseboard/' + opts.baseboard)
        baseboard_dir = os.path.relpath(os.path.realpath(baseboard_dir))

        boards = get_relevant_boards(opts.baseboard)
    elif opts.board:
        board_dir = os.path.relpath('board/' + opts.board)
        board_dir = os.path.relpath(os.path.realpath(board_dir))
        boards = [opts.board]
    else:
        parser.error('You must specify a board OR a baseboard')

    print("Gathering relevant paths...")
    relevant_paths = []
    if opts.baseboard:
        relevant_paths.append(baseboard_dir)
    else:
        relevant_paths.append(board_dir)

    for board in boards:
        relevant_paths.append('board/' + board)

    # Check for the existence of a file that has other paths of interest.
    if opts.relevant_paths_file and os.path.exists(opts.relevant_paths_file):
        with open(opts.relevant_paths_file, 'r') as relevant_paths_file:
            for line in relevant_paths_file:
                if not line.startswith('#'):
                    relevant_paths.append(line.rstrip())
    relevant_paths.append('util/getversion.sh')
    relevant_paths = ' '.join(relevant_paths)

    # Now that we have the paths of interest, let's perform the merge.
    print("Updating remote...")
    subprocess.run(['git', 'remote', 'update'], check=True)
    subprocess.run(['git', 'checkout', '-B', opts.release_branch, 'cros/' +
                    opts.release_branch], check=True)
    print("Attempting git merge...")
    if opts.merge_strategy == 'recursive' and not opts.strategy_option:
        opts.strategy_option = 'theirs'
    print("Using '%s' merge strategy" % opts.merge_strategy,
          ("with strategy option '%s'" % opts.strategy_option
           if opts.strategy_option else ''))
    arglist = ['git', 'merge', '--no-ff', '--no-commit', 'cros/main', '-s',
               opts.merge_strategy]
    if opts.strategy_option:
        arglist.append('-X' + opts.strategy_option)
    subprocess.run(arglist, check=True)

    print("Generating commit message...")
    branch = subprocess.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
                            stdout=subprocess.PIPE,
                            encoding='utf-8',
                            check=True).stdout.rstrip()
    head = subprocess.run(['git', 'rev-parse', '--short', 'HEAD'],
                          stdout=subprocess.PIPE,
                          encoding='utf-8',
                          check=True).stdout.rstrip()
    merge_head = subprocess.run(['git', 'rev-parse', '--short',
                                 'MERGE_HEAD'],
                                stdout=subprocess.PIPE,
                                encoding='utf-8',
                                check=True).stdout.rstrip()

    print("Typing as fast as I can...")
    commit_msg = git_commit_msg(branch, head, merge_head, relevant_paths)
    subprocess.run(['git', 'commit', '--signoff', '-m', commit_msg], check=True)
    subprocess.run(['git', 'commit', '--amend'], check=True)
    print(("Finished! **Please review the commit to see if it's to your "
           "liking.**"))


if __name__ == '__main__':
    main(sys.argv[1:])