• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#!/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
19"""Gerrit Restful API client library."""
20
21from __future__ import print_function
22
23import argparse
24import base64
25import json
26import os
27import sys
28import xml.dom.minidom
29
30try:
31    # PY3
32    from urllib.error import HTTPError
33    from urllib.parse import urlencode, urlparse
34    from urllib.request import (
35        HTTPBasicAuthHandler, Request, build_opener
36    )
37except ImportError:
38    # PY2
39    from urllib import urlencode
40    from urllib2 import (
41        HTTPBasicAuthHandler, HTTPError, Request, build_opener
42    )
43    from urlparse import urlparse
44
45try:
46    # PY3.5
47    from subprocess import PIPE, run
48except 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
87def load_auth_credentials_from_file(cookie_file):
88    """Load credentials from an opened .gitcookies file."""
89    credentials = {}
90    for line in cookie_file:
91        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):
152    """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    """
161
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, start, count):
175    """Query change lists from the Gerrit server with a single request.
176
177    This function performs a single query of the Gerrit server based on the
178    input parameters for a list of changes.  The server may return less than
179    the number of changes requested.  The caller should check the last record
180    returned for the _more_changes attribute to determine if more changes are
181    available and perform additional queries adjusting the start index.
182
183    Args:
184        url_opener:  URL opener for request
185        gerrit: Gerrit server URL
186        query_string: Gerrit query string to select changes
187        start: Number of changes to be skipped from the beginning
188        count: Maximum number of changes to return
189
190    Returns:
191        List of changes
192    """
193    data = [
194        ('q', query_string),
195        ('o', 'CURRENT_REVISION'),
196        ('o', 'CURRENT_COMMIT'),
197        ('start', str(start)),
198        ('n', str(count)),
199    ]
200    url = gerrit + '/a/changes/?' + urlencode(data)
201
202    response_file = url_opener.open(url)
203    try:
204        return _decode_xssi_json(response_file.read())
205    finally:
206        response_file.close()
207
208def query_change_lists(url_opener, gerrit, query_string, start, count):
209    """Query change lists from the Gerrit server.
210
211    This function queries the Gerrit server based on the input parameters for a
212    list of changes.  This function handles querying the server multiple times
213    if necessary and combining the results that are returned to the caller.
214
215    Args:
216        url_opener:  URL opener for request
217        gerrit: Gerrit server URL
218        query_string: Gerrit query string to select changes
219        start: Number of changes to be skipped from the beginning
220        count: Maximum number of changes to return
221
222    Returns:
223        List of changes
224    """
225    changes = []
226    while len(changes) < count:
227        chunk = _query_change_lists(url_opener, gerrit, query_string,
228                                    start + len(changes), count - len(changes))
229        if not chunk:
230            break
231
232        changes += chunk
233
234        # The last change object contains a _more_changes attribute if the
235        # number of changes exceeds the query parameter or the internal server
236        # limit.  Stop iteration if `_more_changes` attribute doesn't exist.
237        if '_more_changes' not in chunk[-1]:
238            break
239
240    return changes
241
242
243def _make_json_post_request(url_opener, url, data, method='POST'):
244    """Open an URL request and decode its response.
245
246    Returns a 3-tuple of (code, body, json).
247        code: A numerical value, the HTTP status code of the response.
248        body: A bytes, the response body.
249        json: An object, the parsed JSON response.
250    """
251
252    data = json.dumps(data).encode('utf-8')
253    headers = {
254        'Content-Type': 'application/json; charset=UTF-8',
255    }
256
257    request = Request(url, data, headers)
258    request.get_method = lambda: method
259
260    try:
261        response_file = url_opener.open(request)
262    except HTTPError as error:
263        response_file = error
264
265    with response_file:
266        res_code = response_file.getcode()
267        res_body = response_file.read()
268        try:
269            res_json = _decode_xssi_json(res_body)
270        except ValueError:
271            # The response isn't JSON if it doesn't start with a XSSI token.
272            # Possibly a plain text error message or empty body.
273            res_json = None
274        return (res_code, res_body, res_json)
275
276
277def set_review(url_opener, gerrit_url, change_id, labels, message):
278    """Set review votes to a change list."""
279
280    url = '{}/a/changes/{}/revisions/current/review'.format(
281        gerrit_url, change_id)
282
283    data = {}
284    if labels:
285        data['labels'] = labels
286    if message:
287        data['message'] = message
288
289    return _make_json_post_request(url_opener, url, data)
290
291
292def submit(url_opener, gerrit_url, change_id):
293    """Submit a change list."""
294
295    url = '{}/a/changes/{}/submit'.format(gerrit_url, change_id)
296
297    return _make_json_post_request(url_opener, url, {})
298
299
300def abandon(url_opener, gerrit_url, change_id, message):
301    """Abandon a change list."""
302
303    url = '{}/a/changes/{}/abandon'.format(gerrit_url, change_id)
304
305    data = {}
306    if message:
307        data['message'] = message
308
309    return _make_json_post_request(url_opener, url, data)
310
311
312def restore(url_opener, gerrit_url, change_id):
313    """Restore a change list."""
314
315    url = '{}/a/changes/{}/restore'.format(gerrit_url, change_id)
316
317    return _make_json_post_request(url_opener, url, {})
318
319
320def set_topic(url_opener, gerrit_url, change_id, name):
321    """Set the topic name."""
322
323    url = '{}/a/changes/{}/topic'.format(gerrit_url, change_id)
324    data = {'topic': name}
325    return _make_json_post_request(url_opener, url, data, method='PUT')
326
327
328def delete_topic(url_opener, gerrit_url, change_id):
329    """Delete the topic name."""
330
331    url = '{}/a/changes/{}/topic'.format(gerrit_url, change_id)
332
333    return _make_json_post_request(url_opener, url, {}, method='DELETE')
334
335
336def set_hashtags(url_opener, gerrit_url, change_id, add_tags=None,
337                 remove_tags=None):
338    """Add or remove hash tags."""
339
340    url = '{}/a/changes/{}/hashtags'.format(gerrit_url, change_id)
341
342    data = {}
343    if add_tags:
344        data['add'] = add_tags
345    if remove_tags:
346        data['remove'] = remove_tags
347
348    return _make_json_post_request(url_opener, url, data)
349
350
351def add_reviewers(url_opener, gerrit_url, change_id, reviewers):
352    """Add reviewers."""
353
354    url = '{}/a/changes/{}/revisions/current/review'.format(
355        gerrit_url, change_id)
356
357    data = {}
358    if reviewers:
359        data['reviewers'] = reviewers
360
361    return _make_json_post_request(url_opener, url, data)
362
363
364def delete_reviewer(url_opener, gerrit_url, change_id, name):
365    """Delete reviewer."""
366
367    url = '{}/a/changes/{}/reviewers/{}/delete'.format(
368        gerrit_url, change_id, name)
369
370    return _make_json_post_request(url_opener, url, {})
371
372
373def get_patch(url_opener, gerrit_url, change_id, revision_id='current'):
374    """Download the patch file."""
375
376    url = '{}/a/changes/{}/revisions/{}/patch'.format(
377        gerrit_url, change_id, revision_id)
378
379    response_file = url_opener.open(url)
380    try:
381        return base64.b64decode(response_file.read())
382    finally:
383        response_file.close()
384
385def find_gerrit_name():
386    """Find the gerrit instance specified in the default remote."""
387    manifest_cmd = ['repo', 'manifest']
388    raw_manifest_xml = run(manifest_cmd, stdout=PIPE, check=True).stdout
389
390    manifest_xml = xml.dom.minidom.parseString(raw_manifest_xml)
391    default_remote = manifest_xml.getElementsByTagName('default')[0]
392    default_remote_name = default_remote.getAttribute('remote')
393    for remote in manifest_xml.getElementsByTagName('remote'):
394        name = remote.getAttribute('name')
395        review = remote.getAttribute('review')
396        if review and name == default_remote_name:
397            return review.rstrip('/')
398
399    raise ValueError('cannot find gerrit URL from manifest')
400
401def normalize_gerrit_name(gerrit):
402    """Strip the trailing slashes because Gerrit will return 404 when there are
403    redundant trailing slashes."""
404    return gerrit.rstrip('/')
405
406def _parse_args():
407    """Parse command line options."""
408    parser = argparse.ArgumentParser()
409
410    parser.add_argument('query', help='Change list query string')
411    parser.add_argument('-g', '--gerrit', help='Gerrit review URL')
412
413    parser.add_argument('--gitcookies',
414                        default=os.path.expanduser('~/.gitcookies'),
415                        help='Gerrit cookie file')
416    parser.add_argument('--limits', default=1000, type=int,
417                        help='Max number of change lists')
418    parser.add_argument('--start', default=0, type=int,
419                        help='Skip first N changes in query')
420    parser.add_argument('--format', default='json',
421                        choices=['json', 'oneline'],
422                        help='Print format')
423
424    return parser.parse_args()
425
426def main():
427    """Main function"""
428    args = _parse_args()
429
430    if args.gerrit:
431        args.gerrit = normalize_gerrit_name(args.gerrit)
432    else:
433        try:
434            args.gerrit = find_gerrit_name()
435        # pylint: disable=bare-except
436        except:
437            print('gerrit instance not found, use [-g GERRIT]')
438            sys.exit(1)
439
440    # Query change lists
441    url_opener = create_url_opener_from_args(args)
442    change_lists = query_change_lists(
443        url_opener, args.gerrit, args.query, args.start, args.limits)
444
445    # Print the result
446    if args.format == 'json':
447        json.dump(change_lists, sys.stdout, indent=4, separators=(', ', ': '))
448        print()  # Print the end-of-line
449    elif args.format == 'oneline':
450        for i, change in enumerate(change_lists):
451            print('{i:<8} {number:<16} {status:<20} ' \
452                  '{change_id:<60} {project:<120} ' \
453                  '{subject}'.format(i=i,
454                                     project=change['project'],
455                                     change_id=change['change_id'],
456                                     status=change['status'],
457                                     number=change['_number'],
458                                     subject=change['subject']))
459
460
461if __name__ == '__main__':
462    main()
463