blob: 9c660f51502d5c6443524e7a8dbf52cbc21af27e [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')
Harry Youd56d2f6f2017-07-18 18:52:42 +0100145 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 -0400146 parser.add_argument('-a', '--abandon-first', action='store_true', help='before cherry picking, abandon the branch specified in --start-branch')
147 parser.add_argument('-b', '--auto-branch', action='store_true', help='shortcut to "--start-branch auto --abandon-first --ignore-missing"')
148 parser.add_argument('-q', '--quiet', action='store_true', help='print as little as possible')
149 parser.add_argument('-v', '--verbose', action='store_true', help='print extra information to aid in debug')
150 parser.add_argument('-f', '--force', action='store_true', help='force cherry pick even if change is closed')
151 parser.add_argument('-p', '--pull', action='store_true', help='execute pull instead of cherry-pick')
152 parser.add_argument('-P', '--path', help='use the specified path for the change')
153 parser.add_argument('-t', '--topic', help='pick all commits from a specified topic')
154 parser.add_argument('-Q', '--query', help='pick all commits using the specified query')
155 parser.add_argument('-g', '--gerrit', default=default_gerrit, help='Gerrit Instance to use. Form proto://[user@]host[:port]')
156 parser.add_argument('-e', '--exclude', nargs=1, help='exclude a list of commit numbers separated by a ,')
157 args = parser.parse_args()
158 if not args.start_branch and args.abandon_first:
159 parser.error('if --abandon-first is set, you must also give the branch name with --start-branch')
160 if args.auto_branch:
161 args.abandon_first = True
162 args.ignore_missing = True
163 if not args.start_branch:
164 args.start_branch = ['auto']
165 if args.quiet and args.verbose:
166 parser.error('--quiet and --verbose cannot be specified together')
167
168 if (1 << bool(args.change_number) << bool(args.topic) << bool(args.query)) != 2:
169 parser.error('One (and only one) of change_number, topic, and query are allowed')
170
171 # Change current directory to the top of the tree
172 if 'ANDROID_BUILD_TOP' in os.environ:
173 top = os.environ['ANDROID_BUILD_TOP']
174
175 if not is_subdir(os.getcwd(), top):
176 sys.stderr.write('ERROR: You must run this tool from within $ANDROID_BUILD_TOP!\n')
177 sys.exit(1)
178 os.chdir(os.environ['ANDROID_BUILD_TOP'])
179
180 # Sanity check that we are being run from the top level of the tree
181 if not os.path.isdir('.repo'):
182 sys.stderr.write('ERROR: No .repo directory found. Please run this from the top of your tree.\n')
183 sys.exit(1)
184
185 # If --abandon-first is given, abandon the branch before starting
186 if args.abandon_first:
187 # Determine if the branch already exists; skip the abandon if it does not
188 plist = subprocess.check_output(['repo', 'info'])
189 if not hasattr(plist, 'encode'):
190 plist = plist.decode()
191 needs_abandon = False
192 for pline in plist.splitlines():
193 matchObj = re.match(r'Local Branches.*\[(.*)\]', pline)
194 if matchObj:
195 local_branches = re.split('\s*,\s*', matchObj.group(1))
196 if any(args.start_branch[0] in s for s in local_branches):
197 needs_abandon = True
198
199 if needs_abandon:
200 # Perform the abandon only if the branch already exists
201 if not args.quiet:
202 print('Abandoning branch: %s' % args.start_branch[0])
203 subprocess.check_output(['repo', 'abandon', args.start_branch[0]])
204 if not args.quiet:
205 print('')
206
207 # Get the master manifest from repo
208 # - convert project name and revision to a path
209 project_name_to_data = {}
210 manifest = subprocess.check_output(['repo', 'manifest'])
211 xml_root = ElementTree.fromstring(manifest)
212 projects = xml_root.findall('project')
213 remotes = xml_root.findall('remote')
214 default_revision = xml_root.findall('default')[0].get('revision')
215
216 #dump project data into the a list of dicts with the following data:
217 #{project: {path, revision}}
218
219 for project in projects:
220 name = project.get('name')
221 path = project.get('path')
222 revision = project.get('revision')
223 if revision is None:
224 for remote in remotes:
225 if remote.get('name') == project.get('remote'):
226 revision = remote.get('revision')
227 if revision is None:
Simon Shields26c964c2016-09-26 17:52:03 +1000228 revision = default_revision
mikeNGbdc1fde2018-05-17 23:17:12 -0400229
230 if not name in project_name_to_data:
231 project_name_to_data[name] = {}
232 revision = revision.split('refs/heads/')[-1]
233 project_name_to_data[name][revision] = path
234
235 # get data on requested changes
236 reviews = []
237 change_numbers = []
238 if args.topic:
239 reviews = fetch_query(args.gerrit, 'topic:{0}'.format(args.topic))
240 change_numbers = sorted([str(r['number']) for r in reviews])
241 if args.query:
242 reviews = fetch_query(args.gerrit, args.query)
243 change_numbers = sorted([str(r['number']) for r in reviews])
244 if args.change_number:
245 for c in args.change_number:
246 if '-' in c:
247 templist = c.split('-')
248 for i in range(int(templist[0]), int(templist[1]) + 1):
249 change_numbers.append(str(i))
250 else:
251 change_numbers.append(c)
252 reviews = fetch_query(args.gerrit, ' OR '.join('change:{0}'.format(x.split('/')[0]) for x in change_numbers))
253
254 # make list of things to actually merge
255 mergables = []
256
257 # If --exclude is given, create the list of commits to ignore
258 exclude = []
259 if args.exclude:
260 exclude = args.exclude[0].split(',')
261
262 for change in change_numbers:
263 patchset = None
264 if '/' in change:
265 (change, patchset) = change.split('/')
266
267 if change in exclude:
268 continue
269
270 change = int(change)
LuK1337646b9bf2017-03-24 23:25:13 +0100271
272 if patchset is not None:
273 patchset = int(patchset)
274
mikeNGbdc1fde2018-05-17 23:17:12 -0400275 review = next((x for x in reviews if x['number'] == change), None)
276 if review is None:
277 print('Change %d not found, skipping' % change)
278 continue
279
280 mergables.append({
281 'subject': review['subject'],
282 'project': review['project'],
283 'branch': review['branch'],
284 'change_id': review['change_id'],
285 'change_number': review['number'],
286 'status': review['status'],
287 'fetch': None
288 })
289 mergables[-1]['fetch'] = review['revisions'][review['current_revision']]['fetch']
290 mergables[-1]['id'] = change
291 if patchset:
292 try:
LuK1337dbeb6322017-03-24 20:00:48 +0100293 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 -0400294 mergables[-1]['id'] = '{0}/{1}'.format(change, patchset)
295 except (IndexError, ValueError):
296 args.quiet or print('ERROR: The patch set {0}/{1} could not be found, using CURRENT_REVISION instead.'.format(change, patchset))
297
298 for item in mergables:
299 args.quiet or print('Applying change number {0}...'.format(item['id']))
300 # Check if change is open and exit if it's not, unless -f is specified
Dan Pasanenb2a0f6f2017-07-09 09:41:33 -0500301 if (item['status'] != 'OPEN' and item['status'] != 'NEW' and item['status'] != 'DRAFT') and not args.query:
mikeNGbdc1fde2018-05-17 23:17:12 -0400302 if args.force:
303 print('!! Force-picking a closed change !!\n')
304 else:
305 print('Change status is ' + item['status'] + '. Skipping the cherry pick.\nUse -f to force this pick.')
306 continue
307
308 # Convert the project name to a project path
309 # - check that the project path exists
310 project_path = None
311
312 if item['project'] in project_name_to_data and item['branch'] in project_name_to_data[item['project']]:
313 project_path = project_name_to_data[item['project']][item['branch']]
314 elif args.path:
315 project_path = args.path
316 elif args.ignore_missing:
317 print('WARNING: Skipping {0} since there is no project directory for: {1}\n'.format(item['id'], item['project']))
318 continue
319 else:
320 sys.stderr.write('ERROR: For {0}, could not determine the project path for project {1}\n'.format(item['id'], item['project']))
321 sys.exit(1)
322
323 # If --start-branch is given, create the branch (more than once per path is okay; repo ignores gracefully)
324 if args.start_branch:
325 subprocess.check_output(['repo', 'start', args.start_branch[0], project_path])
326
327 # Determine the maximum commits to check already picked changes
328 check_picked_count = 10
329 branch_commits_count = int(subprocess.check_output(['git', 'rev-list', '--count', 'HEAD'], cwd=project_path))
330 if branch_commits_count <= check_picked_count:
331 check_picked_count = branch_commits_count - 1
332
333 # Check if change is already picked to HEAD...HEAD~check_picked_count
334 found_change = False
335 for i in range(0, check_picked_count):
Adrian DCb2b1c3b2016-12-04 12:30:26 +0100336 if subprocess.call(['git', 'cat-file', '-e', 'HEAD~{0}'.format(i)], cwd=project_path, stderr=open(os.devnull, 'wb')):
337 continue
mikeNGbdc1fde2018-05-17 23:17:12 -0400338 output = subprocess.check_output(['git', 'show', '-q', 'HEAD~{0}'.format(i)], cwd=project_path).split()
339 if 'Change-Id:' in output:
340 head_change_id = ''
341 for j,t in enumerate(reversed(output)):
342 if t == 'Change-Id:':
343 head_change_id = output[len(output) - j]
344 break
345 if head_change_id.strip() == item['change_id']:
346 print('Skipping {0} - already picked in {1} as HEAD~{2}'.format(item['id'], project_path, i))
347 found_change = True
348 break
349 if found_change:
350 continue
351
352 # Print out some useful info
353 if not args.quiet:
Dan Pasanen138eef22017-03-16 15:25:29 -0500354 print('--> Subject: "{0}"'.format(item['subject'].encode('utf-8')))
mikeNGbdc1fde2018-05-17 23:17:12 -0400355 print('--> Project path: {0}'.format(project_path))
356 print('--> Change number: {0} (Patch Set {0})'.format(item['id']))
357
358 if 'anonymous http' in item['fetch']:
359 method = 'anonymous http'
360 else:
361 method = 'ssh'
362
363 # Try fetching from GitHub first if using default gerrit
364 if args.gerrit == default_gerrit:
365 if args.verbose:
366 print('Trying to fetch the change from GitHub')
367
368 if args.pull:
369 cmd = ['git pull --no-edit github', item['fetch'][method]['ref']]
370 else:
371 cmd = ['git fetch github', item['fetch'][method]['ref']]
372 if args.quiet:
373 cmd.append('--quiet')
374 else:
375 print(cmd)
376 result = subprocess.call([' '.join(cmd)], cwd=project_path, shell=True)
377 FETCH_HEAD = '{0}/.git/FETCH_HEAD'.format(project_path)
378 if result != 0 and os.stat(FETCH_HEAD).st_size != 0:
379 print('ERROR: git command failed')
380 sys.exit(result)
381 # Check if it worked
382 if args.gerrit != default_gerrit or os.stat(FETCH_HEAD).st_size == 0:
383 # If not using the default gerrit or github failed, fetch from gerrit.
384 if args.verbose:
385 if args.gerrit == default_gerrit:
386 print('Fetching from GitHub didn\'t work, trying to fetch the change from Gerrit')
387 else:
388 print('Fetching from {0}'.format(args.gerrit))
389
390 if args.pull:
391 cmd = ['git pull --no-edit', item['fetch'][method]['url'], item['fetch'][method]['ref']]
392 else:
393 cmd = ['git fetch', item['fetch'][method]['url'], 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 if result != 0:
400 print('ERROR: git command failed')
401 sys.exit(result)
402 # Perform the cherry-pick
403 if not args.pull:
404 cmd = ['git cherry-pick FETCH_HEAD']
405 if args.quiet:
406 cmd_out = open(os.devnull, 'wb')
407 else:
408 cmd_out = None
409 result = subprocess.call(cmd, cwd=project_path, shell=True, stdout=cmd_out, stderr=cmd_out)
410 if result != 0:
Harry Youd56d2f6f2017-07-18 18:52:42 +0100411 if args.reset:
412 print('ERROR: git command failed, aborting cherry-pick')
413 cmd = ['git cherry-pick --abort']
414 subprocess.call(cmd, cwd=project_path, shell=True, stdout=cmd_out, stderr=cmd_out)
415 else:
416 print('ERROR: git command failed')
mikeNGbdc1fde2018-05-17 23:17:12 -0400417 sys.exit(result)
418 if not args.quiet:
419 print('')