blob: 7ba30ecc4f9e6c125b69b5da0e439e85c5d7a707 [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:
288 for c in args.change_number:
289 if '-' in c:
290 templist = c.split('-')
291 for i in range(int(templist[0]), int(templist[1]) + 1):
292 change_numbers.append(str(i))
293 else:
294 change_numbers.append(c)
295 reviews = fetch_query(args.gerrit, ' OR '.join('change:{0}'.format(x.split('/')[0]) for x in change_numbers))
296
297 # make list of things to actually merge
298 mergables = []
299
300 # If --exclude is given, create the list of commits to ignore
301 exclude = []
302 if args.exclude:
303 exclude = args.exclude[0].split(',')
304
305 for change in change_numbers:
306 patchset = None
307 if '/' in change:
308 (change, patchset) = change.split('/')
309
310 if change in exclude:
311 continue
312
313 change = int(change)
314
315 if patchset is not None:
316 patchset = int(patchset)
317
318 review = next((x for x in reviews if x['number'] == change), None)
319 if review is None:
320 print('Change %d not found, skipping' % change)
321 continue
322
323 mergables.append({
324 'subject': review['subject'],
325 'project': review['project'],
326 'branch': review['branch'],
327 'change_id': review['change_id'],
328 'change_number': review['number'],
329 'status': review['status'],
Gabriele M1188cbd2018-04-01 17:50:57 +0200330 'fetch': None,
331 'patchset': review['revisions'][review['current_revision']]['_number'],
Marko Manb58468a2018-03-19 13:01:19 +0100332 })
Gabriele M1188cbd2018-04-01 17:50:57 +0200333
Marko Manb58468a2018-03-19 13:01:19 +0100334 mergables[-1]['fetch'] = review['revisions'][review['current_revision']]['fetch']
335 mergables[-1]['id'] = change
336 if patchset:
337 try:
338 mergables[-1]['fetch'] = [review['revisions'][x]['fetch'] for x in review['revisions'] if review['revisions'][x]['_number'] == patchset][0]
339 mergables[-1]['id'] = '{0}/{1}'.format(change, patchset)
Gabriele M1188cbd2018-04-01 17:50:57 +0200340 mergables[-1]['patchset'] = patchset
Marko Manb58468a2018-03-19 13:01:19 +0100341 except (IndexError, ValueError):
342 args.quiet or print('ERROR: The patch set {0}/{1} could not be found, using CURRENT_REVISION instead.'.format(change, patchset))
343
344 for item in mergables:
345 args.quiet or print('Applying change number {0}...'.format(item['id']))
346 # Check if change is open and exit if it's not, unless -f is specified
347 if (item['status'] != 'OPEN' and item['status'] != 'NEW' and item['status'] != 'DRAFT') and not args.query:
348 if args.force:
349 print('!! Force-picking a closed change !!\n')
350 else:
351 print('Change status is ' + item['status'] + '. Skipping the cherry pick.\nUse -f to force this pick.')
352 continue
Pulser72e23242013-09-29 09:56:55 +0100353
354 # Convert the project name to a project path
355 # - check that the project path exists
Marko Manb58468a2018-03-19 13:01:19 +0100356 project_path = None
357
358 if item['project'] in project_name_to_data and item['branch'] in project_name_to_data[item['project']]:
359 project_path = project_name_to_data[item['project']][item['branch']]
360 elif args.path:
361 project_path = args.path
Pulser72e23242013-09-29 09:56:55 +0100362 elif args.ignore_missing:
Marko Manb58468a2018-03-19 13:01:19 +0100363 print('WARNING: Skipping {0} since there is no project directory for: {1}\n'.format(item['id'], item['project']))
364 continue
Pulser72e23242013-09-29 09:56:55 +0100365 else:
Marko Manb58468a2018-03-19 13:01:19 +0100366 sys.stderr.write('ERROR: For {0}, could not determine the project path for project {1}\n'.format(item['id'], item['project']))
367 sys.exit(1)
Pulser72e23242013-09-29 09:56:55 +0100368
369 # If --start-branch is given, create the branch (more than once per path is okay; repo ignores gracefully)
370 if args.start_branch:
Marko Manb58468a2018-03-19 13:01:19 +0100371 subprocess.check_output(['repo', 'start', args.start_branch[0], project_path])
372
373 # Determine the maximum commits to check already picked changes
374 check_picked_count = args.check_picked
375 branch_commits_count = int(subprocess.check_output(['git', 'rev-list', '--count', 'HEAD'], cwd=project_path))
376 if branch_commits_count <= check_picked_count:
377 check_picked_count = branch_commits_count - 1
378
379 # Check if change is already picked to HEAD...HEAD~check_picked_count
380 found_change = False
381 for i in range(0, check_picked_count):
382 if subprocess.call(['git', 'cat-file', '-e', 'HEAD~{0}'.format(i)], cwd=project_path, stderr=open(os.devnull, 'wb')):
383 continue
384 output = subprocess.check_output(['git', 'show', '-q', 'HEAD~{0}'.format(i)], cwd=project_path).split()
385 if 'Change-Id:' in output:
386 head_change_id = ''
387 for j,t in enumerate(reversed(output)):
388 if t == 'Change-Id:':
389 head_change_id = output[len(output) - j]
390 break
391 if head_change_id.strip() == item['change_id']:
392 print('Skipping {0} - already picked in {1} as HEAD~{2}'.format(item['id'], project_path, i))
393 found_change = True
394 break
395 if found_change:
396 continue
Pulser72e23242013-09-29 09:56:55 +0100397
398 # Print out some useful info
399 if not args.quiet:
Marko Manb58468a2018-03-19 13:01:19 +0100400 print('--> Subject: "{0}"'.format(item['subject'].encode('utf-8')))
401 print('--> Project path: {0}'.format(project_path))
Gabriele M1188cbd2018-04-01 17:50:57 +0200402 print('--> Change number: {0} (Patch Set {1})'.format(item['id'], item['patchset']))
Pulser72e23242013-09-29 09:56:55 +0100403
Marko Manb58468a2018-03-19 13:01:19 +0100404 if 'anonymous http' in item['fetch']:
405 method = 'anonymous http'
Pulser72e23242013-09-29 09:56:55 +0100406 else:
Marko Manb58468a2018-03-19 13:01:19 +0100407 method = 'ssh'
Pulser72e23242013-09-29 09:56:55 +0100408
Marko Manb58468a2018-03-19 13:01:19 +0100409 # Try fetching from GitHub first if using default gerrit
410 if args.gerrit == default_gerrit:
411 if args.verbose:
412 print('Trying to fetch the change from GitHub')
413
414 if args.pull:
415 cmd = ['git pull --no-edit omnirom', item['fetch'][method]['ref']]
416 else:
417 cmd = ['git fetch omnirom', item['fetch'][method]['ref']]
418 if args.quiet:
419 cmd.append('--quiet')
420 else:
421 print(cmd)
422 result = subprocess.call([' '.join(cmd)], cwd=project_path, shell=True)
423 FETCH_HEAD = '{0}/.git/FETCH_HEAD'.format(project_path)
424 if result != 0 and os.stat(FETCH_HEAD).st_size != 0:
425 print('ERROR: git command failed')
426 sys.exit(result)
427 # Check if it worked
428 if args.gerrit != default_gerrit or os.stat(FETCH_HEAD).st_size == 0:
429 # If not using the default gerrit or github failed, fetch from gerrit.
430 if args.verbose:
431 if args.gerrit == default_gerrit:
432 print('Fetching from GitHub didn\'t work, trying to fetch the change from Gerrit')
433 else:
434 print('Fetching from {0}'.format(args.gerrit))
435
436 if args.pull:
437 cmd = ['git pull --no-edit', item['fetch'][method]['url'], item['fetch'][method]['ref']]
438 else:
439 cmd = ['git fetch', item['fetch'][method]['url'], item['fetch'][method]['ref']]
440 if args.quiet:
441 cmd.append('--quiet')
442 else:
443 print(cmd)
444 result = subprocess.call([' '.join(cmd)], cwd=project_path, shell=True)
445 if result != 0:
446 print('ERROR: git command failed')
447 sys.exit(result)
448 # Perform the cherry-pick
449 if not args.pull:
450 cmd = ['git cherry-pick FETCH_HEAD']
451 if args.quiet:
452 cmd_out = open(os.devnull, 'wb')
453 else:
454 cmd_out = None
455 result = subprocess.call(cmd, cwd=project_path, shell=True, stdout=cmd_out, stderr=cmd_out)
456 if result != 0:
457 if args.reset:
458 print('ERROR: git command failed, aborting cherry-pick')
459 cmd = ['git cherry-pick --abort']
460 subprocess.call(cmd, cwd=project_path, shell=True, stdout=cmd_out, stderr=cmd_out)
461 else:
462 print('ERROR: git command failed')
463 sys.exit(result)
Pulser72e23242013-09-29 09:56:55 +0100464 if not args.quiet:
Marko Manb58468a2018-03-19 13:01:19 +0100465 print('')