Initial implementation
authorAlexander Kanevskiy <alexander.kanevskiy@intel.com>
Mon, 12 Aug 2013 12:21:10 +0000 (15:21 +0300)
committerAlexander Kanevskiy <alexander.kanevskiy@intel.com>
Mon, 12 Aug 2013 12:21:10 +0000 (15:21 +0300)
gerritrest/GerritREST.py [new file with mode: 0644]
gerritrest/__init__.py [new file with mode: 0644]

diff --git a/gerritrest/GerritREST.py b/gerritrest/GerritREST.py
new file mode 100644 (file)
index 0000000..21503a5
--- /dev/null
@@ -0,0 +1,933 @@
+#!/usr/bin/env 
+# -*- coding: UTF-8 -*-
+# vim: sw=4 ts=4 expandtab ai
+#
+# Copyright (c) 2013 Intel, Inc.
+# License: GPLv2
+# Author: Alexander Kanevskiy <alexander.kanevskiy@intel.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License, version 2,
+# as published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# General Public License for more details.
+
+import urlparse
+import urllib2
+import json
+import types
+
+
+class GerritREST(object):
+    """Access Gerrit via REST interface"""
+    def __init__(self, baseurl, username, password):
+        super(GerritREST, self).__init__()
+        self.baseurl = baseurl
+        mgr = urllib2.HTTPPasswordMgrWithDefaultRealm()
+        mgr.add_password(None, baseurl, username, password)
+        self._opener_auth_handler = urllib2.HTTPDigestAuthHandler(mgr)
+        self._opener = urllib2.build_opener(self._opener_auth_handler)
+
+    def _do_http_call(self, relative_url, data=None, method='GET', headers={}):
+        """Makes HTTP request with digest authentication"""
+        request = urllib2.Request(urlparse.urljoin(self.baseurl, relative_url), data=data, headers = headers)
+        if method == "PUT":
+            request.get_method = lambda: 'PUT'
+        elif method == "DELETE":
+            request.get_method = lambda: 'DELETE'
+        elif method == "POST" and not data:
+            request.get_method = lambda: 'POST'
+
+        try:
+            fobj = self._opener.open(request)
+            content = fobj.read()
+            if content[:5] == ")]}'\n":
+                content = content[5:]
+            return (content, fobj.getcode(), fobj.info())
+        except urllib2.HTTPError, httperr:
+            self._opener_auth_handler.reset_retry_count() # Hack
+            #return (None, httperr.code, httperr.headers)
+            return (None, httperr.code, httperr)
+
+    def _do_json_call(self, relative_url, data=None, method='GET', headers={}):
+        """Makes HTTP request, and returns parsed json result"""
+        ret = self._do_http_call(relative_url, data, method, headers)
+        if ret[0]:
+            return json.loads(ret[0])
+        else:
+            return None
+
+    @staticmethod
+    def project_id(project):
+        "Returns encoded project name if it contains /"
+        return urllib2.quote(project,"") if '/' in project else project
+
+    @staticmethod
+    def change_id(changeid, project=None, branch=None):
+        "Returns constructed change_id with project / branch info"
+        if project and branch:
+            return "%s~%s~%s" % (GerritREST.project_id(project), branch, changeid)
+        else:
+            return "%s" % changeid
+
+    def get_projects(self, description=False, parents=False, prefix=None, branches=None):
+        """
+        GET /projects/
+        """
+        opts = []
+        if description:
+            opts.append("d")
+        if parents:
+            opts.append("t")
+        if prefix:
+            opts.append("p=%s" % (urllib2.quote(prefix,"") 
+                                if '/' in prefix else prefix))
+        if branches:
+            if not isinstance(branches, (types.ListType, types.TupleType)):
+                branches = [ branches ]
+            for branch in branches:
+                opts.append("b=%s" % urllib2.quote(branch,""))
+        if opts:
+            return self._do_json_call("a/projects/?%s" % "&".join(opts))
+        else:
+            return self._do_json_call("a/projects/")
+
+
+    def get_project(self, project):
+        """
+        GET /projects/{project-name}
+        """
+        if not project:
+            return None
+        return self._do_json_call("a/projects/%s" % self.project_id(project))
+
+    def create_project(self, project, parent=None, description=None, 
+        permissions_only=None, create_empty_commit=None, submit_type=None,
+        branches=None, owners=None, use_contributor_agreements=None,
+        use_signed_off_by=None, use_content_merge=None, require_change_id=None):
+        """
+        PUT /projects/{project-name}
+        name: The name of the project (not encoded).
+        parent: The name of the parent project.
+        description: The description of the project.
+        permissions_only: false if not set
+        create_empty_commit: false if not set
+        submit_type: optional (*MERGE_IF_NECESSARY, REBASE_IF_NECESSARY, 
+                        FAST_FORWARD_ONLY, MERGE_ALWAYS, CHERRY_PICK).
+        branches: A list of branches that should be initially created.
+             For the branch names the refs/heads/ prefix can be omitted.
+        owners: A list of groups that should be assigned as project owner.
+        use_contributor_agreements: Whether contributor agreements should be 
+                                used for the project (TRUE, FALSE, *INHERIT).
+        use_signed_off_by: Whether the usage of Signed-Off-By footers is 
+                            required for the project (TRUE, FALSE, *INHERIT).
+        use_content_merge: Whether content merge should be enabled for the 
+                            project (TRUE, FALSE, *INHERIT). 
+                            FALSE, if the submit_type is FAST_FORWARD_ONLY.
+        require_change_id: Whether the usage of Change-Ids is required for 
+                            the project (TRUE, FALSE, *INHERIT).
+        """
+        if not project:
+            return None
+        info = {}
+        info['name'] = project
+        if parent:
+            info['parent'] = parent
+        if description:
+            info['description'] = description
+        if permissions_only is not None:
+            info['permissions_only'] = permissions_only
+        if create_empty_commit is not None:
+            info['create_empty_commit'] = create_empty_commit
+        if submit_type:
+            info['submit_type'] = submit_type
+        if branches:
+            info['branches'] = branches
+        if owners:
+            info['owners'] = owners
+        if use_contributor_agreements:
+            info['use_contributor_agreements'] = use_contributor_agreements
+        if use_signed_off_by:
+            info['use_signed_off_by'] = use_signed_off_by
+        if use_content_merge:
+            info['use_content_merge'] = use_content_merge
+        if require_change_id:
+            info['require_change_id'] = require_change_id
+        return self._do_json_call(
+                    "a/projects/%s" % self.project_id(project),
+                    method='PUT',
+                    headers={'Content-Type': 'application/json;charset=UTF-8'}, 
+                    data=json.dumps(info))
+
+    def get_project_description(self, project):
+        """
+        GET /projects/{project-name}/description
+        """
+        if not project:
+            return None
+        return self._do_json_call("a/projects/%s/description" % self.project_id(project))
+
+    def set_project_description(self, project, description=None, commit_message=None):
+        """
+        PUT /projects/{project-name}/description
+        Content-Type: application/json;charset=UTF-8
+        {
+            "description": optional, The project description. 
+                        The project description will be deleted if not set.
+            "commit_message": optional,  Message that should be used to commit
+            the change of the project description in the project.config file to
+            the refs/meta/config branch.
+        }
+        """
+        info = {}
+        if description:
+            info['description'] = description
+        if commit_message:
+            info['commit_message'] = commit_message
+        return self._do_json_call(
+                    "a/projects/%s/description" % self.project_id(project), 
+                    method='PUT', 
+                    headers={'Content-Type': 'application/json;charset=UTF-8'}, 
+                    data=json.dumps(info))
+
+    def delete_project_description(self, project):
+        """
+        DELETE /projects/{project-name}/description
+        """
+        if not project:
+            return None
+        return self._do_json_call("a/projects/%s/description" % self.project_id(project), method='DELETE')
+
+    def get_project_parent(self, project):
+        """
+        GET /projects/{project-name}/parent
+        """
+        if not project:
+            return None
+        return self._do_json_call("a/projects/%s/parent" % self.project_id(project))
+
+    def set_project_parent(self, project, parent, commit_message=None):
+        """
+        PUT /projects/{project-name}/parent
+        Content-Type: application/json;charset=UTF-8
+
+        {
+            "parent": The name of the parent project.
+            "commit_message": optional, Message that should be used to commit 
+                the change of the project parent in the project.config file to 
+                the refs/meta/config branch.
+        }
+        """
+        info = {}
+        if parent:
+            info['parent'] = parent
+        if commit_message:
+            info['commit_message'] = commit_message
+        return self._do_json_call(
+                    "a/projects/%s/parent" % self.project_id(project), 
+                    method='PUT', 
+                    headers={'Content-Type': 'application/json;charset=UTF-8'}, 
+                    data=json.dumps(info))
+
+    def get_project_head(self, project):
+        """
+        GET /projects/{project-name}/HEAD
+        """
+        if not project:
+            return None
+        return self._do_json_call("a/projects/%s/HEAD" % self.project_id(project))
+
+    def set_project_head(self, project, head):
+        """
+        PUT /projects/{project-name}/HEAD
+        ref: The ref to which HEAD should be set, 
+            the refs/heads prefix can be omitted.
+        """
+        info = {}
+        if head:
+            info['ref'] = head
+        return self._do_json_call(
+                    "a/projects/%s/HEAD" % self.project_id(project), 
+                    method='PUT', 
+                    headers={'Content-Type': 'application/json;charset=UTF-8'}, 
+                    data=json.dumps(info))
+
+    def get_project_statistics(self, project):
+        """
+        GET /projects/{project-name}/statistics.git
+        """
+        if not project:
+            return None
+        return self._do_json_call("a/projects/%s/statistics.git" % self.project_id(project))
+
+    def run_project_gc(self, project):
+        """
+        POST /projects/{project-name}/gc
+        returned data is not JSON!
+        """
+        if not project:
+            return None
+        return self._do_http_call("a/projects/%s/gc" % self.project_id(project), method='POST')[0]
+
+    def get_groups(self, members=False, includes=False, project=None, user=None):
+        """
+        GET /groups/
+        TODO: q parameter
+        """
+        opts = []
+        if members:
+            opts.append("o=MEMBERS")
+        if includes:
+            opts.append("o=INCLUDES")
+        if project:
+            opts.append("p=%s" % self.project_id(project))
+        if user:
+            opts.append("u=%s" % user)
+        if opts:
+            return self._do_json_call("a/groups/?%s" % "&".join(opts))
+        else:
+            return self._do_json_call("a/groups/")
+
+    def get_group(self, group):
+        """
+        GET /groups/{group-id}
+        """
+        if not group:
+            return None
+        return self._do_json_call("a/groups/%s" % group)
+
+
+    def create_group(self, group, description=None, visible_to_all=None, owner=None):
+        """
+        PUT /groups/{group-name}
+        all parameters optional
+        Content-Type: application/json;charset=UTF-8
+
+        {
+            "description": "contains all committers for MyProject",
+            "visible_to_all": true,
+            "owner_id": "7ca042f4d5847936fcb90ca91057673157fd06fc"
+        }        
+        """
+        if not group:
+            return None
+        info = {}
+        if description:
+            info['description'] = description
+        if visible_to_all is not None:
+            info['visible_to_all'] = visible_to_all
+        if owner:
+            info['owner_id'] = owner
+        return self._do_json_call(
+                    "a/groups/%s" % group,
+                    method='PUT', 
+                    headers={'Content-Type': 'application/json;charset=UTF-8'}, 
+                    data=json.dumps(info))
+
+    def get_group_detail(self, group):
+        """
+        GET /groups/{group-id}/detail
+        """
+        if not group:
+            return None
+        return self._do_json_call("a/groups/%s/detail" % group)
+
+
+    def get_group_name(self, group):
+        """
+        GET /groups/{group-id}/name
+        """
+        if not group:
+            return None
+        return self._do_json_call("a/groups/%s/name" % group)
+
+    def set_group_name(self, group, new_name):
+        """
+        PUT /groups/{group-id}/name
+        Content-Type: application/json;charset=UTF-8
+
+        {
+            "name": "My-Project-Committers"
+        }
+        """
+        if not group or not new_name:
+            return None
+        info = {'name': new_name}
+        return self._do_json_call(
+                    "a/groups/%s" % group,
+                    method='PUT', 
+                    headers={'Content-Type': 'application/json;charset=UTF-8'}, 
+                    data=json.dumps(info))
+
+    def get_group_description(self, group):
+        """
+        GET /groups/{group-id}/description
+        """
+        if not group:
+            return None
+        return self._do_json_call("a/groups/%s/description" % group)
+
+    def set_group_description(self, group, new_description):
+        """
+        PUT /groups/{group-id}/description
+        Content-Type: application/json;charset=UTF-8
+
+        {
+            "description": "The committers of MyProject."
+        }
+        """
+        if not group:
+            return None
+        info = {'description': new_description}
+        return self._do_json_call(
+                    "a/groups/%s/description" % group,
+                    method='PUT', 
+                    headers={'Content-Type': 'application/json;charset=UTF-8'}, 
+                    data=json.dumps(info))
+
+    def delete_group_description(self, group):
+        """
+        DELETE /groups/{group-id}/description
+        """
+        if not group:
+            return None
+        return self._do_json_call("a/groups/%s/description" % group, method='DELETE')
+
+    def get_group_options(self, group):
+        """
+        GET /groups/{group-id}/options
+        """
+        if not group:
+            return None
+        return self._do_json_call("a/groups/%s/options" % group)
+
+    def set_group_options(self, group, visible_to_all=None):
+        """
+        PUT /groups/{group-id}/options
+        Content-Type: application/json;charset=UTF-8
+
+        {
+            "visible_to_all": true
+        }
+        """
+        if not group or visible_to_all is None:
+            return None
+        info = {'visible_to_all': visible_to_all}
+        return self._do_json_call(
+                    "a/groups/%s/options" % group,
+                    method='PUT', 
+                    headers={'Content-Type': 'application/json;charset=UTF-8'}, 
+                    data=json.dumps(info))
+
+    def get_group_owner(self, group):
+        """
+        GET /groups/{group-id}/owner
+        """
+        if not group:
+            return None
+        return self._do_json_call("a/groups/%s/owner" % group)
+
+    def set_group_owner(self, group, new_owner):
+        """
+        PUT /groups/{group-id}/owner
+
+        Content-Type: application/json;charset=UTF-8
+
+        {
+            "owner": "6a1e70e1a88782771a91808c8af9bbb7a9871389"
+        }
+        """
+        if not group:
+            return None
+        info = {'owner': new_owner}
+        return self._do_json_call(
+                    "a/groups/%s/owner" % group,
+                    method='PUT',
+                    headers={'Content-Type': 'application/json;charset=UTF-8'}, 
+                    data=json.dumps(info))
+
+    def get_group_member(self, group, member):
+        """
+        GET /groups/{group-id}/members/{account-id}
+        """
+        if not group or not member:
+            return None
+        return self._do_json_call("a/groups/%s/member/%s" % (group, member))
+
+    def add_group_member(self, group, member):
+        """
+        PUT /groups/{group-id}/members/{account-id}
+        """
+        if not group or not member:
+            return None
+        return self._do_json_call("a/groups/%s/members/%s" % (group, member), method='PUT', data="")
+
+    def delete_group_member(self, group, member):
+        """
+        DELETE /groups/{group-id}/members/{account-id}
+        """
+        if not group or not member:
+            return None
+        return self._do_json_call("a/groups/%s/members/%s" % (group, member), method='DELETE')
+
+    def get_group_members(self, group, recursive=None):
+        """
+        GET /groups/{group-id}/members/
+        GET /groups/{group-id}/members/?recursive
+        """
+        if not group:
+            return None
+        options = "?recursive" if recursive else ""
+        return self._do_json_call("a/groups/%s/members/%s" % (group, options))
+
+    def add_group_members(self, group, members):
+        """
+        POST /groups/{group-id}/members.add
+        or
+        POST /groups/{group-id}/members
+        members: A list of account ids that identify the accounts that should 
+                be added or deleted.
+        TODO:
+        _one_member: The id of one account that should be added or deleted.
+          Content-Type: application/json;charset=UTF-8
+
+          {
+            "members": {
+              "jane.roe@example.com",
+              "john.doe@example.com"
+            }
+          }
+        """
+        if not group or not members:
+            return None
+        info = {'members': members}
+        return self._do_json_call(
+                    "a/groups/%s/members.add" % group,
+                    method='POST', 
+                    headers={'Content-Type': 'application/json;charset=UTF-8'}, 
+                    data=json.dumps(info))
+
+    def delete_group_members(self, group, members):
+        """
+        POST /groups/{group-id}/members.delete
+        members: A list of account ids that identify the accounts that 
+                should be added or deleted.
+        TODO:
+        _one_member: The id of one account that should be added or deleted.
+          Content-Type: application/json;charset=UTF-8
+
+          {
+            "members": {
+              "jane.roe@example.com",
+              "john.doe@example.com"
+            }
+          }
+        """
+        if not group or not members:
+            return None
+        info = {'members': members}
+        return self._do_json_call(
+                    "a/groups/%s/members.delete" % group,
+                    method='POST', 
+                    headers={'Content-Type': 'application/json;charset=UTF-8'}, 
+                    data=json.dumps(info))
+
+    def get_group_group(self, group, included_group):
+        """
+        GET /groups/{group-id}/groups/{group-id}
+        """
+        if not group or not included_group:
+            return None
+        return self._do_json_call("a/groups/%s/groups/%s" % (group, included_group))
+
+    def add_group_group(self, group, included_group):
+        """
+        PUT /groups/{group-id}/groups/{group-id}
+        """
+        if not group or not included_group:
+            return None
+        return self._do_json_call("a/groups/%s/groups/%s" % (group, included_group), method='PUT', data="")
+
+    def delete_group_group(self, group, included_group):
+        """
+        DELETE /groups/{group-id}/groups/{group-id}
+        """
+        if not group or not included_group:
+            return None
+        return self._do_json_call("a/groups/%s/groups/%s" % (group, included_group), method='DELETE')
+
+    def get_group_groups(self, group):
+        """
+        GET /groups/{group-id}/groups/
+        """
+        if not group:
+            return None
+        return self._do_json_call("a/groups/%s/groups" % group)
+
+    def add_group_groups(self, group, included_groups):
+        """
+        POST /groups/{group-id}/groups
+        OR
+        POST /groups/{group-id}/groups.add
+        groups: A list of group ids that identify the groups that should be 
+                included or deleted.
+        TODO:
+        _one_group: The id of one group that should be included or deleted.
+          Content-Type: application/json;charset=UTF-8
+
+          {
+            "groups": {
+              "MyGroup",
+              "MyOtherGroup"
+            }
+          }
+
+        """
+        if not group or not included_groups:
+            return None
+        info = {'groups': included_groups}
+        return self._do_json_call(
+                    "a/groups/%s/groups.add" % group,
+                    method='POST', 
+                    headers={'Content-Type': 'application/json;charset=UTF-8'}, 
+                    data=json.dumps(info))
+
+
+    def delete_group_groups(self, group, included_groups):
+        """
+        POST /groups/{group-id}/groups.delete
+        groups: A list of group ids that identify the groups that should be 
+                included or deleted.
+        TODO:
+        _one_group The id of one group that should be included or deleted.
+         Content-Type: application/json;charset=UTF-8
+
+          {
+            "members": {
+              "MyGroup",
+              "MyOtherGroup"
+            }
+          }
+        """
+        if not group or not included_groups:
+            return None
+        info = {'groups': included_groups}
+        return self._do_json_call(
+                    "a/groups/%s/groups.delete" % group,
+                    method='POST', 
+                    headers={'Content-Type': 'application/json;charset=UTF-8'}, 
+                    data=json.dumps(info))
+
+    def get_changes(self, query=None, n=None, labels=False, 
+                    detailed_labels=False, current_revision=False, 
+                    all_revisions=False, current_commit=False,
+                    all_commits=False, current_files=False, all_files=False,
+                    detailed_accounts=False):
+        """
+        GET /changes/
+        q = query
+        n = number (int)
+        """
+        opts = []
+        if query:
+            opts.append("q=%s" % query)
+        if n:
+            opts.append("n=%d" % n)
+        if all_files:
+            opts.append("o=ALL_FILES")
+            current_revision = True
+        if current_commit:
+            opts.append("o=CURRENT_COMMIT")
+            if not current_revision or not all_revisions:
+                current_revision = True
+        if current_files:
+            opts.append("o=CURRENT_FILES")
+            if not current_revision or not all_revisions:
+                current_revision = True
+        if labels:
+            opts.append("o=LABELS")
+        if detailed_labels:
+            opts.append("o=DETAILED_LABELS")
+        if detailed_accounts:
+            opts.append("o=DETAILED_ACCOUNTS")
+        if current_revision:
+            opts.append("o=CURRENT_REVISION")
+        if all_revisions:
+            opts.append("o=ALL_REVISIONS")
+        if all_commits:
+            opts.append("o=ALL_COMMITS")
+        if opts:
+            return self._do_json_call("a/changes/?%s" % "&".join(opts))
+        else:
+            return self._do_json_call("a/changes/")
+
+    def get_change(self, change, project=None, branch=None):
+        """
+        GET /changes/{change-id}
+        """
+        if not change:
+            return None
+        return self._do_json_call("a/changes/%s" % self.change_id(change, project, branch))
+
+    def get_change_detail(self, change):
+        """
+        GET /changes/{change-id}/detail
+        """
+        if not change:
+            return None
+        return self._do_json_call("a/changes/%s/detail" % change)
+
+    def get_change_topic(self, change):
+        """
+        GET /changes/{change-id}/topic
+        """
+        if not change:
+            return None
+        return self._do_json_call("a/changes/%s/topic" % change)
+
+    def set_change_topic(self, change, topic=None, message=None):
+        """
+        PUT /changes/{change-id}/topic
+        topic: The topic. The topic will be deleted if not set.
+        message: Message to be added as review comment to the change when 
+                setting the topic.
+        """
+        if not change:
+            return None
+        info = {}
+        if topic:
+            info['topic'] = topic
+        if message:
+            info['message'] = message
+        return self._do_json_call(
+                        "a/changes/%s/topic" % change,
+                        method='PUT', 
+                        headers={'Content-Type': 'application/json;charset=UTF-8'}, 
+                        data=json.dumps(info))
+
+    def delete_change_topic(self, change):
+        """
+        DELETE /changes/{change-id}/topic
+        """
+        if not change:
+            return None
+        return self._do_json_call("a/changes/%s/topic" % change, method='DELETE')
+
+    def abandon_change(self, change, message=None):
+        """
+        POST /changes/{change-id}/abandon
+        message: Message to be added as review comment to the change when 
+                abandoning the change.
+        TODO: error handling (409 + message in body)
+        """
+        if not change:
+            return None
+        info = {}
+        if message:
+            info['message'] = message
+        return self._do_json_call(
+                    "a/changes/%s/abandon" % change,
+                    method='POST', 
+                    headers={'Content-Type': 'application/json;charset=UTF-8'}, 
+                    data=json.dumps(info))
+
+    def restore_change(self, change, message=None):
+        """
+        POST /changes/{change-id}/restore
+        message: Message to be added as review comment to the change when 
+                restoring the change.
+        TODO: error handling (409 + message in body)
+        """
+        if not change:
+            return None
+        info = {}
+        if message:
+            info['message'] = message
+        return self._do_json_call(
+                    "a/changes/%s/restore" % change,
+                    method='POST', 
+                    headers={'Content-Type': 'application/json;charset=UTF-8'}, 
+                    data=json.dumps(info))
+
+    def revert_change(self, change, message=None):
+        """
+        POST /changes/{change-id}/revert
+        message: Message to be added as review comment to the change when 
+                reverting the change.
+        TODO: error handling (409 + message in body)
+        """
+        if not change:
+            return None
+        info = {}
+        if message:
+            info['message'] = message
+        return self._do_json_call(
+                    "a/changes/%s/revert" % change,
+                    method='POST', 
+                    headers={'Content-Type': 'application/json;charset=UTF-8'}, 
+                    data=json.dumps(info))
+
+    def submit_change(self, change, wait_for_merge=None):
+        """
+        POST /changes/{change-id}/submit
+        wait_for_merge: false if not set. Whether the request should wait for 
+                        the merge to complete. 
+        If false the request returns immediately after the change has been added
+        to the merge queue and the caller can’t know whether the change could 
+        be merged successfully.
+        TODO: error handling (409 + message in body)
+        """
+        if not change:
+            return None
+        info = {}
+        if wait_for_merge is not None:
+            info['wait_for_merge'] = wait_for_merge
+        return self._do_json_call(
+                    "a/changes/%s/submit" % change,
+                    method='POST', 
+                    headers={'Content-Type': 'application/json;charset=UTF-8'}, 
+                    data=json.dumps(info))
+
+    def get_change_reviewers(self, change):
+        """
+        GET /changes/{change-id}/reviewers/
+        """
+        if not change:
+            return None
+        return self._do_json_call("a/changes/%s/reviewers/" % change)
+
+    def get_change_reviewer(self, change, reviewer):
+        """
+        GET /changes/{change-id}/reviewers/{account-id}
+        """
+        if not change:
+            return None
+        return self._do_json_call("a/changes/%s/reviewers/%s" % (change, reviewer))
+
+    def add_change_reviewer(self, change, reviewer, confirmed=None):
+        """
+        POST /changes/{change-id}/reviewers
+        reviewer: The ID of one account that should be added as reviewer or 
+        the ID of one group for which all members should be added as reviewers.
+        If an ID identifies both an account and a group, only the account is 
+        added as reviewer to the change.
+        confirmed: Whether adding the reviewer is confirmed. The Gerrit server 
+            may be configured to require a confirmation when adding a group as 
+            reviewer that has many members.
+        """
+        if not change or not reviewer:
+            return None
+        info = {}
+        if reviewer:
+            info['reviewer'] = reviewer
+        if confirmed is not None:
+            info['confirmed'] = confirmed
+        return self._do_json_call(
+                    "a/changes/%s/reviewers" % change,
+                    method='POST', 
+                    headers={'Content-Type': 'application/json;charset=UTF-8'}, 
+                    data=json.dumps(info))
+
+    def delete_change_reviewer(self, change, reviewer):
+        """
+        DELETE /changes/{change-id}/reviewers/{account-id}
+        """
+        if not change or not reviewer:
+            return None
+        return self._do_json_call("a/changes/%s/reviewers/%s" % (change, reviewer), method='DELETE')
+
+    def get_change_review(self, change, revision):
+        """
+        GET /changes/{change-id}/revisions/{revision-id}/review
+        """
+        if not change or not revision:
+            return None
+        return self._do_json_call("a/changes/%s/revisions/%s/review" % (change, revision))
+
+    def set_change_review(self, change, revision):
+        """
+        POST /changes/{change-id}/revisions/{revision-id}/review
+        message: optional The message to be added as review comment.
+        labels: optional The votes that should be added to the revision as a map that maps the label names to the voting values.
+        comments: optional The comments that should be added as a map that maps a file path to a list of CommentInput entities.
+        strict_labels: true if not set Whether all labels are required to be within the user’s permitted ranges based on access controls.
+                                    If true, attempting to use a label not granted to the user will fail the entire modify operation early.
+                                    If false, the operation will execute anyway, but the proposed labels will be modified to be the "best" value allowed by the access controls.
+        drafts: optional Draft handling that defines how draft comments are handled that are already in the database but that were not also described in this input. Allowed values are DELETE, PUBLISH and KEEP. If not set, the default is DELETE.
+        notify: optional Notify handling that defines to whom email notifications should be sent after the review is stored. Allowed values are NONE, OWNER, OWNER_REVIEWERS and ALL. If not set, the default is ALL.
+        """
+        raise NotImplementedError
+
+    def submit_change_revision(self, change, revision, wait_for_merge=None):
+        """
+        POST /changes/{change-id}/revisions/{revision-id}/submit
+        wait_for_merge: false if not set. Whether the request should wait for 
+                    the merge to complete. 
+                    If false the request returns immediately after the change 
+                    has been added to the merge queue and the caller can’t know 
+                    whether the change could be merged successfully.
+        TODO: error handling (409 + message in body)
+        """
+        if not change or not revision:
+            return None
+        info = {}
+        if wait_for_merge is not None:
+            info['wait_for_merge'] = wait_for_merge
+        return self._do_json_call(
+                    "a/changes/%s/revisions/%s/submit" % (change, revision),
+                    method='POST', 
+                    headers={'Content-Type': 'application/json;charset=UTF-8'}, 
+                    data=json.dumps(info))
+
+    def get_change_revision_submit_type(self, change, revision):
+        """
+        GET /changes/{change-id}/revisions/{revision-id}/submit_type
+        """
+        if not change or not revision:
+            return None
+        return self._do_json_call("a/changes/%s/revisions/%s/submit_type" % (change, revision))
+
+
+    def get_account(self, account="self"):
+        """
+        GET /accounts/{account-id}
+        """
+        return self._do_json_call("a/accounts/%s" % urllib2.quote(account))
+
+    def get_account_capabilities(self, account="self"):
+        """
+        GET /accounts/{account-id}/capabilities
+        """
+        return self._do_json_call("a/accounts/%s/capabilities" % urllib2.quote(account))
+
+    def get_account_capability(self, account="self", capability=None):
+        """
+        GET /accounts/{account-id}/capabilities/{capability-id}
+        returns text "ok" / 404 "not found raised"
+        """
+        if not capability:
+            return None
+        ret = self._do_http_call("a/accounts/%s/capabilities/%s" % 
+                                    (urllib2.quote(account), capability))
+        if ret[0] and ret[0] == "ok\n":
+            return True
+        if ret[1] == 404:
+            return False
+        return None
+
+    def get_account_groups(self, account="self"):
+        """
+        GET /accounts/{account-id}/groups/
+        """
+        return self._do_json_call("a/accounts/%s/groups" % urllib2.quote(account))
+
+    def get_account_avatar(self, account="self", size=None):
+        """
+        GET /accounts/{account-id}/avatar
+        HTTP/1.1 302 Found
+        The response redirects to the URL of the avatar image.
+        """
+        raise NotImplementedError
diff --git a/gerritrest/__init__.py b/gerritrest/__init__.py
new file mode 100644 (file)
index 0000000..0340d84
--- /dev/null
@@ -0,0 +1,7 @@
+#!/usr/bin/env 
+# -*- coding: UTF-8 -*-
+# vim: sw=4 ts=4 expandtab ai
+
+__all__ = [ 'GerritREST' ]
+
+from GerritREST import GerritREST