blob: 64db7bfc566c66cce4f8cbd03262a6f80d4add26 [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])
Anthony Kingc713d762015-11-03 00:23:11 +000069 if not hasattr(out, 'encode'):
70 out = out.decode()
Tom Powellc8580302015-08-04 15:37:12 -070071 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'],
Anthony King5d01e802015-10-31 14:56:51 -040086 'url': 'ssh://{0}:{1}/{2}'.format(userhost, port, data['project'])
Tom Powellc8580302015-08-04 15:37:12 -070087 }
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)
Tom Powellff0032a2015-09-01 16:52:40 -070095 except:
Tom Powellc8580302015-08-04 15:37:12 -070096 pass
Brint E. Kriebel9c1a3c32015-09-09 22:29:28 -070097 args.quiet or print('Found {0} reviews'.format(len(reviews)))
Tom Powellc8580302015-08-04 15:37:12 -070098 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:
Anthony King5d01e802015-10-31 14:56:51 -0400109 review['number'] = review.pop('_number')
Tom Powellc8580302015-08-04 15:37:12 -0700110
111 return reviews
112
113
114def fetch_query(remote_url, query):
115 """Wrapper for fetch_query_via_proto functions"""
Tom Powellff0032a2015-09-01 16:52:40 -0700116 if remote_url[0:3] == 'ssh':
Tom Powellc8580302015-08-04 15:37:12 -0700117 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__':
Brint E. Kriebel9c1a3c32015-09-09 22:29:28 -0700124 # Default to CyanogenMod Gerrit
125 default_gerrit = 'http://review.cyanogenmod.org'
126
Tom Powellc8580302015-08-04 15:37:12 -0700127 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')
jrior001fd11d072015-08-21 17:23:25 -0400149 parser.add_argument('-f', '--force', action='store_true', help='force cherry pick even if change is closed')
Tom Powellc8580302015-08-04 15:37:12 -0700150 parser.add_argument('-p', '--pull', action='store_true', help='execute pull instead of cherry-pick')
Brint E. Kriebel9c1a3c32015-09-09 22:29:28 -0700151 parser.add_argument('-P', '--path', help='use the specified path for the change')
Tom Powellc8580302015-08-04 15:37:12 -0700152 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')
Brint E. Kriebel9c1a3c32015-09-09 22:29:28 -0700154 parser.add_argument('-g', '--gerrit', default=default_gerrit, help='Gerrit Instance to use. Form proto://[user@]host[:port]')
AdrianDCf0b8bff2015-12-08 20:19:41 +0100155 parser.add_argument('-e', '--exclude', nargs=1, help='exclude a list of commit numbers separated by a ,')
Tom Powellc8580302015-08-04 15:37:12 -0700156 args = parser.parse_args()
Tom Powellc8580302015-08-04 15:37:12 -0700157 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')
Chirayu Desai4a319b82013-06-05 20:14:33 +0530182 sys.exit(1)
183
Tom Powellc8580302015-08-04 15:37:12 -0700184 # 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'])
Anthony Kingc713d762015-11-03 00:23:11 +0000188 if not hasattr(plist, 'encode'):
189 plist = plist.decode()
Tom Powellc8580302015-08-04 15:37:12 -0700190 needs_abandon = False
Anthony Kingc713d762015-11-03 00:23:11 +0000191 for pline in plist.splitlines():
Tom Powellc8580302015-08-04 15:37:12 -0700192 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
Chirayu Desai4a319b82013-06-05 20:14:33 +0530197
Tom Powellc8580302015-08-04 15:37:12 -0700198 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('')
Chirayu Desai4a319b82013-06-05 20:14:33 +0530205
Tom Powell8b3a67d2015-09-25 14:23:26 -0700206 # 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 default_revision = xml_root.findall('default')[0].get('revision').split('/')[-1]
Chirayu Desai4a319b82013-06-05 20:14:33 +0530213
Tom Powell8b3a67d2015-09-25 14:23:26 -0700214 #dump project data into the a list of dicts with the following data:
215 #{project: {path, revision}}
Chirayu Desai4a319b82013-06-05 20:14:33 +0530216
Tom Powell8b3a67d2015-09-25 14:23:26 -0700217 for project in projects:
218 name = project.get('name')
219 path = project.get('path')
220 revision = project.get('revision')
221 if revision is None:
222 revision = default_revision
223
224 if not name in project_name_to_data:
225 project_name_to_data[name] = {}
226 project_name_to_data[name][revision] = path
Tom Powellc8580302015-08-04 15:37:12 -0700227
228 # get data on requested changes
229 reviews = []
230 change_numbers = []
231 if args.topic:
232 reviews = fetch_query(args.gerrit, 'topic:{0}'.format(args.topic))
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.query:
235 reviews = fetch_query(args.gerrit, args.query)
Brint E. Kriebel9c1a3c32015-09-09 22:29:28 -0700236 change_numbers = sorted([str(r['number']) for r in reviews])
Tom Powellc8580302015-08-04 15:37:12 -0700237 if args.change_number:
238 reviews = fetch_query(args.gerrit, ' OR '.join('change:{0}'.format(x.split('/')[0]) for x in args.change_number))
239 change_numbers = args.change_number
Tom Powellc8580302015-08-04 15:37:12 -0700240
Brint E. Kriebel9c1a3c32015-09-09 22:29:28 -0700241 # make list of things to actually merge
Tom Powellc8580302015-08-04 15:37:12 -0700242 mergables = []
243
AdrianDCf0b8bff2015-12-08 20:19:41 +0100244 # If --exclude is given, create the list of commits to ignore
245 exclude = []
246 if args.exclude:
247 exclude = args.exclude[0].split(',')
248
Tom Powellc8580302015-08-04 15:37:12 -0700249 for change in change_numbers:
250 patchset = None
251 if '/' in change:
252 (change, patchset) = change.split('/')
Tom Powellc8580302015-08-04 15:37:12 -0700253
AdrianDCf0b8bff2015-12-08 20:19:41 +0100254 if change in exclude:
255 continue
256
257 change = int(change)
Tom Powellc8580302015-08-04 15:37:12 -0700258 review = [x for x in reviews if x['number'] == change][0]
259 mergables.append({
260 'subject': review['subject'],
261 'project': review['project'],
262 'branch': review['branch'],
263 'change_number': review['number'],
264 'status': review['status'],
265 'fetch': None
266 })
267 mergables[-1]['fetch'] = review['revisions'][review['current_revision']]['fetch']
268 mergables[-1]['id'] = change
269 if patchset:
270 try:
271 mergables[-1]['fetch'] = [x['fetch'] for x in review['revisions'] if x['_number'] == patchset][0]
272 mergables[-1]['id'] = '{0}/{1}'.format(change, patchset)
273 except (IndexError, ValueError):
Brint E. Kriebel9c1a3c32015-09-09 22:29:28 -0700274 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 -0700275
276 for item in mergables:
Brint E. Kriebel9c1a3c32015-09-09 22:29:28 -0700277 args.quiet or print('Applying change number {0}...'.format(item['id']))
jrior001fd11d072015-08-21 17:23:25 -0400278 # Check if change is open and exit if it's not, unless -f is specified
Tom Powellc627f072015-09-02 05:46:55 +0000279 if (item['status'] != 'OPEN' and item['status'] != 'NEW') and not args.query:
Tom Powellc8580302015-08-04 15:37:12 -0700280 if args.force:
jrior001fd11d072015-08-21 17:23:25 -0400281 print('!! Force-picking a closed change !!\n')
Tom Powellc8580302015-08-04 15:37:12 -0700282 else:
Dan Pasanenfe636282015-09-09 23:38:16 -0500283 print('Change status is ' + item['status'] + '. Skipping the cherry pick.\nUse -f to force this pick.')
Tom Powellc8580302015-08-04 15:37:12 -0700284 continue
285
286 # Convert the project name to a project path
287 # - check that the project path exists
288 project_path = None
Tom Powellc8580302015-08-04 15:37:12 -0700289
Tom Powell8b3a67d2015-09-25 14:23:26 -0700290 if item['project'] in project_name_to_data and item['branch'] in project_name_to_data[item['project']]:
291 project_path = project_name_to_data[item['project']][item['branch']]
Brint E. Kriebel9c1a3c32015-09-09 22:29:28 -0700292 elif args.path:
293 project_path = args.path
Tom Powellc8580302015-08-04 15:37:12 -0700294 elif args.ignore_missing:
295 print('WARNING: Skipping {0} since there is no project directory for: {1}\n'.format(item['id'], item['project']))
296 continue
297 else:
298 sys.stderr.write('ERROR: For {0}, could not determine the project path for project {1}\n'.format(item['id'], item['project']))
299 sys.exit(1)
300
301 # If --start-branch is given, create the branch (more than once per path is okay; repo ignores gracefully)
302 if args.start_branch:
303 subprocess.check_output(['repo', 'start', args.start_branch[0], project_path])
304
305 # Print out some useful info
Chirayu Desai4a319b82013-06-05 20:14:33 +0530306 if not args.quiet:
Tom Powellc8580302015-08-04 15:37:12 -0700307 print('--> Subject: "{0}"'.format(item['subject']))
308 print('--> Project path: {0}'.format(project_path))
309 print('--> Change number: {0} (Patch Set {0})'.format(item['id']))
310
Tom Powellc8580302015-08-04 15:37:12 -0700311 if 'anonymous http' in item['fetch']:
312 method = 'anonymous http'
313 else:
314 method = 'ssh'
315
Brint E. Kriebel9c1a3c32015-09-09 22:29:28 -0700316 # Try fetching from GitHub first if using default gerrit
317 if args.gerrit == default_gerrit:
Tom Powellc8580302015-08-04 15:37:12 -0700318 if args.verbose:
Brint E. Kriebel9c1a3c32015-09-09 22:29:28 -0700319 print('Trying to fetch the change from GitHub')
320
321 if args.pull:
322 cmd = ['git pull --no-edit github', item['fetch'][method]['ref']]
323 else:
324 cmd = ['git fetch github', item['fetch'][method]['ref']]
325 if args.quiet:
326 cmd.append('--quiet')
327 else:
328 print(cmd)
329 result = subprocess.call([' '.join(cmd)], cwd=project_path, shell=True)
Chirayu Desaieaba0412015-11-14 15:21:57 +0530330 FETCH_HEAD = '{0}/.git/FETCH_HEAD'.format(project_path)
331 if result != 0 and os.stat(FETCH_HEAD).st_size != 0:
Brint E. Kriebel9c1a3c32015-09-09 22:29:28 -0700332 print('ERROR: git command failed')
333 sys.exit(result)
Brint E. Kriebel9c1a3c32015-09-09 22:29:28 -0700334 # Check if it worked
335 if args.gerrit != default_gerrit or os.stat(FETCH_HEAD).st_size == 0:
336 # If not using the default gerrit or github failed, fetch from gerrit.
337 if args.verbose:
338 if args.gerrit == default_gerrit:
339 print('Fetching from GitHub didn\'t work, trying to fetch the change from Gerrit')
340 else:
341 print('Fetching from {0}'.format(args.gerrit))
342
Tom Powellc8580302015-08-04 15:37:12 -0700343 if args.pull:
344 cmd = ['git pull --no-edit', item['fetch'][method]['url'], item['fetch'][method]['ref']]
345 else:
346 cmd = ['git fetch', item['fetch'][method]['url'], item['fetch'][method]['ref']]
Brint E. Kriebel9c1a3c32015-09-09 22:29:28 -0700347 if args.quiet:
348 cmd.append('--quiet')
349 else:
350 print(cmd)
351 result = subprocess.call([' '.join(cmd)], cwd=project_path, shell=True)
352 if result != 0:
353 print('ERROR: git command failed')
354 sys.exit(result)
Tom Powellc8580302015-08-04 15:37:12 -0700355 # Perform the cherry-pick
356 if not args.pull:
357 cmd = ['git cherry-pick FETCH_HEAD']
Brint E. Kriebel9c1a3c32015-09-09 22:29:28 -0700358 if args.quiet:
359 cmd_out = open(os.devnull, 'wb')
360 else:
361 cmd_out = None
362 result = subprocess.call(cmd, cwd=project_path, shell=True, stdout=cmd_out, stderr=cmd_out)
363 if result != 0:
364 print('ERROR: git command failed')
365 sys.exit(result)
Chirayu Desai4a319b82013-06-05 20:14:33 +0530366 if not args.quiet:
367 print('')