View difference between Paste ID: gCSpnirN and 3zeYm2qC
SHOW: | | - or go back to the newest paste.
1
#!/usr/bin/env python
2
# -*- coding: utf-8
3
4
from __future__ import division
5
from __future__ import absolute_import
6
from __future__ import print_function
7
8
import os.path
9
import os
10
import ConfigParser
11
import sys
12
import argparse
13
import logging
14
import json
15
import urllib
16
import urllib2
17
import urlparse
18
import base64
19
import subprocess
20
import re
21
import getpass
22
import traceback
23
import locale
24
25
MIN_CRUCIBLE_VERSION = '3.0.0'
26
SCRIPT_NAME = os.path.basename(__file__)
27
SCRIPT_VERSION='732ec691cc8ddde4b140f2b2022347c2'
28
29
30
### subprocess wrappers
31
def check_output(*popenargs, **kwargs):
32
    """Run command with arguments and return its output as a byte string."""
33
    if 'stdout' in kwargs or 'stderr' in kwargs:
34
        raise ValueError('stdout argument not allowed, it will be overridden.')
35
    logging.debug('Trying to execute %s', popenargs)
36
    process = subprocess.Popen(stdout=subprocess.PIPE, stderr=subprocess.PIPE, *popenargs, **kwargs)
37
    output, err = process.communicate()
38
    retcode = process.poll()
39
    if retcode:
40
        cmd = kwargs.get("args")
41
        if cmd is None:
42
            cmd = popenargs[0]
43
        logging.debug('Error executing, exit code %s\nstdout=%s\nstderr=%s', retcode, output, err)
44
        raise CalledProcessError(retcode, cmd, output=output, error=err)
45
    logging.debug('Finished executing, exit code 0\nstdout=%s\nstderr=%s', output, err)
46
    return output
47
48
49
class CalledProcessError(subprocess.CalledProcessError):
50
    def __init__(self, returncode, cmd, output=None, error=None):
51
        super(CalledProcessError, self).__init__(returncode, cmd, output)
52
        self.error = error
53
54
55
class Console:
56
    NO_ANSI = sys.platform == 'win32' or not sys.stdout.isatty()
57
    GREEN = '\033[92m'
58
    RED = '\033[91m'
59
    ESCAPE = '\033[0m'
60
61
    @staticmethod
62
    def print(s, color=None):
63
        if Console.NO_ANSI or not color:
64
            print(s)
65
        else:
66
            print('%s%s%s' % (color, s, Console.ESCAPE))
67
68
    @staticmethod
69
    def error(s):
70
        Console.print(s, color=Console.RED)
71
72
    @staticmethod
73
    def success(s):
74
        Console.print(s, color=Console.GREEN)
75
76
77
class HTTPRedirectHandler(urllib2.HTTPRedirectHandler):
78
    """ Override HTTPRedirectHandler to make sure the request data is preserved on redirect """
79
    def redirect_request(self, req, fp, code, msg, headers, newurl):
80
        m = req.get_method()
81
        if code in (301, 302, 303, 307) and m in ("GET", "HEAD") or code in (301, 302, 303) and m == "POST":
82
            newurl = newurl.replace(' ', '%20')
83
            newheaders = dict((k, v) for k, v in req.headers.items() if k.lower() not in 'content-length')
84
            return urllib2.Request(newurl,
85
                                   headers=newheaders,
86
                                   data=req.data,
87
                                   origin_req_host=req.get_origin_req_host(),
88
                                   unverifiable=True)
89
        else:
90
            raise urllib2.HTTPError(req.get_full_url(), code, msg, headers, fp)
91
92
93
class Configuration(object):
94
    """Represents the configuration of the current execution of the script"""
95
    def __init__(self):
96
        self._url = None
97
        self.username = None
98
        self.password = None
99
        self.authtoken = None
100
        self._id = None
101
        self.title = None
102
        self.reviewers = []
103
        self.moderator = None
104
        self.executables = {
105
            'hg': 'hg',
106
            'svn': 'svn',
107
            'git': 'git',
108
            'p4': 'p4',
109
            'cvs': 'cvs',
110
        }
111
        self.repository = None
112
        self.last_project = None
113
        self.initial_fill = True
114
        self.no_anchor = False
115
        self.encoding = None
116
        self.diff_file = None
117
        self.patch_source = None
118
        self.new_patch_source = False
119
120
    @property
121
    def url(self):
122
        return self._url
123
124
    @url.setter
125
    def url(self, value):
126
        self._url = value.rstrip('/').strip() if value else None
127
128
    @property
129
    def id(self):
130
        return self._id
131
132
    @id.setter
133
    def id(self, value):
134
        self._id = value.strip() if value else None
135
136
    def fill_from_defaults(self):
137
        self.url, self.username = self.url or 'http://atlassian-demo:8060', self.username or 'pmonteagudo'
138
        return self
139
140
    def fill_from_config_file(self, config_file):
141
        if not self.url:
142
            self.url = config_file.get_default_url()
143
144
        if not self.password:
145
            stored_token, stored_user = config_file.get_token(self.url)
146
            if not self.username or self.username == stored_user:
147
                self.authtoken, self.username = stored_token, stored_user
148
149
        return self
150
151
    def fill_from_args(self, args):
152
        self.url = args.server or self.url
153
        self.username = args.user or self.username
154
        self.password = args.password or self.password
155
        self.title = args.title or self.title
156
157
        args_dict = vars(args)
158
        self.id = args_dict[str('project/review')] or self.id
159
        if '@reviewer' in args_dict:
160
            for reviewer in args_dict[str('@reviewer')]:
161
                self.reviewers.append(reviewer.lstrip('@').strip())
162
                logging.debug('Parsed reviewers: %s', self.reviewers)
163
        self.moderator = args.moderator.lstrip('@').strip() if args.moderator else self.moderator
