##// END OF EJS Templates
api: pull-requests, fixed invocation of merge as another user.
ergo -
r3481:b5202911 default
parent child Browse files
Show More
@@ -1,158 +1,259 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2019 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import pytest
22 22
23 23 from rhodecode.model.db import UserLog, PullRequest
24 24 from rhodecode.model.meta import Session
25 25 from rhodecode.tests import TEST_USER_ADMIN_LOGIN
26 26 from rhodecode.api.tests.utils import (
27 27 build_data, api_call, assert_error, assert_ok)
28 28
29 29
30 30 @pytest.mark.usefixtures("testuser_api", "app")
31 31 class TestMergePullRequest(object):
32 32
33 33 @pytest.mark.backends("git", "hg")
34 34 def test_api_merge_pull_request_merge_failed(self, pr_util, no_notifications):
35 35 pull_request = pr_util.create_pull_request(mergeable=True)
36 36 pull_request_id = pull_request.pull_request_id
37 37 pull_request_repo = pull_request.target_repo.repo_name
38 38
39 39 id_, params = build_data(
40 40 self.apikey, 'merge_pull_request',
41 41 repoid=pull_request_repo,
42 42 pullrequestid=pull_request_id)
43 43
44 44 response = api_call(self.app, params)
45 45
46 46 # The above api call detaches the pull request DB object from the
47 47 # session because of an unconditional transaction rollback in our
48 48 # middleware. Therefore we need to add it back here if we want to use it.
49 49 Session().add(pull_request)
50 50
51 51 expected = 'merge not possible for following reasons: ' \
52 52 'Pull request reviewer approval is pending.'
53 53 assert_error(id_, expected, given=response.body)
54 54
55 55 @pytest.mark.backends("git", "hg")
56 56 def test_api_merge_pull_request_merge_failed_disallowed_state(
57 57 self, pr_util, no_notifications):
58 58 pull_request = pr_util.create_pull_request(mergeable=True, approved=True)
59 59 pull_request_id = pull_request.pull_request_id
60 60 pull_request_repo = pull_request.target_repo.repo_name
61 61
62 62 pr = PullRequest.get(pull_request_id)
63 63 pr.pull_request_state = pull_request.STATE_UPDATING
64 64 Session().add(pr)
65 65 Session().commit()
66 66
67 67 id_, params = build_data(
68 68 self.apikey, 'merge_pull_request',
69 69 repoid=pull_request_repo,
70 70 pullrequestid=pull_request_id)
71 71
72 72 response = api_call(self.app, params)
73 73 expected = 'Operation forbidden because pull request is in state {}, '\
74 74 'only state {} is allowed.'.format(PullRequest.STATE_UPDATING,
75 75 PullRequest.STATE_CREATED)
76 76 assert_error(id_, expected, given=response.body)
77 77
78 78 @pytest.mark.backends("git", "hg")
79 79 def test_api_merge_pull_request(self, pr_util, no_notifications):
80 80 pull_request = pr_util.create_pull_request(mergeable=True, approved=True)
81 81 author = pull_request.user_id
82 82 repo = pull_request.target_repo.repo_id
83 83 pull_request_id = pull_request.pull_request_id
84 84 pull_request_repo = pull_request.target_repo.repo_name
85 85
86 86 id_, params = build_data(
87 87 self.apikey, 'comment_pull_request',
88 88 repoid=pull_request_repo,
89 89 pullrequestid=pull_request_id,
90 90 status='approved')
91 91
92 92 response = api_call(self.app, params)
93 93 expected = {
94 94 'comment_id': response.json.get('result', {}).get('comment_id'),
95 95 'pull_request_id': pull_request_id,
96 96 'status': {'given': 'approved', 'was_changed': True}
97 97 }
98 98 assert_ok(id_, expected, given=response.body)
99 99
100 100 id_, params = build_data(
101 101 self.apikey, 'merge_pull_request',
102 102 repoid=pull_request_repo,
103 103 pullrequestid=pull_request_id)
104 104
105 105 response = api_call(self.app, params)
106 106
107 107 pull_request = PullRequest.get(pull_request_id)
108 108
109 109 expected = {
110 110 'executed': True,
111 111 'failure_reason': 0,
112 112 'merge_status_message': 'This pull request can be automatically merged.',
113 113 'possible': True,
114 114 'merge_commit_id': pull_request.shadow_merge_ref.commit_id,
115 115 'merge_ref': pull_request.shadow_merge_ref._asdict()
116 116 }
117 117
118 118 assert_ok(id_, expected, response.body)
119 119
120 120 journal = UserLog.query()\
121 121 .filter(UserLog.user_id == author)\
122 122 .filter(UserLog.repository_id == repo) \
123 123 .order_by('user_log_id') \
124 124 .all()
125 125 assert journal[-2].action == 'repo.pull_request.merge'
126 126 assert journal[-1].action == 'repo.pull_request.close'
127 127
128 128 id_, params = build_data(
129 129 self.apikey, 'merge_pull_request',
130 130 repoid=pull_request_repo, pullrequestid=pull_request_id)
131 131 response = api_call(self.app, params)
132 132
133 133 expected = 'merge not possible for following reasons: This pull request is closed.'
134 134 assert_error(id_, expected, given=response.body)
135 135
136 136 @pytest.mark.backends("git", "hg")
137 def test_api_merge_pull_request_as_another_user_no_perms_to_merge(
138 self, pr_util, no_notifications, user_util):
139 merge_user = user_util.create_user()
140 merge_user_id = merge_user.user_id
141 merge_user_username = merge_user.username
142
143 pull_request = pr_util.create_pull_request(mergeable=True, approved=True)
144
145 pull_request_id = pull_request.pull_request_id
146 pull_request_repo = pull_request.target_repo.repo_name
147
148 id_, params = build_data(
149 self.apikey, 'comment_pull_request',
150 repoid=pull_request_repo,
151 pullrequestid=pull_request_id,
152 status='approved')
153
154 response = api_call(self.app, params)
155 expected = {
156 'comment_id': response.json.get('result', {}).get('comment_id'),
157 'pull_request_id': pull_request_id,
158 'status': {'given': 'approved', 'was_changed': True}
159 }
160 assert_ok(id_, expected, given=response.body)
161 id_, params = build_data(
162 self.apikey, 'merge_pull_request',
163 repoid=pull_request_repo,
164 pullrequestid=pull_request_id,
165 userid=merge_user_id
166 )
167
168 response = api_call(self.app, params)
169 expected = 'merge not possible for following reasons: User `{}` ' \
170 'not allowed to perform merge.'.format(merge_user_username)
171 assert_error(id_, expected, response.body)
172
173 @pytest.mark.backends("git", "hg")
174 def test_api_merge_pull_request_as_another_user(self, pr_util, no_notifications, user_util):
175 merge_user = user_util.create_user()
176 merge_user_id = merge_user.user_id
177 pull_request = pr_util.create_pull_request(mergeable=True, approved=True)
178 user_util.grant_user_permission_to_repo(
179 pull_request.target_repo, merge_user, 'repository.write')
180 author = pull_request.user_id
181 repo = pull_request.target_repo.repo_id
182 pull_request_id = pull_request.pull_request_id
183 pull_request_repo = pull_request.target_repo.repo_name
184
185 id_, params = build_data(
186 self.apikey, 'comment_pull_request',
187 repoid=pull_request_repo,
188 pullrequestid=pull_request_id,
189 status='approved')
190
191 response = api_call(self.app, params)
192 expected = {
193 'comment_id': response.json.get('result', {}).get('comment_id'),
194 'pull_request_id': pull_request_id,
195 'status': {'given': 'approved', 'was_changed': True}
196 }
197 assert_ok(id_, expected, given=response.body)
198
199 id_, params = build_data(
200 self.apikey, 'merge_pull_request',
201 repoid=pull_request_repo,
202 pullrequestid=pull_request_id,
203 userid=merge_user_id
204 )
205
206 response = api_call(self.app, params)
207
208 pull_request = PullRequest.get(pull_request_id)
209
210 expected = {
211 'executed': True,
212 'failure_reason': 0,
213 'merge_status_message': 'This pull request can be automatically merged.',
214 'possible': True,
215 'merge_commit_id': pull_request.shadow_merge_ref.commit_id,
216 'merge_ref': pull_request.shadow_merge_ref._asdict()
217 }
218
219 assert_ok(id_, expected, response.body)
220
221 journal = UserLog.query() \
222 .filter(UserLog.user_id == merge_user_id) \
223 .filter(UserLog.repository_id == repo) \
224 .order_by('user_log_id') \
225 .all()
226 assert journal[-2].action == 'repo.pull_request.merge'
227 assert journal[-1].action == 'repo.pull_request.close'
228
229 id_, params = build_data(
230 self.apikey, 'merge_pull_request',
231 repoid=pull_request_repo, pullrequestid=pull_request_id, userid=merge_user_id)
232 response = api_call(self.app, params)
233
234 expected = 'merge not possible for following reasons: This pull request is closed.'
235 assert_error(id_, expected, given=response.body)
236
237 @pytest.mark.backends("git", "hg")
137 238 def test_api_merge_pull_request_repo_error(self, pr_util):
138 239 pull_request = pr_util.create_pull_request()
139 240 id_, params = build_data(
140 241 self.apikey, 'merge_pull_request',
141 242 repoid=666, pullrequestid=pull_request.pull_request_id)
142 243 response = api_call(self.app, params)
143 244
144 245 expected = 'repository `666` does not exist'
145 246 assert_error(id_, expected, given=response.body)
146 247
147 248 @pytest.mark.backends("git", "hg")
148 249 def test_api_merge_pull_request_non_admin_with_userid_error(self, pr_util):
149 250 pull_request = pr_util.create_pull_request(mergeable=True)
150 251 id_, params = build_data(
151 252 self.apikey_regular, 'merge_pull_request',
152 253 repoid=pull_request.target_repo.repo_name,
153 254 pullrequestid=pull_request.pull_request_id,
154 255 userid=TEST_USER_ADMIN_LOGIN)
155 256 response = api_call(self.app, params)
156 257
157 258 expected = 'userid is not the same as your user'
158 259 assert_error(id_, expected, given=response.body)
@@ -1,996 +1,997 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2019 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21
22 22 import logging
23 23
24 24 from rhodecode import events
25 25 from rhodecode.api import jsonrpc_method, JSONRPCError, JSONRPCValidationError
26 26 from rhodecode.api.utils import (
27 27 has_superadmin_permission, Optional, OAttr, get_repo_or_error,
28 28 get_pull_request_or_error, get_commit_or_error, get_user_or_error,
29 29 validate_repo_permissions, resolve_ref_or_error, validate_set_owner_permissions)
30 30 from rhodecode.lib.auth import (HasRepoPermissionAnyApi)
31 31 from rhodecode.lib.base import vcs_operation_context
32 32 from rhodecode.lib.utils2 import str2bool
33 33 from rhodecode.model.changeset_status import ChangesetStatusModel
34 34 from rhodecode.model.comment import CommentsModel
35 35 from rhodecode.model.db import Session, ChangesetStatus, ChangesetComment, PullRequest
36 36 from rhodecode.model.pull_request import PullRequestModel, MergeCheck
37 37 from rhodecode.model.settings import SettingsModel
38 38 from rhodecode.model.validation_schema import Invalid
39 39 from rhodecode.model.validation_schema.schemas.reviewer_schema import(
40 40 ReviewerListSchema)
41 41
42 42 log = logging.getLogger(__name__)
43 43
44 44
45 45 @jsonrpc_method()
46 46 def get_pull_request(request, apiuser, pullrequestid, repoid=Optional(None)):
47 47 """
48 48 Get a pull request based on the given ID.
49 49
50 50 :param apiuser: This is filled automatically from the |authtoken|.
51 51 :type apiuser: AuthUser
52 52 :param repoid: Optional, repository name or repository ID from where
53 53 the pull request was opened.
54 54 :type repoid: str or int
55 55 :param pullrequestid: ID of the requested pull request.
56 56 :type pullrequestid: int
57 57
58 58 Example output:
59 59
60 60 .. code-block:: bash
61 61
62 62 "id": <id_given_in_input>,
63 63 "result":
64 64 {
65 65 "pull_request_id": "<pull_request_id>",
66 66 "url": "<url>",
67 67 "title": "<title>",
68 68 "description": "<description>",
69 69 "status" : "<status>",
70 70 "created_on": "<date_time_created>",
71 71 "updated_on": "<date_time_updated>",
72 72 "commit_ids": [
73 73 ...
74 74 "<commit_id>",
75 75 "<commit_id>",
76 76 ...
77 77 ],
78 78 "review_status": "<review_status>",
79 79 "mergeable": {
80 80 "status": "<bool>",
81 81 "message": "<message>",
82 82 },
83 83 "source": {
84 84 "clone_url": "<clone_url>",
85 85 "repository": "<repository_name>",
86 86 "reference":
87 87 {
88 88 "name": "<name>",
89 89 "type": "<type>",
90 90 "commit_id": "<commit_id>",
91 91 }
92 92 },
93 93 "target": {
94 94 "clone_url": "<clone_url>",
95 95 "repository": "<repository_name>",
96 96 "reference":
97 97 {
98 98 "name": "<name>",
99 99 "type": "<type>",
100 100 "commit_id": "<commit_id>",
101 101 }
102 102 },
103 103 "merge": {
104 104 "clone_url": "<clone_url>",
105 105 "reference":
106 106 {
107 107 "name": "<name>",
108 108 "type": "<type>",
109 109 "commit_id": "<commit_id>",
110 110 }
111 111 },
112 112 "author": <user_obj>,
113 113 "reviewers": [
114 114 ...
115 115 {
116 116 "user": "<user_obj>",
117 117 "review_status": "<review_status>",
118 118 }
119 119 ...
120 120 ]
121 121 },
122 122 "error": null
123 123 """
124 124
125 125 pull_request = get_pull_request_or_error(pullrequestid)
126 126 if Optional.extract(repoid):
127 127 repo = get_repo_or_error(repoid)
128 128 else:
129 129 repo = pull_request.target_repo
130 130
131 131 if not PullRequestModel().check_user_read(pull_request, apiuser, api=True):
132 132 raise JSONRPCError('repository `%s` or pull request `%s` '
133 133 'does not exist' % (repoid, pullrequestid))
134 134
135 135 # NOTE(marcink): only calculate and return merge state if the pr state is 'created'
136 136 # otherwise we can lock the repo on calculation of merge state while update/merge
137 137 # is happening.
138 138 merge_state = pull_request.pull_request_state == pull_request.STATE_CREATED
139 139 data = pull_request.get_api_data(with_merge_state=merge_state)
140 140 return data
141 141
142 142
143 143 @jsonrpc_method()
144 144 def get_pull_requests(request, apiuser, repoid, status=Optional('new'),
145 145 merge_state=Optional(True)):
146 146 """
147 147 Get all pull requests from the repository specified in `repoid`.
148 148
149 149 :param apiuser: This is filled automatically from the |authtoken|.
150 150 :type apiuser: AuthUser
151 151 :param repoid: Optional repository name or repository ID.
152 152 :type repoid: str or int
153 153 :param status: Only return pull requests with the specified status.
154 154 Valid options are.
155 155 * ``new`` (default)
156 156 * ``open``
157 157 * ``closed``
158 158 :type status: str
159 159 :param merge_state: Optional calculate merge state for each repository.
160 160 This could result in longer time to fetch the data
161 161 :type merge_state: bool
162 162
163 163 Example output:
164 164
165 165 .. code-block:: bash
166 166
167 167 "id": <id_given_in_input>,
168 168 "result":
169 169 [
170 170 ...
171 171 {
172 172 "pull_request_id": "<pull_request_id>",
173 173 "url": "<url>",
174 174 "title" : "<title>",
175 175 "description": "<description>",
176 176 "status": "<status>",
177 177 "created_on": "<date_time_created>",
178 178 "updated_on": "<date_time_updated>",
179 179 "commit_ids": [
180 180 ...
181 181 "<commit_id>",
182 182 "<commit_id>",
183 183 ...
184 184 ],
185 185 "review_status": "<review_status>",
186 186 "mergeable": {
187 187 "status": "<bool>",
188 188 "message: "<message>",
189 189 },
190 190 "source": {
191 191 "clone_url": "<clone_url>",
192 192 "reference":
193 193 {
194 194 "name": "<name>",
195 195 "type": "<type>",
196 196 "commit_id": "<commit_id>",
197 197 }
198 198 },
199 199 "target": {
200 200 "clone_url": "<clone_url>",
201 201 "reference":
202 202 {
203 203 "name": "<name>",
204 204 "type": "<type>",
205 205 "commit_id": "<commit_id>",
206 206 }
207 207 },
208 208 "merge": {
209 209 "clone_url": "<clone_url>",
210 210 "reference":
211 211 {
212 212 "name": "<name>",
213 213 "type": "<type>",
214 214 "commit_id": "<commit_id>",
215 215 }
216 216 },
217 217 "author": <user_obj>,
218 218 "reviewers": [
219 219 ...
220 220 {
221 221 "user": "<user_obj>",
222 222 "review_status": "<review_status>",
223 223 }
224 224 ...
225 225 ]
226 226 }
227 227 ...
228 228 ],
229 229 "error": null
230 230
231 231 """
232 232 repo = get_repo_or_error(repoid)
233 233 if not has_superadmin_permission(apiuser):
234 234 _perms = (
235 235 'repository.admin', 'repository.write', 'repository.read',)
236 236 validate_repo_permissions(apiuser, repoid, repo, _perms)
237 237
238 238 status = Optional.extract(status)
239 239 merge_state = Optional.extract(merge_state, binary=True)
240 240 pull_requests = PullRequestModel().get_all(repo, statuses=[status],
241 241 order_by='id', order_dir='desc')
242 242 data = [pr.get_api_data(with_merge_state=merge_state) for pr in pull_requests]
243 243 return data
244 244
245 245
246 246 @jsonrpc_method()
247 247 def merge_pull_request(
248 248 request, apiuser, pullrequestid, repoid=Optional(None),
249 249 userid=Optional(OAttr('apiuser'))):
250 250 """
251 251 Merge the pull request specified by `pullrequestid` into its target
252 252 repository.
253 253
254 254 :param apiuser: This is filled automatically from the |authtoken|.
255 255 :type apiuser: AuthUser
256 256 :param repoid: Optional, repository name or repository ID of the
257 257 target repository to which the |pr| is to be merged.
258 258 :type repoid: str or int
259 259 :param pullrequestid: ID of the pull request which shall be merged.
260 260 :type pullrequestid: int
261 261 :param userid: Merge the pull request as this user.
262 262 :type userid: Optional(str or int)
263 263
264 264 Example output:
265 265
266 266 .. code-block:: bash
267 267
268 268 "id": <id_given_in_input>,
269 269 "result": {
270 270 "executed": "<bool>",
271 271 "failure_reason": "<int>",
272 272 "merge_status_message": "<str>",
273 273 "merge_commit_id": "<merge_commit_id>",
274 274 "possible": "<bool>",
275 275 "merge_ref": {
276 276 "commit_id": "<commit_id>",
277 277 "type": "<type>",
278 278 "name": "<name>"
279 279 }
280 280 },
281 281 "error": null
282 282 """
283 283 pull_request = get_pull_request_or_error(pullrequestid)
284 284 if Optional.extract(repoid):
285 285 repo = get_repo_or_error(repoid)
286 286 else:
287 287 repo = pull_request.target_repo
288
288 auth_user = apiuser
289 289 if not isinstance(userid, Optional):
290 290 if (has_superadmin_permission(apiuser) or
291 291 HasRepoPermissionAnyApi('repository.admin')(
292 292 user=apiuser, repo_name=repo.repo_name)):
293 293 apiuser = get_user_or_error(userid)
294 auth_user = apiuser.AuthUser()
294 295 else:
295 296 raise JSONRPCError('userid is not the same as your user')
296 297
297 298 if pull_request.pull_request_state != PullRequest.STATE_CREATED:
298 299 raise JSONRPCError(
299 300 'Operation forbidden because pull request is in state {}, '
300 301 'only state {} is allowed.'.format(
301 302 pull_request.pull_request_state, PullRequest.STATE_CREATED))
302 303
303 304 with pull_request.set_state(PullRequest.STATE_UPDATING):
304 check = MergeCheck.validate(
305 pull_request, auth_user=apiuser,
306 translator=request.translate)
305 check = MergeCheck.validate(pull_request, auth_user=auth_user,
306 translator=request.translate)
307 307 merge_possible = not check.failed
308 308
309 309 if not merge_possible:
310 310 error_messages = []
311 311 for err_type, error_msg in check.errors:
312 312 error_msg = request.translate(error_msg)
313 313 error_messages.append(error_msg)
314 314
315 315 reasons = ','.join(error_messages)
316 316 raise JSONRPCError(
317 317 'merge not possible for following reasons: {}'.format(reasons))
318 318
319 319 target_repo = pull_request.target_repo
320 320 extras = vcs_operation_context(
321 321 request.environ, repo_name=target_repo.repo_name,
322 username=apiuser.username, action='push',
322 username=auth_user.username, action='push',
323 323 scm=target_repo.repo_type)
324 324 with pull_request.set_state(PullRequest.STATE_UPDATING):
325 325 merge_response = PullRequestModel().merge_repo(
326 326 pull_request, apiuser, extras=extras)
327 327 if merge_response.executed:
328 PullRequestModel().close_pull_request(
329 pull_request.pull_request_id, apiuser)
328 PullRequestModel().close_pull_request(pull_request.pull_request_id, auth_user)
330 329
331 330 Session().commit()
332 331
333 332 # In previous versions the merge response directly contained the merge
334 333 # commit id. It is now contained in the merge reference object. To be
335 334 # backwards compatible we have to extract it again.
336 335 merge_response = merge_response.asdict()
337 336 merge_response['merge_commit_id'] = merge_response['merge_ref'].commit_id
338 337
339 338 return merge_response
340 339
341 340
342 341 @jsonrpc_method()
343 342 def get_pull_request_comments(
344 343 request, apiuser, pullrequestid, repoid=Optional(None)):
345 344 """
346 345 Get all comments of pull request specified with the `pullrequestid`
347 346
348 347 :param apiuser: This is filled automatically from the |authtoken|.
349 348 :type apiuser: AuthUser
350 349 :param repoid: Optional repository name or repository ID.
351 350 :type repoid: str or int
352 351 :param pullrequestid: The pull request ID.
353 352 :type pullrequestid: int
354 353
355 354 Example output:
356 355
357 356 .. code-block:: bash
358 357
359 358 id : <id_given_in_input>
360 359 result : [
361 360 {
362 361 "comment_author": {
363 362 "active": true,
364 363 "full_name_or_username": "Tom Gore",
365 364 "username": "admin"
366 365 },
367 366 "comment_created_on": "2017-01-02T18:43:45.533",
368 367 "comment_f_path": null,
369 368 "comment_id": 25,
370 369 "comment_lineno": null,
371 370 "comment_status": {
372 371 "status": "under_review",
373 372 "status_lbl": "Under Review"
374 373 },
375 374 "comment_text": "Example text",
376 375 "comment_type": null,
377 376 "pull_request_version": null
378 377 }
379 378 ],
380 379 error : null
381 380 """
382 381
383 382 pull_request = get_pull_request_or_error(pullrequestid)
384 383 if Optional.extract(repoid):
385 384 repo = get_repo_or_error(repoid)
386 385 else:
387 386 repo = pull_request.target_repo
388 387
389 388 if not PullRequestModel().check_user_read(
390 389 pull_request, apiuser, api=True):
391 390 raise JSONRPCError('repository `%s` or pull request `%s` '
392 391 'does not exist' % (repoid, pullrequestid))
393 392
394 393 (pull_request_latest,
395 394 pull_request_at_ver,
396 395 pull_request_display_obj,
397 396 at_version) = PullRequestModel().get_pr_version(
398 397 pull_request.pull_request_id, version=None)
399 398
400 399 versions = pull_request_display_obj.versions()
401 400 ver_map = {
402 401 ver.pull_request_version_id: cnt
403 402 for cnt, ver in enumerate(versions, 1)
404 403 }
405 404
406 405 # GENERAL COMMENTS with versions #
407 406 q = CommentsModel()._all_general_comments_of_pull_request(pull_request)
408 407 q = q.order_by(ChangesetComment.comment_id.asc())
409 408 general_comments = q.all()
410 409
411 410 # INLINE COMMENTS with versions #
412 411 q = CommentsModel()._all_inline_comments_of_pull_request(pull_request)
413 412 q = q.order_by(ChangesetComment.comment_id.asc())
414 413 inline_comments = q.all()
415 414
416 415 data = []
417 416 for comment in inline_comments + general_comments:
418 417 full_data = comment.get_api_data()
419 418 pr_version_id = None
420 419 if comment.pull_request_version_id:
421 420 pr_version_id = 'v{}'.format(
422 421 ver_map[comment.pull_request_version_id])
423 422
424 423 # sanitize some entries
425 424
426 425 full_data['pull_request_version'] = pr_version_id
427 426 full_data['comment_author'] = {
428 427 'username': full_data['comment_author'].username,
429 428 'full_name_or_username': full_data['comment_author'].full_name_or_username,
430 429 'active': full_data['comment_author'].active,
431 430 }
432 431
433 432 if full_data['comment_status']:
434 433 full_data['comment_status'] = {
435 434 'status': full_data['comment_status'][0].status,
436 435 'status_lbl': full_data['comment_status'][0].status_lbl,
437 436 }
438 437 else:
439 438 full_data['comment_status'] = {}
440 439
441 440 data.append(full_data)
442 441 return data
443 442
444 443
445 444 @jsonrpc_method()
446 445 def comment_pull_request(
447 446 request, apiuser, pullrequestid, repoid=Optional(None),
448 447 message=Optional(None), commit_id=Optional(None), status=Optional(None),
449 448 comment_type=Optional(ChangesetComment.COMMENT_TYPE_NOTE),
450 449 resolves_comment_id=Optional(None),
451 450 userid=Optional(OAttr('apiuser'))):
452 451 """
453 452 Comment on the pull request specified with the `pullrequestid`,
454 453 in the |repo| specified by the `repoid`, and optionally change the
455 454 review status.
456 455
457 456 :param apiuser: This is filled automatically from the |authtoken|.
458 457 :type apiuser: AuthUser
459 458 :param repoid: Optional repository name or repository ID.
460 459 :type repoid: str or int
461 460 :param pullrequestid: The pull request ID.
462 461 :type pullrequestid: int
463 462 :param commit_id: Specify the commit_id for which to set a comment. If
464 463 given commit_id is different than latest in the PR status
465 464 change won't be performed.
466 465 :type commit_id: str
467 466 :param message: The text content of the comment.
468 467 :type message: str
469 468 :param status: (**Optional**) Set the approval status of the pull
470 469 request. One of: 'not_reviewed', 'approved', 'rejected',
471 470 'under_review'
472 471 :type status: str
473 472 :param comment_type: Comment type, one of: 'note', 'todo'
474 473 :type comment_type: Optional(str), default: 'note'
475 474 :param userid: Comment on the pull request as this user
476 475 :type userid: Optional(str or int)
477 476
478 477 Example output:
479 478
480 479 .. code-block:: bash
481 480
482 481 id : <id_given_in_input>
483 482 result : {
484 483 "pull_request_id": "<Integer>",
485 484 "comment_id": "<Integer>",
486 485 "status": {"given": <given_status>,
487 486 "was_changed": <bool status_was_actually_changed> },
488 487 },
489 488 error : null
490 489 """
491 490 pull_request = get_pull_request_or_error(pullrequestid)
492 491 if Optional.extract(repoid):
493 492 repo = get_repo_or_error(repoid)
494 493 else:
495 494 repo = pull_request.target_repo
496 495
496 auth_user = apiuser
497 497 if not isinstance(userid, Optional):
498 498 if (has_superadmin_permission(apiuser) or
499 499 HasRepoPermissionAnyApi('repository.admin')(
500 500 user=apiuser, repo_name=repo.repo_name)):
501 501 apiuser = get_user_or_error(userid)
502 auth_user = apiuser.AuthUser()
502 503 else:
503 504 raise JSONRPCError('userid is not the same as your user')
504 505
505 506 if pull_request.is_closed():
506 507 raise JSONRPCError(
507 508 'pull request `%s` comment failed, pull request is closed' % (
508 509 pullrequestid,))
509 510
510 511 if not PullRequestModel().check_user_read(
511 512 pull_request, apiuser, api=True):
512 513 raise JSONRPCError('repository `%s` does not exist' % (repoid,))
513 514 message = Optional.extract(message)
514 515 status = Optional.extract(status)
515 516 commit_id = Optional.extract(commit_id)
516 517 comment_type = Optional.extract(comment_type)
517 518 resolves_comment_id = Optional.extract(resolves_comment_id)
518 519
519 520 if not message and not status:
520 521 raise JSONRPCError(
521 522 'Both message and status parameters are missing. '
522 523 'At least one is required.')
523 524
524 525 if (status not in (st[0] for st in ChangesetStatus.STATUSES) and
525 526 status is not None):
526 527 raise JSONRPCError('Unknown comment status: `%s`' % status)
527 528
528 529 if commit_id and commit_id not in pull_request.revisions:
529 530 raise JSONRPCError(
530 531 'Invalid commit_id `%s` for this pull request.' % commit_id)
531 532
532 533 allowed_to_change_status = PullRequestModel().check_user_change_status(
533 534 pull_request, apiuser)
534 535
535 536 # if commit_id is passed re-validated if user is allowed to change status
536 537 # based on latest commit_id from the PR
537 538 if commit_id:
538 539 commit_idx = pull_request.revisions.index(commit_id)
539 540 if commit_idx != 0:
540 541 allowed_to_change_status = False
541 542
542 543 if resolves_comment_id:
543 544 comment = ChangesetComment.get(resolves_comment_id)
544 545 if not comment:
545 546 raise JSONRPCError(
546 547 'Invalid resolves_comment_id `%s` for this pull request.'
547 548 % resolves_comment_id)
548 549 if comment.comment_type != ChangesetComment.COMMENT_TYPE_TODO:
549 550 raise JSONRPCError(
550 551 'Comment `%s` is wrong type for setting status to resolved.'
551 552 % resolves_comment_id)
552 553
553 554 text = message
554 555 status_label = ChangesetStatus.get_status_lbl(status)
555 556 if status and allowed_to_change_status:
556 557 st_message = ('Status change %(transition_icon)s %(status)s'
557 558 % {'transition_icon': '>', 'status': status_label})
558 559 text = message or st_message
559 560
560 561 rc_config = SettingsModel().get_all_settings()
561 562 renderer = rc_config.get('rhodecode_markup_renderer', 'rst')
562 563
563 564 status_change = status and allowed_to_change_status
564 565 comment = CommentsModel().create(
565 566 text=text,
566 567 repo=pull_request.target_repo.repo_id,
567 568 user=apiuser.user_id,
568 569 pull_request=pull_request.pull_request_id,
569 570 f_path=None,
570 571 line_no=None,
571 572 status_change=(status_label if status_change else None),
572 573 status_change_type=(status if status_change else None),
573 574 closing_pr=False,
574 575 renderer=renderer,
575 576 comment_type=comment_type,
576 577 resolves_comment_id=resolves_comment_id,
577 auth_user=apiuser
578 auth_user=auth_user
578 579 )
579 580
580 581 if allowed_to_change_status and status:
581 582 old_calculated_status = pull_request.calculated_review_status()
582 583 ChangesetStatusModel().set_status(
583 584 pull_request.target_repo.repo_id,
584 585 status,
585 586 apiuser.user_id,
586 587 comment,
587 588 pull_request=pull_request.pull_request_id
588 589 )
589 590 Session().flush()
590 591
591 592 Session().commit()
592 593
593 594 PullRequestModel().trigger_pull_request_hook(
594 595 pull_request, apiuser, 'comment',
595 596 data={'comment': comment})
596 597
597 598 if allowed_to_change_status and status:
598 599 # we now calculate the status of pull request, and based on that
599 600 # calculation we set the commits status
600 601 calculated_status = pull_request.calculated_review_status()
601 602 if old_calculated_status != calculated_status:
602 603 PullRequestModel().trigger_pull_request_hook(
603 604 pull_request, apiuser, 'review_status_change',
604 605 data={'status': calculated_status})
605 606
606 607 data = {
607 608 'pull_request_id': pull_request.pull_request_id,
608 609 'comment_id': comment.comment_id if comment else None,
609 610 'status': {'given': status, 'was_changed': status_change},
610 611 }
611 612 return data
612 613
613 614
614 615 @jsonrpc_method()
615 616 def create_pull_request(
616 617 request, apiuser, source_repo, target_repo, source_ref, target_ref,
617 618 owner=Optional(OAttr('apiuser')), title=Optional(''), description=Optional(''),
618 619 description_renderer=Optional(''), reviewers=Optional(None)):
619 620 """
620 621 Creates a new pull request.
621 622
622 623 Accepts refs in the following formats:
623 624
624 625 * branch:<branch_name>:<sha>
625 626 * branch:<branch_name>
626 627 * bookmark:<bookmark_name>:<sha> (Mercurial only)
627 628 * bookmark:<bookmark_name> (Mercurial only)
628 629
629 630 :param apiuser: This is filled automatically from the |authtoken|.
630 631 :type apiuser: AuthUser
631 632 :param source_repo: Set the source repository name.
632 633 :type source_repo: str
633 634 :param target_repo: Set the target repository name.
634 635 :type target_repo: str
635 636 :param source_ref: Set the source ref name.
636 637 :type source_ref: str
637 638 :param target_ref: Set the target ref name.
638 639 :type target_ref: str
639 640 :param owner: user_id or username
640 641 :type owner: Optional(str)
641 642 :param title: Optionally Set the pull request title, it's generated otherwise
642 643 :type title: str
643 644 :param description: Set the pull request description.
644 645 :type description: Optional(str)
645 646 :type description_renderer: Optional(str)
646 647 :param description_renderer: Set pull request renderer for the description.
647 648 It should be 'rst', 'markdown' or 'plain'. If not give default
648 649 system renderer will be used
649 650 :param reviewers: Set the new pull request reviewers list.
650 651 Reviewer defined by review rules will be added automatically to the
651 652 defined list.
652 653 :type reviewers: Optional(list)
653 654 Accepts username strings or objects of the format:
654 655
655 656 [{'username': 'nick', 'reasons': ['original author'], 'mandatory': <bool>}]
656 657 """
657 658
658 659 source_db_repo = get_repo_or_error(source_repo)
659 660 target_db_repo = get_repo_or_error(target_repo)
660 661 if not has_superadmin_permission(apiuser):
661 662 _perms = ('repository.admin', 'repository.write', 'repository.read',)
662 663 validate_repo_permissions(apiuser, source_repo, source_db_repo, _perms)
663 664
664 665 owner = validate_set_owner_permissions(apiuser, owner)
665 666
666 667 full_source_ref = resolve_ref_or_error(source_ref, source_db_repo)
667 668 full_target_ref = resolve_ref_or_error(target_ref, target_db_repo)
668 669
669 670 source_scm = source_db_repo.scm_instance()
670 671 target_scm = target_db_repo.scm_instance()
671 672
672 673 source_commit = get_commit_or_error(full_source_ref, source_db_repo)
673 674 target_commit = get_commit_or_error(full_target_ref, target_db_repo)
674 675
675 676 ancestor = source_scm.get_common_ancestor(
676 677 source_commit.raw_id, target_commit.raw_id, target_scm)
677 678 if not ancestor:
678 679 raise JSONRPCError('no common ancestor found')
679 680
680 681 # recalculate target ref based on ancestor
681 682 target_ref_type, target_ref_name, __ = full_target_ref.split(':')
682 683 full_target_ref = ':'.join((target_ref_type, target_ref_name, ancestor))
683 684
684 685 commit_ranges = target_scm.compare(
685 686 target_commit.raw_id, source_commit.raw_id, source_scm,
686 687 merge=True, pre_load=[])
687 688
688 689 if not commit_ranges:
689 690 raise JSONRPCError('no commits found')
690 691
691 692 reviewer_objects = Optional.extract(reviewers) or []
692 693
693 694 # serialize and validate passed in given reviewers
694 695 if reviewer_objects:
695 696 schema = ReviewerListSchema()
696 697 try:
697 698 reviewer_objects = schema.deserialize(reviewer_objects)
698 699 except Invalid as err:
699 700 raise JSONRPCValidationError(colander_exc=err)
700 701
701 702 # validate users
702 703 for reviewer_object in reviewer_objects:
703 704 user = get_user_or_error(reviewer_object['username'])
704 705 reviewer_object['user_id'] = user.user_id
705 706
706 707 get_default_reviewers_data, validate_default_reviewers = \
707 708 PullRequestModel().get_reviewer_functions()
708 709
709 710 # recalculate reviewers logic, to make sure we can validate this
710 711 reviewer_rules = get_default_reviewers_data(
711 712 owner, source_db_repo,
712 713 source_commit, target_db_repo, target_commit)
713 714
714 715 # now MERGE our given with the calculated
715 716 reviewer_objects = reviewer_rules['reviewers'] + reviewer_objects
716 717
717 718 try:
718 719 reviewers = validate_default_reviewers(
719 720 reviewer_objects, reviewer_rules)
720 721 except ValueError as e:
721 722 raise JSONRPCError('Reviewers Validation: {}'.format(e))
722 723
723 724 title = Optional.extract(title)
724 725 if not title:
725 726 title_source_ref = source_ref.split(':', 2)[1]
726 727 title = PullRequestModel().generate_pullrequest_title(
727 728 source=source_repo,
728 729 source_ref=title_source_ref,
729 730 target=target_repo
730 731 )
731 732 # fetch renderer, if set fallback to plain in case of PR
732 733 rc_config = SettingsModel().get_all_settings()
733 734 default_system_renderer = rc_config.get('rhodecode_markup_renderer', 'plain')
734 735 description = Optional.extract(description)
735 736 description_renderer = Optional.extract(description_renderer) or default_system_renderer
736 737
737 738 pull_request = PullRequestModel().create(
738 739 created_by=owner.user_id,
739 740 source_repo=source_repo,
740 741 source_ref=full_source_ref,
741 742 target_repo=target_repo,
742 743 target_ref=full_target_ref,
743 744 revisions=[commit.raw_id for commit in reversed(commit_ranges)],
744 745 reviewers=reviewers,
745 746 title=title,
746 747 description=description,
747 748 description_renderer=description_renderer,
748 749 reviewer_data=reviewer_rules,
749 750 auth_user=apiuser
750 751 )
751 752
752 753 Session().commit()
753 754 data = {
754 755 'msg': 'Created new pull request `{}`'.format(title),
755 756 'pull_request_id': pull_request.pull_request_id,
756 757 }
757 758 return data
758 759
759 760
760 761 @jsonrpc_method()
761 762 def update_pull_request(
762 763 request, apiuser, pullrequestid, repoid=Optional(None),
763 764 title=Optional(''), description=Optional(''), description_renderer=Optional(''),
764 765 reviewers=Optional(None), update_commits=Optional(None)):
765 766 """
766 767 Updates a pull request.
767 768
768 769 :param apiuser: This is filled automatically from the |authtoken|.
769 770 :type apiuser: AuthUser
770 771 :param repoid: Optional repository name or repository ID.
771 772 :type repoid: str or int
772 773 :param pullrequestid: The pull request ID.
773 774 :type pullrequestid: int
774 775 :param title: Set the pull request title.
775 776 :type title: str
776 777 :param description: Update pull request description.
777 778 :type description: Optional(str)
778 779 :type description_renderer: Optional(str)
779 780 :param description_renderer: Update pull request renderer for the description.
780 781 It should be 'rst', 'markdown' or 'plain'
781 782 :param reviewers: Update pull request reviewers list with new value.
782 783 :type reviewers: Optional(list)
783 784 Accepts username strings or objects of the format:
784 785
785 786 [{'username': 'nick', 'reasons': ['original author'], 'mandatory': <bool>}]
786 787
787 788 :param update_commits: Trigger update of commits for this pull request
788 789 :type: update_commits: Optional(bool)
789 790
790 791 Example output:
791 792
792 793 .. code-block:: bash
793 794
794 795 id : <id_given_in_input>
795 796 result : {
796 797 "msg": "Updated pull request `63`",
797 798 "pull_request": <pull_request_object>,
798 799 "updated_reviewers": {
799 800 "added": [
800 801 "username"
801 802 ],
802 803 "removed": []
803 804 },
804 805 "updated_commits": {
805 806 "added": [
806 807 "<sha1_hash>"
807 808 ],
808 809 "common": [
809 810 "<sha1_hash>",
810 811 "<sha1_hash>",
811 812 ],
812 813 "removed": []
813 814 }
814 815 }
815 816 error : null
816 817 """
817 818
818 819 pull_request = get_pull_request_or_error(pullrequestid)
819 820 if Optional.extract(repoid):
820 821 repo = get_repo_or_error(repoid)
821 822 else:
822 823 repo = pull_request.target_repo
823 824
824 825 if not PullRequestModel().check_user_update(
825 826 pull_request, apiuser, api=True):
826 827 raise JSONRPCError(
827 828 'pull request `%s` update failed, no permission to update.' % (
828 829 pullrequestid,))
829 830 if pull_request.is_closed():
830 831 raise JSONRPCError(
831 832 'pull request `%s` update failed, pull request is closed' % (
832 833 pullrequestid,))
833 834
834 835 reviewer_objects = Optional.extract(reviewers) or []
835 836
836 837 if reviewer_objects:
837 838 schema = ReviewerListSchema()
838 839 try:
839 840 reviewer_objects = schema.deserialize(reviewer_objects)
840 841 except Invalid as err:
841 842 raise JSONRPCValidationError(colander_exc=err)
842 843
843 844 # validate users
844 845 for reviewer_object in reviewer_objects:
845 846 user = get_user_or_error(reviewer_object['username'])
846 847 reviewer_object['user_id'] = user.user_id
847 848
848 849 get_default_reviewers_data, get_validated_reviewers = \
849 850 PullRequestModel().get_reviewer_functions()
850 851
851 852 # re-use stored rules
852 853 reviewer_rules = pull_request.reviewer_data
853 854 try:
854 855 reviewers = get_validated_reviewers(
855 856 reviewer_objects, reviewer_rules)
856 857 except ValueError as e:
857 858 raise JSONRPCError('Reviewers Validation: {}'.format(e))
858 859 else:
859 860 reviewers = []
860 861
861 862 title = Optional.extract(title)
862 863 description = Optional.extract(description)
863 864 description_renderer = Optional.extract(description_renderer)
864 865
865 866 if title or description:
866 867 PullRequestModel().edit(
867 868 pull_request,
868 869 title or pull_request.title,
869 870 description or pull_request.description,
870 871 description_renderer or pull_request.description_renderer,
871 872 apiuser)
872 873 Session().commit()
873 874
874 875 commit_changes = {"added": [], "common": [], "removed": []}
875 876 if str2bool(Optional.extract(update_commits)):
876 877
877 878 if pull_request.pull_request_state != PullRequest.STATE_CREATED:
878 879 raise JSONRPCError(
879 880 'Operation forbidden because pull request is in state {}, '
880 881 'only state {} is allowed.'.format(
881 882 pull_request.pull_request_state, PullRequest.STATE_CREATED))
882 883
883 884 with pull_request.set_state(PullRequest.STATE_UPDATING):
884 885 if PullRequestModel().has_valid_update_type(pull_request):
885 886 update_response = PullRequestModel().update_commits(pull_request)
886 887 commit_changes = update_response.changes or commit_changes
887 888 Session().commit()
888 889
889 890 reviewers_changes = {"added": [], "removed": []}
890 891 if reviewers:
891 892 old_calculated_status = pull_request.calculated_review_status()
892 893 added_reviewers, removed_reviewers = \
893 894 PullRequestModel().update_reviewers(pull_request, reviewers, apiuser)
894 895
895 896 reviewers_changes['added'] = sorted(
896 897 [get_user_or_error(n).username for n in added_reviewers])
897 898 reviewers_changes['removed'] = sorted(
898 899 [get_user_or_error(n).username for n in removed_reviewers])
899 900 Session().commit()
900 901
901 902 # trigger status changed if change in reviewers changes the status
902 903 calculated_status = pull_request.calculated_review_status()
903 904 if old_calculated_status != calculated_status:
904 905 PullRequestModel().trigger_pull_request_hook(
905 906 pull_request, apiuser, 'review_status_change',
906 907 data={'status': calculated_status})
907 908
908 909 data = {
909 910 'msg': 'Updated pull request `{}`'.format(
910 911 pull_request.pull_request_id),
911 912 'pull_request': pull_request.get_api_data(),
912 913 'updated_commits': commit_changes,
913 914 'updated_reviewers': reviewers_changes
914 915 }
915 916
916 917 return data
917 918
918 919
919 920 @jsonrpc_method()
920 921 def close_pull_request(
921 922 request, apiuser, pullrequestid, repoid=Optional(None),
922 923 userid=Optional(OAttr('apiuser')), message=Optional('')):
923 924 """
924 925 Close the pull request specified by `pullrequestid`.
925 926
926 927 :param apiuser: This is filled automatically from the |authtoken|.
927 928 :type apiuser: AuthUser
928 929 :param repoid: Repository name or repository ID to which the pull
929 930 request belongs.
930 931 :type repoid: str or int
931 932 :param pullrequestid: ID of the pull request to be closed.
932 933 :type pullrequestid: int
933 934 :param userid: Close the pull request as this user.
934 935 :type userid: Optional(str or int)
935 936 :param message: Optional message to close the Pull Request with. If not
936 937 specified it will be generated automatically.
937 938 :type message: Optional(str)
938 939
939 940 Example output:
940 941
941 942 .. code-block:: bash
942 943
943 944 "id": <id_given_in_input>,
944 945 "result": {
945 946 "pull_request_id": "<int>",
946 947 "close_status": "<str:status_lbl>,
947 948 "closed": "<bool>"
948 949 },
949 950 "error": null
950 951
951 952 """
952 953 _ = request.translate
953 954
954 955 pull_request = get_pull_request_or_error(pullrequestid)
955 956 if Optional.extract(repoid):
956 957 repo = get_repo_or_error(repoid)
957 958 else:
958 959 repo = pull_request.target_repo
959 960
960 961 if not isinstance(userid, Optional):
961 962 if (has_superadmin_permission(apiuser) or
962 963 HasRepoPermissionAnyApi('repository.admin')(
963 964 user=apiuser, repo_name=repo.repo_name)):
964 965 apiuser = get_user_or_error(userid)
965 966 else:
966 967 raise JSONRPCError('userid is not the same as your user')
967 968
968 969 if pull_request.is_closed():
969 970 raise JSONRPCError(
970 971 'pull request `%s` is already closed' % (pullrequestid,))
971 972
972 973 # only owner or admin or person with write permissions
973 974 allowed_to_close = PullRequestModel().check_user_update(
974 975 pull_request, apiuser, api=True)
975 976
976 977 if not allowed_to_close:
977 978 raise JSONRPCError(
978 979 'pull request `%s` close failed, no permission to close.' % (
979 980 pullrequestid,))
980 981
981 982 # message we're using to close the PR, else it's automatically generated
982 983 message = Optional.extract(message)
983 984
984 985 # finally close the PR, with proper message comment
985 986 comment, status = PullRequestModel().close_pull_request_with_comment(
986 987 pull_request, apiuser, repo, message=message, auth_user=apiuser)
987 988 status_lbl = ChangesetStatus.get_status_lbl(status)
988 989
989 990 Session().commit()
990 991
991 992 data = {
992 993 'pull_request_id': pull_request.pull_request_id,
993 994 'close_status': status_lbl,
994 995 'closed': True,
995 996 }
996 997 return data
General Comments 0
You need to be logged in to leave comments. Login now