blob: 3c7c06ca5b4fe9d5912ba98dc7c883c8786248f4 [file] [log] [blame]
Michael Bestas3952f6c2016-08-26 01:12:08 +03001#!/usr/bin/env python
2#
3# Copyright (C) 2013-15 The CyanogenMod Project
Dan Pasanen0fdc0852016-12-27 10:32:16 -06004# (C) 2017 The LineageOS Project
Michael Bestas3952f6c2016-08-26 01:12:08 +03005#
6# Licensed under the Apache License, Version 2.0 (the "License");
7# you may not use this file except in compliance with the License.
8# You may obtain a copy of the License at
9#
10# http://www.apache.org/licenses/LICENSE-2.0
11#
12# Unless required by applicable law or agreed to in writing, software
13# distributed under the License is distributed on an "AS IS" BASIS,
14# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15# See the License for the specific language governing permissions and
16# limitations under the License.
17#
18
19#
20# Run repopick.py -h for a description of this utility.
21#
22
23from __future__ import print_function
24
25import sys
26import json
27import os
28import subprocess
29import re
30import argparse
31import textwrap
Gabriele M5b610ae2018-03-31 14:26:59 +020032from functools import cmp_to_key
Michael Bestas3952f6c2016-08-26 01:12:08 +030033from xml.etree import ElementTree
34
35try:
Dan Pasanen1cdd3802017-01-23 15:08:52 -060036 import requests
Michael Bestas3952f6c2016-08-26 01:12:08 +030037except ImportError:
Dan Pasanen1cdd3802017-01-23 15:08:52 -060038 try:
39 # For python3
40 import urllib.error
41 import urllib.request
42 except ImportError:
43 # For python2
44 import imp
45 import urllib2
46 urllib = imp.new_module('urllib')
47 urllib.error = urllib2
48 urllib.request = urllib2
Michael Bestas3952f6c2016-08-26 01:12:08 +030049
50
51# Verifies whether pathA is a subdirectory (or the same) as pathB
52def is_subdir(a, b):
53 a = os.path.realpath(a) + '/'
54 b = os.path.realpath(b) + '/'
55 return b == a[:len(b)]
56
57
58def fetch_query_via_ssh(remote_url, query):
59 """Given a remote_url and a query, return the list of changes that fit it
60 This function is slightly messy - the ssh api does not return data in the same structure as the HTTP REST API
61 We have to get the data, then transform it to match what we're expecting from the HTTP RESET API"""
62 if remote_url.count(':') == 2:
63 (uri, userhost, port) = remote_url.split(':')
64 userhost = userhost[2:]
65 elif remote_url.count(':') == 1:
66 (uri, userhost) = remote_url.split(':')
67 userhost = userhost[2:]
68 port = 29418
69 else:
70 raise Exception('Malformed URI: Expecting ssh://[user@]host[:port]')
71
72
73 out = subprocess.check_output(['ssh', '-x', '-p{0}'.format(port), userhost, 'gerrit', 'query', '--format=JSON --patch-sets --current-patch-set', query])
74 if not hasattr(out, 'encode'):
75 out = out.decode()
76 reviews = []
77 for line in out.split('\n'):
78 try:
79 data = json.loads(line)
80 # make our data look like the http rest api data
81 review = {
82 'branch': data['branch'],
83 'change_id': data['id'],
84 'current_revision': data['currentPatchSet']['revision'],
85 'number': int(data['number']),
86 'revisions': {patch_set['revision']: {
Gabriele M0fcc1222018-04-10 18:35:12 +020087 '_number': int(patch_set['number']),
Michael Bestas3952f6c2016-08-26 01:12:08 +030088 'fetch': {
89 'ssh': {
90 'ref': patch_set['ref'],
91 'url': 'ssh://{0}:{1}/{2}'.format(userhost, port, data['project'])
92 }
Gabriele M0fcc1222018-04-10 18:35:12 +020093 },
94 'commit': {
95 'parents': [{ 'commit': parent } for parent in patch_set['parents']]
96 },
Michael Bestas3952f6c2016-08-26 01:12:08 +030097 } for patch_set in data['patchSets']},
98 'subject': data['subject'],
99 'project': data['project'],
100 'status': data['status']
101 }
102 reviews.append(review)
103 except:
104 pass
105 args.quiet or print('Found {0} reviews'.format(len(reviews)))
106 return reviews
107
108
109def fetch_query_via_http(remote_url, query):
Dan Pasanen1cdd3802017-01-23 15:08:52 -0600110 if "requests" in sys.modules:
111 auth = None
112 if os.path.isfile(os.getenv("HOME") + "/.gerritrc"):
113 f = open(os.getenv("HOME") + "/.gerritrc", "r")
114 for line in f:
115 parts = line.rstrip().split("|")
116 if parts[0] in remote_url:
117 auth = requests.auth.HTTPBasicAuth(username=parts[1], password=parts[2])
118 statusCode = '-1'
119 if auth:
Gabriele M5b610ae2018-03-31 14:26:59 +0200120 url = '{0}/a/changes/?q={1}&o=CURRENT_REVISION&o=ALL_REVISIONS&o=ALL_COMMITS'.format(remote_url, query)
Dan Pasanen1cdd3802017-01-23 15:08:52 -0600121 data = requests.get(url, auth=auth)
122 statusCode = str(data.status_code)
123 if statusCode != '200':
124 #They didn't get good authorization or data, Let's try the old way
Gabriele M5b610ae2018-03-31 14:26:59 +0200125 url = '{0}/changes/?q={1}&o=CURRENT_REVISION&o=ALL_REVISIONS&o=ALL_COMMITS'.format(remote_url, query)
Dan Pasanen1cdd3802017-01-23 15:08:52 -0600126 data = requests.get(url)
127 reviews = json.loads(data.text[5:])
128 else:
129 """Given a query, fetch the change numbers via http"""
Gabriele M5b610ae2018-03-31 14:26:59 +0200130 url = '{0}/changes/?q={1}&o=CURRENT_REVISION&o=ALL_REVISIONS&o=ALL_COMMITS'.format(remote_url, query)
Dan Pasanen1cdd3802017-01-23 15:08:52 -0600131 data = urllib.request.urlopen(url).read().decode('utf-8')
132 reviews = json.loads(data[5:])
Michael Bestas3952f6c2016-08-26 01:12:08 +0300133
134 for review in reviews:
135 review['number'] = review.pop('_number')
136
137 return reviews
138
139
140def fetch_query(remote_url, query):
141 """Wrapper for fetch_query_via_proto functions"""
142 if remote_url[0:3] == 'ssh':
143 return fetch_query_via_ssh(remote_url, query)
144 elif remote_url[0:4] == 'http':
145 return fetch_query_via_http(remote_url, query.replace(' ', '+'))
146 else:
147 raise Exception('Gerrit URL should be in the form http[s]://hostname/ or ssh://[user@]host[:port]')
148
149if __name__ == '__main__':
Dan Pasanen0fdc0852016-12-27 10:32:16 -0600150 # Default to LineageOS Gerrit
Dan Pasanen29653422017-12-12 15:07:09 -0600151 default_gerrit = 'https://review.lineageos.org'
Michael Bestas3952f6c2016-08-26 01:12:08 +0300152
153 parser = argparse.ArgumentParser(formatter_class=argparse.RawDescriptionHelpFormatter, description=textwrap.dedent('''\
154 repopick.py is a utility to simplify the process of cherry picking
Dan Pasanen0fdc0852016-12-27 10:32:16 -0600155 patches from LineageOS's Gerrit instance (or any gerrit instance of your choosing)
Michael Bestas3952f6c2016-08-26 01:12:08 +0300156
157 Given a list of change numbers, repopick will cd into the project path
158 and cherry pick the latest patch available.
159
160 With the --start-branch argument, the user can specify that a branch
161 should be created before cherry picking. This is useful for
162 cherry-picking many patches into a common branch which can be easily
163 abandoned later (good for testing other's changes.)
164
165 The --abandon-first argument, when used in conjunction with the
166 --start-branch option, will cause repopick to abandon the specified
167 branch in all repos first before performing any cherry picks.'''))
168 parser.add_argument('change_number', nargs='*', help='change number to cherry pick. Use {change number}/{patchset number} to get a specific revision.')
169 parser.add_argument('-i', '--ignore-missing', action='store_true', help='do not error out if a patch applies to a missing directory')
170 parser.add_argument('-s', '--start-branch', nargs=1, help='start the specified branch before cherry picking')
Harry Youd1c9c5a32017-07-18 18:52:42 +0100171 parser.add_argument('-r', '--reset', action='store_true', help='reset to initial state (abort cherry-pick) if there is a conflict')
Michael Bestas3952f6c2016-08-26 01:12:08 +0300172 parser.add_argument('-a', '--abandon-first', action='store_true', help='before cherry picking, abandon the branch specified in --start-branch')
173 parser.add_argument('-b', '--auto-branch', action='store_true', help='shortcut to "--start-branch auto --abandon-first --ignore-missing"')
174 parser.add_argument('-q', '--quiet', action='store_true', help='print as little as possible')
175 parser.add_argument('-v', '--verbose', action='store_true', help='print extra information to aid in debug')
176 parser.add_argument('-f', '--force', action='store_true', help='force cherry pick even if change is closed')
177 parser.add_argument('-p', '--pull', action='store_true', help='execute pull instead of cherry-pick')
178 parser.add_argument('-P', '--path', help='use the specified path for the change')
179 parser.add_argument('-t', '--topic', help='pick all commits from a specified topic')
180 parser.add_argument('-Q', '--query', help='pick all commits using the specified query')
181 parser.add_argument('-g', '--gerrit', default=default_gerrit, help='Gerrit Instance to use. Form proto://[user@]host[:port]')
182 parser.add_argument('-e', '--exclude', nargs=1, help='exclude a list of commit numbers separated by a ,')
Adrian DCdf290222017-09-07 22:59:54 +0200183 parser.add_argument('-c', '--check-picked', type=int, default=10, help='pass the amount of commits to check for already picked changes')
Michael Bestas3952f6c2016-08-26 01:12:08 +0300184 args = parser.parse_args()
185 if not args.start_branch and args.abandon_first:
186 parser.error('if --abandon-first is set, you must also give the branch name with --start-branch')
187 if args.auto_branch:
188 args.abandon_first = True
189 args.ignore_missing = True
190 if not args.start_branch:
191 args.start_branch = ['auto']
192 if args.quiet and args.verbose:
193 parser.error('--quiet and --verbose cannot be specified together')
194
195 if (1 << bool(args.change_number) << bool(args.topic) << bool(args.query)) != 2:
196 parser.error('One (and only one) of change_number, topic, and query are allowed')
197
198 # Change current directory to the top of the tree
199 if 'ANDROID_BUILD_TOP' in os.environ:
200 top = os.environ['ANDROID_BUILD_TOP']
201
202 if not is_subdir(os.getcwd(), top):
203 sys.stderr.write('ERROR: You must run this tool from within $ANDROID_BUILD_TOP!\n')
204 sys.exit(1)
205 os.chdir(os.environ['ANDROID_BUILD_TOP'])
206
207 # Sanity check that we are being run from the top level of the tree
208 if not os.path.isdir('.repo'):
209 sys.stderr.write('ERROR: No .repo directory found. Please run this from the top of your tree.\n')
210 sys.exit(1)
211
212 # If --abandon-first is given, abandon the branch before starting
213 if args.abandon_first:
214 # Determine if the branch already exists; skip the abandon if it does not
215 plist = subprocess.check_output(['repo', 'info'])
216 if not hasattr(plist, 'encode'):
217 plist = plist.decode()
218 needs_abandon = False
219 for pline in plist.splitlines():
220 matchObj = re.match(r'Local Branches.*\[(.*)\]', pline)
221 if matchObj:
222 local_branches = re.split('\s*,\s*', matchObj.group(1))
223 if any(args.start_branch[0] in s for s in local_branches):
224 needs_abandon = True
225
226 if needs_abandon:
227 # Perform the abandon only if the branch already exists
228 if not args.quiet:
229 print('Abandoning branch: %s' % args.start_branch[0])
230 subprocess.check_output(['repo', 'abandon', args.start_branch[0]])
231 if not args.quiet:
232 print('')
233
234 # Get the master manifest from repo
235 # - convert project name and revision to a path
236 project_name_to_data = {}
237 manifest = subprocess.check_output(['repo', 'manifest'])
238 xml_root = ElementTree.fromstring(manifest)
239 projects = xml_root.findall('project')
240 remotes = xml_root.findall('remote')
241 default_revision = xml_root.findall('default')[0].get('revision')
242
243 #dump project data into the a list of dicts with the following data:
244 #{project: {path, revision}}
245
246 for project in projects:
247 name = project.get('name')
248 path = project.get('path')
249 revision = project.get('revision')
250 if revision is None:
251 for remote in remotes:
252 if remote.get('name') == project.get('remote'):
253 revision = remote.get('revision')
254 if revision is None:
Simon Shields2bdb18f2016-09-26 17:52:03 +1000255 revision = default_revision
Michael Bestas3952f6c2016-08-26 01:12:08 +0300256
257 if not name in project_name_to_data:
258 project_name_to_data[name] = {}
259 revision = revision.split('refs/heads/')[-1]
260 project_name_to_data[name][revision] = path
261
262 # get data on requested changes
263 reviews = []
264 change_numbers = []
Gabriele M5b610ae2018-03-31 14:26:59 +0200265
266 def cmp_reviews(review_a, review_b):
267 current_a = review_a['current_revision']
268 parents_a = [r['commit'] for r in review_a['revisions'][current_a]['commit']['parents']]
269 current_b = review_b['current_revision']
270 parents_b = [r['commit'] for r in review_b['revisions'][current_b]['commit']['parents']]
271 if current_a in parents_b:
272 return -1
273 elif current_b in parents_a:
274 return 1
275 else:
276 return cmp(review_a['number'], review_b['number'])
277
Michael Bestas3952f6c2016-08-26 01:12:08 +0300278 if args.topic:
279 reviews = fetch_query(args.gerrit, 'topic:{0}'.format(args.topic))
Gabriele M5b610ae2018-03-31 14:26:59 +0200280 change_numbers = [str(r['number']) for r in sorted(reviews, key=cmp_to_key(cmp_reviews))]
Michael Bestas3952f6c2016-08-26 01:12:08 +0300281 if args.query:
282 reviews = fetch_query(args.gerrit, args.query)
Gabriele M5b610ae2018-03-31 14:26:59 +0200283 change_numbers = [str(r['number']) for r in sorted(reviews, key=cmp_to_key(cmp_reviews))]
Michael Bestas3952f6c2016-08-26 01:12:08 +0300284 if args.change_number:
Gabriele Maf970b62018-04-01 17:50:58 +0200285 change_url_re = re.compile('https?://.+?/([0-9]+(?:/[0-9]+)?)/?')
Michael Bestas3952f6c2016-08-26 01:12:08 +0300286 for c in args.change_number:
Gabriele Maf970b62018-04-01 17:50:58 +0200287 change_number = change_url_re.findall(c)
288 if change_number:
289 change_numbers.extend(change_number)
290 elif '-' in c:
Michael Bestas3952f6c2016-08-26 01:12:08 +0300291 templist = c.split('-')
292 for i in range(int(templist[0]), int(templist[1]) + 1):
293 change_numbers.append(str(i))
294 else:
295 change_numbers.append(c)
296 reviews = fetch_query(args.gerrit, ' OR '.join('change:{0}'.format(x.split('/')[0]) for x in change_numbers))
297
298 # make list of things to actually merge
299 mergables = []
300
301 # If --exclude is given, create the list of commits to ignore
302 exclude = []
303 if args.exclude:
304 exclude = args.exclude[0].split(',')
305
306 for change in change_numbers:
307 patchset = None
308 if '/' in change:
309 (change, patchset) = change.split('/')
310
311 if change in exclude:
312 continue
313
314 change = int(change)
LuK1337ad5d9a02017-03-24 23:25:13 +0100315
Gabriele Mde9e0ae2018-04-01 17:50:55 +0200316 if patchset:
LuK1337ad5d9a02017-03-24 23:25:13 +0100317 patchset = int(patchset)
318
Michael Bestas3952f6c2016-08-26 01:12:08 +0300319 review = next((x for x in reviews if x['number'] == change), None)
320 if review is None:
321 print('Change %d not found, skipping' % change)
322 continue
323
324 mergables.append({
325 'subject': review['subject'],
326 'project': review['project'],
327 'branch': review['branch'],
328 'change_id': review['change_id'],
329 'change_number': review['number'],
330 'status': review['status'],
Gabriele M88c0e5d2018-04-01 17:50:57 +0200331 'fetch': None,
332 'patchset': review['revisions'][review['current_revision']]['_number'],
Michael Bestas3952f6c2016-08-26 01:12:08 +0300333 })
Gabriele M88c0e5d2018-04-01 17:50:57 +0200334
Michael Bestas3952f6c2016-08-26 01:12:08 +0300335 mergables[-1]['fetch'] = review['revisions'][review['current_revision']]['fetch']
336 mergables[-1]['id'] = change
337 if patchset:
338 try:
LuK133727564182017-03-24 20:00:48 +0100339 mergables[-1]['fetch'] = [review['revisions'][x]['fetch'] for x in review['revisions'] if review['revisions'][x]['_number'] == patchset][0]
Michael Bestas3952f6c2016-08-26 01:12:08 +0300340 mergables[-1]['id'] = '{0}/{1}'.format(change, patchset)
Gabriele M88c0e5d2018-04-01 17:50:57 +0200341 mergables[-1]['patchset'] = patchset
Michael Bestas3952f6c2016-08-26 01:12:08 +0300342 except (IndexError, ValueError):
343 args.quiet or print('ERROR: The patch set {0}/{1} could not be found, using CURRENT_REVISION instead.'.format(change, patchset))
344
345 for item in mergables:
346 args.quiet or print('Applying change number {0}...'.format(item['id']))
347 # Check if change is open and exit if it's not, unless -f is specified
Dan Pasanen63f767e2017-07-09 09:41:33 -0500348 if (item['status'] != 'OPEN' and item['status'] != 'NEW' and item['status'] != 'DRAFT') and not args.query:
Michael Bestas3952f6c2016-08-26 01:12:08 +0300349 if args.force:
350 print('!! Force-picking a closed change !!\n')
351 else:
352 print('Change status is ' + item['status'] + '. Skipping the cherry pick.\nUse -f to force this pick.')
353 continue
354
355 # Convert the project name to a project path
356 # - check that the project path exists
357 project_path = None
358
359 if item['project'] in project_name_to_data and item['branch'] in project_name_to_data[item['project']]:
360 project_path = project_name_to_data[item['project']][item['branch']]
361 elif args.path:
362 project_path = args.path
363 elif args.ignore_missing:
364 print('WARNING: Skipping {0} since there is no project directory for: {1}\n'.format(item['id'], item['project']))
365 continue
366 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 DCdf290222017-09-07 22:59:54 +0200375 check_picked_count = args.check_picked
Michael Bestas3952f6c2016-08-26 01:12:08 +0300376 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 DC13b02ff2016-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
Michael Bestas3952f6c2016-08-26 01:12:08 +0300385 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 Pasanend4ee2f62017-03-16 15:25:29 -0500401 print('--> Subject: "{0}"'.format(item['subject'].encode('utf-8')))
Michael Bestas3952f6c2016-08-26 01:12:08 +0300402 print('--> Project path: {0}'.format(project_path))
Gabriele M88c0e5d2018-04-01 17:50:57 +0200403 print('--> Change number: {0} (Patch Set {1})'.format(item['id'], item['patchset']))
Michael Bestas3952f6c2016-08-26 01:12:08 +0300404
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:
Adrian DC0f8230b2018-08-30 23:07:23 +0200458 cmd = ['git diff-index --quiet HEAD --']
459 result = subprocess.call(cmd, cwd=project_path, shell=True, stdout=cmd_out, stderr=cmd_out)
460 if result == 0:
461 print('WARNING: git command resulted with an empty commit, aborting cherry-pick')
462 cmd = ['git cherry-pick --abort']
463 subprocess.call(cmd, cwd=project_path, shell=True, stdout=cmd_out, stderr=cmd_out)
464 elif args.reset:
Harry Youd1c9c5a32017-07-18 18:52:42 +0100465 print('ERROR: git command failed, aborting cherry-pick')
466 cmd = ['git cherry-pick --abort']
467 subprocess.call(cmd, cwd=project_path, shell=True, stdout=cmd_out, stderr=cmd_out)
Adrian DC0f8230b2018-08-30 23:07:23 +0200468 sys.exit(result)
Harry Youd1c9c5a32017-07-18 18:52:42 +0100469 else:
470 print('ERROR: git command failed')
Adrian DC0f8230b2018-08-30 23:07:23 +0200471 sys.exit(result)
Michael Bestas3952f6c2016-08-26 01:12:08 +0300472 if not args.quiet:
473 print('')