Fix pylint whitespace issues
[services/gerritrest.git] / gerritrest / GerritREST.py
1 #!/usr/bin/env python
2 # -*- coding: UTF-8 -*-
3 # vim: sw=4 ts=4 expandtab ai
4 #
5 # Copyright (c) 2013-2014 Intel, Inc.
6 # License: GPLv2
7 # Author: Alexander Kanevskiy <alexander.kanevskiy@intel.com>
8 #
9 # This program is free software; you can redistribute it and/or modify
10 # it under the terms of the GNU General Public License, version 2,
11 # as published by the Free Software Foundation.
12 #
13 # This program is distributed in the hope that it will be useful, but
14 # WITHOUT ANY WARRANTY; without even the implied warranty of
15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
16 # General Public License for more details.
17
18 import urlparse
19 import urllib2
20 import json
21 import types
22
23
24 class GerritREST(object):
25     """Access Gerrit via REST interface"""
26     def __init__(self, baseurl, username, password):
27         super(GerritREST, self).__init__()
28         self.baseurl = baseurl
29         if not self.baseurl.endswith("/"):
30             self.baseurl += "/"
31         mgr = urllib2.HTTPPasswordMgrWithDefaultRealm()
32         mgr.add_password(None, baseurl, username, password)
33         self._opener_auth_handler = urllib2.HTTPDigestAuthHandler(mgr)
34         self._opener = urllib2.build_opener(self._opener_auth_handler)
35
36     def _do_http_call(self, relative_url, data=None, method='GET', headers={}):
37         """Makes HTTP request with digest authentication"""
38         request = urllib2.Request(urlparse.urljoin(self.baseurl, relative_url), data=data, headers=headers)
39         if method == "PUT":
40             request.get_method = lambda: 'PUT'
41         elif method == "DELETE":
42             request.get_method = lambda: 'DELETE'
43         elif method == "POST" and not data:
44             request.get_method = lambda: 'POST'
45
46         try:
47             fobj = self._opener.open(request)
48             content = fobj.read()
49             if content[:5] == ")]}'\n":
50                 content = content[5:]
51             return (content, fobj.getcode(), fobj.info())
52         except urllib2.HTTPError, httperr:
53             self._opener_auth_handler.reset_retry_count() # Hack
54             #return (None, httperr.code, httperr.headers)
55             return (None, httperr.code, httperr)
56
57     def _do_json_call(self, relative_url, data=None, method='GET', headers={}):
58         """Makes HTTP request, and returns parsed json result"""
59         ret = self._do_http_call(relative_url, data, method, headers)
60         if ret[0]:
61             return json.loads(ret[0])
62         else:
63             return None
64
65     @staticmethod
66     def quote_id(oid):
67         "Returns encoded project or group name if it contains /"
68         return urllib2.quote(oid, "") if '/' in oid else oid
69
70     @staticmethod
71     def change_id(changeid, project=None, branch=None):
72         "Returns constructed change_id with project / branch info"
73         if project and branch:
74             return "%s~%s~%s" % (GerritREST.quote_id(project), branch, changeid)
75         else:
76             return "%s" % changeid
77
78     def get_projects(self, description=False, parents=False, prefix=None, branches=None):
79         """
80         GET /projects/
81         """
82         opts = []
83         if description:
84             opts.append("d")
85         if parents:
86             opts.append("t")
87         if prefix:
88             opts.append("p=%s" % (urllib2.quote(prefix, "") \
89                 if '/' in prefix else prefix))
90         if branches:
91             if not isinstance(branches, (types.ListType, types.TupleType)):
92                 branches = [branches]
93             for branch in branches:
94                 opts.append("b=%s" % urllib2.quote(branch, ""))
95         if opts:
96             return self._do_json_call("a/projects/?%s" % "&".join(opts))
97         else:
98             return self._do_json_call("a/projects/")
99
100
101     def get_project(self, project):
102         """
103         GET /projects/{project-name}
104         """
105         if not project:
106             return None
107         return self._do_json_call("a/projects/%s" % self.quote_id(project))
108
109     def create_project(self, project, parent=None, description=None, \
110         permissions_only=None, create_empty_commit=None, submit_type=None, \
111         branches=None, owners=None, use_contributor_agreements=None, \
112         use_signed_off_by=None, use_content_merge=None, require_change_id=None):
113         """
114         PUT /projects/{project-name}
115         name: The name of the project (not encoded).
116         parent: The name of the parent project.
117         description: The description of the project.
118         permissions_only: false if not set
119         create_empty_commit: false if not set
120         submit_type: optional (*MERGE_IF_NECESSARY, REBASE_IF_NECESSARY,
121                         FAST_FORWARD_ONLY, MERGE_ALWAYS, CHERRY_PICK).
122         branches: A list of branches that should be initially created.
123              For the branch names the refs/heads/ prefix can be omitted.
124         owners: A list of groups that should be assigned as project owner.
125         use_contributor_agreements: Whether contributor agreements should be
126                                 used for the project (TRUE, FALSE, *INHERIT).
127         use_signed_off_by: Whether the usage of Signed-Off-By footers is
128                             required for the project (TRUE, FALSE, *INHERIT).
129         use_content_merge: Whether content merge should be enabled for the
130                             project (TRUE, FALSE, *INHERIT).
131                             FALSE, if the submit_type is FAST_FORWARD_ONLY.
132         require_change_id: Whether the usage of Change-Ids is required for
133                             the project (TRUE, FALSE, *INHERIT).
134         """
135         if not project:
136             return None
137         info = {}
138         info['name'] = project
139         if parent:
140             info['parent'] = parent
141         if description:
142             info['description'] = description
143         if permissions_only is not None:
144             info['permissions_only'] = permissions_only
145         if create_empty_commit is not None:
146             info['create_empty_commit'] = create_empty_commit
147         if submit_type:
148             info['submit_type'] = submit_type
149         if branches:
150             info['branches'] = branches
151         if owners:
152             info['owners'] = owners
153         if use_contributor_agreements:
154             info['use_contributor_agreements'] = use_contributor_agreements
155         if use_signed_off_by:
156             info['use_signed_off_by'] = use_signed_off_by
157         if use_content_merge:
158             info['use_content_merge'] = use_content_merge
159         if require_change_id:
160             info['require_change_id'] = require_change_id
161         return self._do_json_call( \
162                 "a/projects/%s" % self.quote_id(project), \
163                 method='PUT', \
164                 headers={'Content-Type': 'application/json;charset=UTF-8'}, \
165                 data=json.dumps(info))
166
167     def get_project_description(self, project):
168         """
169         GET /projects/{project-name}/description
170         """
171         if not project:
172             return None
173         return self._do_json_call("a/projects/%s/description" % self.quote_id(project))
174
175     def set_project_description(self, project, description=None, commit_message=None):
176         """
177         PUT /projects/{project-name}/description
178         Content-Type: application/json;charset=UTF-8
179         {
180             "description": optional, The project description.
181                         The project description will be deleted if not set.
182             "commit_message": optional,  Message that should be used to commit
183             the change of the project description in the project.config file to
184             the refs/meta/config branch.
185         }
186         """
187         info = {}
188         if description:
189             info['description'] = description
190         if commit_message:
191             info['commit_message'] = commit_message
192         return self._do_json_call( \
193                 "a/projects/%s/description" % self.quote_id(project), \
194                 method='PUT', \
195                 headers={'Content-Type': 'application/json;charset=UTF-8'}, \
196                 data=json.dumps(info))
197
198     def delete_project_description(self, project):
199         """
200         DELETE /projects/{project-name}/description
201         """
202         if not project:
203             return None
204         return self._do_json_call("a/projects/%s/description" % \
205                                     self.quote_id(project), method='DELETE')
206
207     def get_project_parent(self, project):
208         """
209         GET /projects/{project-name}/parent
210         """
211         if not project:
212             return None
213         return self._do_json_call("a/projects/%s/parent" % self.quote_id(project))
214
215     def set_project_parent(self, project, parent, commit_message=None):
216         """
217         PUT /projects/{project-name}/parent
218         Content-Type: application/json;charset=UTF-8
219
220         {
221             "parent": The name of the parent project.
222             "commit_message": optional, Message that should be used to commit
223                 the change of the project parent in the project.config file to
224                 the refs/meta/config branch.
225         }
226         """
227         info = {}
228         if parent:
229             info['parent'] = parent
230         if commit_message:
231             info['commit_message'] = commit_message
232         return self._do_json_call( \
233                 "a/projects/%s/parent" % self.quote_id(project), \
234                 method='PUT', \
235                 headers={'Content-Type': 'application/json;charset=UTF-8'}, \
236                 data=json.dumps(info))
237
238     def get_project_head(self, project):
239         """
240         GET /projects/{project-name}/HEAD
241         """
242         if not project:
243             return None
244         return self._do_json_call("a/projects/%s/HEAD" % self.quote_id(project))
245
246     def set_project_head(self, project, head):
247         """
248         PUT /projects/{project-name}/HEAD
249         ref: The ref to which HEAD should be set,
250             the refs/heads prefix can be omitted.
251         """
252         info = {}
253         if head:
254             info['ref'] = head
255         return self._do_json_call( \
256                 "a/projects/%s/HEAD" % self.quote_id(project), \
257                 method='PUT', \
258                 headers={'Content-Type': 'application/json;charset=UTF-8'}, \
259                 data=json.dumps(info))
260
261     def get_project_statistics(self, project):
262         """
263         GET /projects/{project-name}/statistics.git
264         """
265         if not project:
266             return None
267         return self._do_json_call("a/projects/%s/statistics.git" % \
268                                     self.quote_id(project))
269
270     def run_project_gc(self, project):
271         """
272         POST /projects/{project-name}/gc
273         returned data is not JSON!
274         """
275         if not project:
276             return None
277         return self._do_http_call("a/projects/%s/gc" % \
278                                     self.quote_id(project), method='POST')[0]
279
280     def get_groups(self, members=False, includes=False, project=None, user=None):
281         """
282         GET /groups/
283         TODO: q parameter
284         """
285         opts = []
286         if members:
287             opts.append("o=MEMBERS")
288         if includes:
289             opts.append("o=INCLUDES")
290         if project:
291             opts.append("p=%s" % self.quote_id(project))
292         if user:
293             opts.append("u=%s" % user)
294         if opts:
295             return self._do_json_call("a/groups/?%s" % "&".join(opts))
296         else:
297             return self._do_json_call("a/groups/")
298
299     def get_group(self, group):
300         """
301         GET /groups/{group-id}
302         """
303         if not group:
304             return None
305         return self._do_json_call("a/groups/%s" % group)
306
307
308     def create_group(self, group, description=None, visible_to_all=None, owner=None):
309         """
310         PUT /groups/{group-name}
311         all parameters optional
312         Content-Type: application/json;charset=UTF-8
313
314         {
315             "description": "contains all committers for MyProject",
316             "visible_to_all": true,
317             "owner_id": "7ca042f4d5847936fcb90ca91057673157fd06fc"
318         }
319         """
320         if not group:
321             return None
322         info = {}
323         if description:
324             info['description'] = description
325         if visible_to_all is not None:
326             info['visible_to_all'] = visible_to_all
327         if owner:
328             info['owner_id'] = owner
329         return self._do_json_call( \
330                 "a/groups/%s" % self.quote_id(group), \
331                 method='PUT', \
332                 headers={'Content-Type': 'application/json;charset=UTF-8'}, \
333                 data=json.dumps(info))
334
335     def get_group_detail(self, group):
336         """
337         GET /groups/{group-id}/detail
338         """
339         if not group:
340             return None
341         return self._do_json_call("a/groups/%s/detail" % self.quote_id(group))
342
343
344     def get_group_name(self, group):
345         """
346         GET /groups/{group-id}/name
347         """
348         if not group:
349             return None
350         return self._do_json_call("a/groups/%s/name" % self.quote_id(group))
351
352     def set_group_name(self, group, new_name):
353         """
354         PUT /groups/{group-id}/name
355         Content-Type: application/json;charset=UTF-8
356
357         {
358             "name": "My-Project-Committers"
359         }
360         """
361         if not group or not new_name:
362             return None
363         info = {'name': new_name}
364         return self._do_json_call( \
365                 "a/groups/%s" % self.quote_id(group), \
366                 method='PUT', \
367                 headers={'Content-Type': 'application/json;charset=UTF-8'}, \
368                 data=json.dumps(info))
369
370     def get_group_description(self, group):
371         """
372         GET /groups/{group-id}/description
373         """
374         if not group:
375             return None
376         return self._do_json_call("a/groups/%s/description" % \
377                                     self.quote_id(group))
378
379     def set_group_description(self, group, new_description):
380         """
381         PUT /groups/{group-id}/description
382         Content-Type: application/json;charset=UTF-8
383
384         {
385             "description": "The committers of MyProject."
386         }
387         """
388         if not group:
389             return None
390         info = {'description': new_description}
391         return self._do_json_call( \
392                 "a/groups/%s/description" % self.quote_id(group), \
393                 method='PUT', \
394                 headers={'Content-Type': 'application/json;charset=UTF-8'}, \
395                 data=json.dumps(info))
396
397     def delete_group_description(self, group):
398         """
399         DELETE /groups/{group-id}/description
400         """
401         if not group:
402             return None
403         return self._do_json_call("a/groups/%s/description" % \
404                                     self.quote_id(group), method='DELETE')
405
406     def get_group_options(self, group):
407         """
408         GET /groups/{group-id}/options
409         """
410         if not group:
411             return None
412         return self._do_json_call("a/groups/%s/options" % self.quote_id(group))
413
414     def set_group_options(self, group, visible_to_all=None):
415         """
416         PUT /groups/{group-id}/options
417         Content-Type: application/json;charset=UTF-8
418
419         {
420             "visible_to_all": true
421         }
422         """
423         if not group or visible_to_all is None:
424             return None
425         info = {'visible_to_all': visible_to_all}
426         return self._do_json_call( \
427                 "a/groups/%s/options" % self.quote_id(group), \
428                 method='PUT', \
429                 headers={'Content-Type': 'application/json;charset=UTF-8'}, \
430                 data=json.dumps(info))
431
432     def get_group_owner(self, group):
433         """
434         GET /groups/{group-id}/owner
435         """
436         if not group:
437             return None
438         return self._do_json_call("a/groups/%s/owner" % self.quote_id(group))
439
440     def set_group_owner(self, group, new_owner):
441         """
442         PUT /groups/{group-id}/owner
443
444         Content-Type: application/json;charset=UTF-8
445
446         {
447             "owner": "6a1e70e1a88782771a91808c8af9bbb7a9871389"
448         }
449         """
450         if not group:
451             return None
452         info = {'owner': new_owner}
453         return self._do_json_call( \
454                 "a/groups/%s/owner" % self.quote_id(group), \
455                 method='PUT', \
456                 headers={'Content-Type': 'application/json;charset=UTF-8'}, \
457                 data=json.dumps(info))
458
459     def get_group_member(self, group, member):
460         """
461         GET /groups/{group-id}/members/{account-id}
462         """
463         if not group or not member:
464             return None
465         return self._do_json_call("a/groups/%s/member/%s" % \
466                                     (self.quote_id(group), member))
467
468     def add_group_member(self, group, member):
469         """
470         PUT /groups/{group-id}/members/{account-id}
471         """
472         if not group or not member:
473             return None
474         return self._do_json_call("a/groups/%s/members/%s" % \
475                         (self.quote_id(group), member), method='PUT', data="")
476
477     def delete_group_member(self, group, member):
478         """
479         DELETE /groups/{group-id}/members/{account-id}
480         """
481         if not group or not member:
482             return None
483         return self._do_json_call("a/groups/%s/members/%s" % \
484                             (self.quote_id(group), member), method='DELETE')
485
486     def get_group_members(self, group, recursive=None):
487         """
488         GET /groups/{group-id}/members/
489         GET /groups/{group-id}/members/?recursive
490         """
491         if not group:
492             return None
493         options = "?recursive" if recursive else ""
494         return self._do_json_call("a/groups/%s/members/%s" % \
495                                     (self.quote_id(group), options))
496
497     def add_group_members(self, group, members):
498         """
499         POST /groups/{group-id}/members.add
500         or
501         POST /groups/{group-id}/members
502         members: A list of account ids that identify the accounts that should
503                 be added or deleted.
504         TODO:
505         _one_member: The id of one account that should be added or deleted.
506           Content-Type: application/json;charset=UTF-8
507
508           {
509             "members": {
510               "jane.roe@example.com",
511               "john.doe@example.com"
512             }
513           }
514         """
515         if not group or not members:
516             return None
517         info = {'members': members}
518         return self._do_json_call( \
519                 "a/groups/%s/members.add" % self.quote_id(group), \
520                 method='POST', \
521                 headers={'Content-Type': 'application/json;charset=UTF-8'}, \
522                 data=json.dumps(info))
523
524     def delete_group_members(self, group, members):
525         """
526         POST /groups/{group-id}/members.delete
527         members: A list of account ids that identify the accounts that
528                 should be added or deleted.
529         TODO:
530         _one_member: The id of one account that should be added or deleted.
531           Content-Type: application/json;charset=UTF-8
532
533           {
534             "members": {
535               "jane.roe@example.com",
536               "john.doe@example.com"
537             }
538           }
539         """
540         if not group or not members:
541             return None
542         info = {'members': members}
543         return self._do_json_call( \
544                 "a/groups/%s/members.delete" % self.quote_id(group), \
545                 method='POST', \
546                 headers={'Content-Type': 'application/json;charset=UTF-8'}, \
547                 data=json.dumps(info))
548
549     def get_group_group(self, group, included_group):
550         """
551         GET /groups/{group-id}/groups/{group-id}
552         """
553         if not group or not included_group:
554             return None
555         return self._do_json_call("a/groups/%s/groups/%s" % \
556                                     (self.quote_id(group), included_group))
557
558     def add_group_group(self, group, included_group):
559         """
560         PUT /groups/{group-id}/groups/{group-id}
561         """
562         if not group or not included_group:
563             return None
564         return self._do_json_call("a/groups/%s/groups/%s" % \
565                 (self.quote_id(group), included_group), method='PUT', data="")
566
567     def delete_group_group(self, group, included_group):
568         """
569         DELETE /groups/{group-id}/groups/{group-id}
570         """
571         if not group or not included_group:
572             return None
573         return self._do_json_call("a/groups/%s/groups/%s" % \
574                     (self.quote_id(group), included_group), method='DELETE')
575
576     def get_group_groups(self, group):
577         """
578         GET /groups/{group-id}/groups/
579         """
580         if not group:
581             return None
582         return self._do_json_call("a/groups/%s/groups" % self.quote_id(group))
583
584     def add_group_groups(self, group, included_groups):
585         """
586         POST /groups/{group-id}/groups
587         OR
588         POST /groups/{group-id}/groups.add
589         groups: A list of group ids that identify the groups that should be
590                 included or deleted.
591         TODO:
592         _one_group: The id of one group that should be included or deleted.
593           Content-Type: application/json;charset=UTF-8
594
595           {
596             "groups": {
597               "MyGroup",
598               "MyOtherGroup"
599             }
600           }
601
602         """
603         if not group or not included_groups:
604             return None
605         info = {'groups': included_groups}
606         return self._do_json_call( \
607                 "a/groups/%s/groups.add" % self.quote_id(group), \
608                 method='POST', \
609                 headers={'Content-Type': 'application/json;charset=UTF-8'}, \
610                 data=json.dumps(info))
611
612
613     def delete_group_groups(self, group, included_groups):
614         """
615         POST /groups/{group-id}/groups.delete
616         groups: A list of group ids that identify the groups that should be
617                 included or deleted.
618         TODO:
619         _one_group The id of one group that should be included or deleted.
620          Content-Type: application/json;charset=UTF-8
621
622           {
623             "members": {
624               "MyGroup",
625               "MyOtherGroup"
626             }
627           }
628         """
629         if not group or not included_groups:
630             return None
631         info = {'groups': included_groups}
632         return self._do_json_call( \
633                 "a/groups/%s/groups.delete" % self.quote_id(group), \
634                 method='POST', \
635                 headers={'Content-Type': 'application/json;charset=UTF-8'}, \
636                 data=json.dumps(info))
637
638     def get_changes(self, query=None, n=None, labels=False,
639                     detailed_labels=False, current_revision=False,
640                     all_revisions=False, current_commit=False,
641                     all_commits=False, current_files=False, all_files=False,
642                     detailed_accounts=False):
643         """
644         GET /changes/
645         q = query
646         n = number (int)
647         """
648         opts = []
649         if query:
650             opts.append("q=%s" % query)
651         if n:
652             opts.append("n=%d" % n)
653         if all_files:
654             opts.append("o=ALL_FILES")
655             current_revision = True
656         if current_commit:
657             opts.append("o=CURRENT_COMMIT")
658             if not current_revision or not all_revisions:
659                 current_revision = True
660         if current_files:
661             opts.append("o=CURRENT_FILES")
662             if not current_revision or not all_revisions:
663                 current_revision = True
664         if labels:
665             opts.append("o=LABELS")
666         if detailed_labels:
667             opts.append("o=DETAILED_LABELS")
668         if detailed_accounts:
669             opts.append("o=DETAILED_ACCOUNTS")
670         if current_revision:
671             opts.append("o=CURRENT_REVISION")
672         if all_revisions:
673             opts.append("o=ALL_REVISIONS")
674         if all_commits:
675             opts.append("o=ALL_COMMITS")
676         if opts:
677             return self._do_json_call("a/changes/?%s" % "&".join(opts))
678         else:
679             return self._do_json_call("a/changes/")
680
681     def get_change(self, change, project=None, branch=None):
682         """
683         GET /changes/{change-id}
684         """
685         if not change:
686             return None
687         return self._do_json_call("a/changes/%s" % \
688                                     self.change_id(change, project, branch))
689
690     def get_change_detail(self, change):
691         """
692         GET /changes/{change-id}/detail
693         """
694         if not change:
695             return None
696         return self._do_json_call("a/changes/%s/detail" % change)
697
698     def get_change_topic(self, change):
699         """
700         GET /changes/{change-id}/topic
701         """
702         if not change:
703             return None
704         return self._do_json_call("a/changes/%s/topic" % change)
705
706     def set_change_topic(self, change, topic=None, message=None):
707         """
708         PUT /changes/{change-id}/topic
709         topic: The topic. The topic will be deleted if not set.
710         message: Message to be added as review comment to the change when
711                 setting the topic.
712         """
713         if not change:
714             return None
715         info = {}
716         if topic:
717             info['topic'] = topic
718         if message:
719             info['message'] = message
720         return self._do_json_call( \
721                 "a/changes/%s/topic" % change, \
722                 method='PUT', \
723                 headers={'Content-Type': 'application/json;charset=UTF-8'}, \
724                 data=json.dumps(info))
725
726     def delete_change_topic(self, change):
727         """
728         DELETE /changes/{change-id}/topic
729         """
730         if not change:
731             return None
732         return self._do_json_call("a/changes/%s/topic" % \
733                                     change, method='DELETE')
734
735     def abandon_change(self, change, message=None):
736         """
737         POST /changes/{change-id}/abandon
738         message: Message to be added as review comment to the change when
739                 abandoning the change.
740         TODO: error handling (409 + message in body)
741         """
742         if not change:
743             return None
744         info = {}
745         if message:
746             info['message'] = message
747         return self._do_json_call( \
748                 "a/changes/%s/abandon" % change, \
749                 method='POST', \
750                 headers={'Content-Type': 'application/json;charset=UTF-8'}, \
751                 data=json.dumps(info))
752
753     def restore_change(self, change, message=None):
754         """
755         POST /changes/{change-id}/restore
756         message: Message to be added as review comment to the change when
757                 restoring the change.
758         TODO: error handling (409 + message in body)
759         """
760         if not change:
761             return None
762         info = {}
763         if message:
764             info['message'] = message
765         return self._do_json_call( \
766                 "a/changes/%s/restore" % change, \
767                 method='POST', \
768                 headers={'Content-Type': 'application/json;charset=UTF-8'}, \
769                 data=json.dumps(info))
770
771     def revert_change(self, change, message=None):
772         """
773         POST /changes/{change-id}/revert
774         message: Message to be added as review comment to the change when
775                 reverting the change.
776         TODO: error handling (409 + message in body)
777         """
778         if not change:
779             return None
780         info = {}
781         if message:
782             info['message'] = message
783         return self._do_json_call( \
784                 "a/changes/%s/revert" % change, \
785                 method='POST', \
786                 headers={'Content-Type': 'application/json;charset=UTF-8'}, \
787                 data=json.dumps(info))
788
789     def submit_change(self, change, wait_for_merge=None):
790         """
791         POST /changes/{change-id}/submit
792         wait_for_merge: false if not set. Whether the request should wait for
793                         the merge to complete.
794         If false the request returns immediately after the change has been added
795         to the merge queue and the caller can’t know whether the change could
796         be merged successfully.
797         TODO: error handling (409 + message in body)
798         """
799         if not change:
800             return None
801         info = {}
802         if wait_for_merge is not None:
803             info['wait_for_merge'] = wait_for_merge
804         return self._do_json_call( \
805                 "a/changes/%s/submit" % change, \
806                 method='POST', \
807                 headers={'Content-Type': 'application/json;charset=UTF-8'}, \
808                 data=json.dumps(info))
809
810     def get_change_reviewers(self, change):
811         """
812         GET /changes/{change-id}/reviewers/
813         """
814         if not change:
815             return None
816         return self._do_json_call("a/changes/%s/reviewers/" % change)
817
818     def get_change_reviewer(self, change, reviewer):
819         """
820         GET /changes/{change-id}/reviewers/{account-id}
821         """
822         if not change:
823             return None
824         return self._do_json_call("a/changes/%s/reviewers/%s" % \
825                                     (change, reviewer))
826
827     def add_change_reviewer(self, change, reviewer, confirmed=None):
828         """
829         POST /changes/{change-id}/reviewers
830         reviewer: The ID of one account that should be added as reviewer or
831         the ID of one group for which all members should be added as reviewers.
832         If an ID identifies both an account and a group, only the account is
833         added as reviewer to the change.
834         confirmed: Whether adding the reviewer is confirmed. The Gerrit server
835             may be configured to require a confirmation when adding a group as
836             reviewer that has many members.
837         """
838         if not change or not reviewer:
839             return None
840         info = {}
841         if reviewer:
842             info['reviewer'] = reviewer
843         if confirmed is not None:
844             info['confirmed'] = confirmed
845         return self._do_json_call( \
846                 "a/changes/%s/reviewers" % change, \
847                 method='POST', \
848                 headers={'Content-Type': 'application/json;charset=UTF-8'}, \
849                 data=json.dumps(info))
850
851     def delete_change_reviewer(self, change, reviewer):
852         """
853         DELETE /changes/{change-id}/reviewers/{account-id}
854         """
855         if not change or not reviewer:
856             return None
857         return self._do_json_call("a/changes/%s/reviewers/%s" % \
858                                     (change, reviewer), method='DELETE')
859
860     def get_change_review(self, change, revision):
861         """
862         GET /changes/{change-id}/revisions/{revision-id}/review
863         """
864         if not change or not revision:
865             return None
866         return self._do_json_call("a/changes/%s/revisions/%s/review" % \
867                                                             (change, revision))
868
869     def set_change_review(self, change, revision):
870         """
871         POST /changes/{change-id}/revisions/{revision-id}/review
872         message: optional The message to be added as review comment.
873         labels: optional The votes that should be added to the revision as a map that maps the label names to the voting values.
874         comments: optional The comments that should be added as a map that maps a file path to a list of CommentInput entities.
875         strict_labels: true if not set Whether all labels are required to be within the user’s permitted ranges based on access controls.
876                                     If true, attempting to use a label not granted to the user will fail the entire modify operation early.
877                                     If false, the operation will execute anyway, but the proposed labels will be modified to be the "best" value allowed by the access controls.
878         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.
879         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.
880         """
881         raise NotImplementedError
882
883     def submit_change_revision(self, change, revision, wait_for_merge=None):
884         """
885         POST /changes/{change-id}/revisions/{revision-id}/submit
886         wait_for_merge: false if not set. Whether the request should wait for
887                     the merge to complete.
888                     If false the request returns immediately after the change
889                     has been added to the merge queue and the caller can’t know
890                     whether the change could be merged successfully.
891         TODO: error handling (409 + message in body)
892         """
893         if not change or not revision:
894             return None
895         info = {}
896         if wait_for_merge is not None:
897             info['wait_for_merge'] = wait_for_merge
898         return self._do_json_call( \
899                 "a/changes/%s/revisions/%s/submit" % (change, revision), \
900                 method='POST', \
901                 headers={'Content-Type': 'application/json;charset=UTF-8'}, \
902                 data=json.dumps(info))
903
904     def get_change_revision_submit_type(self, change, revision):
905         """
906         GET /changes/{change-id}/revisions/{revision-id}/submit_type
907         """
908         if not change or not revision:
909             return None
910         return self._do_json_call("a/changes/%s/revisions/%s/submit_type" % \
911                                                             (change, revision))
912
913
914     def get_account(self, account="self"):
915         """
916         GET /accounts/{account-id}
917         """
918         return self._do_json_call("a/accounts/%s" % urllib2.quote(account))
919
920     def get_account_capabilities(self, account="self"):
921         """
922         GET /accounts/{account-id}/capabilities
923         """
924         return self._do_json_call("a/accounts/%s/capabilities" % \
925                                                     urllib2.quote(account, ""))
926
927     def get_account_capability(self, account="self", capability=None):
928         """
929         GET /accounts/{account-id}/capabilities/{capability-id}
930         returns text "ok" / 404 "not found raised"
931         """
932         if not capability:
933             return None
934         ret = self._do_http_call("a/accounts/%s/capabilities/%s" % \
935                                     (urllib2.quote(account, ""), capability))
936         if ret[0] and ret[0] == "ok\n":
937             return True
938         if ret[1] == 404:
939             return False
940         return None
941
942     def get_account_groups(self, account="self"):
943         """
944         GET /accounts/{account-id}/groups/
945         """
946         return self._do_json_call("a/accounts/%s/groups" % \
947                                     urllib2.quote(account, ""))
948
949     def get_account_avatar(self, account="self", size=None):
950         """
951         GET /accounts/{account-id}/avatar
952         HTTP/1.1 302 Found
953         The response redirects to the URL of the avatar image.
954         """
955         raise NotImplementedError