blob: 4e0152c82e47f527bfd849f919967ac4ee9a3f9c [file] [log] [blame]
mikeNGbdc1fde2018-05-17 23:17:12 -04001#!/usr/bin/env python
2#
3# Copyright (C) 2013-15 The CyanogenMod Project
4#
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
Gabriele M603ca2c2018-03-31 14:26:59 +020031from functools import cmp_to_key
mikeNGbdc1fde2018-05-17 23:17:12 -040032from xml.etree import ElementTree
33
34try:
Dan Pasanenc3508ce2017-01-23 15:08:52 -060035 import requests
mikeNGbdc1fde2018-05-17 23:17:12 -040036except ImportError:
Dan Pasanenc3508ce2017-01-23 15:08:52 -060037 try:
38 # For python3
39 import urllib.error
40 import urllib.request
41 except ImportError:
42 # For python2
43 import imp
44 import urllib2
45 urllib = imp.new_module('urllib')
46 urllib.error = urllib2
47 urllib.request = urllib2
mikeNGbdc1fde2018-05-17 23:17:12 -040048
49
50# Verifies whether pathA is a subdirectory (or the same) as pathB
51def is_subdir(a, b):
52 a = os.path.realpath(a) + '/'
53 b = os.path.realpath(b) + '/'
54 return b == a[:len(b)]
55
56
57def fetch_query_via_ssh(remote_url, query):
58 """Given a remote_url and a query, return the list of changes that fit it
59 This function is slightly messy - the ssh api does not return data in the same structure as the HTTP REST API
60 We have to get the data, then transform it to match what we're expecting from the HTTP RESET API"""
61 if remote_url.count(':') == 2:
62 (uri, userhost, port) = remote_url.split(':')
63 userhost = userhost[2:]
64 elif remote_url.count(':') == 1:
65 (uri, userhost) = remote_url.split(':')
66 userhost = userhost[2:]
67 port = 29418
68 else:
69 raise Exception('Malformed URI: Expecting ssh://[user@]host[:port]')
70
71
72 out = subprocess.check_output(['ssh', '-x', '-p{0}'.format(port), userhost, 'gerrit', 'query', '--format=JSON --patch-sets --current-patch-set', query])
73 if not hasattr(out, 'encode'):
74 out = out.decode()
75 reviews = []
76 for line in out.split('\n'):
77 try:
78 data = json.loads(line)
79 # make our data look like the http rest api data
80 review = {
81 'branch': data['branch'],
82 'change_id': data['id'],
83 'current_revision': data['currentPatchSet']['revision'],
84 'number': int(data['number']),
85 'revisions': {patch_set['revision']: {
86 'number': int(patch_set['number']),
87 'fetch': {
88 'ssh': {
89 'ref': patch_set['ref'],
90 'url': 'ssh://{0}:{1}/{2}'.format(userhost, port, data['project'])
91 }
92 }
93 } for patch_set in data['patchSets']},
94 'subject': data['subject'],
95 'project': data['project'],
96 'status': data['status']
97 }
98 reviews.append(review)
99 except:
100 pass
101 args.quiet or print('Found {0} reviews'.format(len(reviews)))
102 return reviews
103
104
105def fetch_query_via_http(remote_url, query):
Dan Pasanenc3508ce2017-01-23 15:08:52 -0600106 if "requests" in sys.modules:
107 auth = None
108 if os.path.isfile(os.getenv("HOME") + "/.gerritrc"):
109 f = open(os.getenv("HOME") + "/.gerritrc", "r")
110 for line in f:
111 parts = line.rstrip().split("|")
112 if parts[0] in remote_url:
113 auth = requests.auth.HTTPBasicAuth(username=parts[1], password=parts[2])
114 statusCode = '-1'
115 if auth:
Gabriele M603ca2c2018-03-31 14:26:59 +0200116 url = '{0}/a/changes/?q={1}&o=CURRENT_REVISION&o=ALL_REVISIONS&o=ALL_COMMITS'.format(remote_url, query)
Dan Pasanenc3508ce2017-01-23 15:08:52 -0600117 data = requests.get(url, auth=auth)
118 statusCode = str(data.status_code)
119 if statusCode != '200':
120 #They didn't get good authorization or data, Let's try the old way
Gabriele M603ca2c2018-03-31 14:26:59 +0200121 url = '{0}/changes/?q={1}&o=CURRENT_REVISION&o=ALL_REVISIONS&o=ALL_COMMITS'.format(remote_url, query)
Dan Pasanenc3508ce2017-01-23 15:08:52 -0600122 data = requests.get(url)
123 reviews = json.loads(data.text[5:])
124 else:
125 """Given a query, fetch the change numbers via http"""
Gabriele M603ca2c2018-03-31 14:26:59 +0200126 url = '{0}/changes/?q={1}&o=CURRENT_REVISION&o=ALL_REVISIONS&o=ALL_COMMITS'.format(remote_url, query)
Dan Pasanenc3508ce2017-01-23 15:08:52 -0600127 data = urllib.request.urlopen(url).read().decode('utf-8')
128 reviews = json.loads(data[5:])
mikeNGbdc1fde2018-05-17 23:17:12 -0400129
130 for review in reviews:
131 review['number'] = review.pop('_number')
132
133 return reviews
134
135
136def fetch_query(remote_url, query):
137 """Wrapper for fetch_query_via_proto functions"""
138 if remote_url[0:3] == 'ssh':
139 return fetch_query_via_ssh(remote_url, query)
140 elif remote_url[0:4] == 'http':
141 return fetch_query_via_http(remote_url, query.replace(' ', '+'))
142 else:
143 raise Exception('Gerrit URL should be in the form http[s]://hostname/ or ssh://[user@]host[:port]')
144
145if __name__ == '__main__':
146 # Default to BlissRoms Gerrit
Eric Parkbabd42d2018-05-18 06:02:50 -0400147 default_gerrit = 'https://review.blissroms.com'
mikeNGbdc1fde2018-05-17 23:17:12 -0400148
149 parser = argparse.ArgumentParser(formatter_class=argparse.RawDescriptionHelpFormatter, description=textwrap.dedent('''\
150 repopick.py is a utility to simplify the process of cherry picking
151 patches from CyanogenMod's Gerrit instance (or any gerrit instance of your choosing)
152
153 Given a list of change numbers, repopick will cd into the project path
154 and cherry pick the latest patch available.
155
156 With the --start-branch argument, the user can specify that a branch
157 should be created before cherry picking. This is useful for
158 cherry-picking many patches into a common branch which can be easily
159 abandoned later (good for testing other's changes.)
160
161 The --abandon-first argument, when used in conjunction with the
162 --start-branch option, will cause repopick to abandon the specified
163 branch in all repos first before performing any cherry picks.'''))
164 parser.add_argument('change_number', nargs='*', help='change number to cherry pick. Use {change number}/{patchset number} to get a specific revision.')
165 parser.add_argument('-i', '--ignore-missing', action='store_true', help='do not error out if a patch applies to a missing directory')
166 parser.add_argument('-s', '--start-branch', nargs=1, help='start the specified branch before cherry picking')
Harry Youd56d2f6f2017-07-18 18:52:42 +0100167 parser.add_argument('-r', '--reset', action='store_true', help='reset to initial state (abort cherry-pick) if there is a conflict')
mikeNGbdc1fde2018-05-17 23:17:12 -0400168 parser.add_argument('-a', '--abandon-first', action='store_true', help='before cherry picking, abandon the branch specified in --start-branch')
169 parser.add_argument('-b', '--auto-branch', action='store_true', help='shortcut to "--start-branch auto --abandon-first --ignore-missing"')
170 parser.add_argument('-q', '--quiet', action='store_true', help='print as little as possible')
171 parser.add_argument('-v', '--verbose', action='store_true', help='print extra information to aid in debug')
172 parser.add_argument('-f', '--force', action='store_true', help='force cherry pick even if change is closed')
173 parser.add_argument('-p', '--pull', action='store_true', help='execute pull instead of cherry-pick')
174 parser.add_argument('-P', '--path', help='use the specified path for the change')
175 parser.add_argument('-t', '--topic', help='pick all commits from a specified topic')
176 parser.add_argument('-Q', '--query', help='pick all commits using the specified query')
177 parser.add_argument('-g', '--gerrit', default=default_gerrit, help='Gerrit Instance to use. Form proto://[user@]host[:port]')
178 parser.add_argument('-e', '--exclude', nargs=1, help='exclude a list of commit numbers separated by a ,')
Adrian DC80b7d0b2017-09-07 22:59:54 +0200179 parser.add_argument('-c', '--check-picked', type=int, default=10, help='pass the amount of commits to check for already picked changes')
mikeNGbdc1fde2018-05-17 23:17:12 -0400180 args = parser.parse_args()
181 if not args.start_branch and args.abandon_first:
182 parser.error('if --abandon-first is set, you must also give the branch name with --start-branch')
183 if args.auto_branch:
184 args.abandon_first = True
185 args.ignore_missing = True
186 if not args.start_branch:
187 args.start_branch = ['auto']
188 if args.quiet and args.verbose:
189 parser.error('--quiet and --verbose cannot be specified together')
190
191 if (1 << bool(args.change_number) << bool(args.topic) << bool(args.query)) != 2:
192 parser.error('One (and only one) of change_number, topic, and query are allowed')
193
194 # Change current directory to the top of the tree
195 if 'ANDROID_BUILD_TOP' in os.environ:
196 top = os.environ['ANDROID_BUILD_TOP']
197
198 if not is_subdir(os.getcwd(), top):
199 sys.stderr.write('ERROR: You must run this tool from within $ANDROID_BUILD_TOP!\n')
200 sys.exit(1)
201 os.chdir(os.environ['ANDROID_BUILD_TOP'])
202
203 # Sanity check that we are being run from the top level of the tree
204 if not os.path.isdir('.repo'):
205 sys.stderr.write('ERROR: No .repo directory found. Please run this from the top of your tree.\n')
206 sys.exit(1)
207
208 # If --abandon-first is given, abandon the branch before starting
209 if args.abandon_first:
210 # Determine if the branch already exists; skip the abandon if it does not
211 plist = subprocess.check_output(['repo', 'info'])
212 if not hasattr(plist, 'encode'):
213 plist = plist.decode()
214 needs_abandon = False
215 for pline in plist.splitlines():
216 matchObj = re.match(r'Local Branches.*\[(.*)\]', pline)
217 if matchObj:
218 local_branches = re.split('\s*,\s*', matchObj.group(1))
219 if any(args.start_branch[0] in s for s in local_branches):
220 needs_abandon = True
221
222 if needs_abandon:
223 # Perform the abandon only if the branch already exists
224 if not args.quiet:
225 print('Abandoning branch: %s' % args.start_branch[0])
226 subprocess.check_output(['repo', 'abandon', args.start_branch[0]])
227 if not args.quiet:
228 print('')
229
230 # Get the master manifest from repo
231 # - convert project name and revision to a path
232 project_name_to_data = {}
233 manifest = subprocess.check_output(['repo', 'manifest'])
234 xml_root = ElementTree.fromstring(manifest)
235 projects = xml_root.findall('project')
236 remotes = xml_root.findall('remote')
237 default_revision = xml_root.findall('default')[0].get('revision')
238
239 #dump project data into the a list of dicts with the following data:
240 #{project: {path, revision}}
241
242 for project in projects:
243 name = project.get('name')
244 path = project.get('path')
245 revision = project.get('revision')
246 if revision is None:
247 for remote in remotes:
248 if remote.get('name') == project.get('remote'):
249 revision = remote.get('revision')
250 if revision is None:
Simon Shields26c964c2016-09-26 17:52:03 +1000251 revision = default_revision
mikeNGbdc1fde2018-05-17 23:17:12 -0400252
253 if not name in project_name_to_data:
254 project_name_to_data[name] = {}
255 revision = revision.split('refs/heads/')[-1]
256 project_name_to_data[name][revision] = path
257
258 # get data on requested changes
259 reviews = []
260 change_numbers = []
Gabriele M603ca2c2018-03-31 14:26:59 +0200261
262 def cmp_reviews(review_a, review_b):
263 current_a = review_a['current_revision']
264 parents_a = [r['commit'] for r in review_a['revisions'][current_a]['commit']['parents']]
265 current_b = review_b['current_revision']
266 parents_b = [r['commit'] for r in review_b['revisions'][current_b]['commit']['parents']]
267 if current_a in parents_b:
268 return -1
269 elif current_b in parents_a:
270 return 1
271 else:
272 return cmp(review_a['number'], review_b['number'])
273
mikeNGbdc1fde2018-05-17 23:17:12 -0400274 if args.topic:
275 reviews = fetch_query(args.gerrit, 'topic:{0}'.format(args.topic))
Gabriele M603ca2c2018-03-31 14:26:59 +0200276 change_numbers = [str(r['number']) for r in sorted(reviews, key=cmp_to_key(cmp_reviews))]
mikeNGbdc1fde2018-05-17 23:17:12 -0400277 if args.query:
278 reviews = fetch_query(args.gerrit, args.query)
Gabriele M603ca2c2018-03-31 14:26:59 +0200279 change_numbers = [str(r['number']) for r in sorted(reviews, key=cmp_to_key(cmp_reviews))]
mikeNGbdc1fde2018-05-17 23:17:12 -0400280 if args.change_number:
Gabriele Me0b086a2018-04-01 17:50:58 +0200281 change_url_re = re.compile('https?://.+?/([0-9]+(?:/[0-9]+)?)/?')
mikeNGbdc1fde2018-05-17 23:17:12 -0400282 for c in args.change_number:
Gabriele Me0b086a2018-04-01 17:50:58 +0200283 change_number = change_url_re.findall(c)
284 if change_number:
285 change_numbers.extend(change_number)
286 elif '-' in c:
mikeNGbdc1fde2018-05-17 23:17:12 -0400287 templist = c.split('-')
288 for i in range(int(templist[0]), int(templist[1]) + 1):
289 change_numbers.append(str(i))
290 else:
291 change_numbers.append(c)
292 reviews = fetch_query(args.gerrit, ' OR '.join('change:{0}'.format(x.split('/')[0]) for x in change_numbers))
293
294 # make list of things to actually merge
295 mergables = []
296
297 # If --exclude is given, create the list of commits to ignore
298 exclude = []
299 if args.exclude:
300 exclude = args.exclude[0].split(',')
301
302 for change in change_numbers:
303 patchset = None
304 if '/' in change:
305 (change, patchset) = change.split('/')
306
307 if change in exclude:
308 continue
309
310 change = int(change)
LuK1337646b9bf2017-03-24 23:25:13 +0100311
Gabriele M7909ac62018-04-01 17:50:55 +0200312 if patchset:
LuK1337646b9bf2017-03-24 23:25:13 +0100313 patchset = int(patchset)
314
mikeNGbdc1fde2018-05-17 23:17:12 -0400315 review = next((x for x in reviews if x['number'] == change), None)
316 if review is None:
317 print('Change %d not found, skipping' % change)
318 continue
319
320 mergables.append({
321 'subject': review['subject'],
322 'project': review['project'],
323 'branch': review['branch'],
324 'change_id': review['change_id'],
325 'change_number': review['number'],
326 'status': review['status'],
Gabriele M283396e2018-04-01 17:50:57 +0200327 'fetch': None,
328 'patchset': review['revisions'][review['current_revision']]['_number'],
mikeNGbdc1fde2018-05-17 23:17:12 -0400329 })
Gabriele M283396e2018-04-01 17:50:57 +0200330
mikeNGbdc1fde2018-05-17 23:17:12 -0400331 mergables[-1]['fetch'] = review['revisions'][review['current_revision']]['fetch']
332 mergables[-1]['id'] = change
333 if patchset:
334 try:
LuK1337dbeb6322017-03-24 20:00:48 +0100335 mergables[-1]['fetch'] = [review['revisions'][x]['fetch'] for x in review['revisions'] if review['revisions'][x]['_number'] == patchset][0]
mikeNGbdc1fde2018-05-17 23:17:12 -0400336 mergables[-1]['id'] = '{0}/{1}'.format(change, patchset)
Gabriele M283396e2018-04-01 17:50:57 +0200337 mergables[-1]['patchset'] = patchset
mikeNGbdc1fde2018-05-17 23:17:12 -0400338 except (IndexError, ValueError):
339 args.quiet or print('ERROR: The patch set {0}/{1} could not be found, using CURRENT_REVISION instead.'.format(change, patchset))
340
341 for item in mergables:
342 args.quiet or print('Applying change number {0}...'.format(item['id']))
343 # Check if change is open and exit if it's not, unless -f is specified
Dan Pasanenb2a0f6f2017-07-09 09:41:33 -0500344 if (item['status'] != 'OPEN' and item['status'] != 'NEW' and item['status'] != 'DRAFT') and not args.query:
mikeNGbdc1fde2018-05-17 23:17:12 -0400345 if args.force:
346 print('!! Force-picking a closed change !!\n')
347 else:
348 print('Change status is ' + item['status'] + '. Skipping the cherry pick.\nUse -f to force this pick.')
349 continue
350
351 # Convert the project name to a project path
352 # - check that the project path exists
353 project_path = None
354
355 if item['project'] in project_name_to_data and item['branch'] in project_name_to_data[item['project']]:
356 project_path = project_name_to_data[item['project']][item['branch']]
357 elif args.path:
358 project_path = args.path
359 elif args.ignore_missing:
360 print('WARNING: Skipping {0} since there is no project directory for: {1}\n'.format(item['id'], item['project']))
361 continue
Akhil Narang13bd28a2017-10-31 11:38:29 +0530362 elif item['project'] == 'platform_manifest':
363 project_path = '.repo/manifests'
mikeNGbdc1fde2018-05-17 23:17:12 -0400364 else:
365 sys.stderr.write('ERROR: For {0}, could not determine the project path for project {1}\n'.format(item['id'], item['project']))
366 sys.exit(1)
367
368 # If --start-branch is given, create the branch (more than once per path is okay; repo ignores gracefully)
369 if args.start_branch:
370 subprocess.check_output(['repo', 'start', args.start_branch[0], project_path])
371
372 # Determine the maximum commits to check already picked changes
Adrian DC80b7d0b2017-09-07 22:59:54 +0200373 check_picked_count = args.check_picked
mikeNGbdc1fde2018-05-17 23:17:12 -0400374 branch_commits_count = int(subprocess.check_output(['git', 'rev-list', '--count', 'HEAD'], cwd=project_path))
375 if branch_commits_count <= check_picked_count:
376 check_picked_count = branch_commits_count - 1
377
378 # Check if change is already picked to HEAD...HEAD~check_picked_count
379 found_change = False
380 for i in range(0, check_picked_count):
Adrian DCb2b1c3b2016-12-04 12:30:26 +0100381 if subprocess.call(['git', 'cat-file', '-e', 'HEAD~{0}'.format(i)], cwd=project_path, stderr=open(os.devnull, 'wb')):
382 continue
mikeNGbdc1fde2018-05-17 23:17:12 -0400383 output = subprocess.check_output(['git', 'show', '-q', 'HEAD~{0}'.format(i)], cwd=project_path).split()
384 if 'Change-Id:' in output:
385 head_change_id = ''
386 for j,t in enumerate(reversed(output)):
387 if t == 'Change-Id:':
388 head_change_id = output[len(output) - j]
389 break
390 if head_change_id.strip() == item['change_id']:
391 print('Skipping {0} - already picked in {1} as HEAD~{2}'.format(item['id'], project_path, i))
392 found_change = True
393 break
394 if found_change:
395 continue
396
397 # Print out some useful info
398 if not args.quiet:
Dan Pasanen138eef22017-03-16 15:25:29 -0500399 print('--> Subject: "{0}"'.format(item['subject'].encode('utf-8')))
mikeNGbdc1fde2018-05-17 23:17:12 -0400400 print('--> Project path: {0}'.format(project_path))
Gabriele M283396e2018-04-01 17:50:57 +0200401 print('--> Change number: {0} (Patch Set {1})'.format(item['id'], item['patchset']))
mikeNGbdc1fde2018-05-17 23:17:12 -0400402
403 if 'anonymous http' in item['fetch']:
404 method = 'anonymous http'
405 else:
406 method = 'ssh'
407
408 # Try fetching from GitHub first if using default gerrit
409 if args.gerrit == default_gerrit:
410 if args.verbose:
411 print('Trying to fetch the change from GitHub')
412
413 if args.pull:
414 cmd = ['git pull --no-edit github', item['fetch'][method]['ref']]
415 else:
416 cmd = ['git fetch github', item['fetch'][method]['ref']]
417 if args.quiet:
418 cmd.append('--quiet')
419 else:
420 print(cmd)
421 result = subprocess.call([' '.join(cmd)], cwd=project_path, shell=True)
422 FETCH_HEAD = '{0}/.git/FETCH_HEAD'.format(project_path)
423 if result != 0 and os.stat(FETCH_HEAD).st_size != 0:
424 print('ERROR: git command failed')
425 sys.exit(result)
426 # Check if it worked
427 if args.gerrit != default_gerrit or os.stat(FETCH_HEAD).st_size == 0:
428 # If not using the default gerrit or github failed, fetch from gerrit.
429 if args.verbose:
430 if args.gerrit == default_gerrit:
431 print('Fetching from GitHub didn\'t work, trying to fetch the change from Gerrit')
432 else:
433 print('Fetching from {0}'.format(args.gerrit))
434
435 if args.pull:
436 cmd = ['git pull --no-edit', item['fetch'][method]['url'], item['fetch'][method]['ref']]
437 else:
438 cmd = ['git fetch', item['fetch'][method]['url'], item['fetch'][method]['ref']]
439 if args.quiet:
440 cmd.append('--quiet')
441 else:
442 print(cmd)
443 result = subprocess.call([' '.join(cmd)], cwd=project_path, shell=True)
444 if result != 0:
445 print('ERROR: git command failed')
446 sys.exit(result)
447 # Perform the cherry-pick
448 if not args.pull:
449 cmd = ['git cherry-pick FETCH_HEAD']
450 if args.quiet:
451 cmd_out = open(os.devnull, 'wb')
452 else:
453 cmd_out = None
454 result = subprocess.call(cmd, cwd=project_path, shell=True, stdout=cmd_out, stderr=cmd_out)
455 if result != 0:
Harry Youd56d2f6f2017-07-18 18:52:42 +0100456 if args.reset:
457 print('ERROR: git command failed, aborting cherry-pick')
458 cmd = ['git cherry-pick --abort']
459 subprocess.call(cmd, cwd=project_path, shell=True, stdout=cmd_out, stderr=cmd_out)
460 else:
461 print('ERROR: git command failed')
mikeNGbdc1fde2018-05-17 23:17:12 -0400462 sys.exit(result)
463 if not args.quiet:
464 print('')