blob: 5f6ef6b79a736204f39a006c49ae6edb12a42167 [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:
34 # For python3
35 import urllib.error
36 import urllib.request
37except ImportError:
38 # For python2
39 import imp
40 import urllib2
41 urllib = imp.new_module('urllib')
42 urllib.error = urllib2
43 urllib.request = urllib2
44
45
46# Verifies whether pathA is a subdirectory (or the same) as pathB
47def is_subdir(a, b):
48 a = os.path.realpath(a) + '/'
49 b = os.path.realpath(b) + '/'
50 return b == a[:len(b)]
51
52
53def fetch_query_via_ssh(remote_url, query):
54 """Given a remote_url and a query, return the list of changes that fit it
55 This function is slightly messy - the ssh api does not return data in the same structure as the HTTP REST API
56 We have to get the data, then transform it to match what we're expecting from the HTTP RESET API"""
57 if remote_url.count(':') == 2:
58 (uri, userhost, port) = remote_url.split(':')
59 userhost = userhost[2:]
60 elif remote_url.count(':') == 1:
61 (uri, userhost) = remote_url.split(':')
62 userhost = userhost[2:]
63 port = 29418
64 else:
65 raise Exception('Malformed URI: Expecting ssh://[user@]host[:port]')
66
67
68 out = subprocess.check_output(['ssh', '-x', '-p{0}'.format(port), userhost, 'gerrit', 'query', '--format=JSON --patch-sets --current-patch-set', query])
69 if not hasattr(out, 'encode'):
70 out = out.decode()
71 reviews = []
72 for line in out.split('\n'):
73 try:
74 data = json.loads(line)
75 # make our data look like the http rest api data
76 review = {
77 'branch': data['branch'],
78 'change_id': data['id'],
79 'current_revision': data['currentPatchSet']['revision'],
80 'number': int(data['number']),
81 'revisions': {patch_set['revision']: {
82 'number': int(patch_set['number']),
83 'fetch': {
84 'ssh': {
85 'ref': patch_set['ref'],
86 'url': 'ssh://{0}:{1}/{2}'.format(userhost, port, data['project'])
87 }
88 }
89 } for patch_set in data['patchSets']},
90 'subject': data['subject'],
91 'project': data['project'],
92 'status': data['status']
93 }
94 reviews.append(review)
95 except:
96 pass
97 args.quiet or print('Found {0} reviews'.format(len(reviews)))
98 return reviews
99
100
101def fetch_query_via_http(remote_url, query):
102
103 """Given a query, fetch the change numbers via http"""
104 url = '{0}/changes/?q={1}&o=CURRENT_REVISION&o=ALL_REVISIONS'.format(remote_url, query)
105 data = urllib.request.urlopen(url).read().decode('utf-8')
106 reviews = json.loads(data[5:])
107
108 for review in reviews:
109 review['number'] = review.pop('_number')
110
111 return reviews
112
113
114def fetch_query(remote_url, query):
115 """Wrapper for fetch_query_via_proto functions"""
116 if remote_url[0:3] == 'ssh':
117 return fetch_query_via_ssh(remote_url, query)
118 elif remote_url[0:4] == 'http':
119 return fetch_query_via_http(remote_url, query.replace(' ', '+'))
120 else:
121 raise Exception('Gerrit URL should be in the form http[s]://hostname/ or ssh://[user@]host[:port]')
122
123if __name__ == '__main__':
124 # Default to BlissRoms Gerrit
125 default_gerrit = 'http://review.blissroms.org'
126
127 parser = argparse.ArgumentParser(formatter_class=argparse.RawDescriptionHelpFormatter, description=textwrap.dedent('''\
128 repopick.py is a utility to simplify the process of cherry picking
129 patches from CyanogenMod's Gerrit instance (or any gerrit instance of your choosing)
130
131 Given a list of change numbers, repopick will cd into the project path
132 and cherry pick the latest patch available.
133
134 With the --start-branch argument, the user can specify that a branch
135 should be created before cherry picking. This is useful for
136 cherry-picking many patches into a common branch which can be easily
137 abandoned later (good for testing other's changes.)
138
139 The --abandon-first argument, when used in conjunction with the
140 --start-branch option, will cause repopick to abandon the specified
141 branch in all repos first before performing any cherry picks.'''))
142 parser.add_argument('change_number', nargs='*', help='change number to cherry pick. Use {change number}/{patchset number} to get a specific revision.')
143 parser.add_argument('-i', '--ignore-missing', action='store_true', help='do not error out if a patch applies to a missing directory')
144 parser.add_argument('-s', '--start-branch', nargs=1, help='start the specified branch before cherry picking')
145 parser.add_argument('-a', '--abandon-first', action='store_true', help='before cherry picking, abandon the branch specified in --start-branch')
146 parser.add_argument('-b', '--auto-branch', action='store_true', help='shortcut to "--start-branch auto --abandon-first --ignore-missing"')
147 parser.add_argument('-q', '--quiet', action='store_true', help='print as little as possible')
148 parser.add_argument('-v', '--verbose', action='store_true', help='print extra information to aid in debug')
149 parser.add_argument('-f', '--force', action='store_true', help='force cherry pick even if change is closed')
150 parser.add_argument('-p', '--pull', action='store_true', help='execute pull instead of cherry-pick')
151 parser.add_argument('-P', '--path', help='use the specified path for the change')
152 parser.add_argument('-t', '--topic', help='pick all commits from a specified topic')
153 parser.add_argument('-Q', '--query', help='pick all commits using the specified query')
154 parser.add_argument('-g', '--gerrit', default=default_gerrit, help='Gerrit Instance to use. Form proto://[user@]host[:port]')
155 parser.add_argument('-e', '--exclude', nargs=1, help='exclude a list of commit numbers separated by a ,')
156 args = parser.parse_args()
157 if not args.start_branch and args.abandon_first:
158 parser.error('if --abandon-first is set, you must also give the branch name with --start-branch')
159 if args.auto_branch:
160 args.abandon_first = True
161 args.ignore_missing = True
162 if not args.start_branch:
163 args.start_branch = ['auto']
164 if args.quiet and args.verbose:
165 parser.error('--quiet and --verbose cannot be specified together')
166
167 if (1 << bool(args.change_number) << bool(args.topic) << bool(args.query)) != 2:
168 parser.error('One (and only one) of change_number, topic, and query are allowed')
169
170 # Change current directory to the top of the tree
171 if 'ANDROID_BUILD_TOP' in os.environ:
172 top = os.environ['ANDROID_BUILD_TOP']
173
174 if not is_subdir(os.getcwd(), top):
175 sys.stderr.write('ERROR: You must run this tool from within $ANDROID_BUILD_TOP!\n')
176 sys.exit(1)
177 os.chdir(os.environ['ANDROID_BUILD_TOP'])
178
179 # Sanity check that we are being run from the top level of the tree
180 if not os.path.isdir('.repo'):
181 sys.stderr.write('ERROR: No .repo directory found. Please run this from the top of your tree.\n')
182 sys.exit(1)
183
184 # If --abandon-first is given, abandon the branch before starting
185 if args.abandon_first:
186 # Determine if the branch already exists; skip the abandon if it does not
187 plist = subprocess.check_output(['repo', 'info'])
188 if not hasattr(plist, 'encode'):
189 plist = plist.decode()
190 needs_abandon = False
191 for pline in plist.splitlines():
192 matchObj = re.match(r'Local Branches.*\[(.*)\]', pline)
193 if matchObj:
194 local_branches = re.split('\s*,\s*', matchObj.group(1))
195 if any(args.start_branch[0] in s for s in local_branches):
196 needs_abandon = True
197
198 if needs_abandon:
199 # Perform the abandon only if the branch already exists
200 if not args.quiet:
201 print('Abandoning branch: %s' % args.start_branch[0])
202 subprocess.check_output(['repo', 'abandon', args.start_branch[0]])
203 if not args.quiet:
204 print('')
205
206 # Get the master manifest from repo
207 # - convert project name and revision to a path
208 project_name_to_data = {}
209 manifest = subprocess.check_output(['repo', 'manifest'])
210 xml_root = ElementTree.fromstring(manifest)
211 projects = xml_root.findall('project')
212 remotes = xml_root.findall('remote')
213 default_revision = xml_root.findall('default')[0].get('revision')
214
215 #dump project data into the a list of dicts with the following data:
216 #{project: {path, revision}}
217
218 for project in projects:
219 name = project.get('name')
220 path = project.get('path')
221 revision = project.get('revision')
222 if revision is None:
223 for remote in remotes:
224 if remote.get('name') == project.get('remote'):
225 revision = remote.get('revision')
226 if revision is None:
Simon Shields26c964c2016-09-26 17:52:03 +1000227 revision = default_revision
mikeNGbdc1fde2018-05-17 23:17:12 -0400228
229 if not name in project_name_to_data:
230 project_name_to_data[name] = {}
231 revision = revision.split('refs/heads/')[-1]
232 project_name_to_data[name][revision] = path
233
234 # get data on requested changes
235 reviews = []
236 change_numbers = []
237 if args.topic:
238 reviews = fetch_query(args.gerrit, 'topic:{0}'.format(args.topic))
239 change_numbers = sorted([str(r['number']) for r in reviews])
240 if args.query:
241 reviews = fetch_query(args.gerrit, args.query)
242 change_numbers = sorted([str(r['number']) for r in reviews])
243 if args.change_number:
244 for c in args.change_number:
245 if '-' in c:
246 templist = c.split('-')
247 for i in range(int(templist[0]), int(templist[1]) + 1):
248 change_numbers.append(str(i))
249 else:
250 change_numbers.append(c)
251 reviews = fetch_query(args.gerrit, ' OR '.join('change:{0}'.format(x.split('/')[0]) for x in change_numbers))
252
253 # make list of things to actually merge
254 mergables = []
255
256 # If --exclude is given, create the list of commits to ignore
257 exclude = []
258 if args.exclude:
259 exclude = args.exclude[0].split(',')
260
261 for change in change_numbers:
262 patchset = None
263 if '/' in change:
264 (change, patchset) = change.split('/')
265
266 if change in exclude:
267 continue
268
269 change = int(change)
LuK1337646b9bf2017-03-24 23:25:13 +0100270
271 if patchset is not None:
272 patchset = int(patchset)
273
mikeNGbdc1fde2018-05-17 23:17:12 -0400274 review = next((x for x in reviews if x['number'] == change), None)
275 if review is None:
276 print('Change %d not found, skipping' % change)
277 continue
278
279 mergables.append({
280 'subject': review['subject'],
281 'project': review['project'],
282 'branch': review['branch'],
283 'change_id': review['change_id'],
284 'change_number': review['number'],
285 'status': review['status'],
286 'fetch': None
287 })
288 mergables[-1]['fetch'] = review['revisions'][review['current_revision']]['fetch']
289 mergables[-1]['id'] = change
290 if patchset:
291 try:
LuK1337dbeb6322017-03-24 20:00:48 +0100292 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 -0400293 mergables[-1]['id'] = '{0}/{1}'.format(change, patchset)
294 except (IndexError, ValueError):
295 args.quiet or print('ERROR: The patch set {0}/{1} could not be found, using CURRENT_REVISION instead.'.format(change, patchset))
296
297 for item in mergables:
298 args.quiet or print('Applying change number {0}...'.format(item['id']))
299 # Check if change is open and exit if it's not, unless -f is specified
300 if (item['status'] != 'OPEN' and item['status'] != 'NEW') and not args.query:
301 if args.force:
302 print('!! Force-picking a closed change !!\n')
303 else:
304 print('Change status is ' + item['status'] + '. Skipping the cherry pick.\nUse -f to force this pick.')
305 continue
306
307 # Convert the project name to a project path
308 # - check that the project path exists
309 project_path = None
310
311 if item['project'] in project_name_to_data and item['branch'] in project_name_to_data[item['project']]:
312 project_path = project_name_to_data[item['project']][item['branch']]
313 elif args.path:
314 project_path = args.path
315 elif args.ignore_missing:
316 print('WARNING: Skipping {0} since there is no project directory for: {1}\n'.format(item['id'], item['project']))
317 continue
318 else:
319 sys.stderr.write('ERROR: For {0}, could not determine the project path for project {1}\n'.format(item['id'], item['project']))
320 sys.exit(1)
321
322 # If --start-branch is given, create the branch (more than once per path is okay; repo ignores gracefully)
323 if args.start_branch:
324 subprocess.check_output(['repo', 'start', args.start_branch[0], project_path])
325
326 # Determine the maximum commits to check already picked changes
327 check_picked_count = 10
328 branch_commits_count = int(subprocess.check_output(['git', 'rev-list', '--count', 'HEAD'], cwd=project_path))
329 if branch_commits_count <= check_picked_count:
330 check_picked_count = branch_commits_count - 1
331
332 # Check if change is already picked to HEAD...HEAD~check_picked_count
333 found_change = False
334 for i in range(0, check_picked_count):
Adrian DCb2b1c3b2016-12-04 12:30:26 +0100335 if subprocess.call(['git', 'cat-file', '-e', 'HEAD~{0}'.format(i)], cwd=project_path, stderr=open(os.devnull, 'wb')):
336 continue
mikeNGbdc1fde2018-05-17 23:17:12 -0400337 output = subprocess.check_output(['git', 'show', '-q', 'HEAD~{0}'.format(i)], cwd=project_path).split()
338 if 'Change-Id:' in output:
339 head_change_id = ''
340 for j,t in enumerate(reversed(output)):
341 if t == 'Change-Id:':
342 head_change_id = output[len(output) - j]
343 break
344 if head_change_id.strip() == item['change_id']:
345 print('Skipping {0} - already picked in {1} as HEAD~{2}'.format(item['id'], project_path, i))
346 found_change = True
347 break
348 if found_change:
349 continue
350
351 # Print out some useful info
352 if not args.quiet:
Dan Pasanen138eef22017-03-16 15:25:29 -0500353 print('--> Subject: "{0}"'.format(item['subject'].encode('utf-8')))
mikeNGbdc1fde2018-05-17 23:17:12 -0400354 print('--> Project path: {0}'.format(project_path))
355 print('--> Change number: {0} (Patch Set {0})'.format(item['id']))
356
357 if 'anonymous http' in item['fetch']:
358 method = 'anonymous http'
359 else:
360 method = 'ssh'
361
362 # Try fetching from GitHub first if using default gerrit
363 if args.gerrit == default_gerrit:
364 if args.verbose:
365 print('Trying to fetch the change from GitHub')
366
367 if args.pull:
368 cmd = ['git pull --no-edit github', item['fetch'][method]['ref']]
369 else:
370 cmd = ['git fetch github', item['fetch'][method]['ref']]
371 if args.quiet:
372 cmd.append('--quiet')
373 else:
374 print(cmd)
375 result = subprocess.call([' '.join(cmd)], cwd=project_path, shell=True)
376 FETCH_HEAD = '{0}/.git/FETCH_HEAD'.format(project_path)
377 if result != 0 and os.stat(FETCH_HEAD).st_size != 0:
378 print('ERROR: git command failed')
379 sys.exit(result)
380 # Check if it worked
381 if args.gerrit != default_gerrit or os.stat(FETCH_HEAD).st_size == 0:
382 # If not using the default gerrit or github failed, fetch from gerrit.
383 if args.verbose:
384 if args.gerrit == default_gerrit:
385 print('Fetching from GitHub didn\'t work, trying to fetch the change from Gerrit')
386 else:
387 print('Fetching from {0}'.format(args.gerrit))
388
389 if args.pull:
390 cmd = ['git pull --no-edit', item['fetch'][method]['url'], item['fetch'][method]['ref']]
391 else:
392 cmd = ['git fetch', item['fetch'][method]['url'], item['fetch'][method]['ref']]
393 if args.quiet:
394 cmd.append('--quiet')
395 else:
396 print(cmd)
397 result = subprocess.call([' '.join(cmd)], cwd=project_path, shell=True)
398 if result != 0:
399 print('ERROR: git command failed')
400 sys.exit(result)
401 # Perform the cherry-pick
402 if not args.pull:
403 cmd = ['git cherry-pick FETCH_HEAD']
404 if args.quiet:
405 cmd_out = open(os.devnull, 'wb')
406 else:
407 cmd_out = None
408 result = subprocess.call(cmd, cwd=project_path, shell=True, stdout=cmd_out, stderr=cmd_out)
409 if result != 0:
410 print('ERROR: git command failed')
411 sys.exit(result)
412 if not args.quiet:
413 print('')