blob: 9bb39aadda2f540b43d4a863318fcc3b35c12e03 [file] [log] [blame]
Michael Bestas3952f6c2016-08-26 01:12:08 +03001#!/usr/bin/env python
2#
3# Copyright (C) 2013-15 The CyanogenMod Project
Dan Pasanen0fdc0852016-12-27 10:32:16 -06004# (C) 2017 The LineageOS Project
Michael Bestas3952f6c2016-08-26 01:12:08 +03005#
6# Licensed under the Apache License, Version 2.0 (the "License");
7# you may not use this file except in compliance with the License.
8# You may obtain a copy of the License at
9#
10# http://www.apache.org/licenses/LICENSE-2.0
11#
12# Unless required by applicable law or agreed to in writing, software
13# distributed under the License is distributed on an "AS IS" BASIS,
14# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15# See the License for the specific language governing permissions and
16# limitations under the License.
17#
18
19#
20# Run repopick.py -h for a description of this utility.
21#
22
23from __future__ import print_function
24
25import sys
26import json
27import os
28import subprocess
29import re
30import argparse
31import textwrap
32from xml.etree import ElementTree
33
34try:
35 # For python3
36 import urllib.error
37 import urllib.request
38except ImportError:
39 # For python2
40 import imp
41 import urllib2
42 urllib = imp.new_module('urllib')
43 urllib.error = urllib2
44 urllib.request = urllib2
45
46
47# Verifies whether pathA is a subdirectory (or the same) as pathB
48def is_subdir(a, b):
49 a = os.path.realpath(a) + '/'
50 b = os.path.realpath(b) + '/'
51 return b == a[:len(b)]
52
53
54def fetch_query_via_ssh(remote_url, query):
55 """Given a remote_url and a query, return the list of changes that fit it
56 This function is slightly messy - the ssh api does not return data in the same structure as the HTTP REST API
57 We have to get the data, then transform it to match what we're expecting from the HTTP RESET API"""
58 if remote_url.count(':') == 2:
59 (uri, userhost, port) = remote_url.split(':')
60 userhost = userhost[2:]
61 elif remote_url.count(':') == 1:
62 (uri, userhost) = remote_url.split(':')
63 userhost = userhost[2:]
64 port = 29418
65 else:
66 raise Exception('Malformed URI: Expecting ssh://[user@]host[:port]')
67
68
69 out = subprocess.check_output(['ssh', '-x', '-p{0}'.format(port), userhost, 'gerrit', 'query', '--format=JSON --patch-sets --current-patch-set', query])
70 if not hasattr(out, 'encode'):
71 out = out.decode()
72 reviews = []
73 for line in out.split('\n'):
74 try:
75 data = json.loads(line)
76 # make our data look like the http rest api data
77 review = {
78 'branch': data['branch'],
79 'change_id': data['id'],
80 'current_revision': data['currentPatchSet']['revision'],
81 'number': int(data['number']),
82 'revisions': {patch_set['revision']: {
83 'number': int(patch_set['number']),
84 'fetch': {
85 'ssh': {
86 'ref': patch_set['ref'],
87 'url': 'ssh://{0}:{1}/{2}'.format(userhost, port, data['project'])
88 }
89 }
90 } for patch_set in data['patchSets']},
91 'subject': data['subject'],
92 'project': data['project'],
93 'status': data['status']
94 }
95 reviews.append(review)
96 except:
97 pass
98 args.quiet or print('Found {0} reviews'.format(len(reviews)))
99 return reviews
100
101
102def fetch_query_via_http(remote_url, query):
103
104 """Given a query, fetch the change numbers via http"""
105 url = '{0}/changes/?q={1}&o=CURRENT_REVISION&o=ALL_REVISIONS'.format(remote_url, query)
106 data = urllib.request.urlopen(url).read().decode('utf-8')
107 reviews = json.loads(data[5:])
108
109 for review in reviews:
110 review['number'] = review.pop('_number')
111
112 return reviews
113
114
115def fetch_query(remote_url, query):
116 """Wrapper for fetch_query_via_proto functions"""
117 if remote_url[0:3] == 'ssh':
118 return fetch_query_via_ssh(remote_url, query)
119 elif remote_url[0:4] == 'http':
120 return fetch_query_via_http(remote_url, query.replace(' ', '+'))
121 else:
122 raise Exception('Gerrit URL should be in the form http[s]://hostname/ or ssh://[user@]host[:port]')
123
124if __name__ == '__main__':
Dan Pasanen0fdc0852016-12-27 10:32:16 -0600125 # Default to LineageOS Gerrit
126 default_gerrit = 'http://review.lineageos.org'
Michael Bestas3952f6c2016-08-26 01:12:08 +0300127
128 parser = argparse.ArgumentParser(formatter_class=argparse.RawDescriptionHelpFormatter, description=textwrap.dedent('''\
129 repopick.py is a utility to simplify the process of cherry picking
Dan Pasanen0fdc0852016-12-27 10:32:16 -0600130 patches from LineageOS's Gerrit instance (or any gerrit instance of your choosing)
Michael Bestas3952f6c2016-08-26 01:12:08 +0300131
132 Given a list of change numbers, repopick will cd into the project path
133 and cherry pick the latest patch available.
134
135 With the --start-branch argument, the user can specify that a branch
136 should be created before cherry picking. This is useful for
137 cherry-picking many patches into a common branch which can be easily
138 abandoned later (good for testing other's changes.)
139
140 The --abandon-first argument, when used in conjunction with the
141 --start-branch option, will cause repopick to abandon the specified
142 branch in all repos first before performing any cherry picks.'''))
143 parser.add_argument('change_number', nargs='*', help='change number to cherry pick. Use {change number}/{patchset number} to get a specific revision.')
144 parser.add_argument('-i', '--ignore-missing', action='store_true', help='do not error out if a patch applies to a missing directory')
145 parser.add_argument('-s', '--start-branch', nargs=1, help='start the specified branch before cherry picking')
146 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 Shields2bdb18f2016-09-26 17:52:03 +1000228 revision = default_revision
Michael Bestas3952f6c2016-08-26 01:12:08 +0300229
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)
271 review = next((x for x in reviews if x['number'] == change), None)
272 if review is None:
273 print('Change %d not found, skipping' % change)
274 continue
275
276 mergables.append({
277 'subject': review['subject'],
278 'project': review['project'],
279 'branch': review['branch'],
280 'change_id': review['change_id'],
281 'change_number': review['number'],
282 'status': review['status'],
283 'fetch': None
284 })
285 mergables[-1]['fetch'] = review['revisions'][review['current_revision']]['fetch']
286 mergables[-1]['id'] = change
287 if patchset:
288 try:
289 mergables[-1]['fetch'] = [x['fetch'] for x in review['revisions'] if x['_number'] == patchset][0]
290 mergables[-1]['id'] = '{0}/{1}'.format(change, patchset)
291 except (IndexError, ValueError):
292 args.quiet or print('ERROR: The patch set {0}/{1} could not be found, using CURRENT_REVISION instead.'.format(change, patchset))
293
294 for item in mergables:
295 args.quiet or print('Applying change number {0}...'.format(item['id']))
296 # Check if change is open and exit if it's not, unless -f is specified
297 if (item['status'] != 'OPEN' and item['status'] != 'NEW') and not args.query:
298 if args.force:
299 print('!! Force-picking a closed change !!\n')
300 else:
301 print('Change status is ' + item['status'] + '. Skipping the cherry pick.\nUse -f to force this pick.')
302 continue
303
304 # Convert the project name to a project path
305 # - check that the project path exists
306 project_path = None
307
308 if item['project'] in project_name_to_data and item['branch'] in project_name_to_data[item['project']]:
309 project_path = project_name_to_data[item['project']][item['branch']]
310 elif args.path:
311 project_path = args.path
312 elif args.ignore_missing:
313 print('WARNING: Skipping {0} since there is no project directory for: {1}\n'.format(item['id'], item['project']))
314 continue
315 else:
316 sys.stderr.write('ERROR: For {0}, could not determine the project path for project {1}\n'.format(item['id'], item['project']))
317 sys.exit(1)
318
319 # If --start-branch is given, create the branch (more than once per path is okay; repo ignores gracefully)
320 if args.start_branch:
321 subprocess.check_output(['repo', 'start', args.start_branch[0], project_path])
322
323 # Determine the maximum commits to check already picked changes
324 check_picked_count = 10
325 branch_commits_count = int(subprocess.check_output(['git', 'rev-list', '--count', 'HEAD'], cwd=project_path))
326 if branch_commits_count <= check_picked_count:
327 check_picked_count = branch_commits_count - 1
328
329 # Check if change is already picked to HEAD...HEAD~check_picked_count
330 found_change = False
331 for i in range(0, check_picked_count):
Adrian DC13b02ff2016-12-04 12:30:26 +0100332 if subprocess.call(['git', 'cat-file', '-e', 'HEAD~{0}'.format(i)], cwd=project_path, stderr=open(os.devnull, 'wb')):
333 continue
Michael Bestas3952f6c2016-08-26 01:12:08 +0300334 output = subprocess.check_output(['git', 'show', '-q', 'HEAD~{0}'.format(i)], cwd=project_path).split()
335 if 'Change-Id:' in output:
336 head_change_id = ''
337 for j,t in enumerate(reversed(output)):
338 if t == 'Change-Id:':
339 head_change_id = output[len(output) - j]
340 break
341 if head_change_id.strip() == item['change_id']:
342 print('Skipping {0} - already picked in {1} as HEAD~{2}'.format(item['id'], project_path, i))
343 found_change = True
344 break
345 if found_change:
346 continue
347
348 # Print out some useful info
349 if not args.quiet:
350 print('--> Subject: "{0}"'.format(item['subject']))
351 print('--> Project path: {0}'.format(project_path))
352 print('--> Change number: {0} (Patch Set {0})'.format(item['id']))
353
354 if 'anonymous http' in item['fetch']:
355 method = 'anonymous http'
356 else:
357 method = 'ssh'
358
359 # Try fetching from GitHub first if using default gerrit
360 if args.gerrit == default_gerrit:
361 if args.verbose:
362 print('Trying to fetch the change from GitHub')
363
364 if args.pull:
365 cmd = ['git pull --no-edit github', item['fetch'][method]['ref']]
366 else:
367 cmd = ['git fetch github', item['fetch'][method]['ref']]
368 if args.quiet:
369 cmd.append('--quiet')
370 else:
371 print(cmd)
372 result = subprocess.call([' '.join(cmd)], cwd=project_path, shell=True)
373 FETCH_HEAD = '{0}/.git/FETCH_HEAD'.format(project_path)
374 if result != 0 and os.stat(FETCH_HEAD).st_size != 0:
375 print('ERROR: git command failed')
376 sys.exit(result)
377 # Check if it worked
378 if args.gerrit != default_gerrit or os.stat(FETCH_HEAD).st_size == 0:
379 # If not using the default gerrit or github failed, fetch from gerrit.
380 if args.verbose:
381 if args.gerrit == default_gerrit:
382 print('Fetching from GitHub didn\'t work, trying to fetch the change from Gerrit')
383 else:
384 print('Fetching from {0}'.format(args.gerrit))
385
386 if args.pull:
387 cmd = ['git pull --no-edit', item['fetch'][method]['url'], item['fetch'][method]['ref']]
388 else:
389 cmd = ['git fetch', item['fetch'][method]['url'], item['fetch'][method]['ref']]
390 if args.quiet:
391 cmd.append('--quiet')
392 else:
393 print(cmd)
394 result = subprocess.call([' '.join(cmd)], cwd=project_path, shell=True)
395 if result != 0:
396 print('ERROR: git command failed')
397 sys.exit(result)
398 # Perform the cherry-pick
399 if not args.pull:
400 cmd = ['git cherry-pick FETCH_HEAD']
401 if args.quiet:
402 cmd_out = open(os.devnull, 'wb')
403 else:
404 cmd_out = None
405 result = subprocess.call(cmd, cwd=project_path, shell=True, stdout=cmd_out, stderr=cmd_out)
406 if result != 0:
407 print('ERROR: git command failed')
408 sys.exit(result)
409 if not args.quiet:
410 print('')