blob: c6f7fc7475fcd2005ec5846154869fa9019e03fc [file] [log] [blame]
mikeNGbdc1fde2018-05-17 23:17:12 -04001#!/usr/bin/env python
2#
3# Copyright (C) 2013-15 The CyanogenMod Project
4#
5# Licensed under the Apache License, Version 2.0 (the "License");
6# you may not use this file except in compliance with the License.
7# You may obtain a copy of the License at
8#
9# http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS,
13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14# See the License for the specific language governing permissions and
15# limitations under the License.
16#
17
18#
19# Run repopick.py -h for a description of this utility.
20#
21
22from __future__ import print_function
23
24import sys
25import json
26import os
27import subprocess
28import re
29import argparse
30import textwrap
31from xml.etree import ElementTree
32
33try:
Dan Pasanenc3508ce2017-01-23 15:08:52 -060034 import requests
mikeNGbdc1fde2018-05-17 23:17:12 -040035except ImportError:
Dan Pasanenc3508ce2017-01-23 15:08:52 -060036 try:
37 # For python3
38 import urllib.error
39 import urllib.request
40 except ImportError:
41 # For python2
42 import imp
43 import urllib2
44 urllib = imp.new_module('urllib')
45 urllib.error = urllib2
46 urllib.request = urllib2
mikeNGbdc1fde2018-05-17 23:17:12 -040047
48
49# Verifies whether pathA is a subdirectory (or the same) as pathB
50def is_subdir(a, b):
51 a = os.path.realpath(a) + '/'
52 b = os.path.realpath(b) + '/'
53 return b == a[:len(b)]
54
55
56def fetch_query_via_ssh(remote_url, query):
57 """Given a remote_url and a query, return the list of changes that fit it
58 This function is slightly messy - the ssh api does not return data in the same structure as the HTTP REST API
59 We have to get the data, then transform it to match what we're expecting from the HTTP RESET API"""
60 if remote_url.count(':') == 2:
61 (uri, userhost, port) = remote_url.split(':')
62 userhost = userhost[2:]
63 elif remote_url.count(':') == 1:
64 (uri, userhost) = remote_url.split(':')
65 userhost = userhost[2:]
66 port = 29418
67 else:
68 raise Exception('Malformed URI: Expecting ssh://[user@]host[:port]')
69
70
71 out = subprocess.check_output(['ssh', '-x', '-p{0}'.format(port), userhost, 'gerrit', 'query', '--format=JSON --patch-sets --current-patch-set', query])
72 if not hasattr(out, 'encode'):
73 out = out.decode()
74 reviews = []
75 for line in out.split('\n'):
76 try:
77 data = json.loads(line)
78 # make our data look like the http rest api data
79 review = {
80 'branch': data['branch'],
81 'change_id': data['id'],
82 'current_revision': data['currentPatchSet']['revision'],
83 'number': int(data['number']),
84 'revisions': {patch_set['revision']: {
85 'number': int(patch_set['number']),
86 'fetch': {
87 'ssh': {
88 'ref': patch_set['ref'],
89 'url': 'ssh://{0}:{1}/{2}'.format(userhost, port, data['project'])
90 }
91 }
92 } for patch_set in data['patchSets']},
93 'subject': data['subject'],
94 'project': data['project'],
95 'status': data['status']
96 }
97 reviews.append(review)
98 except:
99 pass
100 args.quiet or print('Found {0} reviews'.format(len(reviews)))
101 return reviews
102
103
104def fetch_query_via_http(remote_url, query):
Dan Pasanenc3508ce2017-01-23 15:08:52 -0600105 if "requests" in sys.modules:
106 auth = None
107 if os.path.isfile(os.getenv("HOME") + "/.gerritrc"):
108 f = open(os.getenv("HOME") + "/.gerritrc", "r")
109 for line in f:
110 parts = line.rstrip().split("|")
111 if parts[0] in remote_url:
112 auth = requests.auth.HTTPBasicAuth(username=parts[1], password=parts[2])
113 statusCode = '-1'
114 if auth:
115 url = '{0}/a/changes/?q={1}&o=CURRENT_REVISION&o=ALL_REVISIONS'.format(remote_url, query)
116 data = requests.get(url, auth=auth)
117 statusCode = str(data.status_code)
118 if statusCode != '200':
119 #They didn't get good authorization or data, Let's try the old way
120 url = '{0}/changes/?q={1}&o=CURRENT_REVISION&o=ALL_REVISIONS'.format(remote_url, query)
121 data = requests.get(url)
122 reviews = json.loads(data.text[5:])
123 else:
124 """Given a query, fetch the change numbers via http"""
125 url = '{0}/changes/?q={1}&o=CURRENT_REVISION&o=ALL_REVISIONS'.format(remote_url, query)
126 data = urllib.request.urlopen(url).read().decode('utf-8')
127 reviews = json.loads(data[5:])
mikeNGbdc1fde2018-05-17 23:17:12 -0400128
129 for review in reviews:
130 review['number'] = review.pop('_number')
131
132 return reviews
133
134
135def fetch_query(remote_url, query):
136 """Wrapper for fetch_query_via_proto functions"""
137 if remote_url[0:3] == 'ssh':
138 return fetch_query_via_ssh(remote_url, query)
139 elif remote_url[0:4] == 'http':
140 return fetch_query_via_http(remote_url, query.replace(' ', '+'))
141 else:
142 raise Exception('Gerrit URL should be in the form http[s]://hostname/ or ssh://[user@]host[:port]')
143
144if __name__ == '__main__':
145 # Default to BlissRoms Gerrit
Eric Park3a2b0e62018-05-18 01:04:30 -0400146 default_gerrit = 'https://review.blissroms.org'
mikeNGbdc1fde2018-05-17 23:17:12 -0400147
148 parser = argparse.ArgumentParser(formatter_class=argparse.RawDescriptionHelpFormatter, description=textwrap.dedent('''\
149 repopick.py is a utility to simplify the process of cherry picking
150 patches from CyanogenMod's Gerrit instance (or any gerrit instance of your choosing)
151
152 Given a list of change numbers, repopick will cd into the project path
153 and cherry pick the latest patch available.
154
155 With the --start-branch argument, the user can specify that a branch
156 should be created before cherry picking. This is useful for
157 cherry-picking many patches into a common branch which can be easily
158 abandoned later (good for testing other's changes.)
159
160 The --abandon-first argument, when used in conjunction with the
161 --start-branch option, will cause repopick to abandon the specified
162 branch in all repos first before performing any cherry picks.'''))
163 parser.add_argument('change_number', nargs='*', help='change number to cherry pick. Use {change number}/{patchset number} to get a specific revision.')
164 parser.add_argument('-i', '--ignore-missing', action='store_true', help='do not error out if a patch applies to a missing directory')
165 parser.add_argument('-s', '--start-branch', nargs=1, help='start the specified branch before cherry picking')
Harry Youd56d2f6f2017-07-18 18:52:42 +0100166 parser.add_argument('-r', '--reset', action='store_true', help='reset to initial state (abort cherry-pick) if there is a conflict')
mikeNGbdc1fde2018-05-17 23:17:12 -0400167 parser.add_argument('-a', '--abandon-first', action='store_true', help='before cherry picking, abandon the branch specified in --start-branch')
168 parser.add_argument('-b', '--auto-branch', action='store_true', help='shortcut to "--start-branch auto --abandon-first --ignore-missing"')
169 parser.add_argument('-q', '--quiet', action='store_true', help='print as little as possible')
170 parser.add_argument('-v', '--verbose', action='store_true', help='print extra information to aid in debug')
171 parser.add_argument('-f', '--force', action='store_true', help='force cherry pick even if change is closed')
172 parser.add_argument('-p', '--pull', action='store_true', help='execute pull instead of cherry-pick')
173 parser.add_argument('-P', '--path', help='use the specified path for the change')
174 parser.add_argument('-t', '--topic', help='pick all commits from a specified topic')
175 parser.add_argument('-Q', '--query', help='pick all commits using the specified query')
176 parser.add_argument('-g', '--gerrit', default=default_gerrit, help='Gerrit Instance to use. Form proto://[user@]host[:port]')
177 parser.add_argument('-e', '--exclude', nargs=1, help='exclude a list of commit numbers separated by a ,')
Adrian DC80b7d0b2017-09-07 22:59:54 +0200178 parser.add_argument('-c', '--check-picked', type=int, default=10, help='pass the amount of commits to check for already picked changes')
mikeNGbdc1fde2018-05-17 23:17:12 -0400179 args = parser.parse_args()
180 if not args.start_branch and args.abandon_first:
181 parser.error('if --abandon-first is set, you must also give the branch name with --start-branch')
182 if args.auto_branch:
183 args.abandon_first = True
184 args.ignore_missing = True
185 if not args.start_branch:
186 args.start_branch = ['auto']
187 if args.quiet and args.verbose:
188 parser.error('--quiet and --verbose cannot be specified together')
189
190 if (1 << bool(args.change_number) << bool(args.topic) << bool(args.query)) != 2:
191 parser.error('One (and only one) of change_number, topic, and query are allowed')
192
193 # Change current directory to the top of the tree
194 if 'ANDROID_BUILD_TOP' in os.environ:
195 top = os.environ['ANDROID_BUILD_TOP']
196
197 if not is_subdir(os.getcwd(), top):
198 sys.stderr.write('ERROR: You must run this tool from within $ANDROID_BUILD_TOP!\n')
199 sys.exit(1)
200 os.chdir(os.environ['ANDROID_BUILD_TOP'])
201
202 # Sanity check that we are being run from the top level of the tree
203 if not os.path.isdir('.repo'):
204 sys.stderr.write('ERROR: No .repo directory found. Please run this from the top of your tree.\n')
205 sys.exit(1)
206
207 # If --abandon-first is given, abandon the branch before starting
208 if args.abandon_first:
209 # Determine if the branch already exists; skip the abandon if it does not
210 plist = subprocess.check_output(['repo', 'info'])
211 if not hasattr(plist, 'encode'):
212 plist = plist.decode()
213 needs_abandon = False
214 for pline in plist.splitlines():
215 matchObj = re.match(r'Local Branches.*\[(.*)\]', pline)
216 if matchObj:
217 local_branches = re.split('\s*,\s*', matchObj.group(1))
218 if any(args.start_branch[0] in s for s in local_branches):
219 needs_abandon = True
220
221 if needs_abandon:
222 # Perform the abandon only if the branch already exists
223 if not args.quiet:
224 print('Abandoning branch: %s' % args.start_branch[0])
225 subprocess.check_output(['repo', 'abandon', args.start_branch[0]])
226 if not args.quiet:
227 print('')
228
229 # Get the master manifest from repo
230 # - convert project name and revision to a path
231 project_name_to_data = {}
232 manifest = subprocess.check_output(['repo', 'manifest'])
233 xml_root = ElementTree.fromstring(manifest)
234 projects = xml_root.findall('project')
235 remotes = xml_root.findall('remote')
236 default_revision = xml_root.findall('default')[0].get('revision')
237
238 #dump project data into the a list of dicts with the following data:
239 #{project: {path, revision}}
240
241 for project in projects:
242 name = project.get('name')
243 path = project.get('path')
244 revision = project.get('revision')
245 if revision is None:
246 for remote in remotes:
247 if remote.get('name') == project.get('remote'):
248 revision = remote.get('revision')
249 if revision is None:
Simon Shields26c964c2016-09-26 17:52:03 +1000250 revision = default_revision
mikeNGbdc1fde2018-05-17 23:17:12 -0400251
252 if not name in project_name_to_data:
253 project_name_to_data[name] = {}
254 revision = revision.split('refs/heads/')[-1]
255 project_name_to_data[name][revision] = path
256
257 # get data on requested changes
258 reviews = []
259 change_numbers = []
260 if args.topic:
261 reviews = fetch_query(args.gerrit, 'topic:{0}'.format(args.topic))
nailyk9e5c5c82017-09-28 08:37:59 +0000262 change_numbers = sorted([str(r['number']) for r in reviews], key=int)
mikeNGbdc1fde2018-05-17 23:17:12 -0400263 if args.query:
264 reviews = fetch_query(args.gerrit, args.query)
nailyk9e5c5c82017-09-28 08:37:59 +0000265 change_numbers = sorted([str(r['number']) for r in reviews], key=int)
mikeNGbdc1fde2018-05-17 23:17:12 -0400266 if args.change_number:
Gabriele Me0b086a2018-04-01 17:50:58 +0200267 change_url_re = re.compile('https?://.+?/([0-9]+(?:/[0-9]+)?)/?')
mikeNGbdc1fde2018-05-17 23:17:12 -0400268 for c in args.change_number:
Gabriele Me0b086a2018-04-01 17:50:58 +0200269 change_number = change_url_re.findall(c)
270 if change_number:
271 change_numbers.extend(change_number)
272 elif '-' in c:
mikeNGbdc1fde2018-05-17 23:17:12 -0400273 templist = c.split('-')
274 for i in range(int(templist[0]), int(templist[1]) + 1):
275 change_numbers.append(str(i))
276 else:
277 change_numbers.append(c)
278 reviews = fetch_query(args.gerrit, ' OR '.join('change:{0}'.format(x.split('/')[0]) for x in change_numbers))
279
280 # make list of things to actually merge
281 mergables = []
282
283 # If --exclude is given, create the list of commits to ignore
284 exclude = []
285 if args.exclude:
286 exclude = args.exclude[0].split(',')
287
288 for change in change_numbers:
289 patchset = None
290 if '/' in change:
291 (change, patchset) = change.split('/')
292
293 if change in exclude:
294 continue
295
296 change = int(change)
LuK1337646b9bf2017-03-24 23:25:13 +0100297
Gabriele M7909ac62018-04-01 17:50:55 +0200298 if patchset:
LuK1337646b9bf2017-03-24 23:25:13 +0100299 patchset = int(patchset)
300
mikeNGbdc1fde2018-05-17 23:17:12 -0400301 review = next((x for x in reviews if x['number'] == change), None)
302 if review is None:
303 print('Change %d not found, skipping' % change)
304 continue
305
306 mergables.append({
307 'subject': review['subject'],
308 'project': review['project'],
309 'branch': review['branch'],
310 'change_id': review['change_id'],
311 'change_number': review['number'],
312 'status': review['status'],
Gabriele M283396e2018-04-01 17:50:57 +0200313 'fetch': None,
314 'patchset': review['revisions'][review['current_revision']]['_number'],
mikeNGbdc1fde2018-05-17 23:17:12 -0400315 })
Gabriele M283396e2018-04-01 17:50:57 +0200316
mikeNGbdc1fde2018-05-17 23:17:12 -0400317 mergables[-1]['fetch'] = review['revisions'][review['current_revision']]['fetch']
318 mergables[-1]['id'] = change
319 if patchset:
320 try:
LuK1337dbeb6322017-03-24 20:00:48 +0100321 mergables[-1]['fetch'] = [review['revisions'][x]['fetch'] for x in review['revisions'] if review['revisions'][x]['_number'] == patchset][0]
mikeNGbdc1fde2018-05-17 23:17:12 -0400322 mergables[-1]['id'] = '{0}/{1}'.format(change, patchset)
Gabriele M283396e2018-04-01 17:50:57 +0200323 mergables[-1]['patchset'] = patchset
mikeNGbdc1fde2018-05-17 23:17:12 -0400324 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
Dan Pasanenb2a0f6f2017-07-09 09:41:33 -0500330 if (item['status'] != 'OPEN' and item['status'] != 'NEW' and item['status'] != 'DRAFT') and not args.query:
mikeNGbdc1fde2018-05-17 23:17:12 -0400331 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
336
337 # Convert the project name to a project path
338 # - check that the project path exists
339 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
345 elif args.ignore_missing:
346 print('WARNING: Skipping {0} since there is no project directory for: {1}\n'.format(item['id'], item['project']))
347 continue
348 else:
349 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)
351
352 # If --start-branch is given, create the branch (more than once per path is okay; repo ignores gracefully)
353 if args.start_branch:
354 subprocess.check_output(['repo', 'start', args.start_branch[0], project_path])
355
356 # Determine the maximum commits to check already picked changes
Adrian DC80b7d0b2017-09-07 22:59:54 +0200357 check_picked_count = args.check_picked
mikeNGbdc1fde2018-05-17 23:17:12 -0400358 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):
Adrian DCb2b1c3b2016-12-04 12:30:26 +0100365 if subprocess.call(['git', 'cat-file', '-e', 'HEAD~{0}'.format(i)], cwd=project_path, stderr=open(os.devnull, 'wb')):
366 continue
mikeNGbdc1fde2018-05-17 23:17:12 -0400367 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
380
381 # Print out some useful info
382 if not args.quiet:
Dan Pasanen138eef22017-03-16 15:25:29 -0500383 print('--> Subject: "{0}"'.format(item['subject'].encode('utf-8')))
mikeNGbdc1fde2018-05-17 23:17:12 -0400384 print('--> Project path: {0}'.format(project_path))
Gabriele M283396e2018-04-01 17:50:57 +0200385 print('--> Change number: {0} (Patch Set {1})'.format(item['id'], item['patchset']))
mikeNGbdc1fde2018-05-17 23:17:12 -0400386
387 if 'anonymous http' in item['fetch']:
388 method = 'anonymous http'
389 else:
390 method = 'ssh'
391
392 # 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 github', item['fetch'][method]['ref']]
399 else:
400 cmd = ['git fetch github', 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:
Harry Youd56d2f6f2017-07-18 18:52:42 +0100440 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')
mikeNGbdc1fde2018-05-17 23:17:12 -0400446 sys.exit(result)
447 if not args.quiet:
448 print('')