blob: 9679d9d0cf95dcb34a63b656c39ef17fa2f7f30e [file] [log] [blame]
Logan Chien3f67c282018-06-06 18:16:31 +08001#!/usr/bin/env python3
2
3#
4# Copyright (C) 2018 The Android Open Source Project
5#
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
Logan Chienbfb414f2018-06-15 16:06:56 +080019"""Gerrit Restful API client library."""
20
Logan Chien3f67c282018-06-06 18:16:31 +080021from __future__ import print_function
22
23import argparse
Logan Chien4721aa12018-06-07 10:50:15 +080024import base64
Logan Chien3f67c282018-06-06 18:16:31 +080025import json
26import os
27import sys
Luca Stefanibd2e5802020-07-01 12:23:29 +020028import xml.dom.minidom
Logan Chien3f67c282018-06-06 18:16:31 +080029
30try:
Luca Stefani5e452992020-07-15 11:10:18 +020031 # PY3
32 from urllib.error import HTTPError
33 from urllib.parse import urlencode, urlparse
Bowgo Tsai33b91c12018-06-08 17:00:46 +080034 from urllib.request import (
Luca Stefani5e452992020-07-15 11:10:18 +020035 HTTPBasicAuthHandler, Request, build_opener
36 )
Logan Chien3f67c282018-06-06 18:16:31 +080037except ImportError:
Luca Stefani5e452992020-07-15 11:10:18 +020038 # PY2
39 from urllib import urlencode
Bowgo Tsai33b91c12018-06-08 17:00:46 +080040 from urllib2 import (
Luca Stefani5e452992020-07-15 11:10:18 +020041 HTTPBasicAuthHandler, HTTPError, Request, build_opener
42 )
43 from urlparse import urlparse
Logan Chien3f67c282018-06-06 18:16:31 +080044
45try:
Luca Stefani5e452992020-07-15 11:10:18 +020046 # PY3.5
47 from subprocess import PIPE, run
Luca Stefanibd2e5802020-07-01 12:23:29 +020048except ImportError:
49 from subprocess import CalledProcessError, PIPE, Popen
50
51 class CompletedProcess(object):
52 """Process execution result returned by subprocess.run()."""
53 # pylint: disable=too-few-public-methods
54
55 def __init__(self, args, returncode, stdout, stderr):
56 self.args = args
57 self.returncode = returncode
58 self.stdout = stdout
59 self.stderr = stderr
60
61 def run(*args, **kwargs):
62 """Run a command with subprocess.Popen() and redirect input/output."""
63
64 check = kwargs.pop('check', False)
65
66 try:
67 stdin = kwargs.pop('input')
68 assert 'stdin' not in kwargs
69 kwargs['stdin'] = PIPE
70 except KeyError:
71 stdin = None
72
73 proc = Popen(*args, **kwargs)
74 try:
75 stdout, stderr = proc.communicate(stdin)
76 except:
77 proc.kill()
78 proc.wait()
79 raise
80 returncode = proc.wait()
81
82 if check and returncode:
83 raise CalledProcessError(returncode, args, stdout)
84 return CompletedProcess(args, returncode, stdout, stderr)
85
86
Logan Chien3f67c282018-06-06 18:16:31 +080087def load_auth_credentials_from_file(cookie_file):
88 """Load credentials from an opened .gitcookies file."""
89 credentials = {}
Logan Chienbfb414f2018-06-15 16:06:56 +080090 for line in cookie_file:
Logan Chien3f67c282018-06-06 18:16:31 +080091 if line.startswith('#HttpOnly_'):
92 line = line[len('#HttpOnly_'):]
93
94 if not line or line[0] == '#':
95 continue
96
97 row = line.split('\t')
98 if len(row) != 7:
99 continue
100
101 domain = row[0]
102 cookie = row[6]
103
104 sep = cookie.find('=')
105 if sep == -1:
106 continue
107 username = cookie[0:sep]
108 password = cookie[sep + 1:]
109
110 credentials[domain] = (username, password)
111 return credentials
112
113
114def load_auth_credentials(cookie_file_path):
115 """Load credentials from a .gitcookies file path."""
116 with open(cookie_file_path, 'r') as cookie_file:
117 return load_auth_credentials_from_file(cookie_file)
118
119
120def create_url_opener(cookie_file_path, domain):
121 """Load username and password from .gitcookies and return a URL opener with
122 an authentication handler."""
123
124 # Load authentication credentials
125 credentials = load_auth_credentials(cookie_file_path)
126 username, password = credentials[domain]
127
128 # Create URL opener with authentication handler
129 auth_handler = HTTPBasicAuthHandler()
130 auth_handler.add_password(domain, domain, username, password)
131 return build_opener(auth_handler)
132
133
134def create_url_opener_from_args(args):
135 """Create URL opener from command line arguments."""
136
137 domain = urlparse(args.gerrit).netloc
138
139 try:
140 return create_url_opener(args.gitcookies, domain)
141 except KeyError:
142 print('error: Cannot find the domain "{}" in "{}". '
143 .format(domain, args.gitcookies), file=sys.stderr)
144 print('error: Please check the Gerrit Code Review URL or follow the '
145 'instructions in '
146 'https://android.googlesource.com/platform/development/'
147 '+/master/tools/repo_pull#installation', file=sys.stderr)
148 sys.exit(1)
149
150
151def _decode_xssi_json(data):
Yo Chiang5f451a42020-04-21 13:36:18 +0800152 """Trim XSSI protector and decode JSON objects.
153
154 Returns:
155 An object returned by json.loads().
156
157 Raises:
158 ValueError: If data doesn't start with a XSSI token.
159 json.JSONDecodeError: If data failed to decode.
160 """
Logan Chien3f67c282018-06-06 18:16:31 +0800161
162 # Decode UTF-8
163 data = data.decode('utf-8')
164
165 # Trim cross site script inclusion (XSSI) protector
166 if data[0:4] != ')]}\'':
167 raise ValueError('unexpected responsed content: ' + data)
168 data = data[4:]
169
170 # Parse JSON objects
171 return json.loads(data)
172
173
174def query_change_lists(url_opener, gerrit, query_string, limits):
175 """Query change lists."""
176 data = [
177 ('q', query_string),
178 ('o', 'CURRENT_REVISION'),
179 ('o', 'CURRENT_COMMIT'),
180 ('n', str(limits)),
181 ]
182 url = gerrit + '/a/changes/?' + urlencode(data)
183
184 response_file = url_opener.open(url)
185 try:
186 return _decode_xssi_json(response_file.read())
187 finally:
188 response_file.close()
189
190
Logan Chien63be1582018-09-07 10:39:00 +0800191def _make_json_post_request(url_opener, url, data, method='POST'):
Yo Chiang5f451a42020-04-21 13:36:18 +0800192 """Open an URL request and decode its response.
193
194 Returns a 3-tuple of (code, body, json).
195 code: A numerical value, the HTTP status code of the response.
196 body: A bytes, the response body.
197 json: An object, the parsed JSON response.
198 """
199
Logan Chien63be1582018-09-07 10:39:00 +0800200 data = json.dumps(data).encode('utf-8')
201 headers = {
202 'Content-Type': 'application/json; charset=UTF-8',
203 }
204
205 request = Request(url, data, headers)
206 request.get_method = lambda: method
Yo Chiangdc9a2b72020-04-20 17:59:23 +0800207
Logan Chien63be1582018-09-07 10:39:00 +0800208 try:
Yo Chiangdc9a2b72020-04-20 17:59:23 +0800209 response_file = url_opener.open(request)
210 except HTTPError as error:
211 response_file = error
212
213 with response_file:
Logan Chien63be1582018-09-07 10:39:00 +0800214 res_code = response_file.getcode()
Yo Chiang5f451a42020-04-21 13:36:18 +0800215 res_body = response_file.read()
216 try:
217 res_json = _decode_xssi_json(res_body)
218 except ValueError:
219 # The response isn't JSON if it doesn't start with a XSSI token.
220 # Possibly a plain text error message or empty body.
221 res_json = None
222 return (res_code, res_body, res_json)
Logan Chien63be1582018-09-07 10:39:00 +0800223
224
Logan Chien3f67c282018-06-06 18:16:31 +0800225def set_review(url_opener, gerrit_url, change_id, labels, message):
226 """Set review votes to a change list."""
227
228 url = '{}/a/changes/{}/revisions/current/review'.format(
229 gerrit_url, change_id)
230
231 data = {}
232 if labels:
233 data['labels'] = labels
234 if message:
235 data['message'] = message
Logan Chien3f67c282018-06-06 18:16:31 +0800236
Logan Chien63be1582018-09-07 10:39:00 +0800237 return _make_json_post_request(url_opener, url, data)
Logan Chien3f67c282018-06-06 18:16:31 +0800238
239
Po-Chien Hsueh4e832372020-05-21 12:43:12 +0800240def submit(url_opener, gerrit_url, change_id):
241 """Submit a change list."""
242
243 url = '{}/a/changes/{}/submit'.format(gerrit_url, change_id)
244
245 return _make_json_post_request(url_opener, url, {})
246
247
Logan Chienb260a602018-07-03 10:27:55 +0800248def abandon(url_opener, gerrit_url, change_id, message):
249 """Abandon a change list."""
250
251 url = '{}/a/changes/{}/abandon'.format(gerrit_url, change_id)
252
253 data = {}
254 if message:
255 data['message'] = message
Logan Chienb260a602018-07-03 10:27:55 +0800256
Logan Chien63be1582018-09-07 10:39:00 +0800257 return _make_json_post_request(url_opener, url, data)
Logan Chienb260a602018-07-03 10:27:55 +0800258
259
Taesu Lee6c7a7932021-04-26 11:34:05 +0900260def restore(url_opener, gerrit_url, change_id):
261 """Restore a change list."""
262
263 url = '{}/a/changes/{}/restore'.format(gerrit_url, change_id)
264
265 return _make_json_post_request(url_opener, url, {})
266
267
Logan Chien2da2f272018-09-07 11:47:37 +0800268def set_topic(url_opener, gerrit_url, change_id, name):
269 """Set the topic name."""
270
271 url = '{}/a/changes/{}/topic'.format(gerrit_url, change_id)
272 data = {'topic': name}
273 return _make_json_post_request(url_opener, url, data, method='PUT')
274
275
276def delete_topic(url_opener, gerrit_url, change_id):
277 """Delete the topic name."""
278
279 url = '{}/a/changes/{}/topic'.format(gerrit_url, change_id)
Yo Chiang5f451a42020-04-21 13:36:18 +0800280
281 return _make_json_post_request(url_opener, url, {}, method='DELETE')
Logan Chien2da2f272018-09-07 11:47:37 +0800282
283
284def set_hashtags(url_opener, gerrit_url, change_id, add_tags=None,
285 remove_tags=None):
286 """Add or remove hash tags."""
287
288 url = '{}/a/changes/{}/hashtags'.format(gerrit_url, change_id)
289
290 data = {}
291 if add_tags:
292 data['add'] = add_tags
293 if remove_tags:
294 data['remove'] = remove_tags
295
296 return _make_json_post_request(url_opener, url, data)
297
298
Yo Chiangc8827d12020-04-20 16:51:34 +0800299def add_reviewers(url_opener, gerrit_url, change_id, reviewers):
300 """Add reviewers."""
301
302 url = '{}/a/changes/{}/revisions/current/review'.format(
303 gerrit_url, change_id)
304
305 data = {}
306 if reviewers:
307 data['reviewers'] = reviewers
308
309 return _make_json_post_request(url_opener, url, data)
310
311
312def delete_reviewer(url_opener, gerrit_url, change_id, name):
313 """Delete reviewer."""
314
315 url = '{}/a/changes/{}/reviewers/{}/delete'.format(
316 gerrit_url, change_id, name)
317
318 return _make_json_post_request(url_opener, url, {})
319
320
Yo Chiang5f451a42020-04-21 13:36:18 +0800321def get_patch(url_opener, gerrit_url, change_id, revision_id='current'):
322 """Download the patch file."""
323
324 url = '{}/a/changes/{}/revisions/{}/patch'.format(
325 gerrit_url, change_id, revision_id)
326
327 response_file = url_opener.open(url)
328 try:
329 return base64.b64decode(response_file.read())
330 finally:
331 response_file.close()
332
Luca Stefanibd2e5802020-07-01 12:23:29 +0200333def find_gerrit_name():
334 """Find the gerrit instance specified in the default remote."""
335 manifest_cmd = ['repo', 'manifest']
336 raw_manifest_xml = run(manifest_cmd, stdout=PIPE, check=True).stdout
337
338 manifest_xml = xml.dom.minidom.parseString(raw_manifest_xml)
339 default_remote = manifest_xml.getElementsByTagName('default')[0]
340 default_remote_name = default_remote.getAttribute('remote')
341 for remote in manifest_xml.getElementsByTagName('remote'):
342 name = remote.getAttribute('name')
343 review = remote.getAttribute('review')
344 if review and name == default_remote_name:
Yo Chiang36d90ee2020-08-07 12:42:50 +0800345 return review.rstrip('/')
Luca Stefanibd2e5802020-07-01 12:23:29 +0200346
347 raise ValueError('cannot find gerrit URL from manifest')
Yo Chiang5f451a42020-04-21 13:36:18 +0800348
Logan Chien3f67c282018-06-06 18:16:31 +0800349def _parse_args():
350 """Parse command line options."""
351 parser = argparse.ArgumentParser()
352
353 parser.add_argument('query', help='Change list query string')
Luca Stefanibd2e5802020-07-01 12:23:29 +0200354 parser.add_argument('-g', '--gerrit', help='Gerrit review URL')
Logan Chien3f67c282018-06-06 18:16:31 +0800355
356 parser.add_argument('--gitcookies',
357 default=os.path.expanduser('~/.gitcookies'),
358 help='Gerrit cookie file')
359 parser.add_argument('--limits', default=1000,
360 help='Max number of change lists')
361
362 return parser.parse_args()
363
364
365def main():
Logan Chienbfb414f2018-06-15 16:06:56 +0800366 """Main function"""
Logan Chien3f67c282018-06-06 18:16:31 +0800367 args = _parse_args()
368
Luca Stefanibd2e5802020-07-01 12:23:29 +0200369 if not args.gerrit:
370 try:
371 args.gerrit = find_gerrit_name()
Luca Stefaniedc569c2020-07-15 11:17:07 +0200372 # pylint: disable=bare-except
Luca Stefanibd2e5802020-07-01 12:23:29 +0200373 except:
374 print('gerrit instance not found, use [-g GERRIT]')
375 sys.exit(1)
376
Logan Chien3f67c282018-06-06 18:16:31 +0800377 # Query change lists
378 url_opener = create_url_opener_from_args(args)
379 change_lists = query_change_lists(
380 url_opener, args.gerrit, args.query, args.limits)
381
382 # Print the result
383 json.dump(change_lists, sys.stdout, indent=4, separators=(', ', ': '))
384 print() # Print the end-of-line
385
386if __name__ == '__main__':
387 main()