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