blob: abc70a9242121c6f38caee38fe2a9d0b7361e334 [file] [log] [blame]
Chirayu Desai4a319b82013-06-05 20:14:33 +05301#!/usr/bin/env python
2#
Tom Powellc8580302015-08-04 15:37:12 -07003# Copyright (C) 2013-15 The CyanogenMod Project
Chirayu Desai4a319b82013-06-05 20:14:33 +05304#
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
Tom Powell8b3a67d2015-09-25 14:23:26 -070031from xml.etree import ElementTree
Chirayu Desai4a319b82013-06-05 20:14:33 +053032
33try:
Tom Powellc8580302015-08-04 15:37:12 -070034 # For python3
35 import urllib.error
36 import urllib.request
Chirayu Desai4a319b82013-06-05 20:14:33 +053037except ImportError:
Tom Powellc8580302015-08-04 15:37:12 -070038 # For python2
39 import imp
40 import urllib2
41 urllib = imp.new_module('urllib')
42 urllib.error = urllib2
43 urllib.request = urllib2
Chirayu Desai4a319b82013-06-05 20:14:33 +053044
Chirayu Desai4a319b82013-06-05 20:14:33 +053045
46# Verifies whether pathA is a subdirectory (or the same) as pathB
Tom Powellc8580302015-08-04 15:37:12 -070047def is_subdir(a, b):
48 a = os.path.realpath(a) + '/'
49 b = os.path.realpath(b) + '/'
50 return b == a[:len(b)]
Chirayu Desai4a319b82013-06-05 20:14:33 +053051
Tom Powellc8580302015-08-04 15:37:12 -070052
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(':')
Tom Powellff0032a2015-09-01 16:52:40 -070059 userhost = userhost[2:]
Tom Powellc8580302015-08-04 15:37:12 -070060 elif remote_url.count(':') == 1:
61 (uri, userhost) = remote_url.split(':')
Tom Powellff0032a2015-09-01 16:52:40 -070062 userhost = userhost[2:]
Tom Powellc8580302015-08-04 15:37:12 -070063 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
70 reviews = []
71 for line in out.split('\n'):
72 try:
73 data = json.loads(line)
74 # make our data look like the http rest api data
75 review = {
76 'branch': data['branch'],
77 'change_id': data['id'],
78 'current_revision': data['currentPatchSet']['revision'],
79 'number': int(data['number']),
80 'revisions': {patch_set['revision']: {
81 'number': int(patch_set['number']),
82 'fetch': {
83 'ssh': {
84 'ref': patch_set['ref'],
Anthony King5d01e802015-10-31 14:56:51 -040085 'url': 'ssh://{0}:{1}/{2}'.format(userhost, port, data['project'])
Tom Powellc8580302015-08-04 15:37:12 -070086 }
87 }
88 } for patch_set in data['patchSets']},
89 'subject': data['subject'],
90 'project': data['project'],
91 'status': data['status']
92 }
93 reviews.append(review)
Tom Powellff0032a2015-09-01 16:52:40 -070094 except:
Tom Powellc8580302015-08-04 15:37:12 -070095 pass
Brint E. Kriebel9c1a3c32015-09-09 22:29:28 -070096 args.quiet or print('Found {0} reviews'.format(len(reviews)))
Tom Powellc8580302015-08-04 15:37:12 -070097 return reviews
98
99
100def fetch_query_via_http(remote_url, query):
101
102 """Given a query, fetch the change numbers via http"""
103 url = '{0}/changes/?q={1}&o=CURRENT_REVISION&o=ALL_REVISIONS'.format(remote_url, query)
104 data = urllib.request.urlopen(url).read().decode('utf-8')
105 reviews = json.loads(data[5:])
106
107 for review in reviews:
Anthony King5d01e802015-10-31 14:56:51 -0400108 review['number'] = review.pop('_number')
Tom Powellc8580302015-08-04 15:37:12 -0700109
110 return reviews
111
112
113def fetch_query(remote_url, query):
114 """Wrapper for fetch_query_via_proto functions"""
Tom Powellff0032a2015-09-01 16:52:40 -0700115 if remote_url[0:3] == 'ssh':
Tom Powellc8580302015-08-04 15:37:12 -0700116 return fetch_query_via_ssh(remote_url, query)
117 elif remote_url[0:4] == 'http':
118 return fetch_query_via_http(remote_url, query.replace(' ', '+'))
119 else:
120 raise Exception('Gerrit URL should be in the form http[s]://hostname/ or ssh://[user@]host[:port]')
121
122if __name__ == '__main__':
Brint E. Kriebel9c1a3c32015-09-09 22:29:28 -0700123 # Default to CyanogenMod Gerrit
124 default_gerrit = 'http://review.cyanogenmod.org'
125
Tom Powellc8580302015-08-04 15:37:12 -0700126 parser = argparse.ArgumentParser(formatter_class=argparse.RawDescriptionHelpFormatter, description=textwrap.dedent('''\
127 repopick.py is a utility to simplify the process of cherry picking
128 patches from CyanogenMod's Gerrit instance (or any gerrit instance of your choosing)
129
130 Given a list of change numbers, repopick will cd into the project path
131 and cherry pick the latest patch available.
132
133 With the --start-branch argument, the user can specify that a branch
134 should be created before cherry picking. This is useful for
135 cherry-picking many patches into a common branch which can be easily
136 abandoned later (good for testing other's changes.)
137
138 The --abandon-first argument, when used in conjunction with the
139 --start-branch option, will cause repopick to abandon the specified
140 branch in all repos first before performing any cherry picks.'''))
141 parser.add_argument('change_number', nargs='*', help='change number to cherry pick. Use {change number}/{patchset number} to get a specific revision.')
142 parser.add_argument('-i', '--ignore-missing', action='store_true', help='do not error out if a patch applies to a missing directory')
143 parser.add_argument('-s', '--start-branch', nargs=1, help='start the specified branch before cherry picking')
144 parser.add_argument('-a', '--abandon-first', action='store_true', help='before cherry picking, abandon the branch specified in --start-branch')
145 parser.add_argument('-b', '--auto-branch', action='store_true', help='shortcut to "--start-branch auto --abandon-first --ignore-missing"')
146 parser.add_argument('-q', '--quiet', action='store_true', help='print as little as possible')
147 parser.add_argument('-v', '--verbose', action='store_true', help='print extra information to aid in debug')
jrior001fd11d072015-08-21 17:23:25 -0400148 parser.add_argument('-f', '--force', action='store_true', help='force cherry pick even if change is closed')
Tom Powellc8580302015-08-04 15:37:12 -0700149 parser.add_argument('-p', '--pull', action='store_true', help='execute pull instead of cherry-pick')
Brint E. Kriebel9c1a3c32015-09-09 22:29:28 -0700150 parser.add_argument('-P', '--path', help='use the specified path for the change')
Tom Powellc8580302015-08-04 15:37:12 -0700151 parser.add_argument('-t', '--topic', help='pick all commits from a specified topic')
152 parser.add_argument('-Q', '--query', help='pick all commits using the specified query')
Brint E. Kriebel9c1a3c32015-09-09 22:29:28 -0700153 parser.add_argument('-g', '--gerrit', default=default_gerrit, help='Gerrit Instance to use. Form proto://[user@]host[:port]')
AdrianDCf0b8bff2015-12-08 20:19:41 +0100154 parser.add_argument('-e', '--exclude', nargs=1, help='exclude a list of commit numbers separated by a ,')
Tom Powellc8580302015-08-04 15:37:12 -0700155 args = parser.parse_args()
Tom Powellc8580302015-08-04 15:37:12 -0700156 if not args.start_branch and args.abandon_first:
157 parser.error('if --abandon-first is set, you must also give the branch name with --start-branch')
158 if args.auto_branch:
159 args.abandon_first = True
160 args.ignore_missing = True
161 if not args.start_branch:
162 args.start_branch = ['auto']
163 if args.quiet and args.verbose:
164 parser.error('--quiet and --verbose cannot be specified together')
165
166 if (1 << bool(args.change_number) << bool(args.topic) << bool(args.query)) != 2:
167 parser.error('One (and only one) of change_number, topic, and query are allowed')
168
169 # Change current directory to the top of the tree
170 if 'ANDROID_BUILD_TOP' in os.environ:
171 top = os.environ['ANDROID_BUILD_TOP']
172
173 if not is_subdir(os.getcwd(), top):
174 sys.stderr.write('ERROR: You must run this tool from within $ANDROID_BUILD_TOP!\n')
175 sys.exit(1)
176 os.chdir(os.environ['ANDROID_BUILD_TOP'])
177
178 # Sanity check that we are being run from the top level of the tree
179 if not os.path.isdir('.repo'):
180 sys.stderr.write('ERROR: No .repo directory found. Please run this from the top of your tree.\n')
Chirayu Desai4a319b82013-06-05 20:14:33 +0530181 sys.exit(1)
182
Tom Powellc8580302015-08-04 15:37:12 -0700183 # If --abandon-first is given, abandon the branch before starting
184 if args.abandon_first:
185 # Determine if the branch already exists; skip the abandon if it does not
186 plist = subprocess.check_output(['repo', 'info'])
187 needs_abandon = False
188 for pline in plist:
189 matchObj = re.match(r'Local Branches.*\[(.*)\]', pline)
190 if matchObj:
191 local_branches = re.split('\s*,\s*', matchObj.group(1))
192 if any(args.start_branch[0] in s for s in local_branches):
193 needs_abandon = True
Chirayu Desai4a319b82013-06-05 20:14:33 +0530194
Tom Powellc8580302015-08-04 15:37:12 -0700195 if needs_abandon:
196 # Perform the abandon only if the branch already exists
197 if not args.quiet:
198 print('Abandoning branch: %s' % args.start_branch[0])
199 subprocess.check_output(['repo', 'abandon', args.start_branch[0]])
200 if not args.quiet:
201 print('')
Chirayu Desai4a319b82013-06-05 20:14:33 +0530202
Tom Powell8b3a67d2015-09-25 14:23:26 -0700203 # Get the master manifest from repo
204 # - convert project name and revision to a path
205 project_name_to_data = {}
206 manifest = subprocess.check_output(['repo', 'manifest'])
207 xml_root = ElementTree.fromstring(manifest)
208 projects = xml_root.findall('project')
209 default_revision = xml_root.findall('default')[0].get('revision').split('/')[-1]
Chirayu Desai4a319b82013-06-05 20:14:33 +0530210
Tom Powell8b3a67d2015-09-25 14:23:26 -0700211 #dump project data into the a list of dicts with the following data:
212 #{project: {path, revision}}
Chirayu Desai4a319b82013-06-05 20:14:33 +0530213
Tom Powell8b3a67d2015-09-25 14:23:26 -0700214 for project in projects:
215 name = project.get('name')
216 path = project.get('path')
217 revision = project.get('revision')
218 if revision is None:
219 revision = default_revision
220
221 if not name in project_name_to_data:
222 project_name_to_data[name] = {}
223 project_name_to_data[name][revision] = path
Tom Powellc8580302015-08-04 15:37:12 -0700224
225 # get data on requested changes
226 reviews = []
227 change_numbers = []
228 if args.topic:
229 reviews = fetch_query(args.gerrit, 'topic:{0}'.format(args.topic))
Brint E. Kriebel9c1a3c32015-09-09 22:29:28 -0700230 change_numbers = sorted([str(r['number']) for r in reviews])
Tom Powellc8580302015-08-04 15:37:12 -0700231 if args.query:
232 reviews = fetch_query(args.gerrit, args.query)
Brint E. Kriebel9c1a3c32015-09-09 22:29:28 -0700233 change_numbers = sorted([str(r['number']) for r in reviews])
Tom Powellc8580302015-08-04 15:37:12 -0700234 if args.change_number:
235 reviews = fetch_query(args.gerrit, ' OR '.join('change:{0}'.format(x.split('/')[0]) for x in args.change_number))
236 change_numbers = args.change_number
Tom Powellc8580302015-08-04 15:37:12 -0700237
Brint E. Kriebel9c1a3c32015-09-09 22:29:28 -0700238 # make list of things to actually merge
Tom Powellc8580302015-08-04 15:37:12 -0700239 mergables = []
240
AdrianDCf0b8bff2015-12-08 20:19:41 +0100241 # If --exclude is given, create the list of commits to ignore
242 exclude = []
243 if args.exclude:
244 exclude = args.exclude[0].split(',')
245
Tom Powellc8580302015-08-04 15:37:12 -0700246 for change in change_numbers:
247 patchset = None
248 if '/' in change:
249 (change, patchset) = change.split('/')
Tom Powellc8580302015-08-04 15:37:12 -0700250
AdrianDCf0b8bff2015-12-08 20:19:41 +0100251 if change in exclude:
252 continue
253
254 change = int(change)
Tom Powellc8580302015-08-04 15:37:12 -0700255 review = [x for x in reviews if x['number'] == change][0]
256 mergables.append({
257 'subject': review['subject'],
258 'project': review['project'],
259 'branch': review['branch'],
260 'change_number': review['number'],
261 'status': review['status'],
262 'fetch': None
263 })
264 mergables[-1]['fetch'] = review['revisions'][review['current_revision']]['fetch']
265 mergables[-1]['id'] = change
266 if patchset:
267 try:
268 mergables[-1]['fetch'] = [x['fetch'] for x in review['revisions'] if x['_number'] == patchset][0]
269 mergables[-1]['id'] = '{0}/{1}'.format(change, patchset)
270 except (IndexError, ValueError):
Brint E. Kriebel9c1a3c32015-09-09 22:29:28 -0700271 args.quiet or print('ERROR: The patch set {0}/{1} could not be found, using CURRENT_REVISION instead.'.format(change, patchset))
Tom Powellc8580302015-08-04 15:37:12 -0700272
273 for item in mergables:
Brint E. Kriebel9c1a3c32015-09-09 22:29:28 -0700274 args.quiet or print('Applying change number {0}...'.format(item['id']))
jrior001fd11d072015-08-21 17:23:25 -0400275 # Check if change is open and exit if it's not, unless -f is specified
Tom Powellc627f072015-09-02 05:46:55 +0000276 if (item['status'] != 'OPEN' and item['status'] != 'NEW') and not args.query:
Tom Powellc8580302015-08-04 15:37:12 -0700277 if args.force:
jrior001fd11d072015-08-21 17:23:25 -0400278 print('!! Force-picking a closed change !!\n')
Tom Powellc8580302015-08-04 15:37:12 -0700279 else:
Dan Pasanenfe636282015-09-09 23:38:16 -0500280 print('Change status is ' + item['status'] + '. Skipping the cherry pick.\nUse -f to force this pick.')
Tom Powellc8580302015-08-04 15:37:12 -0700281 continue
282
283 # Convert the project name to a project path
284 # - check that the project path exists
285 project_path = None
Tom Powellc8580302015-08-04 15:37:12 -0700286
Tom Powell8b3a67d2015-09-25 14:23:26 -0700287 if item['project'] in project_name_to_data and item['branch'] in project_name_to_data[item['project']]:
288 project_path = project_name_to_data[item['project']][item['branch']]
Brint E. Kriebel9c1a3c32015-09-09 22:29:28 -0700289 elif args.path:
290 project_path = args.path
Tom Powellc8580302015-08-04 15:37:12 -0700291 elif args.ignore_missing:
292 print('WARNING: Skipping {0} since there is no project directory for: {1}\n'.format(item['id'], item['project']))
293 continue
294 else:
295 sys.stderr.write('ERROR: For {0}, could not determine the project path for project {1}\n'.format(item['id'], item['project']))
296 sys.exit(1)
297
298 # If --start-branch is given, create the branch (more than once per path is okay; repo ignores gracefully)
299 if args.start_branch:
300 subprocess.check_output(['repo', 'start', args.start_branch[0], project_path])
301
302 # Print out some useful info
Chirayu Desai4a319b82013-06-05 20:14:33 +0530303 if not args.quiet:
Tom Powellc8580302015-08-04 15:37:12 -0700304 print('--> Subject: "{0}"'.format(item['subject']))
305 print('--> Project path: {0}'.format(project_path))
306 print('--> Change number: {0} (Patch Set {0})'.format(item['id']))
307
Tom Powellc8580302015-08-04 15:37:12 -0700308 if 'anonymous http' in item['fetch']:
309 method = 'anonymous http'
310 else:
311 method = 'ssh'
312
Brint E. Kriebel9c1a3c32015-09-09 22:29:28 -0700313 # Try fetching from GitHub first if using default gerrit
314 if args.gerrit == default_gerrit:
Tom Powellc8580302015-08-04 15:37:12 -0700315 if args.verbose:
Brint E. Kriebel9c1a3c32015-09-09 22:29:28 -0700316 print('Trying to fetch the change from GitHub')
317
318 if args.pull:
319 cmd = ['git pull --no-edit github', item['fetch'][method]['ref']]
320 else:
321 cmd = ['git fetch github', item['fetch'][method]['ref']]
322 if args.quiet:
323 cmd.append('--quiet')
324 else:
325 print(cmd)
326 result = subprocess.call([' '.join(cmd)], cwd=project_path, shell=True)
Chirayu Desaieaba0412015-11-14 15:21:57 +0530327 FETCH_HEAD = '{0}/.git/FETCH_HEAD'.format(project_path)
328 if result != 0 and os.stat(FETCH_HEAD).st_size != 0:
Brint E. Kriebel9c1a3c32015-09-09 22:29:28 -0700329 print('ERROR: git command failed')
330 sys.exit(result)
Brint E. Kriebel9c1a3c32015-09-09 22:29:28 -0700331 # Check if it worked
332 if args.gerrit != default_gerrit or os.stat(FETCH_HEAD).st_size == 0:
333 # If not using the default gerrit or github failed, fetch from gerrit.
334 if args.verbose:
335 if args.gerrit == default_gerrit:
336 print('Fetching from GitHub didn\'t work, trying to fetch the change from Gerrit')
337 else:
338 print('Fetching from {0}'.format(args.gerrit))
339
Tom Powellc8580302015-08-04 15:37:12 -0700340 if args.pull:
341 cmd = ['git pull --no-edit', item['fetch'][method]['url'], item['fetch'][method]['ref']]
342 else:
343 cmd = ['git fetch', item['fetch'][method]['url'], item['fetch'][method]['ref']]
Brint E. Kriebel9c1a3c32015-09-09 22:29:28 -0700344 if args.quiet:
345 cmd.append('--quiet')
346 else:
347 print(cmd)
348 result = subprocess.call([' '.join(cmd)], cwd=project_path, shell=True)
349 if result != 0:
350 print('ERROR: git command failed')
351 sys.exit(result)
Tom Powellc8580302015-08-04 15:37:12 -0700352 # Perform the cherry-pick
353 if not args.pull:
354 cmd = ['git cherry-pick FETCH_HEAD']
Brint E. Kriebel9c1a3c32015-09-09 22:29:28 -0700355 if args.quiet:
356 cmd_out = open(os.devnull, 'wb')
357 else:
358 cmd_out = None
359 result = subprocess.call(cmd, cwd=project_path, shell=True, stdout=cmd_out, stderr=cmd_out)
360 if result != 0:
361 print('ERROR: git command failed')
362 sys.exit(result)
Chirayu Desai4a319b82013-06-05 20:14:33 +0530363 if not args.quiet:
364 print('')