164
        self.repository = args.repository or self.repository
165
        self.no_anchor = args.noanchor or self.no_anchor
166
        self.encoding = args.encoding or self.encoding
167
        self.diff_file = args.file or self.diff_file
168
        self.new_patch_source = args.newpatch or self.new_patch_source
169
170
        return self
171
172
    def validate(self, check_title=False, check_id=False):
173
        """Checks if all the required server options are set"""
174
        if self.url and (self.authtoken or (self.username and self.password)) and (not check_id or self.id) and (not check_title or self.title):
175
            return True
176
177
        if not self.url:
178
            Console.error('ERROR: Please specify a Crucible server')
179
        if not self.authtoken and not self.username:
180
            Console.error('ERROR: Please specify a Crucible username')
181
        if not self.authtoken and not self.password:
182
            Console.error('ERROR: Please specify a Crucible password')
183
        if check_id and not self.id:
184
            Console.error('ERROR: Please specify a project or review id')
185
        if check_title and not self.title:
186
            Console.error('ERROR: Please specify a review title')
187
        print(CommandLine().help_blurb())
188
        sys.exit(1)
189
190
    def fill_interactively(self, get_title=False, get_id=False, get_reviewers=False):
191
        """Fills the required parameters by prompting the user if they're not specified"""
192
        if not sys.stdin.isatty():
193
            logging.debug('Not prompting for parameters interactively because stdin is not a tty')
194
        else:
195
            if not self.url:
196
                self.url = raw_input('Please enter the Crucible server URL: ')
197
            elif self.initial_fill:
198
                print('Crucible server: %s' % self.url)
199
200
            if not self.username:
201
                self.username = raw_input('Please enter your Crucible username: ')
202
            elif self.initial_fill:
203
                print("Crucible username: %s" % self.username)
204
205
            if not self.password and not self.authtoken:
206
                self.password = getpass.getpass(str('Please enter your Crucible password: '))
207
208
            if get_id:
209
                while not self.id:
210
                    last_project_prompt = ' [%s]' % self.last_project if self.last_project else ''
211
                    self.id = raw_input('Please specify a project to create the review in or an existing review id to add to%s: ' % last_project_prompt).strip()
212
                    self.id = self.id or self.last_project
213
214
            if get_title:
215
                while not self.title:
216
                    self.title = raw_input('Please specify the review title: ')
217
218
            if get_reviewers and not self.reviewers:
219
                while True:
220
                    reviewer = raw_input('Please specify a reviewer to be added to the review, or press Enter to continue: ').strip()
221
                    if not reviewer:
222
                        break
223
                    if not reviewer in self.reviewers:
224
                        self.reviewers.append(reviewer)
225
226
        self.validate(check_title=get_title, check_id=get_id)
227
        self.initial_fill = False
228
229
        return self
230
231
    def choose_anchor(self, repositories):
232
        """Allows choosing an anchor repository if none detected"""
233
        if not sys.stdin.isatty():
234
            logging.debug('Not prompting for anchor interactively because stdin is not a tty')
235
            return
236
237
        repository_names = [str(repository.get('name'))
238
                            for repository in filter(lambda repository: repository.get('enabled'), repositories)]
239
        while not self.repository and not self.no_anchor:
240
            repository = raw_input("Please choose a repository to anchor to, or press Enter to skip anchoring: ")
241
            if not repository:
242
                self.no_anchor = True
243
            elif repository in repository_names:
244
                self.repository = repository
245
            else:
246
                print('The repository doesn\'t exist or is disabled')
247
248
    def choose_source(self, matching_patch_groups):
249
        """Allows choosing a source from a list of matching ones"""
250
        if len(matching_patch_groups) == 1:
251
            print('Adding patch to existing one: %s. Use --newpatch to add as a new patch instead.' % matching_patch_groups[0]['displayName'])
252
            self.patch_source = matching_patch_groups[0]['sourceName']
253
        elif len(matching_patch_groups) > 1:
254
            if not sys.stdin.isatty():
255
                logging.debug('Not prompting for source interactively because stdin is not a tty')
256
                return
257
258
            print('Found %s patches to add to:' % len(matching_patch_groups))
259
            i = 1
260
            print('0. Create a new patch')
261
            for patch_group in matching_patch_groups:
262
                print('%s. Add to %s' % (i, patch_group['displayName']))
263
                i += 1
264
265
            while not self.patch_source and not self.new_patch_source:
266
                try:
267
                    choice = int(raw_input('Pick patch to add to [0-%s]: ' % len(matching_patch_groups)))
268
                    if 0 < choice <= len(matching_patch_groups):
269
                        self.new_patch_source = False
270
                        self.patch_source = matching_patch_groups[choice - 1]['sourceName']
271
                    elif choice == 0:
272
                        self.new_patch_source = True
273
                        self.patch_source = None
274
                except ValueError:
275
                    pass
276
277
278
279
class ConfigFile:
280
    """Handles reading and storing settings from the config file"""
281
    userConfigPath = os.path.expanduser('~/.atlassian/crucible.conf')
282
    DEFAULT_SECTION = 'DEFAULT'
283
    EXECUTABLES_SECTION = 'executables'
284
    URL = 'url'
285
    TOKEN = 'authtoken'
286
    USER = 'user'
287
288
    def __init__(self):
289
        self.config_parser = ConfigParser.RawConfigParser()
290
        self.config_parser.read([self.userConfigPath])
291
292
    def _get(self, section, key):
293
        if not self.config_parser.has_option(section, key):
294
            return None
295
        return self.config_parser.get(section, key)
296
297
    def get_default_url(self):
298
        return self._get(self.DEFAULT_SECTION, self.URL)
299
300
    def store_configuration(self, configuration):
301
        if self._get(self.DEFAULT_SECTION, self.URL) != configuration.url:
302
            self.config_parser.set(self.DEFAULT_SECTION, self.URL, configuration.url)
