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