blob: 50947b2698ceb038f8df670240b364bc81e2a1a8 [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']: {
87 'number': int(patch_set['number']),
88 'fetch': {
89 'ssh': {
90 'ref': patch_set['ref'],
91 'url': 'ssh://{0}:{1}/{2}'.format(userhost, port, data['project'])
92 }
93 }
94 } for patch_set in data['patchSets']},
95 'subject': data['subject'],
96 'project': data['project'],
97 'status': data['status']
98 }
99 reviews.append(review)
100 except:
101 pass
102 args.quiet or print('Found {0} reviews'.format(len(reviews)))
103 return reviews
104
105
106def fetch_query_via_http(remote_url, query):
Dan Pasanen1cdd3802017-01-23 15:08:52 -0600107 if "requests" in sys.modules:
108 auth = None
109 if os.path.isfile(os.getenv("HOME") + "/.gerritrc"):
110 f = open(os.getenv("HOME") + "/.gerritrc", "r")
111 for line in f:
112 parts = line.rstrip().split("|")
113 if parts[0] in remote_url:
114 auth = requests.auth.HTTPBasicAuth(username=parts[1], password=parts[2])
115 statusCode = '-1'
116 if auth:
Gabriele M5b610ae2018-03-31 14:26:59 +0200117 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 -0600118 data = requests.get(url, auth=auth)
119 statusCode = str(data.status_code)
120 if statusCode != '200':
121 #They didn't get good authorization or data, Let's try the old way
Gabriele M5b610ae2018-03-31 14:26:59 +0200122 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 -0600123 data = requests.get(url)
124 reviews = json.loads(data.text[5:])
125 else:
126 """Given a query, fetch the change numbers via http"""
Gabriele M5b610ae2018-03-31 14:26:59 +0200127 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 -0600128 data = urllib.request.urlopen(url).read().decode('utf-8')
129 reviews = json.loads(data[5:])
Michael Bestas3952f6c2016-08-26 01:12:08 +0300130
131 for review in reviews:
132 review['number'] = review.pop('_number')
133
134 return reviews
135
136
137def fetch_query(remote_url, query):
138 """Wrapper for fetch_query_via_proto functions"""
139 if remote_url[0:3] == 'ssh':
140 return fetch_query_via_ssh(remote_url, query)
141 elif remote_url[0:4] == 'http':
142 return fetch_query_via_http(remote_url, query.replace(' ', '+'))
143 else:
144 raise Exception('Gerrit URL should be in the form http[s]://hostname/ or ssh://[user@]host[:port]')
145
146if __name__ == '__main__':
Dan Pasanen0fdc0852016-12-27 10:32:16 -0600147 # Default to LineageOS Gerrit
Dan Pasanen29653422017-12-12 15:07:09 -0600148 default_gerrit = 'https://review.lineageos.org'
Michael Bestas3952f6c2016-08-26 01:12:08 +0300149
150 parser = argparse.ArgumentParser(formatter_class=argparse.RawDescriptionHelpFormatter, description=textwrap.dedent('''\
151 repopick.py is a utility to simplify the process of cherry picking
Dan Pasanen0fdc0852016-12-27 10:32:16 -0600152 patches from LineageOS's Gerrit instance (or any gerrit instance of your choosing)
Michael Bestas3952f6c2016-08-26 01:12:08 +0300153
154 Given a list of change numbers, repopick will cd into the project path
155 and cherry pick the latest patch available.
156
157 With the --start-branch argument, the user can specify that a branch
158 should be created before cherry picking. This is useful for
159 cherry-picking many patches into a common branch which can be easily
160 abandoned later (good for testing other's changes.)
161
162 The --abandon-first argument, when used in conjunction with the
163 --start-branch option, will cause repopick to abandon the specified
164 branch in all repos first before performing any cherry picks.'''))
165 parser.add_argument('change_number', nargs='*', help='change number to cherry pick. Use {change number}/{patchset number} to get a specific revision.')
166 parser.add_argument('-i', '--ignore-missing', action='store_true', help='do not error out if a patch applies to a missing directory')
167 parser.add_argument('-s', '--start-branch', nargs=1, help='start the specified branch before cherry picking')
Harry Youd1c9c5a32017-07-18 18:52:42 +0100168 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 +0300169 parser.add_argument('-a', '--abandon-first', action='store_true', help='before cherry picking, abandon the branch specified in --start-branch')
170 parser.add_argument('-b', '--auto-branch', action='store_true', help='shortcut to "--start-branch auto --abandon-first --ignore-missing"')
171 parser.add_argument('-q', '--quiet', action='store_true', help='print as little as possible')
172 parser.add_argument('-v', '--verbose', action='store_true', help='print extra information to aid in debug')
173 parser.add_argument('-f', '--force', action='store_true', help='force cherry pick even if change is closed')
174 parser.add_argument('-p', '--pull', action='store_true', help='execute pull instead of cherry-pick')
175 parser.add_argument('-P', '--path', help='use the specified path for the change')
176 parser.add_argument('-t', '--topic', help='pick all commits from a specified topic')
177 parser.add_argument('-Q', '--query', help='pick all commits using the specified query')
178 parser.add_argument('-g', '--gerrit', default=default_gerrit, help='Gerrit Instance to use. Form proto://[user@]host[:port]')
179 parser.add_argument('-e', '--exclude', nargs=1, help='exclude a list of commit numbers separated by a ,')
Adrian DCdf290222017-09-07 22:59:54 +0200180 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 +0300181 args = parser.parse_args()
182 if not args.start_branch and args.abandon_first:
183 parser.error('if --abandon-first is set, you must also give the branch name with --start-branch')
184 if args.auto_branch:
185 args.abandon_first = True
186 args.ignore_missing = True
187 if not args.start_branch:
188 args.start_branch = ['auto']
189 if args.quiet and args.verbose:
190 parser.error('--quiet and --verbose cannot be specified together')
191
192 if (1 << bool(args.change_number) << bool(args.topic) << bool(args.query)) != 2:
193 parser.error('One (and only one) of change_number, topic, and query are allowed')
194
195 # Change current directory to the top of the tree
196 if 'ANDROID_BUILD_TOP' in os.environ:
197 top = os.environ['ANDROID_BUILD_TOP']
198
199 if not is_subdir(os.getcwd(), top):
200 sys.stderr.write('ERROR: You must run this tool from within $ANDROID_BUILD_TOP!\n')
201 sys.exit(1)
202 os.chdir(os.environ['ANDROID_BUILD_TOP'])
203
204 # Sanity check that we are being run from the top level of the tree
205 if not os.path.isdir('.repo'):
206 sys.stderr.write('ERROR: No .repo directory found. Please run this from the top of your tree.\n')
207 sys.exit(1)
208
209 # If --abandon-first is given, abandon the branch before starting
210 if args.abandon_first:
211 # Determine if the branch already exists; skip the abandon if it does not
212 plist = subprocess.check_output(['repo', 'info'])
213 if not hasattr(plist, 'encode'):
214 plist = plist.decode()
215 needs_abandon = False
216 for pline in plist.splitlines():
217 matchObj = re.match(r'Local Branches.*\[(.*)\]', pline)
218 if matchObj:
219 local_branches = re.split('\s*,\s*', matchObj.group(1))
220 if any(args.start_branch[0] in s for s in local_branches):
221 needs_abandon = True
222
223 if needs_abandon:
224 # Perform the abandon only if the branch already exists
225 if not args.quiet:
226 print('Abandoning branch: %s' % args.start_branch[0])
227 subprocess.check_output(['repo', 'abandon', args.start_branch[0]])
228 if not args.quiet:
229 print('')
230
231 # Get the master manifest from repo
232 # - convert project name and revision to a path
233 project_name_to_data = {}
234 manifest = subprocess.check_output(['repo', 'manifest'])
235 xml_root = ElementTree.fromstring(manifest)
236 projects = xml_root.findall('project')
237 remotes = xml_root.findall('remote')
238 default_revision = xml_root.findall('default')[0].get('revision')
239
240 #dump project data into the a list of dicts with the following data:
241 #{project: {path, revision}}
242
243 for project in projects:
244 name = project.get('name')
245 path = project.get('path')
246 revision = project.get('revision')
247 if revision is None:
248 for remote in remotes:
249 if remote.get('name') == project.get('remote'):
250 revision = remote.get('revision')
251 if revision is None:
Simon Shields2bdb18f2016-09-26 17:52:03 +1000252 revision = default_revision
Michael Bestas3952f6c2016-08-26 01:12:08 +0300253
254 if not name in project_name_to_data:
255 project_name_to_data[name] = {}
256 revision = revision.split('refs/heads/')[-1]
257 project_name_to_data[name][revision] = path
258
259 # get data on requested changes
260 reviews = []
261 change_numbers = []
Gabriele M5b610ae2018-03-31 14:26:59 +0200262
263 def cmp_reviews(review_a, review_b):
264 current_a = review_a['current_revision']
265 parents_a = [r['commit'] for r in review_a['revisions'][current_a]['commit']['parents']]
266 current_b = review_b['current_revision']
267 parents_b = [r['commit'] for r in review_b['revisions'][current_b]['commit']['parents']]
268 if current_a in parents_b:
269 return -1
270 elif current_b in parents_a:
271 return 1
272 else:
273 return cmp(review_a['number'], review_b['number'])
274
Michael Bestas3952f6c2016-08-26 01:12:08 +0300275 if args.topic:
276 reviews = fetch_query(args.gerrit, 'topic:{0}'.format(args.topic))
Gabriele M5b610ae2018-03-31 14:26:59 +0200277 change_numbers = [str(r['number']) for r in sorted(reviews, key=cmp_to_key(cmp_reviews))]
Michael Bestas3952f6c2016-08-26 01:12:08 +0300278 if args.query:
279 reviews = fetch_query(args.gerrit, args.query)
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.change_number:
Gabriele Maf970b62018-04-01 17:50:58 +0200282 change_url_re = re.compile('https?://.+?/([0-9]+(?:/[0-9]+)?)/?')
Michael Bestas3952f6c2016-08-26 01:12:08 +0300283 for c in args.change_number:
Gabriele Maf970b62018-04-01 17:50:58 +0200284 change_number = change_url_re.findall(c)
285 if change_number:
286 change_numbers.extend(change_number)
287 elif '-' in c:
Michael Bestas3952f6c2016-08-26 01:12:08 +0300288 templist = c.split('-')
289 for i in range(int(templist[0]), int(templist[1]) + 1):
290 change_numbers.append(str(i))
291 else:
292 change_numbers.append(c)
293 reviews = fetch_query(args.gerrit, ' OR '.join('change:{0}'.format(x.split('/')[0]) for x in change_numbers))
294
295 # make list of things to actually merge
296 mergables = []
297
298 # If --exclude is given, create the list of commits to ignore
299 exclude = []
300 if args.exclude:
301 exclude = args.exclude[0].split(',')
302
303 for change in change_numbers:
304 patchset = None
305 if '/' in change:
306 (change, patchset) = change.split('/')
307
308 if change in exclude:
309 continue
310
311 change = int(change)
LuK1337ad5d9a02017-03-24 23:25:13 +0100312
Gabriele Mde9e0ae2018-04-01 17:50:55 +0200313 if patchset:
LuK1337ad5d9a02017-03-24 23:25:13 +0100314 patchset = int(patchset)
315
Michael Bestas3952f6c2016-08-26 01:12:08 +0300316 review = next((x for x in reviews if x['number'] == change), None)
317 if review is None:
318 print('Change %d not found, skipping' % change)
319 continue
320
321 mergables.append({
322 'subject': review['subject'],
323 'project': review['project'],
324 'branch': review['branch'],
325 'change_id': review['change_id'],
326 'change_number': review['number'],
327 'status': review['status'],
Gabriele M88c0e5d2018-04-01 17:50:57 +0200328 'fetch': None,
329 'patchset': review['revisions'][review['current_revision']]['_number'],
Michael Bestas3952f6c2016-08-26 01:12:08 +0300330 })
Gabriele M88c0e5d2018-04-01 17:50:57 +0200331
Michael Bestas3952f6c2016-08-26 01:12:08 +0300332 mergables[-1]['fetch'] = review['revisions'][review['current_revision']]['fetch']
333 mergables[-1]['id'] = change
334 if patchset:
335 try:
LuK133727564182017-03-24 20:00:48 +0100336 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 +0300337 mergables[-1]['id'] = '{0}/{1}'.format(change, patchset)
Gabriele M88c0e5d2018-04-01 17:50:57 +0200338 mergables[-1]['patchset'] = patchset
Michael Bestas3952f6c2016-08-26 01:12:08 +0300339 except (IndexError, ValueError):
340 args.quiet or print('ERROR: The patch set {0}/{1} could not be found, using CURRENT_REVISION instead.'.format(change, patchset))
341
342 for item in mergables:
343 args.quiet or print('Applying change number {0}...'.format(item['id']))
344 # Check if change is open and exit if it's not, unless -f is specified
Dan Pasanen63f767e2017-07-09 09:41:33 -0500345 if (item['status'] != 'OPEN' and item['status'] != 'NEW' and item['status'] != 'DRAFT') and not args.query:
Michael Bestas3952f6c2016-08-26 01:12:08 +0300346 if args.force:
347 print('!! Force-picking a closed change !!\n')
348 else:
349 print('Change status is ' + item['status'] + '. Skipping the cherry pick.\nUse -f to force this pick.')
350 continue
351
352 # Convert the project name to a project path
353 # - check that the project path exists
354 project_path = None
355
356 if item['project'] in project_name_to_data and item['branch'] in project_name_to_data[item['project']]:
357 project_path = project_name_to_data[item['project']][item['branch']]
358 elif args.path:
359 project_path = args.path
360 elif args.ignore_missing:
361 print('WARNING: Skipping {0} since there is no project directory for: {1}\n'.format(item['id'], item['project']))
362 continue
363 else:
364 sys.stderr.write('ERROR: For {0}, could not determine the project path for project {1}\n'.format(item['id'], item['project']))
365 sys.exit(1)
366
367 # If --start-branch is given, create the branch (more than once per path is okay; repo ignores gracefully)
368 if args.start_branch:
369 subprocess.check_output(['repo', 'start', args.start_branch[0], project_path])
370
371 # Determine the maximum commits to check already picked changes
Adrian DCdf290222017-09-07 22:59:54 +0200372 check_picked_count = args.check_picked
Michael Bestas3952f6c2016-08-26 01:12:08 +0300373 branch_commits_count = int(subprocess.check_output(['git', 'rev-list', '--count', 'HEAD'], cwd=project_path))
374 if branch_commits_count <= check_picked_count:
375 check_picked_count = branch_commits_count - 1
376
377 # Check if change is already picked to HEAD...HEAD~check_picked_count
378 found_change = False
379 for i in range(0, check_picked_count):
Adrian DC13b02ff2016-12-04 12:30:26 +0100380 if subprocess.call(['git', 'cat-file', '-e', 'HEAD~{0}'.format(i)], cwd=project_path, stderr=open(os.devnull, 'wb')):
381 continue
Michael Bestas3952f6c2016-08-26 01:12:08 +0300382 output = subprocess.check_output(['git', 'show', '-q', 'HEAD~{0}'.format(i)], cwd=project_path).split()
383 if 'Change-Id:' in output:
384 head_change_id = ''
385 for j,t in enumerate(reversed(output)):
386 if t == 'Change-Id:':
387 head_change_id = output[len(output) - j]
388 break
389 if head_change_id.strip() == item['change_id']:
390 print('Skipping {0} - already picked in {1} as HEAD~{2}'.format(item['id'], project_path, i))
391 found_change = True
392 break
393 if found_change:
394 continue
395
396 # Print out some useful info
397 if not args.quiet:
Dan Pasanend4ee2f62017-03-16 15:25:29 -0500398 print('--> Subject: "{0}"'.format(item['subject'].encode('utf-8')))
Michael Bestas3952f6c2016-08-26 01:12:08 +0300399 print('--> Project path: {0}'.format(project_path))
Gabriele M88c0e5d2018-04-01 17:50:57 +0200400 print('--> Change number: {0} (Patch Set {1})'.format(item['id'], item['patchset']))
Michael Bestas3952f6c2016-08-26 01:12:08 +0300401
402 if 'anonymous http' in item['fetch']:
403 method = 'anonymous http'
404 else:
405 method = 'ssh'
406
407 # Try fetching from GitHub first if using default gerrit
408 if args.gerrit == default_gerrit:
409 if args.verbose:
410 print('Trying to fetch the change from GitHub')
411
412 if args.pull:
413 cmd = ['git pull --no-edit github', item['fetch'][method]['ref']]
414 else:
415 cmd = ['git fetch github', item['fetch'][method]['ref']]
416 if args.quiet:
417 cmd.append('--quiet')
418 else:
419 print(cmd)
420 result = subprocess.call([' '.join(cmd)], cwd=project_path, shell=True)
421 FETCH_HEAD = '{0}/.git/FETCH_HEAD'.format(project_path)
422 if result != 0 and os.stat(FETCH_HEAD).st_size != 0:
423 print('ERROR: git command failed')
424 sys.exit(result)
425 # Check if it worked
426 if args.gerrit != default_gerrit or os.stat(FETCH_HEAD).st_size == 0:
427 # If not using the default gerrit or github failed, fetch from gerrit.
428 if args.verbose:
429 if args.gerrit == default_gerrit:
430 print('Fetching from GitHub didn\'t work, trying to fetch the change from Gerrit')
431 else:
432 print('Fetching from {0}'.format(args.gerrit))
433
434 if args.pull:
435 cmd = ['git pull --no-edit', item['fetch'][method]['url'], item['fetch'][method]['ref']]
436 else:
437 cmd = ['git fetch', item['fetch'][method]['url'], item['fetch'][method]['ref']]
438 if args.quiet:
439 cmd.append('--quiet')
440 else:
441 print(cmd)
442 result = subprocess.call([' '.join(cmd)], cwd=project_path, shell=True)
443 if result != 0:
444 print('ERROR: git command failed')
445 sys.exit(result)
446 # Perform the cherry-pick
447 if not args.pull:
448 cmd = ['git cherry-pick FETCH_HEAD']
449 if args.quiet:
450 cmd_out = open(os.devnull, 'wb')
451 else:
452 cmd_out = None
453 result = subprocess.call(cmd, cwd=project_path, shell=True, stdout=cmd_out, stderr=cmd_out)
454 if result != 0:
Harry Youd1c9c5a32017-07-18 18:52:42 +0100455 if args.reset:
456 print('ERROR: git command failed, aborting cherry-pick')
457 cmd = ['git cherry-pick --abort']
458 subprocess.call(cmd, cwd=project_path, shell=True, stdout=cmd_out, stderr=cmd_out)
459 else:
460 print('ERROR: git command failed')
Michael Bestas3952f6c2016-08-26 01:12:08 +0300461 sys.exit(result)
462 if not args.quiet:
463 print('')