303
            print('Saved the default server URL %s to %s' % (configuration.url, self.userConfigPath))
304
305
        self.store_token(configuration.url, configuration.username, configuration.authtoken)
306
        self.save()
307
308
    def get_token(self, url):
309
        if not self.config_parser.has_section(url):
310
            return None, None
311
        return self._get(url, self.TOKEN), self._get(url, self.USER)
312
313
    def store_token(self, url, user, token):
314
        if not self.get_token(url) == (token, user):
315
            if not self.config_parser.has_section(url):
316
                self.config_parser.add_section(url)
317
            self.config_parser.set(url, self.TOKEN, token)
318
            self.config_parser.set(url, self.USER, user)
319
            print ('Saved an authentication token for %s to %s' % (url, self.userConfigPath))
320
321
    def save(self):
322
        try:
323
            if not os.path.exists(os.path.dirname(self.userConfigPath)):
324
                os.makedirs(os.path.dirname(self.userConfigPath))
325
326
            with os.fdopen(os.open(self.userConfigPath, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), 'wb') as configfile:
327
                self.config_parser.write(configfile)
328
        except IOError as e:
329
            print('Error saving the configuration file %s - %s' % (self.userConfigPath, e))
330
            logging.debug(traceback.format_exc())
331
332
333
class CommandLine:
334
    """Handles parsing the commandline parameters"""
335
    def __init__(self):
336
        self.parser = argparse.ArgumentParser(
337
            formatter_class=argparse.RawTextHelpFormatter,
338
            description='Creates reviews in Atlassian Crucible from the command line',
339
            add_help=False,
340
            epilog='''
341
EXAMPLE USAGE:
342
 %(executable)s
343
                        run interactively, try to get a patch from SCM, prompt for review details and create a new review\n
344
 cat diff | %(executable)s CR -m "Review title"
345
                        take the patch from the output of first command, and create a review in project CR with title "Review title"\n
346
 %(executable)s CR @ted @matt --moderator @john
347
                        try to get a patch from SCM, create a review in project CR add ted and matt as reviewers, and john as moderator\n
348
 %(executable)s CR-120
349
                        update the review CR-120, adding the current patch from SCM\n
350
 %(executable)s -r repository1
351
                        create a review and anchor it to repository1, instead of trying to detect the repository automatically\n
352
            ''' % {'executable': SCRIPT_NAME})
353
354
        class HelpAction(argparse._HelpAction):
355
            def __call__(self, parser, namespace, values, option_string=None):
356
                print(parser.format_help()
357
                .replace('usage:', 'USAGE:')
358
                .replace('positional arguments:', 'POSITIONAL ARGUMENTS:')
359
                .replace('optional arguments:', 'OPTIONAL ARGUMENTS:'))
360
                parser.exit()
361
362
        self.parser.add_argument('project/review', type=str, nargs='?', default=None, help='the name of the project to create the review in or the id of a review to update\n\n')
363
        self.parser.add_argument('@reviewer', type=str, nargs='*', default=[], help='the usernames of the reviewers to be added')
364
        self.parser.add_argument('-h', '--help', action=HelpAction, help='show this help message and exit\n\n')
365
        self.parser.add_argument('-m', '--title', type=str, help="the title for the review\n\n")
366
        self.parser.add_argument('-M', '--moderator', type=str, help="the moderator for the review\n\n")
367
        self.parser.add_argument('-r', '--repository', type=str, help='the repository to anchor to\n\n')
368
        self.parser.add_argument('-f', '--file', type=str, help='get the diff from the specified file\n\n')
369
        self.parser.add_argument('-s', '--server', type=str, help="the url of the Crucible server to connect to\n\n")
370
        self.parser.add_argument('-u', '--user', type=str, help="the Crucible username to create the review as\n\n")
371
        self.parser.add_argument('-p', '--password', type=str, help="the Crucible user password\n\n")
372
        self.parser.add_argument('-n', '--noanchor', action='store_const', const=True, help='don\'t try to detect the repository to anchor the patch\n\n')
373
        self.parser.add_argument('-N', '--newpatch', action='store_const', const=True, help='add as a new patch instead of trying to an existing one\n\n')
374
        self.parser.add_argument('-e', '--encoding', type=str, help='the name of the encoding to use, see http://docs.python.org/2/library/codecs.html#standard-encodings\n\n')
375
        self.parser.add_argument('-d', '--debug', action='store_const', const=True, help="print debugging information\n\n")
376
        self.parser.add_argument('-v', '--version', action='version', version=('%s %s' % (SCRIPT_NAME, SCRIPT_VERSION)))
377
378
    def parse_args(self):
379
        args = self.parser.parse_args()
380
        if args.debug:
381
            logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)
382
        if args.noanchor and args.repository:
383
            Console.error('Please choose either --noanchor or --repository')
384
            print(self.help_blurb())
385
            sys.exit(1)
386
387
        return args
388
389
    def help_blurb(self):
390
        return 'Try \'%s --help\' for more information.' % os.path.basename(sys.argv[0])
391
392
class CrucibleRest:
393
    """Encapsulates Crucible REST endopoints"""
394
    timeout = 30000
395
    code_anchor_failed = 'PatchAnchorFailed'
396
    code_content_too_large = 'ChangeSetContentTooLarge'
397
    code_review_content_too_large = 'ReviewContentTooLarge'
398
399
    http_handlers_none_on_errors = {
400
        400: lambda http_error, error_body: None,
401
        404: lambda http_error, error_body: None,
402
    }
403
404
    def __init__(self, configuration):
405
        self.configuration = configuration
406
        self.headers = {
407
            'Content-Type': 'application/json',
408
            'Accept': 'application/json',
409
        }
410
        self.repositories = None
411
412
    def __build_headers(self, custom_headers, use_token):
413
        headers = {}
