blob: 3b54a78dc6a67c9fd648fef7c2f611cd39c64436 [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'],
330 'fetch': None
331 })
332 mergables[-1]['fetch'] = review['revisions'][review['current_revision']]['fetch']
333 mergables[-1]['id'] = change
334 if patchset:
335 try:
336 mergables[-1]['fetch'] = [review['revisions'][x]['fetch'] for x in review['revisions'] if review['revisions'][x]['_number'] == patchset][0]
337 mergables[-1]['id'] = '{0}/{1}'.format(change, patchset)
338 except (IndexError, ValueError):
339 args.quiet or print('ERROR: The patch set {0}/{1} could not be found, using CURRENT_REVISION instead.'.format(change, patchset))
340
341 for item in mergables:
342 args.quiet or print('Applying change number {0}...'.format(item['id']))
343 # Check if change is open and exit if it's not, unless -f is specified
344 if (item['status'] != 'OPEN' and item['status'] != 'NEW' and item['status'] != 'DRAFT') and not args.query:
345 if args.force:
346 print('!! Force-picking a closed change !!\n')
347 else:
348 print('Change status is ' + item['status'] + '. Skipping the cherry pick.\nUse -f to force this pick.')
349 continue
Pulser72e23242013-09-29 09:56:55 +0100350
351 # Convert the project name to a project path
352 # - check that the project path exists
Marko Manb58468a2018-03-19 13:01:19 +0100353 project_path = None
354
355 if item['project'] in project_name_to_data and item['branch'] in project_name_to_data[item['project']]:
356 project_path = project_name_to_data[item['project']][item['branch']]
357 elif args.path:
358 project_path = args.path
Pulser72e23242013-09-29 09:56:55 +0100359 elif args.ignore_missing:
Marko Manb58468a2018-03-19 13:01:19 +0100360 print('WARNING: Skipping {0} since there is no project directory for: {1}\n'.format(item['id'], item['project']))
361 continue
Pulser72e23242013-09-29 09:56:55 +0100362 else:
Marko Manb58468a2018-03-19 13:01:19 +0100363 sys.stderr.write('ERROR: For {0}, could not determine the project path for project {1}\n'.format(item['id'], item['project']))
364 sys.exit(1)
Pulser72e23242013-09-29 09:56:55 +0100365
366 # If --start-branch is given, create the branch (more than once per path is okay; repo ignores gracefully)
367 if args.start_branch:
Marko Manb58468a2018-03-19 13:01:19 +0100368 subprocess.check_output(['repo', 'start', args.start_branch[0], project_path])
369
370 # Determine the maximum commits to check already picked changes
371 check_picked_count = args.check_picked
372 branch_commits_count = int(subprocess.check_output(['git', 'rev-list', '--count', 'HEAD'], cwd=project_path))
373 if branch_commits_count <= check_picked_count:
374 check_picked_count = branch_commits_count - 1
375
376 # Check if change is already picked to HEAD...HEAD~check_picked_count
377 found_change = False
378 for i in range(0, check_picked_count):
379 if subprocess.call(['git', 'cat-file', '-e', 'HEAD~{0}'.format(i)], cwd=project_path, stderr=open(os.devnull, 'wb')):
380 continue
381 output = subprocess.check_output(['git', 'show', '-q', 'HEAD~{0}'.format(i)], cwd=project_path).split()
382 if 'Change-Id:' in output:
383 head_change_id = ''
384 for j,t in enumerate(reversed(output)):
385 if t == 'Change-Id:':
386 head_change_id = output[len(output) - j]
387 break
388 if head_change_id.strip() == item['change_id']:
389 print('Skipping {0} - already picked in {1} as HEAD~{2}'.format(item['id'], project_path, i))
390 found_change = True
391 break
392 if found_change:
393 continue
Pulser72e23242013-09-29 09:56:55 +0100394
395 # Print out some useful info
396 if not args.quiet:
Marko Manb58468a2018-03-19 13:01:19 +0100397 print('--> Subject: "{0}"'.format(item['subject'].encode('utf-8')))
398 print('--> Project path: {0}'.format(project_path))
399 print('--> Change number: {0} (Patch Set {0})'.format(item['id']))
Pulser72e23242013-09-29 09:56:55 +0100400
Marko Manb58468a2018-03-19 13:01:19 +0100401 if 'anonymous http' in item['fetch']:
402 method = 'anonymous http'
Pulser72e23242013-09-29 09:56:55 +0100403 else:
Marko Manb58468a2018-03-19 13:01:19 +0100404 method = 'ssh'
Pulser72e23242013-09-29 09:56:55 +0100405
Marko Manb58468a2018-03-19 13:01:19 +0100406 # Try fetching from GitHub first if using default gerrit
407 if args.gerrit == default_gerrit:
408 if args.verbose:
409 print('Trying to fetch the change from GitHub')
410
411 if args.pull:
412 cmd = ['git pull --no-edit omnirom', item['fetch'][method]['ref']]
413 else:
414 cmd = ['git fetch omnirom', item['fetch'][method]['ref']]
415 if args.quiet:
416 cmd.append('--quiet')
417 else:
418 print(cmd)
419 result = subprocess.call([' '.join(cmd)], cwd=project_path, shell=True)
420 FETCH_HEAD = '{0}/.git/FETCH_HEAD'.format(project_path)
421 if result != 0 and os.stat(FETCH_HEAD).st_size != 0:
422 print('ERROR: git command failed')
423 sys.exit(result)
424 # Check if it worked
425 if args.gerrit != default_gerrit or os.stat(FETCH_HEAD).st_size == 0:
426 # If not using the default gerrit or github failed, fetch from gerrit.
427 if args.verbose:
428 if args.gerrit == default_gerrit:
429 print('Fetching from GitHub didn\'t work, trying to fetch the change from Gerrit')
430 else:
431 print('Fetching from {0}'.format(args.gerrit))
432
433 if args.pull:
434 cmd = ['git pull --no-edit', item['fetch'][method]['url'], item['fetch'][method]['ref']]
435 else:
436 cmd = ['git fetch', item['fetch'][method]['url'], item['fetch'][method]['ref']]
437 if args.quiet:
438 cmd.append('--quiet')
439 else:
440 print(cmd)
441 result = subprocess.call([' '.join(cmd)], cwd=project_path, shell=True)
442 if result != 0:
443 print('ERROR: git command failed')
444 sys.exit(result)
445 # Perform the cherry-pick
446 if not args.pull:
447 cmd = ['git cherry-pick FETCH_HEAD']
448 if args.quiet:
449 cmd_out = open(os.devnull, 'wb')
450 else:
451 cmd_out = None
452 result = subprocess.call(cmd, cwd=project_path, shell=True, stdout=cmd_out, stderr=cmd_out)
453 if result != 0:
454 if args.reset:
455 print('ERROR: git command failed, aborting cherry-pick')
456 cmd = ['git cherry-pick --abort']
457 subprocess.call(cmd, cwd=project_path, shell=True, stdout=cmd_out, stderr=cmd_out)
458 else:
459 print('ERROR: git command failed')
460 sys.exit(result)
Pulser72e23242013-09-29 09:56:55 +0100461 if not args.quiet:
Marko Manb58468a2018-03-19 13:01:19 +0100462 print('')