blob: f645d59ecae38bcbf5fa9ae1f3a91b5f5df74755 [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
Marko Manb58468a2018-03-19 13:01:19 +010032from xml.etree import ElementTree
Pulser72e23242013-09-29 09:56:55 +010033
34try:
Marko Manb58468a2018-03-19 13:01:19 +010035 import requests
Pulser72e23242013-09-29 09:56:55 +010036except ImportError:
Marko Manb58468a2018-03-19 13:01:19 +010037 try:
38 # For python3
39 import urllib.error
40 import urllib.request
41 except ImportError:
42 # For python2
43 import imp
44 import urllib2
45 urllib = imp.new_module('urllib')
46 urllib.error = urllib2
47 urllib.request = urllib2
Pulser72e23242013-09-29 09:56:55 +010048
Pulser72e23242013-09-29 09:56:55 +010049
Luca Weissd1bbac62018-11-25 14:07:12 +010050# cmp() is not available in Python 3, define it manually
51# See https://docs.python.org/3.0/whatsnew/3.0.html#ordering-comparisons
52def cmp(a, b):
53 return (a > b) - (a < b)
54
55
Pulser72e23242013-09-29 09:56:55 +010056# Verifies whether pathA is a subdirectory (or the same) as pathB
Marko Manb58468a2018-03-19 13:01:19 +010057def is_subdir(a, b):
58 a = os.path.realpath(a) + '/'
59 b = os.path.realpath(b) + '/'
60 return b == a[:len(b)]
Pulser72e23242013-09-29 09:56:55 +010061
Pulser72e23242013-09-29 09:56:55 +010062
Marko Manb58468a2018-03-19 13:01:19 +010063def fetch_query_via_ssh(remote_url, query):
64 """Given a remote_url and a query, return the list of changes that fit it
65 This function is slightly messy - the ssh api does not return data in the same structure as the HTTP REST API
66 We have to get the data, then transform it to match what we're expecting from the HTTP RESET API"""
67 if remote_url.count(':') == 2:
68 (uri, userhost, port) = remote_url.split(':')
69 userhost = userhost[2:]
70 elif remote_url.count(':') == 1:
71 (uri, userhost) = remote_url.split(':')
72 userhost = userhost[2:]
73 port = 29418
Pulser72e23242013-09-29 09:56:55 +010074 else:
Marko Manb58468a2018-03-19 13:01:19 +010075 raise Exception('Malformed URI: Expecting ssh://[user@]host[:port]')
Pulser72e23242013-09-29 09:56:55 +010076
Pulser72e23242013-09-29 09:56:55 +010077
Marko Manb58468a2018-03-19 13:01:19 +010078 out = subprocess.check_output(['ssh', '-x', '-p{0}'.format(port), userhost, 'gerrit', 'query', '--format=JSON --patch-sets --current-patch-set', query])
79 if not hasattr(out, 'encode'):
80 out = out.decode()
81 reviews = []
82 for line in out.split('\n'):
83 try:
84 data = json.loads(line)
85 # make our data look like the http rest api data
86 review = {
87 'branch': data['branch'],
88 'change_id': data['id'],
89 'current_revision': data['currentPatchSet']['revision'],
90 'number': int(data['number']),
91 'revisions': {patch_set['revision']: {
92 'number': int(patch_set['number']),
93 'fetch': {
94 'ssh': {
95 'ref': patch_set['ref'],
96 'url': 'ssh://{0}:{1}/{2}'.format(userhost, port, data['project'])
97 }
98 }
99 } for patch_set in data['patchSets']},
100 'subject': data['subject'],
101 'project': data['project'],
102 'status': data['status']
103 }
104 reviews.append(review)
105 except:
106 pass
107 args.quiet or print('Found {0} reviews'.format(len(reviews)))
108 return reviews
Pulser72e23242013-09-29 09:56:55 +0100109
Pulser72e23242013-09-29 09:56:55 +0100110
Marko Manb58468a2018-03-19 13:01:19 +0100111def fetch_query_via_http(remote_url, query):
112 if "requests" in sys.modules:
113 auth = None
114 if os.path.isfile(os.getenv("HOME") + "/.gerritrc"):
115 f = open(os.getenv("HOME") + "/.gerritrc", "r")
116 for line in f:
117 parts = line.rstrip().split("|")
118 if parts[0] in remote_url:
119 auth = requests.auth.HTTPBasicAuth(username=parts[1], password=parts[2])
120 statusCode = '-1'
121 if auth:
122 url = '{0}/a/changes/?q={1}&o=CURRENT_REVISION&o=ALL_REVISIONS'.format(remote_url, query)
123 data = requests.get(url, auth=auth)
124 statusCode = str(data.status_code)
125 if statusCode != '200':
126 #They didn't get good authorization or data, Let's try the old way
127 url = '{0}/changes/?q={1}&o=CURRENT_REVISION&o=ALL_REVISIONS'.format(remote_url, query)
128 data = requests.get(url)
129 reviews = json.loads(data.text[5:])
130 else:
131 """Given a query, fetch the change numbers via http"""
132 url = '{0}/changes/?q={1}&o=CURRENT_REVISION&o=ALL_REVISIONS'.format(remote_url, query)
133 data = urllib.request.urlopen(url).read().decode('utf-8')
134 reviews = json.loads(data[5:])
135
136 for review in reviews:
137 review['number'] = review.pop('_number')
138
139 return reviews
140
141
142def fetch_query(remote_url, query):
143 """Wrapper for fetch_query_via_proto functions"""
144 if remote_url[0:3] == 'ssh':
145 return fetch_query_via_ssh(remote_url, query)
146 elif remote_url[0:4] == 'http':
147 return fetch_query_via_http(remote_url, query.replace(' ', '+'))
148 else:
149 raise Exception('Gerrit URL should be in the form http[s]://hostname/ or ssh://[user@]host[:port]')
150
151if __name__ == '__main__':
152 # Default to OmniRom Gerrit
153 default_gerrit = 'https://gerrit.omnirom.org'
154
155 parser = argparse.ArgumentParser(formatter_class=argparse.RawDescriptionHelpFormatter, description=textwrap.dedent('''\
156 repopick.py is a utility to simplify the process of cherry picking
157 patches from OmniRom's Gerrit instance (or any gerrit instance of your choosing)
158
159 Given a list of change numbers, repopick will cd into the project path
160 and cherry pick the latest patch available.
161
162 With the --start-branch argument, the user can specify that a branch
163 should be created before cherry picking. This is useful for
164 cherry-picking many patches into a common branch which can be easily
165 abandoned later (good for testing other's changes.)
166
167 The --abandon-first argument, when used in conjunction with the
168 --start-branch option, will cause repopick to abandon the specified
169 branch in all repos first before performing any cherry picks.'''))
170 parser.add_argument('change_number', nargs='*', help='change number to cherry pick. Use {change number}/{patchset number} to get a specific revision.')
171 parser.add_argument('-i', '--ignore-missing', action='store_true', help='do not error out if a patch applies to a missing directory')
172 parser.add_argument('-s', '--start-branch', nargs=1, help='start the specified branch before cherry picking')
173 parser.add_argument('-r', '--reset', action='store_true', help='reset to initial state (abort cherry-pick) if there is a conflict')
174 parser.add_argument('-a', '--abandon-first', action='store_true', help='before cherry picking, abandon the branch specified in --start-branch')
175 parser.add_argument('-b', '--auto-branch', action='store_true', help='shortcut to "--start-branch auto --abandon-first --ignore-missing"')
176 parser.add_argument('-q', '--quiet', action='store_true', help='print as little as possible')
177 parser.add_argument('-v', '--verbose', action='store_true', help='print extra information to aid in debug')
178 parser.add_argument('-f', '--force', action='store_true', help='force cherry pick even if change is closed')
179 parser.add_argument('-p', '--pull', action='store_true', help='execute pull instead of cherry-pick')
180 parser.add_argument('-P', '--path', help='use the specified path for the change')
181 parser.add_argument('-t', '--topic', help='pick all commits from a specified topic')
182 parser.add_argument('-Q', '--query', help='pick all commits using the specified query')
183 parser.add_argument('-g', '--gerrit', default=default_gerrit, help='Gerrit Instance to use. Form proto://[user@]host[:port]')
184 parser.add_argument('-e', '--exclude', nargs=1, help='exclude a list of commit numbers separated by a ,')
185 parser.add_argument('-c', '--check-picked', type=int, default=10, help='pass the amount of commits to check for already picked changes')
186 args = parser.parse_args()
187 if not args.start_branch and args.abandon_first:
188 parser.error('if --abandon-first is set, you must also give the branch name with --start-branch')
189 if args.auto_branch:
190 args.abandon_first = True
191 args.ignore_missing = True
192 if not args.start_branch:
193 args.start_branch = ['auto']
194 if args.quiet and args.verbose:
195 parser.error('--quiet and --verbose cannot be specified together')
196
197 if (1 << bool(args.change_number) << bool(args.topic) << bool(args.query)) != 2:
198 parser.error('One (and only one) of change_number, topic, and query are allowed')
199
200 # Change current directory to the top of the tree
201 if 'ANDROID_BUILD_TOP' in os.environ:
202 top = os.environ['ANDROID_BUILD_TOP']
203
204 if not is_subdir(os.getcwd(), top):
205 sys.stderr.write('ERROR: You must run this tool from within $ANDROID_BUILD_TOP!\n')
206 sys.exit(1)
207 os.chdir(os.environ['ANDROID_BUILD_TOP'])
208
209 # Sanity check that we are being run from the top level of the tree
210 if not os.path.isdir('.repo'):
211 sys.stderr.write('ERROR: No .repo directory found. Please run this from the top of your tree.\n')
Pulser72e23242013-09-29 09:56:55 +0100212 sys.exit(1)
213
Marko Manb58468a2018-03-19 13:01:19 +0100214 # If --abandon-first is given, abandon the branch before starting
215 if args.abandon_first:
216 # Determine if the branch already exists; skip the abandon if it does not
217 plist = subprocess.check_output(['repo', 'info'])
218 if not hasattr(plist, 'encode'):
219 plist = plist.decode()
220 needs_abandon = False
221 for pline in plist.splitlines():
222 matchObj = re.match(r'Local Branches.*\[(.*)\]', pline)
223 if matchObj:
224 local_branches = re.split('\s*,\s*', matchObj.group(1))
225 if any(args.start_branch[0] in s for s in local_branches):
226 needs_abandon = True
Pulser72e23242013-09-29 09:56:55 +0100227
Marko Manb58468a2018-03-19 13:01:19 +0100228 if needs_abandon:
229 # Perform the abandon only if the branch already exists
230 if not args.quiet:
231 print('Abandoning branch: %s' % args.start_branch[0])
232 subprocess.check_output(['repo', 'abandon', args.start_branch[0]])
233 if not args.quiet:
234 print('')
Pulser72e23242013-09-29 09:56:55 +0100235
Marko Manb58468a2018-03-19 13:01:19 +0100236 # Get the master manifest from repo
237 # - convert project name and revision to a path
238 project_name_to_data = {}
239 manifest = subprocess.check_output(['repo', 'manifest'])
240 xml_root = ElementTree.fromstring(manifest)
241 projects = xml_root.findall('project')
242 remotes = xml_root.findall('remote')
243 default_revision = xml_root.findall('default')[0].get('revision')
244
245 #dump project data into the a list of dicts with the following data:
246 #{project: {path, revision}}
247
248 for project in projects:
249 name = project.get('name')
250 path = project.get('path')
251 revision = project.get('revision')
252 if revision is None:
253 for remote in remotes:
254 if remote.get('name') == project.get('remote'):
255 revision = remote.get('revision')
256 if revision is None:
257 revision = default_revision
258
259 if not name in project_name_to_data:
260 project_name_to_data[name] = {}
261 revision = revision.split('refs/heads/')[-1]
262 project_name_to_data[name][revision] = path
263
264 # get data on requested changes
265 reviews = []
266 change_numbers = []
267 if args.topic:
268 reviews = fetch_query(args.gerrit, 'topic:{0}'.format(args.topic))
269 change_numbers = sorted([str(r['number']) for r in reviews], key=int)
270 if args.query:
271 reviews = fetch_query(args.gerrit, args.query)
272 change_numbers = sorted([str(r['number']) for r in reviews], key=int)
273 if args.change_number:
274 for c in args.change_number:
275 if '-' in c:
276 templist = c.split('-')
277 for i in range(int(templist[0]), int(templist[1]) + 1):
278 change_numbers.append(str(i))
279 else:
280 change_numbers.append(c)
281 reviews = fetch_query(args.gerrit, ' OR '.join('change:{0}'.format(x.split('/')[0]) for x in change_numbers))
282
283 # make list of things to actually merge
284 mergables = []
285
286 # If --exclude is given, create the list of commits to ignore
287 exclude = []
288 if args.exclude:
289 exclude = args.exclude[0].split(',')
290
291 for change in change_numbers:
292 patchset = None
293 if '/' in change:
294 (change, patchset) = change.split('/')
295
296 if change in exclude:
297 continue
298
299 change = int(change)
300
301 if patchset is not None:
302 patchset = int(patchset)
303
304 review = next((x for x in reviews if x['number'] == change), None)
305 if review is None:
306 print('Change %d not found, skipping' % change)
307 continue
308
309 mergables.append({
310 'subject': review['subject'],
311 'project': review['project'],
312 'branch': review['branch'],
313 'change_id': review['change_id'],
314 'change_number': review['number'],
315 'status': review['status'],
316 'fetch': None
317 })
318 mergables[-1]['fetch'] = review['revisions'][review['current_revision']]['fetch']
319 mergables[-1]['id'] = change
320 if patchset:
321 try:
322 mergables[-1]['fetch'] = [review['revisions'][x]['fetch'] for x in review['revisions'] if review['revisions'][x]['_number'] == patchset][0]
323 mergables[-1]['id'] = '{0}/{1}'.format(change, patchset)
324 except (IndexError, ValueError):
325 args.quiet or print('ERROR: The patch set {0}/{1} could not be found, using CURRENT_REVISION instead.'.format(change, patchset))
326
327 for item in mergables:
328 args.quiet or print('Applying change number {0}...'.format(item['id']))
329 # Check if change is open and exit if it's not, unless -f is specified
330 if (item['status'] != 'OPEN' and item['status'] != 'NEW' and item['status'] != 'DRAFT') and not args.query:
331 if args.force:
332 print('!! Force-picking a closed change !!\n')
333 else:
334 print('Change status is ' + item['status'] + '. Skipping the cherry pick.\nUse -f to force this pick.')
335 continue
Pulser72e23242013-09-29 09:56:55 +0100336
337 # Convert the project name to a project path
338 # - check that the project path exists
Marko Manb58468a2018-03-19 13:01:19 +0100339 project_path = None
340
341 if item['project'] in project_name_to_data and item['branch'] in project_name_to_data[item['project']]:
342 project_path = project_name_to_data[item['project']][item['branch']]
343 elif args.path:
344 project_path = args.path
Pulser72e23242013-09-29 09:56:55 +0100345 elif args.ignore_missing:
Marko Manb58468a2018-03-19 13:01:19 +0100346 print('WARNING: Skipping {0} since there is no project directory for: {1}\n'.format(item['id'], item['project']))
347 continue
Pulser72e23242013-09-29 09:56:55 +0100348 else:
Marko Manb58468a2018-03-19 13:01:19 +0100349 sys.stderr.write('ERROR: For {0}, could not determine the project path for project {1}\n'.format(item['id'], item['project']))
350 sys.exit(1)
Pulser72e23242013-09-29 09:56:55 +0100351
352 # If --start-branch is given, create the branch (more than once per path is okay; repo ignores gracefully)
353 if args.start_branch:
Marko Manb58468a2018-03-19 13:01:19 +0100354 subprocess.check_output(['repo', 'start', args.start_branch[0], project_path])
355
356 # Determine the maximum commits to check already picked changes
357 check_picked_count = args.check_picked
358 branch_commits_count = int(subprocess.check_output(['git', 'rev-list', '--count', 'HEAD'], cwd=project_path))
359 if branch_commits_count <= check_picked_count:
360 check_picked_count = branch_commits_count - 1
361
362 # Check if change is already picked to HEAD...HEAD~check_picked_count
363 found_change = False
364 for i in range(0, check_picked_count):
365 if subprocess.call(['git', 'cat-file', '-e', 'HEAD~{0}'.format(i)], cwd=project_path, stderr=open(os.devnull, 'wb')):
366 continue
367 output = subprocess.check_output(['git', 'show', '-q', 'HEAD~{0}'.format(i)], cwd=project_path).split()
368 if 'Change-Id:' in output:
369 head_change_id = ''
370 for j,t in enumerate(reversed(output)):
371 if t == 'Change-Id:':
372 head_change_id = output[len(output) - j]
373 break
374 if head_change_id.strip() == item['change_id']:
375 print('Skipping {0} - already picked in {1} as HEAD~{2}'.format(item['id'], project_path, i))
376 found_change = True
377 break
378 if found_change:
379 continue
Pulser72e23242013-09-29 09:56:55 +0100380
381 # Print out some useful info
382 if not args.quiet:
Marko Manb58468a2018-03-19 13:01:19 +0100383 print('--> Subject: "{0}"'.format(item['subject'].encode('utf-8')))
384 print('--> Project path: {0}'.format(project_path))
385 print('--> Change number: {0} (Patch Set {0})'.format(item['id']))
Pulser72e23242013-09-29 09:56:55 +0100386
Marko Manb58468a2018-03-19 13:01:19 +0100387 if 'anonymous http' in item['fetch']:
388 method = 'anonymous http'
Pulser72e23242013-09-29 09:56:55 +0100389 else:
Marko Manb58468a2018-03-19 13:01:19 +0100390 method = 'ssh'
Pulser72e23242013-09-29 09:56:55 +0100391
Marko Manb58468a2018-03-19 13:01:19 +0100392 # Try fetching from GitHub first if using default gerrit
393 if args.gerrit == default_gerrit:
394 if args.verbose:
395 print('Trying to fetch the change from GitHub')
396
397 if args.pull:
398 cmd = ['git pull --no-edit omnirom', item['fetch'][method]['ref']]
399 else:
400 cmd = ['git fetch omnirom', item['fetch'][method]['ref']]
401 if args.quiet:
402 cmd.append('--quiet')
403 else:
404 print(cmd)
405 result = subprocess.call([' '.join(cmd)], cwd=project_path, shell=True)
406 FETCH_HEAD = '{0}/.git/FETCH_HEAD'.format(project_path)
407 if result != 0 and os.stat(FETCH_HEAD).st_size != 0:
408 print('ERROR: git command failed')
409 sys.exit(result)
410 # Check if it worked
411 if args.gerrit != default_gerrit or os.stat(FETCH_HEAD).st_size == 0:
412 # If not using the default gerrit or github failed, fetch from gerrit.
413 if args.verbose:
414 if args.gerrit == default_gerrit:
415 print('Fetching from GitHub didn\'t work, trying to fetch the change from Gerrit')
416 else:
417 print('Fetching from {0}'.format(args.gerrit))
418
419 if args.pull:
420 cmd = ['git pull --no-edit', item['fetch'][method]['url'], item['fetch'][method]['ref']]
421 else:
422 cmd = ['git fetch', item['fetch'][method]['url'], item['fetch'][method]['ref']]
423 if args.quiet:
424 cmd.append('--quiet')
425 else:
426 print(cmd)
427 result = subprocess.call([' '.join(cmd)], cwd=project_path, shell=True)
428 if result != 0:
429 print('ERROR: git command failed')
430 sys.exit(result)
431 # Perform the cherry-pick
432 if not args.pull:
433 cmd = ['git cherry-pick FETCH_HEAD']
434 if args.quiet:
435 cmd_out = open(os.devnull, 'wb')
436 else:
437 cmd_out = None
438 result = subprocess.call(cmd, cwd=project_path, shell=True, stdout=cmd_out, stderr=cmd_out)
439 if result != 0:
440 if args.reset:
441 print('ERROR: git command failed, aborting cherry-pick')
442 cmd = ['git cherry-pick --abort']
443 subprocess.call(cmd, cwd=project_path, shell=True, stdout=cmd_out, stderr=cmd_out)
444 else:
445 print('ERROR: git command failed')
446 sys.exit(result)
Pulser72e23242013-09-29 09:56:55 +0100447 if not args.quiet:
Marko Manb58468a2018-03-19 13:01:19 +0100448 print('')