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, 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
191def _make_json_post_request(url_opener, url, data, method='POST'):
192    """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
200    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
207
208    try:
209        response_file = url_opener.open(request)
210    except HTTPError as error:
211        response_file = error
212
213    with response_file:
214        res_code = response_file.getcode()
215        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)
223
224
225def 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
236
237    return _make_json_post_request(url_opener, url, data)
238
239
240def 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
248def 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
256
257    return _make_json_post_request(url_opener, url, data)
258
259
260def 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
268def 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)
280
281    return _make_json_post_request(url_opener, url, {}, method='DELETE')
282
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
299def 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
321def 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
333def 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:
345            return review.rstrip('/')
346
347    raise ValueError('cannot find gerrit URL from manifest')
348
349def _parse_args():
350    """Parse command line options."""
351    parser = argparse.ArgumentParser()
352
353    parser.add_argument('query', help='Change list query string')
354    parser.add_argument('-g', '--gerrit', help='Gerrit review URL')
355
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():
366    """Main function"""
367    args = _parse_args()
368
369    if not args.gerrit:
370        try:
371            args.gerrit = find_gerrit_name()
372        # pylint: disable=bare-except
373        except:
374            print('gerrit instance not found, use [-g GERRIT]')
375            sys.exit(1)
376
377    # 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()
388