414
        for key, value in self.headers.items():
415
            headers[key] = value
416
        if not use_token and self.configuration.username and self.configuration.password:
417
            headers['Authorization'] = 'Basic ' + base64.b64encode(
418-
                str('%s:%s') % (self.configuration.username, self.configuration.password)).strip(),
418+
                str('%s:%s') % (self.configuration.username, self.configuration.password)).strip()
419
        for key, value in custom_headers.items():
420
            headers[key] = value
421
        return headers
422
423
    def __build_payload(self, data):
424
        if not data:
425
            return None
426
427
        encodings = []
428
        if self.configuration.encoding:
429
            encodings.append(self.configuration.encoding)
430
        else:
431
            encodings.append('UTF-8')
432
            default_encoding = locale.getdefaultlocale()[1]
433
            if default_encoding and default_encoding not in encodings:
434
                encodings.append(default_encoding)
435
            if sys.stdin.encoding not in encodings:
436
                encodings.append(sys.stdin.encoding)
437
438
        payload = None
439
        for encoding in encodings:
440
            try:
441
                if isinstance(data, unicode):
442
                    payload = data
443
                if isinstance(data, str):
444
                    logging.debug('Trying to encode str as %s', encoding)
445
                    payload = unicode(data, encoding=encoding).encode('utf-8')
446
                else:
447
                    logging.debug('Trying to encode json as %s', encoding)
448
                    payload = json.dumps(data, encoding=encoding)
449
                break
450
            except ValueError:
451
                logging.debug('Encoding failed: %s', traceback.format_exc())
452
        if not payload:
453
            Console.error(
454
                'Error encoding the request (tried %s), please specify an --encoding parameter' % ', '.join(
455
                    encodings))
456
            sys.exit(1)
457
458
        return payload
459
460
    def __build_url(self, url, use_token):
461
        joined_url = self.configuration.url + url
462
        if not use_token:
463
            return joined_url
464
        else:
465
            scheme, netloc, path, query, fragment = urlparse.urlsplit(joined_url)
466
            query_dict = urlparse.parse_qs(query)
467
            query_dict['FEAUTH'] = [self.configuration.authtoken]
468
            return urlparse.urlunsplit((scheme, netloc, path, urllib.urlencode(query_dict, doseq=True), fragment))
469
470
    def _log_request(self, headers, payload, resourceUrl):
471
        resourceUrl = re.sub(r'FEAUTH=(.*)%3A([0-9]+)%3A([0-9a-f]+)', r'FEAUTH=\1%3A\2%3A++SANITIZED++', resourceUrl)
472
        if payload:
473
            payload = re.sub(r'password=(.*)([\s&]?)', r'password=++SANITIZED++\2', payload)
474
        if headers and headers.get('Authorization'):
475
            headers['Authorization'] = '++SANITIZED++'
476
        logging.debug('RestRequest: %s - %s -  %s', resourceUrl, headers, payload)
477
478
    def _log_response(self, response):
479
        if response:
480
            response = re.sub(r'"token":"(.*):([0-9]+):([0-9a-f]+)"', r'"token":"\1:\2:++SANITIZED++"', response)
481
        logging.debug('RestResponse: %s', response)
482
483
    def _request(self, url, data=None, http_handlers = {}, use_token=True, custom_headers={}):
484
        """Executes a REST request the given rest resource, It's a POST if data is set, a GET otherwise"""
485
        resourceUrl = self.__build_url(url, use_token)
486
        headers = self.__build_headers(custom_headers, use_token)
487
        request = urllib2.Request(url=resourceUrl, headers=headers)
488
        payload = self.__build_payload(data)
489
490
        self._log_request(headers, payload, resourceUrl)
491
492
        try:
493
            response = urllib2.build_opener(HTTPRedirectHandler()).open(request, data=payload, timeout=self.timeout).read()
494
        except urllib2.HTTPError as error:
495
            logging.debug('RestError: %s', error)
496
            error_body = error.read()
497
            try:
498
                error_body = json.loads(error_body)
499
            except ValueError:
500
                pass
501
            logging.debug('RestErrorBody: %s', error_body)
502
503
            if error.code in http_handlers:
504
                return http_handlers[error.code](error, error_body)
505
506
            if error.code == 404:
507
                error_msg = error_body['message'] if isinstance(error_body, dict) and 'message' in error_body \
508
                            else 'Please check that %s is a Crucible server and the version is at least %s' % (self.configuration.url, MIN_CRUCIBLE_VERSION)
509
                Console.error('Error : %s' % error_msg)
510
            elif error.code == 401:
511
                Console.error('Authorization error: please check that your username and password are valid, and that you have the correct permissions.')
512
            elif error.code == 403:
513
                error_msg = error_body['message'] if isinstance(error_body, dict) and 'message' in error_body else 'You do not have permission to perform this operation'
514
                permissions_msg = 'Please contact your administrator if you need access'
515
                Console.error('Error: %s\n%s' % (error_msg, permissions_msg))
516
            elif error.code == 500:
517
                Console.error('Server Error: %s' % error_body['message'] if isinstance(error_body, dict) and 'message' in error_body else error_body)
518
            else:
519
                Console.error('Received an unexpected response %s. Please check that %s is a Crucible server' % (error, self.configuration.url))
520
            sys.exit(1)
521
        except Exception as error:
522
            logging.debug(traceback.format_exc())
523
            Console.error('Eror executing request %s - %s' % (resourceUrl, error))
524
            sys.exit(1)
525
526
        self._log_response(response)
527
        if 200 in http_handlers:
528
            return http_handlers[200](response)
529
530
        try:
531
            return json.loads(response)
532
        except ValueError:
533
            return response
534
535
    def check_connection(self):
536
        """ Makes sure we can connect and authenticate with Crucible"""
537
        # if there's a token, check that it's still valid
538
        server_info = None
539
        if self.configuration.authtoken:
