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 set_topic(url_opener, gerrit_url, change_id, name):
261    """Set the topic name."""
262
263    url = '{}/a/changes/{}/topic'.format(gerrit_url, change_id)
264    data = {'topic': name}
265    return _make_json_post_request(url_opener, url, data, method='PUT')
266
267
268def delete_topic(url_opener, gerrit_url, change_id):
269    """Delete the topic name."""
270
271    url = '{}/a/changes/{}/topic'.format(gerrit_url, change_id)
272
273    return _make_json_post_request(url_opener, url, {}, method='DELETE')
274
275
276def set_hashtags(url_opener, gerrit_url, change_id, add_tags=None,
277                 remove_tags=None):
278    """Add or remove hash tags."""
279
280    url = '{}/a/changes/{}/hashtags'.format(gerrit_url, change_id)
281
282    data = {}
283    if add_tags:
284        data['add'] = add_tags
285    if remove_tags:
286        data['remove'] = remove_tags
287
288    return _make_json_post_request(url_opener, url, data)
289
290
291def add_reviewers(url_opener, gerrit_url, change_id, reviewers):
292    """Add reviewers."""
293
294    url = '{}/a/changes/{}/revisions/current/review'.format(
295        gerrit_url, change_id)
296
297    data = {}
298    if reviewers:
299        data['reviewers'] = reviewers
300
301    return _make_json_post_request(url_opener, url, data)
302
303
304def delete_reviewer(url_opener, gerrit_url, change_id, name):
305    """Delete reviewer."""
306
307    url = '{}/a/changes/{}/reviewers/{}/delete'.format(
308        gerrit_url, change_id, name)
309
310    return _make_json_post_request(url_opener, url, {})
311
312
313def get_patch(url_opener, gerrit_url, change_id, revision_id='current'):
314    """Download the patch file."""
315
316    url = '{}/a/changes/{}/revisions/{}/patch'.format(
317        gerrit_url, change_id, revision_id)
318
319    response_file = url_opener.open(url)
320    try:
321        return base64.b64decode(response_file.read())
322    finally:
323        response_file.close()
324
325def find_gerrit_name():
326    """Find the gerrit instance specified in the default remote."""
327    manifest_cmd = ['repo', 'manifest']
328    raw_manifest_xml = run(manifest_cmd, stdout=PIPE, check=True).stdout
329
330    manifest_xml = xml.dom.minidom.parseString(raw_manifest_xml)
331    default_remote = manifest_xml.getElementsByTagName('default')[0]
332    default_remote_name = default_remote.getAttribute('remote')
333    for remote in manifest_xml.getElementsByTagName('remote'):
334        name = remote.getAttribute('name')
335        review = remote.getAttribute('review')
336        if review and name == default_remote_name:
337            return review
338
339    raise ValueError('cannot find gerrit URL from manifest')
340
341def _parse_args():
342    """Parse command line options."""
343    parser = argparse.ArgumentParser()
344
345    parser.add_argument('query', help='Change list query string')
346    parser.add_argument('-g', '--gerrit', help='Gerrit review URL')
347
348    parser.add_argument('--gitcookies',
349                        default=os.path.expanduser('~/.gitcookies'),
350                        help='Gerrit cookie file')
351    parser.add_argument('--limits', default=1000,
352                        help='Max number of change lists')
353
354    return parser.parse_args()
355
356
357def main():
358    """Main function"""
359    args = _parse_args()
360
361    if not args.gerrit:
362        try:
363            args.gerrit = find_gerrit_name()
364        # pylint: disable=bare-except
365        except:
366            print('gerrit instance not found, use [-g GERRIT]')
367            sys.exit(1)
368
369    # Query change lists
370    url_opener = create_url_opener_from_args(args)
371    change_lists = query_change_lists(
372        url_opener, args.gerrit, args.query, args.limits)
373
374    # Print the result
375    json.dump(change_lists, sys.stdout, indent=4, separators=(', ', ': '))
376    print()  # Print the end-of-line
377
378if __name__ == '__main__':
379    main()
380