blob: beda846e622e117883023e95b0e4f9a8da9dbf6b [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:
Dan Pasanen1cdd3802017-01-23 15:08:52 -060035 import requests
Michael Bestas3952f6c2016-08-26 01:12:08 +030036except ImportError:
Dan Pasanen1cdd3802017-01-23 15:08:52 -060037 try:
38 # For python3
39 import urllib.error
40 import urllib.request
41 except ImportError:
42 # For python2
43 import imp
44 import urllib2
45 urllib = imp.new_module('urllib')
46 urllib.error = urllib2
47 urllib.request = urllib2
Michael Bestas3952f6c2016-08-26 01:12:08 +030048
49
50# Verifies whether pathA is a subdirectory (or the same) as pathB
51def is_subdir(a, b):
52 a = os.path.realpath(a) + '/'
53 b = os.path.realpath(b) + '/'
54 return b == a[:len(b)]
55
56
57def fetch_query_via_ssh(remote_url, query):
58 """Given a remote_url and a query, return the list of changes that fit it
59 This function is slightly messy - the ssh api does not return data in the same structure as the HTTP REST API
60 We have to get the data, then transform it to match what we're expecting from the HTTP RESET API"""
61 if remote_url.count(':') == 2:
62 (uri, userhost, port) = remote_url.split(':')
63 userhost = userhost[2:]
64 elif remote_url.count(':') == 1:
65 (uri, userhost) = remote_url.split(':')
66 userhost = userhost[2:]
67 port = 29418
68 else:
69 raise Exception('Malformed URI: Expecting ssh://[user@]host[:port]')
70
71
72 out = subprocess.check_output(['ssh', '-x', '-p{0}'.format(port), userhost, 'gerrit', 'query', '--format=JSON --patch-sets --current-patch-set', query])
73 if not hasattr(out, 'encode'):
74 out = out.decode()
75 reviews = []
76 for line in out.split('\n'):
77 try:
78 data = json.loads(line)
79 # make our data look like the http rest api data
80 review = {
81 'branch': data['branch'],
82 'change_id': data['id'],
83 'current_revision': data['currentPatchSet']['revision'],
84 'number': int(data['number']),
85 'revisions': {patch_set['revision']: {
86 'number': int(patch_set['number']),
87 'fetch': {
88 'ssh': {
89 'ref': patch_set['ref'],
90 'url': 'ssh://{0}:{1}/{2}'.format(userhost, port, data['project'])
91 }
92 }
93 } for patch_set in data['patchSets']},
94 'subject': data['subject'],
95 'project': data['project'],
96 'status': data['status']
97 }
98 reviews.append(review)
99 except:
100 pass
101 args.quiet or print('Found {0} reviews'.format(len(reviews)))
102 return reviews
103
104
105def fetch_query_via_http(remote_url, query):
Dan Pasanen1cdd3802017-01-23 15:08:52 -0600106 if "requests" in sys.modules:
107 auth = None
108 if os.path.isfile(os.getenv("HOME") + "/.gerritrc"):
109 f = open(os.getenv("HOME") + "/.gerritrc", "r")
110 for line in f:
111 parts = line.rstrip().split("|")
112 if parts[0] in remote_url:
113 auth = requests.auth.HTTPBasicAuth(username=parts[1], password=parts[2])
114 statusCode = '-1'
115 if auth:
116 url = '{0}/a/changes/?q={1}&o=CURRENT_REVISION&o=ALL_REVISIONS'.format(remote_url, query)
117 data = requests.get(url, auth=auth)
118 statusCode = str(data.status_code)
119 if statusCode != '200':
120 #They didn't get good authorization or data, Let's try the old way
121 url = '{0}/changes/?q={1}&o=CURRENT_REVISION&o=ALL_REVISIONS'.format(remote_url, query)
122 data = requests.get(url)
123 reviews = json.loads(data.text[5:])
124 else:
125 """Given a query, fetch the change numbers via http"""
126 url = '{0}/changes/?q={1}&o=CURRENT_REVISION&o=ALL_REVISIONS'.format(remote_url, query)
127 data = urllib.request.urlopen(url).read().decode('utf-8')
128 reviews = json.loads(data[5:])
Michael Bestas3952f6c2016-08-26 01:12:08 +0300129
130 for review in reviews:
131 review['number'] = review.pop('_number')
132
133 return reviews
134
135
136def fetch_query(remote_url, query):
137 """Wrapper for fetch_query_via_proto functions"""
138 if remote_url[0:3] == 'ssh':
139 return fetch_query_via_ssh(remote_url, query)
140 elif remote_url[0:4] == 'http':
141 return fetch_query_via_http(remote_url, query.replace(' ', '+'))
142 else:
143 raise Exception('Gerrit URL should be in the form http[s]://hostname/ or ssh://[user@]host[:port]')
144
145if __name__ == '__main__':
Dan Pasanen0fdc0852016-12-27 10:32:16 -0600146 # Default to LineageOS Gerrit
Dan Pasanen29653422017-12-12 15:07:09 -0600147 default_gerrit = 'https://review.lineageos.org'
Michael Bestas3952f6c2016-08-26 01:12:08 +0300148
149 parser = argparse.ArgumentParser(formatter_class=argparse.RawDescriptionHelpFormatter, description=textwrap.dedent('''\
150 repopick.py is a utility to simplify the process of cherry picking
Dan Pasanen0fdc0852016-12-27 10:32:16 -0600151 patches from LineageOS's Gerrit instance (or any gerrit instance of your choosing)
Michael Bestas3952f6c2016-08-26 01:12:08 +0300152
153 Given a list of change numbers, repopick will cd into the project path
154 and cherry pick the latest patch available.
155
156 With the --start-branch argument, the user can specify that a branch
157 should be created before cherry picking. This is useful for
158 cherry-picking many patches into a common branch which can be easily
159 abandoned later (good for testing other's changes.)
160
161 The --abandon-first argument, when used in conjunction with the
162 --start-branch option, will cause repopick to abandon the specified
163 branch in all repos first before performing any cherry picks.'''))
164 parser.add_argument('change_number', nargs='*', help='change number to cherry pick. Use {change number}/{patchset number} to get a specific revision.')
165 parser.add_argument('-i', '--ignore-missing', action='store_true', help='do not error out if a patch applies to a missing directory')
166 parser.add_argument('-s', '--start-branch', nargs=1, help='start the specified branch before cherry picking')
Harry Youd1c9c5a32017-07-18 18:52:42 +0100167 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 +0300168 parser.add_argument('-a', '--abandon-first', action='store_true', help='before cherry picking, abandon the branch specified in --start-branch')
169 parser.add_argument('-b', '--auto-branch', action='store_true', help='shortcut to "--start-branch auto --abandon-first --ignore-missing"')
170 parser.add_argument('-q', '--quiet', action='store_true', help='print as little as possible')
171 parser.add_argument('-v', '--verbose', action='store_true', help='print extra information to aid in debug')
172 parser.add_argument('-f', '--force', action='store_true', help='force cherry pick even if change is closed')
173 parser.add_argument('-p', '--pull', action='store_true', help='execute pull instead of cherry-pick')
174 parser.add_argument('-P', '--path', help='use the specified path for the change')
175 parser.add_argument('-t', '--topic', help='pick all commits from a specified topic')
176 parser.add_argument('-Q', '--query', help='pick all commits using the specified query')
177 parser.add_argument('-g', '--gerrit', default=default_gerrit, help='Gerrit Instance to use. Form proto://[user@]host[:port]')
178 parser.add_argument('-e', '--exclude', nargs=1, help='exclude a list of commit numbers separated by a ,')
Adrian DCdf290222017-09-07 22:59:54 +0200179 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 +0300180 args = parser.parse_args()
181 if not args.start_branch and args.abandon_first:
182 parser.error('if --abandon-first is set, you must also give the branch name with --start-branch')
183 if args.auto_branch:
184 args.abandon_first = True
185 args.ignore_missing = True
186 if not args.start_branch:
187 args.start_branch = ['auto']
188 if args.quiet and args.verbose:
189 parser.error('--quiet and --verbose cannot be specified together')
190
191 if (1 << bool(args.change_number) << bool(args.topic) << bool(args.query)) != 2:
192 parser.error('One (and only one) of change_number, topic, and query are allowed')
193
194 # Change current directory to the top of the tree
195 if 'ANDROID_BUILD_TOP' in os.environ:
196 top = os.environ['ANDROID_BUILD_TOP']
197
198 if not is_subdir(os.getcwd(), top):
199 sys.stderr.write('ERROR: You must run this tool from within $ANDROID_BUILD_TOP!\n')
200 sys.exit(1)
201 os.chdir(os.environ['ANDROID_BUILD_TOP'])
202
203 # Sanity check that we are being run from the top level of the tree
204 if not os.path.isdir('.repo'):
205 sys.stderr.write('ERROR: No .repo directory found. Please run this from the top of your tree.\n')
206 sys.exit(1)
207
208 # If --abandon-first is given, abandon the branch before starting
209 if args.abandon_first:
210 # Determine if the branch already exists; skip the abandon if it does not
211 plist = subprocess.check_output(['repo', 'info'])
212 if not hasattr(plist, 'encode'):
213 plist = plist.decode()
214 needs_abandon = False
215 for pline in plist.splitlines():
216 matchObj = re.match(r'Local Branches.*\[(.*)\]', pline)
217 if matchObj:
218 local_branches = re.split('\s*,\s*', matchObj.group(1))
219 if any(args.start_branch[0] in s for s in local_branches):
220 needs_abandon = True
221
222 if needs_abandon:
223 # Perform the abandon only if the branch already exists
224 if not args.quiet:
225 print('Abandoning branch: %s' % args.start_branch[0])
226 subprocess.check_output(['repo', 'abandon', args.start_branch[0]])
227 if not args.quiet:
228 print('')
229
230 # Get the master manifest from repo
231 # - convert project name and revision to a path
232 project_name_to_data = {}
233 manifest = subprocess.check_output(['repo', 'manifest'])
234 xml_root = ElementTree.fromstring(manifest)
235 projects = xml_root.findall('project')
236 remotes = xml_root.findall('remote')
237 default_revision = xml_root.findall('default')[0].get('revision')
238
239 #dump project data into the a list of dicts with the following data:
240 #{project: {path, revision}}
241
242 for project in projects:
243 name = project.get('name')
244 path = project.get('path')
245 revision = project.get('revision')
246 if revision is None:
247 for remote in remotes:
248 if remote.get('name') == project.get('remote'):
249 revision = remote.get('revision')
250 if revision is None:
Simon Shields2bdb18f2016-09-26 17:52:03 +1000251 revision = default_revision
Michael Bestas3952f6c2016-08-26 01:12:08 +0300252
253 if not name in project_name_to_data:
254 project_name_to_data[name] = {}
255 revision = revision.split('refs/heads/')[-1]
256 project_name_to_data[name][revision] = path
257
258 # get data on requested changes
259 reviews = []
260 change_numbers = []
261 if args.topic:
262 reviews = fetch_query(args.gerrit, 'topic:{0}'.format(args.topic))
nailyk8ce26b32017-09-28 08:37:59 +0000263 change_numbers = sorted([str(r['number']) for r in reviews], key=int)
Michael Bestas3952f6c2016-08-26 01:12:08 +0300264 if args.query:
265 reviews = fetch_query(args.gerrit, args.query)
nailyk8ce26b32017-09-28 08:37:59 +0000266 change_numbers = sorted([str(r['number']) for r in reviews], key=int)
Michael Bestas3952f6c2016-08-26 01:12:08 +0300267 if args.change_number:
268 for c in args.change_number:
269 if '-' in c:
270 templist = c.split('-')
271 for i in range(int(templist[0]), int(templist[1]) + 1):
272 change_numbers.append(str(i))
273 else:
274 change_numbers.append(c)
275 reviews = fetch_query(args.gerrit, ' OR '.join('change:{0}'.format(x.split('/')[0]) for x in change_numbers))
276
277 # make list of things to actually merge
278 mergables = []
279
280 # If --exclude is given, create the list of commits to ignore
281 exclude = []
282 if args.exclude:
283 exclude = args.exclude[0].split(',')
284
285 for change in change_numbers:
286 patchset = None
287 if '/' in change:
288 (change, patchset) = change.split('/')
289
290 if change in exclude:
291 continue
292
293 change = int(change)
LuK1337ad5d9a02017-03-24 23:25:13 +0100294
295 if patchset is not None:
296 patchset = int(patchset)
297
Michael Bestas3952f6c2016-08-26 01:12:08 +0300298 review = next((x for x in reviews if x['number'] == change), None)
299 if review is None:
300 print('Change %d not found, skipping' % change)
301 continue
302
303 mergables.append({
304 'subject': review['subject'],
305 'project': review['project'],
306 'branch': review['branch'],
307 'change_id': review['change_id'],
308 'change_number': review['number'],
309 'status': review['status'],
310 'fetch': None
311 })
312 mergables[-1]['fetch'] = review['revisions'][review['current_revision']]['fetch']
313 mergables[-1]['id'] = change
314 if patchset:
315 try:
LuK133727564182017-03-24 20:00:48 +0100316 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 +0300317 mergables[-1]['id'] = '{0}/{1}'.format(change, patchset)
318 except (IndexError, ValueError):
319 args.quiet or print('ERROR: The patch set {0}/{1} could not be found, using CURRENT_REVISION instead.'.format(change, patchset))
320
321 for item in mergables:
322 args.quiet or print('Applying change number {0}...'.format(item['id']))
323 # Check if change is open and exit if it's not, unless -f is specified
Dan Pasanen63f767e2017-07-09 09:41:33 -0500324 if (item['status'] != 'OPEN' and item['status'] != 'NEW' and item['status'] != 'DRAFT') and not args.query:
Michael Bestas3952f6c2016-08-26 01:12:08 +0300325 if args.force:
326 print('!! Force-picking a closed change !!\n')
327 else:
328 print('Change status is ' + item['status'] + '. Skipping the cherry pick.\nUse -f to force this pick.')
329 continue
330
331 # Convert the project name to a project path
332 # - check that the project path exists
333 project_path = None
334
335 if item['project'] in project_name_to_data and item['branch'] in project_name_to_data[item['project']]:
336 project_path = project_name_to_data[item['project']][item['branch']]
337 elif args.path:
338 project_path = args.path
339 elif args.ignore_missing:
340 print('WARNING: Skipping {0} since there is no project directory for: {1}\n'.format(item['id'], item['project']))
341 continue
342 else:
343 sys.stderr.write('ERROR: For {0}, could not determine the project path for project {1}\n'.format(item['id'], item['project']))
344 sys.exit(1)
345
346 # If --start-branch is given, create the branch (more than once per path is okay; repo ignores gracefully)
347 if args.start_branch:
348 subprocess.check_output(['repo', 'start', args.start_branch[0], project_path])
349
350 # Determine the maximum commits to check already picked changes
Adrian DCdf290222017-09-07 22:59:54 +0200351 check_picked_count = args.check_picked
Michael Bestas3952f6c2016-08-26 01:12:08 +0300352 branch_commits_count = int(subprocess.check_output(['git', 'rev-list', '--count', 'HEAD'], cwd=project_path))
353 if branch_commits_count <= check_picked_count:
354 check_picked_count = branch_commits_count - 1
355
356 # Check if change is already picked to HEAD...HEAD~check_picked_count
357 found_change = False
358 for i in range(0, check_picked_count):
Adrian DC13b02ff2016-12-04 12:30:26 +0100359 if subprocess.call(['git', 'cat-file', '-e', 'HEAD~{0}'.format(i)], cwd=project_path, stderr=open(os.devnull, 'wb')):
360 continue
Michael Bestas3952f6c2016-08-26 01:12:08 +0300361 output = subprocess.check_output(['git', 'show', '-q', 'HEAD~{0}'.format(i)], cwd=project_path).split()
362 if 'Change-Id:' in output:
363 head_change_id = ''
364 for j,t in enumerate(reversed(output)):
365 if t == 'Change-Id:':
366 head_change_id = output[len(output) - j]
367 break
368 if head_change_id.strip() == item['change_id']:
369 print('Skipping {0} - already picked in {1} as HEAD~{2}'.format(item['id'], project_path, i))
370 found_change = True
371 break
372 if found_change:
373 continue
374
375 # Print out some useful info
376 if not args.quiet:
Dan Pasanend4ee2f62017-03-16 15:25:29 -0500377 print('--> Subject: "{0}"'.format(item['subject'].encode('utf-8')))
Michael Bestas3952f6c2016-08-26 01:12:08 +0300378 print('--> Project path: {0}'.format(project_path))
379 print('--> Change number: {0} (Patch Set {0})'.format(item['id']))
380
381 if 'anonymous http' in item['fetch']:
382 method = 'anonymous http'
383 else:
384 method = 'ssh'
385
386 # Try fetching from GitHub first if using default gerrit
387 if args.gerrit == default_gerrit:
388 if args.verbose:
389 print('Trying to fetch the change from GitHub')
390
391 if args.pull:
392 cmd = ['git pull --no-edit github', item['fetch'][method]['ref']]
393 else:
394 cmd = ['git fetch github', 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 FETCH_HEAD = '{0}/.git/FETCH_HEAD'.format(project_path)
401 if result != 0 and os.stat(FETCH_HEAD).st_size != 0:
402 print('ERROR: git command failed')
403 sys.exit(result)
404 # Check if it worked
405 if args.gerrit != default_gerrit or os.stat(FETCH_HEAD).st_size == 0:
406 # If not using the default gerrit or github failed, fetch from gerrit.
407 if args.verbose:
408 if args.gerrit == default_gerrit:
409 print('Fetching from GitHub didn\'t work, trying to fetch the change from Gerrit')
410 else:
411 print('Fetching from {0}'.format(args.gerrit))
412
413 if args.pull:
414 cmd = ['git pull --no-edit', item['fetch'][method]['url'], item['fetch'][method]['ref']]
415 else:
416 cmd = ['git fetch', item['fetch'][method]['url'], item['fetch'][method]['ref']]
417 if args.quiet:
418 cmd.append('--quiet')
419 else:
420 print(cmd)
421 result = subprocess.call([' '.join(cmd)], cwd=project_path, shell=True)
422 if result != 0:
423 print('ERROR: git command failed')
424 sys.exit(result)
425 # Perform the cherry-pick
426 if not args.pull:
427 cmd = ['git cherry-pick FETCH_HEAD']
428 if args.quiet:
429 cmd_out = open(os.devnull, 'wb')
430 else:
431 cmd_out = None
432 result = subprocess.call(cmd, cwd=project_path, shell=True, stdout=cmd_out, stderr=cmd_out)
433 if result != 0:
Harry Youd1c9c5a32017-07-18 18:52:42 +0100434 if args.reset:
435 print('ERROR: git command failed, aborting cherry-pick')
436 cmd = ['git cherry-pick --abort']
437 subprocess.call(cmd, cwd=project_path, shell=True, stdout=cmd_out, stderr=cmd_out)
438 else:
439 print('ERROR: git command failed')
Michael Bestas3952f6c2016-08-26 01:12:08 +0300440 sys.exit(result)
441 if not args.quiet:
442 print('')