540
            server_info = self._request(url="/rest-service-fecru/server-v1", http_handlers={
541
                401: lambda error, error_body: None
542
            })
543
            if not server_info:
544
                print("Your login token has expired, please re-authenticate")
545
                self.configuration.authtoken = None
546
                self.configuration.fill_interactively()
547
548
        # get a token if none defined at this point
549
        if not self.configuration.authtoken:
550
            logging.debug('No authtoken, trying to get one')
551
            login_success, login_response = self._request(
552
                url='/rest-service/auth-v1/login',
553
                data=urllib.urlencode({'userName':self.configuration.username, 'password':self.configuration.password}),
554
                http_handlers={
555
                    403: lambda error, error_body: (False, error_body),
556
                    200: lambda response: (True, json.loads(response))
557
                },
558
                use_token=False,
559
                custom_headers={'Content-Type':'application/x-www-form-urlencoded'}
560
            )
561
562
            if not login_success:
563
                message = login_response['error'] if isinstance(login_response, dict) and 'error' in login_response else 'Please check that the username and password provided are correct.'
564
                Console.error('Error authenticating with Crucible. ' + message)
565
                sys.exit(1)
566
            self.configuration.authtoken = login_response['token']
567
568
        server_info = server_info or self._request(url="/rest-service-fecru/server-v1")
569
        if not server_info['isCrucible']:
570
            Console.error('Connected successfully to %s but no Crucible license is present.' % self.configuration.url)
571
            sys.exit(1)
572
        logging.debug('Connected successfully to %s - Crucible version %s', self.configuration.url, server_info['version']['releaseNumber'])
573
574
    def add_reviewers(self, review_id, reviewers):
575
        """Adds the configuration.reviewers to the review with the given id. Returns True if any reviewers were added"""
576
        if not reviewers: return False
577
578
        logging.debug('Adding reviewers: %s', ', '.join(reviewers))
579
        reviewers_added = False
580
        for reviewer in reviewers:
581
            if reviewer:
582
                reviewer_added, reviewer_msg = self._request("/rest-service/reviews-v1/%s/reviewers" % str(review_id), data=reviewer,
583
                    http_handlers={
584
                        404: lambda http_error, error_body: (False, 'No user \'%s\' found - not adding as a reviewer' % reviewer),
585
                        400: lambda http_error, error_body: (False, error_body['message'] if 'message' in error_body else 'Error adding reviewer %s' % reviewer ),
586
                        200: lambda response: (True, '')
587
                    })
588
                if not reviewer_added:
589
                    print(reviewer_msg)
590
                else:
591
                    reviewers_added = True
592
        return reviewers_added
593
594
    def handle_anchor_error(self, http_error, error_body):
595
        if error_body.get('code') == CrucibleRest.code_anchor_failed:
596
            Console.error('Error: Failed to anchor the patch to repository %s, please check that the repository is the correct one' % self.configuration.repository)
597
            sys.exit(1)
598
        elif error_body.get('code') == CrucibleRest.code_content_too_large or error_body.get('code') == CrucibleRest.code_review_content_too_large:
599
            Console.error('Error: The patch you\'re trying to upload is too large. ' + error_body.get('message'))
600
            sys.exit(1)
601
        else:
602
            Console.error('Received a 409 Conflict response: %s' % error_body['message'])
603
604
    def create_review(self, patch, project):
605
        """Creates a new review from the patch, in the given project"""
606
        logging.debug('Creating new review in project %s', project['key'])
607
        review_data = {"reviewData": {
608
            "projectKey": project['key'],
609
            "name": self.configuration.title,
610
            "description": project['defaultObjectives'] if 'defaultObjectives' in project else None,
611
            },
612
        }
613
614
        review_data = self.add_patch_data(patch, request_dict=review_data)
615
616
        if self.configuration.moderator:
617
            if not project['moderatorEnabled']:
618
                print('Project %s doesn\'t have a moderator role enabled, not setting a moderator' % project['key'])
619
            else:
620
                review_data['reviewData']['moderator'] = {'userName':self.configuration.moderator}
621
622
623
        create_response = self._request("/rest-service/reviews-v1", review_data, http_handlers={
624
            409: self.handle_anchor_error
625
        })
626
        review_id = create_response['permaId']['id']
627
        review_state = create_response['state']
628
629
        reviewers_added = self.add_reviewers(review_id, self.configuration.reviewers)
630
631
        if reviewers_added or project['defaultReviewers']:
632
            logging.debug('Starting review')
633
            approve_response = self._request('/rest-service/reviews-v1/%s/transition?action=action:approveReview' % review_id, data=' ', http_handlers={
634
                401: lambda http_error, error_body: print('You don\'t have permission to approve the review')
635
            })
636
            if approve_response:
637
                review_state = approve_response['state']
638
        else:
639
            print('No reviewers added, review will be left in Draft state')
640
641
        Console.success('Created review %(id)s (state: %(state)s) - %(url)s/cru/%(id)s'
642
              % ({'id': review_id, 'state': review_state,'url':self.configuration.url}))
643
644
    def add_patch_data(self, patch, request_dict={}):
645
        request_dict['patch'] = patch
646
        if self.configuration.repository:
647
            request_dict['anchor'] = {'anchorRepository' : self.configuration.repository}
648
        return request_dict
649
650
    def get_iterable_patchgroups(self, review_id, repository):
651
        patch_groups = self._request('/rest-service/reviews-v1/%s/patch' % review_id)['patchGroup']
652
        matching_repo = lambda patch_group: 'anchor' in patch_group['patches'][0] and \
653
                                            patch_group['patches'][0]['anchor'].get('anchorRepository') == repository
654
        return filter(matching_repo, patch_groups)
655
656
    def add_to_review(self, patch, review):
657
        """Adds the patch and reviewers to the given review"""
658
        review_id = review['permaId']['id']
