2 # -*- coding: UTF-8 -*-
3 # vim: sw=4 ts=4 expandtab ai
5 # Copyright (c) 2013-2014 Intel, Inc.
7 # Author: Alexander Kanevskiy <alexander.kanevskiy@intel.com>
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.
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.
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("/"):
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)
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)
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'
47 fobj = self._opener.open(request)
49 if content[:5] == ")]}'\n":
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)
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)
61 return json.loads(ret[0])
67 "Returns encoded project or group name if it contains /"
68 return urllib2.quote(oid, "") if '/' in oid else oid
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)
76 return "%s" % changeid
78 def get_projects(self, description=False, parents=False, prefix=None, branches=None):
88 opts.append("p=%s" % (urllib2.quote(prefix, "") \
89 if '/' in prefix else prefix))
91 if not isinstance(branches, (types.ListType, types.TupleType)):
93 for branch in branches:
94 opts.append("b=%s" % urllib2.quote(branch, ""))
96 return self._do_json_call("a/projects/?%s" % "&".join(opts))
98 return self._do_json_call("a/projects/")
101 def get_project(self, project):
103 GET /projects/{project-name}
107 return self._do_json_call("a/projects/%s" % self.quote_id(project))
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):
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).
138 info['name'] = project
140 info['parent'] = parent
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
148 info['submit_type'] = submit_type
150 info['branches'] = branches
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), \
164 headers={'Content-Type': 'application/json;charset=UTF-8'}, \
165 data=json.dumps(info))
167 def get_project_description(self, project):
169 GET /projects/{project-name}/description
173 return self._do_json_call("a/projects/%s/description" % self.quote_id(project))
175 def set_project_description(self, project, description=None, commit_message=None):
177 PUT /projects/{project-name}/description
178 Content-Type: application/json;charset=UTF-8
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.
189 info['description'] = description
191 info['commit_message'] = commit_message
192 return self._do_json_call( \
193 "a/projects/%s/description" % self.quote_id(project), \
195 headers={'Content-Type': 'application/json;charset=UTF-8'}, \
196 data=json.dumps(info))
198 def delete_project_description(self, project):
200 DELETE /projects/{project-name}/description
204 return self._do_json_call("a/projects/%s/description" % \
205 self.quote_id(project), method='DELETE')
207 def get_project_parent(self, project):
209 GET /projects/{project-name}/parent
213 return self._do_json_call("a/projects/%s/parent" % self.quote_id(project))
215 def set_project_parent(self, project, parent, commit_message=None):
217 PUT /projects/{project-name}/parent
218 Content-Type: application/json;charset=UTF-8
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.
229 info['parent'] = parent
231 info['commit_message'] = commit_message
232 return self._do_json_call( \
233 "a/projects/%s/parent" % self.quote_id(project), \
235 headers={'Content-Type': 'application/json;charset=UTF-8'}, \
236 data=json.dumps(info))
238 def get_project_head(self, project):
240 GET /projects/{project-name}/HEAD
244 return self._do_json_call("a/projects/%s/HEAD" % self.quote_id(project))
246 def set_project_head(self, project, head):
248 PUT /projects/{project-name}/HEAD
249 ref: The ref to which HEAD should be set,
250 the refs/heads prefix can be omitted.
255 return self._do_json_call( \
256 "a/projects/%s/HEAD" % self.quote_id(project), \
258 headers={'Content-Type': 'application/json;charset=UTF-8'}, \
259 data=json.dumps(info))
261 def get_project_statistics(self, project):
263 GET /projects/{project-name}/statistics.git
267 return self._do_json_call("a/projects/%s/statistics.git" % \
268 self.quote_id(project))
270 def run_project_gc(self, project):
272 POST /projects/{project-name}/gc
273 returned data is not JSON!
277 return self._do_http_call("a/projects/%s/gc" % \
278 self.quote_id(project), method='POST')[0]
280 def get_groups(self, members=False, includes=False, project=None, user=None):
287 opts.append("o=MEMBERS")
289 opts.append("o=INCLUDES")
291 opts.append("p=%s" % self.quote_id(project))
293 opts.append("u=%s" % user)
295 return self._do_json_call("a/groups/?%s" % "&".join(opts))
297 return self._do_json_call("a/groups/")
299 def get_group(self, group):
301 GET /groups/{group-id}
305 return self._do_json_call("a/groups/%s" % group)
308 def create_group(self, group, description=None, visible_to_all=None, owner=None):
310 PUT /groups/{group-name}
311 all parameters optional
312 Content-Type: application/json;charset=UTF-8
315 "description": "contains all committers for MyProject",
316 "visible_to_all": true,
317 "owner_id": "7ca042f4d5847936fcb90ca91057673157fd06fc"
324 info['description'] = description
325 if visible_to_all is not None:
326 info['visible_to_all'] = visible_to_all
328 info['owner_id'] = owner
329 return self._do_json_call( \
330 "a/groups/%s" % self.quote_id(group), \
332 headers={'Content-Type': 'application/json;charset=UTF-8'}, \
333 data=json.dumps(info))
335 def get_group_detail(self, group):
337 GET /groups/{group-id}/detail
341 return self._do_json_call("a/groups/%s/detail" % self.quote_id(group))
344 def get_group_name(self, group):
346 GET /groups/{group-id}/name
350 return self._do_json_call("a/groups/%s/name" % self.quote_id(group))
352 def set_group_name(self, group, new_name):
354 PUT /groups/{group-id}/name
355 Content-Type: application/json;charset=UTF-8
358 "name": "My-Project-Committers"
361 if not group or not new_name:
363 info = {'name': new_name}
364 return self._do_json_call( \
365 "a/groups/%s" % self.quote_id(group), \
367 headers={'Content-Type': 'application/json;charset=UTF-8'}, \
368 data=json.dumps(info))
370 def get_group_description(self, group):
372 GET /groups/{group-id}/description
376 return self._do_json_call("a/groups/%s/description" % \
377 self.quote_id(group))
379 def set_group_description(self, group, new_description):
381 PUT /groups/{group-id}/description
382 Content-Type: application/json;charset=UTF-8
385 "description": "The committers of MyProject."
390 info = {'description': new_description}
391 return self._do_json_call( \
392 "a/groups/%s/description" % self.quote_id(group), \
394 headers={'Content-Type': 'application/json;charset=UTF-8'}, \
395 data=json.dumps(info))
397 def delete_group_description(self, group):
399 DELETE /groups/{group-id}/description
403 return self._do_json_call("a/groups/%s/description" % \
404 self.quote_id(group), method='DELETE')
406 def get_group_options(self, group):
408 GET /groups/{group-id}/options
412 return self._do_json_call("a/groups/%s/options" % self.quote_id(group))
414 def set_group_options(self, group, visible_to_all=None):
416 PUT /groups/{group-id}/options
417 Content-Type: application/json;charset=UTF-8
420 "visible_to_all": true
423 if not group or visible_to_all is None:
425 info = {'visible_to_all': visible_to_all}
426 return self._do_json_call( \
427 "a/groups/%s/options" % self.quote_id(group), \
429 headers={'Content-Type': 'application/json;charset=UTF-8'}, \
430 data=json.dumps(info))
432 def get_group_owner(self, group):
434 GET /groups/{group-id}/owner
438 return self._do_json_call("a/groups/%s/owner" % self.quote_id(group))
440 def set_group_owner(self, group, new_owner):
442 PUT /groups/{group-id}/owner
444 Content-Type: application/json;charset=UTF-8
447 "owner": "6a1e70e1a88782771a91808c8af9bbb7a9871389"
452 info = {'owner': new_owner}
453 return self._do_json_call( \
454 "a/groups/%s/owner" % self.quote_id(group), \
456 headers={'Content-Type': 'application/json;charset=UTF-8'}, \
457 data=json.dumps(info))
459 def get_group_member(self, group, member):
461 GET /groups/{group-id}/members/{account-id}
463 if not group or not member:
465 return self._do_json_call("a/groups/%s/member/%s" % \
466 (self.quote_id(group), member))
468 def add_group_member(self, group, member):
470 PUT /groups/{group-id}/members/{account-id}
472 if not group or not member:
474 return self._do_json_call("a/groups/%s/members/%s" % \
475 (self.quote_id(group), member), method='PUT', data="")
477 def delete_group_member(self, group, member):
479 DELETE /groups/{group-id}/members/{account-id}
481 if not group or not member:
483 return self._do_json_call("a/groups/%s/members/%s" % \
484 (self.quote_id(group), member), method='DELETE')
486 def get_group_members(self, group, recursive=None):
488 GET /groups/{group-id}/members/
489 GET /groups/{group-id}/members/?recursive
493 options = "?recursive" if recursive else ""
494 return self._do_json_call("a/groups/%s/members/%s" % \
495 (self.quote_id(group), options))
497 def add_group_members(self, group, members):
499 POST /groups/{group-id}/members.add
501 POST /groups/{group-id}/members
502 members: A list of account ids that identify the accounts that should
505 _one_member: The id of one account that should be added or deleted.
506 Content-Type: application/json;charset=UTF-8
510 "jane.roe@example.com",
511 "john.doe@example.com"
515 if not group or not members:
517 info = {'members': members}
518 return self._do_json_call( \
519 "a/groups/%s/members.add" % self.quote_id(group), \
521 headers={'Content-Type': 'application/json;charset=UTF-8'}, \
522 data=json.dumps(info))
524 def delete_group_members(self, group, members):
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.
530 _one_member: The id of one account that should be added or deleted.
531 Content-Type: application/json;charset=UTF-8
535 "jane.roe@example.com",
536 "john.doe@example.com"
540 if not group or not members:
542 info = {'members': members}
543 return self._do_json_call( \
544 "a/groups/%s/members.delete" % self.quote_id(group), \
546 headers={'Content-Type': 'application/json;charset=UTF-8'}, \
547 data=json.dumps(info))
549 def get_group_group(self, group, included_group):
551 GET /groups/{group-id}/groups/{group-id}
553 if not group or not included_group:
555 return self._do_json_call("a/groups/%s/groups/%s" % \
556 (self.quote_id(group), included_group))
558 def add_group_group(self, group, included_group):
560 PUT /groups/{group-id}/groups/{group-id}
562 if not group or not included_group:
564 return self._do_json_call("a/groups/%s/groups/%s" % \
565 (self.quote_id(group), included_group), method='PUT', data="")
567 def delete_group_group(self, group, included_group):
569 DELETE /groups/{group-id}/groups/{group-id}
571 if not group or not included_group:
573 return self._do_json_call("a/groups/%s/groups/%s" % \
574 (self.quote_id(group), included_group), method='DELETE')
576 def get_group_groups(self, group):
578 GET /groups/{group-id}/groups/
582 return self._do_json_call("a/groups/%s/groups" % self.quote_id(group))
584 def add_group_groups(self, group, included_groups):
586 POST /groups/{group-id}/groups
588 POST /groups/{group-id}/groups.add
589 groups: A list of group ids that identify the groups that should be
592 _one_group: The id of one group that should be included or deleted.
593 Content-Type: application/json;charset=UTF-8
603 if not group or not included_groups:
605 info = {'groups': included_groups}
606 return self._do_json_call( \
607 "a/groups/%s/groups.add" % self.quote_id(group), \
609 headers={'Content-Type': 'application/json;charset=UTF-8'}, \
610 data=json.dumps(info))
613 def delete_group_groups(self, group, included_groups):
615 POST /groups/{group-id}/groups.delete
616 groups: A list of group ids that identify the groups that should be
619 _one_group The id of one group that should be included or deleted.
620 Content-Type: application/json;charset=UTF-8
629 if not group or not included_groups:
631 info = {'groups': included_groups}
632 return self._do_json_call( \
633 "a/groups/%s/groups.delete" % self.quote_id(group), \
635 headers={'Content-Type': 'application/json;charset=UTF-8'}, \
636 data=json.dumps(info))
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):
650 opts.append("q=%s" % query)
652 opts.append("n=%d" % n)
654 opts.append("o=ALL_FILES")
655 current_revision = True
657 opts.append("o=CURRENT_COMMIT")
658 if not current_revision or not all_revisions:
659 current_revision = True
661 opts.append("o=CURRENT_FILES")
662 if not current_revision or not all_revisions:
663 current_revision = True
665 opts.append("o=LABELS")
667 opts.append("o=DETAILED_LABELS")
668 if detailed_accounts:
669 opts.append("o=DETAILED_ACCOUNTS")
671 opts.append("o=CURRENT_REVISION")
673 opts.append("o=ALL_REVISIONS")
675 opts.append("o=ALL_COMMITS")
677 return self._do_json_call("a/changes/?%s" % "&".join(opts))
679 return self._do_json_call("a/changes/")
681 def get_change(self, change, project=None, branch=None):
683 GET /changes/{change-id}
687 return self._do_json_call("a/changes/%s" % \
688 self.change_id(change, project, branch))
690 def get_change_detail(self, change):
692 GET /changes/{change-id}/detail
696 return self._do_json_call("a/changes/%s/detail" % change)
698 def get_change_topic(self, change):
700 GET /changes/{change-id}/topic
704 return self._do_json_call("a/changes/%s/topic" % change)
706 def set_change_topic(self, change, topic=None, message=None):
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
717 info['topic'] = topic
719 info['message'] = message
720 return self._do_json_call( \
721 "a/changes/%s/topic" % change, \
723 headers={'Content-Type': 'application/json;charset=UTF-8'}, \
724 data=json.dumps(info))
726 def delete_change_topic(self, change):
728 DELETE /changes/{change-id}/topic
732 return self._do_json_call("a/changes/%s/topic" % \
733 change, method='DELETE')
735 def abandon_change(self, change, message=None):
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)
746 info['message'] = message
747 return self._do_json_call( \
748 "a/changes/%s/abandon" % change, \
750 headers={'Content-Type': 'application/json;charset=UTF-8'}, \
751 data=json.dumps(info))
753 def restore_change(self, change, message=None):
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)
764 info['message'] = message
765 return self._do_json_call( \
766 "a/changes/%s/restore" % change, \
768 headers={'Content-Type': 'application/json;charset=UTF-8'}, \
769 data=json.dumps(info))
771 def revert_change(self, change, message=None):
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)
782 info['message'] = message
783 return self._do_json_call( \
784 "a/changes/%s/revert" % change, \
786 headers={'Content-Type': 'application/json;charset=UTF-8'}, \
787 data=json.dumps(info))
789 def submit_change(self, change, wait_for_merge=None):
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)
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, \
807 headers={'Content-Type': 'application/json;charset=UTF-8'}, \
808 data=json.dumps(info))
810 def get_change_reviewers(self, change):
812 GET /changes/{change-id}/reviewers/
816 return self._do_json_call("a/changes/%s/reviewers/" % change)
818 def get_change_reviewer(self, change, reviewer):
820 GET /changes/{change-id}/reviewers/{account-id}
824 return self._do_json_call("a/changes/%s/reviewers/%s" % \
827 def add_change_reviewer(self, change, reviewer, confirmed=None):
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.
838 if not change or not 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, \
848 headers={'Content-Type': 'application/json;charset=UTF-8'}, \
849 data=json.dumps(info))
851 def delete_change_reviewer(self, change, reviewer):
853 DELETE /changes/{change-id}/reviewers/{account-id}
855 if not change or not reviewer:
857 return self._do_json_call("a/changes/%s/reviewers/%s" % \
858 (change, reviewer), method='DELETE')
860 def get_change_review(self, change, revision):
862 GET /changes/{change-id}/revisions/{revision-id}/review
864 if not change or not revision:
866 return self._do_json_call("a/changes/%s/revisions/%s/review" % \
869 def set_change_review(self, change, revision):
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.
881 raise NotImplementedError
883 def submit_change_revision(self, change, revision, wait_for_merge=None):
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)
893 if not change or not revision:
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), \
901 headers={'Content-Type': 'application/json;charset=UTF-8'}, \
902 data=json.dumps(info))
904 def get_change_revision_submit_type(self, change, revision):
906 GET /changes/{change-id}/revisions/{revision-id}/submit_type
908 if not change or not revision:
910 return self._do_json_call("a/changes/%s/revisions/%s/submit_type" % \
914 def get_account(self, account="self"):
916 GET /accounts/{account-id}
918 return self._do_json_call("a/accounts/%s" % urllib2.quote(account))
920 def get_account_capabilities(self, account="self"):
922 GET /accounts/{account-id}/capabilities
924 return self._do_json_call("a/accounts/%s/capabilities" % \
925 urllib2.quote(account, ""))
927 def get_account_capability(self, account="self", capability=None):
929 GET /accounts/{account-id}/capabilities/{capability-id}
930 returns text "ok" / 404 "not found raised"
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":
942 def get_account_groups(self, account="self"):
944 GET /accounts/{account-id}/groups/
946 return self._do_json_call("a/accounts/%s/groups" % \
947 urllib2.quote(account, ""))
949 def get_account_avatar(self, account="self", size=None):
951 GET /accounts/{account-id}/avatar
953 The response redirects to the URL of the avatar image.
955 raise NotImplementedError