blob: 1688ffdb0980cf60e3a4106e6f474aecb982e3ab [file] [log] [blame]
Pulser72e23242013-09-29 09:56:55 +01001#!/usr/bin/env python
2#
Marko Manb58468a2018-03-19 13:01:19 +01003# Copyright (C) 2013-15 The CyanogenMod Project
4# (C) 2017 The LineageOS Project
Pulser72e23242013-09-29 09:56:55 +01005#
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 Md91609d2018-03-31 14:26:59 +020032from functools import cmp_to_key
Marko Manb58468a2018-03-19 13:01:19 +010033from xml.etree import ElementTree
Pulser72e23242013-09-29 09:56:55 +010034
35try:
Marko Manb58468a2018-03-19 13:01:19 +010036 import requests
Pulser72e23242013-09-29 09:56:55 +010037except ImportError:
Marko Manb58468a2018-03-19 13:01:19 +010038 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
Pulser72e23242013-09-29 09:56:55 +010049
Pulser72e23242013-09-29 09:56:55 +010050
Luca Weissd1bbac62018-11-25 14:07:12 +010051# cmp() is not available in Python 3, define it manually
52# See https://docs.python.org/3.0/whatsnew/3.0.html#ordering-comparisons
53def cmp(a, b):
54 return (a > b) - (a < b)
55
56
Pulser72e23242013-09-29 09:56:55 +010057# Verifies whether pathA is a subdirectory (or the same) as pathB
Marko Manb58468a2018-03-19 13:01:19 +010058def is_subdir(a, b):
59 a = os.path.realpath(a) + '/'
60 b = os.path.realpath(b) + '/'
61 return b == a[:len(b)]
Pulser72e23242013-09-29 09:56:55 +010062
Pulser72e23242013-09-29 09:56:55 +010063
Marko Manb58468a2018-03-19 13:01:19 +010064def fetch_query_via_ssh(remote_url, query):
65 """Given a remote_url and a query, return the list of changes that fit it
66 This function is slightly messy - the ssh api does not return data in the same structure as the HTTP REST API
67 We have to get the data, then transform it to match what we're expecting from the HTTP RESET API"""
68 if remote_url.count(':') == 2:
69 (uri, userhost, port) = remote_url.split(':')
70 userhost = userhost[2:]
71 elif remote_url.count(':') == 1:
72 (uri, userhost) = remote_url.split(':')
73 userhost = userhost[2:]
74 port = 29418
Pulser72e23242013-09-29 09:56:55 +010075 else:
Marko Manb58468a2018-03-19 13:01:19 +010076 raise Exception('Malformed URI: Expecting ssh://[user@]host[:port]')
Pulser72e23242013-09-29 09:56:55 +010077
Pulser72e23242013-09-29 09:56:55 +010078
Marko Manb58468a2018-03-19 13:01:19 +010079 out = subprocess.check_output(['ssh', '-x', '-p{0}'.format(port), userhost, 'gerrit', 'query', '--format=JSON --patch-sets --current-patch-set', query])
80 if not hasattr(out, 'encode'):
81 out = out.decode()
82 reviews = []
83 for line in out.split('\n'):
84 try:
85 data = json.loads(line)
86 # make our data look like the http rest api data
87 review = {
88 'branch': data['branch'],
89 'change_id': data['id'],
90 'current_revision': data['currentPatchSet']['revision'],
91 'number': int(data['number']),
92 'revisions': {patch_set['revision']: {
93 'number': int(patch_set['number']),
94 'fetch': {
95 'ssh': {
96 'ref': patch_set['ref'],
97 'url': 'ssh://{0}:{1}/{2}'.format(userhost, port, data['project'])
98 }
99 }
100 } for patch_set in data['patchSets']},
101 'subject': data['subject'],
102 'project': data['project'],
103 'status': data['status']
104 }
105 reviews.append(review)
106 except:
107 pass
108 args.quiet or print('Found {0} reviews'.format(len(reviews)))
109 return reviews
Pulser72e23242013-09-29 09:56:55 +0100110
Pulser72e23242013-09-29 09:56:55 +0100111
Marko Manb58468a2018-03-19 13:01:19 +0100112def fetch_query_via_http(remote_url, query):
113 if "requests" in sys.modules:
114 auth = None
115 if os.path.isfile(os.getenv("HOME") + "/.gerritrc"):
116 f = open(os.getenv("HOME") + "/.gerritrc", "r")
117 for line in f:
118 parts = line.rstrip().split("|")
119 if parts[0] in remote_url:
120 auth = requests.auth.HTTPBasicAuth(username=parts[1], password=parts[2])
121 statusCode = '-1'
122 if auth:
Gabriele Md91609d2018-03-31 14:26:59 +0200123 url = '{0}/a/changes/?q={1}&o=CURRENT_REVISION&o=ALL_REVISIONS&o=ALL_COMMITS'.format(remote_url, query)
Marko Manb58468a2018-03-19 13:01:19 +0100124 data = requests.get(url, auth=auth)
125 statusCode = str(data.status_code)
126 if statusCode != '200':
127 #They didn't get good authorization or data, Let's try the old way
Gabriele Md91609d2018-03-31 14:26:59 +0200128 url = '{0}/changes/?q={1}&o=CURRENT_REVISION&o=ALL_REVISIONS&o=ALL_COMMITS'.format(remote_url, query)
Marko Manb58468a2018-03-19 13:01:19 +0100129 data = requests.get(url)
130 reviews = json.loads(data.text[5:])
131 else:
132 """Given a query, fetch the change numbers via http"""
Gabriele Md91609d2018-03-31 14:26:59 +0200133 url = '{0}/changes/?q={1}&o=CURRENT_REVISION&o=ALL_REVISIONS&o=ALL_COMMITS'.format(remote_url, query)
Marko Manb58468a2018-03-19 13:01:19 +0100134 data = urllib.request.urlopen(url).read().decode('utf-8')
135 reviews = json.loads(data[5:])
136
137 for review in reviews:
138 review['number'] = review.pop('_number')
139
140 return reviews
141
142
143def fetch_query(remote_url, query):
144 """Wrapper for fetch_query_via_proto functions"""
145 if remote_url[0:3] == 'ssh':
146 return fetch_query_via_ssh(remote_url, query)
147 elif remote_url[0:4] == 'http':
148 return fetch_query_via_http(remote_url, query.replace(' ', '+'))
149 else:
150 raise Exception('Gerrit URL should be in the form http[s]://hostname/ or ssh://[user@]host[:port]')
151
152if __name__ == '__main__':
153 # Default to OmniRom Gerrit
154 default_gerrit = 'https://gerrit.omnirom.org'
155
156 parser = argparse.ArgumentParser(formatter_class=argparse.RawDescriptionHelpFormatter, description=textwrap.dedent('''\
157 repopick.py is a utility to simplify the process of cherry picking
158 patches from OmniRom's Gerrit instance (or any gerrit instance of your choosing)
159
160 Given a list of change numbers, repopick will cd into the project path
161 and cherry pick the latest patch available.
162
163 With the --start-branch argument, the user can specify that a branch
164 should be created before cherry picking. This is useful for
165 cherry-picking many patches into a common branch which can be easily
166 abandoned later (good for testing other's changes.)
167
168 The --abandon-first argument, when used in conjunction with the
169 --start-branch option, will cause repopick to abandon the specified
170 branch in all repos first before performing any cherry picks.'''))
171 parser.add_argument('change_number', nargs='*', help='change number to cherry pick. Use {change number}/{patchset number} to get a specific revision.')
172 parser.add_argument('-i', '--ignore-missing', action='store_true', help='do not error out if a patch applies to a missing directory')
173 parser.add_argument('-s', '--start-branch', nargs=1, help='start the specified branch before cherry picking')
174 parser.add_argument('-r', '--reset', action='store_true', help='reset to initial state (abort cherry-pick) if there is a conflict')
175 parser.add_argument('-a', '--abandon-first', action='store_true', help='before cherry picking, abandon the branch specified in --start-branch')
176 parser.add_argument('-b', '--auto-branch', action='store_true', help='shortcut to "--start-branch auto --abandon-first --ignore-missing"')
177 parser.add_argument('-q', '--quiet', action='store_true', help='print as little as possible')
178 parser.add_argument('-v', '--verbose', action='store_true', help='print extra information to aid in debug')
179 parser.add_argument('-f', '--force', action='store_true', help='force cherry pick even if change is closed')
180 parser.add_argument('-p', '--pull', action='store_true', help='execute pull instead of cherry-pick')
181 parser.add_argument('-P', '--path', help='use the specified path for the change')
182 parser.add_argument('-t', '--topic', help='pick all commits from a specified topic')
183 parser.add_argument('-Q', '--query', help='pick all commits using the specified query')
184 parser.add_argument('-g', '--gerrit', default=default_gerrit, help='Gerrit Instance to use. Form proto://[user@]host[:port]')
185 parser.add_argument('-e', '--exclude', nargs=1, help='exclude a list of commit numbers separated by a ,')
186 parser.add_argument('-c', '--check-picked', type=int, default=10, help='pass the amount of commits to check for already picked changes')
187 args = parser.parse_args()
188 if not args.start_branch and args.abandon_first:
189 parser.error('if --abandon-first is set, you must also give the branch name with --start-branch')
190 if args.auto_branch:
191 args.abandon_first = True
192 args.ignore_missing = True
193 if not args.start_branch:
194 args.start_branch = ['auto']
195 if args.quiet and args.verbose:
196 parser.error('--quiet and --verbose cannot be specified together')
197
198 if (1 << bool(args.change_number) << bool(args.topic) << bool(args.query)) != 2:
199 parser.error('One (and only one) of change_number, topic, and query are allowed')
200
201 # Change current directory to the top of the tree
202 if 'ANDROID_BUILD_TOP' in os.environ:
203 top = os.environ['ANDROID_BUILD_TOP']
204
205 if not is_subdir(os.getcwd(), top):
206 sys.stderr.write('ERROR: You must run this tool from within $ANDROID_BUILD_TOP!\n')
207 sys.exit(1)
208 os.chdir(os.environ['ANDROID_BUILD_TOP'])
209
210 # Sanity check that we are being run from the top level of the tree
211 if not os.path.isdir('.repo'):
212 sys.stderr.write('ERROR: No .repo directory found. Please run this from the top of your tree.\n')
Pulser72e23242013-09-29 09:56:55 +0100213 sys.exit(1)
214
Marko Manb58468a2018-03-19 13:01:19 +0100215 # If --abandon-first is given, abandon the branch before starting
216 if args.abandon_first:
217 # Determine if the branch already exists; skip the abandon if it does not
218 plist = subprocess.check_output(['repo', 'info'])
219 if not hasattr(plist, 'encode'):
220 plist = plist.decode()
221 needs_abandon = False
222 for pline in plist.splitlines():
223 matchObj = re.match(r'Local Branches.*\[(.*)\]', pline)
224 if matchObj:
225 local_branches = re.split('\s*,\s*', matchObj.group(1))
226 if any(args.start_branch[0] in s for s in local_branches):
227 needs_abandon = True
Pulser72e23242013-09-29 09:56:55 +0100228
Marko Manb58468a2018-03-19 13:01:19 +0100229 if needs_abandon:
230 # Perform the abandon only if the branch already exists
231 if not args.quiet:
232 print('Abandoning branch: %s' % args.start_branch[0])
233 subprocess.check_output(['repo', 'abandon', args.start_branch[0]])
234 if not args.quiet:
235 print('')
Pulser72e23242013-09-29 09:56:55 +0100236
Marko Manb58468a2018-03-19 13:01:19 +0100237 # Get the master manifest from repo
238 # - convert project name and revision to a path
239 project_name_to_data = {}
240 manifest = subprocess.check_output(['repo', 'manifest'])
241 xml_root = ElementTree.fromstring(manifest)
242 projects = xml_root.findall('project')
243 remotes = xml_root.findall('remote')
244 default_revision = xml_root.findall('default')[0].get('revision')
245
246 #dump project data into the a list of dicts with the following data:
247 #{project: {path, revision}}
248
249 for project in projects:
250 name = project.get('name')
251 path = project.get('path')
252 revision = project.get('revision')
253 if revision is None:
254 for remote in remotes:
255 if remote.get('name') == project.get('remote'):
256 revision = remote.get('revision')
257 if revision is None:
258 revision = default_revision
259
260 if not name in project_name_to_data:
261 project_name_to_data[name] = {}
262 revision = revision.split('refs/heads/')[-1]
263 project_name_to_data[name][revision] = path
264
265 # get data on requested changes
266 reviews = []
267 change_numbers = []
Gabriele Md91609d2018-03-31 14:26:59 +0200268
269 def cmp_reviews(review_a, review_b):
270 current_a = review_a['current_revision']
271 parents_a = [r['commit'] for r in review_a['revisions'][current_a]['commit']['parents']]
272 current_b = review_b['current_revision']
273 parents_b = [r['commit'] for r in review_b['revisions'][current_b]['commit']['parents']]
274 if current_a in parents_b:
275 return -1
276 elif current_b in parents_a:
277 return 1
278 else:
279 return cmp(review_a['number'], review_b['number'])
280
Marko Manb58468a2018-03-19 13:01:19 +0100281 if args.topic:
282 reviews = fetch_query(args.gerrit, 'topic:{0}'.format(args.topic))
Gabriele Md91609d2018-03-31 14:26:59 +0200283 change_numbers = [str(r['number']) for r in sorted(reviews, key=cmp_to_key(cmp_reviews))]
Marko Manb58468a2018-03-19 13:01:19 +0100284 if args.query:
285 reviews = fetch_query(args.gerrit, args.query)
Gabriele Md91609d2018-03-31 14:26:59 +0200286 change_numbers = [str(r['number']) for r in sorted(reviews, key=cmp_to_key(cmp_reviews))]
Marko Manb58468a2018-03-19 13:01:19 +0100287 if args.change_number:
Gabriele M1009aeb2018-04-01 17:50:58 +0200288 change_url_re = re.compile('https?://.+?/([0-9]+(?:/[0-9]+)?)/?')
Marko Manb58468a2018-03-19 13:01:19 +0100289 for c in args.change_number:
Gabriele M1009aeb2018-04-01 17:50:58 +0200290 change_number = change_url_re.findall(c)
291 if change_number:
292 change_numbers.extend(change_number)
293 elif '-' in c:
Marko Manb58468a2018-03-19 13:01:19 +0100294 templist = c.split('-')
295 for i in range(int(templist[0]), int(templist[1]) + 1):
296 change_numbers.append(str(i))
297 else:
298 change_numbers.append(c)
299 reviews = fetch_query(args.gerrit, ' OR '.join('change:{0}'.format(x.split('/')[0]) for x in change_numbers))
300
301 # make list of things to actually merge
302 mergables = []
303
304 # If --exclude is given, create the list of commits to ignore
305 exclude = []
306 if args.exclude:
307 exclude = args.exclude[0].split(',')
308
309 for change in change_numbers:
310 patchset = None
311 if '/' in change:
312 (change, patchset) = change.split('/')
313
314 if change in exclude:
315 continue
316
317 change = int(change)
318
319 if patchset is not None:
320 patchset = int(patchset)
321
322 review = next((x for x in reviews if x['number'] == change), None)
323 if review is None:
324 print('Change %d not found, skipping' % change)
325 continue
326
327 mergables.append({
328 'subject': review['subject'],
329 'project': review['project'],
330 'branch': review['branch'],
331 'change_id': review['change_id'],
332 'change_number': review['number'],
333 'status': review['status'],
Gabriele M1188cbd2018-04-01 17:50:57 +0200334 'fetch': None,
335 'patchset': review['revisions'][review['current_revision']]['_number'],
Marko Manb58468a2018-03-19 13:01:19 +0100336 })
Gabriele M1188cbd2018-04-01 17:50:57 +0200337
Marko Manb58468a2018-03-19 13:01:19 +0100338 mergables[-1]['fetch'] = review['revisions'][review['current_revision']]['fetch']
339 mergables[-1]['id'] = change
340 if patchset:
341 try:
342 mergables[-1]['fetch'] = [review['revisions'][x]['fetch'] for x in review['revisions'] if review['revisions'][x]['_number'] == patchset][0]
343 mergables[-1]['id'] = '{0}/{1}'.format(change, patchset)
Gabriele M1188cbd2018-04-01 17:50:57 +0200344 mergables[-1]['patchset'] = patchset
Marko Manb58468a2018-03-19 13:01:19 +0100345 except (IndexError, ValueError):
346 args.quiet or print('ERROR: The patch set {0}/{1} could not be found, using CURRENT_REVISION instead.'.format(change, patchset))
347
348 for item in mergables:
349 args.quiet or print('Applying change number {0}...'.format(item['id']))
350 # Check if change is open and exit if it's not, unless -f is specified
351 if (item['status'] != 'OPEN' and item['status'] != 'NEW' and item['status'] != 'DRAFT') and not args.query:
352 if args.force:
353 print('!! Force-picking a closed change !!\n')
354 else:
355 print('Change status is ' + item['status'] + '. Skipping the cherry pick.\nUse -f to force this pick.')
356 continue
Pulser72e23242013-09-29 09:56:55 +0100357
358 # Convert the project name to a project path
359 # - check that the project path exists
Marko Manb58468a2018-03-19 13:01:19 +0100360 project_path = None
361
362 if item['project'] in project_name_to_data and item['branch'] in project_name_to_data[item['project']]:
363 project_path = project_name_to_data[item['project']][item['branch']]
364 elif args.path:
365 project_path = args.path
Pulser72e23242013-09-29 09:56:55 +0100366 elif args.ignore_missing:
Marko Manb58468a2018-03-19 13:01:19 +0100367 print('WARNING: Skipping {0} since there is no project directory for: {1}\n'.format(item['id'], item['project']))
368 continue
Pulser72e23242013-09-29 09:56:55 +0100369 else:
Marko Manb58468a2018-03-19 13:01:19 +0100370 sys.stderr.write('ERROR: For {0}, could not determine the project path for project {1}\n'.format(item['id'], item['project']))
371 sys.exit(1)
Pulser72e23242013-09-29 09:56:55 +0100372
373 # If --start-branch is given, create the branch (more than once per path is okay; repo ignores gracefully)
374 if args.start_branch:
Marko Manb58468a2018-03-19 13:01:19 +0100375 subprocess.check_output(['repo', 'start', args.start_branch[0], project_path])
376
377 # Determine the maximum commits to check already picked changes
378 check_picked_count = args.check_picked
379 branch_commits_count = int(subprocess.check_output(['git', 'rev-list', '--count', 'HEAD'], cwd=project_path))
380 if branch_commits_count <= check_picked_count:
381 check_picked_count = branch_commits_count - 1
382
383 # Check if change is already picked to HEAD...HEAD~check_picked_count
384 found_change = False
385 for i in range(0, check_picked_count):
386 if subprocess.call(['git', 'cat-file', '-e', 'HEAD~{0}'.format(i)], cwd=project_path, stderr=open(os.devnull, 'wb')):
387 continue
388 output = subprocess.check_output(['git', 'show', '-q', 'HEAD~{0}'.format(i)], cwd=project_path).split()
389 if 'Change-Id:' in output:
390 head_change_id = ''
391 for j,t in enumerate(reversed(output)):
392 if t == 'Change-Id:':
393 head_change_id = output[len(output) - j]
394 break
395 if head_change_id.strip() == item['change_id']:
396 print('Skipping {0} - already picked in {1} as HEAD~{2}'.format(item['id'], project_path, i))
397 found_change = True
398 break
399 if found_change:
400 continue
Pulser72e23242013-09-29 09:56:55 +0100401
402 # Print out some useful info
403 if not args.quiet:
Marko Manb58468a2018-03-19 13:01:19 +0100404 print('--> Subject: "{0}"'.format(item['subject'].encode('utf-8')))
405 print('--> Project path: {0}'.format(project_path))
Gabriele M1188cbd2018-04-01 17:50:57 +0200406 print('--> Change number: {0} (Patch Set {1})'.format(item['id'], item['patchset']))
Pulser72e23242013-09-29 09:56:55 +0100407
Marko Manb58468a2018-03-19 13:01:19 +0100408 if 'anonymous http' in item['fetch']:
409 method = 'anonymous http'
Pulser72e23242013-09-29 09:56:55 +0100410 else:
Marko Manb58468a2018-03-19 13:01:19 +0100411 method = 'ssh'
Pulser72e23242013-09-29 09:56:55 +0100412
Marko Manb58468a2018-03-19 13:01:19 +0100413 # Try fetching from GitHub first if using default gerrit
414 if args.gerrit == default_gerrit:
415 if args.verbose:
416 print('Trying to fetch the change from GitHub')
417
418 if args.pull:
419 cmd = ['git pull --no-edit omnirom', item['fetch'][method]['ref']]
420 else:
421 cmd = ['git fetch omnirom', item['fetch'][method]['ref']]
422 if args.quiet:
423 cmd.append('--quiet')
424 else:
425 print(cmd)
426 result = subprocess.call([' '.join(cmd)], cwd=project_path, shell=True)
427 FETCH_HEAD = '{0}/.git/FETCH_HEAD'.format(project_path)
428 if result != 0 and os.stat(FETCH_HEAD).st_size != 0:
429 print('ERROR: git command failed')
430 sys.exit(result)
431 # Check if it worked
432 if args.gerrit != default_gerrit or os.stat(FETCH_HEAD).st_size == 0:
433 # If not using the default gerrit or github failed, fetch from gerrit.
434 if args.verbose:
435 if args.gerrit == default_gerrit:
436 print('Fetching from GitHub didn\'t work, trying to fetch the change from Gerrit')
437 else:
438 print('Fetching from {0}'.format(args.gerrit))
439
440 if args.pull:
441 cmd = ['git pull --no-edit', item['fetch'][method]['url'], item['fetch'][method]['ref']]
442 else:
443 cmd = ['git fetch', item['fetch'][method]['url'], item['fetch'][method]['ref']]
444 if args.quiet:
445 cmd.append('--quiet')
446 else:
447 print(cmd)
448 result = subprocess.call([' '.join(cmd)], cwd=project_path, shell=True)
449 if result != 0:
450 print('ERROR: git command failed')
451 sys.exit(result)
452 # Perform the cherry-pick
453 if not args.pull:
454 cmd = ['git cherry-pick FETCH_HEAD']
455 if args.quiet:
456 cmd_out = open(os.devnull, 'wb')
457 else:
458 cmd_out = None
459 result = subprocess.call(cmd, cwd=project_path, shell=True, stdout=cmd_out, stderr=cmd_out)
460 if result != 0:
461 if args.reset:
462 print('ERROR: git command failed, aborting cherry-pick')
463 cmd = ['git cherry-pick --abort']
464 subprocess.call(cmd, cwd=project_path, shell=True, stdout=cmd_out, stderr=cmd_out)
465 else:
466 print('ERROR: git command failed')
467 sys.exit(result)
Pulser72e23242013-09-29 09:56:55 +0100468 if not args.quiet:
Marko Manb58468a2018-03-19 13:01:19 +0100469 print('')