659
        data = self.add_patch_data(patch)
660
661
        if 'anchor' in data and not self.configuration.new_patch_source:
662
            matching_patch_groups = self.get_iterable_patchgroups(review_id, data['anchor']['anchorRepository'])
663
            self.configuration.choose_source(matching_patch_groups)
664
            if self.configuration.patch_source:
665
                data['source'] = self.configuration.patch_source
666
667
        logging.debug('Adding patch to review %s' % review_id)
668
669
        patch_response = self._request('/rest-service/reviews-v1/%s/patch' % review_id, data=data, http_handlers={
670
            409: self.handle_anchor_error
671
        })
672
        self.add_reviewers(review_id, self.configuration.reviewers)
673
674
        Console.success('Updated review %(id)s (state: %(state)s) - %(url)s/cru/%(id)s'
675
              % ({'id': review_id, 'state': patch_response['state'],'url':self.configuration.url}))
676
677
    def get_review(self, id):
678
        """A ReviewData json if the id represents an existing review, None otherwise"""
679
        try:
680
            return self._request('/rest-service/reviews-v1/%s' % id, http_handlers=CrucibleRest.http_handlers_none_on_errors)
681
        except StandardError:
682
            return None
683
684
    def get_project(self, id):
685
        """A ProjectDat json if the id represents an existing project, None otherwise"""
686
        try:
687
            return self._request('/rest-service/projects-v1/%s?excludeAllowedReviewers=true' % id, http_handlers=CrucibleRest.http_handlers_none_on_errors)
688
        except StandardError:
689
            return None
690
691
    def get_last_project(self):
692
        """Retrieves the most recent project, or None if it fails"""
693
        try:
694
            recent_projects = self._request('/rest-service-fecru/recently-visited-v1/projects')['project']
695
            if(len(recent_projects)) == 0: return None
696
            return recent_projects[0]['entityId']
697
        except StandardError:
698
            logging.debug('Error getting last project: %s', traceback.format_exc())
699
            return None
700
701
    def find_repository(self, source):
702
        """Tries to find the anchor repository for the source given"""
703
        try:
704
            for repository in self.get_repositories():
705
                logging.debug('Matching remote repository %s', repository['name'])
706
                if repository['enabled'] and source.matches_repository(repository):
707
                    return repository['name']
708
        except StandardError:
709
            logging.debug('Error suggesting anchor repository: %s', traceback.format_exc())
710
711
        return None
712
713
    def get_repositories(self):
714
        if not self.repositories:
715
            self.repositories = self._request('/rest-service/repositories-v1')['repoData']
716
        return self.repositories
717
718
    def is_script_update_available(self):
719
        return self._request('/rest/review-cli/1.0/version/updateCheck?version=%s' % SCRIPT_VERSION)['isUpdateAvailable']
720
721
class PatchSource(object):
722
    """Base class for different ways to get a patch"""
723
    def __init__(self, configuration):
724
        self.configuration = configuration
725
        self.paths = []
726
727
    def is_active(self):
728
        """Should return True if this is the source can be used"""
729
730
    def get_patch(self):
731
        """Should return the patch content"""
732
733
    def get_review(self):
734
        """A user visible name for the source"""
735
736
    def executable(self):
737
        """The binary to execute commands on the repository"""
738
739
    def matches_repository(self, repository_data):
740
        """Whether the source matches the given RepositoryData json"""
741
742
    def load_patch(self):
743
        """Queries different patch sources for a patch"""
744
        for source_class in PatchSource.__subclasses__():
745
            logging.debug('Checking %s.is_active', source_class)
746
            source = source_class(self.configuration)
747
            if source.is_active():
748
                source._validate_executable()
749
                logging.debug('Getting patch from %s', source)
750
                patch = source.get_patch()
751
                if patch:
752
                    logging.debug('Got %s bytes', len(patch))
753
754
                    if not self.configuration.no_anchor and not self.configuration.repository:
755
                        source.load_paths()
756
                        logging.debug('Loaded paths: %s', source.paths)
757
758
                return patch, source
759
        return None, None
760
761
    def _validate_executable(self):
762
        try:
763
            self.validate_executable()
764
        except (OSError, CalledProcessError) as e:
765
            Console.error('A %s repository was detected, but there was an error executing \'%s\': %s' % (self, self.configuration.executables[self.executable()], e))
766
            sys.exit(1)
767
768
    def validate_executable(self):
769
        pass
770
771
    def load_paths(self):
772
        """Loads the potential remote paths for the given repo type to be used when trying to detect a repository to anchor to"""
773
        pass
774
775
    def matches_url(self, url):
776
        requested_path = urlparse.urlsplit(url)
777
        logging.debug('Matching remote url: %s', requested_path.__str__())
778
        for path in self.paths:
779
            local_path = urlparse.urlsplit(path)
780
            logging.debug('Matching local url: %s', local_path.__str__())
781
            if requested_path.hostname == local_path.hostname and requested_path.port == local_path.port and requested_path.path == local_path.path:
782
                return True
783
        return False
784
785
    def find_metadata_dir(self, dirname):
786
        """Searches for the specified directory name starting with the current directory and going upwards the directory tree"""
787
        cwd = os.getcwd()
788
        if os.path.exists(os.path.join(cwd, dirname)):
789
            return True
790
        tmp = os.path.abspath(os.path.join(cwd, os.pardir))
791
        while tmp is not None and tmp != cwd:
792
            cwd = tmp
793
            if os.path.exists(os.path.join(cwd, dirname)):
794
                return True
795
            tmp = os.path.abspath(os.path.join(cwd, os.pardir))
796
        return False
797
798
class FileSource(PatchSource):
799
    """Gets the patch from the file specified by the commandline"""
800
    def is_active(self):
801
        return self.configuration.diff_file
802
803
    def get_patch(self):
804
        path = self.configuration.diff_file
