blob: 8452eb80b0c5a0fc2fe02ef3ff093783b2e16f92 [file] [log] [blame]
Chirayu Desai4a319b82013-06-05 20:14:33 +05301#!/usr/bin/env python
2#
Tom Powellc8580302015-08-04 15:37:12 -07003# Copyright (C) 2013-15 The CyanogenMod Project
Chirayu Desai4a319b82013-06-05 20:14:33 +05304#
5# Licensed under the Apache License, Version 2.0 (the "License");
6# you may not use this file except in compliance with the License.
7# You may obtain a copy of the License at
8#
9# http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS,
13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14# See the License for the specific language governing permissions and
15# limitations under the License.
16#
17
18#
19# Run repopick.py -h for a description of this utility.
20#
21
22from __future__ import print_function
23
24import sys
25import json
26import os
27import subprocess
28import re
29import argparse
30import textwrap
31
32try:
Tom Powellc8580302015-08-04 15:37:12 -070033 # For python3
34 import urllib.error
35 import urllib.request
Chirayu Desai4a319b82013-06-05 20:14:33 +053036except ImportError:
Tom Powellc8580302015-08-04 15:37:12 -070037 # For python2
38 import imp
39 import urllib2
40 urllib = imp.new_module('urllib')
41 urllib.error = urllib2
42 urllib.request = urllib2
Chirayu Desai4a319b82013-06-05 20:14:33 +053043
Chirayu Desai4a319b82013-06-05 20:14:33 +053044
45# Verifies whether pathA is a subdirectory (or the same) as pathB
Tom Powellc8580302015-08-04 15:37:12 -070046def is_subdir(a, b):
47 a = os.path.realpath(a) + '/'
48 b = os.path.realpath(b) + '/'
49 return b == a[:len(b)]
Chirayu Desai4a319b82013-06-05 20:14:33 +053050
Tom Powellc8580302015-08-04 15:37:12 -070051
52def fetch_query_via_ssh(remote_url, query):
53 """Given a remote_url and a query, return the list of changes that fit it
54 This function is slightly messy - the ssh api does not return data in the same structure as the HTTP REST API
55 We have to get the data, then transform it to match what we're expecting from the HTTP RESET API"""
56 if remote_url.count(':') == 2:
57 (uri, userhost, port) = remote_url.split(':')
58 elif remote_url.count(':') == 1:
59 (uri, userhost) = remote_url.split(':')
60 port = 29418
61 else:
62 raise Exception('Malformed URI: Expecting ssh://[user@]host[:port]')
63
64
65 out = subprocess.check_output(['ssh', '-x', '-p{0}'.format(port), userhost, 'gerrit', 'query', '--format=JSON --patch-sets --current-patch-set', query])
66
67 reviews = []
68 for line in out.split('\n'):
69 try:
70 data = json.loads(line)
71 # make our data look like the http rest api data
72 review = {
73 'branch': data['branch'],
74 'change_id': data['id'],
75 'current_revision': data['currentPatchSet']['revision'],
76 'number': int(data['number']),
77 'revisions': {patch_set['revision']: {
78 'number': int(patch_set['number']),
79 'fetch': {
80 'ssh': {
81 'ref': patch_set['ref'],
82 'url': u'ssh://{0}:{1}/{2}'.format(userhost, port, data['project'])
83 }
84 }
85 } for patch_set in data['patchSets']},
86 'subject': data['subject'],
87 'project': data['project'],
88 'status': data['status']
89 }
90 reviews.append(review)
91 except ValueError:
92 pass
93 print('Found {0} reviews'.format(len(reviews)))
94 return reviews
95
96
97def fetch_query_via_http(remote_url, query):
98
99 """Given a query, fetch the change numbers via http"""
100 url = '{0}/changes/?q={1}&o=CURRENT_REVISION&o=ALL_REVISIONS'.format(remote_url, query)
101 data = urllib.request.urlopen(url).read().decode('utf-8')
102 reviews = json.loads(data[5:])
103
104 for review in reviews:
105 review[u'number'] = review.pop('_number')
106
107 return reviews
108
109
110def fetch_query(remote_url, query):
111 """Wrapper for fetch_query_via_proto functions"""
112 if remote_url[0:2] == 'ssh':
113 return fetch_query_via_ssh(remote_url, query)
114 elif remote_url[0:4] == 'http':
115 return fetch_query_via_http(remote_url, query.replace(' ', '+'))
116 else:
117 raise Exception('Gerrit URL should be in the form http[s]://hostname/ or ssh://[user@]host[:port]')
118
119if __name__ == '__main__':
120 parser = argparse.ArgumentParser(formatter_class=argparse.RawDescriptionHelpFormatter, description=textwrap.dedent('''\
121 repopick.py is a utility to simplify the process of cherry picking
122 patches from CyanogenMod's Gerrit instance (or any gerrit instance of your choosing)
123
124 Given a list of change numbers, repopick will cd into the project path
125 and cherry pick the latest patch available.
126
127 With the --start-branch argument, the user can specify that a branch
128 should be created before cherry picking. This is useful for
129 cherry-picking many patches into a common branch which can be easily
130 abandoned later (good for testing other's changes.)
131
132 The --abandon-first argument, when used in conjunction with the
133 --start-branch option, will cause repopick to abandon the specified
134 branch in all repos first before performing any cherry picks.'''))
135 parser.add_argument('change_number', nargs='*', help='change number to cherry pick. Use {change number}/{patchset number} to get a specific revision.')
136 parser.add_argument('-i', '--ignore-missing', action='store_true', help='do not error out if a patch applies to a missing directory')
137 parser.add_argument('-s', '--start-branch', nargs=1, help='start the specified branch before cherry picking')
138 parser.add_argument('-a', '--abandon-first', action='store_true', help='before cherry picking, abandon the branch specified in --start-branch')
139 parser.add_argument('-b', '--auto-branch', action='store_true', help='shortcut to "--start-branch auto --abandon-first --ignore-missing"')
140 parser.add_argument('-q', '--quiet', action='store_true', help='print as little as possible')
141 parser.add_argument('-v', '--verbose', action='store_true', help='print extra information to aid in debug')
jrior001fd11d072015-08-21 17:23:25 -0400142 parser.add_argument('-f', '--force', action='store_true', help='force cherry pick even if change is closed')
Tom Powellc8580302015-08-04 15:37:12 -0700143 parser.add_argument('-p', '--pull', action='store_true', help='execute pull instead of cherry-pick')
144 parser.add_argument('-t', '--topic', help='pick all commits from a specified topic')
145 parser.add_argument('-Q', '--query', help='pick all commits using the specified query')
146 parser.add_argument('-g', '--gerrit', default='http://review.cyanogenmod.org', help='Gerrit Instance to use. Form proto://[user@]host[:port]')
147 args = parser.parse_args()
148 print (args.gerrit)
149 if not args.start_branch and args.abandon_first:
150 parser.error('if --abandon-first is set, you must also give the branch name with --start-branch')
151 if args.auto_branch:
152 args.abandon_first = True
153 args.ignore_missing = True
154 if not args.start_branch:
155 args.start_branch = ['auto']
156 if args.quiet and args.verbose:
157 parser.error('--quiet and --verbose cannot be specified together')
158
159 if (1 << bool(args.change_number) << bool(args.topic) << bool(args.query)) != 2:
160 parser.error('One (and only one) of change_number, topic, and query are allowed')
161
162 # Change current directory to the top of the tree
163 if 'ANDROID_BUILD_TOP' in os.environ:
164 top = os.environ['ANDROID_BUILD_TOP']
165
166 if not is_subdir(os.getcwd(), top):
167 sys.stderr.write('ERROR: You must run this tool from within $ANDROID_BUILD_TOP!\n')
168 sys.exit(1)
169 os.chdir(os.environ['ANDROID_BUILD_TOP'])
170
171 # Sanity check that we are being run from the top level of the tree
172 if not os.path.isdir('.repo'):
173 sys.stderr.write('ERROR: No .repo directory found. Please run this from the top of your tree.\n')
Chirayu Desai4a319b82013-06-05 20:14:33 +0530174 sys.exit(1)
175
Tom Powellc8580302015-08-04 15:37:12 -0700176 # If --abandon-first is given, abandon the branch before starting
177 if args.abandon_first:
178 # Determine if the branch already exists; skip the abandon if it does not
179 plist = subprocess.check_output(['repo', 'info'])
180 needs_abandon = False
181 for pline in plist:
182 matchObj = re.match(r'Local Branches.*\[(.*)\]', pline)
183 if matchObj:
184 local_branches = re.split('\s*,\s*', matchObj.group(1))
185 if any(args.start_branch[0] in s for s in local_branches):
186 needs_abandon = True
Chirayu Desai4a319b82013-06-05 20:14:33 +0530187
Tom Powellc8580302015-08-04 15:37:12 -0700188 if needs_abandon:
189 # Perform the abandon only if the branch already exists
190 if not args.quiet:
191 print('Abandoning branch: %s' % args.start_branch[0])
192 subprocess.check_output(['repo', 'abandon', args.start_branch[0]])
193 if not args.quiet:
194 print('')
Chirayu Desai4a319b82013-06-05 20:14:33 +0530195
Tom Powellc8580302015-08-04 15:37:12 -0700196 # Get the list of projects that repo knows about
197 # - convert the project name to a project path
198 project_name_to_path = {}
199 plist = subprocess.check_output(['repo', 'list']).split('\n')
Chirayu Desai4a319b82013-06-05 20:14:33 +0530200
Tom Powellc8580302015-08-04 15:37:12 -0700201 for pline in plist:
Chirayu Desai4a319b82013-06-05 20:14:33 +0530202 if not pline:
203 break
Tom Powellc8580302015-08-04 15:37:12 -0700204 ppaths = pline.split(' : ')
Chirayu Desai4a319b82013-06-05 20:14:33 +0530205
Tom Powellc8580302015-08-04 15:37:12 -0700206 project_name_to_path[ppaths[1]] = ppaths[0]
207
208 # get data on requested changes
209 reviews = []
210 change_numbers = []
211 if args.topic:
212 reviews = fetch_query(args.gerrit, 'topic:{0}'.format(args.topic))
213 change_numbers = [str(r['number']) for r in reviews]
214 if args.query:
215 reviews = fetch_query(args.gerrit, args.query)
216 change_numbers = [str(r['number']) for r in reviews]
217 if args.change_number:
218 reviews = fetch_query(args.gerrit, ' OR '.join('change:{0}'.format(x.split('/')[0]) for x in args.change_number))
219 change_numbers = args.change_number
220 # make list of things to actually merge
221
222 mergables = []
223
224 for change in change_numbers:
225 patchset = None
226 if '/' in change:
227 (change, patchset) = change.split('/')
228 change = int(change)
229
230 review = [x for x in reviews if x['number'] == change][0]
231 mergables.append({
232 'subject': review['subject'],
233 'project': review['project'],
234 'branch': review['branch'],
235 'change_number': review['number'],
236 'status': review['status'],
237 'fetch': None
238 })
239 mergables[-1]['fetch'] = review['revisions'][review['current_revision']]['fetch']
240 mergables[-1]['id'] = change
241 if patchset:
242 try:
243 mergables[-1]['fetch'] = [x['fetch'] for x in review['revisions'] if x['_number'] == patchset][0]
244 mergables[-1]['id'] = '{0}/{1}'.format(change, patchset)
245 except (IndexError, ValueError):
246 print('ERROR: The patch set {0}/{1} could not be found, using CURRENT_REVISION instead.'.format(change, patchset))
247
248 for item in mergables:
249 print('Applying change number {0}...'.format(item['id']))
jrior001fd11d072015-08-21 17:23:25 -0400250 # Check if change is open and exit if it's not, unless -f is specified
251 if item['status'] != 'OPEN' and not args.query:
Tom Powellc8580302015-08-04 15:37:12 -0700252 if args.force:
jrior001fd11d072015-08-21 17:23:25 -0400253 print('!! Force-picking a closed change !!\n')
Tom Powellc8580302015-08-04 15:37:12 -0700254 else:
jrior001fd11d072015-08-21 17:23:25 -0400255 print('Change is closed. Skipping the cherry pick.\nUse -f to force this pick.')
Tom Powellc8580302015-08-04 15:37:12 -0700256 continue
257
258 # Convert the project name to a project path
259 # - check that the project path exists
260 project_path = None
261 if item['project'] in project_name_to_path:
262 project_path = project_name_to_path[item['project']]
263
264 if project_path.startswith('hardware/qcom/'):
265 split_path = project_path.split('/')
266 # split_path[2] might be display or it might be display-caf, trim the -caf
267 split_path[2] = split_path[2].split('-')[0]
268
269 # Need to treat hardware/qcom/{audio,display,media} specially
270 if split_path[2] == 'audio' or split_path[2] == 'display' or split_path[2] == 'media':
271 split_branch = item['branch'].split('-')
272
273 # display is extra special
274 if split_path[2] == 'display' and len(split_path) == 3:
275 project_path = '/'.join(split_path)
276 else:
277 project_path = '/'.join(split_path[:-1])
278
279 if len(split_branch) == 4 and split_branch[0] == 'cm' and split_branch[2] == 'caf':
280 project_path += '-caf/msm' + split_branch[3]
281 # audio and media are different from display
282 elif split_path[2] == 'audio' or split_path[2] == 'media':
283 project_path += '/default'
Anthony King8e1ccda2015-07-20 17:39:20 -0400284 elif project_path.startswith('hardware/ril'):
285 project_path = project_path.rstrip('-caf')
286 if item["branch"].split('-')[-1] == 'caf':
287 project_path += '-caf'
Tom Powellc8580302015-08-04 15:37:12 -0700288 elif args.ignore_missing:
289 print('WARNING: Skipping {0} since there is no project directory for: {1}\n'.format(item['id'], item['project']))
290 continue
291 else:
292 sys.stderr.write('ERROR: For {0}, could not determine the project path for project {1}\n'.format(item['id'], item['project']))
293 sys.exit(1)
294
295 # If --start-branch is given, create the branch (more than once per path is okay; repo ignores gracefully)
296 if args.start_branch:
297 subprocess.check_output(['repo', 'start', args.start_branch[0], project_path])
298
299 # Print out some useful info
Chirayu Desai4a319b82013-06-05 20:14:33 +0530300 if not args.quiet:
Tom Powellc8580302015-08-04 15:37:12 -0700301 print('--> Subject: "{0}"'.format(item['subject']))
302 print('--> Project path: {0}'.format(project_path))
303 print('--> Change number: {0} (Patch Set {0})'.format(item['id']))
304
305 # Try fetching from GitHub first
306 if args.verbose:
307 print('Trying to fetch the change from GitHub')
308
309 if 'anonymous http' in item['fetch']:
310 method = 'anonymous http'
311 else:
312 method = 'ssh'
313
314 if args.pull:
315 cmd = ['git pull --no-edit github', item['fetch'][method]['ref']]
316 else:
317 cmd = ['git fetch github', item['fetch'][method]['ref']]
318
319 print(cmd)
320 subprocess.call([' '.join(cmd)], cwd=project_path, shell=True)
321 # Check if it worked
322 FETCH_HEAD = '{0}/.git/FETCH_HEAD'.format(project_path)
323 if os.stat(FETCH_HEAD).st_size == 0:
324 # That didn't work, fetch from Gerrit instead
325 if args.verbose:
326 print('Fetching from GitHub didn\'t work, trying to fetch the change from Gerrit')
327 if args.pull:
328 cmd = ['git pull --no-edit', item['fetch'][method]['url'], item['fetch'][method]['ref']]
329 else:
330 cmd = ['git fetch', item['fetch'][method]['url'], item['fetch'][method]['ref']]
331 subprocess.call([' '.join(cmd)], cwd=project_path, shell=True)
332 # Perform the cherry-pick
333 if not args.pull:
334 cmd = ['git cherry-pick FETCH_HEAD']
335 subprocess.call(cmd, cwd=project_path, shell=True)
Chirayu Desai4a319b82013-06-05 20:14:33 +0530336 if not args.quiet:
337 print('')
338