├── .gitignore ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── doc_requirements.txt ├── example.py ├── pygerrit ├── __init__.py ├── client.py ├── error.py ├── events.py ├── models.py ├── rest │ ├── __init__.py │ └── auth.py ├── ssh.py └── stream.py ├── requirements.txt ├── rest_example.py ├── setup.cfg ├── setup.py ├── test_requirements.txt ├── testdata ├── change-abandoned-event.txt ├── change-merged-event.txt ├── change-restored-event.txt ├── comment-added-event.txt ├── draft-published-event.txt ├── invalid-json.txt ├── merge-failed-event.txt ├── patchset-created-event.txt ├── ref-updated-event.txt ├── reviewer-added-event.txt ├── topic-changed-event.txt ├── unhandled-event.txt └── user-defined-event.txt └── unittests.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .project 3 | .pydevproject 4 | .settings 5 | .idea 6 | build/ 7 | dist/ 8 | docs/ 9 | pygerrit.egg-info/ 10 | pygerritenv/ 11 | ChangeLog 12 | AUTHORS 13 | MANIFEST 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright 2011 Sony Ericsson Mobile Communications. All rights reserved. 4 | Copyright 2012 Sony Mobile Communications. All rights reserved. 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst LICENSE requirements.txt -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | # 3 | # Copyright 2013 Sony Mobile Communications. All rights reserved. 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in 13 | # all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | # THE SOFTWARE. 22 | 23 | PWD := $(shell pwd) 24 | VERSION := $(shell git describe) 25 | 26 | VIRTUALENV := $(shell which virtualenv) 27 | ifeq ($(wildcard $(VIRTUALENV)),) 28 | $(error virtualenv must be available) 29 | endif 30 | 31 | PIP := $(shell which pip) 32 | ifeq ($(wildcard $(PIP)),) 33 | $(error pip must be available) 34 | endif 35 | 36 | REQUIRED_VIRTUALENV ?= 1.10 37 | VIRTUALENV_OK := $(shell expr `virtualenv --version | \ 38 | cut -f2 -d' '` \>= $(REQUIRED_VIRTUALENV)) 39 | 40 | all: test 41 | 42 | test: clean unittests pyflakes pep8 pep257 43 | 44 | docs: html 45 | 46 | sdist: valid-virtualenv test 47 | bash -c "\ 48 | source ./pygerritenv/bin/activate && \ 49 | python setup.py sdist" 50 | 51 | ddist: sdist docs 52 | bash -c "\ 53 | cd docs/_build/html && \ 54 | zip -r $(PWD)/dist/pygerrit-$(VERSION)-api-documentation.zip . && \ 55 | cd $(PWD)" 56 | 57 | valid-virtualenv: 58 | ifeq ($(VIRTUALENV_OK),0) 59 | $(error virtualenv version $(REQUIRED_VIRTUALENV) or higher is needed) 60 | endif 61 | 62 | html: sphinx 63 | bash -c "\ 64 | source ./pygerritenv/bin/activate && \ 65 | export PYTHONPATH=$(PWD) && \ 66 | cd docs && \ 67 | make html && \ 68 | cd $(PWD)" 69 | 70 | sphinx: docenvsetup 71 | bash -c "\ 72 | source ./pygerritenv/bin/activate && \ 73 | sphinx-apidoc \ 74 | -V \"$(VERSION)\" \ 75 | -R \"$(VERSION)\" \ 76 | -H \"Pygerrit\" \ 77 | -A \"Sony Mobile Communications\" \ 78 | --full \ 79 | --force \ 80 | -o docs pygerrit" 81 | 82 | pep257: testenvsetup 83 | bash -c "\ 84 | source ./pygerritenv/bin/activate && \ 85 | git ls-files | grep \"\.py$$\" | xargs pep257" 86 | 87 | pep8: testenvsetup 88 | bash -c "\ 89 | source ./pygerritenv/bin/activate && \ 90 | git ls-files | grep \"\.py$$\" | xargs pep8 --max-line-length 80" 91 | 92 | pyflakes: testenvsetup 93 | bash -c "\ 94 | source ./pygerritenv/bin/activate && \ 95 | git ls-files | grep \"\.py$$\" | xargs pyflakes" 96 | 97 | unittests: testenvsetup 98 | bash -c "\ 99 | source ./pygerritenv/bin/activate && \ 100 | python unittests.py" 101 | 102 | testenvsetup: envsetup 103 | bash -c "\ 104 | source ./pygerritenv/bin/activate && \ 105 | pip install --upgrade -r test_requirements.txt" 106 | 107 | docenvsetup: envsetup 108 | bash -c "\ 109 | source ./pygerritenv/bin/activate && \ 110 | pip install --upgrade -r doc_requirements.txt" 111 | 112 | envsetup: envinit 113 | bash -c "\ 114 | source ./pygerritenv/bin/activate && \ 115 | pip install --upgrade -r requirements.txt" 116 | 117 | envinit: 118 | bash -c "[ -e ./pygerritenv/bin/activate ] || virtualenv --system-site-packages ./pygerritenv" 119 | 120 | clean: 121 | @find . -type f -name "*.pyc" -exec rm -f {} \; 122 | @rm -rf pygerritenv pygerrit.egg-info build dist docs 123 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Pygerrit - Client library for interacting with Gerrit Code Review 2 | ================================================================= 3 | 4 | .. image:: https://img.shields.io/pypi/v/pygerrit.png 5 | 6 | .. image:: https://img.shields.io/pypi/dm/pygerrit.png 7 | 8 | .. image:: https://img.shields.io/pypi/l/pygerrit.png 9 | 10 | Pygerrit provides a simple interface for clients to interact with 11 | `Gerrit Code Review`_ via ssh or the REST API. 12 | 13 | This repository is no longer actively maintained. Development has 14 | moved to `pygerrit2`_. 15 | 16 | Prerequisites 17 | ------------- 18 | 19 | Pygerrit has been tested on Ubuntu 10.4 and Mac OSX 10.8.4, with Python 2.6.x 20 | and 2.7.x. Support for other platforms and Python versions is not guaranteed. 21 | 22 | Pygerrit depends on the `paramiko`_ and `requests`_ libraries. 23 | 24 | 25 | Installation 26 | ------------ 27 | 28 | To install pygerrit, simply:: 29 | 30 | $ pip install pygerrit 31 | 32 | 33 | Configuration 34 | ------------- 35 | 36 | For easier connection to the review server over ssh, the ssh connection 37 | parameters (hostname, port, username) can be given in the user's ``.ssh/config`` 38 | file:: 39 | 40 | Host review 41 | HostName review.example.net 42 | Port 29418 43 | User username 44 | 45 | 46 | For easier connection to the review server over the REST API, the user's 47 | HTTP username and password can be given in the user's ``.netrc`` file:: 48 | 49 | machine review login MyUsername password MyPassword 50 | 51 | 52 | For instructions on how to obtain the HTTP password, refer to Gerrit's 53 | `HTTP upload settings`_ documentation. 54 | 55 | 56 | SSH Interface 57 | ------------- 58 | 59 | The SSH interface can be used to run commands on the Gerrit server:: 60 | 61 | >>> from pygerrit.ssh import GerritSSHClient 62 | >>> client = GerritSSHClient("review") 63 | >>> result = client.run_gerrit_command("version") 64 | >>> result 65 | 66 | >>> result.stdout 67 | >> 68 | >>> result.stdout.read() 69 | 'gerrit version 2.6.1\n' 70 | >>> 71 | 72 | Event Stream 73 | ------------ 74 | 75 | Gerrit offers a ``stream-events`` command that is run over ssh, and returns back 76 | a stream of events (new change uploaded, change merged, comment added, etc) as 77 | JSON text. 78 | 79 | This library handles the parsing of the JSON text from the event stream, 80 | encapsulating the data in event objects (Python classes), and allowing the 81 | client to fetch them from a queue. It also allows users to easily add handling 82 | of custom event types, for example if they are running a customised Gerrit 83 | installation with non-standard events:: 84 | 85 | >>> from pygerrit.client import GerritClient 86 | >>> client = GerritClient("review") 87 | >>> client.gerrit_version() 88 | '2.6.1' 89 | >>> client.start_event_stream() 90 | >>> client.get_event() 91 | : 92 | >>> client.get_event() 93 | : 94 | >>> client.stop_event_stream() 95 | >>> 96 | 97 | 98 | Refer to the `example`_ script for a more detailed example of how the SSH 99 | event stream interface works. 100 | 101 | REST API 102 | -------- 103 | 104 | This simple example shows how to get the user's open changes, authenticating 105 | to Gerrit via HTTP Digest authentication using an explicitly given username and 106 | password:: 107 | 108 | >>> from requests.auth import HTTPDigestAuth 109 | >>> from pygerrit.rest import GerritRestAPI 110 | >>> auth = HTTPDigestAuth('username', 'password') 111 | >>> rest = GerritRestAPI(url='http://review.example.net', auth=auth) 112 | >>> changes = rest.get("/changes/?q=owner:self%20status:open") 113 | 114 | 115 | Refer to the `rest_example`_ script for a more detailed example of how the 116 | REST API interface works. 117 | 118 | 119 | Copyright and License 120 | --------------------- 121 | 122 | Copyright 2011 Sony Ericsson Mobile Communications. All rights reserved. 123 | 124 | Copyright 2012 Sony Mobile Communications. All rights reserved. 125 | 126 | Licensed under The MIT License. Please refer to the `LICENSE`_ file for full 127 | license details. 128 | 129 | .. _`Gerrit Code Review`: https://gerritcodereview.com/ 130 | .. _`pygerrit2`: https://github.com/dpursehouse/pygerrit2 131 | .. _`requests`: https://github.com/kennethreitz/requests 132 | .. _`paramiko`: https://github.com/paramiko/paramiko 133 | .. _example: https://github.com/sonyxperiadev/pygerrit/blob/master/example.py 134 | .. _rest_example: https://github.com/sonyxperiadev/pygerrit/blob/master/rest_example.py 135 | .. _`HTTP upload settings`: https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/user-upload.html#http 136 | .. _LICENSE: https://github.com/sonyxperiadev/pygerrit/blob/master/LICENSE 137 | -------------------------------------------------------------------------------- /doc_requirements.txt: -------------------------------------------------------------------------------- 1 | docutils==0.11 2 | Jinja2==2.7 3 | MarkupSafe==0.18 4 | Pygments==1.6 5 | Sphinx==1.2b1 6 | -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # The MIT License 5 | # 6 | # Copyright 2012 Sony Mobile Communications. All rights reserved. 7 | # 8 | # Permission is hereby granted, free of charge, to any person obtaining a copy 9 | # of this software and associated documentation files (the "Software"), to deal 10 | # in the Software without restriction, including without limitation the rights 11 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | # copies of the Software, and to permit persons to whom the Software is 13 | # furnished to do so, subject to the following conditions: 14 | # 15 | # The above copyright notice and this permission notice shall be included in 16 | # all copies or substantial portions of the Software. 17 | # 18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 24 | # THE SOFTWARE. 25 | 26 | """ Example of using the Gerrit client class. """ 27 | 28 | import argparse 29 | import logging 30 | import sys 31 | from threading import Event 32 | import time 33 | 34 | from pygerrit.client import GerritClient 35 | from pygerrit.error import GerritError 36 | from pygerrit.events import ErrorEvent 37 | 38 | 39 | def _main(): 40 | descr = 'Send request using Gerrit ssh API' 41 | parser = argparse.ArgumentParser( 42 | description=descr, 43 | formatter_class=argparse.ArgumentDefaultsHelpFormatter) 44 | parser.add_argument('-g', '--gerrit-hostname', dest='hostname', 45 | default='review', 46 | help='gerrit server hostname') 47 | parser.add_argument('-p', '--port', dest='port', 48 | type=int, default=29418, 49 | help='port number') 50 | parser.add_argument('-u', '--username', dest='username', 51 | help='username') 52 | parser.add_argument('-b', '--blocking', dest='blocking', 53 | action='store_true', 54 | help='block on event get') 55 | parser.add_argument('-t', '--timeout', dest='timeout', 56 | default=None, type=int, 57 | metavar='SECONDS', 58 | help='timeout for blocking event get') 59 | parser.add_argument('-v', '--verbose', dest='verbose', 60 | action='store_true', 61 | help='enable verbose (debug) logging') 62 | parser.add_argument('-i', '--ignore-stream-errors', dest='ignore', 63 | action='store_true', 64 | help='do not exit when an error event is received') 65 | 66 | options = parser.parse_args() 67 | if options.timeout and not options.blocking: 68 | parser.error('Can only use --timeout with --blocking') 69 | 70 | level = logging.DEBUG if options.verbose else logging.INFO 71 | logging.basicConfig(format='%(asctime)s %(levelname)s %(message)s', 72 | level=level) 73 | 74 | try: 75 | gerrit = GerritClient(host=options.hostname, 76 | username=options.username, 77 | port=options.port) 78 | logging.info("Connected to Gerrit version [%s]", 79 | gerrit.gerrit_version()) 80 | gerrit.start_event_stream() 81 | except GerritError as err: 82 | logging.error("Gerrit error: %s", err) 83 | return 1 84 | 85 | errors = Event() 86 | try: 87 | while True: 88 | event = gerrit.get_event(block=options.blocking, 89 | timeout=options.timeout) 90 | if event: 91 | logging.info("Event: %s", event) 92 | if isinstance(event, ErrorEvent) and not options.ignore: 93 | logging.error(event.error) 94 | errors.set() 95 | break 96 | else: 97 | logging.info("No event") 98 | if not options.blocking: 99 | time.sleep(1) 100 | except KeyboardInterrupt: 101 | logging.info("Terminated by user") 102 | finally: 103 | logging.debug("Stopping event stream...") 104 | gerrit.stop_event_stream() 105 | 106 | if errors.isSet(): 107 | logging.error("Exited with error") 108 | return 1 109 | 110 | if __name__ == "__main__": 111 | sys.exit(_main()) 112 | -------------------------------------------------------------------------------- /pygerrit/__init__.py: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | # 3 | # Copyright 2012 Sony Mobile Communications. All rights reserved. 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in 13 | # all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | # THE SOFTWARE. 22 | 23 | """ Module to interface with Gerrit. """ 24 | 25 | 26 | def from_json(json_data, key): 27 | """ Helper method to extract values from JSON data. 28 | 29 | :arg dict json_data: The JSON data 30 | :arg str key: Key to get data for. 31 | 32 | :Returns: The value of `key` from `json_data`, or None if `json_data` 33 | does not contain `key`. 34 | 35 | """ 36 | if key in json_data: 37 | return json_data[key] 38 | return None 39 | 40 | 41 | def escape_string(string): 42 | """ Escape a string for use in Gerrit commands. 43 | 44 | :arg str string: The string to escape. 45 | 46 | :returns: The string with necessary escapes and surrounding double quotes 47 | so that it can be passed to any of the Gerrit commands that require 48 | double-quoted strings. 49 | 50 | """ 51 | 52 | result = string 53 | result = result.replace('\\', '\\\\') 54 | result = result.replace('"', '\\"') 55 | return '"' + result + '"' 56 | 57 | 58 | class GerritReviewMessageFormatter(object): 59 | 60 | """ Helper class to format review messages that are sent to Gerrit. 61 | 62 | :arg str header: (optional) If specified, will be prepended as the first 63 | paragraph of the output message. 64 | :arg str footer: (optional) If specified, will be appended as the last 65 | paragraph of the output message. 66 | 67 | """ 68 | 69 | def __init__(self, header=None, footer=None): 70 | self.paragraphs = [] 71 | if header: 72 | self.header = header.strip() 73 | else: 74 | self.header = "" 75 | if footer: 76 | self.footer = footer.strip() 77 | else: 78 | self.footer = "" 79 | 80 | def append(self, data): 81 | """ Append the given `data` to the output. 82 | 83 | :arg data: If a list, it is formatted as a bullet list with each 84 | entry in the list being a separate bullet. Otherwise if it is a 85 | string, the string is added as a paragraph. 86 | 87 | :raises: ValueError if `data` is not a list or a string. 88 | 89 | """ 90 | if not data: 91 | return 92 | 93 | if isinstance(data, list): 94 | # First we need to clean up the data. 95 | # 96 | # Gerrit creates new bullet items when it gets newline characters 97 | # within a bullet list paragraph, so unless we remove the newlines 98 | # from the texts the resulting bullet list will contain multiple 99 | # bullets and look crappy. 100 | # 101 | # We add the '*' character on the beginning of each bullet text in 102 | # the next step, so we strip off any existing leading '*' that the 103 | # caller has added, and then strip off any leading or trailing 104 | # whitespace. 105 | _items = [x.replace("\n", " ").strip().lstrip('*').strip() 106 | for x in data] 107 | 108 | # Create the bullet list only with the items that still have any 109 | # text in them after cleaning up. 110 | _paragraph = "\n".join(["* %s" % x for x in _items if x]) 111 | if _paragraph: 112 | self.paragraphs.append(_paragraph) 113 | elif isinstance(data, str): 114 | _paragraph = data.strip() 115 | if _paragraph: 116 | self.paragraphs.append(_paragraph) 117 | else: 118 | raise ValueError('Data must be a list or a string') 119 | 120 | def is_empty(self): 121 | """ Check if the formatter is empty. 122 | 123 | :Returns: True if empty, i.e. no paragraphs have been added. 124 | 125 | """ 126 | return not self.paragraphs 127 | 128 | def format(self): 129 | """ Format the message parts to a string. 130 | 131 | :Returns: A string of all the message parts separated into paragraphs, 132 | with header and footer paragraphs if they were specified in the 133 | constructor. 134 | 135 | """ 136 | message = "" 137 | if self.paragraphs: 138 | if self.header: 139 | message += (self.header + '\n\n') 140 | message += "\n\n".join(self.paragraphs) 141 | if self.footer: 142 | message += ('\n\n' + self.footer) 143 | return message 144 | -------------------------------------------------------------------------------- /pygerrit/client.py: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | # 3 | # Copyright 2012 Sony Mobile Communications. All rights reserved. 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in 13 | # all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | # THE SOFTWARE. 22 | 23 | """ Gerrit client interface. """ 24 | 25 | from json import JSONDecoder 26 | from Queue import Queue, Empty, Full 27 | 28 | from . import escape_string 29 | from .error import GerritError 30 | from .events import GerritEventFactory 31 | from .models import Change 32 | from .ssh import GerritSSHClient 33 | from .stream import GerritStream 34 | 35 | 36 | class GerritClient(object): 37 | 38 | """ Gerrit client interface. 39 | 40 | :arg str host: The hostname. 41 | :arg str username: (optional) The username to use when connecting. 42 | :arg str port: (optional) The port number to connect to. 43 | :arg int keepalive: (optional) Keepalive interval in seconds. 44 | :arg bool auto_add_hosts: (optional) If True, the ssh client will 45 | automatically add hosts to known_hosts. 46 | 47 | """ 48 | 49 | def __init__(self, host, username=None, port=None, 50 | keepalive=None, auto_add_hosts=False): 51 | self._factory = GerritEventFactory() 52 | self._events = Queue() 53 | self._stream = None 54 | self.keepalive = keepalive 55 | self._ssh_client = GerritSSHClient(host, 56 | username=username, 57 | port=port, 58 | keepalive=keepalive, 59 | auto_add_hosts=auto_add_hosts) 60 | 61 | def gerrit_version(self): 62 | """ Get the Gerrit version. 63 | 64 | :Returns: The version of Gerrit that is connected to, as a string. 65 | 66 | """ 67 | return self._ssh_client.get_remote_version() 68 | 69 | def gerrit_info(self): 70 | """ Get connection information. 71 | 72 | :Returns: A tuple of the username, and version of Gerrit that is 73 | connected to. 74 | 75 | """ 76 | 77 | return self._ssh_client.get_remote_info() 78 | 79 | def run_command(self, command): 80 | """ Run a command. 81 | 82 | :arg str command: The command to run. 83 | 84 | :Return: The result as a string. 85 | 86 | :Raises: `ValueError` if `command` is not a string. 87 | 88 | """ 89 | if not isinstance(command, basestring): 90 | raise ValueError("command must be a string") 91 | return self._ssh_client.run_gerrit_command(command) 92 | 93 | def query(self, term): 94 | """ Run a query. 95 | 96 | :arg str term: The query term to run. 97 | 98 | :Returns: A list of results as :class:`pygerrit.models.Change` objects. 99 | 100 | :Raises: `ValueError` if `term` is not a string. 101 | 102 | """ 103 | results = [] 104 | command = ["query", "--current-patch-set", "--all-approvals", 105 | "--format JSON", "--commit-message"] 106 | 107 | if not isinstance(term, basestring): 108 | raise ValueError("term must be a string") 109 | 110 | command.append(escape_string(term)) 111 | result = self._ssh_client.run_gerrit_command(" ".join(command)) 112 | decoder = JSONDecoder() 113 | for line in result.stdout.read().splitlines(): 114 | # Gerrit's response to the query command contains one or more 115 | # lines of JSON-encoded strings. The last one is a status 116 | # dictionary containing the key "type" whose value indicates 117 | # whether or not the operation was successful. 118 | # According to http://goo.gl/h13HD it should be safe to use the 119 | # presence of the "type" key to determine whether the dictionary 120 | # represents a change or if it's the query status indicator. 121 | try: 122 | data = decoder.decode(line) 123 | except ValueError as err: 124 | raise GerritError("Query returned invalid data: %s", err) 125 | if "type" in data and data["type"] == "error": 126 | raise GerritError("Query error: %s" % data["message"]) 127 | elif "project" in data: 128 | results.append(Change(data)) 129 | return results 130 | 131 | def start_event_stream(self): 132 | """ Start streaming events from `gerrit stream-events`. """ 133 | if not self._stream: 134 | self._stream = GerritStream(self, ssh_client=self._ssh_client) 135 | self._stream.start() 136 | 137 | def stop_event_stream(self): 138 | """ Stop streaming events from `gerrit stream-events`.""" 139 | if self._stream: 140 | self._stream.stop() 141 | self._stream.join() 142 | self._stream = None 143 | with self._events.mutex: 144 | self._events.queue.clear() 145 | 146 | def get_event(self, block=True, timeout=None): 147 | """ Get the next event from the queue. 148 | 149 | :arg boolean block: Set to True to block if no event is available. 150 | :arg seconds timeout: Timeout to wait if no event is available. 151 | 152 | :Returns: The next event as a :class:`pygerrit.events.GerritEvent` 153 | instance, or `None` if: 154 | - `block` is False and there is no event available in the queue, or 155 | - `block` is True and no event is available within the time 156 | specified by `timeout`. 157 | 158 | """ 159 | try: 160 | return self._events.get(block, timeout) 161 | except Empty: 162 | return None 163 | 164 | def put_event(self, data): 165 | """ Create event from `data` and add it to the queue. 166 | 167 | :arg json data: The JSON data from which to create the event. 168 | 169 | :Raises: :class:`pygerrit.error.GerritError` if the queue is full, or 170 | the factory could not create the event. 171 | 172 | """ 173 | try: 174 | event = self._factory.create(data) 175 | self._events.put(event) 176 | except Full: 177 | raise GerritError("Unable to add event: queue is full") 178 | -------------------------------------------------------------------------------- /pygerrit/error.py: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | # 3 | # Copyright 2011 Sony Ericsson Mobile Communications. All rights reserved. 4 | # Copyright 2012 Sony Mobile Communications. All rights reserved. 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in 14 | # all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | # THE SOFTWARE. 23 | 24 | """ Error classes. """ 25 | 26 | 27 | class GerritError(Exception): 28 | 29 | """ Raised when something goes wrong in Gerrit handling. """ 30 | 31 | pass 32 | -------------------------------------------------------------------------------- /pygerrit/events.py: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | # 3 | # Copyright 2011 Sony Ericsson Mobile Communications. All rights reserved. 4 | # Copyright 2012 Sony Mobile Communications. All rights reserved. 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in 14 | # all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | # THE SOFTWARE. 23 | 24 | """ Gerrit event classes. """ 25 | 26 | import json 27 | import logging 28 | 29 | from .error import GerritError 30 | from .models import Account, Approval, Change, Patchset, RefUpdate 31 | 32 | 33 | class GerritEventFactory(object): 34 | 35 | """ Gerrit event factory. """ 36 | 37 | _events = {} 38 | 39 | @classmethod 40 | def register(cls, name): 41 | """ Decorator to register the event identified by `name`. 42 | 43 | Return the decorated class. 44 | 45 | Raise GerritError if the event is already registered. 46 | 47 | """ 48 | 49 | def decorate(klazz): 50 | """ Decorator. """ 51 | if name in cls._events: 52 | raise GerritError("Duplicate event: %s" % name) 53 | cls._events[name] = [klazz.__module__, klazz.__name__] 54 | klazz.name = name 55 | return klazz 56 | return decorate 57 | 58 | @classmethod 59 | def create(cls, data): 60 | """ Create a new event instance. 61 | 62 | Return an instance of the `GerritEvent` subclass after converting 63 | `data` to json. 64 | 65 | Raise GerritError if json parsed from `data` does not contain a `type` 66 | key. 67 | 68 | """ 69 | try: 70 | json_data = json.loads(data) 71 | except ValueError as err: 72 | logging.debug("Failed to load json data: %s: [%s]", str(err), data) 73 | json_data = json.loads(ErrorEvent.error_json(err)) 74 | 75 | if "type" not in json_data: 76 | raise GerritError("`type` not in json_data") 77 | name = json_data["type"] 78 | if name not in cls._events: 79 | name = 'unhandled-event' 80 | event = cls._events[name] 81 | module_name = event[0] 82 | class_name = event[1] 83 | module = __import__(module_name, fromlist=[module_name]) 84 | klazz = getattr(module, class_name) 85 | return klazz(json_data) 86 | 87 | 88 | class GerritEvent(object): 89 | 90 | """ Gerrit event base class. """ 91 | 92 | def __init__(self, json_data): 93 | self.json = json_data 94 | 95 | 96 | @GerritEventFactory.register("unhandled-event") 97 | class UnhandledEvent(GerritEvent): 98 | 99 | """ Unknown event type received in json data from Gerrit's event stream. """ 100 | 101 | def __init__(self, json_data): 102 | super(UnhandledEvent, self).__init__(json_data) 103 | 104 | def __repr__(self): 105 | return u" %s" % self.json["type"] 106 | 107 | 108 | @GerritEventFactory.register("error-event") 109 | class ErrorEvent(GerritEvent): 110 | 111 | """ Error occurred when processing json data from Gerrit's event stream. """ 112 | 113 | def __init__(self, json_data): 114 | super(ErrorEvent, self).__init__(json_data) 115 | self.error = json_data["error"] 116 | 117 | @classmethod 118 | def error_json(cls, error): 119 | """ Return a json string for the `error`. """ 120 | return '{"type":"error-event",' \ 121 | '"error":"%s"}' % str(error) 122 | 123 | def __repr__(self): 124 | return u"" % self.error 125 | 126 | 127 | @GerritEventFactory.register("patchset-created") 128 | class PatchsetCreatedEvent(GerritEvent): 129 | 130 | """ Gerrit "patchset-created" event. """ 131 | 132 | def __init__(self, json_data): 133 | super(PatchsetCreatedEvent, self).__init__(json_data) 134 | try: 135 | self.change = Change(json_data["change"]) 136 | self.patchset = Patchset(json_data["patchSet"]) 137 | self.uploader = Account(json_data["uploader"]) 138 | except KeyError as e: 139 | raise GerritError("PatchsetCreatedEvent: %s" % e) 140 | 141 | def __repr__(self): 142 | return u": %s %s %s" % (self.change, 143 | self.patchset, 144 | self.uploader) 145 | 146 | 147 | @GerritEventFactory.register("draft-published") 148 | class DraftPublishedEvent(GerritEvent): 149 | 150 | """ Gerrit "draft-published" event. """ 151 | 152 | def __init__(self, json_data): 153 | super(DraftPublishedEvent, self).__init__(json_data) 154 | try: 155 | self.change = Change(json_data["change"]) 156 | self.patchset = Patchset(json_data["patchSet"]) 157 | self.uploader = Account(json_data["uploader"]) 158 | except KeyError as e: 159 | raise GerritError("DraftPublishedEvent: %s" % e) 160 | 161 | def __repr__(self): 162 | return u": %s %s %s" % (self.change, 163 | self.patchset, 164 | self.uploader) 165 | 166 | 167 | @GerritEventFactory.register("comment-added") 168 | class CommentAddedEvent(GerritEvent): 169 | 170 | """ Gerrit "comment-added" event. """ 171 | 172 | def __init__(self, json_data): 173 | super(CommentAddedEvent, self).__init__(json_data) 174 | try: 175 | self.change = Change(json_data["change"]) 176 | self.patchset = Patchset(json_data["patchSet"]) 177 | self.author = Account(json_data["author"]) 178 | self.approvals = [] 179 | if "approvals" in json_data: 180 | for approval in json_data["approvals"]: 181 | self.approvals.append(Approval(approval)) 182 | self.comment = json_data["comment"] 183 | except (KeyError, ValueError) as e: 184 | raise GerritError("CommentAddedEvent: %s" % e) 185 | 186 | def __repr__(self): 187 | return u": %s %s %s" % (self.change, 188 | self.patchset, 189 | self.author) 190 | 191 | 192 | @GerritEventFactory.register("change-merged") 193 | class ChangeMergedEvent(GerritEvent): 194 | 195 | """ Gerrit "change-merged" event. """ 196 | 197 | def __init__(self, json_data): 198 | super(ChangeMergedEvent, self).__init__(json_data) 199 | try: 200 | self.change = Change(json_data["change"]) 201 | self.patchset = Patchset(json_data["patchSet"]) 202 | self.submitter = Account(json_data["submitter"]) 203 | except KeyError as e: 204 | raise GerritError("ChangeMergedEvent: %s" % e) 205 | 206 | def __repr__(self): 207 | return u": %s %s %s" % (self.change, 208 | self.patchset, 209 | self.submitter) 210 | 211 | 212 | @GerritEventFactory.register("merge-failed") 213 | class MergeFailedEvent(GerritEvent): 214 | 215 | """ Gerrit "merge-failed" event. """ 216 | 217 | def __init__(self, json_data): 218 | super(MergeFailedEvent, self).__init__(json_data) 219 | try: 220 | self.change = Change(json_data["change"]) 221 | self.patchset = Patchset(json_data["patchSet"]) 222 | self.submitter = Account(json_data["submitter"]) 223 | if 'reason' in json_data: 224 | self.reason = json_data["reason"] 225 | except KeyError as e: 226 | raise GerritError("MergeFailedEvent: %s" % e) 227 | 228 | def __repr__(self): 229 | return u": %s %s %s" % (self.change, 230 | self.patchset, 231 | self.submitter) 232 | 233 | 234 | @GerritEventFactory.register("change-abandoned") 235 | class ChangeAbandonedEvent(GerritEvent): 236 | 237 | """ Gerrit "change-abandoned" event. """ 238 | 239 | def __init__(self, json_data): 240 | super(ChangeAbandonedEvent, self).__init__(json_data) 241 | try: 242 | self.change = Change(json_data["change"]) 243 | self.abandoner = Account(json_data["abandoner"]) 244 | if 'reason' in json_data: 245 | self.reason = json_data["reason"] 246 | except KeyError as e: 247 | raise GerritError("ChangeAbandonedEvent: %s" % e) 248 | 249 | def __repr__(self): 250 | return u": %s %s" % (self.change, 251 | self.abandoner) 252 | 253 | 254 | @GerritEventFactory.register("change-restored") 255 | class ChangeRestoredEvent(GerritEvent): 256 | 257 | """ Gerrit "change-restored" event. """ 258 | 259 | def __init__(self, json_data): 260 | super(ChangeRestoredEvent, self).__init__(json_data) 261 | try: 262 | self.change = Change(json_data["change"]) 263 | self.restorer = Account(json_data["restorer"]) 264 | if 'reason' in json_data: 265 | self.reason = json_data["reason"] 266 | except KeyError as e: 267 | raise GerritError("ChangeRestoredEvent: %s" % e) 268 | 269 | def __repr__(self): 270 | return u": %s %s" % (self.change, 271 | self.restorer) 272 | 273 | 274 | @GerritEventFactory.register("ref-updated") 275 | class RefUpdatedEvent(GerritEvent): 276 | 277 | """ Gerrit "ref-updated" event. """ 278 | 279 | def __init__(self, json_data): 280 | super(RefUpdatedEvent, self).__init__(json_data) 281 | try: 282 | self.ref_update = RefUpdate(json_data["refUpdate"]) 283 | self.submitter = Account.from_json(json_data, "submitter") 284 | except KeyError as e: 285 | raise GerritError("RefUpdatedEvent: %s" % e) 286 | 287 | def __repr__(self): 288 | return u": %s %s" % (self.ref_update, self.submitter) 289 | 290 | 291 | @GerritEventFactory.register("reviewer-added") 292 | class ReviewerAddedEvent(GerritEvent): 293 | 294 | """ Gerrit "reviewer-added" event. """ 295 | 296 | def __init__(self, json_data): 297 | super(ReviewerAddedEvent, self).__init__(json_data) 298 | try: 299 | self.change = Change(json_data["change"]) 300 | self.patchset = Patchset.from_json(json_data) 301 | self.reviewer = Account(json_data["reviewer"]) 302 | except KeyError as e: 303 | raise GerritError("ReviewerAddedEvent: %s" % e) 304 | 305 | def __repr__(self): 306 | return u": %s %s %s" % (self.change, 307 | self.patchset, 308 | self.reviewer) 309 | 310 | 311 | @GerritEventFactory.register("topic-changed") 312 | class TopicChangedEvent(GerritEvent): 313 | 314 | """ Gerrit "topic-changed" event. """ 315 | 316 | def __init__(self, json_data): 317 | super(TopicChangedEvent, self).__init__(json_data) 318 | try: 319 | self.change = Change(json_data["change"]) 320 | self.changer = Account(json_data["changer"]) 321 | if "oldTopic" in json_data: 322 | self.oldtopic = json_data["oldTopic"] 323 | else: 324 | self.oldtopic = "" 325 | except KeyError as e: 326 | raise GerritError("TopicChangedEvent: %s" % e) 327 | 328 | def __repr__(self): 329 | return u": %s %s [%s]" % (self.change, 330 | self.changer, 331 | self.oldtopic) 332 | -------------------------------------------------------------------------------- /pygerrit/models.py: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | # 3 | # Copyright 2011 Sony Ericsson Mobile Communications. All rights reserved. 4 | # Copyright 2012 Sony Mobile Communications. All rights reserved. 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in 14 | # all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | # THE SOFTWARE. 23 | 24 | """ Models for Gerrit JSON data. """ 25 | 26 | from . import from_json 27 | 28 | 29 | class Account(object): 30 | 31 | """ Gerrit user account (name and email address). """ 32 | 33 | def __init__(self, json_data): 34 | self.name = from_json(json_data, "name") 35 | self.email = from_json(json_data, "email") 36 | self.username = from_json(json_data, "username") 37 | 38 | def __repr__(self): 39 | return u"" % (self.name, 40 | " (%s)" % self.email if self.email else "") 41 | 42 | @staticmethod 43 | def from_json(json_data, key): 44 | """ Create an Account instance. 45 | 46 | Return an instance of Account initialised with values from `key` 47 | in `json_data`, or None if `json_data` does not contain `key`. 48 | 49 | """ 50 | if key in json_data: 51 | return Account(json_data[key]) 52 | return None 53 | 54 | 55 | class Change(object): 56 | 57 | """ Gerrit change. """ 58 | 59 | def __init__(self, json_data): 60 | self.project = from_json(json_data, "project") 61 | self.branch = from_json(json_data, "branch") 62 | self.topic = from_json(json_data, "topic") 63 | self.change_id = from_json(json_data, "id") 64 | self.number = from_json(json_data, "number") 65 | self.subject = from_json(json_data, "subject") 66 | self.url = from_json(json_data, "url") 67 | self.owner = Account.from_json(json_data, "owner") 68 | self.sortkey = from_json(json_data, "sortKey") 69 | self.status = from_json(json_data, "status") 70 | self.current_patchset = CurrentPatchset.from_json(json_data) 71 | 72 | def __repr__(self): 73 | return u"" % (self.number, self.project, self.branch) 74 | 75 | @staticmethod 76 | def from_json(json_data): 77 | r""" Create a Change instance. 78 | 79 | Return an instance of Change initialised with values from "change" 80 | in `json_data`, or None if `json_data` does not contain "change". 81 | 82 | """ 83 | if "change" in json_data: 84 | return Change(json_data["change"]) 85 | return None 86 | 87 | 88 | class Patchset(object): 89 | 90 | """ Gerrit patch set. """ 91 | 92 | def __init__(self, json_data): 93 | self.number = from_json(json_data, "number") 94 | self.revision = from_json(json_data, "revision") 95 | self.ref = from_json(json_data, "ref") 96 | self.uploader = Account.from_json(json_data, "uploader") 97 | 98 | def __repr__(self): 99 | return u"" % (self.number, self.revision) 100 | 101 | @staticmethod 102 | def from_json(json_data): 103 | r""" Create a Patchset instance. 104 | 105 | Return an instance of Patchset initialised with values from "patchSet" 106 | in `json_data`, or None if `json_data` does not contain "patchSet". 107 | 108 | """ 109 | if "patchSet" in json_data: 110 | return Patchset(json_data["patchSet"]) 111 | return None 112 | 113 | 114 | class CurrentPatchset(Patchset): 115 | 116 | """ Gerrit current patch set. """ 117 | 118 | def __init__(self, json_data): 119 | super(CurrentPatchset, self).__init__(json_data) 120 | self.author = Account.from_json(json_data, "author") 121 | self.approvals = [] 122 | if "approvals" in json_data: 123 | for approval in json_data["approvals"]: 124 | self.approvals.append(Approval(approval)) 125 | 126 | def __repr__(self): 127 | return u"" % (self.number, self.revision) 128 | 129 | @staticmethod 130 | def from_json(json_data): 131 | r""" Create a CurrentPatchset instance. 132 | 133 | Return an instance of CurrentPatchset initialised with values from 134 | "currentPatchSet" in `json_data`, or None if `json_data` does not 135 | contain "currentPatchSet". 136 | 137 | """ 138 | if "currentPatchSet" in json_data: 139 | return CurrentPatchset(json_data["currentPatchSet"]) 140 | return None 141 | 142 | 143 | class Approval(object): 144 | 145 | """ Gerrit approval (verified, code review, etc). """ 146 | 147 | def __init__(self, json_data): 148 | self.category = from_json(json_data, "type") 149 | self.value = from_json(json_data, "value") 150 | self.description = from_json(json_data, "description") 151 | self.approver = Account.from_json(json_data, "by") 152 | 153 | def __repr__(self): 154 | return u"" % (self.description, self.value) 155 | 156 | 157 | class RefUpdate(object): 158 | 159 | """ Gerrit ref update. """ 160 | 161 | def __init__(self, json_data): 162 | self.oldrev = from_json(json_data, "oldRev") 163 | self.newrev = from_json(json_data, "newRev") 164 | self.refname = from_json(json_data, "refName") 165 | self.project = from_json(json_data, "project") 166 | 167 | def __repr__(self): 168 | return "" % \ 169 | (self.project, self.refname, self.oldrev, self.newrev) 170 | -------------------------------------------------------------------------------- /pygerrit/rest/__init__.py: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | # 3 | # Copyright 2013 Sony Mobile Communications. All rights reserved. 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in 13 | # all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | # THE SOFTWARE. 22 | 23 | """ Interface to the Gerrit REST API. """ 24 | 25 | import json 26 | import logging 27 | import requests 28 | 29 | GERRIT_MAGIC_JSON_PREFIX = ")]}\'\n" 30 | GERRIT_AUTH_SUFFIX = "/a" 31 | 32 | 33 | def _decode_response(response): 34 | """ Strip off Gerrit's magic prefix and decode a response. 35 | 36 | :returns: 37 | Decoded JSON content as a dict, or raw text if content could not be 38 | decoded as JSON. 39 | 40 | :raises: 41 | requests.HTTPError if the response contains an HTTP error status code. 42 | 43 | """ 44 | content = response.content.strip() 45 | logging.debug(content[:512]) 46 | response.raise_for_status() 47 | if content.startswith(GERRIT_MAGIC_JSON_PREFIX): 48 | content = content[len(GERRIT_MAGIC_JSON_PREFIX):] 49 | try: 50 | return json.loads(content) 51 | except ValueError: 52 | logging.error('Invalid json content: %s' % content) 53 | raise 54 | 55 | 56 | class GerritRestAPI(object): 57 | 58 | """ Interface to the Gerrit REST API. 59 | 60 | :arg str url: The full URL to the server, including the `http(s)://` prefix. 61 | If `auth` is given, `url` will be automatically adjusted to include 62 | Gerrit's authentication suffix. 63 | :arg auth: (optional) Authentication handler. Must be derived from 64 | `requests.auth.AuthBase`. 65 | :arg boolean verify: (optional) Set to False to disable verification of 66 | SSL certificates. 67 | 68 | """ 69 | 70 | def __init__(self, url, auth=None, verify=True): 71 | headers = {'Accept': 'application/json', 72 | 'Accept-Encoding': 'gzip'} 73 | self.kwargs = {'auth': auth, 74 | 'verify': verify, 75 | 'headers': headers} 76 | self.url = url.rstrip('/') 77 | 78 | if auth: 79 | if not isinstance(auth, requests.auth.AuthBase): 80 | raise ValueError('Invalid auth type; must be derived ' 81 | 'from requests.auth.AuthBase') 82 | 83 | if not self.url.endswith(GERRIT_AUTH_SUFFIX): 84 | self.url += GERRIT_AUTH_SUFFIX 85 | else: 86 | if self.url.endswith(GERRIT_AUTH_SUFFIX): 87 | self.url = self.url[: - len(GERRIT_AUTH_SUFFIX)] 88 | 89 | if not self.url.endswith('/'): 90 | self.url += '/' 91 | logging.debug("url %s", self.url) 92 | 93 | def make_url(self, endpoint): 94 | """ Make the full url for the endpoint. 95 | 96 | :arg str endpoint: The endpoint. 97 | 98 | :returns: 99 | The full url. 100 | 101 | """ 102 | endpoint = endpoint.lstrip('/') 103 | return self.url + endpoint 104 | 105 | def get(self, endpoint, **kwargs): 106 | """ Send HTTP GET to the endpoint. 107 | 108 | :arg str endpoint: The endpoint to send to. 109 | 110 | :returns: 111 | JSON decoded result. 112 | 113 | :raises: 114 | requests.RequestException on timeout or connection error. 115 | 116 | """ 117 | kwargs.update(self.kwargs.copy()) 118 | response = requests.get(self.make_url(endpoint), **kwargs) 119 | return _decode_response(response) 120 | 121 | def put(self, endpoint, **kwargs): 122 | """ Send HTTP PUT to the endpoint. 123 | 124 | :arg str endpoint: The endpoint to send to. 125 | 126 | :returns: 127 | JSON decoded result. 128 | 129 | :raises: 130 | requests.RequestException on timeout or connection error. 131 | 132 | """ 133 | kwargs.update(self.kwargs.copy()) 134 | if "data" in kwargs: 135 | kwargs["headers"].update( 136 | {"Content-Type": "application/json;charset=UTF-8"}) 137 | response = requests.put(self.make_url(endpoint), **kwargs) 138 | return _decode_response(response) 139 | 140 | def post(self, endpoint, **kwargs): 141 | """ Send HTTP POST to the endpoint. 142 | 143 | :arg str endpoint: The endpoint to send to. 144 | 145 | :returns: 146 | JSON decoded result. 147 | 148 | :raises: 149 | requests.RequestException on timeout or connection error. 150 | 151 | """ 152 | kwargs.update(self.kwargs.copy()) 153 | if "data" in kwargs: 154 | kwargs["headers"].update( 155 | {"Content-Type": "application/json;charset=UTF-8"}) 156 | response = requests.post(self.make_url(endpoint), **kwargs) 157 | return _decode_response(response) 158 | 159 | def delete(self, endpoint, **kwargs): 160 | """ Send HTTP DELETE to the endpoint. 161 | 162 | :arg str endpoint: The endpoint to send to. 163 | 164 | :returns: 165 | JSON decoded result. 166 | 167 | :raises: 168 | requests.RequestException on timeout or connection error. 169 | 170 | """ 171 | kwargs.update(self.kwargs.copy()) 172 | response = requests.delete(self.make_url(endpoint), **kwargs) 173 | return _decode_response(response) 174 | 175 | def review(self, change_id, revision, review): 176 | """ Submit a review. 177 | 178 | :arg str change_id: The change ID. 179 | :arg str revision: The revision. 180 | :arg str review: The review details as a :class:`GerritReview`. 181 | 182 | :returns: 183 | JSON decoded result. 184 | 185 | :raises: 186 | requests.RequestException on timeout or connection error. 187 | 188 | """ 189 | 190 | endpoint = "changes/%s/revisions/%s/review" % (change_id, revision) 191 | self.post(endpoint, data=str(review)) 192 | 193 | 194 | class GerritReview(object): 195 | 196 | """ Encapsulation of a Gerrit review. 197 | 198 | :arg str message: (optional) Cover message. 199 | :arg dict labels: (optional) Review labels. 200 | :arg dict comments: (optional) Inline comments. 201 | 202 | """ 203 | 204 | def __init__(self, message=None, labels=None, comments=None): 205 | self.message = message if message else "" 206 | if labels: 207 | if not isinstance(labels, dict): 208 | raise ValueError("labels must be a dict.") 209 | self.labels = labels 210 | else: 211 | self.labels = {} 212 | if comments: 213 | if not isinstance(comments, list): 214 | raise ValueError("comments must be a list.") 215 | self.comments = {} 216 | self.add_comments(comments) 217 | else: 218 | self.comments = {} 219 | 220 | def set_message(self, message): 221 | """ Set review cover message. 222 | 223 | :arg str message: Cover message. 224 | 225 | """ 226 | self.message = message 227 | 228 | def add_labels(self, labels): 229 | """ Add labels. 230 | 231 | :arg dict labels: Labels to add, for example 232 | 233 | Usage:: 234 | 235 | add_labels({'Verified': 1, 236 | 'Code-Review': -1}) 237 | 238 | """ 239 | self.labels.update(labels) 240 | 241 | def add_comments(self, comments): 242 | """ Add inline comments. 243 | 244 | :arg dict comments: Comments to add. 245 | 246 | Usage:: 247 | 248 | add_comments([{'filename': 'Makefile', 249 | 'line': 10, 250 | 'message': 'inline message'}]) 251 | 252 | add_comments([{'filename': 'Makefile', 253 | 'range': {'start_line': 0, 254 | 'start_character': 1, 255 | 'end_line': 0, 256 | 'end_character': 5}, 257 | 'message': 'inline message'}]) 258 | 259 | """ 260 | for comment in comments: 261 | if 'filename' and 'message' in comment.keys(): 262 | msg = {} 263 | if 'range' in comment.keys(): 264 | msg = {"range": comment['range'], 265 | "message": comment['message']} 266 | elif 'line' in comment.keys(): 267 | msg = {"line": comment['line'], 268 | "message": comment['message']} 269 | else: 270 | continue 271 | file_comment = {comment['filename']: [msg]} 272 | if self.comments: 273 | if comment['filename'] in self.comments.keys(): 274 | self.comments[comment['filename']].append(msg) 275 | else: 276 | self.comments.update(file_comment) 277 | else: 278 | self.comments.update(file_comment) 279 | 280 | def __str__(self): 281 | review_input = {} 282 | if self.message: 283 | review_input.update({'message': self.message}) 284 | if self.labels: 285 | review_input.update({'labels': self.labels}) 286 | if self.comments: 287 | review_input.update({'comments': self.comments}) 288 | return json.dumps(review_input) 289 | -------------------------------------------------------------------------------- /pygerrit/rest/auth.py: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | # 3 | # Copyright 2013 Sony Mobile Communications. All rights reserved. 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in 13 | # all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | # THE SOFTWARE. 22 | 23 | """ Authentication handlers. """ 24 | 25 | from requests.auth import HTTPDigestAuth, HTTPBasicAuth 26 | from requests.utils import get_netrc_auth 27 | 28 | 29 | class HTTPDigestAuthFromNetrc(HTTPDigestAuth): 30 | 31 | """ HTTP Digest Auth with netrc credentials. """ 32 | 33 | def __init__(self, url): 34 | auth = get_netrc_auth(url) 35 | if not auth: 36 | raise ValueError("netrc missing or no credentials found in netrc") 37 | username, password = auth 38 | super(HTTPDigestAuthFromNetrc, self).__init__(username, password) 39 | 40 | def __call__(self, req): 41 | return super(HTTPDigestAuthFromNetrc, self).__call__(req) 42 | 43 | 44 | class HTTPBasicAuthFromNetrc(HTTPBasicAuth): 45 | 46 | """ HTTP Basic Auth with netrc credentials. """ 47 | 48 | def __init__(self, url): 49 | auth = get_netrc_auth(url) 50 | if not auth: 51 | raise ValueError("netrc missing or no credentials found in netrc") 52 | username, password = auth 53 | super(HTTPBasicAuthFromNetrc, self).__init__(username, password) 54 | 55 | def __call__(self, req): 56 | return super(HTTPBasicAuthFromNetrc, self).__call__(req) 57 | -------------------------------------------------------------------------------- /pygerrit/ssh.py: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | # 3 | # Copyright 2012 Sony Mobile Communications. All rights reserved. 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in 13 | # all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | # THE SOFTWARE. 22 | 23 | """ Gerrit SSH Client. """ 24 | 25 | from os.path import abspath, expanduser, isfile 26 | import re 27 | import socket 28 | from threading import Event, Lock 29 | 30 | from .error import GerritError 31 | 32 | from paramiko import AutoAddPolicy, SSHClient, SSHConfig, ProxyCommand 33 | from paramiko.ssh_exception import SSHException 34 | 35 | 36 | def _extract_version(version_string, pattern): 37 | """ Extract the version from `version_string` using `pattern`. 38 | 39 | Return the version as a string, with leading/trailing whitespace 40 | stripped. 41 | 42 | """ 43 | if version_string: 44 | match = pattern.match(version_string.strip()) 45 | if match: 46 | return match.group(1) 47 | return "" 48 | 49 | 50 | class GerritSSHCommandResult(object): 51 | 52 | """ Represents the results of a Gerrit command run over SSH. """ 53 | 54 | def __init__(self, command, stdin, stdout, stderr): 55 | self.command = command 56 | self.stdin = stdin 57 | self.stdout = stdout 58 | self.stderr = stderr 59 | 60 | def __repr__(self): 61 | return "" % self.command 62 | 63 | 64 | class GerritSSHClient(SSHClient): 65 | 66 | """ Gerrit SSH Client, wrapping the paramiko SSH Client. """ 67 | 68 | def __init__(self, hostname, username=None, port=None, 69 | keepalive=None, auto_add_hosts=False): 70 | """ Initialise and connect to SSH. """ 71 | super(GerritSSHClient, self).__init__() 72 | self.remote_version = None 73 | self.hostname = hostname 74 | self.username = username 75 | self.key_filename = None 76 | self.port = port 77 | self.connected = Event() 78 | self.lock = Lock() 79 | self.proxy = None 80 | self.keepalive = keepalive 81 | if auto_add_hosts: 82 | self.set_missing_host_key_policy(AutoAddPolicy()) 83 | 84 | def _configure(self): 85 | """ Configure the ssh parameters from the config file. """ 86 | configfile = expanduser("~/.ssh/config") 87 | if not isfile(configfile): 88 | raise GerritError("ssh config file '%s' does not exist" % 89 | configfile) 90 | 91 | config = SSHConfig() 92 | config.parse(open(configfile)) 93 | data = config.lookup(self.hostname) 94 | if not data: 95 | raise GerritError("No ssh config for host %s" % self.hostname) 96 | if 'hostname' not in data or 'port' not in data or 'user' not in data: 97 | raise GerritError("Missing configuration data in %s" % configfile) 98 | self.hostname = data['hostname'] 99 | self.username = data['user'] 100 | if 'identityfile' in data: 101 | key_filename = abspath(expanduser(data['identityfile'][0])) 102 | if not isfile(key_filename): 103 | raise GerritError("Identity file '%s' does not exist" % 104 | key_filename) 105 | self.key_filename = key_filename 106 | try: 107 | self.port = int(data['port']) 108 | except ValueError: 109 | raise GerritError("Invalid port: %s" % data['port']) 110 | if 'proxycommand' in data: 111 | self.proxy = ProxyCommand(data['proxycommand']) 112 | 113 | def _do_connect(self): 114 | """ Connect to the remote. """ 115 | self.load_system_host_keys() 116 | if self.username is None or self.port is None: 117 | self._configure() 118 | try: 119 | self.connect(hostname=self.hostname, 120 | port=self.port, 121 | username=self.username, 122 | key_filename=self.key_filename, 123 | sock=self.proxy) 124 | except socket.error as e: 125 | raise GerritError("Failed to connect to server: %s" % e) 126 | 127 | try: 128 | version_string = self._transport.remote_version 129 | pattern = re.compile(r'^.*GerritCodeReview_([a-z0-9-\.]*) .*$') 130 | self.remote_version = _extract_version(version_string, pattern) 131 | except AttributeError: 132 | self.remote_version = None 133 | 134 | def _connect(self): 135 | """ Connect to the remote if not already connected. """ 136 | if not self.connected.is_set(): 137 | try: 138 | self.lock.acquire() 139 | # Another thread may have connected while we were 140 | # waiting to acquire the lock 141 | if not self.connected.is_set(): 142 | self._do_connect() 143 | if self.keepalive: 144 | self._transport.set_keepalive(self.keepalive) 145 | self.connected.set() 146 | except GerritError: 147 | raise 148 | finally: 149 | self.lock.release() 150 | 151 | def get_remote_version(self): 152 | """ Return the version of the remote Gerrit server. """ 153 | if self.remote_version is None: 154 | result = self.run_gerrit_command("version") 155 | version_string = result.stdout.read() 156 | pattern = re.compile(r'^gerrit version (.*)$') 157 | self.remote_version = _extract_version(version_string, pattern) 158 | return self.remote_version 159 | 160 | def get_remote_info(self): 161 | """ Return the username, and version of the remote Gerrit server. """ 162 | version = self.get_remote_version() 163 | return (self.username, version) 164 | 165 | def run_gerrit_command(self, command): 166 | """ Run the given command. 167 | 168 | Make sure we're connected to the remote server, and run `command`. 169 | 170 | Return the results as a `GerritSSHCommandResult`. 171 | 172 | Raise `ValueError` if `command` is not a string, or `GerritError` if 173 | command execution fails. 174 | 175 | """ 176 | if not isinstance(command, basestring): 177 | raise ValueError("command must be a string") 178 | gerrit_command = "gerrit " + command 179 | 180 | # are we sending non-ascii data? 181 | try: 182 | gerrit_command.encode('ascii') 183 | except UnicodeEncodeError: 184 | gerrit_command = gerrit_command.encode('utf-8') 185 | 186 | self._connect() 187 | try: 188 | stdin, stdout, stderr = self.exec_command(gerrit_command, 189 | bufsize=1, 190 | timeout=None, 191 | get_pty=False) 192 | except SSHException as err: 193 | raise GerritError("Command execution error: %s" % err) 194 | return GerritSSHCommandResult(command, stdin, stdout, stderr) 195 | -------------------------------------------------------------------------------- /pygerrit/stream.py: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | # 3 | # Copyright 2012 Sony Mobile Communications. All rights reserved. 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in 13 | # all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | # THE SOFTWARE. 22 | 23 | """ Gerrit event stream interface. 24 | 25 | Class to listen to the Gerrit event stream and dispatch events. 26 | 27 | """ 28 | 29 | from threading import Thread, Event 30 | 31 | from .events import ErrorEvent 32 | 33 | 34 | class GerritStream(Thread): 35 | 36 | """ Gerrit events stream handler. """ 37 | 38 | def __init__(self, gerrit, ssh_client): 39 | Thread.__init__(self) 40 | self.daemon = True 41 | self._gerrit = gerrit 42 | self._ssh_client = ssh_client 43 | self._stop = Event() 44 | self._channel = None 45 | 46 | def stop(self): 47 | """ Stop the thread. """ 48 | self._stop.set() 49 | if self._channel is not None: 50 | self._channel.close() 51 | 52 | def _error_event(self, error): 53 | """ Dispatch `error` to the Gerrit client. """ 54 | self._gerrit.put_event(ErrorEvent.error_json(error)) 55 | 56 | def run(self): 57 | """ Listen to the stream and send events to the client. """ 58 | channel = self._ssh_client.get_transport().open_session() 59 | self._channel = channel 60 | channel.exec_command("gerrit stream-events") 61 | stdout = channel.makefile() 62 | stderr = channel.makefile_stderr() 63 | while not self._stop.is_set(): 64 | try: 65 | if channel.exit_status_ready(): 66 | if channel.recv_stderr_ready(): 67 | error = stderr.readline().strip() 68 | else: 69 | error = "Remote server connection closed" 70 | self._error_event(error) 71 | self._stop.set() 72 | else: 73 | data = stdout.readline() 74 | self._gerrit.put_event(data) 75 | except Exception as e: # pylint: disable=W0703 76 | self._error_event(repr(e)) 77 | self._stop.set() 78 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | ecdsa==0.11 2 | paramiko==1.16.0 3 | pbr>=0.8.0 4 | pycrypto==2.6.1 5 | requests==2.9.1 6 | -------------------------------------------------------------------------------- /rest_example.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # The MIT License 5 | # 6 | # Copyright 2013 Sony Mobile Communications. All rights reserved. 7 | # 8 | # Permission is hereby granted, free of charge, to any person obtaining a copy 9 | # of this software and associated documentation files (the "Software"), to deal 10 | # in the Software without restriction, including without limitation the rights 11 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | # copies of the Software, and to permit persons to whom the Software is 13 | # furnished to do so, subject to the following conditions: 14 | # 15 | # The above copyright notice and this permission notice shall be included in 16 | # all copies or substantial portions of the Software. 17 | # 18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 24 | # THE SOFTWARE. 25 | 26 | """ Example of using the Gerrit client REST API. """ 27 | 28 | import argparse 29 | import logging 30 | import sys 31 | 32 | from requests.auth import HTTPBasicAuth, HTTPDigestAuth 33 | from requests.exceptions import RequestException 34 | try: 35 | # pylint: disable=F0401 36 | from requests_kerberos import HTTPKerberosAuth, OPTIONAL 37 | # pylint: enable=F0401 38 | _kerberos_support = True 39 | except ImportError: 40 | _kerberos_support = False 41 | 42 | from pygerrit.rest import GerritRestAPI 43 | from pygerrit.rest.auth import HTTPDigestAuthFromNetrc, HTTPBasicAuthFromNetrc 44 | 45 | 46 | def _main(): 47 | descr = 'Send request using Gerrit HTTP API' 48 | parser = argparse.ArgumentParser( 49 | description=descr, 50 | formatter_class=argparse.ArgumentDefaultsHelpFormatter) 51 | parser.add_argument('-g', '--gerrit-url', dest='gerrit_url', 52 | required=True, 53 | help='gerrit server url') 54 | parser.add_argument('-b', '--basic-auth', dest='basic_auth', 55 | action='store_true', 56 | help='use basic auth instead of digest') 57 | if _kerberos_support: 58 | parser.add_argument('-k', '--kerberos-auth', dest='kerberos_auth', 59 | action='store_true', 60 | help='use kerberos auth') 61 | parser.add_argument('-u', '--username', dest='username', 62 | help='username') 63 | parser.add_argument('-p', '--password', dest='password', 64 | help='password') 65 | parser.add_argument('-n', '--netrc', dest='netrc', 66 | action='store_true', 67 | help='Use credentials from netrc') 68 | parser.add_argument('-v', '--verbose', dest='verbose', 69 | action='store_true', 70 | help='enable verbose (debug) logging') 71 | 72 | options = parser.parse_args() 73 | 74 | level = logging.DEBUG if options.verbose else logging.INFO 75 | logging.basicConfig(format='%(asctime)s %(levelname)s %(message)s', 76 | level=level) 77 | 78 | if _kerberos_support and options.kerberos_auth: 79 | if options.username or options.password \ 80 | or options.basic_auth or options.netrc: 81 | parser.error("--kerberos-auth may not be used together with " 82 | "--username, --password, --basic-auth or --netrc") 83 | auth = HTTPKerberosAuth(mutual_authentication=OPTIONAL) 84 | elif options.username and options.password: 85 | if options.netrc: 86 | logging.warning("--netrc option ignored") 87 | if options.basic_auth: 88 | auth = HTTPBasicAuth(options.username, options.password) 89 | else: 90 | auth = HTTPDigestAuth(options.username, options.password) 91 | elif options.netrc: 92 | if options.basic_auth: 93 | auth = HTTPBasicAuthFromNetrc(url=options.gerrit_url) 94 | else: 95 | auth = HTTPDigestAuthFromNetrc(url=options.gerrit_url) 96 | else: 97 | auth = None 98 | 99 | rest = GerritRestAPI(url=options.gerrit_url, auth=auth) 100 | 101 | try: 102 | changes = rest.get("/changes/?q=owner:self%20status:open") 103 | logging.info("%d changes", len(changes)) 104 | for change in changes: 105 | logging.info(change['change_id']) 106 | except RequestException as err: 107 | logging.error("Error: %s", str(err)) 108 | 109 | if __name__ == "__main__": 110 | sys.exit(_main()) 111 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = pygerrit 3 | summary = Client library for interacting with Gerrit 4 | author = David Pursehouse 5 | author_email = david.pursehouse@sonymobile.com 6 | home-page = https://github.com/sonyxperiadev/pygerrit 7 | license = The MIT License 8 | description-file = README.rst 9 | keywords = 10 | gerrit 11 | rest 12 | http 13 | json 14 | classifiers = 15 | Development Status :: 3 - Alpha 16 | Environment :: Console 17 | Intended Audience :: Developers 18 | License :: OSI Approved :: MIT License 19 | Natural Language :: English 20 | Programming Language :: Python 21 | Programming Language :: Python :: 2.6 22 | Programming Language :: Python :: 2.7 23 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # The MIT License 5 | # 6 | # Copyright 2012 Sony Mobile Communications. All rights reserved. 7 | # 8 | # Permission is hereby granted, free of charge, to any person obtaining a copy 9 | # of this software and associated documentation files (the "Software"), to deal 10 | # in the Software without restriction, including without limitation the rights 11 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | # copies of the Software, and to permit persons to whom the Software is 13 | # furnished to do so, subject to the following conditions: 14 | # 15 | # The above copyright notice and this permission notice shall be included in 16 | # all copies or substantial portions of the Software. 17 | # 18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 24 | # THE SOFTWARE. 25 | 26 | """ Client library for interacting with Gerrit. """ 27 | 28 | import setuptools 29 | 30 | 31 | def _main(): 32 | setuptools.setup( 33 | packages=setuptools.find_packages(), 34 | setup_requires=['pbr'], 35 | pbr=True) 36 | 37 | if __name__ == "__main__": 38 | _main() 39 | -------------------------------------------------------------------------------- /test_requirements.txt: -------------------------------------------------------------------------------- 1 | pep257==0.2.4 2 | pep8==1.7.0 3 | pyflakes==1.0.0 4 | -------------------------------------------------------------------------------- /testdata/change-abandoned-event.txt: -------------------------------------------------------------------------------- 1 | {"type":"change-abandoned", 2 | "change":{"project":"project-name", 3 | "branch":"branch-name", 4 | "topic":"topic-name", 5 | "id":"Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef", 6 | "number":"123456", 7 | "subject":"Commit message subject", 8 | "owner":{"name":"Owner Name", 9 | "email":"owner@example.com"}, 10 | "url":"http://review.example.com/123456"}, 11 | "abandoner":{"name":"Abandoner Name", 12 | "email":"abandoner@example.com"}, 13 | "reason":"Abandon reason"} 14 | -------------------------------------------------------------------------------- /testdata/change-merged-event.txt: -------------------------------------------------------------------------------- 1 | {"type":"change-merged", 2 | "change":{"project":"project-name", 3 | "branch":"branch-name", 4 | "topic":"topic-name", 5 | "id":"Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef", 6 | "number":"123456", 7 | "subject":"Commit message subject", 8 | "owner":{"name":"Owner Name", 9 | "email":"owner@example.com"}, 10 | "url":"http://review.example.com/123456"}, 11 | "patchSet":{"number":"4", 12 | "revision":"deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", 13 | "ref":"refs/changes/56/123456/4", 14 | "uploader":{"name":"Uploader Name", 15 | "email":"uploader@example.com"}, 16 | "createdOn":1341370514}, 17 | "submitter":{"name":"Submitter Name", 18 | "email":"submitter@example.com"}} 19 | -------------------------------------------------------------------------------- /testdata/change-restored-event.txt: -------------------------------------------------------------------------------- 1 | {"type":"change-restored", 2 | "change":{"project":"project-name", 3 | "branch":"branch-name", 4 | "topic":"topic-name", 5 | "id":"Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef", 6 | "number":"123456", 7 | "subject":"Commit message subject", 8 | "owner":{"name":"Owner Name", 9 | "email":"owner@example.com"}, 10 | "url":"http://review.example.com/123456"}, 11 | "restorer":{"name":"Restorer Name", 12 | "email":"restorer@example.com"}, 13 | "reason":"Restore reason"} 14 | -------------------------------------------------------------------------------- /testdata/comment-added-event.txt: -------------------------------------------------------------------------------- 1 | {"type":"comment-added", 2 | "change":{"project":"project-name", 3 | "branch":"branch-name", 4 | "topic":"topic-name", 5 | "id":"Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef", 6 | "number":"123456", 7 | "subject":"Commit message subject", 8 | "owner":{"name":"Owner Name", 9 | "email":"owner@example.com"}, 10 | "url":"http://review.example.com/123456"}, 11 | "patchSet":{"number":"4", 12 | "revision":"deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", 13 | "ref":"refs/changes/56/123456/4", 14 | "uploader":{"name":"Uploader Name", 15 | "email":"uploader@example.com"}, 16 | "createdOn":1341370514}, 17 | "author":{"name":"Author Name", 18 | "email":"author@example.com"}, 19 | "approvals":[{"type":"CRVW", 20 | "description":"Code Review", 21 | "value":"1"}, 22 | {"type":"VRIF", 23 | "description":"Verified", 24 | "value":"1"}], 25 | "comment":"Review comment"} 26 | -------------------------------------------------------------------------------- /testdata/draft-published-event.txt: -------------------------------------------------------------------------------- 1 | {"type":"draft-published", 2 | "change":{"project":"project-name", 3 | "branch":"branch-name", 4 | "topic":"topic-name", 5 | "id":"Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef", 6 | "number":"123456", 7 | "subject":"Commit message subject", 8 | "owner":{"name":"Owner Name", 9 | "email":"owner@example.com"}, 10 | "url":"http://review.example.com/123456"}, 11 | "patchSet":{"number":"4", 12 | "revision":"deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", 13 | "ref":"refs/changes/56/123456/4", 14 | "uploader":{"name":"Uploader Name", 15 | "email":"uploader@example.com"}, 16 | "createdOn":1342075181}, 17 | "uploader":{"name":"Uploader Name", 18 | "email":"uploader@example.com"}} 19 | -------------------------------------------------------------------------------- /testdata/invalid-json.txt: -------------------------------------------------------------------------------- 1 | )]}' 2 | {"type":"user-defined-event", 3 | "title":"Event title", 4 | "description":"Event description"} 5 | -------------------------------------------------------------------------------- /testdata/merge-failed-event.txt: -------------------------------------------------------------------------------- 1 | {"type":"merge-failed", 2 | "change":{"project":"project-name", 3 | "branch":"branch-name", 4 | "topic":"topic-name", 5 | "id":"Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef", 6 | "number":"123456", 7 | "subject":"Commit message subject", 8 | "owner":{"name":"Owner Name", 9 | "email":"owner@example.com"}, 10 | "url":"http://review.example.com/123456"}, 11 | "patchSet":{"number":"4", 12 | "revision":"deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", 13 | "ref":"refs/changes/56/123456/4", 14 | "uploader":{"name":"Uploader Name", 15 | "email":"uploader@example.com"}, 16 | "createdOn":1341370514}, 17 | "submitter":{"name":"Submitter Name", 18 | "email":"submitter@example.com"}, 19 | "reason":"Merge failed reason"} 20 | -------------------------------------------------------------------------------- /testdata/patchset-created-event.txt: -------------------------------------------------------------------------------- 1 | {"type":"patchset-created", 2 | "change":{"project":"project-name", 3 | "branch":"branch-name", 4 | "topic":"topic-name", 5 | "id":"Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef", 6 | "number":"123456", 7 | "subject":"Commit message subject", 8 | "owner":{"name":"Owner Name", 9 | "email":"owner@example.com"}, 10 | "url":"http://review.example.com/123456"}, 11 | "patchSet":{"number":"4", 12 | "revision":"deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", 13 | "ref":"refs/changes/56/123456/4", 14 | "uploader":{"name":"Uploader Name", 15 | "email":"uploader@example.com"}, 16 | "createdOn":1342075181}, 17 | "uploader":{"name":"Uploader Name", 18 | "email":"uploader@example.com"}} -------------------------------------------------------------------------------- /testdata/ref-updated-event.txt: -------------------------------------------------------------------------------- 1 | {"type":"ref-updated", 2 | "submitter":{"name":"Submitter Name", 3 | "email":"submitter@example.com"}, 4 | "refUpdate":{"oldRev":"0000000000000000000000000000000000000000", 5 | "newRev":"deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", 6 | "refName":"refs/tags/refname", 7 | "project":"project-name"}} 8 | -------------------------------------------------------------------------------- /testdata/reviewer-added-event.txt: -------------------------------------------------------------------------------- 1 | {"type":"reviewer-added", 2 | "change":{"project":"project-name", 3 | "branch":"branch-name", 4 | "topic":"topic-name", 5 | "id":"Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef", 6 | "number":"123456", 7 | "subject":"Commit message subject", 8 | "owner":{"name":"Owner Name", 9 | "email":"owner@example.com"}, 10 | "url":"http://review.example.com/123456"}, 11 | "patchSet":{"number":"4", 12 | "revision":"deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", 13 | "ref":"refs/changes/56/123456/4", 14 | "uploader":{"name":"Uploader Name", 15 | "email":"uploader@example.com"}, 16 | "createdOn":1341370514}, 17 | "reviewer":{"name":"Reviewer Name", 18 | "email":"reviewer@example.com"}} 19 | -------------------------------------------------------------------------------- /testdata/topic-changed-event.txt: -------------------------------------------------------------------------------- 1 | {"type":"topic-changed", 2 | "change":{"project":"project-name", 3 | "branch":"branch-name", 4 | "topic":"topic-name", 5 | "id":"Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef", 6 | "number":"123456", 7 | "subject":"Commit message subject", 8 | "owner":{"name":"Owner Name", 9 | "email":"owner@example.com"}, 10 | "url":"http://review.example.com/123456"}, 11 | "changer":{"name":"Changer Name", 12 | "email":"changer@example.com"}, 13 | "oldTopic":"old-topic"} 14 | -------------------------------------------------------------------------------- /testdata/unhandled-event.txt: -------------------------------------------------------------------------------- 1 | {"type":"this-event-is-not-handled", 2 | "title":"Unhandled event title", 3 | "description":"Unhandled event description"} 4 | -------------------------------------------------------------------------------- /testdata/user-defined-event.txt: -------------------------------------------------------------------------------- 1 | {"type":"user-defined-event", 2 | "title":"Event title", 3 | "description":"Event description"} 4 | -------------------------------------------------------------------------------- /unittests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # The MIT License 5 | # 6 | # Copyright 2012 Sony Mobile Communications. All rights reserved. 7 | # 8 | # Permission is hereby granted, free of charge, to any person obtaining a copy 9 | # of this software and associated documentation files (the "Software"), to deal 10 | # in the Software without restriction, including without limitation the rights 11 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | # copies of the Software, and to permit persons to whom the Software is 13 | # furnished to do so, subject to the following conditions: 14 | # 15 | # The above copyright notice and this permission notice shall be included in 16 | # all copies or substantial portions of the Software. 17 | # 18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 24 | # THE SOFTWARE. 25 | 26 | """ Unit tests for the Gerrit event stream handler and event objects. """ 27 | 28 | import json 29 | import os 30 | import unittest 31 | 32 | from pygerrit.events import PatchsetCreatedEvent, \ 33 | RefUpdatedEvent, ChangeMergedEvent, CommentAddedEvent, \ 34 | ChangeAbandonedEvent, ChangeRestoredEvent, \ 35 | DraftPublishedEvent, GerritEventFactory, GerritEvent, UnhandledEvent, \ 36 | ErrorEvent, MergeFailedEvent, ReviewerAddedEvent, TopicChangedEvent 37 | from pygerrit.client import GerritClient 38 | from pygerrit import GerritReviewMessageFormatter 39 | from pygerrit.rest import GerritReview 40 | 41 | EXPECTED_TEST_CASE_FIELDS = ['header', 'footer', 'paragraphs', 'result'] 42 | 43 | 44 | TEST_CASES = [ 45 | {'header': None, 46 | 'footer': None, 47 | 'paragraphs': [], 48 | 'result': ""}, 49 | {'header': "Header", 50 | 'footer': "Footer", 51 | 'paragraphs': [], 52 | 'result': ""}, 53 | {'header': None, 54 | 'footer': None, 55 | 'paragraphs': ["Test"], 56 | 'result': "Test"}, 57 | {'header': None, 58 | 'footer': None, 59 | 'paragraphs': ["Test", "Test"], 60 | 'result': "Test\n\nTest"}, 61 | {'header': "Header", 62 | 'footer': None, 63 | 'paragraphs': ["Test"], 64 | 'result': "Header\n\nTest"}, 65 | {'header': "Header", 66 | 'footer': None, 67 | 'paragraphs': ["Test", "Test"], 68 | 'result': "Header\n\nTest\n\nTest"}, 69 | {'header': "Header", 70 | 'footer': "Footer", 71 | 'paragraphs': ["Test", "Test"], 72 | 'result': "Header\n\nTest\n\nTest\n\nFooter"}, 73 | {'header': "Header", 74 | 'footer': "Footer", 75 | 'paragraphs': [["One"]], 76 | 'result': "Header\n\n* One\n\nFooter"}, 77 | {'header': "Header", 78 | 'footer': "Footer", 79 | 'paragraphs': [["One", "Two"]], 80 | 'result': "Header\n\n* One\n* Two\n\nFooter"}, 81 | {'header': "Header", 82 | 'footer': "Footer", 83 | 'paragraphs': ["Test", ["One"], "Test"], 84 | 'result': "Header\n\nTest\n\n* One\n\nTest\n\nFooter"}, 85 | {'header': "Header", 86 | 'footer': "Footer", 87 | 'paragraphs': ["Test", ["One", "Two"], "Test"], 88 | 'result': "Header\n\nTest\n\n* One\n* Two\n\nTest\n\nFooter"}, 89 | {'header': "Header", 90 | 'footer': "Footer", 91 | 'paragraphs': ["Test", "Test", ["One"]], 92 | 'result': "Header\n\nTest\n\nTest\n\n* One\n\nFooter"}, 93 | {'header': None, 94 | 'footer': None, 95 | 'paragraphs': [["* One", "* Two"]], 96 | 'result': "* One\n* Two"}, 97 | {'header': None, 98 | 'footer': None, 99 | 'paragraphs': [["* One ", " * Two "]], 100 | 'result': "* One\n* Two"}, 101 | {'header': None, 102 | 'footer': None, 103 | 'paragraphs': [["*", "*"]], 104 | 'result': ""}, 105 | {'header': None, 106 | 'footer': None, 107 | 'paragraphs': [["", ""]], 108 | 'result': ""}, 109 | {'header': None, 110 | 'footer': None, 111 | 'paragraphs': [[" ", " "]], 112 | 'result': ""}, 113 | {'header': None, 114 | 'footer': None, 115 | 'paragraphs': [["* One", " ", "* Two"]], 116 | 'result': "* One\n* Two"}] 117 | 118 | 119 | @GerritEventFactory.register("user-defined-event") 120 | class UserDefinedEvent(GerritEvent): 121 | 122 | """ Dummy event class to test event registration. """ 123 | 124 | def __init__(self, json_data): 125 | super(UserDefinedEvent, self).__init__(json_data) 126 | self.title = json_data['title'] 127 | self.description = json_data['description'] 128 | 129 | 130 | def _create_event(name, gerrit): 131 | """ Create a new event. 132 | 133 | Read the contents of the file specified by `name` and load as JSON 134 | data, then add as an event in the `gerrit` client. 135 | 136 | """ 137 | testfile = open(os.path.join("testdata", name + ".txt")) 138 | data = testfile.read().replace("\n", "") 139 | gerrit.put_event(data) 140 | return data 141 | 142 | 143 | class TestGerritEvents(unittest.TestCase): 144 | def setUp(self): 145 | self.gerrit = GerritClient("review") 146 | 147 | def test_patchset_created(self): 148 | _create_event("patchset-created-event", self.gerrit) 149 | event = self.gerrit.get_event(False) 150 | self.assertTrue(isinstance(event, PatchsetCreatedEvent)) 151 | self.assertEqual(event.name, "patchset-created") 152 | self.assertEqual(event.change.project, "project-name") 153 | self.assertEqual(event.change.branch, "branch-name") 154 | self.assertEqual(event.change.topic, "topic-name") 155 | self.assertEqual(event.change.change_id, 156 | "Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef") 157 | self.assertEqual(event.change.number, "123456") 158 | self.assertEqual(event.change.subject, "Commit message subject") 159 | self.assertEqual(event.change.url, "http://review.example.com/123456") 160 | self.assertEqual(event.change.owner.name, "Owner Name") 161 | self.assertEqual(event.change.owner.email, "owner@example.com") 162 | self.assertEqual(event.patchset.number, "4") 163 | self.assertEqual(event.patchset.revision, 164 | "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef") 165 | self.assertEqual(event.patchset.ref, "refs/changes/56/123456/4") 166 | self.assertEqual(event.patchset.uploader.name, "Uploader Name") 167 | self.assertEqual(event.patchset.uploader.email, "uploader@example.com") 168 | self.assertEqual(event.uploader.name, "Uploader Name") 169 | self.assertEqual(event.uploader.email, "uploader@example.com") 170 | 171 | def test_draft_published(self): 172 | _create_event("draft-published-event", self.gerrit) 173 | event = self.gerrit.get_event(False) 174 | self.assertTrue(isinstance(event, DraftPublishedEvent)) 175 | self.assertEqual(event.name, "draft-published") 176 | self.assertEqual(event.change.project, "project-name") 177 | self.assertEqual(event.change.branch, "branch-name") 178 | self.assertEqual(event.change.topic, "topic-name") 179 | self.assertEqual(event.change.change_id, 180 | "Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef") 181 | self.assertEqual(event.change.number, "123456") 182 | self.assertEqual(event.change.subject, "Commit message subject") 183 | self.assertEqual(event.change.url, "http://review.example.com/123456") 184 | self.assertEqual(event.change.owner.name, "Owner Name") 185 | self.assertEqual(event.change.owner.email, "owner@example.com") 186 | self.assertEqual(event.patchset.number, "4") 187 | self.assertEqual(event.patchset.revision, 188 | "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef") 189 | self.assertEqual(event.patchset.ref, "refs/changes/56/123456/4") 190 | self.assertEqual(event.patchset.uploader.name, "Uploader Name") 191 | self.assertEqual(event.patchset.uploader.email, "uploader@example.com") 192 | self.assertEqual(event.uploader.name, "Uploader Name") 193 | self.assertEqual(event.uploader.email, "uploader@example.com") 194 | 195 | def test_ref_updated(self): 196 | _create_event("ref-updated-event", self.gerrit) 197 | event = self.gerrit.get_event(False) 198 | self.assertTrue(isinstance(event, RefUpdatedEvent)) 199 | self.assertEqual(event.name, "ref-updated") 200 | self.assertEqual(event.ref_update.project, "project-name") 201 | self.assertEqual(event.ref_update.oldrev, 202 | "0000000000000000000000000000000000000000") 203 | self.assertEqual(event.ref_update.newrev, 204 | "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef") 205 | self.assertEqual(event.ref_update.refname, "refs/tags/refname") 206 | self.assertEqual(event.submitter.name, "Submitter Name") 207 | self.assertEqual(event.submitter.email, "submitter@example.com") 208 | 209 | def test_change_merged(self): 210 | _create_event("change-merged-event", self.gerrit) 211 | event = self.gerrit.get_event(False) 212 | self.assertTrue(isinstance(event, ChangeMergedEvent)) 213 | self.assertEqual(event.name, "change-merged") 214 | self.assertEqual(event.change.project, "project-name") 215 | self.assertEqual(event.change.branch, "branch-name") 216 | self.assertEqual(event.change.topic, "topic-name") 217 | self.assertEqual(event.change.change_id, 218 | "Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef") 219 | self.assertEqual(event.change.number, "123456") 220 | self.assertEqual(event.change.subject, "Commit message subject") 221 | self.assertEqual(event.change.url, "http://review.example.com/123456") 222 | self.assertEqual(event.change.owner.name, "Owner Name") 223 | self.assertEqual(event.change.owner.email, "owner@example.com") 224 | self.assertEqual(event.patchset.number, "4") 225 | self.assertEqual(event.patchset.revision, 226 | "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef") 227 | self.assertEqual(event.patchset.ref, "refs/changes/56/123456/4") 228 | self.assertEqual(event.patchset.uploader.name, "Uploader Name") 229 | self.assertEqual(event.patchset.uploader.email, "uploader@example.com") 230 | self.assertEqual(event.submitter.name, "Submitter Name") 231 | self.assertEqual(event.submitter.email, "submitter@example.com") 232 | 233 | def test_merge_failed(self): 234 | _create_event("merge-failed-event", self.gerrit) 235 | event = self.gerrit.get_event(False) 236 | self.assertTrue(isinstance(event, MergeFailedEvent)) 237 | self.assertEqual(event.name, "merge-failed") 238 | self.assertEqual(event.change.project, "project-name") 239 | self.assertEqual(event.change.branch, "branch-name") 240 | self.assertEqual(event.change.topic, "topic-name") 241 | self.assertEqual(event.change.change_id, 242 | "Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef") 243 | self.assertEqual(event.change.number, "123456") 244 | self.assertEqual(event.change.subject, "Commit message subject") 245 | self.assertEqual(event.change.url, "http://review.example.com/123456") 246 | self.assertEqual(event.change.owner.name, "Owner Name") 247 | self.assertEqual(event.change.owner.email, "owner@example.com") 248 | self.assertEqual(event.patchset.number, "4") 249 | self.assertEqual(event.patchset.revision, 250 | "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef") 251 | self.assertEqual(event.patchset.ref, "refs/changes/56/123456/4") 252 | self.assertEqual(event.patchset.uploader.name, "Uploader Name") 253 | self.assertEqual(event.patchset.uploader.email, "uploader@example.com") 254 | self.assertEqual(event.submitter.name, "Submitter Name") 255 | self.assertEqual(event.submitter.email, "submitter@example.com") 256 | self.assertEqual(event.reason, "Merge failed reason") 257 | 258 | def test_comment_added(self): 259 | _create_event("comment-added-event", self.gerrit) 260 | event = self.gerrit.get_event(False) 261 | self.assertTrue(isinstance(event, CommentAddedEvent)) 262 | self.assertEqual(event.name, "comment-added") 263 | self.assertEqual(event.change.project, "project-name") 264 | self.assertEqual(event.change.branch, "branch-name") 265 | self.assertEqual(event.change.topic, "topic-name") 266 | self.assertEqual(event.change.change_id, 267 | "Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef") 268 | self.assertEqual(event.change.number, "123456") 269 | self.assertEqual(event.change.subject, "Commit message subject") 270 | self.assertEqual(event.change.url, "http://review.example.com/123456") 271 | self.assertEqual(event.change.owner.name, "Owner Name") 272 | self.assertEqual(event.change.owner.email, "owner@example.com") 273 | self.assertEqual(event.patchset.number, "4") 274 | self.assertEqual(event.patchset.revision, 275 | "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef") 276 | self.assertEqual(event.patchset.ref, "refs/changes/56/123456/4") 277 | self.assertEqual(event.patchset.uploader.name, "Uploader Name") 278 | self.assertEqual(event.patchset.uploader.email, "uploader@example.com") 279 | self.assertEqual(len(event.approvals), 2) 280 | self.assertEqual(event.approvals[0].category, "CRVW") 281 | self.assertEqual(event.approvals[0].description, "Code Review") 282 | self.assertEqual(event.approvals[0].value, "1") 283 | self.assertEqual(event.approvals[1].category, "VRIF") 284 | self.assertEqual(event.approvals[1].description, "Verified") 285 | self.assertEqual(event.approvals[1].value, "1") 286 | self.assertEqual(event.author.name, "Author Name") 287 | self.assertEqual(event.author.email, "author@example.com") 288 | 289 | def test_reviewer_added(self): 290 | _create_event("reviewer-added-event", self.gerrit) 291 | event = self.gerrit.get_event(False) 292 | self.assertTrue(isinstance(event, ReviewerAddedEvent)) 293 | self.assertEqual(event.name, "reviewer-added") 294 | self.assertEqual(event.change.project, "project-name") 295 | self.assertEqual(event.change.branch, "branch-name") 296 | self.assertEqual(event.change.topic, "topic-name") 297 | self.assertEqual(event.change.change_id, 298 | "Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef") 299 | self.assertEqual(event.change.number, "123456") 300 | self.assertEqual(event.change.subject, "Commit message subject") 301 | self.assertEqual(event.change.url, "http://review.example.com/123456") 302 | self.assertEqual(event.change.owner.name, "Owner Name") 303 | self.assertEqual(event.change.owner.email, "owner@example.com") 304 | self.assertEqual(event.patchset.number, "4") 305 | self.assertEqual(event.patchset.revision, 306 | "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef") 307 | self.assertEqual(event.patchset.ref, "refs/changes/56/123456/4") 308 | self.assertEqual(event.patchset.uploader.name, "Uploader Name") 309 | self.assertEqual(event.patchset.uploader.email, "uploader@example.com") 310 | self.assertEqual(event.reviewer.name, "Reviewer Name") 311 | self.assertEqual(event.reviewer.email, "reviewer@example.com") 312 | 313 | def test_change_abandoned(self): 314 | _create_event("change-abandoned-event", self.gerrit) 315 | event = self.gerrit.get_event(False) 316 | self.assertTrue(isinstance(event, ChangeAbandonedEvent)) 317 | self.assertEqual(event.name, "change-abandoned") 318 | self.assertEqual(event.change.project, "project-name") 319 | self.assertEqual(event.change.branch, "branch-name") 320 | self.assertEqual(event.change.topic, "topic-name") 321 | self.assertEqual(event.change.change_id, 322 | "Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef") 323 | self.assertEqual(event.change.number, "123456") 324 | self.assertEqual(event.change.subject, "Commit message subject") 325 | self.assertEqual(event.change.url, "http://review.example.com/123456") 326 | self.assertEqual(event.change.owner.name, "Owner Name") 327 | self.assertEqual(event.change.owner.email, "owner@example.com") 328 | self.assertEqual(event.abandoner.name, "Abandoner Name") 329 | self.assertEqual(event.abandoner.email, "abandoner@example.com") 330 | self.assertEqual(event.reason, "Abandon reason") 331 | 332 | def test_change_restored(self): 333 | _create_event("change-restored-event", self.gerrit) 334 | event = self.gerrit.get_event(False) 335 | self.assertTrue(isinstance(event, ChangeRestoredEvent)) 336 | self.assertEqual(event.name, "change-restored") 337 | self.assertEqual(event.change.project, "project-name") 338 | self.assertEqual(event.change.branch, "branch-name") 339 | self.assertEqual(event.change.topic, "topic-name") 340 | self.assertEqual(event.change.change_id, 341 | "Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef") 342 | self.assertEqual(event.change.number, "123456") 343 | self.assertEqual(event.change.subject, "Commit message subject") 344 | self.assertEqual(event.change.url, "http://review.example.com/123456") 345 | self.assertEqual(event.change.owner.name, "Owner Name") 346 | self.assertEqual(event.change.owner.email, "owner@example.com") 347 | self.assertEqual(event.restorer.name, "Restorer Name") 348 | self.assertEqual(event.restorer.email, "restorer@example.com") 349 | self.assertEqual(event.reason, "Restore reason") 350 | 351 | def test_topic_changed(self): 352 | _create_event("topic-changed-event", self.gerrit) 353 | event = self.gerrit.get_event(False) 354 | self.assertTrue(isinstance(event, TopicChangedEvent)) 355 | self.assertEqual(event.name, "topic-changed") 356 | self.assertEqual(event.change.project, "project-name") 357 | self.assertEqual(event.change.branch, "branch-name") 358 | self.assertEqual(event.change.topic, "topic-name") 359 | self.assertEqual(event.change.change_id, 360 | "Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef") 361 | self.assertEqual(event.change.number, "123456") 362 | self.assertEqual(event.change.subject, "Commit message subject") 363 | self.assertEqual(event.change.url, "http://review.example.com/123456") 364 | self.assertEqual(event.change.owner.name, "Owner Name") 365 | self.assertEqual(event.change.owner.email, "owner@example.com") 366 | self.assertEqual(event.changer.name, "Changer Name") 367 | self.assertEqual(event.changer.email, "changer@example.com") 368 | self.assertEqual(event.oldtopic, "old-topic") 369 | 370 | def test_user_defined_event(self): 371 | _create_event("user-defined-event", self.gerrit) 372 | event = self.gerrit.get_event(False) 373 | self.assertTrue(isinstance(event, UserDefinedEvent)) 374 | self.assertEqual(event.title, "Event title") 375 | self.assertEqual(event.description, "Event description") 376 | 377 | def test_unhandled_event(self): 378 | data = _create_event("unhandled-event", self.gerrit) 379 | event = self.gerrit.get_event(False) 380 | self.assertTrue(isinstance(event, UnhandledEvent)) 381 | self.assertEqual(event.json, json.loads(data)) 382 | self.assertEqual( 383 | repr(event), " this-event-is-not-handled") 384 | 385 | def test_invalid_json(self): 386 | _create_event("invalid-json", self.gerrit) 387 | event = self.gerrit.get_event(False) 388 | self.assertTrue(isinstance(event, ErrorEvent)) 389 | 390 | def test_add_duplicate_event(self): 391 | try: 392 | @GerritEventFactory.register("user-defined-event") 393 | class AnotherUserDefinedEvent(GerritEvent): 394 | pass 395 | except: 396 | return 397 | self.fail("Did not raise exception when duplicate event registered") 398 | 399 | 400 | class TestGerritReviewMessageFormatter(unittest.TestCase): 401 | 402 | """ Test that the GerritReviewMessageFormatter class behaves properly. """ 403 | 404 | def _check_test_case_fields(self, test_case, i): 405 | for field in EXPECTED_TEST_CASE_FIELDS: 406 | self.assertTrue(field in test_case, 407 | "field '%s' not present in test case #%d" % 408 | (field, i)) 409 | self.assertTrue(isinstance(test_case['paragraphs'], list), 410 | "'paragraphs' field is not a list in test case #%d" % i) 411 | 412 | def test_is_empty(self): 413 | """ Test if message is empty for missing header and footer. """ 414 | f = GerritReviewMessageFormatter(header=None, footer=None) 415 | self.assertTrue(f.is_empty()) 416 | f.append(['test']) 417 | self.assertFalse(f.is_empty()) 418 | 419 | def test_message_formatting(self): 420 | """ Test message formatter for different test cases. """ 421 | for i in range(len(TEST_CASES)): 422 | test_case = TEST_CASES[i] 423 | self._check_test_case_fields(test_case, i) 424 | f = GerritReviewMessageFormatter(header=test_case['header'], 425 | footer=test_case['footer']) 426 | for paragraph in test_case['paragraphs']: 427 | f.append(paragraph) 428 | m = f.format() 429 | self.assertEqual(m, test_case['result'], 430 | "Formatted message does not match expected " 431 | "result in test case #%d:\n[%s]" % (i, m)) 432 | 433 | 434 | class TestGerritReview(unittest.TestCase): 435 | 436 | """ Test that the GerritReview class behaves properly. """ 437 | 438 | def test_str(self): 439 | """ Test for str function. """ 440 | obj = GerritReview() 441 | self.assertEqual(str(obj), '{}') 442 | 443 | obj2 = GerritReview(labels={'Verified': 1, 'Code-Review': -1}) 444 | self.assertEqual( 445 | str(obj2), 446 | '{"labels": {"Verified": 1, "Code-Review": -1}}') 447 | 448 | obj3 = GerritReview(comments=[{'filename': 'Makefile', 449 | 'line': 10, 'message': 'test'}]) 450 | self.assertEqual( 451 | str(obj3), 452 | '{"comments": {"Makefile": [{"line": 10, "message": "test"}]}}') 453 | 454 | obj4 = GerritReview(labels={'Verified': 1, 'Code-Review': -1}, 455 | comments=[{'filename': 'Makefile', 'line': 10, 456 | 'message': 'test'}]) 457 | self.assertEqual( 458 | str(obj4), 459 | '{"labels": {"Verified": 1, "Code-Review": -1},' 460 | ' "comments": {"Makefile": [{"line": 10, "message": "test"}]}}') 461 | 462 | obj5 = GerritReview(comments=[{'filename': 'Makefile', 'line': 15, 463 | 'message': 'test'}, {'filename': 'Make', 464 | 'line': 10, 465 | 'message': 'test1'}]) 466 | self.assertEqual( 467 | str(obj5), 468 | '{"comments": {"Make": [{"line": 10, "message": "test1"}],' 469 | ' "Makefile": [{"line": 15, "message": "test"}]}}') 470 | 471 | 472 | if __name__ == '__main__': 473 | unittest.main() 474 | --------------------------------------------------------------------------------