805
        if not os.path.isfile(path):
806
            print("No such file %s" % path)
807
            return None
808
809
        with file(path) as f:
810
            return f.read()
811
812
    def __str__(self): return 'file %s' % self.configuration.diff_file
813
814
class StdInSource(PatchSource):
815
    """Gets a patch from stdin"""
816
    def is_active(self):
817
        return not sys.stdin.isatty()
818
819
    def get_patch(self):
820
        return sys.stdin.read()
821
822
    def __str__(self):
823
        return 'standard input'
824
825
class HgSource(PatchSource):
826
    """Gets a patch from a mercurial repository"""
827
828
    def is_active(self):
829
        return self.find_metadata_dir('.hg')
830
831
    def get_patch(self):
832
        output = check_output([self.configuration.executables[self.executable()], 'diff'])
833
        return output
834
835
    def __str__(self):
836
        return 'Mercurial'
837
838
    def executable(self):
839
        return 'hg'
840
841
    def validate_executable(self):
842
        check_output([self.configuration.executables[self.executable()], 'help'])
843
844
    def load_paths(self):
845
        paths_output = check_output([self.configuration.executables[self.executable()], 'paths']).splitlines()
846
        if paths_output:
847
            for path in paths_output:
848
                path_split = path.split('=')
849
                if len(path_split) > 1:
850
                    self.paths.append(path_split[1].strip())
851
852
    def matches_repository(self, repository_data):
853
        return repository_data['type'] == 'hg' and self.matches_url(repository_data['location'])
854
855
856
class GitSource(PatchSource):
857
    """Gets a patch from a git repository"""
858
    def is_active(self):
859
        return self.find_metadata_dir('.git')
860
861
    def get_patch(self):
862
        output = check_output([self.configuration.executables[self.executable()], 'diff', '--cached', '--no-prefix'])
863
        if not output:
864
            output = check_output([self.configuration.executables[self.executable()], 'diff', '--no-prefix'])
865
            if output:
866
                print('No staged changes were found in the repository, creating a review for all unstaged changes.')
867
868
        return output
869
870
    def __str__(self):
871
        return 'Git'
872
873
    def executable(self):
874
        return 'git'
875
876
    def validate_executable(self):
877
        check_output([self.configuration.executables[self.executable()], 'help'])
878
879
    def load_paths(self):
880
        paths_output = check_output([self.configuration.executables[self.executable()], 'remote', '-v']).splitlines()
881
        if paths_output:
882
            for path in paths_output:
883
                path_split = path.split()
884
                if len(path_split) > 1:
885
                    self.paths.append(path_split[1].strip())
886
887
    def matches_repository(self, repository_data):
888
        return repository_data['type'] == 'git' and self.matches_url(repository_data['location'])
889
890
891
class SvnSource(PatchSource):
892
    """Gets a patch from a subversion repository"""
893
    def is_active(self):
894
        return self.find_metadata_dir('.svn')
895
896
    def get_patch(self):
897
        try:
898
            return check_output([self.configuration.executables[self.executable()], 'diff'])
899
        except CalledProcessError as e:
900
            print('svn diff returned error: %s' % e.error.strip())
901
            return None
902
903
    def __str__(self):
904
        return 'Subversion'
905
906
    def executable(self):
907
        return 'svn'
908
909
    def validate_executable(self):
910
        check_output([self.configuration.executables[self.executable()], 'help'])
911
912
    def load_paths(self):
913
        info_output = check_output([self.configuration.executables[self.executable()], 'info']).splitlines()
914
        if info_output:
915
            for line in info_output:
916
                if line.startswith('URL:'):
917
                    line_split = line.split()
918
                    if (len(line_split)) > 1:
919
                        self.paths.append(line_split[1].strip())
920
                    break
921
922
    def matches_repository(self, repository_data):
923
        if not repository_data['type'] == 'svn':
924
            return False
925
        remote_url = (repository_data['url'].rstrip('/') + '/' + repository_data['path']).rstrip('/')
926
        logging.debug('Matching remote url %s', remote_url)
927
        return self.paths[0].startswith(remote_url)
928
929
930
class CvsSource(PatchSource):
931
    """Gets a patch from a CVS repository"""
932
    def is_active(self):
933
        try:
934
            cmd = [self.configuration.executables[self.executable()], 'status']
935
            check_output(cmd)
936
            return True
937
        except (OSError, CalledProcessError):
938
            return False
939
940
    def get_patch(self):
941
        try:
942
            return check_output([self.configuration.executables[self.executable()], 'diff', '-N', '-U', '10000'])
943
        except CalledProcessError as e:
944
            return e.output
945
946
    def __str__(self):
947
        return 'CVS'
948
949
    def executable(self):
950
        return 'cvs'
951
952
    def load_paths(self):
953
        try:
954
            with open('CVS/Root') as root_file:
955
                self.paths.append(root_file.read().rstrip('/\n'))
956
        except IOError as e:
957
            logging.debug('Failed getting file root file: %s', e)
958
959
        if 'CVSROOT' in os.environ:
960
            self.paths.append(os.environ['CVSROOT'].rstrip('/'))
961
962
    def matches_repository(self, repository_data):
963
        return repository_data['type'] == 'cvs' and self.matches_url(repository_data['dir'].rstrip('/'))
964
965
966
class P4Source(PatchSource):
967
    def __init__(self, configuration):
968
        super(P4Source, self).__init__(configuration)
969
        self.p4info = {}
970
971
    def is_active(self):
972
        """Gets a patch from a perforce repository."""
973
        try:
974
            check_output([self.configuration.executables[self.executable()]])
975
        except (OSError, CalledProcessError):
976
            return False
977
978
        self.get_client_info()
979
980
        clientRoot = self.p4info['Client root']
981
        if not clientRoot:
982
            logging.debug('Cannot find Perforce Client root while executing \'p4 info\'')
