blob: f99c017f74d5a68a8d91581107367bed419a7c3c [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')
Akhil Narangb11ede32018-03-06 16:23:58 +0530245 if path is None:
246 path=name
mikeNGbdc1fde2018-05-17 23:17:12 -0400247 revision = project.get('revision')
248 if revision is None:
249 for remote in remotes:
250 if remote.get('name') == project.get('remote'):
251 revision = remote.get('revision')
252 if revision is None:
Simon Shields26c964c2016-09-26 17:52:03 +1000253 revision = default_revision
mikeNGbdc1fde2018-05-17 23:17:12 -0400254
255 if not name in project_name_to_data:
256 project_name_to_data[name] = {}
257 revision = revision.split('refs/heads/')[-1]
258 project_name_to_data[name][revision] = path
259
260 # get data on requested changes
261 reviews = []
262 change_numbers = []
Gabriele M603ca2c2018-03-31 14:26:59 +0200263
264 def cmp_reviews(review_a, review_b):
265 current_a = review_a['current_revision']
266 parents_a = [r['commit'] for r in review_a['revisions'][current_a]['commit']['parents']]
267 current_b = review_b['current_revision']
268 parents_b = [r['commit'] for r in review_b['revisions'][current_b]['commit']['parents']]
269 if current_a in parents_b:
270 return -1
271 elif current_b in parents_a:
272 return 1
273 else:
274 return cmp(review_a['number'], review_b['number'])
275
mikeNGbdc1fde2018-05-17 23:17:12 -0400276 if args.topic:
Akhil Narang7b54fd62018-10-02 13:29:15 +0530277 reviews = fetch_query(args.gerrit, 'status:open+topic:{0}'.format(args.topic))
Gabriele M603ca2c2018-03-31 14:26:59 +0200278 change_numbers = [str(r['number']) for r in sorted(reviews, key=cmp_to_key(cmp_reviews))]
mikeNGbdc1fde2018-05-17 23:17:12 -0400279 if args.query:
280 reviews = fetch_query(args.gerrit, args.query)
Gabriele M603ca2c2018-03-31 14:26:59 +0200281 change_numbers = [str(r['number']) for r in sorted(reviews, key=cmp_to_key(cmp_reviews))]
mikeNGbdc1fde2018-05-17 23:17:12 -0400282 if args.change_number:
Gabriele Me0b086a2018-04-01 17:50:58 +0200283 change_url_re = re.compile('https?://.+?/([0-9]+(?:/[0-9]+)?)/?')
mikeNGbdc1fde2018-05-17 23:17:12 -0400284 for c in args.change_number:
Gabriele Me0b086a2018-04-01 17:50:58 +0200285 change_number = change_url_re.findall(c)
286 if change_number:
287 change_numbers.extend(change_number)
288 elif '-' in c:
mikeNGbdc1fde2018-05-17 23:17:12 -0400289 templist = c.split('-')
290 for i in range(int(templist[0]), int(templist[1]) + 1):
291 change_numbers.append(str(i))
292 else:
293 change_numbers.append(c)
294 reviews = fetch_query(args.gerrit, ' OR '.join('change:{0}'.format(x.split('/')[0]) for x in change_numbers))
295
296 # make list of things to actually merge
297 mergables = []
298
299 # If --exclude is given, create the list of commits to ignore
300 exclude = []
301 if args.exclude:
302 exclude = args.exclude[0].split(',')
303
304 for change in change_numbers:
305 patchset = None
306 if '/' in change:
307 (change, patchset) = change.split('/')
308
309 if change in exclude:
310 continue
311
312 change = int(change)
LuK1337646b9bf2017-03-24 23:25:13 +0100313
Gabriele M7909ac62018-04-01 17:50:55 +0200314 if patchset:
LuK1337646b9bf2017-03-24 23:25:13 +0100315 patchset = int(patchset)
316
mikeNGbdc1fde2018-05-17 23:17:12 -0400317 review = next((x for x in reviews if x['number'] == change), None)
318 if review is None:
319 print('Change %d not found, skipping' % change)
320 continue
321
322 mergables.append({
323 'subject': review['subject'],
324 'project': review['project'],
325 'branch': review['branch'],
326 'change_id': review['change_id'],
327 'change_number': review['number'],
328 'status': review['status'],
Gabriele M283396e2018-04-01 17:50:57 +0200329 'fetch': None,
330 'patchset': review['revisions'][review['current_revision']]['_number'],
mikeNGbdc1fde2018-05-17 23:17:12 -0400331 })
Gabriele M283396e2018-04-01 17:50:57 +0200332
mikeNGbdc1fde2018-05-17 23:17:12 -0400333 mergables[-1]['fetch'] = review['revisions'][review['current_revision']]['fetch']
334 mergables[-1]['id'] = change
335 if patchset:
336 try:
LuK1337dbeb6322017-03-24 20:00:48 +0100337 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 -0400338 mergables[-1]['id'] = '{0}/{1}'.format(change, patchset)
Gabriele M283396e2018-04-01 17:50:57 +0200339 mergables[-1]['patchset'] = patchset
mikeNGbdc1fde2018-05-17 23:17:12 -0400340 except (IndexError, ValueError):
341 args.quiet or print('ERROR: The patch set {0}/{1} could not be found, using CURRENT_REVISION instead.'.format(change, patchset))
342
343 for item in mergables:
344 args.quiet or print('Applying change number {0}...'.format(item['id']))
345 # Check if change is open and exit if it's not, unless -f is specified
Dan Pasanenb2a0f6f2017-07-09 09:41:33 -0500346 if (item['status'] != 'OPEN' and item['status'] != 'NEW' and item['status'] != 'DRAFT') and not args.query:
mikeNGbdc1fde2018-05-17 23:17:12 -0400347 if args.force:
348 print('!! Force-picking a closed change !!\n')
349 else:
350 print('Change status is ' + item['status'] + '. Skipping the cherry pick.\nUse -f to force this pick.')
351 continue
352
353 # Convert the project name to a project path
354 # - check that the project path exists
355 project_path = None
356
357 if item['project'] in project_name_to_data and item['branch'] in project_name_to_data[item['project']]:
358 project_path = project_name_to_data[item['project']][item['branch']]
359 elif args.path:
360 project_path = args.path
361 elif args.ignore_missing:
362 print('WARNING: Skipping {0} since there is no project directory for: {1}\n'.format(item['id'], item['project']))
363 continue
Akhil Narang13bd28a2017-10-31 11:38:29 +0530364 elif item['project'] == 'platform_manifest':
365 project_path = '.repo/manifests'
mikeNGbdc1fde2018-05-17 23:17:12 -0400366 else:
367 sys.stderr.write('ERROR: For {0}, could not determine the project path for project {1}\n'.format(item['id'], item['project']))
368 sys.exit(1)
369
370 # If --start-branch is given, create the branch (more than once per path is okay; repo ignores gracefully)
371 if args.start_branch:
372 subprocess.check_output(['repo', 'start', args.start_branch[0], project_path])
373
374 # Determine the maximum commits to check already picked changes
Adrian DC80b7d0b2017-09-07 22:59:54 +0200375 check_picked_count = args.check_picked
mikeNGbdc1fde2018-05-17 23:17:12 -0400376 branch_commits_count = int(subprocess.check_output(['git', 'rev-list', '--count', 'HEAD'], cwd=project_path))
377 if branch_commits_count <= check_picked_count:
378 check_picked_count = branch_commits_count - 1
379
380 # Check if change is already picked to HEAD...HEAD~check_picked_count
381 found_change = False
382 for i in range(0, check_picked_count):
Adrian DCb2b1c3b2016-12-04 12:30:26 +0100383 if subprocess.call(['git', 'cat-file', '-e', 'HEAD~{0}'.format(i)], cwd=project_path, stderr=open(os.devnull, 'wb')):
384 continue
mikeNGbdc1fde2018-05-17 23:17:12 -0400385 output = subprocess.check_output(['git', 'show', '-q', 'HEAD~{0}'.format(i)], cwd=project_path).split()
386 if 'Change-Id:' in output:
387 head_change_id = ''
388 for j,t in enumerate(reversed(output)):
389 if t == 'Change-Id:':
390 head_change_id = output[len(output) - j]
391 break
392 if head_change_id.strip() == item['change_id']:
393 print('Skipping {0} - already picked in {1} as HEAD~{2}'.format(item['id'], project_path, i))
394 found_change = True
395 break
396 if found_change:
397 continue
398
399 # Print out some useful info
400 if not args.quiet:
Dan Pasanen138eef22017-03-16 15:25:29 -0500401 print('--> Subject: "{0}"'.format(item['subject'].encode('utf-8')))
mikeNGbdc1fde2018-05-17 23:17:12 -0400402 print('--> Project path: {0}'.format(project_path))
Gabriele M283396e2018-04-01 17:50:57 +0200403 print('--> Change number: {0} (Patch Set {1})'.format(item['id'], item['patchset']))
mikeNGbdc1fde2018-05-17 23:17:12 -0400404
405 if 'anonymous http' in item['fetch']:
406 method = 'anonymous http'
407 else:
408 method = 'ssh'
409
410 # Try fetching from GitHub first if using default gerrit
411 if args.gerrit == default_gerrit:
412 if args.verbose:
413 print('Trying to fetch the change from GitHub')
414
415 if args.pull:
416 cmd = ['git pull --no-edit github', item['fetch'][method]['ref']]
417 else:
418 cmd = ['git fetch github', item['fetch'][method]['ref']]
419 if args.quiet:
420 cmd.append('--quiet')
421 else:
422 print(cmd)
423 result = subprocess.call([' '.join(cmd)], cwd=project_path, shell=True)
424 FETCH_HEAD = '{0}/.git/FETCH_HEAD'.format(project_path)
425 if result != 0 and os.stat(FETCH_HEAD).st_size != 0:
426 print('ERROR: git command failed')
427 sys.exit(result)
428 # Check if it worked
429 if args.gerrit != default_gerrit or os.stat(FETCH_HEAD).st_size == 0:
430 # If not using the default gerrit or github failed, fetch from gerrit.
431 if args.verbose:
432 if args.gerrit == default_gerrit:
433 print('Fetching from GitHub didn\'t work, trying to fetch the change from Gerrit')
434 else:
435 print('Fetching from {0}'.format(args.gerrit))
436
437 if args.pull:
438 cmd = ['git pull --no-edit', item['fetch'][method]['url'], item['fetch'][method]['ref']]
439 else:
440 cmd = ['git fetch', item['fetch'][method]['url'], item['fetch'][method]['ref']]
441 if args.quiet:
442 cmd.append('--quiet')
443 else:
444 print(cmd)
445 result = subprocess.call([' '.join(cmd)], cwd=project_path, shell=True)
446 if result != 0:
447 print('ERROR: git command failed')
448 sys.exit(result)
449 # Perform the cherry-pick
450 if not args.pull:
451 cmd = ['git cherry-pick FETCH_HEAD']
452 if args.quiet:
453 cmd_out = open(os.devnull, 'wb')
454 else:
455 cmd_out = None
456 result = subprocess.call(cmd, cwd=project_path, shell=True, stdout=cmd_out, stderr=cmd_out)
457 if result != 0:
Harry Youd56d2f6f2017-07-18 18:52:42 +0100458 if args.reset:
459 print('ERROR: git command failed, aborting cherry-pick')
460 cmd = ['git cherry-pick --abort']
461 subprocess.call(cmd, cwd=project_path, shell=True, stdout=cmd_out, stderr=cmd_out)
462 else:
463 print('ERROR: git command failed')
mikeNGbdc1fde2018-05-17 23:17:12 -0400464 sys.exit(result)
465 if not args.quiet:
466 print('')