##// END OF EJS Templates
api: expose merge message in the merge operation
marcink -
r3458:a818b875 default
parent child Browse files
Show More
@@ -1,157 +1,158 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 'merge_status_message': 'This pull request can be automatically merged.',
112 113 'possible': True,
113 114 'merge_commit_id': pull_request.shadow_merge_ref.commit_id,
114 115 'merge_ref': pull_request.shadow_merge_ref._asdict()
115 116 }
116 117
117 118 assert_ok(id_, expected, response.body)
118 119
119 120 journal = UserLog.query()\
120 121 .filter(UserLog.user_id == author)\
121 122 .filter(UserLog.repository_id == repo) \
122 123 .order_by('user_log_id') \
123 124 .all()
124 125 assert journal[-2].action == 'repo.pull_request.merge'
125 126 assert journal[-1].action == 'repo.pull_request.close'
126 127
127 128 id_, params = build_data(
128 129 self.apikey, 'merge_pull_request',
129 130 repoid=pull_request_repo, pullrequestid=pull_request_id)
130 131 response = api_call(self.app, params)
131 132
132 133 expected = 'merge not possible for following reasons: This pull request is closed.'
133 134 assert_error(id_, expected, given=response.body)
134 135
135 136 @pytest.mark.backends("git", "hg")
136 137 def test_api_merge_pull_request_repo_error(self, pr_util):
137 138 pull_request = pr_util.create_pull_request()
138 139 id_, params = build_data(
139 140 self.apikey, 'merge_pull_request',
140 141 repoid=666, pullrequestid=pull_request.pull_request_id)
141 142 response = api_call(self.app, params)
142 143
143 144 expected = 'repository `666` does not exist'
144 145 assert_error(id_, expected, given=response.body)
145 146
146 147 @pytest.mark.backends("git", "hg")
147 148 def test_api_merge_pull_request_non_admin_with_userid_error(self, pr_util):
148 149 pull_request = pr_util.create_pull_request(mergeable=True)
149 150 id_, params = build_data(
150 151 self.apikey_regular, 'merge_pull_request',
151 152 repoid=pull_request.target_repo.repo_name,
152 153 pullrequestid=pull_request.pull_request_id,
153 154 userid=TEST_USER_ADMIN_LOGIN)
154 155 response = api_call(self.app, params)
155 156
156 157 expected = 'userid is not the same as your user'
157 158 assert_error(id_, expected, given=response.body)
@@ -1,986 +1,987 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)
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 "executed": "<bool>",
271 "failure_reason": "<int>",
272 "merge_commit_id": "<merge_commit_id>",
273 "possible": "<bool>",
270 "executed": "<bool>",
271 "failure_reason": "<int>",
272 "merge_status_message": "<str>",
273 "merge_commit_id": "<merge_commit_id>",
274 "possible": "<bool>",
274 275 "merge_ref": {
275 276 "commit_id": "<commit_id>",
276 277 "type": "<type>",
277 278 "name": "<name>"
278 279 }
279 280 },
280 281 "error": null
281 282 """
282 283 pull_request = get_pull_request_or_error(pullrequestid)
283 284 if Optional.extract(repoid):
284 285 repo = get_repo_or_error(repoid)
285 286 else:
286 287 repo = pull_request.target_repo
287 288
288 289 if not isinstance(userid, Optional):
289 290 if (has_superadmin_permission(apiuser) or
290 291 HasRepoPermissionAnyApi('repository.admin')(
291 292 user=apiuser, repo_name=repo.repo_name)):
292 293 apiuser = get_user_or_error(userid)
293 294 else:
294 295 raise JSONRPCError('userid is not the same as your user')
295 296
296 297 if pull_request.pull_request_state != PullRequest.STATE_CREATED:
297 298 raise JSONRPCError(
298 299 'Operation forbidden because pull request is in state {}, '
299 300 'only state {} is allowed.'.format(
300 301 pull_request.pull_request_state, PullRequest.STATE_CREATED))
301 302
302 303 with pull_request.set_state(PullRequest.STATE_UPDATING):
303 304 check = MergeCheck.validate(
304 305 pull_request, auth_user=apiuser,
305 306 translator=request.translate)
306 307 merge_possible = not check.failed
307 308
308 309 if not merge_possible:
309 310 error_messages = []
310 311 for err_type, error_msg in check.errors:
311 312 error_msg = request.translate(error_msg)
312 313 error_messages.append(error_msg)
313 314
314 315 reasons = ','.join(error_messages)
315 316 raise JSONRPCError(
316 317 'merge not possible for following reasons: {}'.format(reasons))
317 318
318 319 target_repo = pull_request.target_repo
319 320 extras = vcs_operation_context(
320 321 request.environ, repo_name=target_repo.repo_name,
321 322 username=apiuser.username, action='push',
322 323 scm=target_repo.repo_type)
323 324 with pull_request.set_state(PullRequest.STATE_UPDATING):
324 325 merge_response = PullRequestModel().merge_repo(
325 326 pull_request, apiuser, extras=extras)
326 327 if merge_response.executed:
327 328 PullRequestModel().close_pull_request(
328 329 pull_request.pull_request_id, apiuser)
329 330
330 331 Session().commit()
331 332
332 333 # In previous versions the merge response directly contained the merge
333 334 # commit id. It is now contained in the merge reference object. To be
334 335 # backwards compatible we have to extract it again.
335 336 merge_response = merge_response.asdict()
336 337 merge_response['merge_commit_id'] = merge_response['merge_ref'].commit_id
337 338
338 339 return merge_response
339 340
340 341
341 342 @jsonrpc_method()
342 343 def get_pull_request_comments(
343 344 request, apiuser, pullrequestid, repoid=Optional(None)):
344 345 """
345 346 Get all comments of pull request specified with the `pullrequestid`
346 347
347 348 :param apiuser: This is filled automatically from the |authtoken|.
348 349 :type apiuser: AuthUser
349 350 :param repoid: Optional repository name or repository ID.
350 351 :type repoid: str or int
351 352 :param pullrequestid: The pull request ID.
352 353 :type pullrequestid: int
353 354
354 355 Example output:
355 356
356 357 .. code-block:: bash
357 358
358 359 id : <id_given_in_input>
359 360 result : [
360 361 {
361 362 "comment_author": {
362 363 "active": true,
363 364 "full_name_or_username": "Tom Gore",
364 365 "username": "admin"
365 366 },
366 367 "comment_created_on": "2017-01-02T18:43:45.533",
367 368 "comment_f_path": null,
368 369 "comment_id": 25,
369 370 "comment_lineno": null,
370 371 "comment_status": {
371 372 "status": "under_review",
372 373 "status_lbl": "Under Review"
373 374 },
374 375 "comment_text": "Example text",
375 376 "comment_type": null,
376 377 "pull_request_version": null
377 378 }
378 379 ],
379 380 error : null
380 381 """
381 382
382 383 pull_request = get_pull_request_or_error(pullrequestid)
383 384 if Optional.extract(repoid):
384 385 repo = get_repo_or_error(repoid)
385 386 else:
386 387 repo = pull_request.target_repo
387 388
388 389 if not PullRequestModel().check_user_read(
389 390 pull_request, apiuser, api=True):
390 391 raise JSONRPCError('repository `%s` or pull request `%s` '
391 392 'does not exist' % (repoid, pullrequestid))
392 393
393 394 (pull_request_latest,
394 395 pull_request_at_ver,
395 396 pull_request_display_obj,
396 397 at_version) = PullRequestModel().get_pr_version(
397 398 pull_request.pull_request_id, version=None)
398 399
399 400 versions = pull_request_display_obj.versions()
400 401 ver_map = {
401 402 ver.pull_request_version_id: cnt
402 403 for cnt, ver in enumerate(versions, 1)
403 404 }
404 405
405 406 # GENERAL COMMENTS with versions #
406 407 q = CommentsModel()._all_general_comments_of_pull_request(pull_request)
407 408 q = q.order_by(ChangesetComment.comment_id.asc())
408 409 general_comments = q.all()
409 410
410 411 # INLINE COMMENTS with versions #
411 412 q = CommentsModel()._all_inline_comments_of_pull_request(pull_request)
412 413 q = q.order_by(ChangesetComment.comment_id.asc())
413 414 inline_comments = q.all()
414 415
415 416 data = []
416 417 for comment in inline_comments + general_comments:
417 418 full_data = comment.get_api_data()
418 419 pr_version_id = None
419 420 if comment.pull_request_version_id:
420 421 pr_version_id = 'v{}'.format(
421 422 ver_map[comment.pull_request_version_id])
422 423
423 424 # sanitize some entries
424 425
425 426 full_data['pull_request_version'] = pr_version_id
426 427 full_data['comment_author'] = {
427 428 'username': full_data['comment_author'].username,
428 429 'full_name_or_username': full_data['comment_author'].full_name_or_username,
429 430 'active': full_data['comment_author'].active,
430 431 }
431 432
432 433 if full_data['comment_status']:
433 434 full_data['comment_status'] = {
434 435 'status': full_data['comment_status'][0].status,
435 436 'status_lbl': full_data['comment_status'][0].status_lbl,
436 437 }
437 438 else:
438 439 full_data['comment_status'] = {}
439 440
440 441 data.append(full_data)
441 442 return data
442 443
443 444
444 445 @jsonrpc_method()
445 446 def comment_pull_request(
446 447 request, apiuser, pullrequestid, repoid=Optional(None),
447 448 message=Optional(None), commit_id=Optional(None), status=Optional(None),
448 449 comment_type=Optional(ChangesetComment.COMMENT_TYPE_NOTE),
449 450 resolves_comment_id=Optional(None),
450 451 userid=Optional(OAttr('apiuser'))):
451 452 """
452 453 Comment on the pull request specified with the `pullrequestid`,
453 454 in the |repo| specified by the `repoid`, and optionally change the
454 455 review status.
455 456
456 457 :param apiuser: This is filled automatically from the |authtoken|.
457 458 :type apiuser: AuthUser
458 459 :param repoid: Optional repository name or repository ID.
459 460 :type repoid: str or int
460 461 :param pullrequestid: The pull request ID.
461 462 :type pullrequestid: int
462 463 :param commit_id: Specify the commit_id for which to set a comment. If
463 464 given commit_id is different than latest in the PR status
464 465 change won't be performed.
465 466 :type commit_id: str
466 467 :param message: The text content of the comment.
467 468 :type message: str
468 469 :param status: (**Optional**) Set the approval status of the pull
469 470 request. One of: 'not_reviewed', 'approved', 'rejected',
470 471 'under_review'
471 472 :type status: str
472 473 :param comment_type: Comment type, one of: 'note', 'todo'
473 474 :type comment_type: Optional(str), default: 'note'
474 475 :param userid: Comment on the pull request as this user
475 476 :type userid: Optional(str or int)
476 477
477 478 Example output:
478 479
479 480 .. code-block:: bash
480 481
481 482 id : <id_given_in_input>
482 483 result : {
483 484 "pull_request_id": "<Integer>",
484 485 "comment_id": "<Integer>",
485 486 "status": {"given": <given_status>,
486 487 "was_changed": <bool status_was_actually_changed> },
487 488 },
488 489 error : null
489 490 """
490 491 pull_request = get_pull_request_or_error(pullrequestid)
491 492 if Optional.extract(repoid):
492 493 repo = get_repo_or_error(repoid)
493 494 else:
494 495 repo = pull_request.target_repo
495 496
496 497 if not isinstance(userid, Optional):
497 498 if (has_superadmin_permission(apiuser) or
498 499 HasRepoPermissionAnyApi('repository.admin')(
499 500 user=apiuser, repo_name=repo.repo_name)):
500 501 apiuser = get_user_or_error(userid)
501 502 else:
502 503 raise JSONRPCError('userid is not the same as your user')
503 504
504 505 if not PullRequestModel().check_user_read(
505 506 pull_request, apiuser, api=True):
506 507 raise JSONRPCError('repository `%s` does not exist' % (repoid,))
507 508 message = Optional.extract(message)
508 509 status = Optional.extract(status)
509 510 commit_id = Optional.extract(commit_id)
510 511 comment_type = Optional.extract(comment_type)
511 512 resolves_comment_id = Optional.extract(resolves_comment_id)
512 513
513 514 if not message and not status:
514 515 raise JSONRPCError(
515 516 'Both message and status parameters are missing. '
516 517 'At least one is required.')
517 518
518 519 if (status not in (st[0] for st in ChangesetStatus.STATUSES) and
519 520 status is not None):
520 521 raise JSONRPCError('Unknown comment status: `%s`' % status)
521 522
522 523 if commit_id and commit_id not in pull_request.revisions:
523 524 raise JSONRPCError(
524 525 'Invalid commit_id `%s` for this pull request.' % commit_id)
525 526
526 527 allowed_to_change_status = PullRequestModel().check_user_change_status(
527 528 pull_request, apiuser)
528 529
529 530 # if commit_id is passed re-validated if user is allowed to change status
530 531 # based on latest commit_id from the PR
531 532 if commit_id:
532 533 commit_idx = pull_request.revisions.index(commit_id)
533 534 if commit_idx != 0:
534 535 allowed_to_change_status = False
535 536
536 537 if resolves_comment_id:
537 538 comment = ChangesetComment.get(resolves_comment_id)
538 539 if not comment:
539 540 raise JSONRPCError(
540 541 'Invalid resolves_comment_id `%s` for this pull request.'
541 542 % resolves_comment_id)
542 543 if comment.comment_type != ChangesetComment.COMMENT_TYPE_TODO:
543 544 raise JSONRPCError(
544 545 'Comment `%s` is wrong type for setting status to resolved.'
545 546 % resolves_comment_id)
546 547
547 548 text = message
548 549 status_label = ChangesetStatus.get_status_lbl(status)
549 550 if status and allowed_to_change_status:
550 551 st_message = ('Status change %(transition_icon)s %(status)s'
551 552 % {'transition_icon': '>', 'status': status_label})
552 553 text = message or st_message
553 554
554 555 rc_config = SettingsModel().get_all_settings()
555 556 renderer = rc_config.get('rhodecode_markup_renderer', 'rst')
556 557
557 558 status_change = status and allowed_to_change_status
558 559 comment = CommentsModel().create(
559 560 text=text,
560 561 repo=pull_request.target_repo.repo_id,
561 562 user=apiuser.user_id,
562 563 pull_request=pull_request.pull_request_id,
563 564 f_path=None,
564 565 line_no=None,
565 566 status_change=(status_label if status_change else None),
566 567 status_change_type=(status if status_change else None),
567 568 closing_pr=False,
568 569 renderer=renderer,
569 570 comment_type=comment_type,
570 571 resolves_comment_id=resolves_comment_id,
571 572 auth_user=apiuser
572 573 )
573 574
574 575 if allowed_to_change_status and status:
575 576 old_calculated_status = pull_request.calculated_review_status()
576 577 ChangesetStatusModel().set_status(
577 578 pull_request.target_repo.repo_id,
578 579 status,
579 580 apiuser.user_id,
580 581 comment,
581 582 pull_request=pull_request.pull_request_id
582 583 )
583 584 Session().flush()
584 585
585 586 Session().commit()
586 587
587 588 PullRequestModel().trigger_pull_request_hook(
588 589 pull_request, apiuser, 'comment',
589 590 data={'comment': comment})
590 591
591 592 if allowed_to_change_status and status:
592 593 # we now calculate the status of pull request, and based on that
593 594 # calculation we set the commits status
594 595 calculated_status = pull_request.calculated_review_status()
595 596 if old_calculated_status != calculated_status:
596 597 PullRequestModel().trigger_pull_request_hook(
597 598 pull_request, apiuser, 'review_status_change',
598 599 data={'status': calculated_status})
599 600
600 601 data = {
601 602 'pull_request_id': pull_request.pull_request_id,
602 603 'comment_id': comment.comment_id if comment else None,
603 604 'status': {'given': status, 'was_changed': status_change},
604 605 }
605 606 return data
606 607
607 608
608 609 @jsonrpc_method()
609 610 def create_pull_request(
610 611 request, apiuser, source_repo, target_repo, source_ref, target_ref,
611 612 title=Optional(''), description=Optional(''), description_renderer=Optional(''),
612 613 reviewers=Optional(None)):
613 614 """
614 615 Creates a new pull request.
615 616
616 617 Accepts refs in the following formats:
617 618
618 619 * branch:<branch_name>:<sha>
619 620 * branch:<branch_name>
620 621 * bookmark:<bookmark_name>:<sha> (Mercurial only)
621 622 * bookmark:<bookmark_name> (Mercurial only)
622 623
623 624 :param apiuser: This is filled automatically from the |authtoken|.
624 625 :type apiuser: AuthUser
625 626 :param source_repo: Set the source repository name.
626 627 :type source_repo: str
627 628 :param target_repo: Set the target repository name.
628 629 :type target_repo: str
629 630 :param source_ref: Set the source ref name.
630 631 :type source_ref: str
631 632 :param target_ref: Set the target ref name.
632 633 :type target_ref: str
633 634 :param title: Optionally Set the pull request title, it's generated otherwise
634 635 :type title: str
635 636 :param description: Set the pull request description.
636 637 :type description: Optional(str)
637 638 :type description_renderer: Optional(str)
638 639 :param description_renderer: Set pull request renderer for the description.
639 640 It should be 'rst', 'markdown' or 'plain'. If not give default
640 641 system renderer will be used
641 642 :param reviewers: Set the new pull request reviewers list.
642 643 Reviewer defined by review rules will be added automatically to the
643 644 defined list.
644 645 :type reviewers: Optional(list)
645 646 Accepts username strings or objects of the format:
646 647
647 648 [{'username': 'nick', 'reasons': ['original author'], 'mandatory': <bool>}]
648 649 """
649 650
650 651 source_db_repo = get_repo_or_error(source_repo)
651 652 target_db_repo = get_repo_or_error(target_repo)
652 653 if not has_superadmin_permission(apiuser):
653 654 _perms = ('repository.admin', 'repository.write', 'repository.read',)
654 655 validate_repo_permissions(apiuser, source_repo, source_db_repo, _perms)
655 656
656 657 full_source_ref = resolve_ref_or_error(source_ref, source_db_repo)
657 658 full_target_ref = resolve_ref_or_error(target_ref, target_db_repo)
658 659
659 660 source_scm = source_db_repo.scm_instance()
660 661 target_scm = target_db_repo.scm_instance()
661 662
662 663 source_commit = get_commit_or_error(full_source_ref, source_db_repo)
663 664 target_commit = get_commit_or_error(full_target_ref, target_db_repo)
664 665
665 666 ancestor = source_scm.get_common_ancestor(
666 667 source_commit.raw_id, target_commit.raw_id, target_scm)
667 668 if not ancestor:
668 669 raise JSONRPCError('no common ancestor found')
669 670
670 671 # recalculate target ref based on ancestor
671 672 target_ref_type, target_ref_name, __ = full_target_ref.split(':')
672 673 full_target_ref = ':'.join((target_ref_type, target_ref_name, ancestor))
673 674
674 675 commit_ranges = target_scm.compare(
675 676 target_commit.raw_id, source_commit.raw_id, source_scm,
676 677 merge=True, pre_load=[])
677 678
678 679 if not commit_ranges:
679 680 raise JSONRPCError('no commits found')
680 681
681 682 reviewer_objects = Optional.extract(reviewers) or []
682 683
683 684 # serialize and validate passed in given reviewers
684 685 if reviewer_objects:
685 686 schema = ReviewerListSchema()
686 687 try:
687 688 reviewer_objects = schema.deserialize(reviewer_objects)
688 689 except Invalid as err:
689 690 raise JSONRPCValidationError(colander_exc=err)
690 691
691 692 # validate users
692 693 for reviewer_object in reviewer_objects:
693 694 user = get_user_or_error(reviewer_object['username'])
694 695 reviewer_object['user_id'] = user.user_id
695 696
696 697 get_default_reviewers_data, validate_default_reviewers = \
697 698 PullRequestModel().get_reviewer_functions()
698 699
699 700 # recalculate reviewers logic, to make sure we can validate this
700 701 reviewer_rules = get_default_reviewers_data(
701 702 apiuser.get_instance(), source_db_repo,
702 703 source_commit, target_db_repo, target_commit)
703 704
704 705 # now MERGE our given with the calculated
705 706 reviewer_objects = reviewer_rules['reviewers'] + reviewer_objects
706 707
707 708 try:
708 709 reviewers = validate_default_reviewers(
709 710 reviewer_objects, reviewer_rules)
710 711 except ValueError as e:
711 712 raise JSONRPCError('Reviewers Validation: {}'.format(e))
712 713
713 714 title = Optional.extract(title)
714 715 if not title:
715 716 title_source_ref = source_ref.split(':', 2)[1]
716 717 title = PullRequestModel().generate_pullrequest_title(
717 718 source=source_repo,
718 719 source_ref=title_source_ref,
719 720 target=target_repo
720 721 )
721 722 # fetch renderer, if set fallback to plain in case of PR
722 723 rc_config = SettingsModel().get_all_settings()
723 724 default_system_renderer = rc_config.get('rhodecode_markup_renderer', 'plain')
724 725 description = Optional.extract(description)
725 726 description_renderer = Optional.extract(description_renderer) or default_system_renderer
726 727
727 728 pull_request = PullRequestModel().create(
728 729 created_by=apiuser.user_id,
729 730 source_repo=source_repo,
730 731 source_ref=full_source_ref,
731 732 target_repo=target_repo,
732 733 target_ref=full_target_ref,
733 734 revisions=[commit.raw_id for commit in reversed(commit_ranges)],
734 735 reviewers=reviewers,
735 736 title=title,
736 737 description=description,
737 738 description_renderer=description_renderer,
738 739 reviewer_data=reviewer_rules,
739 740 auth_user=apiuser
740 741 )
741 742
742 743 Session().commit()
743 744 data = {
744 745 'msg': 'Created new pull request `{}`'.format(title),
745 746 'pull_request_id': pull_request.pull_request_id,
746 747 }
747 748 return data
748 749
749 750
750 751 @jsonrpc_method()
751 752 def update_pull_request(
752 753 request, apiuser, pullrequestid, repoid=Optional(None),
753 754 title=Optional(''), description=Optional(''), description_renderer=Optional(''),
754 755 reviewers=Optional(None), update_commits=Optional(None)):
755 756 """
756 757 Updates a pull request.
757 758
758 759 :param apiuser: This is filled automatically from the |authtoken|.
759 760 :type apiuser: AuthUser
760 761 :param repoid: Optional repository name or repository ID.
761 762 :type repoid: str or int
762 763 :param pullrequestid: The pull request ID.
763 764 :type pullrequestid: int
764 765 :param title: Set the pull request title.
765 766 :type title: str
766 767 :param description: Update pull request description.
767 768 :type description: Optional(str)
768 769 :type description_renderer: Optional(str)
769 770 :param description_renderer: Update pull request renderer for the description.
770 771 It should be 'rst', 'markdown' or 'plain'
771 772 :param reviewers: Update pull request reviewers list with new value.
772 773 :type reviewers: Optional(list)
773 774 Accepts username strings or objects of the format:
774 775
775 776 [{'username': 'nick', 'reasons': ['original author'], 'mandatory': <bool>}]
776 777
777 778 :param update_commits: Trigger update of commits for this pull request
778 779 :type: update_commits: Optional(bool)
779 780
780 781 Example output:
781 782
782 783 .. code-block:: bash
783 784
784 785 id : <id_given_in_input>
785 786 result : {
786 787 "msg": "Updated pull request `63`",
787 788 "pull_request": <pull_request_object>,
788 789 "updated_reviewers": {
789 790 "added": [
790 791 "username"
791 792 ],
792 793 "removed": []
793 794 },
794 795 "updated_commits": {
795 796 "added": [
796 797 "<sha1_hash>"
797 798 ],
798 799 "common": [
799 800 "<sha1_hash>",
800 801 "<sha1_hash>",
801 802 ],
802 803 "removed": []
803 804 }
804 805 }
805 806 error : null
806 807 """
807 808
808 809 pull_request = get_pull_request_or_error(pullrequestid)
809 810 if Optional.extract(repoid):
810 811 repo = get_repo_or_error(repoid)
811 812 else:
812 813 repo = pull_request.target_repo
813 814
814 815 if not PullRequestModel().check_user_update(
815 816 pull_request, apiuser, api=True):
816 817 raise JSONRPCError(
817 818 'pull request `%s` update failed, no permission to update.' % (
818 819 pullrequestid,))
819 820 if pull_request.is_closed():
820 821 raise JSONRPCError(
821 822 'pull request `%s` update failed, pull request is closed' % (
822 823 pullrequestid,))
823 824
824 825 reviewer_objects = Optional.extract(reviewers) or []
825 826
826 827 if reviewer_objects:
827 828 schema = ReviewerListSchema()
828 829 try:
829 830 reviewer_objects = schema.deserialize(reviewer_objects)
830 831 except Invalid as err:
831 832 raise JSONRPCValidationError(colander_exc=err)
832 833
833 834 # validate users
834 835 for reviewer_object in reviewer_objects:
835 836 user = get_user_or_error(reviewer_object['username'])
836 837 reviewer_object['user_id'] = user.user_id
837 838
838 839 get_default_reviewers_data, get_validated_reviewers = \
839 840 PullRequestModel().get_reviewer_functions()
840 841
841 842 # re-use stored rules
842 843 reviewer_rules = pull_request.reviewer_data
843 844 try:
844 845 reviewers = get_validated_reviewers(
845 846 reviewer_objects, reviewer_rules)
846 847 except ValueError as e:
847 848 raise JSONRPCError('Reviewers Validation: {}'.format(e))
848 849 else:
849 850 reviewers = []
850 851
851 852 title = Optional.extract(title)
852 853 description = Optional.extract(description)
853 854 description_renderer = Optional.extract(description_renderer)
854 855
855 856 if title or description:
856 857 PullRequestModel().edit(
857 858 pull_request,
858 859 title or pull_request.title,
859 860 description or pull_request.description,
860 861 description_renderer or pull_request.description_renderer,
861 862 apiuser)
862 863 Session().commit()
863 864
864 865 commit_changes = {"added": [], "common": [], "removed": []}
865 866 if str2bool(Optional.extract(update_commits)):
866 867
867 868 if pull_request.pull_request_state != PullRequest.STATE_CREATED:
868 869 raise JSONRPCError(
869 870 'Operation forbidden because pull request is in state {}, '
870 871 'only state {} is allowed.'.format(
871 872 pull_request.pull_request_state, PullRequest.STATE_CREATED))
872 873
873 874 with pull_request.set_state(PullRequest.STATE_UPDATING):
874 875 if PullRequestModel().has_valid_update_type(pull_request):
875 876 update_response = PullRequestModel().update_commits(pull_request)
876 877 commit_changes = update_response.changes or commit_changes
877 878 Session().commit()
878 879
879 880 reviewers_changes = {"added": [], "removed": []}
880 881 if reviewers:
881 882 old_calculated_status = pull_request.calculated_review_status()
882 883 added_reviewers, removed_reviewers = \
883 884 PullRequestModel().update_reviewers(pull_request, reviewers, apiuser)
884 885
885 886 reviewers_changes['added'] = sorted(
886 887 [get_user_or_error(n).username for n in added_reviewers])
887 888 reviewers_changes['removed'] = sorted(
888 889 [get_user_or_error(n).username for n in removed_reviewers])
889 890 Session().commit()
890 891
891 892 # trigger status changed if change in reviewers changes the status
892 893 calculated_status = pull_request.calculated_review_status()
893 894 if old_calculated_status != calculated_status:
894 895 PullRequestModel().trigger_pull_request_hook(
895 896 pull_request, apiuser, 'review_status_change',
896 897 data={'status': calculated_status})
897 898
898 899 data = {
899 900 'msg': 'Updated pull request `{}`'.format(
900 901 pull_request.pull_request_id),
901 902 'pull_request': pull_request.get_api_data(),
902 903 'updated_commits': commit_changes,
903 904 'updated_reviewers': reviewers_changes
904 905 }
905 906
906 907 return data
907 908
908 909
909 910 @jsonrpc_method()
910 911 def close_pull_request(
911 912 request, apiuser, pullrequestid, repoid=Optional(None),
912 913 userid=Optional(OAttr('apiuser')), message=Optional('')):
913 914 """
914 915 Close the pull request specified by `pullrequestid`.
915 916
916 917 :param apiuser: This is filled automatically from the |authtoken|.
917 918 :type apiuser: AuthUser
918 919 :param repoid: Repository name or repository ID to which the pull
919 920 request belongs.
920 921 :type repoid: str or int
921 922 :param pullrequestid: ID of the pull request to be closed.
922 923 :type pullrequestid: int
923 924 :param userid: Close the pull request as this user.
924 925 :type userid: Optional(str or int)
925 926 :param message: Optional message to close the Pull Request with. If not
926 927 specified it will be generated automatically.
927 928 :type message: Optional(str)
928 929
929 930 Example output:
930 931
931 932 .. code-block:: bash
932 933
933 934 "id": <id_given_in_input>,
934 935 "result": {
935 936 "pull_request_id": "<int>",
936 937 "close_status": "<str:status_lbl>,
937 938 "closed": "<bool>"
938 939 },
939 940 "error": null
940 941
941 942 """
942 943 _ = request.translate
943 944
944 945 pull_request = get_pull_request_or_error(pullrequestid)
945 946 if Optional.extract(repoid):
946 947 repo = get_repo_or_error(repoid)
947 948 else:
948 949 repo = pull_request.target_repo
949 950
950 951 if not isinstance(userid, Optional):
951 952 if (has_superadmin_permission(apiuser) or
952 953 HasRepoPermissionAnyApi('repository.admin')(
953 954 user=apiuser, repo_name=repo.repo_name)):
954 955 apiuser = get_user_or_error(userid)
955 956 else:
956 957 raise JSONRPCError('userid is not the same as your user')
957 958
958 959 if pull_request.is_closed():
959 960 raise JSONRPCError(
960 961 'pull request `%s` is already closed' % (pullrequestid,))
961 962
962 963 # only owner or admin or person with write permissions
963 964 allowed_to_close = PullRequestModel().check_user_update(
964 965 pull_request, apiuser, api=True)
965 966
966 967 if not allowed_to_close:
967 968 raise JSONRPCError(
968 969 'pull request `%s` close failed, no permission to close.' % (
969 970 pullrequestid,))
970 971
971 972 # message we're using to close the PR, else it's automatically generated
972 973 message = Optional.extract(message)
973 974
974 975 # finally close the PR, with proper message comment
975 976 comment, status = PullRequestModel().close_pull_request_with_comment(
976 977 pull_request, apiuser, repo, message=message, auth_user=apiuser)
977 978 status_lbl = ChangesetStatus.get_status_lbl(status)
978 979
979 980 Session().commit()
980 981
981 982 data = {
982 983 'pull_request_id': pull_request.pull_request_id,
983 984 'close_status': status_lbl,
984 985 'closed': True,
985 986 }
986 987 return data
@@ -1,1841 +1,1842 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2014-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 Base module for all VCS systems
23 23 """
24 24 import os
25 25 import re
26 26 import time
27 27 import shutil
28 28 import datetime
29 29 import fnmatch
30 30 import itertools
31 31 import logging
32 32 import collections
33 33 import warnings
34 34
35 35 from zope.cachedescriptors.property import Lazy as LazyProperty
36 36 from pyramid import compat
37 37
38 38 from rhodecode.translation import lazy_ugettext
39 39 from rhodecode.lib.utils2 import safe_str, safe_unicode
40 40 from rhodecode.lib.vcs import connection
41 41 from rhodecode.lib.vcs.utils import author_name, author_email
42 42 from rhodecode.lib.vcs.conf import settings
43 43 from rhodecode.lib.vcs.exceptions import (
44 44 CommitError, EmptyRepositoryError, NodeAlreadyAddedError,
45 45 NodeAlreadyChangedError, NodeAlreadyExistsError, NodeAlreadyRemovedError,
46 46 NodeDoesNotExistError, NodeNotChangedError, VCSError,
47 47 ImproperArchiveTypeError, BranchDoesNotExistError, CommitDoesNotExistError,
48 48 RepositoryError)
49 49
50 50
51 51 log = logging.getLogger(__name__)
52 52
53 53
54 54 FILEMODE_DEFAULT = 0o100644
55 55 FILEMODE_EXECUTABLE = 0o100755
56 56
57 57 Reference = collections.namedtuple('Reference', ('type', 'name', 'commit_id'))
58 58
59 59
60 60 class MergeFailureReason(object):
61 61 """
62 62 Enumeration with all the reasons why the server side merge could fail.
63 63
64 64 DO NOT change the number of the reasons, as they may be stored in the
65 65 database.
66 66
67 67 Changing the name of a reason is acceptable and encouraged to deprecate old
68 68 reasons.
69 69 """
70 70
71 71 # Everything went well.
72 72 NONE = 0
73 73
74 74 # An unexpected exception was raised. Check the logs for more details.
75 75 UNKNOWN = 1
76 76
77 77 # The merge was not successful, there are conflicts.
78 78 MERGE_FAILED = 2
79 79
80 80 # The merge succeeded but we could not push it to the target repository.
81 81 PUSH_FAILED = 3
82 82
83 83 # The specified target is not a head in the target repository.
84 84 TARGET_IS_NOT_HEAD = 4
85 85
86 86 # The source repository contains more branches than the target. Pushing
87 87 # the merge will create additional branches in the target.
88 88 HG_SOURCE_HAS_MORE_BRANCHES = 5
89 89
90 90 # The target reference has multiple heads. That does not allow to correctly
91 91 # identify the target location. This could only happen for mercurial
92 92 # branches.
93 93 HG_TARGET_HAS_MULTIPLE_HEADS = 6
94 94
95 95 # The target repository is locked
96 96 TARGET_IS_LOCKED = 7
97 97
98 98 # Deprecated, use MISSING_TARGET_REF or MISSING_SOURCE_REF instead.
99 99 # A involved commit could not be found.
100 100 _DEPRECATED_MISSING_COMMIT = 8
101 101
102 102 # The target repo reference is missing.
103 103 MISSING_TARGET_REF = 9
104 104
105 105 # The source repo reference is missing.
106 106 MISSING_SOURCE_REF = 10
107 107
108 108 # The merge was not successful, there are conflicts related to sub
109 109 # repositories.
110 110 SUBREPO_MERGE_FAILED = 11
111 111
112 112
113 113 class UpdateFailureReason(object):
114 114 """
115 115 Enumeration with all the reasons why the pull request update could fail.
116 116
117 117 DO NOT change the number of the reasons, as they may be stored in the
118 118 database.
119 119
120 120 Changing the name of a reason is acceptable and encouraged to deprecate old
121 121 reasons.
122 122 """
123 123
124 124 # Everything went well.
125 125 NONE = 0
126 126
127 127 # An unexpected exception was raised. Check the logs for more details.
128 128 UNKNOWN = 1
129 129
130 130 # The pull request is up to date.
131 131 NO_CHANGE = 2
132 132
133 133 # The pull request has a reference type that is not supported for update.
134 134 WRONG_REF_TYPE = 3
135 135
136 136 # Update failed because the target reference is missing.
137 137 MISSING_TARGET_REF = 4
138 138
139 139 # Update failed because the source reference is missing.
140 140 MISSING_SOURCE_REF = 5
141 141
142 142
143 143 class MergeResponse(object):
144 144
145 145 # uses .format(**metadata) for variables
146 146 MERGE_STATUS_MESSAGES = {
147 147 MergeFailureReason.NONE: lazy_ugettext(
148 148 u'This pull request can be automatically merged.'),
149 149 MergeFailureReason.UNKNOWN: lazy_ugettext(
150 150 u'This pull request cannot be merged because of an unhandled exception. '
151 151 u'{exception}'),
152 152 MergeFailureReason.MERGE_FAILED: lazy_ugettext(
153 153 u'This pull request cannot be merged because of merge conflicts.'),
154 154 MergeFailureReason.PUSH_FAILED: lazy_ugettext(
155 155 u'This pull request could not be merged because push to '
156 156 u'target:`{target}@{merge_commit}` failed.'),
157 157 MergeFailureReason.TARGET_IS_NOT_HEAD: lazy_ugettext(
158 158 u'This pull request cannot be merged because the target '
159 159 u'`{target_ref.name}` is not a head.'),
160 160 MergeFailureReason.HG_SOURCE_HAS_MORE_BRANCHES: lazy_ugettext(
161 161 u'This pull request cannot be merged because the source contains '
162 162 u'more branches than the target.'),
163 163 MergeFailureReason.HG_TARGET_HAS_MULTIPLE_HEADS: lazy_ugettext(
164 164 u'This pull request cannot be merged because the target '
165 165 u'has multiple heads: `{heads}`.'),
166 166 MergeFailureReason.TARGET_IS_LOCKED: lazy_ugettext(
167 167 u'This pull request cannot be merged because the target repository is '
168 168 u'locked by {locked_by}.'),
169 169
170 170 MergeFailureReason.MISSING_TARGET_REF: lazy_ugettext(
171 171 u'This pull request cannot be merged because the target '
172 172 u'reference `{target_ref.name}` is missing.'),
173 173 MergeFailureReason.MISSING_SOURCE_REF: lazy_ugettext(
174 174 u'This pull request cannot be merged because the source '
175 175 u'reference `{source_ref.name}` is missing.'),
176 176 MergeFailureReason.SUBREPO_MERGE_FAILED: lazy_ugettext(
177 177 u'This pull request cannot be merged because of conflicts related '
178 178 u'to sub repositories.'),
179 179
180 180 # Deprecations
181 181 MergeFailureReason._DEPRECATED_MISSING_COMMIT: lazy_ugettext(
182 182 u'This pull request cannot be merged because the target or the '
183 183 u'source reference is missing.'),
184 184
185 185 }
186 186
187 187 def __init__(self, possible, executed, merge_ref, failure_reason, metadata=None):
188 188 self.possible = possible
189 189 self.executed = executed
190 190 self.merge_ref = merge_ref
191 191 self.failure_reason = failure_reason
192 192 self.metadata = metadata or {}
193 193
194 194 def __repr__(self):
195 195 return '<MergeResponse:{} {}>'.format(self.label, self.failure_reason)
196 196
197 197 def __eq__(self, other):
198 198 same_instance = isinstance(other, self.__class__)
199 199 return same_instance \
200 200 and self.possible == other.possible \
201 201 and self.executed == other.executed \
202 202 and self.failure_reason == other.failure_reason
203 203
204 204 @property
205 205 def label(self):
206 206 label_dict = dict((v, k) for k, v in MergeFailureReason.__dict__.items() if
207 207 not k.startswith('_'))
208 208 return label_dict.get(self.failure_reason)
209 209
210 210 @property
211 211 def merge_status_message(self):
212 212 """
213 213 Return a human friendly error message for the given merge status code.
214 214 """
215 215 msg = safe_unicode(self.MERGE_STATUS_MESSAGES[self.failure_reason])
216 216 try:
217 217 return msg.format(**self.metadata)
218 218 except Exception:
219 219 log.exception('Failed to format %s message', self)
220 220 return msg
221 221
222 222 def asdict(self):
223 223 data = {}
224 for k in ['possible', 'executed', 'merge_ref', 'failure_reason']:
224 for k in ['possible', 'executed', 'merge_ref', 'failure_reason',
225 'merge_status_message']:
225 226 data[k] = getattr(self, k)
226 227 return data
227 228
228 229
229 230 class BaseRepository(object):
230 231 """
231 232 Base Repository for final backends
232 233
233 234 .. attribute:: DEFAULT_BRANCH_NAME
234 235
235 236 name of default branch (i.e. "trunk" for svn, "master" for git etc.
236 237
237 238 .. attribute:: commit_ids
238 239
239 240 list of all available commit ids, in ascending order
240 241
241 242 .. attribute:: path
242 243
243 244 absolute path to the repository
244 245
245 246 .. attribute:: bookmarks
246 247
247 248 Mapping from name to :term:`Commit ID` of the bookmark. Empty in case
248 249 there are no bookmarks or the backend implementation does not support
249 250 bookmarks.
250 251
251 252 .. attribute:: tags
252 253
253 254 Mapping from name to :term:`Commit ID` of the tag.
254 255
255 256 """
256 257
257 258 DEFAULT_BRANCH_NAME = None
258 259 DEFAULT_CONTACT = u"Unknown"
259 260 DEFAULT_DESCRIPTION = u"unknown"
260 261 EMPTY_COMMIT_ID = '0' * 40
261 262
262 263 path = None
263 264
264 265 def __init__(self, repo_path, config=None, create=False, **kwargs):
265 266 """
266 267 Initializes repository. Raises RepositoryError if repository could
267 268 not be find at the given ``repo_path`` or directory at ``repo_path``
268 269 exists and ``create`` is set to True.
269 270
270 271 :param repo_path: local path of the repository
271 272 :param config: repository configuration
272 273 :param create=False: if set to True, would try to create repository.
273 274 :param src_url=None: if set, should be proper url from which repository
274 275 would be cloned; requires ``create`` parameter to be set to True -
275 276 raises RepositoryError if src_url is set and create evaluates to
276 277 False
277 278 """
278 279 raise NotImplementedError
279 280
280 281 def __repr__(self):
281 282 return '<%s at %s>' % (self.__class__.__name__, self.path)
282 283
283 284 def __len__(self):
284 285 return self.count()
285 286
286 287 def __eq__(self, other):
287 288 same_instance = isinstance(other, self.__class__)
288 289 return same_instance and other.path == self.path
289 290
290 291 def __ne__(self, other):
291 292 return not self.__eq__(other)
292 293
293 294 def get_create_shadow_cache_pr_path(self, db_repo):
294 295 path = db_repo.cached_diffs_dir
295 296 if not os.path.exists(path):
296 297 os.makedirs(path, 0o755)
297 298 return path
298 299
299 300 @classmethod
300 301 def get_default_config(cls, default=None):
301 302 config = Config()
302 303 if default and isinstance(default, list):
303 304 for section, key, val in default:
304 305 config.set(section, key, val)
305 306 return config
306 307
307 308 @LazyProperty
308 309 def _remote(self):
309 310 raise NotImplementedError
310 311
311 312 @LazyProperty
312 313 def EMPTY_COMMIT(self):
313 314 return EmptyCommit(self.EMPTY_COMMIT_ID)
314 315
315 316 @LazyProperty
316 317 def alias(self):
317 318 for k, v in settings.BACKENDS.items():
318 319 if v.split('.')[-1] == str(self.__class__.__name__):
319 320 return k
320 321
321 322 @LazyProperty
322 323 def name(self):
323 324 return safe_unicode(os.path.basename(self.path))
324 325
325 326 @LazyProperty
326 327 def description(self):
327 328 raise NotImplementedError
328 329
329 330 def refs(self):
330 331 """
331 332 returns a `dict` with branches, bookmarks, tags, and closed_branches
332 333 for this repository
333 334 """
334 335 return dict(
335 336 branches=self.branches,
336 337 branches_closed=self.branches_closed,
337 338 tags=self.tags,
338 339 bookmarks=self.bookmarks
339 340 )
340 341
341 342 @LazyProperty
342 343 def branches(self):
343 344 """
344 345 A `dict` which maps branch names to commit ids.
345 346 """
346 347 raise NotImplementedError
347 348
348 349 @LazyProperty
349 350 def branches_closed(self):
350 351 """
351 352 A `dict` which maps tags names to commit ids.
352 353 """
353 354 raise NotImplementedError
354 355
355 356 @LazyProperty
356 357 def bookmarks(self):
357 358 """
358 359 A `dict` which maps tags names to commit ids.
359 360 """
360 361 raise NotImplementedError
361 362
362 363 @LazyProperty
363 364 def tags(self):
364 365 """
365 366 A `dict` which maps tags names to commit ids.
366 367 """
367 368 raise NotImplementedError
368 369
369 370 @LazyProperty
370 371 def size(self):
371 372 """
372 373 Returns combined size in bytes for all repository files
373 374 """
374 375 tip = self.get_commit()
375 376 return tip.size
376 377
377 378 def size_at_commit(self, commit_id):
378 379 commit = self.get_commit(commit_id)
379 380 return commit.size
380 381
381 382 def is_empty(self):
382 383 return not bool(self.commit_ids)
383 384
384 385 @staticmethod
385 386 def check_url(url, config):
386 387 """
387 388 Function will check given url and try to verify if it's a valid
388 389 link.
389 390 """
390 391 raise NotImplementedError
391 392
392 393 @staticmethod
393 394 def is_valid_repository(path):
394 395 """
395 396 Check if given `path` contains a valid repository of this backend
396 397 """
397 398 raise NotImplementedError
398 399
399 400 # ==========================================================================
400 401 # COMMITS
401 402 # ==========================================================================
402 403
403 404 def get_commit(self, commit_id=None, commit_idx=None, pre_load=None):
404 405 """
405 406 Returns instance of `BaseCommit` class. If `commit_id` and `commit_idx`
406 407 are both None, most recent commit is returned.
407 408
408 409 :param pre_load: Optional. List of commit attributes to load.
409 410
410 411 :raises ``EmptyRepositoryError``: if there are no commits
411 412 """
412 413 raise NotImplementedError
413 414
414 415 def __iter__(self):
415 416 for commit_id in self.commit_ids:
416 417 yield self.get_commit(commit_id=commit_id)
417 418
418 419 def get_commits(
419 420 self, start_id=None, end_id=None, start_date=None, end_date=None,
420 421 branch_name=None, show_hidden=False, pre_load=None):
421 422 """
422 423 Returns iterator of `BaseCommit` objects from start to end
423 424 not inclusive. This should behave just like a list, ie. end is not
424 425 inclusive.
425 426
426 427 :param start_id: None or str, must be a valid commit id
427 428 :param end_id: None or str, must be a valid commit id
428 429 :param start_date:
429 430 :param end_date:
430 431 :param branch_name:
431 432 :param show_hidden:
432 433 :param pre_load:
433 434 """
434 435 raise NotImplementedError
435 436
436 437 def __getitem__(self, key):
437 438 """
438 439 Allows index based access to the commit objects of this repository.
439 440 """
440 441 pre_load = ["author", "branch", "date", "message", "parents"]
441 442 if isinstance(key, slice):
442 443 return self._get_range(key, pre_load)
443 444 return self.get_commit(commit_idx=key, pre_load=pre_load)
444 445
445 446 def _get_range(self, slice_obj, pre_load):
446 447 for commit_id in self.commit_ids.__getitem__(slice_obj):
447 448 yield self.get_commit(commit_id=commit_id, pre_load=pre_load)
448 449
449 450 def count(self):
450 451 return len(self.commit_ids)
451 452
452 453 def tag(self, name, user, commit_id=None, message=None, date=None, **opts):
453 454 """
454 455 Creates and returns a tag for the given ``commit_id``.
455 456
456 457 :param name: name for new tag
457 458 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
458 459 :param commit_id: commit id for which new tag would be created
459 460 :param message: message of the tag's commit
460 461 :param date: date of tag's commit
461 462
462 463 :raises TagAlreadyExistError: if tag with same name already exists
463 464 """
464 465 raise NotImplementedError
465 466
466 467 def remove_tag(self, name, user, message=None, date=None):
467 468 """
468 469 Removes tag with the given ``name``.
469 470
470 471 :param name: name of the tag to be removed
471 472 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
472 473 :param message: message of the tag's removal commit
473 474 :param date: date of tag's removal commit
474 475
475 476 :raises TagDoesNotExistError: if tag with given name does not exists
476 477 """
477 478 raise NotImplementedError
478 479
479 480 def get_diff(
480 481 self, commit1, commit2, path=None, ignore_whitespace=False,
481 482 context=3, path1=None):
482 483 """
483 484 Returns (git like) *diff*, as plain text. Shows changes introduced by
484 485 `commit2` since `commit1`.
485 486
486 487 :param commit1: Entry point from which diff is shown. Can be
487 488 ``self.EMPTY_COMMIT`` - in this case, patch showing all
488 489 the changes since empty state of the repository until `commit2`
489 490 :param commit2: Until which commit changes should be shown.
490 491 :param path: Can be set to a path of a file to create a diff of that
491 492 file. If `path1` is also set, this value is only associated to
492 493 `commit2`.
493 494 :param ignore_whitespace: If set to ``True``, would not show whitespace
494 495 changes. Defaults to ``False``.
495 496 :param context: How many lines before/after changed lines should be
496 497 shown. Defaults to ``3``.
497 498 :param path1: Can be set to a path to associate with `commit1`. This
498 499 parameter works only for backends which support diff generation for
499 500 different paths. Other backends will raise a `ValueError` if `path1`
500 501 is set and has a different value than `path`.
501 502 :param file_path: filter this diff by given path pattern
502 503 """
503 504 raise NotImplementedError
504 505
505 506 def strip(self, commit_id, branch=None):
506 507 """
507 508 Strip given commit_id from the repository
508 509 """
509 510 raise NotImplementedError
510 511
511 512 def get_common_ancestor(self, commit_id1, commit_id2, repo2):
512 513 """
513 514 Return a latest common ancestor commit if one exists for this repo
514 515 `commit_id1` vs `commit_id2` from `repo2`.
515 516
516 517 :param commit_id1: Commit it from this repository to use as a
517 518 target for the comparison.
518 519 :param commit_id2: Source commit id to use for comparison.
519 520 :param repo2: Source repository to use for comparison.
520 521 """
521 522 raise NotImplementedError
522 523
523 524 def compare(self, commit_id1, commit_id2, repo2, merge, pre_load=None):
524 525 """
525 526 Compare this repository's revision `commit_id1` with `commit_id2`.
526 527
527 528 Returns a tuple(commits, ancestor) that would be merged from
528 529 `commit_id2`. Doing a normal compare (``merge=False``), ``None``
529 530 will be returned as ancestor.
530 531
531 532 :param commit_id1: Commit it from this repository to use as a
532 533 target for the comparison.
533 534 :param commit_id2: Source commit id to use for comparison.
534 535 :param repo2: Source repository to use for comparison.
535 536 :param merge: If set to ``True`` will do a merge compare which also
536 537 returns the common ancestor.
537 538 :param pre_load: Optional. List of commit attributes to load.
538 539 """
539 540 raise NotImplementedError
540 541
541 542 def merge(self, repo_id, workspace_id, target_ref, source_repo, source_ref,
542 543 user_name='', user_email='', message='', dry_run=False,
543 544 use_rebase=False, close_branch=False):
544 545 """
545 546 Merge the revisions specified in `source_ref` from `source_repo`
546 547 onto the `target_ref` of this repository.
547 548
548 549 `source_ref` and `target_ref` are named tupls with the following
549 550 fields `type`, `name` and `commit_id`.
550 551
551 552 Returns a MergeResponse named tuple with the following fields
552 553 'possible', 'executed', 'source_commit', 'target_commit',
553 554 'merge_commit'.
554 555
555 556 :param repo_id: `repo_id` target repo id.
556 557 :param workspace_id: `workspace_id` unique identifier.
557 558 :param target_ref: `target_ref` points to the commit on top of which
558 559 the `source_ref` should be merged.
559 560 :param source_repo: The repository that contains the commits to be
560 561 merged.
561 562 :param source_ref: `source_ref` points to the topmost commit from
562 563 the `source_repo` which should be merged.
563 564 :param user_name: Merge commit `user_name`.
564 565 :param user_email: Merge commit `user_email`.
565 566 :param message: Merge commit `message`.
566 567 :param dry_run: If `True` the merge will not take place.
567 568 :param use_rebase: If `True` commits from the source will be rebased
568 569 on top of the target instead of being merged.
569 570 :param close_branch: If `True` branch will be close before merging it
570 571 """
571 572 if dry_run:
572 573 message = message or settings.MERGE_DRY_RUN_MESSAGE
573 574 user_email = user_email or settings.MERGE_DRY_RUN_EMAIL
574 575 user_name = user_name or settings.MERGE_DRY_RUN_USER
575 576 else:
576 577 if not user_name:
577 578 raise ValueError('user_name cannot be empty')
578 579 if not user_email:
579 580 raise ValueError('user_email cannot be empty')
580 581 if not message:
581 582 raise ValueError('message cannot be empty')
582 583
583 584 try:
584 585 return self._merge_repo(
585 586 repo_id, workspace_id, target_ref, source_repo,
586 587 source_ref, message, user_name, user_email, dry_run=dry_run,
587 588 use_rebase=use_rebase, close_branch=close_branch)
588 589 except RepositoryError as exc:
589 590 log.exception('Unexpected failure when running merge, dry-run=%s', dry_run)
590 591 return MergeResponse(
591 592 False, False, None, MergeFailureReason.UNKNOWN,
592 593 metadata={'exception': str(exc)})
593 594
594 595 def _merge_repo(self, repo_id, workspace_id, target_ref,
595 596 source_repo, source_ref, merge_message,
596 597 merger_name, merger_email, dry_run=False,
597 598 use_rebase=False, close_branch=False):
598 599 """Internal implementation of merge."""
599 600 raise NotImplementedError
600 601
601 602 def _maybe_prepare_merge_workspace(
602 603 self, repo_id, workspace_id, target_ref, source_ref):
603 604 """
604 605 Create the merge workspace.
605 606
606 607 :param workspace_id: `workspace_id` unique identifier.
607 608 """
608 609 raise NotImplementedError
609 610
610 611 def _get_legacy_shadow_repository_path(self, workspace_id):
611 612 """
612 613 Legacy version that was used before. We still need it for
613 614 backward compat
614 615 """
615 616 return os.path.join(
616 617 os.path.dirname(self.path),
617 618 '.__shadow_%s_%s' % (os.path.basename(self.path), workspace_id))
618 619
619 620 def _get_shadow_repository_path(self, repo_id, workspace_id):
620 621 # The name of the shadow repository must start with '.', so it is
621 622 # skipped by 'rhodecode.lib.utils.get_filesystem_repos'.
622 623 legacy_repository_path = self._get_legacy_shadow_repository_path(workspace_id)
623 624 if os.path.exists(legacy_repository_path):
624 625 return legacy_repository_path
625 626 else:
626 627 return os.path.join(
627 628 os.path.dirname(self.path),
628 629 '.__shadow_repo_%s_%s' % (repo_id, workspace_id))
629 630
630 631 def cleanup_merge_workspace(self, repo_id, workspace_id):
631 632 """
632 633 Remove merge workspace.
633 634
634 635 This function MUST not fail in case there is no workspace associated to
635 636 the given `workspace_id`.
636 637
637 638 :param workspace_id: `workspace_id` unique identifier.
638 639 """
639 640 shadow_repository_path = self._get_shadow_repository_path(repo_id, workspace_id)
640 641 shadow_repository_path_del = '{}.{}.delete'.format(
641 642 shadow_repository_path, time.time())
642 643
643 644 # move the shadow repo, so it never conflicts with the one used.
644 645 # we use this method because shutil.rmtree had some edge case problems
645 646 # removing symlinked repositories
646 647 if not os.path.isdir(shadow_repository_path):
647 648 return
648 649
649 650 shutil.move(shadow_repository_path, shadow_repository_path_del)
650 651 try:
651 652 shutil.rmtree(shadow_repository_path_del, ignore_errors=False)
652 653 except Exception:
653 654 log.exception('Failed to gracefully remove shadow repo under %s',
654 655 shadow_repository_path_del)
655 656 shutil.rmtree(shadow_repository_path_del, ignore_errors=True)
656 657
657 658 # ========== #
658 659 # COMMIT API #
659 660 # ========== #
660 661
661 662 @LazyProperty
662 663 def in_memory_commit(self):
663 664 """
664 665 Returns :class:`InMemoryCommit` object for this repository.
665 666 """
666 667 raise NotImplementedError
667 668
668 669 # ======================== #
669 670 # UTILITIES FOR SUBCLASSES #
670 671 # ======================== #
671 672
672 673 def _validate_diff_commits(self, commit1, commit2):
673 674 """
674 675 Validates that the given commits are related to this repository.
675 676
676 677 Intended as a utility for sub classes to have a consistent validation
677 678 of input parameters in methods like :meth:`get_diff`.
678 679 """
679 680 self._validate_commit(commit1)
680 681 self._validate_commit(commit2)
681 682 if (isinstance(commit1, EmptyCommit) and
682 683 isinstance(commit2, EmptyCommit)):
683 684 raise ValueError("Cannot compare two empty commits")
684 685
685 686 def _validate_commit(self, commit):
686 687 if not isinstance(commit, BaseCommit):
687 688 raise TypeError(
688 689 "%s is not of type BaseCommit" % repr(commit))
689 690 if commit.repository != self and not isinstance(commit, EmptyCommit):
690 691 raise ValueError(
691 692 "Commit %s must be a valid commit from this repository %s, "
692 693 "related to this repository instead %s." %
693 694 (commit, self, commit.repository))
694 695
695 696 def _validate_commit_id(self, commit_id):
696 697 if not isinstance(commit_id, compat.string_types):
697 698 raise TypeError("commit_id must be a string value")
698 699
699 700 def _validate_commit_idx(self, commit_idx):
700 701 if not isinstance(commit_idx, (int, long)):
701 702 raise TypeError("commit_idx must be a numeric value")
702 703
703 704 def _validate_branch_name(self, branch_name):
704 705 if branch_name and branch_name not in self.branches_all:
705 706 msg = ("Branch %s not found in %s" % (branch_name, self))
706 707 raise BranchDoesNotExistError(msg)
707 708
708 709 #
709 710 # Supporting deprecated API parts
710 711 # TODO: johbo: consider to move this into a mixin
711 712 #
712 713
713 714 @property
714 715 def EMPTY_CHANGESET(self):
715 716 warnings.warn(
716 717 "Use EMPTY_COMMIT or EMPTY_COMMIT_ID instead", DeprecationWarning)
717 718 return self.EMPTY_COMMIT_ID
718 719
719 720 @property
720 721 def revisions(self):
721 722 warnings.warn("Use commits attribute instead", DeprecationWarning)
722 723 return self.commit_ids
723 724
724 725 @revisions.setter
725 726 def revisions(self, value):
726 727 warnings.warn("Use commits attribute instead", DeprecationWarning)
727 728 self.commit_ids = value
728 729
729 730 def get_changeset(self, revision=None, pre_load=None):
730 731 warnings.warn("Use get_commit instead", DeprecationWarning)
731 732 commit_id = None
732 733 commit_idx = None
733 734 if isinstance(revision, compat.string_types):
734 735 commit_id = revision
735 736 else:
736 737 commit_idx = revision
737 738 return self.get_commit(
738 739 commit_id=commit_id, commit_idx=commit_idx, pre_load=pre_load)
739 740
740 741 def get_changesets(
741 742 self, start=None, end=None, start_date=None, end_date=None,
742 743 branch_name=None, pre_load=None):
743 744 warnings.warn("Use get_commits instead", DeprecationWarning)
744 745 start_id = self._revision_to_commit(start)
745 746 end_id = self._revision_to_commit(end)
746 747 return self.get_commits(
747 748 start_id=start_id, end_id=end_id, start_date=start_date,
748 749 end_date=end_date, branch_name=branch_name, pre_load=pre_load)
749 750
750 751 def _revision_to_commit(self, revision):
751 752 """
752 753 Translates a revision to a commit_id
753 754
754 755 Helps to support the old changeset based API which allows to use
755 756 commit ids and commit indices interchangeable.
756 757 """
757 758 if revision is None:
758 759 return revision
759 760
760 761 if isinstance(revision, compat.string_types):
761 762 commit_id = revision
762 763 else:
763 764 commit_id = self.commit_ids[revision]
764 765 return commit_id
765 766
766 767 @property
767 768 def in_memory_changeset(self):
768 769 warnings.warn("Use in_memory_commit instead", DeprecationWarning)
769 770 return self.in_memory_commit
770 771
771 772 def get_path_permissions(self, username):
772 773 """
773 774 Returns a path permission checker or None if not supported
774 775
775 776 :param username: session user name
776 777 :return: an instance of BasePathPermissionChecker or None
777 778 """
778 779 return None
779 780
780 781 def install_hooks(self, force=False):
781 782 return self._remote.install_hooks(force)
782 783
783 784 def get_hooks_info(self):
784 785 return self._remote.get_hooks_info()
785 786
786 787
787 788 class BaseCommit(object):
788 789 """
789 790 Each backend should implement it's commit representation.
790 791
791 792 **Attributes**
792 793
793 794 ``repository``
794 795 repository object within which commit exists
795 796
796 797 ``id``
797 798 The commit id, may be ``raw_id`` or i.e. for mercurial's tip
798 799 just ``tip``.
799 800
800 801 ``raw_id``
801 802 raw commit representation (i.e. full 40 length sha for git
802 803 backend)
803 804
804 805 ``short_id``
805 806 shortened (if apply) version of ``raw_id``; it would be simple
806 807 shortcut for ``raw_id[:12]`` for git/mercurial backends or same
807 808 as ``raw_id`` for subversion
808 809
809 810 ``idx``
810 811 commit index
811 812
812 813 ``files``
813 814 list of ``FileNode`` (``Node`` with NodeKind.FILE) objects
814 815
815 816 ``dirs``
816 817 list of ``DirNode`` (``Node`` with NodeKind.DIR) objects
817 818
818 819 ``nodes``
819 820 combined list of ``Node`` objects
820 821
821 822 ``author``
822 823 author of the commit, as unicode
823 824
824 825 ``message``
825 826 message of the commit, as unicode
826 827
827 828 ``parents``
828 829 list of parent commits
829 830
830 831 """
831 832
832 833 branch = None
833 834 """
834 835 Depending on the backend this should be set to the branch name of the
835 836 commit. Backends not supporting branches on commits should leave this
836 837 value as ``None``.
837 838 """
838 839
839 840 _ARCHIVE_PREFIX_TEMPLATE = b'{repo_name}-{short_id}'
840 841 """
841 842 This template is used to generate a default prefix for repository archives
842 843 if no prefix has been specified.
843 844 """
844 845
845 846 def __str__(self):
846 847 return '<%s at %s:%s>' % (
847 848 self.__class__.__name__, self.idx, self.short_id)
848 849
849 850 def __repr__(self):
850 851 return self.__str__()
851 852
852 853 def __unicode__(self):
853 854 return u'%s:%s' % (self.idx, self.short_id)
854 855
855 856 def __eq__(self, other):
856 857 same_instance = isinstance(other, self.__class__)
857 858 return same_instance and self.raw_id == other.raw_id
858 859
859 860 def __json__(self):
860 861 parents = []
861 862 try:
862 863 for parent in self.parents:
863 864 parents.append({'raw_id': parent.raw_id})
864 865 except NotImplementedError:
865 866 # empty commit doesn't have parents implemented
866 867 pass
867 868
868 869 return {
869 870 'short_id': self.short_id,
870 871 'raw_id': self.raw_id,
871 872 'revision': self.idx,
872 873 'message': self.message,
873 874 'date': self.date,
874 875 'author': self.author,
875 876 'parents': parents,
876 877 'branch': self.branch
877 878 }
878 879
879 880 def __getstate__(self):
880 881 d = self.__dict__.copy()
881 882 d.pop('_remote', None)
882 883 d.pop('repository', None)
883 884 return d
884 885
885 886 def _get_refs(self):
886 887 return {
887 888 'branches': [self.branch] if self.branch else [],
888 889 'bookmarks': getattr(self, 'bookmarks', []),
889 890 'tags': self.tags
890 891 }
891 892
892 893 @LazyProperty
893 894 def last(self):
894 895 """
895 896 ``True`` if this is last commit in repository, ``False``
896 897 otherwise; trying to access this attribute while there is no
897 898 commits would raise `EmptyRepositoryError`
898 899 """
899 900 if self.repository is None:
900 901 raise CommitError("Cannot check if it's most recent commit")
901 902 return self.raw_id == self.repository.commit_ids[-1]
902 903
903 904 @LazyProperty
904 905 def parents(self):
905 906 """
906 907 Returns list of parent commits.
907 908 """
908 909 raise NotImplementedError
909 910
910 911 @LazyProperty
911 912 def first_parent(self):
912 913 """
913 914 Returns list of parent commits.
914 915 """
915 916 return self.parents[0] if self.parents else EmptyCommit()
916 917
917 918 @property
918 919 def merge(self):
919 920 """
920 921 Returns boolean if commit is a merge.
921 922 """
922 923 return len(self.parents) > 1
923 924
924 925 @LazyProperty
925 926 def children(self):
926 927 """
927 928 Returns list of child commits.
928 929 """
929 930 raise NotImplementedError
930 931
931 932 @LazyProperty
932 933 def id(self):
933 934 """
934 935 Returns string identifying this commit.
935 936 """
936 937 raise NotImplementedError
937 938
938 939 @LazyProperty
939 940 def raw_id(self):
940 941 """
941 942 Returns raw string identifying this commit.
942 943 """
943 944 raise NotImplementedError
944 945
945 946 @LazyProperty
946 947 def short_id(self):
947 948 """
948 949 Returns shortened version of ``raw_id`` attribute, as string,
949 950 identifying this commit, useful for presentation to users.
950 951 """
951 952 raise NotImplementedError
952 953
953 954 @LazyProperty
954 955 def idx(self):
955 956 """
956 957 Returns integer identifying this commit.
957 958 """
958 959 raise NotImplementedError
959 960
960 961 @LazyProperty
961 962 def committer(self):
962 963 """
963 964 Returns committer for this commit
964 965 """
965 966 raise NotImplementedError
966 967
967 968 @LazyProperty
968 969 def committer_name(self):
969 970 """
970 971 Returns committer name for this commit
971 972 """
972 973
973 974 return author_name(self.committer)
974 975
975 976 @LazyProperty
976 977 def committer_email(self):
977 978 """
978 979 Returns committer email address for this commit
979 980 """
980 981
981 982 return author_email(self.committer)
982 983
983 984 @LazyProperty
984 985 def author(self):
985 986 """
986 987 Returns author for this commit
987 988 """
988 989
989 990 raise NotImplementedError
990 991
991 992 @LazyProperty
992 993 def author_name(self):
993 994 """
994 995 Returns author name for this commit
995 996 """
996 997
997 998 return author_name(self.author)
998 999
999 1000 @LazyProperty
1000 1001 def author_email(self):
1001 1002 """
1002 1003 Returns author email address for this commit
1003 1004 """
1004 1005
1005 1006 return author_email(self.author)
1006 1007
1007 1008 def get_file_mode(self, path):
1008 1009 """
1009 1010 Returns stat mode of the file at `path`.
1010 1011 """
1011 1012 raise NotImplementedError
1012 1013
1013 1014 def is_link(self, path):
1014 1015 """
1015 1016 Returns ``True`` if given `path` is a symlink
1016 1017 """
1017 1018 raise NotImplementedError
1018 1019
1019 1020 def get_file_content(self, path):
1020 1021 """
1021 1022 Returns content of the file at the given `path`.
1022 1023 """
1023 1024 raise NotImplementedError
1024 1025
1025 1026 def get_file_size(self, path):
1026 1027 """
1027 1028 Returns size of the file at the given `path`.
1028 1029 """
1029 1030 raise NotImplementedError
1030 1031
1031 1032 def get_path_commit(self, path, pre_load=None):
1032 1033 """
1033 1034 Returns last commit of the file at the given `path`.
1034 1035
1035 1036 :param pre_load: Optional. List of commit attributes to load.
1036 1037 """
1037 1038 commits = self.get_path_history(path, limit=1, pre_load=pre_load)
1038 1039 if not commits:
1039 1040 raise RepositoryError(
1040 1041 'Failed to fetch history for path {}. '
1041 1042 'Please check if such path exists in your repository'.format(
1042 1043 path))
1043 1044 return commits[0]
1044 1045
1045 1046 def get_path_history(self, path, limit=None, pre_load=None):
1046 1047 """
1047 1048 Returns history of file as reversed list of :class:`BaseCommit`
1048 1049 objects for which file at given `path` has been modified.
1049 1050
1050 1051 :param limit: Optional. Allows to limit the size of the returned
1051 1052 history. This is intended as a hint to the underlying backend, so
1052 1053 that it can apply optimizations depending on the limit.
1053 1054 :param pre_load: Optional. List of commit attributes to load.
1054 1055 """
1055 1056 raise NotImplementedError
1056 1057
1057 1058 def get_file_annotate(self, path, pre_load=None):
1058 1059 """
1059 1060 Returns a generator of four element tuples with
1060 1061 lineno, sha, commit lazy loader and line
1061 1062
1062 1063 :param pre_load: Optional. List of commit attributes to load.
1063 1064 """
1064 1065 raise NotImplementedError
1065 1066
1066 1067 def get_nodes(self, path):
1067 1068 """
1068 1069 Returns combined ``DirNode`` and ``FileNode`` objects list representing
1069 1070 state of commit at the given ``path``.
1070 1071
1071 1072 :raises ``CommitError``: if node at the given ``path`` is not
1072 1073 instance of ``DirNode``
1073 1074 """
1074 1075 raise NotImplementedError
1075 1076
1076 1077 def get_node(self, path):
1077 1078 """
1078 1079 Returns ``Node`` object from the given ``path``.
1079 1080
1080 1081 :raises ``NodeDoesNotExistError``: if there is no node at the given
1081 1082 ``path``
1082 1083 """
1083 1084 raise NotImplementedError
1084 1085
1085 1086 def get_largefile_node(self, path):
1086 1087 """
1087 1088 Returns the path to largefile from Mercurial/Git-lfs storage.
1088 1089 or None if it's not a largefile node
1089 1090 """
1090 1091 return None
1091 1092
1092 1093 def archive_repo(self, file_path, kind='tgz', subrepos=None,
1093 1094 prefix=None, write_metadata=False, mtime=None):
1094 1095 """
1095 1096 Creates an archive containing the contents of the repository.
1096 1097
1097 1098 :param file_path: path to the file which to create the archive.
1098 1099 :param kind: one of following: ``"tbz2"``, ``"tgz"``, ``"zip"``.
1099 1100 :param prefix: name of root directory in archive.
1100 1101 Default is repository name and commit's short_id joined with dash:
1101 1102 ``"{repo_name}-{short_id}"``.
1102 1103 :param write_metadata: write a metadata file into archive.
1103 1104 :param mtime: custom modification time for archive creation, defaults
1104 1105 to time.time() if not given.
1105 1106
1106 1107 :raise VCSError: If prefix has a problem.
1107 1108 """
1108 1109 allowed_kinds = settings.ARCHIVE_SPECS.keys()
1109 1110 if kind not in allowed_kinds:
1110 1111 raise ImproperArchiveTypeError(
1111 1112 'Archive kind (%s) not supported use one of %s' %
1112 1113 (kind, allowed_kinds))
1113 1114
1114 1115 prefix = self._validate_archive_prefix(prefix)
1115 1116
1116 1117 mtime = mtime or time.mktime(self.date.timetuple())
1117 1118
1118 1119 file_info = []
1119 1120 cur_rev = self.repository.get_commit(commit_id=self.raw_id)
1120 1121 for _r, _d, files in cur_rev.walk('/'):
1121 1122 for f in files:
1122 1123 f_path = os.path.join(prefix, f.path)
1123 1124 file_info.append(
1124 1125 (f_path, f.mode, f.is_link(), f.raw_bytes))
1125 1126
1126 1127 if write_metadata:
1127 1128 metadata = [
1128 1129 ('repo_name', self.repository.name),
1129 1130 ('rev', self.raw_id),
1130 1131 ('create_time', mtime),
1131 1132 ('branch', self.branch),
1132 1133 ('tags', ','.join(self.tags)),
1133 1134 ]
1134 1135 meta = ["%s:%s" % (f_name, value) for f_name, value in metadata]
1135 1136 file_info.append(('.archival.txt', 0o644, False, '\n'.join(meta)))
1136 1137
1137 1138 connection.Hg.archive_repo(file_path, mtime, file_info, kind)
1138 1139
1139 1140 def _validate_archive_prefix(self, prefix):
1140 1141 if prefix is None:
1141 1142 prefix = self._ARCHIVE_PREFIX_TEMPLATE.format(
1142 1143 repo_name=safe_str(self.repository.name),
1143 1144 short_id=self.short_id)
1144 1145 elif not isinstance(prefix, str):
1145 1146 raise ValueError("prefix not a bytes object: %s" % repr(prefix))
1146 1147 elif prefix.startswith('/'):
1147 1148 raise VCSError("Prefix cannot start with leading slash")
1148 1149 elif prefix.strip() == '':
1149 1150 raise VCSError("Prefix cannot be empty")
1150 1151 return prefix
1151 1152
1152 1153 @LazyProperty
1153 1154 def root(self):
1154 1155 """
1155 1156 Returns ``RootNode`` object for this commit.
1156 1157 """
1157 1158 return self.get_node('')
1158 1159
1159 1160 def next(self, branch=None):
1160 1161 """
1161 1162 Returns next commit from current, if branch is gives it will return
1162 1163 next commit belonging to this branch
1163 1164
1164 1165 :param branch: show commits within the given named branch
1165 1166 """
1166 1167 indexes = xrange(self.idx + 1, self.repository.count())
1167 1168 return self._find_next(indexes, branch)
1168 1169
1169 1170 def prev(self, branch=None):
1170 1171 """
1171 1172 Returns previous commit from current, if branch is gives it will
1172 1173 return previous commit belonging to this branch
1173 1174
1174 1175 :param branch: show commit within the given named branch
1175 1176 """
1176 1177 indexes = xrange(self.idx - 1, -1, -1)
1177 1178 return self._find_next(indexes, branch)
1178 1179
1179 1180 def _find_next(self, indexes, branch=None):
1180 1181 if branch and self.branch != branch:
1181 1182 raise VCSError('Branch option used on commit not belonging '
1182 1183 'to that branch')
1183 1184
1184 1185 for next_idx in indexes:
1185 1186 commit = self.repository.get_commit(commit_idx=next_idx)
1186 1187 if branch and branch != commit.branch:
1187 1188 continue
1188 1189 return commit
1189 1190 raise CommitDoesNotExistError
1190 1191
1191 1192 def diff(self, ignore_whitespace=True, context=3):
1192 1193 """
1193 1194 Returns a `Diff` object representing the change made by this commit.
1194 1195 """
1195 1196 parent = self.first_parent
1196 1197 diff = self.repository.get_diff(
1197 1198 parent, self,
1198 1199 ignore_whitespace=ignore_whitespace,
1199 1200 context=context)
1200 1201 return diff
1201 1202
1202 1203 @LazyProperty
1203 1204 def added(self):
1204 1205 """
1205 1206 Returns list of added ``FileNode`` objects.
1206 1207 """
1207 1208 raise NotImplementedError
1208 1209
1209 1210 @LazyProperty
1210 1211 def changed(self):
1211 1212 """
1212 1213 Returns list of modified ``FileNode`` objects.
1213 1214 """
1214 1215 raise NotImplementedError
1215 1216
1216 1217 @LazyProperty
1217 1218 def removed(self):
1218 1219 """
1219 1220 Returns list of removed ``FileNode`` objects.
1220 1221 """
1221 1222 raise NotImplementedError
1222 1223
1223 1224 @LazyProperty
1224 1225 def size(self):
1225 1226 """
1226 1227 Returns total number of bytes from contents of all filenodes.
1227 1228 """
1228 1229 return sum((node.size for node in self.get_filenodes_generator()))
1229 1230
1230 1231 def walk(self, topurl=''):
1231 1232 """
1232 1233 Similar to os.walk method. Insted of filesystem it walks through
1233 1234 commit starting at given ``topurl``. Returns generator of tuples
1234 1235 (topnode, dirnodes, filenodes).
1235 1236 """
1236 1237 topnode = self.get_node(topurl)
1237 1238 if not topnode.is_dir():
1238 1239 return
1239 1240 yield (topnode, topnode.dirs, topnode.files)
1240 1241 for dirnode in topnode.dirs:
1241 1242 for tup in self.walk(dirnode.path):
1242 1243 yield tup
1243 1244
1244 1245 def get_filenodes_generator(self):
1245 1246 """
1246 1247 Returns generator that yields *all* file nodes.
1247 1248 """
1248 1249 for topnode, dirs, files in self.walk():
1249 1250 for node in files:
1250 1251 yield node
1251 1252
1252 1253 #
1253 1254 # Utilities for sub classes to support consistent behavior
1254 1255 #
1255 1256
1256 1257 def no_node_at_path(self, path):
1257 1258 return NodeDoesNotExistError(
1258 1259 u"There is no file nor directory at the given path: "
1259 1260 u"`%s` at commit %s" % (safe_unicode(path), self.short_id))
1260 1261
1261 1262 def _fix_path(self, path):
1262 1263 """
1263 1264 Paths are stored without trailing slash so we need to get rid off it if
1264 1265 needed.
1265 1266 """
1266 1267 return path.rstrip('/')
1267 1268
1268 1269 #
1269 1270 # Deprecated API based on changesets
1270 1271 #
1271 1272
1272 1273 @property
1273 1274 def revision(self):
1274 1275 warnings.warn("Use idx instead", DeprecationWarning)
1275 1276 return self.idx
1276 1277
1277 1278 @revision.setter
1278 1279 def revision(self, value):
1279 1280 warnings.warn("Use idx instead", DeprecationWarning)
1280 1281 self.idx = value
1281 1282
1282 1283 def get_file_changeset(self, path):
1283 1284 warnings.warn("Use get_path_commit instead", DeprecationWarning)
1284 1285 return self.get_path_commit(path)
1285 1286
1286 1287
1287 1288 class BaseChangesetClass(type):
1288 1289
1289 1290 def __instancecheck__(self, instance):
1290 1291 return isinstance(instance, BaseCommit)
1291 1292
1292 1293
1293 1294 class BaseChangeset(BaseCommit):
1294 1295
1295 1296 __metaclass__ = BaseChangesetClass
1296 1297
1297 1298 def __new__(cls, *args, **kwargs):
1298 1299 warnings.warn(
1299 1300 "Use BaseCommit instead of BaseChangeset", DeprecationWarning)
1300 1301 return super(BaseChangeset, cls).__new__(cls, *args, **kwargs)
1301 1302
1302 1303
1303 1304 class BaseInMemoryCommit(object):
1304 1305 """
1305 1306 Represents differences between repository's state (most recent head) and
1306 1307 changes made *in place*.
1307 1308
1308 1309 **Attributes**
1309 1310
1310 1311 ``repository``
1311 1312 repository object for this in-memory-commit
1312 1313
1313 1314 ``added``
1314 1315 list of ``FileNode`` objects marked as *added*
1315 1316
1316 1317 ``changed``
1317 1318 list of ``FileNode`` objects marked as *changed*
1318 1319
1319 1320 ``removed``
1320 1321 list of ``FileNode`` or ``RemovedFileNode`` objects marked to be
1321 1322 *removed*
1322 1323
1323 1324 ``parents``
1324 1325 list of :class:`BaseCommit` instances representing parents of
1325 1326 in-memory commit. Should always be 2-element sequence.
1326 1327
1327 1328 """
1328 1329
1329 1330 def __init__(self, repository):
1330 1331 self.repository = repository
1331 1332 self.added = []
1332 1333 self.changed = []
1333 1334 self.removed = []
1334 1335 self.parents = []
1335 1336
1336 1337 def add(self, *filenodes):
1337 1338 """
1338 1339 Marks given ``FileNode`` objects as *to be committed*.
1339 1340
1340 1341 :raises ``NodeAlreadyExistsError``: if node with same path exists at
1341 1342 latest commit
1342 1343 :raises ``NodeAlreadyAddedError``: if node with same path is already
1343 1344 marked as *added*
1344 1345 """
1345 1346 # Check if not already marked as *added* first
1346 1347 for node in filenodes:
1347 1348 if node.path in (n.path for n in self.added):
1348 1349 raise NodeAlreadyAddedError(
1349 1350 "Such FileNode %s is already marked for addition"
1350 1351 % node.path)
1351 1352 for node in filenodes:
1352 1353 self.added.append(node)
1353 1354
1354 1355 def change(self, *filenodes):
1355 1356 """
1356 1357 Marks given ``FileNode`` objects to be *changed* in next commit.
1357 1358
1358 1359 :raises ``EmptyRepositoryError``: if there are no commits yet
1359 1360 :raises ``NodeAlreadyExistsError``: if node with same path is already
1360 1361 marked to be *changed*
1361 1362 :raises ``NodeAlreadyRemovedError``: if node with same path is already
1362 1363 marked to be *removed*
1363 1364 :raises ``NodeDoesNotExistError``: if node doesn't exist in latest
1364 1365 commit
1365 1366 :raises ``NodeNotChangedError``: if node hasn't really be changed
1366 1367 """
1367 1368 for node in filenodes:
1368 1369 if node.path in (n.path for n in self.removed):
1369 1370 raise NodeAlreadyRemovedError(
1370 1371 "Node at %s is already marked as removed" % node.path)
1371 1372 try:
1372 1373 self.repository.get_commit()
1373 1374 except EmptyRepositoryError:
1374 1375 raise EmptyRepositoryError(
1375 1376 "Nothing to change - try to *add* new nodes rather than "
1376 1377 "changing them")
1377 1378 for node in filenodes:
1378 1379 if node.path in (n.path for n in self.changed):
1379 1380 raise NodeAlreadyChangedError(
1380 1381 "Node at '%s' is already marked as changed" % node.path)
1381 1382 self.changed.append(node)
1382 1383
1383 1384 def remove(self, *filenodes):
1384 1385 """
1385 1386 Marks given ``FileNode`` (or ``RemovedFileNode``) objects to be
1386 1387 *removed* in next commit.
1387 1388
1388 1389 :raises ``NodeAlreadyRemovedError``: if node has been already marked to
1389 1390 be *removed*
1390 1391 :raises ``NodeAlreadyChangedError``: if node has been already marked to
1391 1392 be *changed*
1392 1393 """
1393 1394 for node in filenodes:
1394 1395 if node.path in (n.path for n in self.removed):
1395 1396 raise NodeAlreadyRemovedError(
1396 1397 "Node is already marked to for removal at %s" % node.path)
1397 1398 if node.path in (n.path for n in self.changed):
1398 1399 raise NodeAlreadyChangedError(
1399 1400 "Node is already marked to be changed at %s" % node.path)
1400 1401 # We only mark node as *removed* - real removal is done by
1401 1402 # commit method
1402 1403 self.removed.append(node)
1403 1404
1404 1405 def reset(self):
1405 1406 """
1406 1407 Resets this instance to initial state (cleans ``added``, ``changed``
1407 1408 and ``removed`` lists).
1408 1409 """
1409 1410 self.added = []
1410 1411 self.changed = []
1411 1412 self.removed = []
1412 1413 self.parents = []
1413 1414
1414 1415 def get_ipaths(self):
1415 1416 """
1416 1417 Returns generator of paths from nodes marked as added, changed or
1417 1418 removed.
1418 1419 """
1419 1420 for node in itertools.chain(self.added, self.changed, self.removed):
1420 1421 yield node.path
1421 1422
1422 1423 def get_paths(self):
1423 1424 """
1424 1425 Returns list of paths from nodes marked as added, changed or removed.
1425 1426 """
1426 1427 return list(self.get_ipaths())
1427 1428
1428 1429 def check_integrity(self, parents=None):
1429 1430 """
1430 1431 Checks in-memory commit's integrity. Also, sets parents if not
1431 1432 already set.
1432 1433
1433 1434 :raises CommitError: if any error occurs (i.e.
1434 1435 ``NodeDoesNotExistError``).
1435 1436 """
1436 1437 if not self.parents:
1437 1438 parents = parents or []
1438 1439 if len(parents) == 0:
1439 1440 try:
1440 1441 parents = [self.repository.get_commit(), None]
1441 1442 except EmptyRepositoryError:
1442 1443 parents = [None, None]
1443 1444 elif len(parents) == 1:
1444 1445 parents += [None]
1445 1446 self.parents = parents
1446 1447
1447 1448 # Local parents, only if not None
1448 1449 parents = [p for p in self.parents if p]
1449 1450
1450 1451 # Check nodes marked as added
1451 1452 for p in parents:
1452 1453 for node in self.added:
1453 1454 try:
1454 1455 p.get_node(node.path)
1455 1456 except NodeDoesNotExistError:
1456 1457 pass
1457 1458 else:
1458 1459 raise NodeAlreadyExistsError(
1459 1460 "Node `%s` already exists at %s" % (node.path, p))
1460 1461
1461 1462 # Check nodes marked as changed
1462 1463 missing = set(self.changed)
1463 1464 not_changed = set(self.changed)
1464 1465 if self.changed and not parents:
1465 1466 raise NodeDoesNotExistError(str(self.changed[0].path))
1466 1467 for p in parents:
1467 1468 for node in self.changed:
1468 1469 try:
1469 1470 old = p.get_node(node.path)
1470 1471 missing.remove(node)
1471 1472 # if content actually changed, remove node from not_changed
1472 1473 if old.content != node.content:
1473 1474 not_changed.remove(node)
1474 1475 except NodeDoesNotExistError:
1475 1476 pass
1476 1477 if self.changed and missing:
1477 1478 raise NodeDoesNotExistError(
1478 1479 "Node `%s` marked as modified but missing in parents: %s"
1479 1480 % (node.path, parents))
1480 1481
1481 1482 if self.changed and not_changed:
1482 1483 raise NodeNotChangedError(
1483 1484 "Node `%s` wasn't actually changed (parents: %s)"
1484 1485 % (not_changed.pop().path, parents))
1485 1486
1486 1487 # Check nodes marked as removed
1487 1488 if self.removed and not parents:
1488 1489 raise NodeDoesNotExistError(
1489 1490 "Cannot remove node at %s as there "
1490 1491 "were no parents specified" % self.removed[0].path)
1491 1492 really_removed = set()
1492 1493 for p in parents:
1493 1494 for node in self.removed:
1494 1495 try:
1495 1496 p.get_node(node.path)
1496 1497 really_removed.add(node)
1497 1498 except CommitError:
1498 1499 pass
1499 1500 not_removed = set(self.removed) - really_removed
1500 1501 if not_removed:
1501 1502 # TODO: johbo: This code branch does not seem to be covered
1502 1503 raise NodeDoesNotExistError(
1503 1504 "Cannot remove node at %s from "
1504 1505 "following parents: %s" % (not_removed, parents))
1505 1506
1506 1507 def commit(
1507 1508 self, message, author, parents=None, branch=None, date=None,
1508 1509 **kwargs):
1509 1510 """
1510 1511 Performs in-memory commit (doesn't check workdir in any way) and
1511 1512 returns newly created :class:`BaseCommit`. Updates repository's
1512 1513 attribute `commits`.
1513 1514
1514 1515 .. note::
1515 1516
1516 1517 While overriding this method each backend's should call
1517 1518 ``self.check_integrity(parents)`` in the first place.
1518 1519
1519 1520 :param message: message of the commit
1520 1521 :param author: full username, i.e. "Joe Doe <joe.doe@example.com>"
1521 1522 :param parents: single parent or sequence of parents from which commit
1522 1523 would be derived
1523 1524 :param date: ``datetime.datetime`` instance. Defaults to
1524 1525 ``datetime.datetime.now()``.
1525 1526 :param branch: branch name, as string. If none given, default backend's
1526 1527 branch would be used.
1527 1528
1528 1529 :raises ``CommitError``: if any error occurs while committing
1529 1530 """
1530 1531 raise NotImplementedError
1531 1532
1532 1533
1533 1534 class BaseInMemoryChangesetClass(type):
1534 1535
1535 1536 def __instancecheck__(self, instance):
1536 1537 return isinstance(instance, BaseInMemoryCommit)
1537 1538
1538 1539
1539 1540 class BaseInMemoryChangeset(BaseInMemoryCommit):
1540 1541
1541 1542 __metaclass__ = BaseInMemoryChangesetClass
1542 1543
1543 1544 def __new__(cls, *args, **kwargs):
1544 1545 warnings.warn(
1545 1546 "Use BaseCommit instead of BaseInMemoryCommit", DeprecationWarning)
1546 1547 return super(BaseInMemoryChangeset, cls).__new__(cls, *args, **kwargs)
1547 1548
1548 1549
1549 1550 class EmptyCommit(BaseCommit):
1550 1551 """
1551 1552 An dummy empty commit. It's possible to pass hash when creating
1552 1553 an EmptyCommit
1553 1554 """
1554 1555
1555 1556 def __init__(
1556 1557 self, commit_id='0' * 40, repo=None, alias=None, idx=-1,
1557 1558 message='', author='', date=None):
1558 1559 self._empty_commit_id = commit_id
1559 1560 # TODO: johbo: Solve idx parameter, default value does not make
1560 1561 # too much sense
1561 1562 self.idx = idx
1562 1563 self.message = message
1563 1564 self.author = author
1564 1565 self.date = date or datetime.datetime.fromtimestamp(0)
1565 1566 self.repository = repo
1566 1567 self.alias = alias
1567 1568
1568 1569 @LazyProperty
1569 1570 def raw_id(self):
1570 1571 """
1571 1572 Returns raw string identifying this commit, useful for web
1572 1573 representation.
1573 1574 """
1574 1575
1575 1576 return self._empty_commit_id
1576 1577
1577 1578 @LazyProperty
1578 1579 def branch(self):
1579 1580 if self.alias:
1580 1581 from rhodecode.lib.vcs.backends import get_backend
1581 1582 return get_backend(self.alias).DEFAULT_BRANCH_NAME
1582 1583
1583 1584 @LazyProperty
1584 1585 def short_id(self):
1585 1586 return self.raw_id[:12]
1586 1587
1587 1588 @LazyProperty
1588 1589 def id(self):
1589 1590 return self.raw_id
1590 1591
1591 1592 def get_path_commit(self, path):
1592 1593 return self
1593 1594
1594 1595 def get_file_content(self, path):
1595 1596 return u''
1596 1597
1597 1598 def get_file_size(self, path):
1598 1599 return 0
1599 1600
1600 1601
1601 1602 class EmptyChangesetClass(type):
1602 1603
1603 1604 def __instancecheck__(self, instance):
1604 1605 return isinstance(instance, EmptyCommit)
1605 1606
1606 1607
1607 1608 class EmptyChangeset(EmptyCommit):
1608 1609
1609 1610 __metaclass__ = EmptyChangesetClass
1610 1611
1611 1612 def __new__(cls, *args, **kwargs):
1612 1613 warnings.warn(
1613 1614 "Use EmptyCommit instead of EmptyChangeset", DeprecationWarning)
1614 1615 return super(EmptyCommit, cls).__new__(cls, *args, **kwargs)
1615 1616
1616 1617 def __init__(self, cs='0' * 40, repo=None, requested_revision=None,
1617 1618 alias=None, revision=-1, message='', author='', date=None):
1618 1619 if requested_revision is not None:
1619 1620 warnings.warn(
1620 1621 "Parameter requested_revision not supported anymore",
1621 1622 DeprecationWarning)
1622 1623 super(EmptyChangeset, self).__init__(
1623 1624 commit_id=cs, repo=repo, alias=alias, idx=revision,
1624 1625 message=message, author=author, date=date)
1625 1626
1626 1627 @property
1627 1628 def revision(self):
1628 1629 warnings.warn("Use idx instead", DeprecationWarning)
1629 1630 return self.idx
1630 1631
1631 1632 @revision.setter
1632 1633 def revision(self, value):
1633 1634 warnings.warn("Use idx instead", DeprecationWarning)
1634 1635 self.idx = value
1635 1636
1636 1637
1637 1638 class EmptyRepository(BaseRepository):
1638 1639 def __init__(self, repo_path=None, config=None, create=False, **kwargs):
1639 1640 pass
1640 1641
1641 1642 def get_diff(self, *args, **kwargs):
1642 1643 from rhodecode.lib.vcs.backends.git.diff import GitDiff
1643 1644 return GitDiff('')
1644 1645
1645 1646
1646 1647 class CollectionGenerator(object):
1647 1648
1648 1649 def __init__(self, repo, commit_ids, collection_size=None, pre_load=None):
1649 1650 self.repo = repo
1650 1651 self.commit_ids = commit_ids
1651 1652 # TODO: (oliver) this isn't currently hooked up
1652 1653 self.collection_size = None
1653 1654 self.pre_load = pre_load
1654 1655
1655 1656 def __len__(self):
1656 1657 if self.collection_size is not None:
1657 1658 return self.collection_size
1658 1659 return self.commit_ids.__len__()
1659 1660
1660 1661 def __iter__(self):
1661 1662 for commit_id in self.commit_ids:
1662 1663 # TODO: johbo: Mercurial passes in commit indices or commit ids
1663 1664 yield self._commit_factory(commit_id)
1664 1665
1665 1666 def _commit_factory(self, commit_id):
1666 1667 """
1667 1668 Allows backends to override the way commits are generated.
1668 1669 """
1669 1670 return self.repo.get_commit(commit_id=commit_id,
1670 1671 pre_load=self.pre_load)
1671 1672
1672 1673 def __getslice__(self, i, j):
1673 1674 """
1674 1675 Returns an iterator of sliced repository
1675 1676 """
1676 1677 commit_ids = self.commit_ids[i:j]
1677 1678 return self.__class__(
1678 1679 self.repo, commit_ids, pre_load=self.pre_load)
1679 1680
1680 1681 def __repr__(self):
1681 1682 return '<CollectionGenerator[len:%s]>' % (self.__len__())
1682 1683
1683 1684
1684 1685 class Config(object):
1685 1686 """
1686 1687 Represents the configuration for a repository.
1687 1688
1688 1689 The API is inspired by :class:`ConfigParser.ConfigParser` from the
1689 1690 standard library. It implements only the needed subset.
1690 1691 """
1691 1692
1692 1693 def __init__(self):
1693 1694 self._values = {}
1694 1695
1695 1696 def copy(self):
1696 1697 clone = Config()
1697 1698 for section, values in self._values.items():
1698 1699 clone._values[section] = values.copy()
1699 1700 return clone
1700 1701
1701 1702 def __repr__(self):
1702 1703 return '<Config(%s sections) at %s>' % (
1703 1704 len(self._values), hex(id(self)))
1704 1705
1705 1706 def items(self, section):
1706 1707 return self._values.get(section, {}).iteritems()
1707 1708
1708 1709 def get(self, section, option):
1709 1710 return self._values.get(section, {}).get(option)
1710 1711
1711 1712 def set(self, section, option, value):
1712 1713 section_values = self._values.setdefault(section, {})
1713 1714 section_values[option] = value
1714 1715
1715 1716 def clear_section(self, section):
1716 1717 self._values[section] = {}
1717 1718
1718 1719 def serialize(self):
1719 1720 """
1720 1721 Creates a list of three tuples (section, key, value) representing
1721 1722 this config object.
1722 1723 """
1723 1724 items = []
1724 1725 for section in self._values:
1725 1726 for option, value in self._values[section].items():
1726 1727 items.append(
1727 1728 (safe_str(section), safe_str(option), safe_str(value)))
1728 1729 return items
1729 1730
1730 1731
1731 1732 class Diff(object):
1732 1733 """
1733 1734 Represents a diff result from a repository backend.
1734 1735
1735 1736 Subclasses have to provide a backend specific value for
1736 1737 :attr:`_header_re` and :attr:`_meta_re`.
1737 1738 """
1738 1739 _meta_re = None
1739 1740 _header_re = None
1740 1741
1741 1742 def __init__(self, raw_diff):
1742 1743 self.raw = raw_diff
1743 1744
1744 1745 def chunks(self):
1745 1746 """
1746 1747 split the diff in chunks of separate --git a/file b/file chunks
1747 1748 to make diffs consistent we must prepend with \n, and make sure
1748 1749 we can detect last chunk as this was also has special rule
1749 1750 """
1750 1751
1751 1752 diff_parts = ('\n' + self.raw).split('\ndiff --git')
1752 1753 header = diff_parts[0]
1753 1754
1754 1755 if self._meta_re:
1755 1756 match = self._meta_re.match(header)
1756 1757
1757 1758 chunks = diff_parts[1:]
1758 1759 total_chunks = len(chunks)
1759 1760
1760 1761 return (
1761 1762 DiffChunk(chunk, self, cur_chunk == total_chunks)
1762 1763 for cur_chunk, chunk in enumerate(chunks, start=1))
1763 1764
1764 1765
1765 1766 class DiffChunk(object):
1766 1767
1767 1768 def __init__(self, chunk, diff, last_chunk):
1768 1769 self._diff = diff
1769 1770
1770 1771 # since we split by \ndiff --git that part is lost from original diff
1771 1772 # we need to re-apply it at the end, EXCEPT ! if it's last chunk
1772 1773 if not last_chunk:
1773 1774 chunk += '\n'
1774 1775
1775 1776 match = self._diff._header_re.match(chunk)
1776 1777 self.header = match.groupdict()
1777 1778 self.diff = chunk[match.end():]
1778 1779 self.raw = chunk
1779 1780
1780 1781
1781 1782 class BasePathPermissionChecker(object):
1782 1783
1783 1784 @staticmethod
1784 1785 def create_from_patterns(includes, excludes):
1785 1786 if includes and '*' in includes and not excludes:
1786 1787 return AllPathPermissionChecker()
1787 1788 elif excludes and '*' in excludes:
1788 1789 return NonePathPermissionChecker()
1789 1790 else:
1790 1791 return PatternPathPermissionChecker(includes, excludes)
1791 1792
1792 1793 @property
1793 1794 def has_full_access(self):
1794 1795 raise NotImplemented()
1795 1796
1796 1797 def has_access(self, path):
1797 1798 raise NotImplemented()
1798 1799
1799 1800
1800 1801 class AllPathPermissionChecker(BasePathPermissionChecker):
1801 1802
1802 1803 @property
1803 1804 def has_full_access(self):
1804 1805 return True
1805 1806
1806 1807 def has_access(self, path):
1807 1808 return True
1808 1809
1809 1810
1810 1811 class NonePathPermissionChecker(BasePathPermissionChecker):
1811 1812
1812 1813 @property
1813 1814 def has_full_access(self):
1814 1815 return False
1815 1816
1816 1817 def has_access(self, path):
1817 1818 return False
1818 1819
1819 1820
1820 1821 class PatternPathPermissionChecker(BasePathPermissionChecker):
1821 1822
1822 1823 def __init__(self, includes, excludes):
1823 1824 self.includes = includes
1824 1825 self.excludes = excludes
1825 1826 self.includes_re = [] if not includes else [
1826 1827 re.compile(fnmatch.translate(pattern)) for pattern in includes]
1827 1828 self.excludes_re = [] if not excludes else [
1828 1829 re.compile(fnmatch.translate(pattern)) for pattern in excludes]
1829 1830
1830 1831 @property
1831 1832 def has_full_access(self):
1832 1833 return '*' in self.includes and not self.excludes
1833 1834
1834 1835 def has_access(self, path):
1835 1836 for regex in self.excludes_re:
1836 1837 if regex.match(path):
1837 1838 return False
1838 1839 for regex in self.includes_re:
1839 1840 if regex.match(path):
1840 1841 return True
1841 1842 return False
General Comments 0
You need to be logged in to leave comments. Login now