983
            return False
984
985
        return (os.path.normcase(os.getcwd())).startswith(os.path.normcase(clientRoot))
986
987
    def get_client_info(self):
988
        info = check_output([self.configuration.executables[self.executable()], 'info']).splitlines()
989
        for line in info:
990
            line_split = line.split(':', 1)
991
            if len(line_split) > 1:
992
                self.p4info[line_split[0].strip()] = line_split[1].strip()
993
        logging.debug('p4info: %s ', self.p4info)
994
995
    def get_path(self, file):
996
        output = check_output([self.configuration.executables[self.executable()], 'fstat', '-T', 'clientFile', file])
997
        return output[len('... clientFile '):].rstrip()
998
999
    def get_add(self, file, rev):
1000
        f = open(self.get_path(file))
1001
        lines = f.read().replace('\xc2\x85', '\n').splitlines()
1002
        f.close()
1003
1004
        output = '--- /dev/null\n' + \
1005
                 '+++ ' + file + '\t(revision ' + rev + ')\n' + \
1006
                 '@@ -0,0 +1,' + str(len(lines)) + ' @@\n'
1007
        output += '+' + '\n+'.join(lines)
1008
        return output
1009
1010
    def get_delete(self, file, rev):
1011
        lines = check_output([self.configuration.executables[self.executable()], 'print', '-q', file + '#' + rev]).splitlines()
1012
1013
        output = '--- ' + file + '\t(revision ' + rev + ')\n' + \
1014
                 '+++ /dev/null\n' + \
1015
                 '@@ -1,' + str(len(lines)) + ' +0,0 @@\n'
1016
        output += '-' + '\n-'.join(lines)
1017
        return output
1018
1019
    def get_patch(self):
1020
        p4 = self.configuration.executables[self.executable()]
1021
        output = check_output([p4, 'diff', '-dcu'])
1022
1023
        opened = check_output([p4, 'opened']).splitlines()
1024
1025
        pattern = re.compile('([^#]+)#([\\d]+) - ([^\\s]+)[^\\(](.*)')
1026
        for line in opened:
1027
            match = pattern.match(line)
1028
            if match:
1029
                file = match.group(1)
1030
                rev = match.group(2)
1031
                change = match.group(3)
1032
                fileType = match.group(4)
1033
                if change == 'add':
1034
                    output += '\n' + self.get_add(file, rev)
1035
                elif change == 'delete':
1036
                    output += '\n' + self.get_delete(file, rev)
1037
1038
        return output
1039
1040
    def __str__(self):
1041
        return 'Perforce'
1042
1043
    def executable(self):
1044
        return 'p4'
1045
1046
    def load_paths(self):
1047
        where_output = check_output([self.configuration.executables[self.executable()], 'where']).splitlines()
1048
        for where in where_output:
1049
            where_split = where.split()
1050
            if len(where_split) == 3 and not where_split[0].startswith('-') and where_split[1].startswith('//%s' % self.p4info['Client name']):
1051
                self.paths.append(where_split[0])
1052
1053
    def matches_repository(self, repository_data):
1054
        if not repository_data['type'] == 'p4': return False
1055
        remote_address = '%s:%s' % (repository_data['server'], repository_data['port'])
1056
        logging.debug('Matching remote url: %s', remote_address)
1057
        if self.p4info.get('Server address') == remote_address or self.p4info.get('P4Sandbox broker port') == remote_address:
1058
            for path in self.paths:
1059
                logging.debug('Matching path: %s', path)
1060
                if path.startswith(repository_data['path']):
1061
                    return True
1062
1063
        return False
1064
1065
def main():
1066
    """
1067
    Runs the script to create a review
1068
    """
1069
1070
    # read commandline
1071
    command_line = CommandLine()
1072
    args = command_line.parse_args()
1073
1074
    # parse/fill config
1075
    config_file = ConfigFile()
1076
    configuration = Configuration().fill_from_args(args).fill_from_config_file(config_file).fill_from_defaults().fill_interactively()
1077
1078
    # set up authentication, check for updates
1079
    rest = CrucibleRest(configuration)
1080
    rest.check_connection()
1081
    config_file.store_configuration(configuration)
1082
1083
    if rest.is_script_update_available():
1084
        print('An updated version of this script is available. Visit %s to download it' % (configuration.url + '/plugins/servlet/viewReviewCLI'))
1085
1086
    # get a patch
1087
    patch, source = PatchSource(configuration).load_patch()
1088
    if not patch:
1089
        Console.error('Failed to get a patch%s. Please make sure to call the script from an SCM directory with local changes, or pipe in some input.'
1090
              % (' from %s' % source if source else ''))
1091
        sys.exit(1)
1092
1093
    # create or update the review
1094
    if not configuration.repository and not configuration.no_anchor:
1095
        detected_repository = rest.find_repository(source)
1096
        if detected_repository:
1097
            configuration.repository = detected_repository
1098
            print('Detected Crucible repository %s. Use --repository or --noanchor options to override' % configuration.repository)
1099
        else:
1100
            print('No matching FishEye repository detected')
1101
            configuration.choose_anchor(rest.get_repositories())
1102
1103
    configuration.last_project = rest.get_last_project()
1104
    configuration.fill_interactively(get_id=True)
1105
1106
    review = rest.get_review(configuration.id)
1107
    if review:
1108
        rest.add_to_review(patch, review)
1109
        sys.exit(0)
1110
1111
    project = rest.get_project(configuration.id)
1112
    if project:
1113
        configuration.fill_interactively(get_title=True, get_reviewers=True)
1114
        rest.create_review(patch, project)
1115
        sys.exit(0)
1116
1117
    Console.error('Failed to find a review or project with an id %s, make sure the id is correct.' % configuration.id)
1118
    sys.exit(1)
1119
1120
if __name__ == '__main__':
1121
    main()