blob: c62a579e04a8ac57ad61daa624d84af1b2e1a438 [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')
Harry Youd1c9c5a32017-07-18 18:52:42 +0100146 parser.add_argument('-r', '--reset', action='store_true', help='reset to initial state (abort cherry-pick) if there is a conflict')
Michael Bestas3952f6c2016-08-26 01:12:08 +0300147 parser.add_argument('-a', '--abandon-first', action='store_true', help='before cherry picking, abandon the branch specified in --start-branch')
148 parser.add_argument('-b', '--auto-branch', action='store_true', help='shortcut to "--start-branch auto --abandon-first --ignore-missing"')
149 parser.add_argument('-q', '--quiet', action='store_true', help='print as little as possible')
150 parser.add_argument('-v', '--verbose', action='store_true', help='print extra information to aid in debug')
151 parser.add_argument('-f', '--force', action='store_true', help='force cherry pick even if change is closed')
152 parser.add_argument('-p', '--pull', action='store_true', help='execute pull instead of cherry-pick')
153 parser.add_argument('-P', '--path', help='use the specified path for the change')
154 parser.add_argument('-t', '--topic', help='pick all commits from a specified topic')
155 parser.add_argument('-Q', '--query', help='pick all commits using the specified query')
156 parser.add_argument('-g', '--gerrit', default=default_gerrit, help='Gerrit Instance to use. Form proto://[user@]host[:port]')
157 parser.add_argument('-e', '--exclude', nargs=1, help='exclude a list of commit numbers separated by a ,')
158 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 Shields2bdb18f2016-09-26 17:52:03 +1000229 revision = default_revision
Michael Bestas3952f6c2016-08-26 01:12:08 +0300230
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)
LuK1337ad5d9a02017-03-24 23:25:13 +0100272
273 if patchset is not None:
274 patchset = int(patchset)
275
Michael Bestas3952f6c2016-08-26 01:12:08 +0300276 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:
LuK133727564182017-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]
Michael Bestas3952f6c2016-08-26 01:12:08 +0300295 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 Pasanen63f767e2017-07-09 09:41:33 -0500302 if (item['status'] != 'OPEN' and item['status'] != 'NEW' and item['status'] != 'DRAFT') and not args.query:
Michael Bestas3952f6c2016-08-26 01:12:08 +0300303 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
329 check_picked_count = 10
330 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 DC13b02ff2016-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
Michael Bestas3952f6c2016-08-26 01:12:08 +0300339 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 Pasanend4ee2f62017-03-16 15:25:29 -0500355 print('--> Subject: "{0}"'.format(item['subject'].encode('utf-8')))
Michael Bestas3952f6c2016-08-26 01:12:08 +0300356 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 Youd1c9c5a32017-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')
Michael Bestas3952f6c2016-08-26 01:12:08 +0300418 sys.exit(result)
419 if not args.quiet:
420 print('')