blob: 2ce1ee7a7f79ff8220aba1edc6578ca125e47416 [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:
267 for c in args.change_number:
268 if '-' in c:
269 templist = c.split('-')
270 for i in range(int(templist[0]), int(templist[1]) + 1):
271 change_numbers.append(str(i))
272 else:
273 change_numbers.append(c)
274 reviews = fetch_query(args.gerrit, ' OR '.join('change:{0}'.format(x.split('/')[0]) for x in change_numbers))
275
276 # make list of things to actually merge
277 mergables = []
278
279 # If --exclude is given, create the list of commits to ignore
280 exclude = []
281 if args.exclude:
282 exclude = args.exclude[0].split(',')
283
284 for change in change_numbers:
285 patchset = None
286 if '/' in change:
287 (change, patchset) = change.split('/')
288
289 if change in exclude:
290 continue
291
292 change = int(change)
LuK1337646b9bf2017-03-24 23:25:13 +0100293
Gabriele M7909ac62018-04-01 17:50:55 +0200294 if patchset:
LuK1337646b9bf2017-03-24 23:25:13 +0100295 patchset = int(patchset)
296
mikeNGbdc1fde2018-05-17 23:17:12 -0400297 review = next((x for x in reviews if x['number'] == change), None)
298 if review is None:
299 print('Change %d not found, skipping' % change)
300 continue
301
302 mergables.append({
303 'subject': review['subject'],
304 'project': review['project'],
305 'branch': review['branch'],
306 'change_id': review['change_id'],
307 'change_number': review['number'],
308 'status': review['status'],
309 'fetch': None
310 })
311 mergables[-1]['fetch'] = review['revisions'][review['current_revision']]['fetch']
312 mergables[-1]['id'] = change
313 if patchset:
314 try:
LuK1337dbeb6322017-03-24 20:00:48 +0100315 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 -0400316 mergables[-1]['id'] = '{0}/{1}'.format(change, patchset)
317 except (IndexError, ValueError):
318 args.quiet or print('ERROR: The patch set {0}/{1} could not be found, using CURRENT_REVISION instead.'.format(change, patchset))
319
320 for item in mergables:
321 args.quiet or print('Applying change number {0}...'.format(item['id']))
322 # Check if change is open and exit if it's not, unless -f is specified
Dan Pasanenb2a0f6f2017-07-09 09:41:33 -0500323 if (item['status'] != 'OPEN' and item['status'] != 'NEW' and item['status'] != 'DRAFT') and not args.query:
mikeNGbdc1fde2018-05-17 23:17:12 -0400324 if args.force:
325 print('!! Force-picking a closed change !!\n')
326 else:
327 print('Change status is ' + item['status'] + '. Skipping the cherry pick.\nUse -f to force this pick.')
328 continue
329
330 # Convert the project name to a project path
331 # - check that the project path exists
332 project_path = None
333
334 if item['project'] in project_name_to_data and item['branch'] in project_name_to_data[item['project']]:
335 project_path = project_name_to_data[item['project']][item['branch']]
336 elif args.path:
337 project_path = args.path
338 elif args.ignore_missing:
339 print('WARNING: Skipping {0} since there is no project directory for: {1}\n'.format(item['id'], item['project']))
340 continue
341 else:
342 sys.stderr.write('ERROR: For {0}, could not determine the project path for project {1}\n'.format(item['id'], item['project']))
343 sys.exit(1)
344
345 # If --start-branch is given, create the branch (more than once per path is okay; repo ignores gracefully)
346 if args.start_branch:
347 subprocess.check_output(['repo', 'start', args.start_branch[0], project_path])
348
349 # Determine the maximum commits to check already picked changes
Adrian DC80b7d0b2017-09-07 22:59:54 +0200350 check_picked_count = args.check_picked
mikeNGbdc1fde2018-05-17 23:17:12 -0400351 branch_commits_count = int(subprocess.check_output(['git', 'rev-list', '--count', 'HEAD'], cwd=project_path))
352 if branch_commits_count <= check_picked_count:
353 check_picked_count = branch_commits_count - 1
354
355 # Check if change is already picked to HEAD...HEAD~check_picked_count
356 found_change = False
357 for i in range(0, check_picked_count):
Adrian DCb2b1c3b2016-12-04 12:30:26 +0100358 if subprocess.call(['git', 'cat-file', '-e', 'HEAD~{0}'.format(i)], cwd=project_path, stderr=open(os.devnull, 'wb')):
359 continue
mikeNGbdc1fde2018-05-17 23:17:12 -0400360 output = subprocess.check_output(['git', 'show', '-q', 'HEAD~{0}'.format(i)], cwd=project_path).split()
361 if 'Change-Id:' in output:
362 head_change_id = ''
363 for j,t in enumerate(reversed(output)):
364 if t == 'Change-Id:':
365 head_change_id = output[len(output) - j]
366 break
367 if head_change_id.strip() == item['change_id']:
368 print('Skipping {0} - already picked in {1} as HEAD~{2}'.format(item['id'], project_path, i))
369 found_change = True
370 break
371 if found_change:
372 continue
373
374 # Print out some useful info
375 if not args.quiet:
Dan Pasanen138eef22017-03-16 15:25:29 -0500376 print('--> Subject: "{0}"'.format(item['subject'].encode('utf-8')))
mikeNGbdc1fde2018-05-17 23:17:12 -0400377 print('--> Project path: {0}'.format(project_path))
378 print('--> Change number: {0} (Patch Set {0})'.format(item['id']))
379
380 if 'anonymous http' in item['fetch']:
381 method = 'anonymous http'
382 else:
383 method = 'ssh'
384
385 # Try fetching from GitHub first if using default gerrit
386 if args.gerrit == default_gerrit:
387 if args.verbose:
388 print('Trying to fetch the change from GitHub')
389
390 if args.pull:
391 cmd = ['git pull --no-edit github', item['fetch'][method]['ref']]
392 else:
393 cmd = ['git fetch github', item['fetch'][method]['ref']]
394 if args.quiet:
395 cmd.append('--quiet')
396 else:
397 print(cmd)
398 result = subprocess.call([' '.join(cmd)], cwd=project_path, shell=True)
399 FETCH_HEAD = '{0}/.git/FETCH_HEAD'.format(project_path)
400 if result != 0 and os.stat(FETCH_HEAD).st_size != 0:
401 print('ERROR: git command failed')
402 sys.exit(result)
403 # Check if it worked
404 if args.gerrit != default_gerrit or os.stat(FETCH_HEAD).st_size == 0:
405 # If not using the default gerrit or github failed, fetch from gerrit.
406 if args.verbose:
407 if args.gerrit == default_gerrit:
408 print('Fetching from GitHub didn\'t work, trying to fetch the change from Gerrit')
409 else:
410 print('Fetching from {0}'.format(args.gerrit))
411
412 if args.pull:
413 cmd = ['git pull --no-edit', item['fetch'][method]['url'], item['fetch'][method]['ref']]
414 else:
415 cmd = ['git fetch', item['fetch'][method]['url'], item['fetch'][method]['ref']]
416 if args.quiet:
417 cmd.append('--quiet')
418 else:
419 print(cmd)
420 result = subprocess.call([' '.join(cmd)], cwd=project_path, shell=True)
421 if result != 0:
422 print('ERROR: git command failed')
423 sys.exit(result)
424 # Perform the cherry-pick
425 if not args.pull:
426 cmd = ['git cherry-pick FETCH_HEAD']
427 if args.quiet:
428 cmd_out = open(os.devnull, 'wb')
429 else:
430 cmd_out = None
431 result = subprocess.call(cmd, cwd=project_path, shell=True, stdout=cmd_out, stderr=cmd_out)
432 if result != 0:
Harry Youd56d2f6f2017-07-18 18:52:42 +0100433 if args.reset:
434 print('ERROR: git command failed, aborting cherry-pick')
435 cmd = ['git cherry-pick --abort']
436 subprocess.call(cmd, cwd=project_path, shell=True, stdout=cmd_out, stderr=cmd_out)
437 else:
438 print('ERROR: git command failed')
mikeNGbdc1fde2018-05-17 23:17:12 -0400439 sys.exit(result)
440 if not args.quiet:
441 print('')