##// END OF EJS Templates
hooks: added new hooks for comments on pull requests and commits....
dan -
r4305:de8db8da default
parent child Browse files
Show More
@@ -0,0 +1,60 b''
1 # Example to trigger a CI call action on specific comment text, e.g chatops and ci
2 # rebuild on mention of ci bot
3
4 @has_kwargs({
5 'repo_name': '',
6 'repo_type': '',
7 'description': '',
8 'private': '',
9 'created_on': '',
10 'enable_downloads': '',
11 'repo_id': '',
12 'user_id': '',
13 'enable_statistics': '',
14 'clone_uri': '',
15 'fork_id': '',
16 'group_id': '',
17 'created_by': '',
18 'repository': '',
19 'comment': '',
20 'commit': ''
21 })
22 def _comment_commit_repo_hook(*args, **kwargs):
23 """
24 POST CREATE REPOSITORY COMMENT ON COMMIT HOOK. This function will be executed after
25 a comment is made on this repository commit.
26
27 """
28 from .helpers import http_call, extra_fields
29 from .utils import UrlTemplate
30 # returns list of dicts with key-val fetched from extra fields
31 repo_extra_fields = extra_fields.run(**kwargs)
32
33 import rhodecode
34 from rc_integrations.jenkins_ci import csrf_call, get_auth, requests_retry_call
35
36 endpoint_url = extra_fields.get_field(
37 repo_extra_fields, key='ci_endpoint_url',
38 default='http://ci.rc.com/job/rc-ce-commits/build?COMMIT_ID=${commit}')
39 mention_text = extra_fields.get_field(
40 repo_extra_fields, key='ci_mention_text',
41 default='@jenkins build')
42
43 endpoint_url = UrlTemplate(endpoint_url).safe_substitute(
44 commit=kwargs['commit']['raw_id'])
45
46 trigger_ci = False
47 comment = kwargs['comment']['comment_text']
48 if mention_text in comment:
49 trigger_ci = True
50
51 if trigger_ci is False:
52 return HookResponse(0, '')
53
54 # call some CI based on the special coment mention marker
55 data = {
56 'project': kwargs['repository'],
57 }
58 response = http_call.run(url=endpoint_url, params=data)
59
60 return HookResponse(0, '') No newline at end of file
@@ -1,2343 +1,2348 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 import logging
22 22 import time
23 23
24 24 import rhodecode
25 25 from rhodecode.api import (
26 26 jsonrpc_method, JSONRPCError, JSONRPCForbidden, JSONRPCValidationError)
27 27 from rhodecode.api.utils import (
28 28 has_superadmin_permission, Optional, OAttr, get_repo_or_error,
29 29 get_user_group_or_error, get_user_or_error, validate_repo_permissions,
30 30 get_perm_or_error, parse_args, get_origin, build_commit_data,
31 31 validate_set_owner_permissions)
32 32 from rhodecode.lib import audit_logger, rc_cache
33 33 from rhodecode.lib import repo_maintenance
34 34 from rhodecode.lib.auth import HasPermissionAnyApi, HasUserGroupPermissionAnyApi
35 35 from rhodecode.lib.celerylib.utils import get_task_id
36 36 from rhodecode.lib.utils2 import str2bool, time_to_datetime, safe_str, safe_int
37 37 from rhodecode.lib.ext_json import json
38 38 from rhodecode.lib.exceptions import StatusChangeOnClosedPullRequestError
39 39 from rhodecode.lib.vcs import RepositoryError
40 40 from rhodecode.lib.vcs.exceptions import NodeDoesNotExistError
41 41 from rhodecode.model.changeset_status import ChangesetStatusModel
42 42 from rhodecode.model.comment import CommentsModel
43 43 from rhodecode.model.db import (
44 44 Session, ChangesetStatus, RepositoryField, Repository, RepoGroup,
45 45 ChangesetComment)
46 46 from rhodecode.model.permission import PermissionModel
47 47 from rhodecode.model.repo import RepoModel
48 48 from rhodecode.model.scm import ScmModel, RepoList
49 49 from rhodecode.model.settings import SettingsModel, VcsSettingsModel
50 50 from rhodecode.model import validation_schema
51 51 from rhodecode.model.validation_schema.schemas import repo_schema
52 52
53 53 log = logging.getLogger(__name__)
54 54
55 55
56 56 @jsonrpc_method()
57 57 def get_repo(request, apiuser, repoid, cache=Optional(True)):
58 58 """
59 59 Gets an existing repository by its name or repository_id.
60 60
61 61 The members section so the output returns users groups or users
62 62 associated with that repository.
63 63
64 64 This command can only be run using an |authtoken| with admin rights,
65 65 or users with at least read rights to the |repo|.
66 66
67 67 :param apiuser: This is filled automatically from the |authtoken|.
68 68 :type apiuser: AuthUser
69 69 :param repoid: The repository name or repository id.
70 70 :type repoid: str or int
71 71 :param cache: use the cached value for last changeset
72 72 :type: cache: Optional(bool)
73 73
74 74 Example output:
75 75
76 76 .. code-block:: bash
77 77
78 78 {
79 79 "error": null,
80 80 "id": <repo_id>,
81 81 "result": {
82 82 "clone_uri": null,
83 83 "created_on": "timestamp",
84 84 "description": "repo description",
85 85 "enable_downloads": false,
86 86 "enable_locking": false,
87 87 "enable_statistics": false,
88 88 "followers": [
89 89 {
90 90 "active": true,
91 91 "admin": false,
92 92 "api_key": "****************************************",
93 93 "api_keys": [
94 94 "****************************************"
95 95 ],
96 96 "email": "user@example.com",
97 97 "emails": [
98 98 "user@example.com"
99 99 ],
100 100 "extern_name": "rhodecode",
101 101 "extern_type": "rhodecode",
102 102 "firstname": "username",
103 103 "ip_addresses": [],
104 104 "language": null,
105 105 "last_login": "2015-09-16T17:16:35.854",
106 106 "lastname": "surname",
107 107 "user_id": <user_id>,
108 108 "username": "name"
109 109 }
110 110 ],
111 111 "fork_of": "parent-repo",
112 112 "landing_rev": [
113 113 "rev",
114 114 "tip"
115 115 ],
116 116 "last_changeset": {
117 117 "author": "User <user@example.com>",
118 118 "branch": "default",
119 119 "date": "timestamp",
120 120 "message": "last commit message",
121 121 "parents": [
122 122 {
123 123 "raw_id": "commit-id"
124 124 }
125 125 ],
126 126 "raw_id": "commit-id",
127 127 "revision": <revision number>,
128 128 "short_id": "short id"
129 129 },
130 130 "lock_reason": null,
131 131 "locked_by": null,
132 132 "locked_date": null,
133 133 "owner": "owner-name",
134 134 "permissions": [
135 135 {
136 136 "name": "super-admin-name",
137 137 "origin": "super-admin",
138 138 "permission": "repository.admin",
139 139 "type": "user"
140 140 },
141 141 {
142 142 "name": "owner-name",
143 143 "origin": "owner",
144 144 "permission": "repository.admin",
145 145 "type": "user"
146 146 },
147 147 {
148 148 "name": "user-group-name",
149 149 "origin": "permission",
150 150 "permission": "repository.write",
151 151 "type": "user_group"
152 152 }
153 153 ],
154 154 "private": true,
155 155 "repo_id": 676,
156 156 "repo_name": "user-group/repo-name",
157 157 "repo_type": "hg"
158 158 }
159 159 }
160 160 """
161 161
162 162 repo = get_repo_or_error(repoid)
163 163 cache = Optional.extract(cache)
164 164
165 165 include_secrets = False
166 166 if has_superadmin_permission(apiuser):
167 167 include_secrets = True
168 168 else:
169 169 # check if we have at least read permission for this repo !
170 170 _perms = (
171 171 'repository.admin', 'repository.write', 'repository.read',)
172 172 validate_repo_permissions(apiuser, repoid, repo, _perms)
173 173
174 174 permissions = []
175 175 for _user in repo.permissions():
176 176 user_data = {
177 177 'name': _user.username,
178 178 'permission': _user.permission,
179 179 'origin': get_origin(_user),
180 180 'type': "user",
181 181 }
182 182 permissions.append(user_data)
183 183
184 184 for _user_group in repo.permission_user_groups():
185 185 user_group_data = {
186 186 'name': _user_group.users_group_name,
187 187 'permission': _user_group.permission,
188 188 'origin': get_origin(_user_group),
189 189 'type': "user_group",
190 190 }
191 191 permissions.append(user_group_data)
192 192
193 193 following_users = [
194 194 user.user.get_api_data(include_secrets=include_secrets)
195 195 for user in repo.followers]
196 196
197 197 if not cache:
198 198 repo.update_commit_cache()
199 199 data = repo.get_api_data(include_secrets=include_secrets)
200 200 data['permissions'] = permissions
201 201 data['followers'] = following_users
202 202 return data
203 203
204 204
205 205 @jsonrpc_method()
206 206 def get_repos(request, apiuser, root=Optional(None), traverse=Optional(True)):
207 207 """
208 208 Lists all existing repositories.
209 209
210 210 This command can only be run using an |authtoken| with admin rights,
211 211 or users with at least read rights to |repos|.
212 212
213 213 :param apiuser: This is filled automatically from the |authtoken|.
214 214 :type apiuser: AuthUser
215 215 :param root: specify root repository group to fetch repositories.
216 216 filters the returned repositories to be members of given root group.
217 217 :type root: Optional(None)
218 218 :param traverse: traverse given root into subrepositories. With this flag
219 219 set to False, it will only return top-level repositories from `root`.
220 220 if root is empty it will return just top-level repositories.
221 221 :type traverse: Optional(True)
222 222
223 223
224 224 Example output:
225 225
226 226 .. code-block:: bash
227 227
228 228 id : <id_given_in_input>
229 229 result: [
230 230 {
231 231 "repo_id" : "<repo_id>",
232 232 "repo_name" : "<reponame>"
233 233 "repo_type" : "<repo_type>",
234 234 "clone_uri" : "<clone_uri>",
235 235 "private": : "<bool>",
236 236 "created_on" : "<datetimecreated>",
237 237 "description" : "<description>",
238 238 "landing_rev": "<landing_rev>",
239 239 "owner": "<repo_owner>",
240 240 "fork_of": "<name_of_fork_parent>",
241 241 "enable_downloads": "<bool>",
242 242 "enable_locking": "<bool>",
243 243 "enable_statistics": "<bool>",
244 244 },
245 245 ...
246 246 ]
247 247 error: null
248 248 """
249 249
250 250 include_secrets = has_superadmin_permission(apiuser)
251 251 _perms = ('repository.read', 'repository.write', 'repository.admin',)
252 252 extras = {'user': apiuser}
253 253
254 254 root = Optional.extract(root)
255 255 traverse = Optional.extract(traverse, binary=True)
256 256
257 257 if root:
258 258 # verify parent existance, if it's empty return an error
259 259 parent = RepoGroup.get_by_group_name(root)
260 260 if not parent:
261 261 raise JSONRPCError(
262 262 'Root repository group `{}` does not exist'.format(root))
263 263
264 264 if traverse:
265 265 repos = RepoModel().get_repos_for_root(root=root, traverse=traverse)
266 266 else:
267 267 repos = RepoModel().get_repos_for_root(root=parent)
268 268 else:
269 269 if traverse:
270 270 repos = RepoModel().get_all()
271 271 else:
272 272 # return just top-level
273 273 repos = RepoModel().get_repos_for_root(root=None)
274 274
275 275 repo_list = RepoList(repos, perm_set=_perms, extra_kwargs=extras)
276 276 return [repo.get_api_data(include_secrets=include_secrets)
277 277 for repo in repo_list]
278 278
279 279
280 280 @jsonrpc_method()
281 281 def get_repo_changeset(request, apiuser, repoid, revision,
282 282 details=Optional('basic')):
283 283 """
284 284 Returns information about a changeset.
285 285
286 286 Additionally parameters define the amount of details returned by
287 287 this function.
288 288
289 289 This command can only be run using an |authtoken| with admin rights,
290 290 or users with at least read rights to the |repo|.
291 291
292 292 :param apiuser: This is filled automatically from the |authtoken|.
293 293 :type apiuser: AuthUser
294 294 :param repoid: The repository name or repository id
295 295 :type repoid: str or int
296 296 :param revision: revision for which listing should be done
297 297 :type revision: str
298 298 :param details: details can be 'basic|extended|full' full gives diff
299 299 info details like the diff itself, and number of changed files etc.
300 300 :type details: Optional(str)
301 301
302 302 """
303 303 repo = get_repo_or_error(repoid)
304 304 if not has_superadmin_permission(apiuser):
305 305 _perms = (
306 306 'repository.admin', 'repository.write', 'repository.read',)
307 307 validate_repo_permissions(apiuser, repoid, repo, _perms)
308 308
309 309 changes_details = Optional.extract(details)
310 310 _changes_details_types = ['basic', 'extended', 'full']
311 311 if changes_details not in _changes_details_types:
312 312 raise JSONRPCError(
313 313 'ret_type must be one of %s' % (
314 314 ','.join(_changes_details_types)))
315 315
316 316 pre_load = ['author', 'branch', 'date', 'message', 'parents',
317 317 'status', '_commit', '_file_paths']
318 318
319 319 try:
320 320 cs = repo.get_commit(commit_id=revision, pre_load=pre_load)
321 321 except TypeError as e:
322 322 raise JSONRPCError(safe_str(e))
323 323 _cs_json = cs.__json__()
324 324 _cs_json['diff'] = build_commit_data(cs, changes_details)
325 325 if changes_details == 'full':
326 326 _cs_json['refs'] = cs._get_refs()
327 327 return _cs_json
328 328
329 329
330 330 @jsonrpc_method()
331 331 def get_repo_changesets(request, apiuser, repoid, start_rev, limit,
332 332 details=Optional('basic')):
333 333 """
334 334 Returns a set of commits limited by the number starting
335 335 from the `start_rev` option.
336 336
337 337 Additional parameters define the amount of details returned by this
338 338 function.
339 339
340 340 This command can only be run using an |authtoken| with admin rights,
341 341 or users with at least read rights to |repos|.
342 342
343 343 :param apiuser: This is filled automatically from the |authtoken|.
344 344 :type apiuser: AuthUser
345 345 :param repoid: The repository name or repository ID.
346 346 :type repoid: str or int
347 347 :param start_rev: The starting revision from where to get changesets.
348 348 :type start_rev: str
349 349 :param limit: Limit the number of commits to this amount
350 350 :type limit: str or int
351 351 :param details: Set the level of detail returned. Valid option are:
352 352 ``basic``, ``extended`` and ``full``.
353 353 :type details: Optional(str)
354 354
355 355 .. note::
356 356
357 357 Setting the parameter `details` to the value ``full`` is extensive
358 358 and returns details like the diff itself, and the number
359 359 of changed files.
360 360
361 361 """
362 362 repo = get_repo_or_error(repoid)
363 363 if not has_superadmin_permission(apiuser):
364 364 _perms = (
365 365 'repository.admin', 'repository.write', 'repository.read',)
366 366 validate_repo_permissions(apiuser, repoid, repo, _perms)
367 367
368 368 changes_details = Optional.extract(details)
369 369 _changes_details_types = ['basic', 'extended', 'full']
370 370 if changes_details not in _changes_details_types:
371 371 raise JSONRPCError(
372 372 'ret_type must be one of %s' % (
373 373 ','.join(_changes_details_types)))
374 374
375 375 limit = int(limit)
376 376 pre_load = ['author', 'branch', 'date', 'message', 'parents',
377 377 'status', '_commit', '_file_paths']
378 378
379 379 vcs_repo = repo.scm_instance()
380 380 # SVN needs a special case to distinguish its index and commit id
381 381 if vcs_repo and vcs_repo.alias == 'svn' and (start_rev == '0'):
382 382 start_rev = vcs_repo.commit_ids[0]
383 383
384 384 try:
385 385 commits = vcs_repo.get_commits(
386 386 start_id=start_rev, pre_load=pre_load, translate_tags=False)
387 387 except TypeError as e:
388 388 raise JSONRPCError(safe_str(e))
389 389 except Exception:
390 390 log.exception('Fetching of commits failed')
391 391 raise JSONRPCError('Error occurred during commit fetching')
392 392
393 393 ret = []
394 394 for cnt, commit in enumerate(commits):
395 395 if cnt >= limit != -1:
396 396 break
397 397 _cs_json = commit.__json__()
398 398 _cs_json['diff'] = build_commit_data(commit, changes_details)
399 399 if changes_details == 'full':
400 400 _cs_json['refs'] = {
401 401 'branches': [commit.branch],
402 402 'bookmarks': getattr(commit, 'bookmarks', []),
403 403 'tags': commit.tags
404 404 }
405 405 ret.append(_cs_json)
406 406 return ret
407 407
408 408
409 409 @jsonrpc_method()
410 410 def get_repo_nodes(request, apiuser, repoid, revision, root_path,
411 411 ret_type=Optional('all'), details=Optional('basic'),
412 412 max_file_bytes=Optional(None)):
413 413 """
414 414 Returns a list of nodes and children in a flat list for a given
415 415 path at given revision.
416 416
417 417 It's possible to specify ret_type to show only `files` or `dirs`.
418 418
419 419 This command can only be run using an |authtoken| with admin rights,
420 420 or users with at least read rights to |repos|.
421 421
422 422 :param apiuser: This is filled automatically from the |authtoken|.
423 423 :type apiuser: AuthUser
424 424 :param repoid: The repository name or repository ID.
425 425 :type repoid: str or int
426 426 :param revision: The revision for which listing should be done.
427 427 :type revision: str
428 428 :param root_path: The path from which to start displaying.
429 429 :type root_path: str
430 430 :param ret_type: Set the return type. Valid options are
431 431 ``all`` (default), ``files`` and ``dirs``.
432 432 :type ret_type: Optional(str)
433 433 :param details: Returns extended information about nodes, such as
434 434 md5, binary, and or content.
435 435 The valid options are ``basic`` and ``full``.
436 436 :type details: Optional(str)
437 437 :param max_file_bytes: Only return file content under this file size bytes
438 438 :type details: Optional(int)
439 439
440 440 Example output:
441 441
442 442 .. code-block:: bash
443 443
444 444 id : <id_given_in_input>
445 445 result: [
446 446 {
447 447 "binary": false,
448 448 "content": "File line",
449 449 "extension": "md",
450 450 "lines": 2,
451 451 "md5": "059fa5d29b19c0657e384749480f6422",
452 452 "mimetype": "text/x-minidsrc",
453 453 "name": "file.md",
454 454 "size": 580,
455 455 "type": "file"
456 456 },
457 457 ...
458 458 ]
459 459 error: null
460 460 """
461 461
462 462 repo = get_repo_or_error(repoid)
463 463 if not has_superadmin_permission(apiuser):
464 464 _perms = ('repository.admin', 'repository.write', 'repository.read',)
465 465 validate_repo_permissions(apiuser, repoid, repo, _perms)
466 466
467 467 ret_type = Optional.extract(ret_type)
468 468 details = Optional.extract(details)
469 469 _extended_types = ['basic', 'full']
470 470 if details not in _extended_types:
471 471 raise JSONRPCError('ret_type must be one of %s' % (','.join(_extended_types)))
472 472 extended_info = False
473 473 content = False
474 474 if details == 'basic':
475 475 extended_info = True
476 476
477 477 if details == 'full':
478 478 extended_info = content = True
479 479
480 480 _map = {}
481 481 try:
482 482 # check if repo is not empty by any chance, skip quicker if it is.
483 483 _scm = repo.scm_instance()
484 484 if _scm.is_empty():
485 485 return []
486 486
487 487 _d, _f = ScmModel().get_nodes(
488 488 repo, revision, root_path, flat=False,
489 489 extended_info=extended_info, content=content,
490 490 max_file_bytes=max_file_bytes)
491 491 _map = {
492 492 'all': _d + _f,
493 493 'files': _f,
494 494 'dirs': _d,
495 495 }
496 496 return _map[ret_type]
497 497 except KeyError:
498 498 raise JSONRPCError(
499 499 'ret_type must be one of %s' % (','.join(sorted(_map.keys()))))
500 500 except Exception:
501 501 log.exception("Exception occurred while trying to get repo nodes")
502 502 raise JSONRPCError(
503 503 'failed to get repo: `%s` nodes' % repo.repo_name
504 504 )
505 505
506 506
507 507 @jsonrpc_method()
508 508 def get_repo_file(request, apiuser, repoid, commit_id, file_path,
509 509 max_file_bytes=Optional(None), details=Optional('basic'),
510 510 cache=Optional(True)):
511 511 """
512 512 Returns a single file from repository at given revision.
513 513
514 514 This command can only be run using an |authtoken| with admin rights,
515 515 or users with at least read rights to |repos|.
516 516
517 517 :param apiuser: This is filled automatically from the |authtoken|.
518 518 :type apiuser: AuthUser
519 519 :param repoid: The repository name or repository ID.
520 520 :type repoid: str or int
521 521 :param commit_id: The revision for which listing should be done.
522 522 :type commit_id: str
523 523 :param file_path: The path from which to start displaying.
524 524 :type file_path: str
525 525 :param details: Returns different set of information about nodes.
526 526 The valid options are ``minimal`` ``basic`` and ``full``.
527 527 :type details: Optional(str)
528 528 :param max_file_bytes: Only return file content under this file size bytes
529 529 :type max_file_bytes: Optional(int)
530 530 :param cache: Use internal caches for fetching files. If disabled fetching
531 531 files is slower but more memory efficient
532 532 :type cache: Optional(bool)
533 533
534 534 Example output:
535 535
536 536 .. code-block:: bash
537 537
538 538 id : <id_given_in_input>
539 539 result: {
540 540 "binary": false,
541 541 "extension": "py",
542 542 "lines": 35,
543 543 "content": "....",
544 544 "md5": "76318336366b0f17ee249e11b0c99c41",
545 545 "mimetype": "text/x-python",
546 546 "name": "python.py",
547 547 "size": 817,
548 548 "type": "file",
549 549 }
550 550 error: null
551 551 """
552 552
553 553 repo = get_repo_or_error(repoid)
554 554 if not has_superadmin_permission(apiuser):
555 555 _perms = ('repository.admin', 'repository.write', 'repository.read',)
556 556 validate_repo_permissions(apiuser, repoid, repo, _perms)
557 557
558 558 cache = Optional.extract(cache, binary=True)
559 559 details = Optional.extract(details)
560 560 _extended_types = ['minimal', 'minimal+search', 'basic', 'full']
561 561 if details not in _extended_types:
562 562 raise JSONRPCError(
563 563 'ret_type must be one of %s, got %s' % (','.join(_extended_types)), details)
564 564 extended_info = False
565 565 content = False
566 566
567 567 if details == 'minimal':
568 568 extended_info = False
569 569
570 570 elif details == 'basic':
571 571 extended_info = True
572 572
573 573 elif details == 'full':
574 574 extended_info = content = True
575 575
576 576 try:
577 577 # check if repo is not empty by any chance, skip quicker if it is.
578 578 _scm = repo.scm_instance()
579 579 if _scm.is_empty():
580 580 return None
581 581
582 582 node = ScmModel().get_node(
583 583 repo, commit_id, file_path, extended_info=extended_info,
584 584 content=content, max_file_bytes=max_file_bytes, cache=cache)
585 585 except NodeDoesNotExistError:
586 586 raise JSONRPCError('There is no file in repo: `{}` at path `{}` for commit: `{}`'.format(
587 587 repo.repo_name, file_path, commit_id))
588 588 except Exception:
589 589 log.exception("Exception occurred while trying to get repo %s file",
590 590 repo.repo_name)
591 591 raise JSONRPCError('failed to get repo: `{}` file at path {}'.format(
592 592 repo.repo_name, file_path))
593 593
594 594 return node
595 595
596 596
597 597 @jsonrpc_method()
598 598 def get_repo_fts_tree(request, apiuser, repoid, commit_id, root_path):
599 599 """
600 600 Returns a list of tree nodes for path at given revision. This api is built
601 601 strictly for usage in full text search building, and shouldn't be consumed
602 602
603 603 This command can only be run using an |authtoken| with admin rights,
604 604 or users with at least read rights to |repos|.
605 605
606 606 """
607 607
608 608 repo = get_repo_or_error(repoid)
609 609 if not has_superadmin_permission(apiuser):
610 610 _perms = ('repository.admin', 'repository.write', 'repository.read',)
611 611 validate_repo_permissions(apiuser, repoid, repo, _perms)
612 612
613 613 repo_id = repo.repo_id
614 614 cache_seconds = safe_int(rhodecode.CONFIG.get('rc_cache.cache_repo.expiration_time'))
615 615 cache_on = cache_seconds > 0
616 616
617 617 cache_namespace_uid = 'cache_repo.{}'.format(repo_id)
618 618 region = rc_cache.get_or_create_region('cache_repo', cache_namespace_uid)
619 619
620 620 def compute_fts_tree(cache_ver, repo_id, commit_id, root_path):
621 621 return ScmModel().get_fts_data(repo_id, commit_id, root_path)
622 622
623 623 try:
624 624 # check if repo is not empty by any chance, skip quicker if it is.
625 625 _scm = repo.scm_instance()
626 626 if _scm.is_empty():
627 627 return []
628 628 except RepositoryError:
629 629 log.exception("Exception occurred while trying to get repo nodes")
630 630 raise JSONRPCError('failed to get repo: `%s` nodes' % repo.repo_name)
631 631
632 632 try:
633 633 # we need to resolve commit_id to a FULL sha for cache to work correctly.
634 634 # sending 'master' is a pointer that needs to be translated to current commit.
635 635 commit_id = _scm.get_commit(commit_id=commit_id).raw_id
636 636 log.debug(
637 637 'Computing FTS REPO TREE for repo_id %s commit_id `%s` '
638 638 'with caching: %s[TTL: %ss]' % (
639 639 repo_id, commit_id, cache_on, cache_seconds or 0))
640 640
641 641 tree_files = compute_fts_tree(rc_cache.FILE_TREE_CACHE_VER, repo_id, commit_id, root_path)
642 642 return tree_files
643 643
644 644 except Exception:
645 645 log.exception("Exception occurred while trying to get repo nodes")
646 646 raise JSONRPCError('failed to get repo: `%s` nodes' % repo.repo_name)
647 647
648 648
649 649 @jsonrpc_method()
650 650 def get_repo_refs(request, apiuser, repoid):
651 651 """
652 652 Returns a dictionary of current references. It returns
653 653 bookmarks, branches, closed_branches, and tags for given repository
654 654
655 655 It's possible to specify ret_type to show only `files` or `dirs`.
656 656
657 657 This command can only be run using an |authtoken| with admin rights,
658 658 or users with at least read rights to |repos|.
659 659
660 660 :param apiuser: This is filled automatically from the |authtoken|.
661 661 :type apiuser: AuthUser
662 662 :param repoid: The repository name or repository ID.
663 663 :type repoid: str or int
664 664
665 665 Example output:
666 666
667 667 .. code-block:: bash
668 668
669 669 id : <id_given_in_input>
670 670 "result": {
671 671 "bookmarks": {
672 672 "dev": "5611d30200f4040ba2ab4f3d64e5b06408a02188",
673 673 "master": "367f590445081d8ec8c2ea0456e73ae1f1c3d6cf"
674 674 },
675 675 "branches": {
676 676 "default": "5611d30200f4040ba2ab4f3d64e5b06408a02188",
677 677 "stable": "367f590445081d8ec8c2ea0456e73ae1f1c3d6cf"
678 678 },
679 679 "branches_closed": {},
680 680 "tags": {
681 681 "tip": "5611d30200f4040ba2ab4f3d64e5b06408a02188",
682 682 "v4.4.0": "1232313f9e6adac5ce5399c2a891dc1e72b79022",
683 683 "v4.4.1": "cbb9f1d329ae5768379cdec55a62ebdd546c4e27",
684 684 "v4.4.2": "24ffe44a27fcd1c5b6936144e176b9f6dd2f3a17",
685 685 }
686 686 }
687 687 error: null
688 688 """
689 689
690 690 repo = get_repo_or_error(repoid)
691 691 if not has_superadmin_permission(apiuser):
692 692 _perms = ('repository.admin', 'repository.write', 'repository.read',)
693 693 validate_repo_permissions(apiuser, repoid, repo, _perms)
694 694
695 695 try:
696 696 # check if repo is not empty by any chance, skip quicker if it is.
697 697 vcs_instance = repo.scm_instance()
698 698 refs = vcs_instance.refs()
699 699 return refs
700 700 except Exception:
701 701 log.exception("Exception occurred while trying to get repo refs")
702 702 raise JSONRPCError(
703 703 'failed to get repo: `%s` references' % repo.repo_name
704 704 )
705 705
706 706
707 707 @jsonrpc_method()
708 708 def create_repo(
709 709 request, apiuser, repo_name, repo_type,
710 710 owner=Optional(OAttr('apiuser')),
711 711 description=Optional(''),
712 712 private=Optional(False),
713 713 clone_uri=Optional(None),
714 714 push_uri=Optional(None),
715 715 landing_rev=Optional(None),
716 716 enable_statistics=Optional(False),
717 717 enable_locking=Optional(False),
718 718 enable_downloads=Optional(False),
719 719 copy_permissions=Optional(False)):
720 720 """
721 721 Creates a repository.
722 722
723 723 * If the repository name contains "/", repository will be created inside
724 724 a repository group or nested repository groups
725 725
726 726 For example "foo/bar/repo1" will create |repo| called "repo1" inside
727 727 group "foo/bar". You have to have permissions to access and write to
728 728 the last repository group ("bar" in this example)
729 729
730 730 This command can only be run using an |authtoken| with at least
731 731 permissions to create repositories, or write permissions to
732 732 parent repository groups.
733 733
734 734 :param apiuser: This is filled automatically from the |authtoken|.
735 735 :type apiuser: AuthUser
736 736 :param repo_name: Set the repository name.
737 737 :type repo_name: str
738 738 :param repo_type: Set the repository type; 'hg','git', or 'svn'.
739 739 :type repo_type: str
740 740 :param owner: user_id or username
741 741 :type owner: Optional(str)
742 742 :param description: Set the repository description.
743 743 :type description: Optional(str)
744 744 :param private: set repository as private
745 745 :type private: bool
746 746 :param clone_uri: set clone_uri
747 747 :type clone_uri: str
748 748 :param push_uri: set push_uri
749 749 :type push_uri: str
750 750 :param landing_rev: <rev_type>:<rev>, e.g branch:default, book:dev, rev:abcd
751 751 :type landing_rev: str
752 752 :param enable_locking:
753 753 :type enable_locking: bool
754 754 :param enable_downloads:
755 755 :type enable_downloads: bool
756 756 :param enable_statistics:
757 757 :type enable_statistics: bool
758 758 :param copy_permissions: Copy permission from group in which the
759 759 repository is being created.
760 760 :type copy_permissions: bool
761 761
762 762
763 763 Example output:
764 764
765 765 .. code-block:: bash
766 766
767 767 id : <id_given_in_input>
768 768 result: {
769 769 "msg": "Created new repository `<reponame>`",
770 770 "success": true,
771 771 "task": "<celery task id or None if done sync>"
772 772 }
773 773 error: null
774 774
775 775
776 776 Example error output:
777 777
778 778 .. code-block:: bash
779 779
780 780 id : <id_given_in_input>
781 781 result : null
782 782 error : {
783 783 'failed to create repository `<repo_name>`'
784 784 }
785 785
786 786 """
787 787
788 788 owner = validate_set_owner_permissions(apiuser, owner)
789 789
790 790 description = Optional.extract(description)
791 791 copy_permissions = Optional.extract(copy_permissions)
792 792 clone_uri = Optional.extract(clone_uri)
793 793 push_uri = Optional.extract(push_uri)
794 794
795 795 defs = SettingsModel().get_default_repo_settings(strip_prefix=True)
796 796 if isinstance(private, Optional):
797 797 private = defs.get('repo_private') or Optional.extract(private)
798 798 if isinstance(repo_type, Optional):
799 799 repo_type = defs.get('repo_type')
800 800 if isinstance(enable_statistics, Optional):
801 801 enable_statistics = defs.get('repo_enable_statistics')
802 802 if isinstance(enable_locking, Optional):
803 803 enable_locking = defs.get('repo_enable_locking')
804 804 if isinstance(enable_downloads, Optional):
805 805 enable_downloads = defs.get('repo_enable_downloads')
806 806
807 807 landing_ref, _label = ScmModel.backend_landing_ref(repo_type)
808 808 ref_choices, _labels = ScmModel().get_repo_landing_revs(request.translate)
809 809 ref_choices = list(set(ref_choices + [landing_ref]))
810 810
811 811 landing_commit_ref = Optional.extract(landing_rev) or landing_ref
812 812
813 813 schema = repo_schema.RepoSchema().bind(
814 814 repo_type_options=rhodecode.BACKENDS.keys(),
815 815 repo_ref_options=ref_choices,
816 816 repo_type=repo_type,
817 817 # user caller
818 818 user=apiuser)
819 819
820 820 try:
821 821 schema_data = schema.deserialize(dict(
822 822 repo_name=repo_name,
823 823 repo_type=repo_type,
824 824 repo_owner=owner.username,
825 825 repo_description=description,
826 826 repo_landing_commit_ref=landing_commit_ref,
827 827 repo_clone_uri=clone_uri,
828 828 repo_push_uri=push_uri,
829 829 repo_private=private,
830 830 repo_copy_permissions=copy_permissions,
831 831 repo_enable_statistics=enable_statistics,
832 832 repo_enable_downloads=enable_downloads,
833 833 repo_enable_locking=enable_locking))
834 834 except validation_schema.Invalid as err:
835 835 raise JSONRPCValidationError(colander_exc=err)
836 836
837 837 try:
838 838 data = {
839 839 'owner': owner,
840 840 'repo_name': schema_data['repo_group']['repo_name_without_group'],
841 841 'repo_name_full': schema_data['repo_name'],
842 842 'repo_group': schema_data['repo_group']['repo_group_id'],
843 843 'repo_type': schema_data['repo_type'],
844 844 'repo_description': schema_data['repo_description'],
845 845 'repo_private': schema_data['repo_private'],
846 846 'clone_uri': schema_data['repo_clone_uri'],
847 847 'push_uri': schema_data['repo_push_uri'],
848 848 'repo_landing_rev': schema_data['repo_landing_commit_ref'],
849 849 'enable_statistics': schema_data['repo_enable_statistics'],
850 850 'enable_locking': schema_data['repo_enable_locking'],
851 851 'enable_downloads': schema_data['repo_enable_downloads'],
852 852 'repo_copy_permissions': schema_data['repo_copy_permissions'],
853 853 }
854 854
855 855 task = RepoModel().create(form_data=data, cur_user=owner.user_id)
856 856 task_id = get_task_id(task)
857 857 # no commit, it's done in RepoModel, or async via celery
858 858 return {
859 859 'msg': "Created new repository `%s`" % (schema_data['repo_name'],),
860 860 'success': True, # cannot return the repo data here since fork
861 861 # can be done async
862 862 'task': task_id
863 863 }
864 864 except Exception:
865 865 log.exception(
866 866 u"Exception while trying to create the repository %s",
867 867 schema_data['repo_name'])
868 868 raise JSONRPCError(
869 869 'failed to create repository `%s`' % (schema_data['repo_name'],))
870 870
871 871
872 872 @jsonrpc_method()
873 873 def add_field_to_repo(request, apiuser, repoid, key, label=Optional(''),
874 874 description=Optional('')):
875 875 """
876 876 Adds an extra field to a repository.
877 877
878 878 This command can only be run using an |authtoken| with at least
879 879 write permissions to the |repo|.
880 880
881 881 :param apiuser: This is filled automatically from the |authtoken|.
882 882 :type apiuser: AuthUser
883 883 :param repoid: Set the repository name or repository id.
884 884 :type repoid: str or int
885 885 :param key: Create a unique field key for this repository.
886 886 :type key: str
887 887 :param label:
888 888 :type label: Optional(str)
889 889 :param description:
890 890 :type description: Optional(str)
891 891 """
892 892 repo = get_repo_or_error(repoid)
893 893 if not has_superadmin_permission(apiuser):
894 894 _perms = ('repository.admin',)
895 895 validate_repo_permissions(apiuser, repoid, repo, _perms)
896 896
897 897 label = Optional.extract(label) or key
898 898 description = Optional.extract(description)
899 899
900 900 field = RepositoryField.get_by_key_name(key, repo)
901 901 if field:
902 902 raise JSONRPCError('Field with key '
903 903 '`%s` exists for repo `%s`' % (key, repoid))
904 904
905 905 try:
906 906 RepoModel().add_repo_field(repo, key, field_label=label,
907 907 field_desc=description)
908 908 Session().commit()
909 909 return {
910 910 'msg': "Added new repository field `%s`" % (key,),
911 911 'success': True,
912 912 }
913 913 except Exception:
914 914 log.exception("Exception occurred while trying to add field to repo")
915 915 raise JSONRPCError(
916 916 'failed to create new field for repository `%s`' % (repoid,))
917 917
918 918
919 919 @jsonrpc_method()
920 920 def remove_field_from_repo(request, apiuser, repoid, key):
921 921 """
922 922 Removes an extra field from a repository.
923 923
924 924 This command can only be run using an |authtoken| with at least
925 925 write permissions to the |repo|.
926 926
927 927 :param apiuser: This is filled automatically from the |authtoken|.
928 928 :type apiuser: AuthUser
929 929 :param repoid: Set the repository name or repository ID.
930 930 :type repoid: str or int
931 931 :param key: Set the unique field key for this repository.
932 932 :type key: str
933 933 """
934 934
935 935 repo = get_repo_or_error(repoid)
936 936 if not has_superadmin_permission(apiuser):
937 937 _perms = ('repository.admin',)
938 938 validate_repo_permissions(apiuser, repoid, repo, _perms)
939 939
940 940 field = RepositoryField.get_by_key_name(key, repo)
941 941 if not field:
942 942 raise JSONRPCError('Field with key `%s` does not '
943 943 'exists for repo `%s`' % (key, repoid))
944 944
945 945 try:
946 946 RepoModel().delete_repo_field(repo, field_key=key)
947 947 Session().commit()
948 948 return {
949 949 'msg': "Deleted repository field `%s`" % (key,),
950 950 'success': True,
951 951 }
952 952 except Exception:
953 953 log.exception(
954 954 "Exception occurred while trying to delete field from repo")
955 955 raise JSONRPCError(
956 956 'failed to delete field for repository `%s`' % (repoid,))
957 957
958 958
959 959 @jsonrpc_method()
960 960 def update_repo(
961 961 request, apiuser, repoid, repo_name=Optional(None),
962 962 owner=Optional(OAttr('apiuser')), description=Optional(''),
963 963 private=Optional(False),
964 964 clone_uri=Optional(None), push_uri=Optional(None),
965 965 landing_rev=Optional(None), fork_of=Optional(None),
966 966 enable_statistics=Optional(False),
967 967 enable_locking=Optional(False),
968 968 enable_downloads=Optional(False), fields=Optional('')):
969 969 """
970 970 Updates a repository with the given information.
971 971
972 972 This command can only be run using an |authtoken| with at least
973 973 admin permissions to the |repo|.
974 974
975 975 * If the repository name contains "/", repository will be updated
976 976 accordingly with a repository group or nested repository groups
977 977
978 978 For example repoid=repo-test name="foo/bar/repo-test" will update |repo|
979 979 called "repo-test" and place it inside group "foo/bar".
980 980 You have to have permissions to access and write to the last repository
981 981 group ("bar" in this example)
982 982
983 983 :param apiuser: This is filled automatically from the |authtoken|.
984 984 :type apiuser: AuthUser
985 985 :param repoid: repository name or repository ID.
986 986 :type repoid: str or int
987 987 :param repo_name: Update the |repo| name, including the
988 988 repository group it's in.
989 989 :type repo_name: str
990 990 :param owner: Set the |repo| owner.
991 991 :type owner: str
992 992 :param fork_of: Set the |repo| as fork of another |repo|.
993 993 :type fork_of: str
994 994 :param description: Update the |repo| description.
995 995 :type description: str
996 996 :param private: Set the |repo| as private. (True | False)
997 997 :type private: bool
998 998 :param clone_uri: Update the |repo| clone URI.
999 999 :type clone_uri: str
1000 1000 :param landing_rev: Set the |repo| landing revision. e.g branch:default, book:dev, rev:abcd
1001 1001 :type landing_rev: str
1002 1002 :param enable_statistics: Enable statistics on the |repo|, (True | False).
1003 1003 :type enable_statistics: bool
1004 1004 :param enable_locking: Enable |repo| locking.
1005 1005 :type enable_locking: bool
1006 1006 :param enable_downloads: Enable downloads from the |repo|, (True | False).
1007 1007 :type enable_downloads: bool
1008 1008 :param fields: Add extra fields to the |repo|. Use the following
1009 1009 example format: ``field_key=field_val,field_key2=fieldval2``.
1010 1010 Escape ', ' with \,
1011 1011 :type fields: str
1012 1012 """
1013 1013
1014 1014 repo = get_repo_or_error(repoid)
1015 1015
1016 1016 include_secrets = False
1017 1017 if not has_superadmin_permission(apiuser):
1018 1018 validate_repo_permissions(apiuser, repoid, repo, ('repository.admin',))
1019 1019 else:
1020 1020 include_secrets = True
1021 1021
1022 1022 updates = dict(
1023 1023 repo_name=repo_name
1024 1024 if not isinstance(repo_name, Optional) else repo.repo_name,
1025 1025
1026 1026 fork_id=fork_of
1027 1027 if not isinstance(fork_of, Optional) else repo.fork.repo_name if repo.fork else None,
1028 1028
1029 1029 user=owner
1030 1030 if not isinstance(owner, Optional) else repo.user.username,
1031 1031
1032 1032 repo_description=description
1033 1033 if not isinstance(description, Optional) else repo.description,
1034 1034
1035 1035 repo_private=private
1036 1036 if not isinstance(private, Optional) else repo.private,
1037 1037
1038 1038 clone_uri=clone_uri
1039 1039 if not isinstance(clone_uri, Optional) else repo.clone_uri,
1040 1040
1041 1041 push_uri=push_uri
1042 1042 if not isinstance(push_uri, Optional) else repo.push_uri,
1043 1043
1044 1044 repo_landing_rev=landing_rev
1045 1045 if not isinstance(landing_rev, Optional) else repo._landing_revision,
1046 1046
1047 1047 repo_enable_statistics=enable_statistics
1048 1048 if not isinstance(enable_statistics, Optional) else repo.enable_statistics,
1049 1049
1050 1050 repo_enable_locking=enable_locking
1051 1051 if not isinstance(enable_locking, Optional) else repo.enable_locking,
1052 1052
1053 1053 repo_enable_downloads=enable_downloads
1054 1054 if not isinstance(enable_downloads, Optional) else repo.enable_downloads)
1055 1055
1056 1056 landing_ref, _label = ScmModel.backend_landing_ref(repo.repo_type)
1057 1057 ref_choices, _labels = ScmModel().get_repo_landing_revs(
1058 1058 request.translate, repo=repo)
1059 1059 ref_choices = list(set(ref_choices + [landing_ref]))
1060 1060
1061 1061 old_values = repo.get_api_data()
1062 1062 repo_type = repo.repo_type
1063 1063 schema = repo_schema.RepoSchema().bind(
1064 1064 repo_type_options=rhodecode.BACKENDS.keys(),
1065 1065 repo_ref_options=ref_choices,
1066 1066 repo_type=repo_type,
1067 1067 # user caller
1068 1068 user=apiuser,
1069 1069 old_values=old_values)
1070 1070 try:
1071 1071 schema_data = schema.deserialize(dict(
1072 1072 # we save old value, users cannot change type
1073 1073 repo_type=repo_type,
1074 1074
1075 1075 repo_name=updates['repo_name'],
1076 1076 repo_owner=updates['user'],
1077 1077 repo_description=updates['repo_description'],
1078 1078 repo_clone_uri=updates['clone_uri'],
1079 1079 repo_push_uri=updates['push_uri'],
1080 1080 repo_fork_of=updates['fork_id'],
1081 1081 repo_private=updates['repo_private'],
1082 1082 repo_landing_commit_ref=updates['repo_landing_rev'],
1083 1083 repo_enable_statistics=updates['repo_enable_statistics'],
1084 1084 repo_enable_downloads=updates['repo_enable_downloads'],
1085 1085 repo_enable_locking=updates['repo_enable_locking']))
1086 1086 except validation_schema.Invalid as err:
1087 1087 raise JSONRPCValidationError(colander_exc=err)
1088 1088
1089 1089 # save validated data back into the updates dict
1090 1090 validated_updates = dict(
1091 1091 repo_name=schema_data['repo_group']['repo_name_without_group'],
1092 1092 repo_group=schema_data['repo_group']['repo_group_id'],
1093 1093
1094 1094 user=schema_data['repo_owner'],
1095 1095 repo_description=schema_data['repo_description'],
1096 1096 repo_private=schema_data['repo_private'],
1097 1097 clone_uri=schema_data['repo_clone_uri'],
1098 1098 push_uri=schema_data['repo_push_uri'],
1099 1099 repo_landing_rev=schema_data['repo_landing_commit_ref'],
1100 1100 repo_enable_statistics=schema_data['repo_enable_statistics'],
1101 1101 repo_enable_locking=schema_data['repo_enable_locking'],
1102 1102 repo_enable_downloads=schema_data['repo_enable_downloads'],
1103 1103 )
1104 1104
1105 1105 if schema_data['repo_fork_of']:
1106 1106 fork_repo = get_repo_or_error(schema_data['repo_fork_of'])
1107 1107 validated_updates['fork_id'] = fork_repo.repo_id
1108 1108
1109 1109 # extra fields
1110 1110 fields = parse_args(Optional.extract(fields), key_prefix='ex_')
1111 1111 if fields:
1112 1112 validated_updates.update(fields)
1113 1113
1114 1114 try:
1115 1115 RepoModel().update(repo, **validated_updates)
1116 1116 audit_logger.store_api(
1117 1117 'repo.edit', action_data={'old_data': old_values},
1118 1118 user=apiuser, repo=repo)
1119 1119 Session().commit()
1120 1120 return {
1121 1121 'msg': 'updated repo ID:%s %s' % (repo.repo_id, repo.repo_name),
1122 1122 'repository': repo.get_api_data(include_secrets=include_secrets)
1123 1123 }
1124 1124 except Exception:
1125 1125 log.exception(
1126 1126 u"Exception while trying to update the repository %s",
1127 1127 repoid)
1128 1128 raise JSONRPCError('failed to update repo `%s`' % repoid)
1129 1129
1130 1130
1131 1131 @jsonrpc_method()
1132 1132 def fork_repo(request, apiuser, repoid, fork_name,
1133 1133 owner=Optional(OAttr('apiuser')),
1134 1134 description=Optional(''),
1135 1135 private=Optional(False),
1136 1136 clone_uri=Optional(None),
1137 1137 landing_rev=Optional(None),
1138 1138 copy_permissions=Optional(False)):
1139 1139 """
1140 1140 Creates a fork of the specified |repo|.
1141 1141
1142 1142 * If the fork_name contains "/", fork will be created inside
1143 1143 a repository group or nested repository groups
1144 1144
1145 1145 For example "foo/bar/fork-repo" will create fork called "fork-repo"
1146 1146 inside group "foo/bar". You have to have permissions to access and
1147 1147 write to the last repository group ("bar" in this example)
1148 1148
1149 1149 This command can only be run using an |authtoken| with minimum
1150 1150 read permissions of the forked repo, create fork permissions for an user.
1151 1151
1152 1152 :param apiuser: This is filled automatically from the |authtoken|.
1153 1153 :type apiuser: AuthUser
1154 1154 :param repoid: Set repository name or repository ID.
1155 1155 :type repoid: str or int
1156 1156 :param fork_name: Set the fork name, including it's repository group membership.
1157 1157 :type fork_name: str
1158 1158 :param owner: Set the fork owner.
1159 1159 :type owner: str
1160 1160 :param description: Set the fork description.
1161 1161 :type description: str
1162 1162 :param copy_permissions: Copy permissions from parent |repo|. The
1163 1163 default is False.
1164 1164 :type copy_permissions: bool
1165 1165 :param private: Make the fork private. The default is False.
1166 1166 :type private: bool
1167 1167 :param landing_rev: Set the landing revision. E.g branch:default, book:dev, rev:abcd
1168 1168
1169 1169 Example output:
1170 1170
1171 1171 .. code-block:: bash
1172 1172
1173 1173 id : <id_for_response>
1174 1174 api_key : "<api_key>"
1175 1175 args: {
1176 1176 "repoid" : "<reponame or repo_id>",
1177 1177 "fork_name": "<forkname>",
1178 1178 "owner": "<username or user_id = Optional(=apiuser)>",
1179 1179 "description": "<description>",
1180 1180 "copy_permissions": "<bool>",
1181 1181 "private": "<bool>",
1182 1182 "landing_rev": "<landing_rev>"
1183 1183 }
1184 1184
1185 1185 Example error output:
1186 1186
1187 1187 .. code-block:: bash
1188 1188
1189 1189 id : <id_given_in_input>
1190 1190 result: {
1191 1191 "msg": "Created fork of `<reponame>` as `<forkname>`",
1192 1192 "success": true,
1193 1193 "task": "<celery task id or None if done sync>"
1194 1194 }
1195 1195 error: null
1196 1196
1197 1197 """
1198 1198
1199 1199 repo = get_repo_or_error(repoid)
1200 1200 repo_name = repo.repo_name
1201 1201
1202 1202 if not has_superadmin_permission(apiuser):
1203 1203 # check if we have at least read permission for
1204 1204 # this repo that we fork !
1205 1205 _perms = (
1206 1206 'repository.admin', 'repository.write', 'repository.read')
1207 1207 validate_repo_permissions(apiuser, repoid, repo, _perms)
1208 1208
1209 1209 # check if the regular user has at least fork permissions as well
1210 1210 if not HasPermissionAnyApi('hg.fork.repository')(user=apiuser):
1211 1211 raise JSONRPCForbidden()
1212 1212
1213 1213 # check if user can set owner parameter
1214 1214 owner = validate_set_owner_permissions(apiuser, owner)
1215 1215
1216 1216 description = Optional.extract(description)
1217 1217 copy_permissions = Optional.extract(copy_permissions)
1218 1218 clone_uri = Optional.extract(clone_uri)
1219 1219
1220 1220 landing_ref, _label = ScmModel.backend_landing_ref(repo.repo_type)
1221 1221 ref_choices, _labels = ScmModel().get_repo_landing_revs(request.translate)
1222 1222 ref_choices = list(set(ref_choices + [landing_ref]))
1223 1223 landing_commit_ref = Optional.extract(landing_rev) or landing_ref
1224 1224
1225 1225 private = Optional.extract(private)
1226 1226
1227 1227 schema = repo_schema.RepoSchema().bind(
1228 1228 repo_type_options=rhodecode.BACKENDS.keys(),
1229 1229 repo_ref_options=ref_choices,
1230 1230 repo_type=repo.repo_type,
1231 1231 # user caller
1232 1232 user=apiuser)
1233 1233
1234 1234 try:
1235 1235 schema_data = schema.deserialize(dict(
1236 1236 repo_name=fork_name,
1237 1237 repo_type=repo.repo_type,
1238 1238 repo_owner=owner.username,
1239 1239 repo_description=description,
1240 1240 repo_landing_commit_ref=landing_commit_ref,
1241 1241 repo_clone_uri=clone_uri,
1242 1242 repo_private=private,
1243 1243 repo_copy_permissions=copy_permissions))
1244 1244 except validation_schema.Invalid as err:
1245 1245 raise JSONRPCValidationError(colander_exc=err)
1246 1246
1247 1247 try:
1248 1248 data = {
1249 1249 'fork_parent_id': repo.repo_id,
1250 1250
1251 1251 'repo_name': schema_data['repo_group']['repo_name_without_group'],
1252 1252 'repo_name_full': schema_data['repo_name'],
1253 1253 'repo_group': schema_data['repo_group']['repo_group_id'],
1254 1254 'repo_type': schema_data['repo_type'],
1255 1255 'description': schema_data['repo_description'],
1256 1256 'private': schema_data['repo_private'],
1257 1257 'copy_permissions': schema_data['repo_copy_permissions'],
1258 1258 'landing_rev': schema_data['repo_landing_commit_ref'],
1259 1259 }
1260 1260
1261 1261 task = RepoModel().create_fork(data, cur_user=owner.user_id)
1262 1262 # no commit, it's done in RepoModel, or async via celery
1263 1263 task_id = get_task_id(task)
1264 1264
1265 1265 return {
1266 1266 'msg': 'Created fork of `%s` as `%s`' % (
1267 1267 repo.repo_name, schema_data['repo_name']),
1268 1268 'success': True, # cannot return the repo data here since fork
1269 1269 # can be done async
1270 1270 'task': task_id
1271 1271 }
1272 1272 except Exception:
1273 1273 log.exception(
1274 1274 u"Exception while trying to create fork %s",
1275 1275 schema_data['repo_name'])
1276 1276 raise JSONRPCError(
1277 1277 'failed to fork repository `%s` as `%s`' % (
1278 1278 repo_name, schema_data['repo_name']))
1279 1279
1280 1280
1281 1281 @jsonrpc_method()
1282 1282 def delete_repo(request, apiuser, repoid, forks=Optional('')):
1283 1283 """
1284 1284 Deletes a repository.
1285 1285
1286 1286 * When the `forks` parameter is set it's possible to detach or delete
1287 1287 forks of deleted repository.
1288 1288
1289 1289 This command can only be run using an |authtoken| with admin
1290 1290 permissions on the |repo|.
1291 1291
1292 1292 :param apiuser: This is filled automatically from the |authtoken|.
1293 1293 :type apiuser: AuthUser
1294 1294 :param repoid: Set the repository name or repository ID.
1295 1295 :type repoid: str or int
1296 1296 :param forks: Set to `detach` or `delete` forks from the |repo|.
1297 1297 :type forks: Optional(str)
1298 1298
1299 1299 Example error output:
1300 1300
1301 1301 .. code-block:: bash
1302 1302
1303 1303 id : <id_given_in_input>
1304 1304 result: {
1305 1305 "msg": "Deleted repository `<reponame>`",
1306 1306 "success": true
1307 1307 }
1308 1308 error: null
1309 1309 """
1310 1310
1311 1311 repo = get_repo_or_error(repoid)
1312 1312 repo_name = repo.repo_name
1313 1313 if not has_superadmin_permission(apiuser):
1314 1314 _perms = ('repository.admin',)
1315 1315 validate_repo_permissions(apiuser, repoid, repo, _perms)
1316 1316
1317 1317 try:
1318 1318 handle_forks = Optional.extract(forks)
1319 1319 _forks_msg = ''
1320 1320 _forks = [f for f in repo.forks]
1321 1321 if handle_forks == 'detach':
1322 1322 _forks_msg = ' ' + 'Detached %s forks' % len(_forks)
1323 1323 elif handle_forks == 'delete':
1324 1324 _forks_msg = ' ' + 'Deleted %s forks' % len(_forks)
1325 1325 elif _forks:
1326 1326 raise JSONRPCError(
1327 1327 'Cannot delete `%s` it still contains attached forks' %
1328 1328 (repo.repo_name,)
1329 1329 )
1330 1330 old_data = repo.get_api_data()
1331 1331 RepoModel().delete(repo, forks=forks)
1332 1332
1333 1333 repo = audit_logger.RepoWrap(repo_id=None,
1334 1334 repo_name=repo.repo_name)
1335 1335
1336 1336 audit_logger.store_api(
1337 1337 'repo.delete', action_data={'old_data': old_data},
1338 1338 user=apiuser, repo=repo)
1339 1339
1340 1340 ScmModel().mark_for_invalidation(repo_name, delete=True)
1341 1341 Session().commit()
1342 1342 return {
1343 1343 'msg': 'Deleted repository `%s`%s' % (repo_name, _forks_msg),
1344 1344 'success': True
1345 1345 }
1346 1346 except Exception:
1347 1347 log.exception("Exception occurred while trying to delete repo")
1348 1348 raise JSONRPCError(
1349 1349 'failed to delete repository `%s`' % (repo_name,)
1350 1350 )
1351 1351
1352 1352
1353 1353 #TODO: marcink, change name ?
1354 1354 @jsonrpc_method()
1355 1355 def invalidate_cache(request, apiuser, repoid, delete_keys=Optional(False)):
1356 1356 """
1357 1357 Invalidates the cache for the specified repository.
1358 1358
1359 1359 This command can only be run using an |authtoken| with admin rights to
1360 1360 the specified repository.
1361 1361
1362 1362 This command takes the following options:
1363 1363
1364 1364 :param apiuser: This is filled automatically from |authtoken|.
1365 1365 :type apiuser: AuthUser
1366 1366 :param repoid: Sets the repository name or repository ID.
1367 1367 :type repoid: str or int
1368 1368 :param delete_keys: This deletes the invalidated keys instead of
1369 1369 just flagging them.
1370 1370 :type delete_keys: Optional(``True`` | ``False``)
1371 1371
1372 1372 Example output:
1373 1373
1374 1374 .. code-block:: bash
1375 1375
1376 1376 id : <id_given_in_input>
1377 1377 result : {
1378 1378 'msg': Cache for repository `<repository name>` was invalidated,
1379 1379 'repository': <repository name>
1380 1380 }
1381 1381 error : null
1382 1382
1383 1383 Example error output:
1384 1384
1385 1385 .. code-block:: bash
1386 1386
1387 1387 id : <id_given_in_input>
1388 1388 result : null
1389 1389 error : {
1390 1390 'Error occurred during cache invalidation action'
1391 1391 }
1392 1392
1393 1393 """
1394 1394
1395 1395 repo = get_repo_or_error(repoid)
1396 1396 if not has_superadmin_permission(apiuser):
1397 1397 _perms = ('repository.admin', 'repository.write',)
1398 1398 validate_repo_permissions(apiuser, repoid, repo, _perms)
1399 1399
1400 1400 delete = Optional.extract(delete_keys)
1401 1401 try:
1402 1402 ScmModel().mark_for_invalidation(repo.repo_name, delete=delete)
1403 1403 return {
1404 1404 'msg': 'Cache for repository `%s` was invalidated' % (repoid,),
1405 1405 'repository': repo.repo_name
1406 1406 }
1407 1407 except Exception:
1408 1408 log.exception(
1409 1409 "Exception occurred while trying to invalidate repo cache")
1410 1410 raise JSONRPCError(
1411 1411 'Error occurred during cache invalidation action'
1412 1412 )
1413 1413
1414 1414
1415 1415 #TODO: marcink, change name ?
1416 1416 @jsonrpc_method()
1417 1417 def lock(request, apiuser, repoid, locked=Optional(None),
1418 1418 userid=Optional(OAttr('apiuser'))):
1419 1419 """
1420 1420 Sets the lock state of the specified |repo| by the given user.
1421 1421 From more information, see :ref:`repo-locking`.
1422 1422
1423 1423 * If the ``userid`` option is not set, the repository is locked to the
1424 1424 user who called the method.
1425 1425 * If the ``locked`` parameter is not set, the current lock state of the
1426 1426 repository is displayed.
1427 1427
1428 1428 This command can only be run using an |authtoken| with admin rights to
1429 1429 the specified repository.
1430 1430
1431 1431 This command takes the following options:
1432 1432
1433 1433 :param apiuser: This is filled automatically from the |authtoken|.
1434 1434 :type apiuser: AuthUser
1435 1435 :param repoid: Sets the repository name or repository ID.
1436 1436 :type repoid: str or int
1437 1437 :param locked: Sets the lock state.
1438 1438 :type locked: Optional(``True`` | ``False``)
1439 1439 :param userid: Set the repository lock to this user.
1440 1440 :type userid: Optional(str or int)
1441 1441
1442 1442 Example error output:
1443 1443
1444 1444 .. code-block:: bash
1445 1445
1446 1446 id : <id_given_in_input>
1447 1447 result : {
1448 1448 'repo': '<reponame>',
1449 1449 'locked': <bool: lock state>,
1450 1450 'locked_since': <int: lock timestamp>,
1451 1451 'locked_by': <username of person who made the lock>,
1452 1452 'lock_reason': <str: reason for locking>,
1453 1453 'lock_state_changed': <bool: True if lock state has been changed in this request>,
1454 1454 'msg': 'Repo `<reponame>` locked by `<username>` on <timestamp>.'
1455 1455 or
1456 1456 'msg': 'Repo `<repository name>` not locked.'
1457 1457 or
1458 1458 'msg': 'User `<user name>` set lock state for repo `<repository name>` to `<new lock state>`'
1459 1459 }
1460 1460 error : null
1461 1461
1462 1462 Example error output:
1463 1463
1464 1464 .. code-block:: bash
1465 1465
1466 1466 id : <id_given_in_input>
1467 1467 result : null
1468 1468 error : {
1469 1469 'Error occurred locking repository `<reponame>`'
1470 1470 }
1471 1471 """
1472 1472
1473 1473 repo = get_repo_or_error(repoid)
1474 1474 if not has_superadmin_permission(apiuser):
1475 1475 # check if we have at least write permission for this repo !
1476 1476 _perms = ('repository.admin', 'repository.write',)
1477 1477 validate_repo_permissions(apiuser, repoid, repo, _perms)
1478 1478
1479 1479 # make sure normal user does not pass someone else userid,
1480 1480 # he is not allowed to do that
1481 1481 if not isinstance(userid, Optional) and userid != apiuser.user_id:
1482 1482 raise JSONRPCError('userid is not the same as your user')
1483 1483
1484 1484 if isinstance(userid, Optional):
1485 1485 userid = apiuser.user_id
1486 1486
1487 1487 user = get_user_or_error(userid)
1488 1488
1489 1489 if isinstance(locked, Optional):
1490 1490 lockobj = repo.locked
1491 1491
1492 1492 if lockobj[0] is None:
1493 1493 _d = {
1494 1494 'repo': repo.repo_name,
1495 1495 'locked': False,
1496 1496 'locked_since': None,
1497 1497 'locked_by': None,
1498 1498 'lock_reason': None,
1499 1499 'lock_state_changed': False,
1500 1500 'msg': 'Repo `%s` not locked.' % repo.repo_name
1501 1501 }
1502 1502 return _d
1503 1503 else:
1504 1504 _user_id, _time, _reason = lockobj
1505 1505 lock_user = get_user_or_error(userid)
1506 1506 _d = {
1507 1507 'repo': repo.repo_name,
1508 1508 'locked': True,
1509 1509 'locked_since': _time,
1510 1510 'locked_by': lock_user.username,
1511 1511 'lock_reason': _reason,
1512 1512 'lock_state_changed': False,
1513 1513 'msg': ('Repo `%s` locked by `%s` on `%s`.'
1514 1514 % (repo.repo_name, lock_user.username,
1515 1515 json.dumps(time_to_datetime(_time))))
1516 1516 }
1517 1517 return _d
1518 1518
1519 1519 # force locked state through a flag
1520 1520 else:
1521 1521 locked = str2bool(locked)
1522 1522 lock_reason = Repository.LOCK_API
1523 1523 try:
1524 1524 if locked:
1525 1525 lock_time = time.time()
1526 1526 Repository.lock(repo, user.user_id, lock_time, lock_reason)
1527 1527 else:
1528 1528 lock_time = None
1529 1529 Repository.unlock(repo)
1530 1530 _d = {
1531 1531 'repo': repo.repo_name,
1532 1532 'locked': locked,
1533 1533 'locked_since': lock_time,
1534 1534 'locked_by': user.username,
1535 1535 'lock_reason': lock_reason,
1536 1536 'lock_state_changed': True,
1537 1537 'msg': ('User `%s` set lock state for repo `%s` to `%s`'
1538 1538 % (user.username, repo.repo_name, locked))
1539 1539 }
1540 1540 return _d
1541 1541 except Exception:
1542 1542 log.exception(
1543 1543 "Exception occurred while trying to lock repository")
1544 1544 raise JSONRPCError(
1545 1545 'Error occurred locking repository `%s`' % repo.repo_name
1546 1546 )
1547 1547
1548 1548
1549 1549 @jsonrpc_method()
1550 1550 def comment_commit(
1551 1551 request, apiuser, repoid, commit_id, message, status=Optional(None),
1552 1552 comment_type=Optional(ChangesetComment.COMMENT_TYPE_NOTE),
1553 1553 resolves_comment_id=Optional(None), extra_recipients=Optional([]),
1554 1554 userid=Optional(OAttr('apiuser')), send_email=Optional(True)):
1555 1555 """
1556 1556 Set a commit comment, and optionally change the status of the commit.
1557 1557
1558 1558 :param apiuser: This is filled automatically from the |authtoken|.
1559 1559 :type apiuser: AuthUser
1560 1560 :param repoid: Set the repository name or repository ID.
1561 1561 :type repoid: str or int
1562 1562 :param commit_id: Specify the commit_id for which to set a comment.
1563 1563 :type commit_id: str
1564 1564 :param message: The comment text.
1565 1565 :type message: str
1566 1566 :param status: (**Optional**) status of commit, one of: 'not_reviewed',
1567 1567 'approved', 'rejected', 'under_review'
1568 1568 :type status: str
1569 1569 :param comment_type: Comment type, one of: 'note', 'todo'
1570 1570 :type comment_type: Optional(str), default: 'note'
1571 1571 :param resolves_comment_id: id of comment which this one will resolve
1572 1572 :type resolves_comment_id: Optional(int)
1573 1573 :param extra_recipients: list of user ids or usernames to add
1574 1574 notifications for this comment. Acts like a CC for notification
1575 1575 :type extra_recipients: Optional(list)
1576 1576 :param userid: Set the user name of the comment creator.
1577 1577 :type userid: Optional(str or int)
1578 1578 :param send_email: Define if this comment should also send email notification
1579 1579 :type send_email: Optional(bool)
1580 1580
1581 1581 Example error output:
1582 1582
1583 1583 .. code-block:: bash
1584 1584
1585 1585 {
1586 1586 "id" : <id_given_in_input>,
1587 1587 "result" : {
1588 1588 "msg": "Commented on commit `<commit_id>` for repository `<repoid>`",
1589 1589 "status_change": null or <status>,
1590 1590 "success": true
1591 1591 },
1592 1592 "error" : null
1593 1593 }
1594 1594
1595 1595 """
1596 1596 repo = get_repo_or_error(repoid)
1597 1597 if not has_superadmin_permission(apiuser):
1598 1598 _perms = ('repository.read', 'repository.write', 'repository.admin')
1599 1599 validate_repo_permissions(apiuser, repoid, repo, _perms)
1600 1600
1601 1601 try:
1602 commit_id = repo.scm_instance().get_commit(commit_id=commit_id).raw_id
1602 commit = repo.scm_instance().get_commit(commit_id=commit_id)
1603 commit_id = commit.raw_id
1603 1604 except Exception as e:
1604 1605 log.exception('Failed to fetch commit')
1605 1606 raise JSONRPCError(safe_str(e))
1606 1607
1607 1608 if isinstance(userid, Optional):
1608 1609 userid = apiuser.user_id
1609 1610
1610 1611 user = get_user_or_error(userid)
1611 1612 status = Optional.extract(status)
1612 1613 comment_type = Optional.extract(comment_type)
1613 1614 resolves_comment_id = Optional.extract(resolves_comment_id)
1614 1615 extra_recipients = Optional.extract(extra_recipients)
1615 1616 send_email = Optional.extract(send_email, binary=True)
1616 1617
1617 1618 allowed_statuses = [x[0] for x in ChangesetStatus.STATUSES]
1618 1619 if status and status not in allowed_statuses:
1619 1620 raise JSONRPCError('Bad status, must be on '
1620 1621 'of %s got %s' % (allowed_statuses, status,))
1621 1622
1622 1623 if resolves_comment_id:
1623 1624 comment = ChangesetComment.get(resolves_comment_id)
1624 1625 if not comment:
1625 1626 raise JSONRPCError(
1626 1627 'Invalid resolves_comment_id `%s` for this commit.'
1627 1628 % resolves_comment_id)
1628 1629 if comment.comment_type != ChangesetComment.COMMENT_TYPE_TODO:
1629 1630 raise JSONRPCError(
1630 1631 'Comment `%s` is wrong type for setting status to resolved.'
1631 1632 % resolves_comment_id)
1632 1633
1633 1634 try:
1634 1635 rc_config = SettingsModel().get_all_settings()
1635 1636 renderer = rc_config.get('rhodecode_markup_renderer', 'rst')
1636 1637 status_change_label = ChangesetStatus.get_status_lbl(status)
1637 1638 comment = CommentsModel().create(
1638 1639 message, repo, user, commit_id=commit_id,
1639 1640 status_change=status_change_label,
1640 1641 status_change_type=status,
1641 1642 renderer=renderer,
1642 1643 comment_type=comment_type,
1643 1644 resolves_comment_id=resolves_comment_id,
1644 1645 auth_user=apiuser,
1645 1646 extra_recipients=extra_recipients,
1646 1647 send_email=send_email
1647 1648 )
1648 1649 if status:
1649 1650 # also do a status change
1650 1651 try:
1651 1652 ChangesetStatusModel().set_status(
1652 1653 repo, status, user, comment, revision=commit_id,
1653 1654 dont_allow_on_closed_pull_request=True
1654 1655 )
1655 1656 except StatusChangeOnClosedPullRequestError:
1656 1657 log.exception(
1657 1658 "Exception occurred while trying to change repo commit status")
1658 1659 msg = ('Changing status on a commit associated with '
1659 1660 'a closed pull request is not allowed')
1660 1661 raise JSONRPCError(msg)
1661 1662
1663 CommentsModel().trigger_commit_comment_hook(
1664 repo, apiuser, 'create',
1665 data={'comment': comment, 'commit': commit})
1666
1662 1667 Session().commit()
1663 1668 return {
1664 1669 'msg': (
1665 1670 'Commented on commit `%s` for repository `%s`' % (
1666 1671 comment.revision, repo.repo_name)),
1667 1672 'status_change': status,
1668 1673 'success': True,
1669 1674 }
1670 1675 except JSONRPCError:
1671 1676 # catch any inside errors, and re-raise them to prevent from
1672 1677 # below global catch to silence them
1673 1678 raise
1674 1679 except Exception:
1675 1680 log.exception("Exception occurred while trying to comment on commit")
1676 1681 raise JSONRPCError(
1677 1682 'failed to set comment on repository `%s`' % (repo.repo_name,)
1678 1683 )
1679 1684
1680 1685
1681 1686 @jsonrpc_method()
1682 1687 def get_repo_comments(request, apiuser, repoid,
1683 1688 commit_id=Optional(None), comment_type=Optional(None),
1684 1689 userid=Optional(None)):
1685 1690 """
1686 1691 Get all comments for a repository
1687 1692
1688 1693 :param apiuser: This is filled automatically from the |authtoken|.
1689 1694 :type apiuser: AuthUser
1690 1695 :param repoid: Set the repository name or repository ID.
1691 1696 :type repoid: str or int
1692 1697 :param commit_id: Optionally filter the comments by the commit_id
1693 1698 :type commit_id: Optional(str), default: None
1694 1699 :param comment_type: Optionally filter the comments by the comment_type
1695 1700 one of: 'note', 'todo'
1696 1701 :type comment_type: Optional(str), default: None
1697 1702 :param userid: Optionally filter the comments by the author of comment
1698 1703 :type userid: Optional(str or int), Default: None
1699 1704
1700 1705 Example error output:
1701 1706
1702 1707 .. code-block:: bash
1703 1708
1704 1709 {
1705 1710 "id" : <id_given_in_input>,
1706 1711 "result" : [
1707 1712 {
1708 1713 "comment_author": <USER_DETAILS>,
1709 1714 "comment_created_on": "2017-02-01T14:38:16.309",
1710 1715 "comment_f_path": "file.txt",
1711 1716 "comment_id": 282,
1712 1717 "comment_lineno": "n1",
1713 1718 "comment_resolved_by": null,
1714 1719 "comment_status": [],
1715 1720 "comment_text": "This file needs a header",
1716 1721 "comment_type": "todo"
1717 1722 }
1718 1723 ],
1719 1724 "error" : null
1720 1725 }
1721 1726
1722 1727 """
1723 1728 repo = get_repo_or_error(repoid)
1724 1729 if not has_superadmin_permission(apiuser):
1725 1730 _perms = ('repository.read', 'repository.write', 'repository.admin')
1726 1731 validate_repo_permissions(apiuser, repoid, repo, _perms)
1727 1732
1728 1733 commit_id = Optional.extract(commit_id)
1729 1734
1730 1735 userid = Optional.extract(userid)
1731 1736 if userid:
1732 1737 user = get_user_or_error(userid)
1733 1738 else:
1734 1739 user = None
1735 1740
1736 1741 comment_type = Optional.extract(comment_type)
1737 1742 if comment_type and comment_type not in ChangesetComment.COMMENT_TYPES:
1738 1743 raise JSONRPCError(
1739 1744 'comment_type must be one of `{}` got {}'.format(
1740 1745 ChangesetComment.COMMENT_TYPES, comment_type)
1741 1746 )
1742 1747
1743 1748 comments = CommentsModel().get_repository_comments(
1744 1749 repo=repo, comment_type=comment_type, user=user, commit_id=commit_id)
1745 1750 return comments
1746 1751
1747 1752
1748 1753 @jsonrpc_method()
1749 1754 def grant_user_permission(request, apiuser, repoid, userid, perm):
1750 1755 """
1751 1756 Grant permissions for the specified user on the given repository,
1752 1757 or update existing permissions if found.
1753 1758
1754 1759 This command can only be run using an |authtoken| with admin
1755 1760 permissions on the |repo|.
1756 1761
1757 1762 :param apiuser: This is filled automatically from the |authtoken|.
1758 1763 :type apiuser: AuthUser
1759 1764 :param repoid: Set the repository name or repository ID.
1760 1765 :type repoid: str or int
1761 1766 :param userid: Set the user name.
1762 1767 :type userid: str
1763 1768 :param perm: Set the user permissions, using the following format
1764 1769 ``(repository.(none|read|write|admin))``
1765 1770 :type perm: str
1766 1771
1767 1772 Example output:
1768 1773
1769 1774 .. code-block:: bash
1770 1775
1771 1776 id : <id_given_in_input>
1772 1777 result: {
1773 1778 "msg" : "Granted perm: `<perm>` for user: `<username>` in repo: `<reponame>`",
1774 1779 "success": true
1775 1780 }
1776 1781 error: null
1777 1782 """
1778 1783
1779 1784 repo = get_repo_or_error(repoid)
1780 1785 user = get_user_or_error(userid)
1781 1786 perm = get_perm_or_error(perm)
1782 1787 if not has_superadmin_permission(apiuser):
1783 1788 _perms = ('repository.admin',)
1784 1789 validate_repo_permissions(apiuser, repoid, repo, _perms)
1785 1790
1786 1791 perm_additions = [[user.user_id, perm.permission_name, "user"]]
1787 1792 try:
1788 1793 changes = RepoModel().update_permissions(
1789 1794 repo=repo, perm_additions=perm_additions, cur_user=apiuser)
1790 1795
1791 1796 action_data = {
1792 1797 'added': changes['added'],
1793 1798 'updated': changes['updated'],
1794 1799 'deleted': changes['deleted'],
1795 1800 }
1796 1801 audit_logger.store_api(
1797 1802 'repo.edit.permissions', action_data=action_data, user=apiuser, repo=repo)
1798 1803 Session().commit()
1799 1804 PermissionModel().flush_user_permission_caches(changes)
1800 1805
1801 1806 return {
1802 1807 'msg': 'Granted perm: `%s` for user: `%s` in repo: `%s`' % (
1803 1808 perm.permission_name, user.username, repo.repo_name
1804 1809 ),
1805 1810 'success': True
1806 1811 }
1807 1812 except Exception:
1808 1813 log.exception("Exception occurred while trying edit permissions for repo")
1809 1814 raise JSONRPCError(
1810 1815 'failed to edit permission for user: `%s` in repo: `%s`' % (
1811 1816 userid, repoid
1812 1817 )
1813 1818 )
1814 1819
1815 1820
1816 1821 @jsonrpc_method()
1817 1822 def revoke_user_permission(request, apiuser, repoid, userid):
1818 1823 """
1819 1824 Revoke permission for a user on the specified repository.
1820 1825
1821 1826 This command can only be run using an |authtoken| with admin
1822 1827 permissions on the |repo|.
1823 1828
1824 1829 :param apiuser: This is filled automatically from the |authtoken|.
1825 1830 :type apiuser: AuthUser
1826 1831 :param repoid: Set the repository name or repository ID.
1827 1832 :type repoid: str or int
1828 1833 :param userid: Set the user name of revoked user.
1829 1834 :type userid: str or int
1830 1835
1831 1836 Example error output:
1832 1837
1833 1838 .. code-block:: bash
1834 1839
1835 1840 id : <id_given_in_input>
1836 1841 result: {
1837 1842 "msg" : "Revoked perm for user: `<username>` in repo: `<reponame>`",
1838 1843 "success": true
1839 1844 }
1840 1845 error: null
1841 1846 """
1842 1847
1843 1848 repo = get_repo_or_error(repoid)
1844 1849 user = get_user_or_error(userid)
1845 1850 if not has_superadmin_permission(apiuser):
1846 1851 _perms = ('repository.admin',)
1847 1852 validate_repo_permissions(apiuser, repoid, repo, _perms)
1848 1853
1849 1854 perm_deletions = [[user.user_id, None, "user"]]
1850 1855 try:
1851 1856 changes = RepoModel().update_permissions(
1852 1857 repo=repo, perm_deletions=perm_deletions, cur_user=user)
1853 1858
1854 1859 action_data = {
1855 1860 'added': changes['added'],
1856 1861 'updated': changes['updated'],
1857 1862 'deleted': changes['deleted'],
1858 1863 }
1859 1864 audit_logger.store_api(
1860 1865 'repo.edit.permissions', action_data=action_data, user=apiuser, repo=repo)
1861 1866 Session().commit()
1862 1867 PermissionModel().flush_user_permission_caches(changes)
1863 1868
1864 1869 return {
1865 1870 'msg': 'Revoked perm for user: `%s` in repo: `%s`' % (
1866 1871 user.username, repo.repo_name
1867 1872 ),
1868 1873 'success': True
1869 1874 }
1870 1875 except Exception:
1871 1876 log.exception("Exception occurred while trying revoke permissions to repo")
1872 1877 raise JSONRPCError(
1873 1878 'failed to edit permission for user: `%s` in repo: `%s`' % (
1874 1879 userid, repoid
1875 1880 )
1876 1881 )
1877 1882
1878 1883
1879 1884 @jsonrpc_method()
1880 1885 def grant_user_group_permission(request, apiuser, repoid, usergroupid, perm):
1881 1886 """
1882 1887 Grant permission for a user group on the specified repository,
1883 1888 or update existing permissions.
1884 1889
1885 1890 This command can only be run using an |authtoken| with admin
1886 1891 permissions on the |repo|.
1887 1892
1888 1893 :param apiuser: This is filled automatically from the |authtoken|.
1889 1894 :type apiuser: AuthUser
1890 1895 :param repoid: Set the repository name or repository ID.
1891 1896 :type repoid: str or int
1892 1897 :param usergroupid: Specify the ID of the user group.
1893 1898 :type usergroupid: str or int
1894 1899 :param perm: Set the user group permissions using the following
1895 1900 format: (repository.(none|read|write|admin))
1896 1901 :type perm: str
1897 1902
1898 1903 Example output:
1899 1904
1900 1905 .. code-block:: bash
1901 1906
1902 1907 id : <id_given_in_input>
1903 1908 result : {
1904 1909 "msg" : "Granted perm: `<perm>` for group: `<usersgroupname>` in repo: `<reponame>`",
1905 1910 "success": true
1906 1911
1907 1912 }
1908 1913 error : null
1909 1914
1910 1915 Example error output:
1911 1916
1912 1917 .. code-block:: bash
1913 1918
1914 1919 id : <id_given_in_input>
1915 1920 result : null
1916 1921 error : {
1917 1922 "failed to edit permission for user group: `<usergroup>` in repo `<repo>`'
1918 1923 }
1919 1924
1920 1925 """
1921 1926
1922 1927 repo = get_repo_or_error(repoid)
1923 1928 perm = get_perm_or_error(perm)
1924 1929 if not has_superadmin_permission(apiuser):
1925 1930 _perms = ('repository.admin',)
1926 1931 validate_repo_permissions(apiuser, repoid, repo, _perms)
1927 1932
1928 1933 user_group = get_user_group_or_error(usergroupid)
1929 1934 if not has_superadmin_permission(apiuser):
1930 1935 # check if we have at least read permission for this user group !
1931 1936 _perms = ('usergroup.read', 'usergroup.write', 'usergroup.admin',)
1932 1937 if not HasUserGroupPermissionAnyApi(*_perms)(
1933 1938 user=apiuser, user_group_name=user_group.users_group_name):
1934 1939 raise JSONRPCError(
1935 1940 'user group `%s` does not exist' % (usergroupid,))
1936 1941
1937 1942 perm_additions = [[user_group.users_group_id, perm.permission_name, "user_group"]]
1938 1943 try:
1939 1944 changes = RepoModel().update_permissions(
1940 1945 repo=repo, perm_additions=perm_additions, cur_user=apiuser)
1941 1946 action_data = {
1942 1947 'added': changes['added'],
1943 1948 'updated': changes['updated'],
1944 1949 'deleted': changes['deleted'],
1945 1950 }
1946 1951 audit_logger.store_api(
1947 1952 'repo.edit.permissions', action_data=action_data, user=apiuser, repo=repo)
1948 1953 Session().commit()
1949 1954 PermissionModel().flush_user_permission_caches(changes)
1950 1955
1951 1956 return {
1952 1957 'msg': 'Granted perm: `%s` for user group: `%s` in '
1953 1958 'repo: `%s`' % (
1954 1959 perm.permission_name, user_group.users_group_name,
1955 1960 repo.repo_name
1956 1961 ),
1957 1962 'success': True
1958 1963 }
1959 1964 except Exception:
1960 1965 log.exception(
1961 1966 "Exception occurred while trying change permission on repo")
1962 1967 raise JSONRPCError(
1963 1968 'failed to edit permission for user group: `%s` in '
1964 1969 'repo: `%s`' % (
1965 1970 usergroupid, repo.repo_name
1966 1971 )
1967 1972 )
1968 1973
1969 1974
1970 1975 @jsonrpc_method()
1971 1976 def revoke_user_group_permission(request, apiuser, repoid, usergroupid):
1972 1977 """
1973 1978 Revoke the permissions of a user group on a given repository.
1974 1979
1975 1980 This command can only be run using an |authtoken| with admin
1976 1981 permissions on the |repo|.
1977 1982
1978 1983 :param apiuser: This is filled automatically from the |authtoken|.
1979 1984 :type apiuser: AuthUser
1980 1985 :param repoid: Set the repository name or repository ID.
1981 1986 :type repoid: str or int
1982 1987 :param usergroupid: Specify the user group ID.
1983 1988 :type usergroupid: str or int
1984 1989
1985 1990 Example output:
1986 1991
1987 1992 .. code-block:: bash
1988 1993
1989 1994 id : <id_given_in_input>
1990 1995 result: {
1991 1996 "msg" : "Revoked perm for group: `<usersgroupname>` in repo: `<reponame>`",
1992 1997 "success": true
1993 1998 }
1994 1999 error: null
1995 2000 """
1996 2001
1997 2002 repo = get_repo_or_error(repoid)
1998 2003 if not has_superadmin_permission(apiuser):
1999 2004 _perms = ('repository.admin',)
2000 2005 validate_repo_permissions(apiuser, repoid, repo, _perms)
2001 2006
2002 2007 user_group = get_user_group_or_error(usergroupid)
2003 2008 if not has_superadmin_permission(apiuser):
2004 2009 # check if we have at least read permission for this user group !
2005 2010 _perms = ('usergroup.read', 'usergroup.write', 'usergroup.admin',)
2006 2011 if not HasUserGroupPermissionAnyApi(*_perms)(
2007 2012 user=apiuser, user_group_name=user_group.users_group_name):
2008 2013 raise JSONRPCError(
2009 2014 'user group `%s` does not exist' % (usergroupid,))
2010 2015
2011 2016 perm_deletions = [[user_group.users_group_id, None, "user_group"]]
2012 2017 try:
2013 2018 changes = RepoModel().update_permissions(
2014 2019 repo=repo, perm_deletions=perm_deletions, cur_user=apiuser)
2015 2020 action_data = {
2016 2021 'added': changes['added'],
2017 2022 'updated': changes['updated'],
2018 2023 'deleted': changes['deleted'],
2019 2024 }
2020 2025 audit_logger.store_api(
2021 2026 'repo.edit.permissions', action_data=action_data, user=apiuser, repo=repo)
2022 2027 Session().commit()
2023 2028 PermissionModel().flush_user_permission_caches(changes)
2024 2029
2025 2030 return {
2026 2031 'msg': 'Revoked perm for user group: `%s` in repo: `%s`' % (
2027 2032 user_group.users_group_name, repo.repo_name
2028 2033 ),
2029 2034 'success': True
2030 2035 }
2031 2036 except Exception:
2032 2037 log.exception("Exception occurred while trying revoke "
2033 2038 "user group permission on repo")
2034 2039 raise JSONRPCError(
2035 2040 'failed to edit permission for user group: `%s` in '
2036 2041 'repo: `%s`' % (
2037 2042 user_group.users_group_name, repo.repo_name
2038 2043 )
2039 2044 )
2040 2045
2041 2046
2042 2047 @jsonrpc_method()
2043 2048 def pull(request, apiuser, repoid, remote_uri=Optional(None)):
2044 2049 """
2045 2050 Triggers a pull on the given repository from a remote location. You
2046 2051 can use this to keep remote repositories up-to-date.
2047 2052
2048 2053 This command can only be run using an |authtoken| with admin
2049 2054 rights to the specified repository. For more information,
2050 2055 see :ref:`config-token-ref`.
2051 2056
2052 2057 This command takes the following options:
2053 2058
2054 2059 :param apiuser: This is filled automatically from the |authtoken|.
2055 2060 :type apiuser: AuthUser
2056 2061 :param repoid: The repository name or repository ID.
2057 2062 :type repoid: str or int
2058 2063 :param remote_uri: Optional remote URI to pass in for pull
2059 2064 :type remote_uri: str
2060 2065
2061 2066 Example output:
2062 2067
2063 2068 .. code-block:: bash
2064 2069
2065 2070 id : <id_given_in_input>
2066 2071 result : {
2067 2072 "msg": "Pulled from url `<remote_url>` on repo `<repository name>`"
2068 2073 "repository": "<repository name>"
2069 2074 }
2070 2075 error : null
2071 2076
2072 2077 Example error output:
2073 2078
2074 2079 .. code-block:: bash
2075 2080
2076 2081 id : <id_given_in_input>
2077 2082 result : null
2078 2083 error : {
2079 2084 "Unable to push changes from `<remote_url>`"
2080 2085 }
2081 2086
2082 2087 """
2083 2088
2084 2089 repo = get_repo_or_error(repoid)
2085 2090 remote_uri = Optional.extract(remote_uri)
2086 2091 remote_uri_display = remote_uri or repo.clone_uri_hidden
2087 2092 if not has_superadmin_permission(apiuser):
2088 2093 _perms = ('repository.admin',)
2089 2094 validate_repo_permissions(apiuser, repoid, repo, _perms)
2090 2095
2091 2096 try:
2092 2097 ScmModel().pull_changes(
2093 2098 repo.repo_name, apiuser.username, remote_uri=remote_uri)
2094 2099 return {
2095 2100 'msg': 'Pulled from url `%s` on repo `%s`' % (
2096 2101 remote_uri_display, repo.repo_name),
2097 2102 'repository': repo.repo_name
2098 2103 }
2099 2104 except Exception:
2100 2105 log.exception("Exception occurred while trying to "
2101 2106 "pull changes from remote location")
2102 2107 raise JSONRPCError(
2103 2108 'Unable to pull changes from `%s`' % remote_uri_display
2104 2109 )
2105 2110
2106 2111
2107 2112 @jsonrpc_method()
2108 2113 def strip(request, apiuser, repoid, revision, branch):
2109 2114 """
2110 2115 Strips the given revision from the specified repository.
2111 2116
2112 2117 * This will remove the revision and all of its decendants.
2113 2118
2114 2119 This command can only be run using an |authtoken| with admin rights to
2115 2120 the specified repository.
2116 2121
2117 2122 This command takes the following options:
2118 2123
2119 2124 :param apiuser: This is filled automatically from the |authtoken|.
2120 2125 :type apiuser: AuthUser
2121 2126 :param repoid: The repository name or repository ID.
2122 2127 :type repoid: str or int
2123 2128 :param revision: The revision you wish to strip.
2124 2129 :type revision: str
2125 2130 :param branch: The branch from which to strip the revision.
2126 2131 :type branch: str
2127 2132
2128 2133 Example output:
2129 2134
2130 2135 .. code-block:: bash
2131 2136
2132 2137 id : <id_given_in_input>
2133 2138 result : {
2134 2139 "msg": "'Stripped commit <commit_hash> from repo `<repository name>`'"
2135 2140 "repository": "<repository name>"
2136 2141 }
2137 2142 error : null
2138 2143
2139 2144 Example error output:
2140 2145
2141 2146 .. code-block:: bash
2142 2147
2143 2148 id : <id_given_in_input>
2144 2149 result : null
2145 2150 error : {
2146 2151 "Unable to strip commit <commit_hash> from repo `<repository name>`"
2147 2152 }
2148 2153
2149 2154 """
2150 2155
2151 2156 repo = get_repo_or_error(repoid)
2152 2157 if not has_superadmin_permission(apiuser):
2153 2158 _perms = ('repository.admin',)
2154 2159 validate_repo_permissions(apiuser, repoid, repo, _perms)
2155 2160
2156 2161 try:
2157 2162 ScmModel().strip(repo, revision, branch)
2158 2163 audit_logger.store_api(
2159 2164 'repo.commit.strip', action_data={'commit_id': revision},
2160 2165 repo=repo,
2161 2166 user=apiuser, commit=True)
2162 2167
2163 2168 return {
2164 2169 'msg': 'Stripped commit %s from repo `%s`' % (
2165 2170 revision, repo.repo_name),
2166 2171 'repository': repo.repo_name
2167 2172 }
2168 2173 except Exception:
2169 2174 log.exception("Exception while trying to strip")
2170 2175 raise JSONRPCError(
2171 2176 'Unable to strip commit %s from repo `%s`' % (
2172 2177 revision, repo.repo_name)
2173 2178 )
2174 2179
2175 2180
2176 2181 @jsonrpc_method()
2177 2182 def get_repo_settings(request, apiuser, repoid, key=Optional(None)):
2178 2183 """
2179 2184 Returns all settings for a repository. If key is given it only returns the
2180 2185 setting identified by the key or null.
2181 2186
2182 2187 :param apiuser: This is filled automatically from the |authtoken|.
2183 2188 :type apiuser: AuthUser
2184 2189 :param repoid: The repository name or repository id.
2185 2190 :type repoid: str or int
2186 2191 :param key: Key of the setting to return.
2187 2192 :type: key: Optional(str)
2188 2193
2189 2194 Example output:
2190 2195
2191 2196 .. code-block:: bash
2192 2197
2193 2198 {
2194 2199 "error": null,
2195 2200 "id": 237,
2196 2201 "result": {
2197 2202 "extensions_largefiles": true,
2198 2203 "extensions_evolve": true,
2199 2204 "hooks_changegroup_push_logger": true,
2200 2205 "hooks_changegroup_repo_size": false,
2201 2206 "hooks_outgoing_pull_logger": true,
2202 2207 "phases_publish": "True",
2203 2208 "rhodecode_hg_use_rebase_for_merging": true,
2204 2209 "rhodecode_pr_merge_enabled": true,
2205 2210 "rhodecode_use_outdated_comments": true
2206 2211 }
2207 2212 }
2208 2213 """
2209 2214
2210 2215 # Restrict access to this api method to admins only.
2211 2216 if not has_superadmin_permission(apiuser):
2212 2217 raise JSONRPCForbidden()
2213 2218
2214 2219 try:
2215 2220 repo = get_repo_or_error(repoid)
2216 2221 settings_model = VcsSettingsModel(repo=repo)
2217 2222 settings = settings_model.get_global_settings()
2218 2223 settings.update(settings_model.get_repo_settings())
2219 2224
2220 2225 # If only a single setting is requested fetch it from all settings.
2221 2226 key = Optional.extract(key)
2222 2227 if key is not None:
2223 2228 settings = settings.get(key, None)
2224 2229 except Exception:
2225 2230 msg = 'Failed to fetch settings for repository `{}`'.format(repoid)
2226 2231 log.exception(msg)
2227 2232 raise JSONRPCError(msg)
2228 2233
2229 2234 return settings
2230 2235
2231 2236
2232 2237 @jsonrpc_method()
2233 2238 def set_repo_settings(request, apiuser, repoid, settings):
2234 2239 """
2235 2240 Update repository settings. Returns true on success.
2236 2241
2237 2242 :param apiuser: This is filled automatically from the |authtoken|.
2238 2243 :type apiuser: AuthUser
2239 2244 :param repoid: The repository name or repository id.
2240 2245 :type repoid: str or int
2241 2246 :param settings: The new settings for the repository.
2242 2247 :type: settings: dict
2243 2248
2244 2249 Example output:
2245 2250
2246 2251 .. code-block:: bash
2247 2252
2248 2253 {
2249 2254 "error": null,
2250 2255 "id": 237,
2251 2256 "result": true
2252 2257 }
2253 2258 """
2254 2259 # Restrict access to this api method to admins only.
2255 2260 if not has_superadmin_permission(apiuser):
2256 2261 raise JSONRPCForbidden()
2257 2262
2258 2263 if type(settings) is not dict:
2259 2264 raise JSONRPCError('Settings have to be a JSON Object.')
2260 2265
2261 2266 try:
2262 2267 settings_model = VcsSettingsModel(repo=repoid)
2263 2268
2264 2269 # Merge global, repo and incoming settings.
2265 2270 new_settings = settings_model.get_global_settings()
2266 2271 new_settings.update(settings_model.get_repo_settings())
2267 2272 new_settings.update(settings)
2268 2273
2269 2274 # Update the settings.
2270 2275 inherit_global_settings = new_settings.get(
2271 2276 'inherit_global_settings', False)
2272 2277 settings_model.create_or_update_repo_settings(
2273 2278 new_settings, inherit_global_settings=inherit_global_settings)
2274 2279 Session().commit()
2275 2280 except Exception:
2276 2281 msg = 'Failed to update settings for repository `{}`'.format(repoid)
2277 2282 log.exception(msg)
2278 2283 raise JSONRPCError(msg)
2279 2284
2280 2285 # Indicate success.
2281 2286 return True
2282 2287
2283 2288
2284 2289 @jsonrpc_method()
2285 2290 def maintenance(request, apiuser, repoid):
2286 2291 """
2287 2292 Triggers a maintenance on the given repository.
2288 2293
2289 2294 This command can only be run using an |authtoken| with admin
2290 2295 rights to the specified repository. For more information,
2291 2296 see :ref:`config-token-ref`.
2292 2297
2293 2298 This command takes the following options:
2294 2299
2295 2300 :param apiuser: This is filled automatically from the |authtoken|.
2296 2301 :type apiuser: AuthUser
2297 2302 :param repoid: The repository name or repository ID.
2298 2303 :type repoid: str or int
2299 2304
2300 2305 Example output:
2301 2306
2302 2307 .. code-block:: bash
2303 2308
2304 2309 id : <id_given_in_input>
2305 2310 result : {
2306 2311 "msg": "executed maintenance command",
2307 2312 "executed_actions": [
2308 2313 <action_message>, <action_message2>...
2309 2314 ],
2310 2315 "repository": "<repository name>"
2311 2316 }
2312 2317 error : null
2313 2318
2314 2319 Example error output:
2315 2320
2316 2321 .. code-block:: bash
2317 2322
2318 2323 id : <id_given_in_input>
2319 2324 result : null
2320 2325 error : {
2321 2326 "Unable to execute maintenance on `<reponame>`"
2322 2327 }
2323 2328
2324 2329 """
2325 2330
2326 2331 repo = get_repo_or_error(repoid)
2327 2332 if not has_superadmin_permission(apiuser):
2328 2333 _perms = ('repository.admin',)
2329 2334 validate_repo_permissions(apiuser, repoid, repo, _perms)
2330 2335
2331 2336 try:
2332 2337 maintenance = repo_maintenance.RepoMaintenance()
2333 2338 executed_actions = maintenance.execute(repo)
2334 2339
2335 2340 return {
2336 2341 'msg': 'executed maintenance command',
2337 2342 'executed_actions': executed_actions,
2338 2343 'repository': repo.repo_name
2339 2344 }
2340 2345 except Exception:
2341 2346 log.exception("Exception occurred while trying to run maintenance")
2342 2347 raise JSONRPCError(
2343 2348 'Unable to execute maintenance on `%s`' % repo.repo_name)
@@ -1,601 +1,606 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
22 22 import logging
23 23 import collections
24 24
25 25 from pyramid.httpexceptions import HTTPNotFound, HTTPBadRequest, HTTPFound
26 26 from pyramid.view import view_config
27 27 from pyramid.renderers import render
28 28 from pyramid.response import Response
29 29
30 30 from rhodecode.apps._base import RepoAppView
31 31 from rhodecode.apps.file_store import utils as store_utils
32 32 from rhodecode.apps.file_store.exceptions import FileNotAllowedException, FileOverSizeException
33 33
34 34 from rhodecode.lib import diffs, codeblocks
35 35 from rhodecode.lib.auth import (
36 36 LoginRequired, HasRepoPermissionAnyDecorator, NotAnonymous, CSRFRequired)
37 37
38 38 from rhodecode.lib.compat import OrderedDict
39 39 from rhodecode.lib.diffs import (
40 40 cache_diff, load_cached_diff, diff_cache_exist, get_diff_context,
41 41 get_diff_whitespace_flag)
42 42 from rhodecode.lib.exceptions import StatusChangeOnClosedPullRequestError
43 43 import rhodecode.lib.helpers as h
44 44 from rhodecode.lib.utils2 import safe_unicode, str2bool
45 45 from rhodecode.lib.vcs.backends.base import EmptyCommit
46 46 from rhodecode.lib.vcs.exceptions import (
47 47 RepositoryError, CommitDoesNotExistError)
48 48 from rhodecode.model.db import ChangesetComment, ChangesetStatus, FileStore
49 49 from rhodecode.model.changeset_status import ChangesetStatusModel
50 50 from rhodecode.model.comment import CommentsModel
51 51 from rhodecode.model.meta import Session
52 52 from rhodecode.model.settings import VcsSettingsModel
53 53
54 54 log = logging.getLogger(__name__)
55 55
56 56
57 57 def _update_with_GET(params, request):
58 58 for k in ['diff1', 'diff2', 'diff']:
59 59 params[k] += request.GET.getall(k)
60 60
61 61
62 62 class RepoCommitsView(RepoAppView):
63 63 def load_default_context(self):
64 64 c = self._get_local_tmpl_context(include_app_defaults=True)
65 65 c.rhodecode_repo = self.rhodecode_vcs_repo
66 66
67 67 return c
68 68
69 69 def _is_diff_cache_enabled(self, target_repo):
70 70 caching_enabled = self._get_general_setting(
71 71 target_repo, 'rhodecode_diff_cache')
72 72 log.debug('Diff caching enabled: %s', caching_enabled)
73 73 return caching_enabled
74 74
75 75 def _commit(self, commit_id_range, method):
76 76 _ = self.request.translate
77 77 c = self.load_default_context()
78 78 c.fulldiff = self.request.GET.get('fulldiff')
79 79
80 80 # fetch global flags of ignore ws or context lines
81 81 diff_context = get_diff_context(self.request)
82 82 hide_whitespace_changes = get_diff_whitespace_flag(self.request)
83 83
84 84 # diff_limit will cut off the whole diff if the limit is applied
85 85 # otherwise it will just hide the big files from the front-end
86 86 diff_limit = c.visual.cut_off_limit_diff
87 87 file_limit = c.visual.cut_off_limit_file
88 88
89 89 # get ranges of commit ids if preset
90 90 commit_range = commit_id_range.split('...')[:2]
91 91
92 92 try:
93 93 pre_load = ['affected_files', 'author', 'branch', 'date',
94 94 'message', 'parents']
95 95 if self.rhodecode_vcs_repo.alias == 'hg':
96 96 pre_load += ['hidden', 'obsolete', 'phase']
97 97
98 98 if len(commit_range) == 2:
99 99 commits = self.rhodecode_vcs_repo.get_commits(
100 100 start_id=commit_range[0], end_id=commit_range[1],
101 101 pre_load=pre_load, translate_tags=False)
102 102 commits = list(commits)
103 103 else:
104 104 commits = [self.rhodecode_vcs_repo.get_commit(
105 105 commit_id=commit_id_range, pre_load=pre_load)]
106 106
107 107 c.commit_ranges = commits
108 108 if not c.commit_ranges:
109 109 raise RepositoryError('The commit range returned an empty result')
110 110 except CommitDoesNotExistError as e:
111 111 msg = _('No such commit exists. Org exception: `{}`').format(e)
112 112 h.flash(msg, category='error')
113 113 raise HTTPNotFound()
114 114 except Exception:
115 115 log.exception("General failure")
116 116 raise HTTPNotFound()
117 117
118 118 c.changes = OrderedDict()
119 119 c.lines_added = 0
120 120 c.lines_deleted = 0
121 121
122 122 # auto collapse if we have more than limit
123 123 collapse_limit = diffs.DiffProcessor._collapse_commits_over
124 124 c.collapse_all_commits = len(c.commit_ranges) > collapse_limit
125 125
126 126 c.commit_statuses = ChangesetStatus.STATUSES
127 127 c.inline_comments = []
128 128 c.files = []
129 129
130 130 c.statuses = []
131 131 c.comments = []
132 132 c.unresolved_comments = []
133 133 c.resolved_comments = []
134 134 if len(c.commit_ranges) == 1:
135 135 commit = c.commit_ranges[0]
136 136 c.comments = CommentsModel().get_comments(
137 137 self.db_repo.repo_id,
138 138 revision=commit.raw_id)
139 139 c.statuses.append(ChangesetStatusModel().get_status(
140 140 self.db_repo.repo_id, commit.raw_id))
141 141 # comments from PR
142 142 statuses = ChangesetStatusModel().get_statuses(
143 143 self.db_repo.repo_id, commit.raw_id,
144 144 with_revisions=True)
145 145 prs = set(st.pull_request for st in statuses
146 146 if st.pull_request is not None)
147 147 # from associated statuses, check the pull requests, and
148 148 # show comments from them
149 149 for pr in prs:
150 150 c.comments.extend(pr.comments)
151 151
152 152 c.unresolved_comments = CommentsModel()\
153 153 .get_commit_unresolved_todos(commit.raw_id)
154 154 c.resolved_comments = CommentsModel()\
155 155 .get_commit_resolved_todos(commit.raw_id)
156 156
157 157 diff = None
158 158 # Iterate over ranges (default commit view is always one commit)
159 159 for commit in c.commit_ranges:
160 160 c.changes[commit.raw_id] = []
161 161
162 162 commit2 = commit
163 163 commit1 = commit.first_parent
164 164
165 165 if method == 'show':
166 166 inline_comments = CommentsModel().get_inline_comments(
167 167 self.db_repo.repo_id, revision=commit.raw_id)
168 168 c.inline_cnt = CommentsModel().get_inline_comments_count(
169 169 inline_comments)
170 170 c.inline_comments = inline_comments
171 171
172 172 cache_path = self.rhodecode_vcs_repo.get_create_shadow_cache_pr_path(
173 173 self.db_repo)
174 174 cache_file_path = diff_cache_exist(
175 175 cache_path, 'diff', commit.raw_id,
176 176 hide_whitespace_changes, diff_context, c.fulldiff)
177 177
178 178 caching_enabled = self._is_diff_cache_enabled(self.db_repo)
179 179 force_recache = str2bool(self.request.GET.get('force_recache'))
180 180
181 181 cached_diff = None
182 182 if caching_enabled:
183 183 cached_diff = load_cached_diff(cache_file_path)
184 184
185 185 has_proper_diff_cache = cached_diff and cached_diff.get('diff')
186 186 if not force_recache and has_proper_diff_cache:
187 187 diffset = cached_diff['diff']
188 188 else:
189 189 vcs_diff = self.rhodecode_vcs_repo.get_diff(
190 190 commit1, commit2,
191 191 ignore_whitespace=hide_whitespace_changes,
192 192 context=diff_context)
193 193
194 194 diff_processor = diffs.DiffProcessor(
195 195 vcs_diff, format='newdiff', diff_limit=diff_limit,
196 196 file_limit=file_limit, show_full_diff=c.fulldiff)
197 197
198 198 _parsed = diff_processor.prepare()
199 199
200 200 diffset = codeblocks.DiffSet(
201 201 repo_name=self.db_repo_name,
202 202 source_node_getter=codeblocks.diffset_node_getter(commit1),
203 203 target_node_getter=codeblocks.diffset_node_getter(commit2))
204 204
205 205 diffset = self.path_filter.render_patchset_filtered(
206 206 diffset, _parsed, commit1.raw_id, commit2.raw_id)
207 207
208 208 # save cached diff
209 209 if caching_enabled:
210 210 cache_diff(cache_file_path, diffset, None)
211 211
212 212 c.limited_diff = diffset.limited_diff
213 213 c.changes[commit.raw_id] = diffset
214 214 else:
215 215 # TODO(marcink): no cache usage here...
216 216 _diff = self.rhodecode_vcs_repo.get_diff(
217 217 commit1, commit2,
218 218 ignore_whitespace=hide_whitespace_changes, context=diff_context)
219 219 diff_processor = diffs.DiffProcessor(
220 220 _diff, format='newdiff', diff_limit=diff_limit,
221 221 file_limit=file_limit, show_full_diff=c.fulldiff)
222 222 # downloads/raw we only need RAW diff nothing else
223 223 diff = self.path_filter.get_raw_patch(diff_processor)
224 224 c.changes[commit.raw_id] = [None, None, None, None, diff, None, None]
225 225
226 226 # sort comments by how they were generated
227 227 c.comments = sorted(c.comments, key=lambda x: x.comment_id)
228 228
229 229 if len(c.commit_ranges) == 1:
230 230 c.commit = c.commit_ranges[0]
231 231 c.parent_tmpl = ''.join(
232 232 '# Parent %s\n' % x.raw_id for x in c.commit.parents)
233 233
234 234 if method == 'download':
235 235 response = Response(diff)
236 236 response.content_type = 'text/plain'
237 237 response.content_disposition = (
238 238 'attachment; filename=%s.diff' % commit_id_range[:12])
239 239 return response
240 240 elif method == 'patch':
241 241 c.diff = safe_unicode(diff)
242 242 patch = render(
243 243 'rhodecode:templates/changeset/patch_changeset.mako',
244 244 self._get_template_context(c), self.request)
245 245 response = Response(patch)
246 246 response.content_type = 'text/plain'
247 247 return response
248 248 elif method == 'raw':
249 249 response = Response(diff)
250 250 response.content_type = 'text/plain'
251 251 return response
252 252 elif method == 'show':
253 253 if len(c.commit_ranges) == 1:
254 254 html = render(
255 255 'rhodecode:templates/changeset/changeset.mako',
256 256 self._get_template_context(c), self.request)
257 257 return Response(html)
258 258 else:
259 259 c.ancestor = None
260 260 c.target_repo = self.db_repo
261 261 html = render(
262 262 'rhodecode:templates/changeset/changeset_range.mako',
263 263 self._get_template_context(c), self.request)
264 264 return Response(html)
265 265
266 266 raise HTTPBadRequest()
267 267
268 268 @LoginRequired()
269 269 @HasRepoPermissionAnyDecorator(
270 270 'repository.read', 'repository.write', 'repository.admin')
271 271 @view_config(
272 272 route_name='repo_commit', request_method='GET',
273 273 renderer=None)
274 274 def repo_commit_show(self):
275 275 commit_id = self.request.matchdict['commit_id']
276 276 return self._commit(commit_id, method='show')
277 277
278 278 @LoginRequired()
279 279 @HasRepoPermissionAnyDecorator(
280 280 'repository.read', 'repository.write', 'repository.admin')
281 281 @view_config(
282 282 route_name='repo_commit_raw', request_method='GET',
283 283 renderer=None)
284 284 @view_config(
285 285 route_name='repo_commit_raw_deprecated', request_method='GET',
286 286 renderer=None)
287 287 def repo_commit_raw(self):
288 288 commit_id = self.request.matchdict['commit_id']
289 289 return self._commit(commit_id, method='raw')
290 290
291 291 @LoginRequired()
292 292 @HasRepoPermissionAnyDecorator(
293 293 'repository.read', 'repository.write', 'repository.admin')
294 294 @view_config(
295 295 route_name='repo_commit_patch', request_method='GET',
296 296 renderer=None)
297 297 def repo_commit_patch(self):
298 298 commit_id = self.request.matchdict['commit_id']
299 299 return self._commit(commit_id, method='patch')
300 300
301 301 @LoginRequired()
302 302 @HasRepoPermissionAnyDecorator(
303 303 'repository.read', 'repository.write', 'repository.admin')
304 304 @view_config(
305 305 route_name='repo_commit_download', request_method='GET',
306 306 renderer=None)
307 307 def repo_commit_download(self):
308 308 commit_id = self.request.matchdict['commit_id']
309 309 return self._commit(commit_id, method='download')
310 310
311 311 @LoginRequired()
312 312 @NotAnonymous()
313 313 @HasRepoPermissionAnyDecorator(
314 314 'repository.read', 'repository.write', 'repository.admin')
315 315 @CSRFRequired()
316 316 @view_config(
317 317 route_name='repo_commit_comment_create', request_method='POST',
318 318 renderer='json_ext')
319 319 def repo_commit_comment_create(self):
320 320 _ = self.request.translate
321 321 commit_id = self.request.matchdict['commit_id']
322 322
323 323 c = self.load_default_context()
324 324 status = self.request.POST.get('changeset_status', None)
325 325 text = self.request.POST.get('text')
326 326 comment_type = self.request.POST.get('comment_type')
327 327 resolves_comment_id = self.request.POST.get('resolves_comment_id', None)
328 328
329 329 if status:
330 330 text = text or (_('Status change %(transition_icon)s %(status)s')
331 331 % {'transition_icon': '>',
332 332 'status': ChangesetStatus.get_status_lbl(status)})
333 333
334 334 multi_commit_ids = []
335 335 for _commit_id in self.request.POST.get('commit_ids', '').split(','):
336 336 if _commit_id not in ['', None, EmptyCommit.raw_id]:
337 337 if _commit_id not in multi_commit_ids:
338 338 multi_commit_ids.append(_commit_id)
339 339
340 340 commit_ids = multi_commit_ids or [commit_id]
341 341
342 342 comment = None
343 343 for current_id in filter(None, commit_ids):
344 344 comment = CommentsModel().create(
345 345 text=text,
346 346 repo=self.db_repo.repo_id,
347 347 user=self._rhodecode_db_user.user_id,
348 348 commit_id=current_id,
349 349 f_path=self.request.POST.get('f_path'),
350 350 line_no=self.request.POST.get('line'),
351 351 status_change=(ChangesetStatus.get_status_lbl(status)
352 352 if status else None),
353 353 status_change_type=status,
354 354 comment_type=comment_type,
355 355 resolves_comment_id=resolves_comment_id,
356 356 auth_user=self._rhodecode_user
357 357 )
358 358
359 359 # get status if set !
360 360 if status:
361 361 # if latest status was from pull request and it's closed
362 362 # disallow changing status !
363 363 # dont_allow_on_closed_pull_request = True !
364 364
365 365 try:
366 366 ChangesetStatusModel().set_status(
367 367 self.db_repo.repo_id,
368 368 status,
369 369 self._rhodecode_db_user.user_id,
370 370 comment,
371 371 revision=current_id,
372 372 dont_allow_on_closed_pull_request=True
373 373 )
374 374 except StatusChangeOnClosedPullRequestError:
375 375 msg = _('Changing the status of a commit associated with '
376 376 'a closed pull request is not allowed')
377 377 log.exception(msg)
378 378 h.flash(msg, category='warning')
379 379 raise HTTPFound(h.route_path(
380 380 'repo_commit', repo_name=self.db_repo_name,
381 381 commit_id=current_id))
382 382
383 commit = self.db_repo.get_commit(current_id)
384 CommentsModel().trigger_commit_comment_hook(
385 self.db_repo, self._rhodecode_user, 'create',
386 data={'comment': comment, 'commit': commit})
387
383 388 # finalize, commit and redirect
384 389 Session().commit()
385 390
386 391 data = {
387 392 'target_id': h.safeid(h.safe_unicode(
388 393 self.request.POST.get('f_path'))),
389 394 }
390 395 if comment:
391 396 c.co = comment
392 397 rendered_comment = render(
393 398 'rhodecode:templates/changeset/changeset_comment_block.mako',
394 399 self._get_template_context(c), self.request)
395 400
396 401 data.update(comment.get_dict())
397 402 data.update({'rendered_text': rendered_comment})
398 403
399 404 return data
400 405
401 406 @LoginRequired()
402 407 @NotAnonymous()
403 408 @HasRepoPermissionAnyDecorator(
404 409 'repository.read', 'repository.write', 'repository.admin')
405 410 @CSRFRequired()
406 411 @view_config(
407 412 route_name='repo_commit_comment_preview', request_method='POST',
408 413 renderer='string', xhr=True)
409 414 def repo_commit_comment_preview(self):
410 415 # Technically a CSRF token is not needed as no state changes with this
411 416 # call. However, as this is a POST is better to have it, so automated
412 417 # tools don't flag it as potential CSRF.
413 418 # Post is required because the payload could be bigger than the maximum
414 419 # allowed by GET.
415 420
416 421 text = self.request.POST.get('text')
417 422 renderer = self.request.POST.get('renderer') or 'rst'
418 423 if text:
419 424 return h.render(text, renderer=renderer, mentions=True,
420 425 repo_name=self.db_repo_name)
421 426 return ''
422 427
423 428 @LoginRequired()
424 429 @NotAnonymous()
425 430 @HasRepoPermissionAnyDecorator(
426 431 'repository.read', 'repository.write', 'repository.admin')
427 432 @CSRFRequired()
428 433 @view_config(
429 434 route_name='repo_commit_comment_attachment_upload', request_method='POST',
430 435 renderer='json_ext', xhr=True)
431 436 def repo_commit_comment_attachment_upload(self):
432 437 c = self.load_default_context()
433 438 upload_key = 'attachment'
434 439
435 440 file_obj = self.request.POST.get(upload_key)
436 441
437 442 if file_obj is None:
438 443 self.request.response.status = 400
439 444 return {'store_fid': None,
440 445 'access_path': None,
441 446 'error': '{} data field is missing'.format(upload_key)}
442 447
443 448 if not hasattr(file_obj, 'filename'):
444 449 self.request.response.status = 400
445 450 return {'store_fid': None,
446 451 'access_path': None,
447 452 'error': 'filename cannot be read from the data field'}
448 453
449 454 filename = file_obj.filename
450 455 file_display_name = filename
451 456
452 457 metadata = {
453 458 'user_uploaded': {'username': self._rhodecode_user.username,
454 459 'user_id': self._rhodecode_user.user_id,
455 460 'ip': self._rhodecode_user.ip_addr}}
456 461
457 462 # TODO(marcink): allow .ini configuration for allowed_extensions, and file-size
458 463 allowed_extensions = [
459 464 'gif', '.jpeg', '.jpg', '.png', '.docx', '.gz', '.log', '.pdf',
460 465 '.pptx', '.txt', '.xlsx', '.zip']
461 466 max_file_size = 10 * 1024 * 1024 # 10MB, also validated via dropzone.js
462 467
463 468 try:
464 469 storage = store_utils.get_file_storage(self.request.registry.settings)
465 470 store_uid, metadata = storage.save_file(
466 471 file_obj.file, filename, extra_metadata=metadata,
467 472 extensions=allowed_extensions, max_filesize=max_file_size)
468 473 except FileNotAllowedException:
469 474 self.request.response.status = 400
470 475 permitted_extensions = ', '.join(allowed_extensions)
471 476 error_msg = 'File `{}` is not allowed. ' \
472 477 'Only following extensions are permitted: {}'.format(
473 478 filename, permitted_extensions)
474 479 return {'store_fid': None,
475 480 'access_path': None,
476 481 'error': error_msg}
477 482 except FileOverSizeException:
478 483 self.request.response.status = 400
479 484 limit_mb = h.format_byte_size_binary(max_file_size)
480 485 return {'store_fid': None,
481 486 'access_path': None,
482 487 'error': 'File {} is exceeding allowed limit of {}.'.format(
483 488 filename, limit_mb)}
484 489
485 490 try:
486 491 entry = FileStore.create(
487 492 file_uid=store_uid, filename=metadata["filename"],
488 493 file_hash=metadata["sha256"], file_size=metadata["size"],
489 494 file_display_name=file_display_name,
490 495 file_description=u'comment attachment `{}`'.format(safe_unicode(filename)),
491 496 hidden=True, check_acl=True, user_id=self._rhodecode_user.user_id,
492 497 scope_repo_id=self.db_repo.repo_id
493 498 )
494 499 Session().add(entry)
495 500 Session().commit()
496 501 log.debug('Stored upload in DB as %s', entry)
497 502 except Exception:
498 503 log.exception('Failed to store file %s', filename)
499 504 self.request.response.status = 400
500 505 return {'store_fid': None,
501 506 'access_path': None,
502 507 'error': 'File {} failed to store in DB.'.format(filename)}
503 508
504 509 Session().commit()
505 510
506 511 return {
507 512 'store_fid': store_uid,
508 513 'access_path': h.route_path(
509 514 'download_file', fid=store_uid),
510 515 'fqn_access_path': h.route_url(
511 516 'download_file', fid=store_uid),
512 517 'repo_access_path': h.route_path(
513 518 'repo_artifacts_get', repo_name=self.db_repo_name, uid=store_uid),
514 519 'repo_fqn_access_path': h.route_url(
515 520 'repo_artifacts_get', repo_name=self.db_repo_name, uid=store_uid),
516 521 }
517 522
518 523 @LoginRequired()
519 524 @NotAnonymous()
520 525 @HasRepoPermissionAnyDecorator(
521 526 'repository.read', 'repository.write', 'repository.admin')
522 527 @CSRFRequired()
523 528 @view_config(
524 529 route_name='repo_commit_comment_delete', request_method='POST',
525 530 renderer='json_ext')
526 531 def repo_commit_comment_delete(self):
527 532 commit_id = self.request.matchdict['commit_id']
528 533 comment_id = self.request.matchdict['comment_id']
529 534
530 535 comment = ChangesetComment.get_or_404(comment_id)
531 536 if not comment:
532 537 log.debug('Comment with id:%s not found, skipping', comment_id)
533 538 # comment already deleted in another call probably
534 539 return True
535 540
536 541 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name)
537 542 super_admin = h.HasPermissionAny('hg.admin')()
538 543 comment_owner = (comment.author.user_id == self._rhodecode_db_user.user_id)
539 544 is_repo_comment = comment.repo.repo_name == self.db_repo_name
540 545 comment_repo_admin = is_repo_admin and is_repo_comment
541 546
542 547 if super_admin or comment_owner or comment_repo_admin:
543 548 CommentsModel().delete(comment=comment, auth_user=self._rhodecode_user)
544 549 Session().commit()
545 550 return True
546 551 else:
547 552 log.warning('No permissions for user %s to delete comment_id: %s',
548 553 self._rhodecode_db_user, comment_id)
549 554 raise HTTPNotFound()
550 555
551 556 @LoginRequired()
552 557 @HasRepoPermissionAnyDecorator(
553 558 'repository.read', 'repository.write', 'repository.admin')
554 559 @view_config(
555 560 route_name='repo_commit_data', request_method='GET',
556 561 renderer='json_ext', xhr=True)
557 562 def repo_commit_data(self):
558 563 commit_id = self.request.matchdict['commit_id']
559 564 self.load_default_context()
560 565
561 566 try:
562 567 return self.rhodecode_vcs_repo.get_commit(commit_id=commit_id)
563 568 except CommitDoesNotExistError as e:
564 569 return EmptyCommit(message=str(e))
565 570
566 571 @LoginRequired()
567 572 @HasRepoPermissionAnyDecorator(
568 573 'repository.read', 'repository.write', 'repository.admin')
569 574 @view_config(
570 575 route_name='repo_commit_children', request_method='GET',
571 576 renderer='json_ext', xhr=True)
572 577 def repo_commit_children(self):
573 578 commit_id = self.request.matchdict['commit_id']
574 579 self.load_default_context()
575 580
576 581 try:
577 582 commit = self.rhodecode_vcs_repo.get_commit(commit_id=commit_id)
578 583 children = commit.children
579 584 except CommitDoesNotExistError:
580 585 children = []
581 586
582 587 result = {"results": children}
583 588 return result
584 589
585 590 @LoginRequired()
586 591 @HasRepoPermissionAnyDecorator(
587 592 'repository.read', 'repository.write', 'repository.admin')
588 593 @view_config(
589 594 route_name='repo_commit_parents', request_method='GET',
590 595 renderer='json_ext')
591 596 def repo_commit_parents(self):
592 597 commit_id = self.request.matchdict['commit_id']
593 598 self.load_default_context()
594 599
595 600 try:
596 601 commit = self.rhodecode_vcs_repo.get_commit(commit_id=commit_id)
597 602 parents = commit.parents
598 603 except CommitDoesNotExistError:
599 604 parents = []
600 605 result = {"results": parents}
601 606 return result
@@ -1,56 +1,60 b''
1 1 # Copyright (C) 2016-2019 RhodeCode GmbH
2 2 #
3 3 # This program is free software: you can redistribute it and/or modify
4 4 # it under the terms of the GNU Affero General Public License, version 3
5 5 # (only), as published by the Free Software Foundation.
6 6 #
7 7 # This program is distributed in the hope that it will be useful,
8 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 # GNU General Public License for more details.
11 11 #
12 12 # You should have received a copy of the GNU Affero General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 #
15 15 # This program is dual-licensed. If you wish to learn more about the
16 16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18 18
19 19 """
20 20 rcextensions module, please edit `hooks.py` to over write hooks logic
21 21 """
22 22
23 23 from .hooks import (
24 24 _create_repo_hook,
25 25 _create_repo_group_hook,
26 26 _pre_create_user_hook,
27 27 _create_user_hook,
28 _comment_commit_repo_hook,
28 29 _delete_repo_hook,
29 30 _delete_user_hook,
30 31 _pre_push_hook,
31 32 _push_hook,
32 33 _pre_pull_hook,
33 34 _pull_hook,
34 35 _create_pull_request_hook,
35 36 _review_pull_request_hook,
37 _comment_pull_request_hook,
36 38 _update_pull_request_hook,
37 39 _merge_pull_request_hook,
38 40 _close_pull_request_hook,
39 41 )
40 42
41 43 # set as module attributes, we use those to call hooks. *do not change this*
42 44 CREATE_REPO_HOOK = _create_repo_hook
45 COMMENT_COMMIT_REPO_HOOK = _comment_commit_repo_hook
43 46 CREATE_REPO_GROUP_HOOK = _create_repo_group_hook
44 47 PRE_CREATE_USER_HOOK = _pre_create_user_hook
45 48 CREATE_USER_HOOK = _create_user_hook
46 49 DELETE_REPO_HOOK = _delete_repo_hook
47 50 DELETE_USER_HOOK = _delete_user_hook
48 51 PRE_PUSH_HOOK = _pre_push_hook
49 52 PUSH_HOOK = _push_hook
50 53 PRE_PULL_HOOK = _pre_pull_hook
51 54 PULL_HOOK = _pull_hook
52 55 CREATE_PULL_REQUEST = _create_pull_request_hook
53 56 REVIEW_PULL_REQUEST = _review_pull_request_hook
57 COMMENT_PULL_REQUEST = _comment_pull_request_hook
54 58 UPDATE_PULL_REQUEST = _update_pull_request_hook
55 59 MERGE_PULL_REQUEST = _merge_pull_request_hook
56 60 CLOSE_PULL_REQUEST = _close_pull_request_hook
@@ -1,37 +1,36 b''
1 1 # Example to trigger a HTTP call via an HTTP helper via post_push hook
2 2
3 3
4 4 @has_kwargs({
5 5 'server_url': 'url of instance that triggered this hook',
6 6 'config': 'path to .ini config used',
7 7 'scm': 'type of version control "git", "hg", "svn"',
8 8 'username': 'username of actor who triggered this event',
9 9 'ip': 'ip address of actor who triggered this hook',
10 10 'action': '',
11 11 'repository': 'repository name',
12 12 'repo_store_path': 'full path to where repositories are stored',
13 13 'commit_ids': '',
14 14 'hook_type': '',
15 15 'user_agent': '',
16 16 })
17 17 def _push_hook(*args, **kwargs):
18 18 """
19 19 POST PUSH HOOK, this function will be executed after each push it's
20 20 executed after the build-in hook that RhodeCode uses for logging pushes
21 21 """
22 22
23 23 from .helpers import http_call, extra_fields
24 24 # returns list of dicts with key-val fetched from extra fields
25 25 repo_extra_fields = extra_fields.run(**kwargs)
26 26
27 if repo_extra_fields.get('endpoint_url'):
28 field_metadata = repo_extra_fields['endpoint_url']
29 endpoint = field_metadata['field_value']
30 if endpoint:
31 data = {
32 'project': kwargs['repository'],
33 }
34 response = http_call.run(url=endpoint, params=data)
35 return HookResponse(0, 'Called endpoint {}, with response {}\n'.format(endpoint, response))
27 endpoint_url = extra_fields.get_field(repo_extra_fields, key='endpoint_url', default='')
28
29 if endpoint_url:
30 data = {
31 'project': kwargs['repository'],
32 }
33 response = http_call.run(url=endpoint_url, params=data)
34 return HookResponse(0, 'Called endpoint {}, with response {}\n'.format(endpoint_url, response))
36 35
37 36 return HookResponse(0, '')
@@ -1,37 +1,35 b''
1 1 # Example to trigger a CI call via an HTTP helper via post_push hook
2 2
3 3
4 4 @has_kwargs({
5 5 'server_url': 'url of instance that triggered this hook',
6 6 'config': 'path to .ini config used',
7 7 'scm': 'type of version control "git", "hg", "svn"',
8 8 'username': 'username of actor who triggered this event',
9 9 'ip': 'ip address of actor who triggered this hook',
10 10 'action': '',
11 11 'repository': 'repository name',
12 12 'repo_store_path': 'full path to where repositories are stored',
13 13 'commit_ids': '',
14 14 'hook_type': '',
15 15 'user_agent': '',
16 16 })
17 17 def _push_hook(*args, **kwargs):
18 18 """
19 19 POST PUSH HOOK, this function will be executed after each push it's
20 20 executed after the build-in hook that RhodeCode uses for logging pushes
21 21 """
22 22
23 23 from .helpers import http_call, extra_fields
24 24 # returns list of dicts with key-val fetched from extra fields
25 25 repo_extra_fields = extra_fields.run(**kwargs)
26 26
27 if repo_extra_fields.get('endpoint_url'):
28 field_metadata = repo_extra_fields['endpoint_url']
29 endpoint = field_metadata['field_value']
30 if endpoint:
31 data = {
32 'some_key': 'val'
33 }
34 response = http_call.run(url=endpoint, json_data=data)
35 return HookResponse(0, 'Called endpoint {}, with response {}'.format(endpoint, response))
27 endpoint_url = extra_fields.get_field(repo_extra_fields, key='endpoint_url', default='')
28 if endpoint_url:
29 data = {
30 'some_key': 'val'
31 }
32 response = http_call.run(url=endpoint_url, json_data=data)
33 return HookResponse(0, 'Called endpoint {}, with response {}'.format(endpoint_url, response))
36 34
37 35 return HookResponse(0, '')
@@ -1,79 +1,81 b''
1 1 # Example to validate commit message or author using some sort of rules
2 2
3 3
4 4 @has_kwargs({
5 5 'server_url': 'url of instance that triggered this hook',
6 6 'config': 'path to .ini config used',
7 7 'scm': 'type of version control "git", "hg", "svn"',
8 8 'username': 'username of actor who triggered this event',
9 9 'ip': 'ip address of actor who triggered this hook',
10 10 'action': '',
11 11 'repository': 'repository name',
12 12 'repo_store_path': 'full path to where repositories are stored',
13 13 'commit_ids': 'pre transaction metadata for commit ids',
14 14 'hook_type': '',
15 15 'user_agent': 'Client user agent, e.g git or mercurial CLI version',
16 16 })
17 17 def _pre_push_hook(*args, **kwargs):
18 18 """
19 19 Post push hook
20 20 To stop version control from storing the transaction and send a message to user
21 21 use non-zero HookResponse with a message, e.g return HookResponse(1, 'Not allowed')
22 22
23 23 This message will be shown back to client during PUSH operation
24 24
25 25 Commit ids might look like that::
26 26
27 27 [{u'hg_env|git_env': ...,
28 28 u'multiple_heads': [],
29 29 u'name': u'default',
30 30 u'new_rev': u'd0befe0692e722e01d5677f27a104631cf798b69',
31 31 u'old_rev': u'd0befe0692e722e01d5677f27a104631cf798b69',
32 32 u'ref': u'',
33 33 u'total_commits': 2,
34 34 u'type': u'branch'}]
35 35 """
36 36 import re
37 37 from .helpers import extra_fields, extract_pre_commits
38 38 from .utils import str2bool
39 39
40 40 # returns list of dicts with key-val fetched from extra fields
41 41 repo_extra_fields = extra_fields.run(**kwargs)
42 42
43 43 # optionally use 'extra fields' to control the logic per repo
44 validate_author = repo_extra_fields.get('validate_author', {}).get('field_value')
44 validate_author = extra_fields.get_field(
45 repo_extra_fields, key='validate_author', default=False)
45 46 should_validate = str2bool(validate_author)
46 47
47 48 # optionally store validation regex into extra fields
48 validation_regex = repo_extra_fields.get('validation_regex', {}).get('field_value')
49 validation_regex = extra_fields.get_field(
50 repo_extra_fields, key='validation_regex', default='')
49 51
50 52 def validate_commit_message(commit_message, message_regex=None):
51 53 """
52 54 This function validates commit_message against some sort of rules.
53 55 It should return a valid boolean, and a reason for failure
54 56 """
55 57
56 58 if "secret_string" in commit_message:
57 59 msg = "!!Push forbidden: secret string found in commit messages"
58 60 return False, msg
59 61
60 62 if validation_regex:
61 63 regexp = re.compile(validation_regex)
62 64 if not regexp.match(message):
63 65 msg = "!!Push forbidden: commit message does not match regexp"
64 66 return False, msg
65 67
66 68 return True, ''
67 69
68 70 if should_validate:
69 71 # returns list of dicts with key-val fetched from extra fields
70 72 commit_list = extract_pre_commits.run(**kwargs)
71 73
72 74 for commit_data in commit_list:
73 75 message = commit_data['message']
74 76
75 77 message_valid, reason = validate_commit_message(message, validation_regex)
76 78 if not message_valid:
77 79 return HookResponse(1, reason)
78 80
79 81 return HookResponse(0, '')
@@ -1,117 +1,120 b''
1 1 # Example to validate pushed files names and size using some sort of rules
2 2
3 3
4 4
5 5 @has_kwargs({
6 6 'server_url': 'url of instance that triggered this hook',
7 7 'config': 'path to .ini config used',
8 8 'scm': 'type of version control "git", "hg", "svn"',
9 9 'username': 'username of actor who triggered this event',
10 10 'ip': 'ip address of actor who triggered this hook',
11 11 'action': '',
12 12 'repository': 'repository name',
13 13 'repo_store_path': 'full path to where repositories are stored',
14 14 'commit_ids': 'pre transaction metadata for commit ids',
15 15 'hook_type': '',
16 16 'user_agent': 'Client user agent, e.g git or mercurial CLI version',
17 17 })
18 18 def _pre_push_hook(*args, **kwargs):
19 19 """
20 20 Post push hook
21 21 To stop version control from storing the transaction and send a message to user
22 22 use non-zero HookResponse with a message, e.g return HookResponse(1, 'Not allowed')
23 23
24 24 This message will be shown back to client during PUSH operation
25 25
26 26 Commit ids might look like that::
27 27
28 28 [{u'hg_env|git_env': ...,
29 29 u'multiple_heads': [],
30 30 u'name': u'default',
31 31 u'new_rev': u'd0b2ae0692e722e01d5677f27a104631cf798b69',
32 32 u'old_rev': u'd0b1ae0692e722e01d5677f27a104631cf798b69',
33 33 u'ref': u'',
34 34 u'total_commits': 2,
35 35 u'type': u'branch'}]
36 36 """
37 37 import fnmatch
38 38 from .helpers import extra_fields, extract_pre_files
39 39 from .utils import str2bool, aslist
40 40 from rhodecode.lib.helpers import format_byte_size_binary
41 41
42 42 # returns list of dicts with key-val fetched from extra fields
43 43 repo_extra_fields = extra_fields.run(**kwargs)
44 44
45 45 # optionally use 'extra fields' to control the logic per repo
46 46 # e.g store a list of patterns to be forbidden e.g `*.exe, *.dump`
47 forbid_files = repo_extra_fields.get('forbid_files_glob', {}).get('field_value')
47 forbid_files = extra_fields.get_field(repo_extra_fields, key='forbid_files_glob',
48 convert_type=False, default=[])
48 49 forbid_files = aslist(forbid_files)
49 50
50 51 # forbid_files = ['*'] # example pattern
51 52
52 53 # optionally get bytes limit for a single file, e.g 1024 for 1KB
53 forbid_size_over = repo_extra_fields.get('forbid_size_over', {}).get('field_value')
54 forbid_size_over = extra_fields.get_field(repo_extra_fields, key='forbid_size_over',
55 convert_type=False, default=0)
56
54 57 forbid_size_over = int(forbid_size_over or 0)
55 58
56 59 # forbid_size_over = 1024 # example 1024
57 60
58 61 def validate_file_name_and_size(file_data, forbidden_files=None, size_limit=None):
59 62 """
60 63 This function validates comited files against some sort of rules.
61 64 It should return a valid boolean, and a reason for failure
62 65
63 66 file_data =[
64 67 'raw_diff', 'old_revision', 'stats', 'original_filename', 'is_limited_diff',
65 68 'chunks', 'new_revision', 'operation', 'exceeds_limit', 'filename'
66 69 ]
67 70 file_data['ops'] = {
68 71 # is file binary
69 72 'binary': False,
70 73
71 74 # lines
72 75 'added': 32,
73 76 'deleted': 0
74 77
75 78 'ops': {3: 'modified file'},
76 79 'new_mode': '100644',
77 80 'old_mode': None
78 81 }
79 82 """
80 83 file_name = file_data['filename']
81 84 operation = file_data['operation'] # can be A(dded), M(odified), D(eleted)
82 85
83 86 # check files names
84 87 if forbidden_files:
85 88 reason = 'File {} is forbidden to be pushed'.format(file_name)
86 89 for forbidden_pattern in forbid_files:
87 90 # here we can also filter for operation, e.g if check for only ADDED files
88 91 # if operation == 'A':
89 92 if fnmatch.fnmatch(file_name, forbidden_pattern):
90 93 return False, reason
91 94
92 95 # validate A(dded) files and size
93 96 if size_limit and operation == 'A':
94 97 if 'file_size' in file_data:
95 98 size = file_data['file_size']
96 99 else:
97 100 size = len(file_data['raw_diff'])
98 101
99 102 reason = 'File {} size of {} bytes exceeds limit {}'.format(
100 103 file_name, format_byte_size_binary(size),
101 104 format_byte_size_binary(size_limit))
102 105 if size > size_limit:
103 106 return False, reason
104 107
105 108 return True, ''
106 109
107 110 if forbid_files or forbid_size_over:
108 111 # returns list of dicts with key-val fetched from extra fields
109 112 file_list = extract_pre_files.run(**kwargs)
110 113
111 114 for file_data in file_list:
112 115 file_valid, reason = validate_file_name_and_size(
113 116 file_data, forbid_files, forbid_size_over)
114 117 if not file_valid:
115 118 return HookResponse(1, reason)
116 119
117 120 return HookResponse(0, '')
@@ -1,55 +1,88 b''
1 1 # -*- coding: utf-8 -*-
2 2 # Copyright (C) 2016-2019 RhodeCode GmbH
3 3 #
4 4 # This program is free software: you can redistribute it and/or modify
5 5 # it under the terms of the GNU Affero General Public License, version 3
6 6 # (only), as published by the Free Software Foundation.
7 7 #
8 8 # This program is distributed in the hope that it will be useful,
9 9 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 11 # GNU General Public License for more details.
12 12 #
13 13 # You should have received a copy of the GNU Affero General Public License
14 14 # along with this program. If not, see <http://www.gnu.org/licenses/>.
15 15 #
16 16 # This program is dual-licensed. If you wish to learn more about the
17 17 # RhodeCode Enterprise Edition, including its added features, Support services,
18 18 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 19
20 20 """
21 21 example usage in hooks::
22 22
23 23 from .helpers import extra_fields
24 24 # returns list of dicts with key-val fetched from extra fields
25 25 repo_extra_fields = extra_fields.run(**kwargs)
26 26 repo_extra_fields.get('endpoint_url')
27 27
28 28 # the field stored the following example values
29 29 {u'created_on': datetime.datetime(),
30 30 u'field_key': u'endpoint_url',
31 31 u'field_label': u'Endpoint URL',
32 32 u'field_desc': u'Full HTTP endpoint to call if given',
33 33 u'field_type': u'str',
34 34 u'field_value': u'http://server.com/post',
35 35 u'repo_field_id': 1,
36 36 u'repository_id': 1}
37 37 # for example to obtain the value:
38 38 endpoint_field = repo_extra_fields.get('endpoint_url')
39 39 if endpoint_field:
40 40 url = endpoint_field['field_value']
41 41
42 42 """
43 43
44 44
45 45 def run(*args, **kwargs):
46 46 from rhodecode.model.db import Repository
47 47 # use temp name then the main one propagated
48 48 repo_name = kwargs.pop('REPOSITORY', None) or kwargs['repository']
49 49 repo = Repository.get_by_repo_name(repo_name)
50 50
51 51 fields = {}
52 52 for field in repo.extra_fields:
53 53 fields[field.field_key] = field.get_dict()
54 54
55 55 return fields
56
57
58 class _Undefined(object):
59 pass
60
61
62 def get_field(extra_fields_data, key, default=_Undefined(), convert_type=True):
63 """
64 field_value = get_field(extra_fields, key='ci_endpoint_url', default='')
65 """
66 from ..utils import str2bool, aslist
67
68 if key not in extra_fields_data:
69 if isinstance(default, _Undefined):
70 raise ValueError('key {} not present in extra_fields'.format(key))
71 return default
72
73 # NOTE(dan): from metadata we get field_label, field_value, field_desc, field_type
74 field_metadata = extra_fields_data[key]
75
76 field_value = field_metadata['field_value']
77
78 # NOTE(dan): empty value, use default
79 if not field_value and not isinstance(default, _Undefined):
80 return default
81
82 if convert_type:
83 # 'str', 'unicode', 'list', 'tuple'
84 _type = field_metadata['field_type']
85 if _type in ['list', 'tuple']:
86 field_value = aslist(field_value)
87
88 return field_value
@@ -1,432 +1,492 b''
1 1 # Copyright (C) 2016-2019 RhodeCode GmbH
2 2 #
3 3 # This program is free software: you can redistribute it and/or modify
4 4 # it under the terms of the GNU Affero General Public License, version 3
5 5 # (only), as published by the Free Software Foundation.
6 6 #
7 7 # This program is distributed in the hope that it will be useful,
8 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 # GNU General Public License for more details.
11 11 #
12 12 # You should have received a copy of the GNU Affero General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 #
15 15 # This program is dual-licensed. If you wish to learn more about the
16 16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18
18 19 import logging
19 20 from .utils import DotDict, HookResponse, has_kwargs
21
20 22 log = logging.getLogger('rhodecode.' + __name__)
21 23
22
23 24 # Config shortcut to keep, all configuration in one place
24 25 # Example: api_key = CONFIG.my_config.api_key
25 26 CONFIG = DotDict(
26 27 my_config=DotDict(
27 28 api_key='<secret>',
28 29 ),
29 30
30 31 )
31 32
32 33
33 34 @has_kwargs({
34 35 'repo_name': '',
35 36 'repo_type': '',
36 37 'description': '',
37 38 'private': '',
38 39 'created_on': '',
39 40 'enable_downloads': '',
40 41 'repo_id': '',
41 42 'user_id': '',
42 43 'enable_statistics': '',
43 44 'clone_uri': '',
44 45 'fork_id': '',
45 46 'group_id': '',
46 47 'created_by': ''
47 48 })
48 49 def _create_repo_hook(*args, **kwargs):
49 50 """
50 51 POST CREATE REPOSITORY HOOK. This function will be executed after
51 52 each repository is created. kwargs available:
52 53
53 54 """
54 55 return HookResponse(0, '')
55 56
56 57
57 58 @has_kwargs({
58 'group_name': '',
59 'group_parent_id': '',
59 'repo_name': '',
60 'repo_type': '',
61 'description': '',
62 'private': '',
63 'created_on': '',
64 'enable_downloads': '',
65 'repo_id': '',
66 'user_id': '',
67 'enable_statistics': '',
68 'clone_uri': '',
69 'fork_id': '',
70 'group_id': '',
71 'created_by': '',
72 'repository': '',
73 'comment': '',
74 'commit': ''
75 })
76 def _comment_commit_repo_hook(*args, **kwargs):
77 """
78 POST CREATE REPOSITORY COMMENT ON COMMIT HOOK. This function will be executed after
79 a comment is made on this repository commit.
80
81 """
82 return HookResponse(0, '')
83
84
85 @has_kwargs({
86 'group_name': '',
87 'group_parent_id': '',
60 88 'group_description': '',
61 'group_id': '',
62 'user_id': '',
63 'created_by': '',
64 'created_on': '',
89 'group_id': '',
90 'user_id': '',
91 'created_by': '',
92 'created_on': '',
65 93 'enable_locking': ''
66 94 })
67 95 def _create_repo_group_hook(*args, **kwargs):
68 96 """
69 97 POST CREATE REPOSITORY GROUP HOOK, this function will be
70 98 executed after each repository group is created. kwargs available:
71 99 """
72 100 return HookResponse(0, '')
73 101
74 102
75 103 @has_kwargs({
76 'username': '',
77 'password': '',
78 'email': '',
104 'username': '',
105 'password': '',
106 'email': '',
79 107 'firstname': '',
80 'lastname': '',
81 'active': '',
82 'admin': '',
108 'lastname': '',
109 'active': '',
110 'admin': '',
83 111 'created_by': '',
84 112 })
85 113 def _pre_create_user_hook(*args, **kwargs):
86 114 """
87 115 PRE CREATE USER HOOK, this function will be executed before each
88 116 user is created, it returns a tuple of bool, reason.
89 117 If bool is False the user creation will be stopped and reason
90 118 will be displayed to the user.
91 119
92 120 Return HookResponse(1, reason) to block user creation
93 121
94 122 """
95 123
96 124 reason = 'allowed'
97 125 return HookResponse(0, reason)
98 126
99 127
100 128 @has_kwargs({
101 129 'username': '',
102 130 'full_name_or_username': '',
103 131 'full_contact': '',
104 132 'user_id': '',
105 133 'name': '',
106 134 'firstname': '',
107 135 'short_contact': '',
108 136 'admin': '',
109 137 'lastname': '',
110 138 'ip_addresses': '',
111 139 'extern_type': '',
112 140 'extern_name': '',
113 141 'email': '',
114 142 'api_key': '',
115 143 'api_keys': '',
116 144 'last_login': '',
117 145 'full_name': '',
118 146 'active': '',
119 147 'password': '',
120 148 'emails': '',
121 149 'inherit_default_permissions': '',
122 150 'created_by': '',
123 151 'created_on': '',
124 152 })
125 153 def _create_user_hook(*args, **kwargs):
126 154 """
127 155 POST CREATE USER HOOK, this function will be executed after each user is created
128 156 """
129 157 return HookResponse(0, '')
130 158
131 159
132 160 @has_kwargs({
133 161 'repo_name': '',
134 162 'repo_type': '',
135 163 'description': '',
136 164 'private': '',
137 165 'created_on': '',
138 166 'enable_downloads': '',
139 167 'repo_id': '',
140 168 'user_id': '',
141 169 'enable_statistics': '',
142 170 'clone_uri': '',
143 171 'fork_id': '',
144 172 'group_id': '',
145 173 'deleted_by': '',
146 174 'deleted_on': '',
147 175 })
148 176 def _delete_repo_hook(*args, **kwargs):
149 177 """
150 178 POST DELETE REPOSITORY HOOK, this function will be executed after
151 179 each repository deletion
152 180 """
153 181 return HookResponse(0, '')
154 182
155 183
156 184 @has_kwargs({
157 185 'username': '',
158 186 'full_name_or_username': '',
159 187 'full_contact': '',
160 188 'user_id': '',
161 189 'name': '',
162 190 'short_contact': '',
163 191 'admin': '',
164 192 'firstname': '',
165 193 'lastname': '',
166 194 'ip_addresses': '',
167 195 'email': '',
168 196 'api_key': '',
169 197 'last_login': '',
170 198 'full_name': '',
171 199 'active': '',
172 200 'password': '',
173 201 'emails': '',
174 202 'inherit_default_permissions': '',
175 203 'deleted_by': '',
176 })
204 })
177 205 def _delete_user_hook(*args, **kwargs):
178 206 """
179 207 POST DELETE USER HOOK, this function will be executed after each
180 208 user is deleted kwargs available:
181 209 """
182 210 return HookResponse(0, '')
183 211
184 212
185 213 # =============================================================================
186 214 # PUSH/PULL RELATED HOOKS
187 215 # =============================================================================
188 216 @has_kwargs({
189 217 'server_url': 'url of instance that triggered this hook',
190 218 'config': 'path to .ini config used',
191 219 'scm': 'type of version control "git", "hg", "svn"',
192 220 'username': 'username of actor who triggered this event',
193 221 'ip': 'ip address of actor who triggered this hook',
194 222 'action': '',
195 223 'repository': 'repository name',
196 224 'repo_store_path': 'full path to where repositories are stored',
197 225 'commit_ids': 'pre transaction metadata for commit ids',
198 226 'hook_type': '',
199 227 'user_agent': 'Client user agent, e.g git or mercurial CLI version',
200 228 })
201 229 def _pre_push_hook(*args, **kwargs):
202 230 """
203 231 Post push hook
204 232 To stop version control from storing the transaction and send a message to user
205 233 use non-zero HookResponse with a message, e.g return HookResponse(1, 'Not allowed')
206 234
207 235 This message will be shown back to client during PUSH operation
208 236
209 237 Commit ids might look like that::
210 238
211 239 [{u'hg_env|git_env': ...,
212 240 u'multiple_heads': [],
213 241 u'name': u'default',
214 242 u'new_rev': u'd0befe0692e722e01d5677f27a104631cf798b69',
215 243 u'old_rev': u'd0befe0692e722e01d5677f27a104631cf798b69',
216 244 u'ref': u'',
217 245 u'total_commits': 2,
218 246 u'type': u'branch'}]
219 247 """
220 248 return HookResponse(0, '')
221 249
222 250
223 251 @has_kwargs({
224 252 'server_url': 'url of instance that triggered this hook',
225 253 'config': 'path to .ini config used',
226 254 'scm': 'type of version control "git", "hg", "svn"',
227 255 'username': 'username of actor who triggered this event',
228 256 'ip': 'ip address of actor who triggered this hook',
229 257 'action': '',
230 258 'repository': 'repository name',
231 259 'repo_store_path': 'full path to where repositories are stored',
232 260 'commit_ids': 'list of pushed commit_ids (sha1)',
233 261 'hook_type': '',
234 262 'user_agent': 'Client user agent, e.g git or mercurial CLI version',
235 263 })
236 264 def _push_hook(*args, **kwargs):
237 265 """
238 266 POST PUSH HOOK, this function will be executed after each push it's
239 267 executed after the build-in hook that RhodeCode uses for logging pushes
240 268 """
241 269 return HookResponse(0, '')
242 270
243 271
244 272 @has_kwargs({
245 273 'server_url': 'url of instance that triggered this hook',
246 274 'repo_store_path': 'full path to where repositories are stored',
247 275 'config': 'path to .ini config used',
248 276 'scm': 'type of version control "git", "hg", "svn"',
249 277 'username': 'username of actor who triggered this event',
250 278 'ip': 'ip address of actor who triggered this hook',
251 279 'action': '',
252 280 'repository': 'repository name',
253 281 'hook_type': '',
254 282 'user_agent': 'Client user agent, e.g git or mercurial CLI version',
255 283 })
256 284 def _pre_pull_hook(*args, **kwargs):
257 285 """
258 286 Post pull hook
259 287 """
260 288 return HookResponse(0, '')
261 289
262 290
263 291 @has_kwargs({
264 292 'server_url': 'url of instance that triggered this hook',
265 293 'repo_store_path': 'full path to where repositories are stored',
266 294 'config': 'path to .ini config used',
267 295 'scm': 'type of version control "git", "hg", "svn"',
268 296 'username': 'username of actor who triggered this event',
269 297 'ip': 'ip address of actor who triggered this hook',
270 298 'action': '',
271 299 'repository': 'repository name',
272 300 'hook_type': '',
273 301 'user_agent': 'Client user agent, e.g git or mercurial CLI version',
274 302 })
275 303 def _pull_hook(*args, **kwargs):
276 304 """
277 305 This hook will be executed after each code pull.
278 306 """
279 307 return HookResponse(0, '')
280 308
281 309
282 310 # =============================================================================
283 311 # PULL REQUEST RELATED HOOKS
284 312 # =============================================================================
285 313 @has_kwargs({
286 314 'server_url': 'url of instance that triggered this hook',
287 315 'config': 'path to .ini config used',
288 316 'scm': 'type of version control "git", "hg", "svn"',
289 317 'username': 'username of actor who triggered this event',
290 318 'ip': 'ip address of actor who triggered this hook',
291 319 'action': '',
292 320 'repository': 'repository name',
293 321 'pull_request_id': '',
294 322 'url': '',
295 323 'title': '',
296 324 'description': '',
297 325 'status': '',
298 326 'created_on': '',
299 327 'updated_on': '',
300 328 'commit_ids': '',
301 329 'review_status': '',
302 330 'mergeable': '',
303 331 'source': '',
304 332 'target': '',
305 333 'author': '',
306 334 'reviewers': '',
307 335 })
308 336 def _create_pull_request_hook(*args, **kwargs):
309 337 """
310 338 This hook will be executed after creation of a pull request.
311 339 """
312 340 return HookResponse(0, '')
313 341
314 342
315 343 @has_kwargs({
316 344 'server_url': 'url of instance that triggered this hook',
317 345 'config': 'path to .ini config used',
318 346 'scm': 'type of version control "git", "hg", "svn"',
319 347 'username': 'username of actor who triggered this event',
320 348 'ip': 'ip address of actor who triggered this hook',
321 349 'action': '',
322 350 'repository': 'repository name',
323 351 'pull_request_id': '',
324 352 'url': '',
325 353 'title': '',
326 354 'description': '',
327 355 'status': '',
328 356 'created_on': '',
329 357 'updated_on': '',
330 358 'commit_ids': '',
331 359 'review_status': '',
332 360 'mergeable': '',
333 361 'source': '',
334 362 'target': '',
335 363 'author': '',
336 364 'reviewers': '',
337 365 })
338 366 def _review_pull_request_hook(*args, **kwargs):
339 367 """
340 368 This hook will be executed after review action was made on a pull request.
341 369 """
342 370 return HookResponse(0, '')
343 371
344 372
345 373 @has_kwargs({
346 374 'server_url': 'url of instance that triggered this hook',
347 375 'config': 'path to .ini config used',
348 376 'scm': 'type of version control "git", "hg", "svn"',
349 377 'username': 'username of actor who triggered this event',
350 378 'ip': 'ip address of actor who triggered this hook',
379
380 'action': '',
381 'repository': 'repository name',
382 'pull_request_id': '',
383 'url': '',
384 'title': '',
385 'description': '',
386 'status': '',
387 'comment': '',
388 'created_on': '',
389 'updated_on': '',
390 'commit_ids': '',
391 'review_status': '',
392 'mergeable': '',
393 'source': '',
394 'target': '',
395 'author': '',
396 'reviewers': '',
397 })
398 def _comment_pull_request_hook(*args, **kwargs):
399 """
400 This hook will be executed after comment is made on a pull request
401 """
402 return HookResponse(0, '')
403
404
405 @has_kwargs({
406 'server_url': 'url of instance that triggered this hook',
407 'config': 'path to .ini config used',
408 'scm': 'type of version control "git", "hg", "svn"',
409 'username': 'username of actor who triggered this event',
410 'ip': 'ip address of actor who triggered this hook',
351 411 'action': '',
352 412 'repository': 'repository name',
353 413 'pull_request_id': '',
354 414 'url': '',
355 415 'title': '',
356 416 'description': '',
357 417 'status': '',
358 418 'created_on': '',
359 419 'updated_on': '',
360 420 'commit_ids': '',
361 421 'review_status': '',
362 422 'mergeable': '',
363 423 'source': '',
364 424 'target': '',
365 425 'author': '',
366 426 'reviewers': '',
367 427 })
368 428 def _update_pull_request_hook(*args, **kwargs):
369 429 """
370 430 This hook will be executed after pull requests has been updated with new commits.
371 431 """
372 432 return HookResponse(0, '')
373 433
374 434
375 435 @has_kwargs({
376 436 'server_url': 'url of instance that triggered this hook',
377 437 'config': 'path to .ini config used',
378 438 'scm': 'type of version control "git", "hg", "svn"',
379 439 'username': 'username of actor who triggered this event',
380 440 'ip': 'ip address of actor who triggered this hook',
381 441 'action': '',
382 442 'repository': 'repository name',
383 443 'pull_request_id': '',
384 444 'url': '',
385 445 'title': '',
386 446 'description': '',
387 447 'status': '',
388 448 'created_on': '',
389 449 'updated_on': '',
390 450 'commit_ids': '',
391 451 'review_status': '',
392 452 'mergeable': '',
393 453 'source': '',
394 454 'target': '',
395 455 'author': '',
396 456 'reviewers': '',
397 457 })
398 458 def _merge_pull_request_hook(*args, **kwargs):
399 459 """
400 460 This hook will be executed after merge of a pull request.
401 461 """
402 462 return HookResponse(0, '')
403 463
404 464
405 465 @has_kwargs({
406 466 'server_url': 'url of instance that triggered this hook',
407 467 'config': 'path to .ini config used',
408 468 'scm': 'type of version control "git", "hg", "svn"',
409 469 'username': 'username of actor who triggered this event',
410 470 'ip': 'ip address of actor who triggered this hook',
411 471 'action': '',
412 472 'repository': 'repository name',
413 473 'pull_request_id': '',
414 474 'url': '',
415 475 'title': '',
416 476 'description': '',
417 477 'status': '',
418 478 'created_on': '',
419 479 'updated_on': '',
420 480 'commit_ids': '',
421 481 'review_status': '',
422 482 'mergeable': '',
423 483 'source': '',
424 484 'target': '',
425 485 'author': '',
426 486 'reviewers': '',
427 487 })
428 488 def _close_pull_request_hook(*args, **kwargs):
429 489 """
430 490 This hook will be executed after close of a pull request.
431 491 """
432 492 return HookResponse(0, '')
@@ -1,189 +1,199 b''
1 1 # Copyright (C) 2016-2019 RhodeCode GmbH
2 2 #
3 3 # This program is free software: you can redistribute it and/or modify
4 4 # it under the terms of the GNU Affero General Public License, version 3
5 5 # (only), as published by the Free Software Foundation.
6 6 #
7 7 # This program is distributed in the hope that it will be useful,
8 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 # GNU General Public License for more details.
11 11 #
12 12 # You should have received a copy of the GNU Affero General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 #
15 15 # This program is dual-licensed. If you wish to learn more about the
16 16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18 18
19 19 import logging
20 20 import os
21 import string
21 22 import functools
22 23 import collections
24 import urllib
23 25
24 26 log = logging.getLogger('rhodecode.' + __name__)
25 27
26 28
27 29 class HookResponse(object):
28 30 def __init__(self, status, output):
29 31 self.status = status
30 32 self.output = output
31 33
32 34 def __add__(self, other):
33 35 other_status = getattr(other, 'status', 0)
34 36 new_status = max(self.status, other_status)
35 37 other_output = getattr(other, 'output', '')
36 38 new_output = self.output + other_output
37 39
38 40 return HookResponse(new_status, new_output)
39 41
40 42 def __bool__(self):
41 43 return self.status == 0
42 44
43 45
44 46 class DotDict(dict):
45 47
46 48 def __contains__(self, k):
47 49 try:
48 50 return dict.__contains__(self, k) or hasattr(self, k)
49 51 except:
50 52 return False
51 53
52 54 # only called if k not found in normal places
53 55 def __getattr__(self, k):
54 56 try:
55 57 return object.__getattribute__(self, k)
56 58 except AttributeError:
57 59 try:
58 60 return self[k]
59 61 except KeyError:
60 62 raise AttributeError(k)
61 63
62 64 def __setattr__(self, k, v):
63 65 try:
64 66 object.__getattribute__(self, k)
65 67 except AttributeError:
66 68 try:
67 69 self[k] = v
68 70 except:
69 71 raise AttributeError(k)
70 72 else:
71 73 object.__setattr__(self, k, v)
72 74
73 75 def __delattr__(self, k):
74 76 try:
75 77 object.__getattribute__(self, k)
76 78 except AttributeError:
77 79 try:
78 80 del self[k]
79 81 except KeyError:
80 82 raise AttributeError(k)
81 83 else:
82 84 object.__delattr__(self, k)
83 85
84 86 def toDict(self):
85 87 return unserialize(self)
86 88
87 89 def __repr__(self):
88 90 keys = list(self.keys())
89 91 keys.sort()
90 92 args = ', '.join(['%s=%r' % (key, self[key]) for key in keys])
91 93 return '%s(%s)' % (self.__class__.__name__, args)
92 94
93 95 @staticmethod
94 96 def fromDict(d):
95 97 return serialize(d)
96 98
97 99
98 100 def serialize(x):
99 101 if isinstance(x, dict):
100 102 return DotDict((k, serialize(v)) for k, v in x.items())
101 103 elif isinstance(x, (list, tuple)):
102 104 return type(x)(serialize(v) for v in x)
103 105 else:
104 106 return x
105 107
106 108
107 109 def unserialize(x):
108 110 if isinstance(x, dict):
109 111 return dict((k, unserialize(v)) for k, v in x.items())
110 112 elif isinstance(x, (list, tuple)):
111 113 return type(x)(unserialize(v) for v in x)
112 114 else:
113 115 return x
114 116
115 117
116 118 def _verify_kwargs(func_name, expected_parameters, kwargs):
117 119 """
118 120 Verify that exactly `expected_parameters` are passed in as `kwargs`.
119 121 """
120 122 expected_parameters = set(expected_parameters)
121 123 kwargs_keys = set(kwargs.keys())
122 124 if kwargs_keys != expected_parameters:
123 125 missing_kwargs = expected_parameters - kwargs_keys
124 126 unexpected_kwargs = kwargs_keys - expected_parameters
125 127 raise AssertionError(
126 128 "func:%s: missing parameters: %r, unexpected parameters: %s" %
127 129 (func_name, missing_kwargs, unexpected_kwargs))
128 130
129 131
130 132 def has_kwargs(required_args):
131 133 """
132 134 decorator to verify extension calls arguments.
133 135
134 136 :param required_args:
135 137 """
136 138 def wrap(func):
137 139 def wrapper(*args, **kwargs):
138 140 _verify_kwargs(func.func_name, required_args.keys(), kwargs)
139 141 # in case there's `calls` defined on module we store the data
140 142 maybe_log_call(func.func_name, args, kwargs)
141 143 log.debug('Calling rcextensions function %s', func.func_name)
142 144 return func(*args, **kwargs)
143 145 return wrapper
144 146 return wrap
145 147
146 148
147 149 def maybe_log_call(name, args, kwargs):
148 150 from rhodecode.config import rcextensions
149 151 if hasattr(rcextensions, 'calls'):
150 152 calls = rcextensions.calls
151 153 calls[name].append((args, kwargs))
152 154
153 155
154 156 def str2bool(_str):
155 157 """
156 158 returns True/False value from given string, it tries to translate the
157 159 string into boolean
158 160
159 161 :param _str: string value to translate into boolean
160 162 :rtype: boolean
161 163 :returns: boolean from given string
162 164 """
163 165 if _str is None:
164 166 return False
165 167 if _str in (True, False):
166 168 return _str
167 169 _str = str(_str).strip().lower()
168 170 return _str in ('t', 'true', 'y', 'yes', 'on', '1')
169 171
170 172
171 173 def aslist(obj, sep=None, strip=True):
172 174 """
173 175 Returns given string separated by sep as list
174 176
175 177 :param obj:
176 178 :param sep:
177 179 :param strip:
178 180 """
179 181 if isinstance(obj, (basestring,)):
180 182 lst = obj.split(sep)
181 183 if strip:
182 184 lst = [v.strip() for v in lst]
183 185 return lst
184 186 elif isinstance(obj, (list, tuple)):
185 187 return obj
186 188 elif obj is None:
187 189 return []
188 190 else:
189 return [obj] No newline at end of file
191 return [obj]
192
193
194 class UrlTemplate(string.Template):
195
196 def safe_substitute(self, **kws):
197 # url encode the kw for usage in url
198 kws = {k: urllib.quote(str(v)) for k, v in kws.items()}
199 return super(UrlTemplate, self).safe_substitute(**kws)
@@ -1,78 +1,78 b''
1 1 # Copyright (C) 2016-2019 RhodeCode GmbH
2 2 #
3 3 # This program is free software: you can redistribute it and/or modify
4 4 # it under the terms of the GNU Affero General Public License, version 3
5 5 # (only), as published by the Free Software Foundation.
6 6 #
7 7 # This program is distributed in the hope that it will be useful,
8 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 # GNU General Public License for more details.
11 11 #
12 12 # You should have received a copy of the GNU Affero General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 #
15 15 # This program is dual-licensed. If you wish to learn more about the
16 16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18 18
19 19 import logging
20 20 from pyramid.threadlocal import get_current_registry
21 21 from rhodecode.events.base import RhodeCodeIntegrationEvent
22 22
23 23
24 24 log = logging.getLogger(__name__)
25 25
26 26
27 27 def trigger(event, registry=None):
28 28 """
29 29 Helper method to send an event. This wraps the pyramid logic to send an
30 30 event.
31 31 """
32 32 # For the first step we are using pyramids thread locals here. If the
33 33 # event mechanism works out as a good solution we should think about
34 34 # passing the registry as an argument to get rid of it.
35 35 event_name = event.__class__
36 36 log.debug('event %s sent for execution', event_name)
37 37 registry = registry or get_current_registry()
38 38 registry.notify(event)
39 39 log.debug('event %s triggered using registry %s', event_name, registry)
40 40
41 41 # Send the events to integrations directly
42 42 from rhodecode.integrations import integrations_event_handler
43 43 if isinstance(event, RhodeCodeIntegrationEvent):
44 44 integrations_event_handler(event)
45 45
46 46
47 47 from rhodecode.events.user import ( # pragma: no cover
48 48 UserPreCreate,
49 49 UserPostCreate,
50 50 UserPreUpdate,
51 51 UserRegistered,
52 52 UserPermissionsChange,
53 53 )
54 54
55 55 from rhodecode.events.repo import ( # pragma: no cover
56 RepoEvent,
56 RepoEvent, RepoCommitCommentEvent,
57 57 RepoPreCreateEvent, RepoCreateEvent,
58 58 RepoPreDeleteEvent, RepoDeleteEvent,
59 59 RepoPrePushEvent, RepoPushEvent,
60 60 RepoPrePullEvent, RepoPullEvent,
61 61 )
62 62
63 63 from rhodecode.events.repo_group import ( # pragma: no cover
64 64 RepoGroupEvent,
65 65 RepoGroupCreateEvent,
66 66 RepoGroupUpdateEvent,
67 67 RepoGroupDeleteEvent,
68 68 )
69 69
70 70 from rhodecode.events.pullrequest import ( # pragma: no cover
71 71 PullRequestEvent,
72 72 PullRequestCreateEvent,
73 73 PullRequestUpdateEvent,
74 74 PullRequestCommentEvent,
75 75 PullRequestReviewEvent,
76 76 PullRequestMergeEvent,
77 77 PullRequestCloseEvent,
78 78 )
@@ -1,356 +1,370 b''
1 1 # Copyright (C) 2016-2019 RhodeCode GmbH
2 2 #
3 3 # This program is free software: you can redistribute it and/or modify
4 4 # it under the terms of the GNU Affero General Public License, version 3
5 5 # (only), as published by the Free Software Foundation.
6 6 #
7 7 # This program is distributed in the hope that it will be useful,
8 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 # GNU General Public License for more details.
11 11 #
12 12 # You should have received a copy of the GNU Affero General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 #
15 15 # This program is dual-licensed. If you wish to learn more about the
16 16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18 18
19 19 import collections
20 20 import logging
21 21 import datetime
22 22
23 23 from rhodecode.translation import lazy_ugettext
24 24 from rhodecode.model.db import User, Repository, Session
25 25 from rhodecode.events.base import RhodeCodeIntegrationEvent
26 26 from rhodecode.lib.vcs.exceptions import CommitDoesNotExistError
27 27
28 28 log = logging.getLogger(__name__)
29 29
30 30
31 31 def _commits_as_dict(event, commit_ids, repos):
32 32 """
33 33 Helper function to serialize commit_ids
34 34
35 35 :param event: class calling this method
36 36 :param commit_ids: commits to get
37 37 :param repos: list of repos to check
38 38 """
39 39 from rhodecode.lib.utils2 import extract_mentioned_users
40 40 from rhodecode.lib.helpers import (
41 41 urlify_commit_message, process_patterns, chop_at_smart)
42 42 from rhodecode.model.repo import RepoModel
43 43
44 44 if not repos:
45 45 raise Exception('no repo defined')
46 46
47 47 if not isinstance(repos, (tuple, list)):
48 48 repos = [repos]
49 49
50 50 if not commit_ids:
51 51 return []
52 52
53 53 needed_commits = list(commit_ids)
54 54
55 55 commits = []
56 56 reviewers = []
57 57 for repo in repos:
58 58 if not needed_commits:
59 59 return commits # return early if we have the commits we need
60 60
61 61 vcs_repo = repo.scm_instance(cache=False)
62 62
63 63 try:
64 64 # use copy of needed_commits since we modify it while iterating
65 65 for commit_id in list(needed_commits):
66 66 if commit_id.startswith('tag=>'):
67 67 raw_id = commit_id[5:]
68 68 cs_data = {
69 69 'raw_id': commit_id, 'short_id': commit_id,
70 70 'branch': None,
71 71 'git_ref_change': 'tag_add',
72 72 'message': 'Added new tag {}'.format(raw_id),
73 73 'author': event.actor.full_contact,
74 74 'date': datetime.datetime.now(),
75 75 'refs': {
76 76 'branches': [],
77 77 'bookmarks': [],
78 78 'tags': []
79 79 }
80 80 }
81 81 commits.append(cs_data)
82 82
83 83 elif commit_id.startswith('delete_branch=>'):
84 84 raw_id = commit_id[15:]
85 85 cs_data = {
86 86 'raw_id': commit_id, 'short_id': commit_id,
87 87 'branch': None,
88 88 'git_ref_change': 'branch_delete',
89 89 'message': 'Deleted branch {}'.format(raw_id),
90 90 'author': event.actor.full_contact,
91 91 'date': datetime.datetime.now(),
92 92 'refs': {
93 93 'branches': [],
94 94 'bookmarks': [],
95 95 'tags': []
96 96 }
97 97 }
98 98 commits.append(cs_data)
99 99
100 100 else:
101 101 try:
102 102 cs = vcs_repo.get_commit(commit_id)
103 103 except CommitDoesNotExistError:
104 104 continue # maybe its in next repo
105 105
106 106 cs_data = cs.__json__()
107 107 cs_data['refs'] = cs._get_refs()
108 108
109 109 cs_data['mentions'] = extract_mentioned_users(cs_data['message'])
110 110 cs_data['reviewers'] = reviewers
111 111 cs_data['url'] = RepoModel().get_commit_url(
112 112 repo, cs_data['raw_id'], request=event.request)
113 113 cs_data['permalink_url'] = RepoModel().get_commit_url(
114 114 repo, cs_data['raw_id'], request=event.request,
115 115 permalink=True)
116 116 urlified_message, issues_data = process_patterns(
117 117 cs_data['message'], repo.repo_name)
118 118 cs_data['issues'] = issues_data
119 119 cs_data['message_html'] = urlify_commit_message(
120 120 cs_data['message'], repo.repo_name)
121 121 cs_data['message_html_title'] = chop_at_smart(
122 122 cs_data['message'], '\n', suffix_if_chopped='...')
123 123 commits.append(cs_data)
124 124
125 125 needed_commits.remove(commit_id)
126 126
127 127 except Exception:
128 128 log.exception('Failed to extract commits data')
129 129 # we don't send any commits when crash happens, only full list
130 130 # matters we short circuit then.
131 131 return []
132 132
133 133 missing_commits = set(commit_ids) - set(c['raw_id'] for c in commits)
134 134 if missing_commits:
135 135 log.error('Inconsistent repository state. '
136 136 'Missing commits: %s', ', '.join(missing_commits))
137 137
138 138 return commits
139 139
140 140
141 141 def _issues_as_dict(commits):
142 142 """ Helper function to serialize issues from commits """
143 143 issues = {}
144 144 for commit in commits:
145 145 for issue in commit['issues']:
146 146 issues[issue['id']] = issue
147 147 return issues
148 148
149 149
150 150 class RepoEvent(RhodeCodeIntegrationEvent):
151 151 """
152 152 Base class for events acting on a repository.
153 153
154 154 :param repo: a :class:`Repository` instance
155 155 """
156 156
157 157 def __init__(self, repo):
158 158 super(RepoEvent, self).__init__()
159 159 self.repo = repo
160 160
161 161 def as_dict(self):
162 162 from rhodecode.model.repo import RepoModel
163 163 data = super(RepoEvent, self).as_dict()
164 164
165 165 extra_fields = collections.OrderedDict()
166 166 for field in self.repo.extra_fields:
167 167 extra_fields[field.field_key] = field.field_value
168 168
169 169 data.update({
170 170 'repo': {
171 171 'repo_id': self.repo.repo_id,
172 172 'repo_name': self.repo.repo_name,
173 173 'repo_type': self.repo.repo_type,
174 174 'url': RepoModel().get_url(
175 175 self.repo, request=self.request),
176 176 'permalink_url': RepoModel().get_url(
177 177 self.repo, request=self.request, permalink=True),
178 178 'extra_fields': extra_fields
179 179 }
180 180 })
181 181 return data
182 182
183 183
184 class RepoCommitCommentEvent(RepoEvent):
185 """
186 An instance of this class is emitted as an :term:`event` after a comment is made
187 on repository commit.
188 """
189 def __init__(self, repo, commit, comment):
190 super(RepoCommitCommentEvent, self).__init__(repo)
191 self.commit = commit
192 self.comment = comment
193
194 name = 'repo-commit-comment'
195 display_name = lazy_ugettext('repository commit comment')
196
197
184 198 class RepoPreCreateEvent(RepoEvent):
185 199 """
186 200 An instance of this class is emitted as an :term:`event` before a repo is
187 201 created.
188 202 """
189 203 name = 'repo-pre-create'
190 204 display_name = lazy_ugettext('repository pre create')
191 205
192 206
193 207 class RepoCreateEvent(RepoEvent):
194 208 """
195 209 An instance of this class is emitted as an :term:`event` whenever a repo is
196 210 created.
197 211 """
198 212 name = 'repo-create'
199 213 display_name = lazy_ugettext('repository created')
200 214
201 215
202 216 class RepoPreDeleteEvent(RepoEvent):
203 217 """
204 218 An instance of this class is emitted as an :term:`event` whenever a repo is
205 219 created.
206 220 """
207 221 name = 'repo-pre-delete'
208 222 display_name = lazy_ugettext('repository pre delete')
209 223
210 224
211 225 class RepoDeleteEvent(RepoEvent):
212 226 """
213 227 An instance of this class is emitted as an :term:`event` whenever a repo is
214 228 created.
215 229 """
216 230 name = 'repo-delete'
217 231 display_name = lazy_ugettext('repository deleted')
218 232
219 233
220 234 class RepoVCSEvent(RepoEvent):
221 235 """
222 236 Base class for events triggered by the VCS
223 237 """
224 238 def __init__(self, repo_name, extras):
225 239 self.repo = Repository.get_by_repo_name(repo_name)
226 240 if not self.repo:
227 241 raise Exception('repo by this name %s does not exist' % repo_name)
228 242 self.extras = extras
229 243 super(RepoVCSEvent, self).__init__(self.repo)
230 244
231 245 @property
232 246 def actor(self):
233 247 if self.extras.get('username'):
234 248 return User.get_by_username(self.extras['username'])
235 249
236 250 @property
237 251 def actor_ip(self):
238 252 if self.extras.get('ip'):
239 253 return self.extras['ip']
240 254
241 255 @property
242 256 def server_url(self):
243 257 if self.extras.get('server_url'):
244 258 return self.extras['server_url']
245 259
246 260 @property
247 261 def request(self):
248 262 return self.extras.get('request') or self.get_request()
249 263
250 264
251 265 class RepoPrePullEvent(RepoVCSEvent):
252 266 """
253 267 An instance of this class is emitted as an :term:`event` before commits
254 268 are pulled from a repo.
255 269 """
256 270 name = 'repo-pre-pull'
257 271 display_name = lazy_ugettext('repository pre pull')
258 272
259 273
260 274 class RepoPullEvent(RepoVCSEvent):
261 275 """
262 276 An instance of this class is emitted as an :term:`event` after commits
263 277 are pulled from a repo.
264 278 """
265 279 name = 'repo-pull'
266 280 display_name = lazy_ugettext('repository pull')
267 281
268 282
269 283 class RepoPrePushEvent(RepoVCSEvent):
270 284 """
271 285 An instance of this class is emitted as an :term:`event` before commits
272 286 are pushed to a repo.
273 287 """
274 288 name = 'repo-pre-push'
275 289 display_name = lazy_ugettext('repository pre push')
276 290
277 291
278 292 class RepoPushEvent(RepoVCSEvent):
279 293 """
280 294 An instance of this class is emitted as an :term:`event` after commits
281 295 are pushed to a repo.
282 296
283 297 :param extras: (optional) dict of data from proxied VCS actions
284 298 """
285 299 name = 'repo-push'
286 300 display_name = lazy_ugettext('repository push')
287 301
288 302 def __init__(self, repo_name, pushed_commit_ids, extras):
289 303 super(RepoPushEvent, self).__init__(repo_name, extras)
290 304 self.pushed_commit_ids = pushed_commit_ids
291 305 self.new_refs = extras.new_refs
292 306
293 307 def as_dict(self):
294 308 data = super(RepoPushEvent, self).as_dict()
295 309
296 310 def branch_url(branch_name):
297 311 return '{}/changelog?branch={}'.format(
298 312 data['repo']['url'], branch_name)
299 313
300 314 def tag_url(tag_name):
301 315 return '{}/files/{}/'.format(
302 316 data['repo']['url'], tag_name)
303 317
304 318 commits = _commits_as_dict(
305 319 self, commit_ids=self.pushed_commit_ids, repos=[self.repo])
306 320
307 321 last_branch = None
308 322 for commit in reversed(commits):
309 323 commit['branch'] = commit['branch'] or last_branch
310 324 last_branch = commit['branch']
311 325 issues = _issues_as_dict(commits)
312 326
313 327 branches = set()
314 328 tags = set()
315 329 for commit in commits:
316 330 if commit['refs']['tags']:
317 331 for tag in commit['refs']['tags']:
318 332 tags.add(tag)
319 333 if commit['branch']:
320 334 branches.add(commit['branch'])
321 335
322 336 # maybe we have branches in new_refs ?
323 337 try:
324 338 branches = branches.union(set(self.new_refs['branches']))
325 339 except Exception:
326 340 pass
327 341
328 342 branches = [
329 343 {
330 344 'name': branch,
331 345 'url': branch_url(branch)
332 346 }
333 347 for branch in branches
334 348 ]
335 349
336 350 # maybe we have branches in new_refs ?
337 351 try:
338 352 tags = tags.union(set(self.new_refs['tags']))
339 353 except Exception:
340 354 pass
341 355
342 356 tags = [
343 357 {
344 358 'name': tag,
345 359 'url': tag_url(tag)
346 360 }
347 361 for tag in tags
348 362 ]
349 363
350 364 data['push'] = {
351 365 'commits': commits,
352 366 'issues': issues,
353 367 'branches': branches,
354 368 'tags': tags,
355 369 }
356 370 return data
@@ -1,492 +1,509 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2013-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 """
23 23 Set of hooks run by RhodeCode Enterprise
24 24 """
25 25
26 26 import os
27 import collections
28 27 import logging
29 28
30 29 import rhodecode
31 30 from rhodecode import events
32 31 from rhodecode.lib import helpers as h
33 32 from rhodecode.lib import audit_logger
34 33 from rhodecode.lib.utils2 import safe_str
35 34 from rhodecode.lib.exceptions import (
36 35 HTTPLockedRC, HTTPBranchProtected, UserCreationError)
37 36 from rhodecode.model.db import Repository, User
38 37
39 38 log = logging.getLogger(__name__)
40 39
41 40
42 41 class HookResponse(object):
43 42 def __init__(self, status, output):
44 43 self.status = status
45 44 self.output = output
46 45
47 46 def __add__(self, other):
48 47 other_status = getattr(other, 'status', 0)
49 48 new_status = max(self.status, other_status)
50 49 other_output = getattr(other, 'output', '')
51 50 new_output = self.output + other_output
52 51
53 52 return HookResponse(new_status, new_output)
54 53
55 54 def __bool__(self):
56 55 return self.status == 0
57 56
58 57
59 58 def is_shadow_repo(extras):
60 59 """
61 60 Returns ``True`` if this is an action executed against a shadow repository.
62 61 """
63 62 return extras['is_shadow_repo']
64 63
65 64
66 65 def _get_scm_size(alias, root_path):
67 66
68 67 if not alias.startswith('.'):
69 68 alias += '.'
70 69
71 70 size_scm, size_root = 0, 0
72 71 for path, unused_dirs, files in os.walk(safe_str(root_path)):
73 72 if path.find(alias) != -1:
74 73 for f in files:
75 74 try:
76 75 size_scm += os.path.getsize(os.path.join(path, f))
77 76 except OSError:
78 77 pass
79 78 else:
80 79 for f in files:
81 80 try:
82 81 size_root += os.path.getsize(os.path.join(path, f))
83 82 except OSError:
84 83 pass
85 84
86 85 size_scm_f = h.format_byte_size_binary(size_scm)
87 86 size_root_f = h.format_byte_size_binary(size_root)
88 87 size_total_f = h.format_byte_size_binary(size_root + size_scm)
89 88
90 89 return size_scm_f, size_root_f, size_total_f
91 90
92 91
93 92 # actual hooks called by Mercurial internally, and GIT by our Python Hooks
94 93 def repo_size(extras):
95 94 """Present size of repository after push."""
96 95 repo = Repository.get_by_repo_name(extras.repository)
97 96 vcs_part = safe_str(u'.%s' % repo.repo_type)
98 97 size_vcs, size_root, size_total = _get_scm_size(vcs_part,
99 98 repo.repo_full_path)
100 99 msg = ('Repository `%s` size summary %s:%s repo:%s total:%s\n'
101 100 % (repo.repo_name, vcs_part, size_vcs, size_root, size_total))
102 101 return HookResponse(0, msg)
103 102
104 103
105 104 def pre_push(extras):
106 105 """
107 106 Hook executed before pushing code.
108 107
109 108 It bans pushing when the repository is locked.
110 109 """
111 110
112 111 user = User.get_by_username(extras.username)
113 112 output = ''
114 113 if extras.locked_by[0] and user.user_id != int(extras.locked_by[0]):
115 114 locked_by = User.get(extras.locked_by[0]).username
116 115 reason = extras.locked_by[2]
117 116 # this exception is interpreted in git/hg middlewares and based
118 117 # on that proper return code is server to client
119 118 _http_ret = HTTPLockedRC(
120 119 _locked_by_explanation(extras.repository, locked_by, reason))
121 120 if str(_http_ret.code).startswith('2'):
122 121 # 2xx Codes don't raise exceptions
123 122 output = _http_ret.title
124 123 else:
125 124 raise _http_ret
126 125
127 126 hook_response = ''
128 127 if not is_shadow_repo(extras):
129 128 if extras.commit_ids and extras.check_branch_perms:
130 129
131 130 auth_user = user.AuthUser()
132 131 repo = Repository.get_by_repo_name(extras.repository)
133 132 affected_branches = []
134 133 if repo.repo_type == 'hg':
135 134 for entry in extras.commit_ids:
136 135 if entry['type'] == 'branch':
137 136 is_forced = bool(entry['multiple_heads'])
138 137 affected_branches.append([entry['name'], is_forced])
139 138 elif repo.repo_type == 'git':
140 139 for entry in extras.commit_ids:
141 140 if entry['type'] == 'heads':
142 141 is_forced = bool(entry['pruned_sha'])
143 142 affected_branches.append([entry['name'], is_forced])
144 143
145 144 for branch_name, is_forced in affected_branches:
146 145
147 146 rule, branch_perm = auth_user.get_rule_and_branch_permission(
148 147 extras.repository, branch_name)
149 148 if not branch_perm:
150 149 # no branch permission found for this branch, just keep checking
151 150 continue
152 151
153 152 if branch_perm == 'branch.push_force':
154 153 continue
155 154 elif branch_perm == 'branch.push' and is_forced is False:
156 155 continue
157 156 elif branch_perm == 'branch.push' and is_forced is True:
158 157 halt_message = 'Branch `{}` changes rejected by rule {}. ' \
159 158 'FORCE PUSH FORBIDDEN.'.format(branch_name, rule)
160 159 else:
161 160 halt_message = 'Branch `{}` changes rejected by rule {}.'.format(
162 161 branch_name, rule)
163 162
164 163 if halt_message:
165 164 _http_ret = HTTPBranchProtected(halt_message)
166 165 raise _http_ret
167 166
168 167 # Propagate to external components. This is done after checking the
169 168 # lock, for consistent behavior.
170 169 hook_response = pre_push_extension(
171 170 repo_store_path=Repository.base_path(), **extras)
172 171 events.trigger(events.RepoPrePushEvent(
173 172 repo_name=extras.repository, extras=extras))
174 173
175 174 return HookResponse(0, output) + hook_response
176 175
177 176
178 177 def pre_pull(extras):
179 178 """
180 179 Hook executed before pulling the code.
181 180
182 181 It bans pulling when the repository is locked.
183 182 """
184 183
185 184 output = ''
186 185 if extras.locked_by[0]:
187 186 locked_by = User.get(extras.locked_by[0]).username
188 187 reason = extras.locked_by[2]
189 188 # this exception is interpreted in git/hg middlewares and based
190 189 # on that proper return code is server to client
191 190 _http_ret = HTTPLockedRC(
192 191 _locked_by_explanation(extras.repository, locked_by, reason))
193 192 if str(_http_ret.code).startswith('2'):
194 193 # 2xx Codes don't raise exceptions
195 194 output = _http_ret.title
196 195 else:
197 196 raise _http_ret
198 197
199 198 # Propagate to external components. This is done after checking the
200 199 # lock, for consistent behavior.
201 200 hook_response = ''
202 201 if not is_shadow_repo(extras):
203 202 extras.hook_type = extras.hook_type or 'pre_pull'
204 203 hook_response = pre_pull_extension(
205 204 repo_store_path=Repository.base_path(), **extras)
206 205 events.trigger(events.RepoPrePullEvent(
207 206 repo_name=extras.repository, extras=extras))
208 207
209 208 return HookResponse(0, output) + hook_response
210 209
211 210
212 211 def post_pull(extras):
213 212 """Hook executed after client pulls the code."""
214 213
215 214 audit_user = audit_logger.UserWrap(
216 215 username=extras.username,
217 216 ip_addr=extras.ip)
218 217 repo = audit_logger.RepoWrap(repo_name=extras.repository)
219 218 audit_logger.store(
220 219 'user.pull', action_data={'user_agent': extras.user_agent},
221 220 user=audit_user, repo=repo, commit=True)
222 221
223 222 output = ''
224 223 # make lock is a tri state False, True, None. We only make lock on True
225 224 if extras.make_lock is True and not is_shadow_repo(extras):
226 225 user = User.get_by_username(extras.username)
227 226 Repository.lock(Repository.get_by_repo_name(extras.repository),
228 227 user.user_id,
229 228 lock_reason=Repository.LOCK_PULL)
230 229 msg = 'Made lock on repo `%s`' % (extras.repository,)
231 230 output += msg
232 231
233 232 if extras.locked_by[0]:
234 233 locked_by = User.get(extras.locked_by[0]).username
235 234 reason = extras.locked_by[2]
236 235 _http_ret = HTTPLockedRC(
237 236 _locked_by_explanation(extras.repository, locked_by, reason))
238 237 if str(_http_ret.code).startswith('2'):
239 238 # 2xx Codes don't raise exceptions
240 239 output += _http_ret.title
241 240
242 241 # Propagate to external components.
243 242 hook_response = ''
244 243 if not is_shadow_repo(extras):
245 244 extras.hook_type = extras.hook_type or 'post_pull'
246 245 hook_response = post_pull_extension(
247 246 repo_store_path=Repository.base_path(), **extras)
248 247 events.trigger(events.RepoPullEvent(
249 248 repo_name=extras.repository, extras=extras))
250 249
251 250 return HookResponse(0, output) + hook_response
252 251
253 252
254 253 def post_push(extras):
255 254 """Hook executed after user pushes to the repository."""
256 255 commit_ids = extras.commit_ids
257 256
258 257 # log the push call
259 258 audit_user = audit_logger.UserWrap(
260 259 username=extras.username, ip_addr=extras.ip)
261 260 repo = audit_logger.RepoWrap(repo_name=extras.repository)
262 261 audit_logger.store(
263 262 'user.push', action_data={
264 263 'user_agent': extras.user_agent,
265 264 'commit_ids': commit_ids[:400]},
266 265 user=audit_user, repo=repo, commit=True)
267 266
268 267 # Propagate to external components.
269 268 output = ''
270 269 # make lock is a tri state False, True, None. We only release lock on False
271 270 if extras.make_lock is False and not is_shadow_repo(extras):
272 271 Repository.unlock(Repository.get_by_repo_name(extras.repository))
273 272 msg = 'Released lock on repo `{}`\n'.format(safe_str(extras.repository))
274 273 output += msg
275 274
276 275 if extras.locked_by[0]:
277 276 locked_by = User.get(extras.locked_by[0]).username
278 277 reason = extras.locked_by[2]
279 278 _http_ret = HTTPLockedRC(
280 279 _locked_by_explanation(extras.repository, locked_by, reason))
281 280 # TODO: johbo: if not?
282 281 if str(_http_ret.code).startswith('2'):
283 282 # 2xx Codes don't raise exceptions
284 283 output += _http_ret.title
285 284
286 285 if extras.new_refs:
287 286 tmpl = '{}/{}/pull-request/new?{{ref_type}}={{ref_name}}'.format(
288 287 safe_str(extras.server_url), safe_str(extras.repository))
289 288
290 289 for branch_name in extras.new_refs['branches']:
291 290 output += 'RhodeCode: open pull request link: {}\n'.format(
292 291 tmpl.format(ref_type='branch', ref_name=safe_str(branch_name)))
293 292
294 293 for book_name in extras.new_refs['bookmarks']:
295 294 output += 'RhodeCode: open pull request link: {}\n'.format(
296 295 tmpl.format(ref_type='bookmark', ref_name=safe_str(book_name)))
297 296
298 297 hook_response = ''
299 298 if not is_shadow_repo(extras):
300 299 hook_response = post_push_extension(
301 300 repo_store_path=Repository.base_path(),
302 301 **extras)
303 302 events.trigger(events.RepoPushEvent(
304 303 repo_name=extras.repository, pushed_commit_ids=commit_ids, extras=extras))
305 304
306 305 output += 'RhodeCode: push completed\n'
307 306 return HookResponse(0, output) + hook_response
308 307
309 308
310 309 def _locked_by_explanation(repo_name, user_name, reason):
311 310 message = (
312 311 'Repository `%s` locked by user `%s`. Reason:`%s`'
313 312 % (repo_name, user_name, reason))
314 313 return message
315 314
316 315
317 316 def check_allowed_create_user(user_dict, created_by, **kwargs):
318 317 # pre create hooks
319 318 if pre_create_user.is_active():
320 319 hook_result = pre_create_user(created_by=created_by, **user_dict)
321 320 allowed = hook_result.status == 0
322 321 if not allowed:
323 322 reason = hook_result.output
324 323 raise UserCreationError(reason)
325 324
326 325
327 326 class ExtensionCallback(object):
328 327 """
329 328 Forwards a given call to rcextensions, sanitizes keyword arguments.
330 329
331 330 Does check if there is an extension active for that hook. If it is
332 331 there, it will forward all `kwargs_keys` keyword arguments to the
333 332 extension callback.
334 333 """
335 334
336 335 def __init__(self, hook_name, kwargs_keys):
337 336 self._hook_name = hook_name
338 337 self._kwargs_keys = set(kwargs_keys)
339 338
340 339 def __call__(self, *args, **kwargs):
341 340 log.debug('Calling extension callback for `%s`', self._hook_name)
342 341 callback = self._get_callback()
343 342 if not callback:
344 343 log.debug('extension callback `%s` not found, skipping...', self._hook_name)
345 344 return
346 345
347 346 kwargs_to_pass = {}
348 347 for key in self._kwargs_keys:
349 348 try:
350 349 kwargs_to_pass[key] = kwargs[key]
351 350 except KeyError:
352 log.error('Failed to fetch %s key. Expected keys: %s',
353 key, self._kwargs_keys)
351 log.error('Failed to fetch %s key from given kwargs. '
352 'Expected keys: %s', key, self._kwargs_keys)
354 353 raise
355 354
356 355 # backward compat for removed api_key for old hooks. This was it works
357 356 # with older rcextensions that require api_key present
358 357 if self._hook_name in ['CREATE_USER_HOOK', 'DELETE_USER_HOOK']:
359 358 kwargs_to_pass['api_key'] = '_DEPRECATED_'
360 359 return callback(**kwargs_to_pass)
361 360
362 361 def is_active(self):
363 362 return hasattr(rhodecode.EXTENSIONS, self._hook_name)
364 363
365 364 def _get_callback(self):
366 365 return getattr(rhodecode.EXTENSIONS, self._hook_name, None)
367 366
368 367
369 368 pre_pull_extension = ExtensionCallback(
370 369 hook_name='PRE_PULL_HOOK',
371 370 kwargs_keys=(
372 371 'server_url', 'config', 'scm', 'username', 'ip', 'action',
373 372 'repository', 'hook_type', 'user_agent', 'repo_store_path',))
374 373
375 374
376 375 post_pull_extension = ExtensionCallback(
377 376 hook_name='PULL_HOOK',
378 377 kwargs_keys=(
379 378 'server_url', 'config', 'scm', 'username', 'ip', 'action',
380 379 'repository', 'hook_type', 'user_agent', 'repo_store_path',))
381 380
382 381
383 382 pre_push_extension = ExtensionCallback(
384 383 hook_name='PRE_PUSH_HOOK',
385 384 kwargs_keys=(
386 385 'server_url', 'config', 'scm', 'username', 'ip', 'action',
387 386 'repository', 'repo_store_path', 'commit_ids', 'hook_type', 'user_agent',))
388 387
389 388
390 389 post_push_extension = ExtensionCallback(
391 390 hook_name='PUSH_HOOK',
392 391 kwargs_keys=(
393 392 'server_url', 'config', 'scm', 'username', 'ip', 'action',
394 393 'repository', 'repo_store_path', 'commit_ids', 'hook_type', 'user_agent',))
395 394
396 395
397 396 pre_create_user = ExtensionCallback(
398 397 hook_name='PRE_CREATE_USER_HOOK',
399 398 kwargs_keys=(
400 399 'username', 'password', 'email', 'firstname', 'lastname', 'active',
401 400 'admin', 'created_by'))
402 401
403 402
404 403 log_create_pull_request = ExtensionCallback(
405 404 hook_name='CREATE_PULL_REQUEST',
406 405 kwargs_keys=(
407 406 'server_url', 'config', 'scm', 'username', 'ip', 'action',
408 407 'repository', 'pull_request_id', 'url', 'title', 'description',
409 408 'status', 'created_on', 'updated_on', 'commit_ids', 'review_status',
410 409 'mergeable', 'source', 'target', 'author', 'reviewers'))
411 410
412 411
413 412 log_merge_pull_request = ExtensionCallback(
414 413 hook_name='MERGE_PULL_REQUEST',
415 414 kwargs_keys=(
416 415 'server_url', 'config', 'scm', 'username', 'ip', 'action',
417 416 'repository', 'pull_request_id', 'url', 'title', 'description',
418 417 'status', 'created_on', 'updated_on', 'commit_ids', 'review_status',
419 418 'mergeable', 'source', 'target', 'author', 'reviewers'))
420 419
421 420
422 421 log_close_pull_request = ExtensionCallback(
423 422 hook_name='CLOSE_PULL_REQUEST',
424 423 kwargs_keys=(
425 424 'server_url', 'config', 'scm', 'username', 'ip', 'action',
426 425 'repository', 'pull_request_id', 'url', 'title', 'description',
427 426 'status', 'created_on', 'updated_on', 'commit_ids', 'review_status',
428 427 'mergeable', 'source', 'target', 'author', 'reviewers'))
429 428
430 429
431 430 log_review_pull_request = ExtensionCallback(
432 431 hook_name='REVIEW_PULL_REQUEST',
433 432 kwargs_keys=(
434 433 'server_url', 'config', 'scm', 'username', 'ip', 'action',
435 434 'repository', 'pull_request_id', 'url', 'title', 'description',
436 435 'status', 'created_on', 'updated_on', 'commit_ids', 'review_status',
437 436 'mergeable', 'source', 'target', 'author', 'reviewers'))
438 437
439 438
439 log_comment_pull_request = ExtensionCallback(
440 hook_name='COMMENT_PULL_REQUEST',
441 kwargs_keys=(
442 'server_url', 'config', 'scm', 'username', 'ip', 'action',
443 'repository', 'pull_request_id', 'url', 'title', 'description',
444 'status', 'comment', 'created_on', 'updated_on', 'commit_ids', 'review_status',
445 'mergeable', 'source', 'target', 'author', 'reviewers'))
446
447
440 448 log_update_pull_request = ExtensionCallback(
441 449 hook_name='UPDATE_PULL_REQUEST',
442 450 kwargs_keys=(
443 451 'server_url', 'config', 'scm', 'username', 'ip', 'action',
444 452 'repository', 'pull_request_id', 'url', 'title', 'description',
445 453 'status', 'created_on', 'updated_on', 'commit_ids', 'review_status',
446 454 'mergeable', 'source', 'target', 'author', 'reviewers'))
447 455
448 456
449 457 log_create_user = ExtensionCallback(
450 458 hook_name='CREATE_USER_HOOK',
451 459 kwargs_keys=(
452 460 'username', 'full_name_or_username', 'full_contact', 'user_id',
453 461 'name', 'firstname', 'short_contact', 'admin', 'lastname',
454 462 'ip_addresses', 'extern_type', 'extern_name',
455 463 'email', 'api_keys', 'last_login',
456 464 'full_name', 'active', 'password', 'emails',
457 465 'inherit_default_permissions', 'created_by', 'created_on'))
458 466
459 467
460 468 log_delete_user = ExtensionCallback(
461 469 hook_name='DELETE_USER_HOOK',
462 470 kwargs_keys=(
463 471 'username', 'full_name_or_username', 'full_contact', 'user_id',
464 472 'name', 'firstname', 'short_contact', 'admin', 'lastname',
465 473 'ip_addresses',
466 474 'email', 'last_login',
467 475 'full_name', 'active', 'password', 'emails',
468 476 'inherit_default_permissions', 'deleted_by'))
469 477
470 478
471 479 log_create_repository = ExtensionCallback(
472 480 hook_name='CREATE_REPO_HOOK',
473 481 kwargs_keys=(
474 482 'repo_name', 'repo_type', 'description', 'private', 'created_on',
475 483 'enable_downloads', 'repo_id', 'user_id', 'enable_statistics',
476 484 'clone_uri', 'fork_id', 'group_id', 'created_by'))
477 485
478 486
479 487 log_delete_repository = ExtensionCallback(
480 488 hook_name='DELETE_REPO_HOOK',
481 489 kwargs_keys=(
482 490 'repo_name', 'repo_type', 'description', 'private', 'created_on',
483 491 'enable_downloads', 'repo_id', 'user_id', 'enable_statistics',
484 492 'clone_uri', 'fork_id', 'group_id', 'deleted_by', 'deleted_on'))
485 493
486 494
495 log_comment_commit_repository = ExtensionCallback(
496 hook_name='COMMENT_COMMIT_REPO_HOOK',
497 kwargs_keys=(
498 'repo_name', 'repo_type', 'description', 'private', 'created_on',
499 'enable_downloads', 'repo_id', 'user_id', 'enable_statistics',
500 'clone_uri', 'fork_id', 'group_id',
501 'repository', 'created_by', 'comment', 'commit'))
502
503
487 504 log_create_repository_group = ExtensionCallback(
488 505 hook_name='CREATE_REPO_GROUP_HOOK',
489 506 kwargs_keys=(
490 507 'group_name', 'group_parent_id', 'group_description',
491 508 'group_id', 'user_id', 'created_by', 'created_on',
492 509 'enable_locking'))
@@ -1,169 +1,215 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 webob
22 22 from pyramid.threadlocal import get_current_request
23 23
24 24 from rhodecode import events
25 25 from rhodecode.lib import hooks_base
26 26 from rhodecode.lib import utils2
27 27
28 28
29 def _get_rc_scm_extras(username, repo_name, repo_alias, action):
30 # TODO: johbo: Replace by vcs_operation_context and remove fully
29 def _supports_repo_type(repo_type):
30 if repo_type in ('hg', 'git'):
31 return True
32 return False
33
34
35 def _get_vcs_operation_context(username, repo_name, repo_type, action):
36 # NOTE(dan): import loop
31 37 from rhodecode.lib.base import vcs_operation_context
38
32 39 check_locking = action in ('pull', 'push')
33 40
34 41 request = get_current_request()
35 42
36 # default
37 dummy_environ = webob.Request.blank('').environ
38 43 try:
39 environ = request.environ or dummy_environ
44 environ = request.environ
40 45 except TypeError:
41 46 # we might use this outside of request context
42 environ = dummy_environ
47 environ = {}
43 48
44 extras = vcs_operation_context(
45 environ, repo_name, username, action, repo_alias, check_locking)
49 if not environ:
50 environ = webob.Request.blank('').environ
51
52 extras = vcs_operation_context(environ, repo_name, username, action, repo_type, check_locking)
46 53 return utils2.AttributeDict(extras)
47 54
48 55
49 def trigger_post_push_hook(
50 username, action, hook_type, repo_name, repo_alias, commit_ids):
56 def trigger_post_push_hook(username, action, hook_type, repo_name, repo_type, commit_ids):
51 57 """
52 58 Triggers push action hooks
53 59
54 60 :param username: username who pushes
55 61 :param action: push/push_local/push_remote
62 :param hook_type: type of hook executed
56 63 :param repo_name: name of repo
57 :param repo_alias: the type of SCM repo
64 :param repo_type: the type of SCM repo
58 65 :param commit_ids: list of commit ids that we pushed
59 66 """
60 extras = _get_rc_scm_extras(username, repo_name, repo_alias, action)
67 extras = _get_vcs_operation_context(username, repo_name, repo_type, action)
61 68 extras.commit_ids = commit_ids
62 69 extras.hook_type = hook_type
63 70 hooks_base.post_push(extras)
64 71
65 72
66 def trigger_log_create_pull_request_hook(username, repo_name, repo_alias,
67 pull_request, data=None):
73 def trigger_comment_commit_hooks(username, repo_name, repo_type, repo, data=None):
74 """
75 Triggers when a comment is made on a commit
76
77 :param username: username who creates the comment
78 :param repo_name: name of target repo
79 :param repo_type: the type of SCM target repo
80 :param repo: the repo object we trigger the event for
81 :param data: extra data for specific events e.g {'comment': comment_obj, 'commit': commit_obj}
82 """
83 if not _supports_repo_type(repo_type):
84 return
85
86 extras = _get_vcs_operation_context(username, repo_name, repo_type, 'comment_commit')
87
88 comment = data['comment']
89 commit = data['commit']
90
91 events.trigger(events.RepoCommitCommentEvent(repo, commit, comment))
92 extras.update(repo.get_dict())
93
94 extras.commit = commit.serialize()
95 extras.comment = comment.get_api_data()
96 extras.created_by = username
97 hooks_base.log_comment_commit_repository(**extras)
98
99
100 def trigger_create_pull_request_hook(username, repo_name, repo_type, pull_request, data=None):
68 101 """
69 102 Triggers create pull request action hooks
70 103
71 104 :param username: username who creates the pull request
72 105 :param repo_name: name of target repo
73 :param repo_alias: the type of SCM target repo
106 :param repo_type: the type of SCM target repo
74 107 :param pull_request: the pull request that was created
75 108 :param data: extra data for specific events e.g {'comment': comment_obj}
76 109 """
77 if repo_alias not in ('hg', 'git'):
110 if not _supports_repo_type(repo_type):
78 111 return
79 112
80 extras = _get_rc_scm_extras(username, repo_name, repo_alias,
81 'create_pull_request')
113 extras = _get_vcs_operation_context(username, repo_name, repo_type, 'create_pull_request')
82 114 events.trigger(events.PullRequestCreateEvent(pull_request))
83 115 extras.update(pull_request.get_api_data(with_merge_state=False))
84 116 hooks_base.log_create_pull_request(**extras)
85 117
86 118
87 def trigger_log_merge_pull_request_hook(username, repo_name, repo_alias,
88 pull_request, data=None):
119 def trigger_merge_pull_request_hook(username, repo_name, repo_type, pull_request, data=None):
89 120 """
90 121 Triggers merge pull request action hooks
91 122
92 123 :param username: username who creates the pull request
93 124 :param repo_name: name of target repo
94 :param repo_alias: the type of SCM target repo
125 :param repo_type: the type of SCM target repo
95 126 :param pull_request: the pull request that was merged
96 127 :param data: extra data for specific events e.g {'comment': comment_obj}
97 128 """
98 if repo_alias not in ('hg', 'git'):
129 if not _supports_repo_type(repo_type):
99 130 return
100 131
101 extras = _get_rc_scm_extras(username, repo_name, repo_alias,
102 'merge_pull_request')
132 extras = _get_vcs_operation_context(username, repo_name, repo_type, 'merge_pull_request')
103 133 events.trigger(events.PullRequestMergeEvent(pull_request))
104 134 extras.update(pull_request.get_api_data())
105 135 hooks_base.log_merge_pull_request(**extras)
106 136
107 137
108 def trigger_log_close_pull_request_hook(username, repo_name, repo_alias,
109 pull_request, data=None):
138 def trigger_close_pull_request_hook(username, repo_name, repo_type, pull_request, data=None):
110 139 """
111 140 Triggers close pull request action hooks
112 141
113 142 :param username: username who creates the pull request
114 143 :param repo_name: name of target repo
115 :param repo_alias: the type of SCM target repo
144 :param repo_type: the type of SCM target repo
116 145 :param pull_request: the pull request that was closed
117 146 :param data: extra data for specific events e.g {'comment': comment_obj}
118 147 """
119 if repo_alias not in ('hg', 'git'):
148 if not _supports_repo_type(repo_type):
120 149 return
121 150
122 extras = _get_rc_scm_extras(username, repo_name, repo_alias,
123 'close_pull_request')
151 extras = _get_vcs_operation_context(username, repo_name, repo_type, 'close_pull_request')
124 152 events.trigger(events.PullRequestCloseEvent(pull_request))
125 153 extras.update(pull_request.get_api_data())
126 154 hooks_base.log_close_pull_request(**extras)
127 155
128 156
129 def trigger_log_review_pull_request_hook(username, repo_name, repo_alias,
130 pull_request, data=None):
157 def trigger_review_pull_request_hook(username, repo_name, repo_type, pull_request, data=None):
131 158 """
132 159 Triggers review status change pull request action hooks
133 160
134 161 :param username: username who creates the pull request
135 162 :param repo_name: name of target repo
136 :param repo_alias: the type of SCM target repo
163 :param repo_type: the type of SCM target repo
137 164 :param pull_request: the pull request that review status changed
138 165 :param data: extra data for specific events e.g {'comment': comment_obj}
139 166 """
140 if repo_alias not in ('hg', 'git'):
167 if not _supports_repo_type(repo_type):
141 168 return
142 169
143 extras = _get_rc_scm_extras(username, repo_name, repo_alias,
144 'review_pull_request')
170 extras = _get_vcs_operation_context(username, repo_name, repo_type, 'review_pull_request')
145 171 status = data.get('status')
146 172 events.trigger(events.PullRequestReviewEvent(pull_request, status))
147 173 extras.update(pull_request.get_api_data())
148 174 hooks_base.log_review_pull_request(**extras)
149 175
150 176
151 def trigger_log_update_pull_request_hook(username, repo_name, repo_alias,
152 pull_request, data=None):
177 def trigger_comment_pull_request_hook(username, repo_name, repo_type, pull_request, data=None):
178 """
179 Triggers when a comment is made on a pull request
180
181 :param username: username who creates the pull request
182 :param repo_name: name of target repo
183 :param repo_type: the type of SCM target repo
184 :param pull_request: the pull request that comment was made on
185 :param data: extra data for specific events e.g {'comment': comment_obj}
186 """
187 if not _supports_repo_type(repo_type):
188 return
189
190 extras = _get_vcs_operation_context(username, repo_name, repo_type, 'comment_pull_request')
191
192 comment = data['comment']
193 events.trigger(events.PullRequestCommentEvent(pull_request, comment))
194 extras.update(pull_request.get_api_data())
195 extras.comment = comment.get_api_data()
196 hooks_base.log_comment_pull_request(**extras)
197
198
199 def trigger_update_pull_request_hook(username, repo_name, repo_type, pull_request, data=None):
153 200 """
154 201 Triggers update pull request action hooks
155 202
156 203 :param username: username who creates the pull request
157 204 :param repo_name: name of target repo
158 :param repo_alias: the type of SCM target repo
205 :param repo_type: the type of SCM target repo
159 206 :param pull_request: the pull request that was updated
160 207 :param data: extra data for specific events e.g {'comment': comment_obj}
161 208 """
162 if repo_alias not in ('hg', 'git'):
209 if not _supports_repo_type(repo_type):
163 210 return
164 211
165 extras = _get_rc_scm_extras(username, repo_name, repo_alias,
166 'update_pull_request')
212 extras = _get_vcs_operation_context(username, repo_name, repo_type, 'update_pull_request')
167 213 events.trigger(events.PullRequestUpdateEvent(pull_request))
168 214 extras.update(pull_request.get_api_data())
169 215 hooks_base.log_update_pull_request(**extras)
@@ -1,1901 +1,1904 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
37 37 from pyramid import compat
38 38
39 39 import rhodecode
40 40 from rhodecode.translation import lazy_ugettext
41 41 from rhodecode.lib.utils2 import safe_str, safe_unicode, CachedProperty
42 42 from rhodecode.lib.vcs import connection
43 43 from rhodecode.lib.vcs.utils import author_name, author_email
44 44 from rhodecode.lib.vcs.conf import settings
45 45 from rhodecode.lib.vcs.exceptions import (
46 46 CommitError, EmptyRepositoryError, NodeAlreadyAddedError,
47 47 NodeAlreadyChangedError, NodeAlreadyExistsError, NodeAlreadyRemovedError,
48 48 NodeDoesNotExistError, NodeNotChangedError, VCSError,
49 49 ImproperArchiveTypeError, BranchDoesNotExistError, CommitDoesNotExistError,
50 50 RepositoryError)
51 51
52 52
53 53 log = logging.getLogger(__name__)
54 54
55 55
56 56 FILEMODE_DEFAULT = 0o100644
57 57 FILEMODE_EXECUTABLE = 0o100755
58 58 EMPTY_COMMIT_ID = '0' * 40
59 59
60 60 Reference = collections.namedtuple('Reference', ('type', 'name', 'commit_id'))
61 61
62 62
63 63 class MergeFailureReason(object):
64 64 """
65 65 Enumeration with all the reasons why the server side merge could fail.
66 66
67 67 DO NOT change the number of the reasons, as they may be stored in the
68 68 database.
69 69
70 70 Changing the name of a reason is acceptable and encouraged to deprecate old
71 71 reasons.
72 72 """
73 73
74 74 # Everything went well.
75 75 NONE = 0
76 76
77 77 # An unexpected exception was raised. Check the logs for more details.
78 78 UNKNOWN = 1
79 79
80 80 # The merge was not successful, there are conflicts.
81 81 MERGE_FAILED = 2
82 82
83 83 # The merge succeeded but we could not push it to the target repository.
84 84 PUSH_FAILED = 3
85 85
86 86 # The specified target is not a head in the target repository.
87 87 TARGET_IS_NOT_HEAD = 4
88 88
89 89 # The source repository contains more branches than the target. Pushing
90 90 # the merge will create additional branches in the target.
91 91 HG_SOURCE_HAS_MORE_BRANCHES = 5
92 92
93 93 # The target reference has multiple heads. That does not allow to correctly
94 94 # identify the target location. This could only happen for mercurial
95 95 # branches.
96 96 HG_TARGET_HAS_MULTIPLE_HEADS = 6
97 97
98 98 # The target repository is locked
99 99 TARGET_IS_LOCKED = 7
100 100
101 101 # Deprecated, use MISSING_TARGET_REF or MISSING_SOURCE_REF instead.
102 102 # A involved commit could not be found.
103 103 _DEPRECATED_MISSING_COMMIT = 8
104 104
105 105 # The target repo reference is missing.
106 106 MISSING_TARGET_REF = 9
107 107
108 108 # The source repo reference is missing.
109 109 MISSING_SOURCE_REF = 10
110 110
111 111 # The merge was not successful, there are conflicts related to sub
112 112 # repositories.
113 113 SUBREPO_MERGE_FAILED = 11
114 114
115 115
116 116 class UpdateFailureReason(object):
117 117 """
118 118 Enumeration with all the reasons why the pull request update could fail.
119 119
120 120 DO NOT change the number of the reasons, as they may be stored in the
121 121 database.
122 122
123 123 Changing the name of a reason is acceptable and encouraged to deprecate old
124 124 reasons.
125 125 """
126 126
127 127 # Everything went well.
128 128 NONE = 0
129 129
130 130 # An unexpected exception was raised. Check the logs for more details.
131 131 UNKNOWN = 1
132 132
133 133 # The pull request is up to date.
134 134 NO_CHANGE = 2
135 135
136 136 # The pull request has a reference type that is not supported for update.
137 137 WRONG_REF_TYPE = 3
138 138
139 139 # Update failed because the target reference is missing.
140 140 MISSING_TARGET_REF = 4
141 141
142 142 # Update failed because the source reference is missing.
143 143 MISSING_SOURCE_REF = 5
144 144
145 145
146 146 class MergeResponse(object):
147 147
148 148 # uses .format(**metadata) for variables
149 149 MERGE_STATUS_MESSAGES = {
150 150 MergeFailureReason.NONE: lazy_ugettext(
151 151 u'This pull request can be automatically merged.'),
152 152 MergeFailureReason.UNKNOWN: lazy_ugettext(
153 153 u'This pull request cannot be merged because of an unhandled exception. '
154 154 u'{exception}'),
155 155 MergeFailureReason.MERGE_FAILED: lazy_ugettext(
156 156 u'This pull request cannot be merged because of merge conflicts. {unresolved_files}'),
157 157 MergeFailureReason.PUSH_FAILED: lazy_ugettext(
158 158 u'This pull request could not be merged because push to '
159 159 u'target:`{target}@{merge_commit}` failed.'),
160 160 MergeFailureReason.TARGET_IS_NOT_HEAD: lazy_ugettext(
161 161 u'This pull request cannot be merged because the target '
162 162 u'`{target_ref.name}` is not a head.'),
163 163 MergeFailureReason.HG_SOURCE_HAS_MORE_BRANCHES: lazy_ugettext(
164 164 u'This pull request cannot be merged because the source contains '
165 165 u'more branches than the target.'),
166 166 MergeFailureReason.HG_TARGET_HAS_MULTIPLE_HEADS: lazy_ugettext(
167 167 u'This pull request cannot be merged because the target `{target_ref.name}` '
168 168 u'has multiple heads: `{heads}`.'),
169 169 MergeFailureReason.TARGET_IS_LOCKED: lazy_ugettext(
170 170 u'This pull request cannot be merged because the target repository is '
171 171 u'locked by {locked_by}.'),
172 172
173 173 MergeFailureReason.MISSING_TARGET_REF: lazy_ugettext(
174 174 u'This pull request cannot be merged because the target '
175 175 u'reference `{target_ref.name}` is missing.'),
176 176 MergeFailureReason.MISSING_SOURCE_REF: lazy_ugettext(
177 177 u'This pull request cannot be merged because the source '
178 178 u'reference `{source_ref.name}` is missing.'),
179 179 MergeFailureReason.SUBREPO_MERGE_FAILED: lazy_ugettext(
180 180 u'This pull request cannot be merged because of conflicts related '
181 181 u'to sub repositories.'),
182 182
183 183 # Deprecations
184 184 MergeFailureReason._DEPRECATED_MISSING_COMMIT: lazy_ugettext(
185 185 u'This pull request cannot be merged because the target or the '
186 186 u'source reference is missing.'),
187 187
188 188 }
189 189
190 190 def __init__(self, possible, executed, merge_ref, failure_reason, metadata=None):
191 191 self.possible = possible
192 192 self.executed = executed
193 193 self.merge_ref = merge_ref
194 194 self.failure_reason = failure_reason
195 195 self.metadata = metadata or {}
196 196
197 197 def __repr__(self):
198 198 return '<MergeResponse:{} {}>'.format(self.label, self.failure_reason)
199 199
200 200 def __eq__(self, other):
201 201 same_instance = isinstance(other, self.__class__)
202 202 return same_instance \
203 203 and self.possible == other.possible \
204 204 and self.executed == other.executed \
205 205 and self.failure_reason == other.failure_reason
206 206
207 207 @property
208 208 def label(self):
209 209 label_dict = dict((v, k) for k, v in MergeFailureReason.__dict__.items() if
210 210 not k.startswith('_'))
211 211 return label_dict.get(self.failure_reason)
212 212
213 213 @property
214 214 def merge_status_message(self):
215 215 """
216 216 Return a human friendly error message for the given merge status code.
217 217 """
218 218 msg = safe_unicode(self.MERGE_STATUS_MESSAGES[self.failure_reason])
219 219
220 220 try:
221 221 return msg.format(**self.metadata)
222 222 except Exception:
223 223 log.exception('Failed to format %s message', self)
224 224 return msg
225 225
226 226 def asdict(self):
227 227 data = {}
228 228 for k in ['possible', 'executed', 'merge_ref', 'failure_reason',
229 229 'merge_status_message']:
230 230 data[k] = getattr(self, k)
231 231 return data
232 232
233 233
234 234 class BaseRepository(object):
235 235 """
236 236 Base Repository for final backends
237 237
238 238 .. attribute:: DEFAULT_BRANCH_NAME
239 239
240 240 name of default branch (i.e. "trunk" for svn, "master" for git etc.
241 241
242 242 .. attribute:: commit_ids
243 243
244 244 list of all available commit ids, in ascending order
245 245
246 246 .. attribute:: path
247 247
248 248 absolute path to the repository
249 249
250 250 .. attribute:: bookmarks
251 251
252 252 Mapping from name to :term:`Commit ID` of the bookmark. Empty in case
253 253 there are no bookmarks or the backend implementation does not support
254 254 bookmarks.
255 255
256 256 .. attribute:: tags
257 257
258 258 Mapping from name to :term:`Commit ID` of the tag.
259 259
260 260 """
261 261
262 262 DEFAULT_BRANCH_NAME = None
263 263 DEFAULT_CONTACT = u"Unknown"
264 264 DEFAULT_DESCRIPTION = u"unknown"
265 265 EMPTY_COMMIT_ID = '0' * 40
266 266
267 267 path = None
268 268
269 269 _is_empty = None
270 270 _commit_ids = {}
271 271
272 272 def __init__(self, repo_path, config=None, create=False, **kwargs):
273 273 """
274 274 Initializes repository. Raises RepositoryError if repository could
275 275 not be find at the given ``repo_path`` or directory at ``repo_path``
276 276 exists and ``create`` is set to True.
277 277
278 278 :param repo_path: local path of the repository
279 279 :param config: repository configuration
280 280 :param create=False: if set to True, would try to create repository.
281 281 :param src_url=None: if set, should be proper url from which repository
282 282 would be cloned; requires ``create`` parameter to be set to True -
283 283 raises RepositoryError if src_url is set and create evaluates to
284 284 False
285 285 """
286 286 raise NotImplementedError
287 287
288 288 def __repr__(self):
289 289 return '<%s at %s>' % (self.__class__.__name__, self.path)
290 290
291 291 def __len__(self):
292 292 return self.count()
293 293
294 294 def __eq__(self, other):
295 295 same_instance = isinstance(other, self.__class__)
296 296 return same_instance and other.path == self.path
297 297
298 298 def __ne__(self, other):
299 299 return not self.__eq__(other)
300 300
301 301 def get_create_shadow_cache_pr_path(self, db_repo):
302 302 path = db_repo.cached_diffs_dir
303 303 if not os.path.exists(path):
304 304 os.makedirs(path, 0o755)
305 305 return path
306 306
307 307 @classmethod
308 308 def get_default_config(cls, default=None):
309 309 config = Config()
310 310 if default and isinstance(default, list):
311 311 for section, key, val in default:
312 312 config.set(section, key, val)
313 313 return config
314 314
315 315 @LazyProperty
316 316 def _remote(self):
317 317 raise NotImplementedError
318 318
319 319 def _heads(self, branch=None):
320 320 return []
321 321
322 322 @LazyProperty
323 323 def EMPTY_COMMIT(self):
324 324 return EmptyCommit(self.EMPTY_COMMIT_ID)
325 325
326 326 @LazyProperty
327 327 def alias(self):
328 328 for k, v in settings.BACKENDS.items():
329 329 if v.split('.')[-1] == str(self.__class__.__name__):
330 330 return k
331 331
332 332 @LazyProperty
333 333 def name(self):
334 334 return safe_unicode(os.path.basename(self.path))
335 335
336 336 @LazyProperty
337 337 def description(self):
338 338 raise NotImplementedError
339 339
340 340 def refs(self):
341 341 """
342 342 returns a `dict` with branches, bookmarks, tags, and closed_branches
343 343 for this repository
344 344 """
345 345 return dict(
346 346 branches=self.branches,
347 347 branches_closed=self.branches_closed,
348 348 tags=self.tags,
349 349 bookmarks=self.bookmarks
350 350 )
351 351
352 352 @LazyProperty
353 353 def branches(self):
354 354 """
355 355 A `dict` which maps branch names to commit ids.
356 356 """
357 357 raise NotImplementedError
358 358
359 359 @LazyProperty
360 360 def branches_closed(self):
361 361 """
362 362 A `dict` which maps tags names to commit ids.
363 363 """
364 364 raise NotImplementedError
365 365
366 366 @LazyProperty
367 367 def bookmarks(self):
368 368 """
369 369 A `dict` which maps tags names to commit ids.
370 370 """
371 371 raise NotImplementedError
372 372
373 373 @LazyProperty
374 374 def tags(self):
375 375 """
376 376 A `dict` which maps tags names to commit ids.
377 377 """
378 378 raise NotImplementedError
379 379
380 380 @LazyProperty
381 381 def size(self):
382 382 """
383 383 Returns combined size in bytes for all repository files
384 384 """
385 385 tip = self.get_commit()
386 386 return tip.size
387 387
388 388 def size_at_commit(self, commit_id):
389 389 commit = self.get_commit(commit_id)
390 390 return commit.size
391 391
392 392 def _check_for_empty(self):
393 393 no_commits = len(self._commit_ids) == 0
394 394 if no_commits:
395 395 # check on remote to be sure
396 396 return self._remote.is_empty()
397 397 else:
398 398 return False
399 399
400 400 def is_empty(self):
401 401 if rhodecode.is_test:
402 402 return self._check_for_empty()
403 403
404 404 if self._is_empty is None:
405 405 # cache empty for production, but not tests
406 406 self._is_empty = self._check_for_empty()
407 407
408 408 return self._is_empty
409 409
410 410 @staticmethod
411 411 def check_url(url, config):
412 412 """
413 413 Function will check given url and try to verify if it's a valid
414 414 link.
415 415 """
416 416 raise NotImplementedError
417 417
418 418 @staticmethod
419 419 def is_valid_repository(path):
420 420 """
421 421 Check if given `path` contains a valid repository of this backend
422 422 """
423 423 raise NotImplementedError
424 424
425 425 # ==========================================================================
426 426 # COMMITS
427 427 # ==========================================================================
428 428
429 429 @CachedProperty
430 430 def commit_ids(self):
431 431 raise NotImplementedError
432 432
433 433 def append_commit_id(self, commit_id):
434 434 if commit_id not in self.commit_ids:
435 435 self._rebuild_cache(self.commit_ids + [commit_id])
436 436
437 437 # clear cache
438 438 self._invalidate_prop_cache('commit_ids')
439 439 self._is_empty = False
440 440
441 441 def get_commit(self, commit_id=None, commit_idx=None, pre_load=None,
442 442 translate_tag=None, maybe_unreachable=False):
443 443 """
444 444 Returns instance of `BaseCommit` class. If `commit_id` and `commit_idx`
445 445 are both None, most recent commit is returned.
446 446
447 447 :param pre_load: Optional. List of commit attributes to load.
448 448
449 449 :raises ``EmptyRepositoryError``: if there are no commits
450 450 """
451 451 raise NotImplementedError
452 452
453 453 def __iter__(self):
454 454 for commit_id in self.commit_ids:
455 455 yield self.get_commit(commit_id=commit_id)
456 456
457 457 def get_commits(
458 458 self, start_id=None, end_id=None, start_date=None, end_date=None,
459 459 branch_name=None, show_hidden=False, pre_load=None, translate_tags=None):
460 460 """
461 461 Returns iterator of `BaseCommit` objects from start to end
462 462 not inclusive. This should behave just like a list, ie. end is not
463 463 inclusive.
464 464
465 465 :param start_id: None or str, must be a valid commit id
466 466 :param end_id: None or str, must be a valid commit id
467 467 :param start_date:
468 468 :param end_date:
469 469 :param branch_name:
470 470 :param show_hidden:
471 471 :param pre_load:
472 472 :param translate_tags:
473 473 """
474 474 raise NotImplementedError
475 475
476 476 def __getitem__(self, key):
477 477 """
478 478 Allows index based access to the commit objects of this repository.
479 479 """
480 480 pre_load = ["author", "branch", "date", "message", "parents"]
481 481 if isinstance(key, slice):
482 482 return self._get_range(key, pre_load)
483 483 return self.get_commit(commit_idx=key, pre_load=pre_load)
484 484
485 485 def _get_range(self, slice_obj, pre_load):
486 486 for commit_id in self.commit_ids.__getitem__(slice_obj):
487 487 yield self.get_commit(commit_id=commit_id, pre_load=pre_load)
488 488
489 489 def count(self):
490 490 return len(self.commit_ids)
491 491
492 492 def tag(self, name, user, commit_id=None, message=None, date=None, **opts):
493 493 """
494 494 Creates and returns a tag for the given ``commit_id``.
495 495
496 496 :param name: name for new tag
497 497 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
498 498 :param commit_id: commit id for which new tag would be created
499 499 :param message: message of the tag's commit
500 500 :param date: date of tag's commit
501 501
502 502 :raises TagAlreadyExistError: if tag with same name already exists
503 503 """
504 504 raise NotImplementedError
505 505
506 506 def remove_tag(self, name, user, message=None, date=None):
507 507 """
508 508 Removes tag with the given ``name``.
509 509
510 510 :param name: name of the tag to be removed
511 511 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
512 512 :param message: message of the tag's removal commit
513 513 :param date: date of tag's removal commit
514 514
515 515 :raises TagDoesNotExistError: if tag with given name does not exists
516 516 """
517 517 raise NotImplementedError
518 518
519 519 def get_diff(
520 520 self, commit1, commit2, path=None, ignore_whitespace=False,
521 521 context=3, path1=None):
522 522 """
523 523 Returns (git like) *diff*, as plain text. Shows changes introduced by
524 524 `commit2` since `commit1`.
525 525
526 526 :param commit1: Entry point from which diff is shown. Can be
527 527 ``self.EMPTY_COMMIT`` - in this case, patch showing all
528 528 the changes since empty state of the repository until `commit2`
529 529 :param commit2: Until which commit changes should be shown.
530 530 :param path: Can be set to a path of a file to create a diff of that
531 531 file. If `path1` is also set, this value is only associated to
532 532 `commit2`.
533 533 :param ignore_whitespace: If set to ``True``, would not show whitespace
534 534 changes. Defaults to ``False``.
535 535 :param context: How many lines before/after changed lines should be
536 536 shown. Defaults to ``3``.
537 537 :param path1: Can be set to a path to associate with `commit1`. This
538 538 parameter works only for backends which support diff generation for
539 539 different paths. Other backends will raise a `ValueError` if `path1`
540 540 is set and has a different value than `path`.
541 541 :param file_path: filter this diff by given path pattern
542 542 """
543 543 raise NotImplementedError
544 544
545 545 def strip(self, commit_id, branch=None):
546 546 """
547 547 Strip given commit_id from the repository
548 548 """
549 549 raise NotImplementedError
550 550
551 551 def get_common_ancestor(self, commit_id1, commit_id2, repo2):
552 552 """
553 553 Return a latest common ancestor commit if one exists for this repo
554 554 `commit_id1` vs `commit_id2` from `repo2`.
555 555
556 556 :param commit_id1: Commit it from this repository to use as a
557 557 target for the comparison.
558 558 :param commit_id2: Source commit id to use for comparison.
559 559 :param repo2: Source repository to use for comparison.
560 560 """
561 561 raise NotImplementedError
562 562
563 563 def compare(self, commit_id1, commit_id2, repo2, merge, pre_load=None):
564 564 """
565 565 Compare this repository's revision `commit_id1` with `commit_id2`.
566 566
567 567 Returns a tuple(commits, ancestor) that would be merged from
568 568 `commit_id2`. Doing a normal compare (``merge=False``), ``None``
569 569 will be returned as ancestor.
570 570
571 571 :param commit_id1: Commit it from this repository to use as a
572 572 target for the comparison.
573 573 :param commit_id2: Source commit id to use for comparison.
574 574 :param repo2: Source repository to use for comparison.
575 575 :param merge: If set to ``True`` will do a merge compare which also
576 576 returns the common ancestor.
577 577 :param pre_load: Optional. List of commit attributes to load.
578 578 """
579 579 raise NotImplementedError
580 580
581 581 def merge(self, repo_id, workspace_id, target_ref, source_repo, source_ref,
582 582 user_name='', user_email='', message='', dry_run=False,
583 583 use_rebase=False, close_branch=False):
584 584 """
585 585 Merge the revisions specified in `source_ref` from `source_repo`
586 586 onto the `target_ref` of this repository.
587 587
588 588 `source_ref` and `target_ref` are named tupls with the following
589 589 fields `type`, `name` and `commit_id`.
590 590
591 591 Returns a MergeResponse named tuple with the following fields
592 592 'possible', 'executed', 'source_commit', 'target_commit',
593 593 'merge_commit'.
594 594
595 595 :param repo_id: `repo_id` target repo id.
596 596 :param workspace_id: `workspace_id` unique identifier.
597 597 :param target_ref: `target_ref` points to the commit on top of which
598 598 the `source_ref` should be merged.
599 599 :param source_repo: The repository that contains the commits to be
600 600 merged.
601 601 :param source_ref: `source_ref` points to the topmost commit from
602 602 the `source_repo` which should be merged.
603 603 :param user_name: Merge commit `user_name`.
604 604 :param user_email: Merge commit `user_email`.
605 605 :param message: Merge commit `message`.
606 606 :param dry_run: If `True` the merge will not take place.
607 607 :param use_rebase: If `True` commits from the source will be rebased
608 608 on top of the target instead of being merged.
609 609 :param close_branch: If `True` branch will be close before merging it
610 610 """
611 611 if dry_run:
612 612 message = message or settings.MERGE_DRY_RUN_MESSAGE
613 613 user_email = user_email or settings.MERGE_DRY_RUN_EMAIL
614 614 user_name = user_name or settings.MERGE_DRY_RUN_USER
615 615 else:
616 616 if not user_name:
617 617 raise ValueError('user_name cannot be empty')
618 618 if not user_email:
619 619 raise ValueError('user_email cannot be empty')
620 620 if not message:
621 621 raise ValueError('message cannot be empty')
622 622
623 623 try:
624 624 return self._merge_repo(
625 625 repo_id, workspace_id, target_ref, source_repo,
626 626 source_ref, message, user_name, user_email, dry_run=dry_run,
627 627 use_rebase=use_rebase, close_branch=close_branch)
628 628 except RepositoryError as exc:
629 629 log.exception('Unexpected failure when running merge, dry-run=%s', dry_run)
630 630 return MergeResponse(
631 631 False, False, None, MergeFailureReason.UNKNOWN,
632 632 metadata={'exception': str(exc)})
633 633
634 634 def _merge_repo(self, repo_id, workspace_id, target_ref,
635 635 source_repo, source_ref, merge_message,
636 636 merger_name, merger_email, dry_run=False,
637 637 use_rebase=False, close_branch=False):
638 638 """Internal implementation of merge."""
639 639 raise NotImplementedError
640 640
641 641 def _maybe_prepare_merge_workspace(
642 642 self, repo_id, workspace_id, target_ref, source_ref):
643 643 """
644 644 Create the merge workspace.
645 645
646 646 :param workspace_id: `workspace_id` unique identifier.
647 647 """
648 648 raise NotImplementedError
649 649
650 650 @classmethod
651 651 def _get_legacy_shadow_repository_path(cls, repo_path, workspace_id):
652 652 """
653 653 Legacy version that was used before. We still need it for
654 654 backward compat
655 655 """
656 656 return os.path.join(
657 657 os.path.dirname(repo_path),
658 658 '.__shadow_%s_%s' % (os.path.basename(repo_path), workspace_id))
659 659
660 660 @classmethod
661 661 def _get_shadow_repository_path(cls, repo_path, repo_id, workspace_id):
662 662 # The name of the shadow repository must start with '.', so it is
663 663 # skipped by 'rhodecode.lib.utils.get_filesystem_repos'.
664 664 legacy_repository_path = cls._get_legacy_shadow_repository_path(repo_path, workspace_id)
665 665 if os.path.exists(legacy_repository_path):
666 666 return legacy_repository_path
667 667 else:
668 668 return os.path.join(
669 669 os.path.dirname(repo_path),
670 670 '.__shadow_repo_%s_%s' % (repo_id, workspace_id))
671 671
672 672 def cleanup_merge_workspace(self, repo_id, workspace_id):
673 673 """
674 674 Remove merge workspace.
675 675
676 676 This function MUST not fail in case there is no workspace associated to
677 677 the given `workspace_id`.
678 678
679 679 :param workspace_id: `workspace_id` unique identifier.
680 680 """
681 681 shadow_repository_path = self._get_shadow_repository_path(
682 682 self.path, repo_id, workspace_id)
683 683 shadow_repository_path_del = '{}.{}.delete'.format(
684 684 shadow_repository_path, time.time())
685 685
686 686 # move the shadow repo, so it never conflicts with the one used.
687 687 # we use this method because shutil.rmtree had some edge case problems
688 688 # removing symlinked repositories
689 689 if not os.path.isdir(shadow_repository_path):
690 690 return
691 691
692 692 shutil.move(shadow_repository_path, shadow_repository_path_del)
693 693 try:
694 694 shutil.rmtree(shadow_repository_path_del, ignore_errors=False)
695 695 except Exception:
696 696 log.exception('Failed to gracefully remove shadow repo under %s',
697 697 shadow_repository_path_del)
698 698 shutil.rmtree(shadow_repository_path_del, ignore_errors=True)
699 699
700 700 # ========== #
701 701 # COMMIT API #
702 702 # ========== #
703 703
704 704 @LazyProperty
705 705 def in_memory_commit(self):
706 706 """
707 707 Returns :class:`InMemoryCommit` object for this repository.
708 708 """
709 709 raise NotImplementedError
710 710
711 711 # ======================== #
712 712 # UTILITIES FOR SUBCLASSES #
713 713 # ======================== #
714 714
715 715 def _validate_diff_commits(self, commit1, commit2):
716 716 """
717 717 Validates that the given commits are related to this repository.
718 718
719 719 Intended as a utility for sub classes to have a consistent validation
720 720 of input parameters in methods like :meth:`get_diff`.
721 721 """
722 722 self._validate_commit(commit1)
723 723 self._validate_commit(commit2)
724 724 if (isinstance(commit1, EmptyCommit) and
725 725 isinstance(commit2, EmptyCommit)):
726 726 raise ValueError("Cannot compare two empty commits")
727 727
728 728 def _validate_commit(self, commit):
729 729 if not isinstance(commit, BaseCommit):
730 730 raise TypeError(
731 731 "%s is not of type BaseCommit" % repr(commit))
732 732 if commit.repository != self and not isinstance(commit, EmptyCommit):
733 733 raise ValueError(
734 734 "Commit %s must be a valid commit from this repository %s, "
735 735 "related to this repository instead %s." %
736 736 (commit, self, commit.repository))
737 737
738 738 def _validate_commit_id(self, commit_id):
739 739 if not isinstance(commit_id, compat.string_types):
740 740 raise TypeError("commit_id must be a string value got {} instead".format(type(commit_id)))
741 741
742 742 def _validate_commit_idx(self, commit_idx):
743 743 if not isinstance(commit_idx, (int, long)):
744 744 raise TypeError("commit_idx must be a numeric value")
745 745
746 746 def _validate_branch_name(self, branch_name):
747 747 if branch_name and branch_name not in self.branches_all:
748 748 msg = ("Branch %s not found in %s" % (branch_name, self))
749 749 raise BranchDoesNotExistError(msg)
750 750
751 751 #
752 752 # Supporting deprecated API parts
753 753 # TODO: johbo: consider to move this into a mixin
754 754 #
755 755
756 756 @property
757 757 def EMPTY_CHANGESET(self):
758 758 warnings.warn(
759 759 "Use EMPTY_COMMIT or EMPTY_COMMIT_ID instead", DeprecationWarning)
760 760 return self.EMPTY_COMMIT_ID
761 761
762 762 @property
763 763 def revisions(self):
764 764 warnings.warn("Use commits attribute instead", DeprecationWarning)
765 765 return self.commit_ids
766 766
767 767 @revisions.setter
768 768 def revisions(self, value):
769 769 warnings.warn("Use commits attribute instead", DeprecationWarning)
770 770 self.commit_ids = value
771 771
772 772 def get_changeset(self, revision=None, pre_load=None):
773 773 warnings.warn("Use get_commit instead", DeprecationWarning)
774 774 commit_id = None
775 775 commit_idx = None
776 776 if isinstance(revision, compat.string_types):
777 777 commit_id = revision
778 778 else:
779 779 commit_idx = revision
780 780 return self.get_commit(
781 781 commit_id=commit_id, commit_idx=commit_idx, pre_load=pre_load)
782 782
783 783 def get_changesets(
784 784 self, start=None, end=None, start_date=None, end_date=None,
785 785 branch_name=None, pre_load=None):
786 786 warnings.warn("Use get_commits instead", DeprecationWarning)
787 787 start_id = self._revision_to_commit(start)
788 788 end_id = self._revision_to_commit(end)
789 789 return self.get_commits(
790 790 start_id=start_id, end_id=end_id, start_date=start_date,
791 791 end_date=end_date, branch_name=branch_name, pre_load=pre_load)
792 792
793 793 def _revision_to_commit(self, revision):
794 794 """
795 795 Translates a revision to a commit_id
796 796
797 797 Helps to support the old changeset based API which allows to use
798 798 commit ids and commit indices interchangeable.
799 799 """
800 800 if revision is None:
801 801 return revision
802 802
803 803 if isinstance(revision, compat.string_types):
804 804 commit_id = revision
805 805 else:
806 806 commit_id = self.commit_ids[revision]
807 807 return commit_id
808 808
809 809 @property
810 810 def in_memory_changeset(self):
811 811 warnings.warn("Use in_memory_commit instead", DeprecationWarning)
812 812 return self.in_memory_commit
813 813
814 814 def get_path_permissions(self, username):
815 815 """
816 816 Returns a path permission checker or None if not supported
817 817
818 818 :param username: session user name
819 819 :return: an instance of BasePathPermissionChecker or None
820 820 """
821 821 return None
822 822
823 823 def install_hooks(self, force=False):
824 824 return self._remote.install_hooks(force)
825 825
826 826 def get_hooks_info(self):
827 827 return self._remote.get_hooks_info()
828 828
829 829
830 830 class BaseCommit(object):
831 831 """
832 832 Each backend should implement it's commit representation.
833 833
834 834 **Attributes**
835 835
836 836 ``repository``
837 837 repository object within which commit exists
838 838
839 839 ``id``
840 840 The commit id, may be ``raw_id`` or i.e. for mercurial's tip
841 841 just ``tip``.
842 842
843 843 ``raw_id``
844 844 raw commit representation (i.e. full 40 length sha for git
845 845 backend)
846 846
847 847 ``short_id``
848 848 shortened (if apply) version of ``raw_id``; it would be simple
849 849 shortcut for ``raw_id[:12]`` for git/mercurial backends or same
850 850 as ``raw_id`` for subversion
851 851
852 852 ``idx``
853 853 commit index
854 854
855 855 ``files``
856 856 list of ``FileNode`` (``Node`` with NodeKind.FILE) objects
857 857
858 858 ``dirs``
859 859 list of ``DirNode`` (``Node`` with NodeKind.DIR) objects
860 860
861 861 ``nodes``
862 862 combined list of ``Node`` objects
863 863
864 864 ``author``
865 865 author of the commit, as unicode
866 866
867 867 ``message``
868 868 message of the commit, as unicode
869 869
870 870 ``parents``
871 871 list of parent commits
872 872
873 873 """
874 874
875 875 branch = None
876 876 """
877 877 Depending on the backend this should be set to the branch name of the
878 878 commit. Backends not supporting branches on commits should leave this
879 879 value as ``None``.
880 880 """
881 881
882 882 _ARCHIVE_PREFIX_TEMPLATE = b'{repo_name}-{short_id}'
883 883 """
884 884 This template is used to generate a default prefix for repository archives
885 885 if no prefix has been specified.
886 886 """
887 887
888 888 def __str__(self):
889 889 return '<%s at %s:%s>' % (
890 890 self.__class__.__name__, self.idx, self.short_id)
891 891
892 892 def __repr__(self):
893 893 return self.__str__()
894 894
895 895 def __unicode__(self):
896 896 return u'%s:%s' % (self.idx, self.short_id)
897 897
898 898 def __eq__(self, other):
899 899 same_instance = isinstance(other, self.__class__)
900 900 return same_instance and self.raw_id == other.raw_id
901 901
902 902 def __json__(self):
903 903 parents = []
904 904 try:
905 905 for parent in self.parents:
906 906 parents.append({'raw_id': parent.raw_id})
907 907 except NotImplementedError:
908 908 # empty commit doesn't have parents implemented
909 909 pass
910 910
911 911 return {
912 912 'short_id': self.short_id,
913 913 'raw_id': self.raw_id,
914 914 'revision': self.idx,
915 915 'message': self.message,
916 916 'date': self.date,
917 917 'author': self.author,
918 918 'parents': parents,
919 919 'branch': self.branch
920 920 }
921 921
922 922 def __getstate__(self):
923 923 d = self.__dict__.copy()
924 924 d.pop('_remote', None)
925 925 d.pop('repository', None)
926 926 return d
927 927
928 def serialize(self):
929 return self.__json__()
930
928 931 def _get_refs(self):
929 932 return {
930 933 'branches': [self.branch] if self.branch else [],
931 934 'bookmarks': getattr(self, 'bookmarks', []),
932 935 'tags': self.tags
933 936 }
934 937
935 938 @LazyProperty
936 939 def last(self):
937 940 """
938 941 ``True`` if this is last commit in repository, ``False``
939 942 otherwise; trying to access this attribute while there is no
940 943 commits would raise `EmptyRepositoryError`
941 944 """
942 945 if self.repository is None:
943 946 raise CommitError("Cannot check if it's most recent commit")
944 947 return self.raw_id == self.repository.commit_ids[-1]
945 948
946 949 @LazyProperty
947 950 def parents(self):
948 951 """
949 952 Returns list of parent commits.
950 953 """
951 954 raise NotImplementedError
952 955
953 956 @LazyProperty
954 957 def first_parent(self):
955 958 """
956 959 Returns list of parent commits.
957 960 """
958 961 return self.parents[0] if self.parents else EmptyCommit()
959 962
960 963 @property
961 964 def merge(self):
962 965 """
963 966 Returns boolean if commit is a merge.
964 967 """
965 968 return len(self.parents) > 1
966 969
967 970 @LazyProperty
968 971 def children(self):
969 972 """
970 973 Returns list of child commits.
971 974 """
972 975 raise NotImplementedError
973 976
974 977 @LazyProperty
975 978 def id(self):
976 979 """
977 980 Returns string identifying this commit.
978 981 """
979 982 raise NotImplementedError
980 983
981 984 @LazyProperty
982 985 def raw_id(self):
983 986 """
984 987 Returns raw string identifying this commit.
985 988 """
986 989 raise NotImplementedError
987 990
988 991 @LazyProperty
989 992 def short_id(self):
990 993 """
991 994 Returns shortened version of ``raw_id`` attribute, as string,
992 995 identifying this commit, useful for presentation to users.
993 996 """
994 997 raise NotImplementedError
995 998
996 999 @LazyProperty
997 1000 def idx(self):
998 1001 """
999 1002 Returns integer identifying this commit.
1000 1003 """
1001 1004 raise NotImplementedError
1002 1005
1003 1006 @LazyProperty
1004 1007 def committer(self):
1005 1008 """
1006 1009 Returns committer for this commit
1007 1010 """
1008 1011 raise NotImplementedError
1009 1012
1010 1013 @LazyProperty
1011 1014 def committer_name(self):
1012 1015 """
1013 1016 Returns committer name for this commit
1014 1017 """
1015 1018
1016 1019 return author_name(self.committer)
1017 1020
1018 1021 @LazyProperty
1019 1022 def committer_email(self):
1020 1023 """
1021 1024 Returns committer email address for this commit
1022 1025 """
1023 1026
1024 1027 return author_email(self.committer)
1025 1028
1026 1029 @LazyProperty
1027 1030 def author(self):
1028 1031 """
1029 1032 Returns author for this commit
1030 1033 """
1031 1034
1032 1035 raise NotImplementedError
1033 1036
1034 1037 @LazyProperty
1035 1038 def author_name(self):
1036 1039 """
1037 1040 Returns author name for this commit
1038 1041 """
1039 1042
1040 1043 return author_name(self.author)
1041 1044
1042 1045 @LazyProperty
1043 1046 def author_email(self):
1044 1047 """
1045 1048 Returns author email address for this commit
1046 1049 """
1047 1050
1048 1051 return author_email(self.author)
1049 1052
1050 1053 def get_file_mode(self, path):
1051 1054 """
1052 1055 Returns stat mode of the file at `path`.
1053 1056 """
1054 1057 raise NotImplementedError
1055 1058
1056 1059 def is_link(self, path):
1057 1060 """
1058 1061 Returns ``True`` if given `path` is a symlink
1059 1062 """
1060 1063 raise NotImplementedError
1061 1064
1062 1065 def is_node_binary(self, path):
1063 1066 """
1064 1067 Returns ``True`` is given path is a binary file
1065 1068 """
1066 1069 raise NotImplementedError
1067 1070
1068 1071 def get_file_content(self, path):
1069 1072 """
1070 1073 Returns content of the file at the given `path`.
1071 1074 """
1072 1075 raise NotImplementedError
1073 1076
1074 1077 def get_file_content_streamed(self, path):
1075 1078 """
1076 1079 returns a streaming response from vcsserver with file content
1077 1080 """
1078 1081 raise NotImplementedError
1079 1082
1080 1083 def get_file_size(self, path):
1081 1084 """
1082 1085 Returns size of the file at the given `path`.
1083 1086 """
1084 1087 raise NotImplementedError
1085 1088
1086 1089 def get_path_commit(self, path, pre_load=None):
1087 1090 """
1088 1091 Returns last commit of the file at the given `path`.
1089 1092
1090 1093 :param pre_load: Optional. List of commit attributes to load.
1091 1094 """
1092 1095 commits = self.get_path_history(path, limit=1, pre_load=pre_load)
1093 1096 if not commits:
1094 1097 raise RepositoryError(
1095 1098 'Failed to fetch history for path {}. '
1096 1099 'Please check if such path exists in your repository'.format(
1097 1100 path))
1098 1101 return commits[0]
1099 1102
1100 1103 def get_path_history(self, path, limit=None, pre_load=None):
1101 1104 """
1102 1105 Returns history of file as reversed list of :class:`BaseCommit`
1103 1106 objects for which file at given `path` has been modified.
1104 1107
1105 1108 :param limit: Optional. Allows to limit the size of the returned
1106 1109 history. This is intended as a hint to the underlying backend, so
1107 1110 that it can apply optimizations depending on the limit.
1108 1111 :param pre_load: Optional. List of commit attributes to load.
1109 1112 """
1110 1113 raise NotImplementedError
1111 1114
1112 1115 def get_file_annotate(self, path, pre_load=None):
1113 1116 """
1114 1117 Returns a generator of four element tuples with
1115 1118 lineno, sha, commit lazy loader and line
1116 1119
1117 1120 :param pre_load: Optional. List of commit attributes to load.
1118 1121 """
1119 1122 raise NotImplementedError
1120 1123
1121 1124 def get_nodes(self, path):
1122 1125 """
1123 1126 Returns combined ``DirNode`` and ``FileNode`` objects list representing
1124 1127 state of commit at the given ``path``.
1125 1128
1126 1129 :raises ``CommitError``: if node at the given ``path`` is not
1127 1130 instance of ``DirNode``
1128 1131 """
1129 1132 raise NotImplementedError
1130 1133
1131 1134 def get_node(self, path):
1132 1135 """
1133 1136 Returns ``Node`` object from the given ``path``.
1134 1137
1135 1138 :raises ``NodeDoesNotExistError``: if there is no node at the given
1136 1139 ``path``
1137 1140 """
1138 1141 raise NotImplementedError
1139 1142
1140 1143 def get_largefile_node(self, path):
1141 1144 """
1142 1145 Returns the path to largefile from Mercurial/Git-lfs storage.
1143 1146 or None if it's not a largefile node
1144 1147 """
1145 1148 return None
1146 1149
1147 1150 def archive_repo(self, archive_dest_path, kind='tgz', subrepos=None,
1148 1151 prefix=None, write_metadata=False, mtime=None, archive_at_path='/'):
1149 1152 """
1150 1153 Creates an archive containing the contents of the repository.
1151 1154
1152 1155 :param archive_dest_path: path to the file which to create the archive.
1153 1156 :param kind: one of following: ``"tbz2"``, ``"tgz"``, ``"zip"``.
1154 1157 :param prefix: name of root directory in archive.
1155 1158 Default is repository name and commit's short_id joined with dash:
1156 1159 ``"{repo_name}-{short_id}"``.
1157 1160 :param write_metadata: write a metadata file into archive.
1158 1161 :param mtime: custom modification time for archive creation, defaults
1159 1162 to time.time() if not given.
1160 1163 :param archive_at_path: pack files at this path (default '/')
1161 1164
1162 1165 :raise VCSError: If prefix has a problem.
1163 1166 """
1164 1167 allowed_kinds = [x[0] for x in settings.ARCHIVE_SPECS]
1165 1168 if kind not in allowed_kinds:
1166 1169 raise ImproperArchiveTypeError(
1167 1170 'Archive kind (%s) not supported use one of %s' %
1168 1171 (kind, allowed_kinds))
1169 1172
1170 1173 prefix = self._validate_archive_prefix(prefix)
1171 1174
1172 1175 mtime = mtime is not None or time.mktime(self.date.timetuple())
1173 1176
1174 1177 file_info = []
1175 1178 cur_rev = self.repository.get_commit(commit_id=self.raw_id)
1176 1179 for _r, _d, files in cur_rev.walk(archive_at_path):
1177 1180 for f in files:
1178 1181 f_path = os.path.join(prefix, f.path)
1179 1182 file_info.append(
1180 1183 (f_path, f.mode, f.is_link(), f.raw_bytes))
1181 1184
1182 1185 if write_metadata:
1183 1186 metadata = [
1184 1187 ('repo_name', self.repository.name),
1185 1188 ('commit_id', self.raw_id),
1186 1189 ('mtime', mtime),
1187 1190 ('branch', self.branch),
1188 1191 ('tags', ','.join(self.tags)),
1189 1192 ]
1190 1193 meta = ["%s:%s" % (f_name, value) for f_name, value in metadata]
1191 1194 file_info.append(('.archival.txt', 0o644, False, '\n'.join(meta)))
1192 1195
1193 1196 connection.Hg.archive_repo(archive_dest_path, mtime, file_info, kind)
1194 1197
1195 1198 def _validate_archive_prefix(self, prefix):
1196 1199 if prefix is None:
1197 1200 prefix = self._ARCHIVE_PREFIX_TEMPLATE.format(
1198 1201 repo_name=safe_str(self.repository.name),
1199 1202 short_id=self.short_id)
1200 1203 elif not isinstance(prefix, str):
1201 1204 raise ValueError("prefix not a bytes object: %s" % repr(prefix))
1202 1205 elif prefix.startswith('/'):
1203 1206 raise VCSError("Prefix cannot start with leading slash")
1204 1207 elif prefix.strip() == '':
1205 1208 raise VCSError("Prefix cannot be empty")
1206 1209 return prefix
1207 1210
1208 1211 @LazyProperty
1209 1212 def root(self):
1210 1213 """
1211 1214 Returns ``RootNode`` object for this commit.
1212 1215 """
1213 1216 return self.get_node('')
1214 1217
1215 1218 def next(self, branch=None):
1216 1219 """
1217 1220 Returns next commit from current, if branch is gives it will return
1218 1221 next commit belonging to this branch
1219 1222
1220 1223 :param branch: show commits within the given named branch
1221 1224 """
1222 1225 indexes = xrange(self.idx + 1, self.repository.count())
1223 1226 return self._find_next(indexes, branch)
1224 1227
1225 1228 def prev(self, branch=None):
1226 1229 """
1227 1230 Returns previous commit from current, if branch is gives it will
1228 1231 return previous commit belonging to this branch
1229 1232
1230 1233 :param branch: show commit within the given named branch
1231 1234 """
1232 1235 indexes = xrange(self.idx - 1, -1, -1)
1233 1236 return self._find_next(indexes, branch)
1234 1237
1235 1238 def _find_next(self, indexes, branch=None):
1236 1239 if branch and self.branch != branch:
1237 1240 raise VCSError('Branch option used on commit not belonging '
1238 1241 'to that branch')
1239 1242
1240 1243 for next_idx in indexes:
1241 1244 commit = self.repository.get_commit(commit_idx=next_idx)
1242 1245 if branch and branch != commit.branch:
1243 1246 continue
1244 1247 return commit
1245 1248 raise CommitDoesNotExistError
1246 1249
1247 1250 def diff(self, ignore_whitespace=True, context=3):
1248 1251 """
1249 1252 Returns a `Diff` object representing the change made by this commit.
1250 1253 """
1251 1254 parent = self.first_parent
1252 1255 diff = self.repository.get_diff(
1253 1256 parent, self,
1254 1257 ignore_whitespace=ignore_whitespace,
1255 1258 context=context)
1256 1259 return diff
1257 1260
1258 1261 @LazyProperty
1259 1262 def added(self):
1260 1263 """
1261 1264 Returns list of added ``FileNode`` objects.
1262 1265 """
1263 1266 raise NotImplementedError
1264 1267
1265 1268 @LazyProperty
1266 1269 def changed(self):
1267 1270 """
1268 1271 Returns list of modified ``FileNode`` objects.
1269 1272 """
1270 1273 raise NotImplementedError
1271 1274
1272 1275 @LazyProperty
1273 1276 def removed(self):
1274 1277 """
1275 1278 Returns list of removed ``FileNode`` objects.
1276 1279 """
1277 1280 raise NotImplementedError
1278 1281
1279 1282 @LazyProperty
1280 1283 def size(self):
1281 1284 """
1282 1285 Returns total number of bytes from contents of all filenodes.
1283 1286 """
1284 1287 return sum((node.size for node in self.get_filenodes_generator()))
1285 1288
1286 1289 def walk(self, topurl=''):
1287 1290 """
1288 1291 Similar to os.walk method. Insted of filesystem it walks through
1289 1292 commit starting at given ``topurl``. Returns generator of tuples
1290 1293 (topnode, dirnodes, filenodes).
1291 1294 """
1292 1295 topnode = self.get_node(topurl)
1293 1296 if not topnode.is_dir():
1294 1297 return
1295 1298 yield (topnode, topnode.dirs, topnode.files)
1296 1299 for dirnode in topnode.dirs:
1297 1300 for tup in self.walk(dirnode.path):
1298 1301 yield tup
1299 1302
1300 1303 def get_filenodes_generator(self):
1301 1304 """
1302 1305 Returns generator that yields *all* file nodes.
1303 1306 """
1304 1307 for topnode, dirs, files in self.walk():
1305 1308 for node in files:
1306 1309 yield node
1307 1310
1308 1311 #
1309 1312 # Utilities for sub classes to support consistent behavior
1310 1313 #
1311 1314
1312 1315 def no_node_at_path(self, path):
1313 1316 return NodeDoesNotExistError(
1314 1317 u"There is no file nor directory at the given path: "
1315 1318 u"`%s` at commit %s" % (safe_unicode(path), self.short_id))
1316 1319
1317 1320 def _fix_path(self, path):
1318 1321 """
1319 1322 Paths are stored without trailing slash so we need to get rid off it if
1320 1323 needed.
1321 1324 """
1322 1325 return path.rstrip('/')
1323 1326
1324 1327 #
1325 1328 # Deprecated API based on changesets
1326 1329 #
1327 1330
1328 1331 @property
1329 1332 def revision(self):
1330 1333 warnings.warn("Use idx instead", DeprecationWarning)
1331 1334 return self.idx
1332 1335
1333 1336 @revision.setter
1334 1337 def revision(self, value):
1335 1338 warnings.warn("Use idx instead", DeprecationWarning)
1336 1339 self.idx = value
1337 1340
1338 1341 def get_file_changeset(self, path):
1339 1342 warnings.warn("Use get_path_commit instead", DeprecationWarning)
1340 1343 return self.get_path_commit(path)
1341 1344
1342 1345
1343 1346 class BaseChangesetClass(type):
1344 1347
1345 1348 def __instancecheck__(self, instance):
1346 1349 return isinstance(instance, BaseCommit)
1347 1350
1348 1351
1349 1352 class BaseChangeset(BaseCommit):
1350 1353
1351 1354 __metaclass__ = BaseChangesetClass
1352 1355
1353 1356 def __new__(cls, *args, **kwargs):
1354 1357 warnings.warn(
1355 1358 "Use BaseCommit instead of BaseChangeset", DeprecationWarning)
1356 1359 return super(BaseChangeset, cls).__new__(cls, *args, **kwargs)
1357 1360
1358 1361
1359 1362 class BaseInMemoryCommit(object):
1360 1363 """
1361 1364 Represents differences between repository's state (most recent head) and
1362 1365 changes made *in place*.
1363 1366
1364 1367 **Attributes**
1365 1368
1366 1369 ``repository``
1367 1370 repository object for this in-memory-commit
1368 1371
1369 1372 ``added``
1370 1373 list of ``FileNode`` objects marked as *added*
1371 1374
1372 1375 ``changed``
1373 1376 list of ``FileNode`` objects marked as *changed*
1374 1377
1375 1378 ``removed``
1376 1379 list of ``FileNode`` or ``RemovedFileNode`` objects marked to be
1377 1380 *removed*
1378 1381
1379 1382 ``parents``
1380 1383 list of :class:`BaseCommit` instances representing parents of
1381 1384 in-memory commit. Should always be 2-element sequence.
1382 1385
1383 1386 """
1384 1387
1385 1388 def __init__(self, repository):
1386 1389 self.repository = repository
1387 1390 self.added = []
1388 1391 self.changed = []
1389 1392 self.removed = []
1390 1393 self.parents = []
1391 1394
1392 1395 def add(self, *filenodes):
1393 1396 """
1394 1397 Marks given ``FileNode`` objects as *to be committed*.
1395 1398
1396 1399 :raises ``NodeAlreadyExistsError``: if node with same path exists at
1397 1400 latest commit
1398 1401 :raises ``NodeAlreadyAddedError``: if node with same path is already
1399 1402 marked as *added*
1400 1403 """
1401 1404 # Check if not already marked as *added* first
1402 1405 for node in filenodes:
1403 1406 if node.path in (n.path for n in self.added):
1404 1407 raise NodeAlreadyAddedError(
1405 1408 "Such FileNode %s is already marked for addition"
1406 1409 % node.path)
1407 1410 for node in filenodes:
1408 1411 self.added.append(node)
1409 1412
1410 1413 def change(self, *filenodes):
1411 1414 """
1412 1415 Marks given ``FileNode`` objects to be *changed* in next commit.
1413 1416
1414 1417 :raises ``EmptyRepositoryError``: if there are no commits yet
1415 1418 :raises ``NodeAlreadyExistsError``: if node with same path is already
1416 1419 marked to be *changed*
1417 1420 :raises ``NodeAlreadyRemovedError``: if node with same path is already
1418 1421 marked to be *removed*
1419 1422 :raises ``NodeDoesNotExistError``: if node doesn't exist in latest
1420 1423 commit
1421 1424 :raises ``NodeNotChangedError``: if node hasn't really be changed
1422 1425 """
1423 1426 for node in filenodes:
1424 1427 if node.path in (n.path for n in self.removed):
1425 1428 raise NodeAlreadyRemovedError(
1426 1429 "Node at %s is already marked as removed" % node.path)
1427 1430 try:
1428 1431 self.repository.get_commit()
1429 1432 except EmptyRepositoryError:
1430 1433 raise EmptyRepositoryError(
1431 1434 "Nothing to change - try to *add* new nodes rather than "
1432 1435 "changing them")
1433 1436 for node in filenodes:
1434 1437 if node.path in (n.path for n in self.changed):
1435 1438 raise NodeAlreadyChangedError(
1436 1439 "Node at '%s' is already marked as changed" % node.path)
1437 1440 self.changed.append(node)
1438 1441
1439 1442 def remove(self, *filenodes):
1440 1443 """
1441 1444 Marks given ``FileNode`` (or ``RemovedFileNode``) objects to be
1442 1445 *removed* in next commit.
1443 1446
1444 1447 :raises ``NodeAlreadyRemovedError``: if node has been already marked to
1445 1448 be *removed*
1446 1449 :raises ``NodeAlreadyChangedError``: if node has been already marked to
1447 1450 be *changed*
1448 1451 """
1449 1452 for node in filenodes:
1450 1453 if node.path in (n.path for n in self.removed):
1451 1454 raise NodeAlreadyRemovedError(
1452 1455 "Node is already marked to for removal at %s" % node.path)
1453 1456 if node.path in (n.path for n in self.changed):
1454 1457 raise NodeAlreadyChangedError(
1455 1458 "Node is already marked to be changed at %s" % node.path)
1456 1459 # We only mark node as *removed* - real removal is done by
1457 1460 # commit method
1458 1461 self.removed.append(node)
1459 1462
1460 1463 def reset(self):
1461 1464 """
1462 1465 Resets this instance to initial state (cleans ``added``, ``changed``
1463 1466 and ``removed`` lists).
1464 1467 """
1465 1468 self.added = []
1466 1469 self.changed = []
1467 1470 self.removed = []
1468 1471 self.parents = []
1469 1472
1470 1473 def get_ipaths(self):
1471 1474 """
1472 1475 Returns generator of paths from nodes marked as added, changed or
1473 1476 removed.
1474 1477 """
1475 1478 for node in itertools.chain(self.added, self.changed, self.removed):
1476 1479 yield node.path
1477 1480
1478 1481 def get_paths(self):
1479 1482 """
1480 1483 Returns list of paths from nodes marked as added, changed or removed.
1481 1484 """
1482 1485 return list(self.get_ipaths())
1483 1486
1484 1487 def check_integrity(self, parents=None):
1485 1488 """
1486 1489 Checks in-memory commit's integrity. Also, sets parents if not
1487 1490 already set.
1488 1491
1489 1492 :raises CommitError: if any error occurs (i.e.
1490 1493 ``NodeDoesNotExistError``).
1491 1494 """
1492 1495 if not self.parents:
1493 1496 parents = parents or []
1494 1497 if len(parents) == 0:
1495 1498 try:
1496 1499 parents = [self.repository.get_commit(), None]
1497 1500 except EmptyRepositoryError:
1498 1501 parents = [None, None]
1499 1502 elif len(parents) == 1:
1500 1503 parents += [None]
1501 1504 self.parents = parents
1502 1505
1503 1506 # Local parents, only if not None
1504 1507 parents = [p for p in self.parents if p]
1505 1508
1506 1509 # Check nodes marked as added
1507 1510 for p in parents:
1508 1511 for node in self.added:
1509 1512 try:
1510 1513 p.get_node(node.path)
1511 1514 except NodeDoesNotExistError:
1512 1515 pass
1513 1516 else:
1514 1517 raise NodeAlreadyExistsError(
1515 1518 "Node `%s` already exists at %s" % (node.path, p))
1516 1519
1517 1520 # Check nodes marked as changed
1518 1521 missing = set(self.changed)
1519 1522 not_changed = set(self.changed)
1520 1523 if self.changed and not parents:
1521 1524 raise NodeDoesNotExistError(str(self.changed[0].path))
1522 1525 for p in parents:
1523 1526 for node in self.changed:
1524 1527 try:
1525 1528 old = p.get_node(node.path)
1526 1529 missing.remove(node)
1527 1530 # if content actually changed, remove node from not_changed
1528 1531 if old.content != node.content:
1529 1532 not_changed.remove(node)
1530 1533 except NodeDoesNotExistError:
1531 1534 pass
1532 1535 if self.changed and missing:
1533 1536 raise NodeDoesNotExistError(
1534 1537 "Node `%s` marked as modified but missing in parents: %s"
1535 1538 % (node.path, parents))
1536 1539
1537 1540 if self.changed and not_changed:
1538 1541 raise NodeNotChangedError(
1539 1542 "Node `%s` wasn't actually changed (parents: %s)"
1540 1543 % (not_changed.pop().path, parents))
1541 1544
1542 1545 # Check nodes marked as removed
1543 1546 if self.removed and not parents:
1544 1547 raise NodeDoesNotExistError(
1545 1548 "Cannot remove node at %s as there "
1546 1549 "were no parents specified" % self.removed[0].path)
1547 1550 really_removed = set()
1548 1551 for p in parents:
1549 1552 for node in self.removed:
1550 1553 try:
1551 1554 p.get_node(node.path)
1552 1555 really_removed.add(node)
1553 1556 except CommitError:
1554 1557 pass
1555 1558 not_removed = set(self.removed) - really_removed
1556 1559 if not_removed:
1557 1560 # TODO: johbo: This code branch does not seem to be covered
1558 1561 raise NodeDoesNotExistError(
1559 1562 "Cannot remove node at %s from "
1560 1563 "following parents: %s" % (not_removed, parents))
1561 1564
1562 1565 def commit(self, message, author, parents=None, branch=None, date=None, **kwargs):
1563 1566 """
1564 1567 Performs in-memory commit (doesn't check workdir in any way) and
1565 1568 returns newly created :class:`BaseCommit`. Updates repository's
1566 1569 attribute `commits`.
1567 1570
1568 1571 .. note::
1569 1572
1570 1573 While overriding this method each backend's should call
1571 1574 ``self.check_integrity(parents)`` in the first place.
1572 1575
1573 1576 :param message: message of the commit
1574 1577 :param author: full username, i.e. "Joe Doe <joe.doe@example.com>"
1575 1578 :param parents: single parent or sequence of parents from which commit
1576 1579 would be derived
1577 1580 :param date: ``datetime.datetime`` instance. Defaults to
1578 1581 ``datetime.datetime.now()``.
1579 1582 :param branch: branch name, as string. If none given, default backend's
1580 1583 branch would be used.
1581 1584
1582 1585 :raises ``CommitError``: if any error occurs while committing
1583 1586 """
1584 1587 raise NotImplementedError
1585 1588
1586 1589
1587 1590 class BaseInMemoryChangesetClass(type):
1588 1591
1589 1592 def __instancecheck__(self, instance):
1590 1593 return isinstance(instance, BaseInMemoryCommit)
1591 1594
1592 1595
1593 1596 class BaseInMemoryChangeset(BaseInMemoryCommit):
1594 1597
1595 1598 __metaclass__ = BaseInMemoryChangesetClass
1596 1599
1597 1600 def __new__(cls, *args, **kwargs):
1598 1601 warnings.warn(
1599 1602 "Use BaseCommit instead of BaseInMemoryCommit", DeprecationWarning)
1600 1603 return super(BaseInMemoryChangeset, cls).__new__(cls, *args, **kwargs)
1601 1604
1602 1605
1603 1606 class EmptyCommit(BaseCommit):
1604 1607 """
1605 1608 An dummy empty commit. It's possible to pass hash when creating
1606 1609 an EmptyCommit
1607 1610 """
1608 1611
1609 1612 def __init__(
1610 1613 self, commit_id=EMPTY_COMMIT_ID, repo=None, alias=None, idx=-1,
1611 1614 message='', author='', date=None):
1612 1615 self._empty_commit_id = commit_id
1613 1616 # TODO: johbo: Solve idx parameter, default value does not make
1614 1617 # too much sense
1615 1618 self.idx = idx
1616 1619 self.message = message
1617 1620 self.author = author
1618 1621 self.date = date or datetime.datetime.fromtimestamp(0)
1619 1622 self.repository = repo
1620 1623 self.alias = alias
1621 1624
1622 1625 @LazyProperty
1623 1626 def raw_id(self):
1624 1627 """
1625 1628 Returns raw string identifying this commit, useful for web
1626 1629 representation.
1627 1630 """
1628 1631
1629 1632 return self._empty_commit_id
1630 1633
1631 1634 @LazyProperty
1632 1635 def branch(self):
1633 1636 if self.alias:
1634 1637 from rhodecode.lib.vcs.backends import get_backend
1635 1638 return get_backend(self.alias).DEFAULT_BRANCH_NAME
1636 1639
1637 1640 @LazyProperty
1638 1641 def short_id(self):
1639 1642 return self.raw_id[:12]
1640 1643
1641 1644 @LazyProperty
1642 1645 def id(self):
1643 1646 return self.raw_id
1644 1647
1645 1648 def get_path_commit(self, path):
1646 1649 return self
1647 1650
1648 1651 def get_file_content(self, path):
1649 1652 return u''
1650 1653
1651 1654 def get_file_content_streamed(self, path):
1652 1655 yield self.get_file_content()
1653 1656
1654 1657 def get_file_size(self, path):
1655 1658 return 0
1656 1659
1657 1660
1658 1661 class EmptyChangesetClass(type):
1659 1662
1660 1663 def __instancecheck__(self, instance):
1661 1664 return isinstance(instance, EmptyCommit)
1662 1665
1663 1666
1664 1667 class EmptyChangeset(EmptyCommit):
1665 1668
1666 1669 __metaclass__ = EmptyChangesetClass
1667 1670
1668 1671 def __new__(cls, *args, **kwargs):
1669 1672 warnings.warn(
1670 1673 "Use EmptyCommit instead of EmptyChangeset", DeprecationWarning)
1671 1674 return super(EmptyCommit, cls).__new__(cls, *args, **kwargs)
1672 1675
1673 1676 def __init__(self, cs=EMPTY_COMMIT_ID, repo=None, requested_revision=None,
1674 1677 alias=None, revision=-1, message='', author='', date=None):
1675 1678 if requested_revision is not None:
1676 1679 warnings.warn(
1677 1680 "Parameter requested_revision not supported anymore",
1678 1681 DeprecationWarning)
1679 1682 super(EmptyChangeset, self).__init__(
1680 1683 commit_id=cs, repo=repo, alias=alias, idx=revision,
1681 1684 message=message, author=author, date=date)
1682 1685
1683 1686 @property
1684 1687 def revision(self):
1685 1688 warnings.warn("Use idx instead", DeprecationWarning)
1686 1689 return self.idx
1687 1690
1688 1691 @revision.setter
1689 1692 def revision(self, value):
1690 1693 warnings.warn("Use idx instead", DeprecationWarning)
1691 1694 self.idx = value
1692 1695
1693 1696
1694 1697 class EmptyRepository(BaseRepository):
1695 1698 def __init__(self, repo_path=None, config=None, create=False, **kwargs):
1696 1699 pass
1697 1700
1698 1701 def get_diff(self, *args, **kwargs):
1699 1702 from rhodecode.lib.vcs.backends.git.diff import GitDiff
1700 1703 return GitDiff('')
1701 1704
1702 1705
1703 1706 class CollectionGenerator(object):
1704 1707
1705 1708 def __init__(self, repo, commit_ids, collection_size=None, pre_load=None, translate_tag=None):
1706 1709 self.repo = repo
1707 1710 self.commit_ids = commit_ids
1708 1711 # TODO: (oliver) this isn't currently hooked up
1709 1712 self.collection_size = None
1710 1713 self.pre_load = pre_load
1711 1714 self.translate_tag = translate_tag
1712 1715
1713 1716 def __len__(self):
1714 1717 if self.collection_size is not None:
1715 1718 return self.collection_size
1716 1719 return self.commit_ids.__len__()
1717 1720
1718 1721 def __iter__(self):
1719 1722 for commit_id in self.commit_ids:
1720 1723 # TODO: johbo: Mercurial passes in commit indices or commit ids
1721 1724 yield self._commit_factory(commit_id)
1722 1725
1723 1726 def _commit_factory(self, commit_id):
1724 1727 """
1725 1728 Allows backends to override the way commits are generated.
1726 1729 """
1727 1730 return self.repo.get_commit(
1728 1731 commit_id=commit_id, pre_load=self.pre_load,
1729 1732 translate_tag=self.translate_tag)
1730 1733
1731 1734 def __getslice__(self, i, j):
1732 1735 """
1733 1736 Returns an iterator of sliced repository
1734 1737 """
1735 1738 commit_ids = self.commit_ids[i:j]
1736 1739 return self.__class__(
1737 1740 self.repo, commit_ids, pre_load=self.pre_load,
1738 1741 translate_tag=self.translate_tag)
1739 1742
1740 1743 def __repr__(self):
1741 1744 return '<CollectionGenerator[len:%s]>' % (self.__len__())
1742 1745
1743 1746
1744 1747 class Config(object):
1745 1748 """
1746 1749 Represents the configuration for a repository.
1747 1750
1748 1751 The API is inspired by :class:`ConfigParser.ConfigParser` from the
1749 1752 standard library. It implements only the needed subset.
1750 1753 """
1751 1754
1752 1755 def __init__(self):
1753 1756 self._values = {}
1754 1757
1755 1758 def copy(self):
1756 1759 clone = Config()
1757 1760 for section, values in self._values.items():
1758 1761 clone._values[section] = values.copy()
1759 1762 return clone
1760 1763
1761 1764 def __repr__(self):
1762 1765 return '<Config(%s sections) at %s>' % (
1763 1766 len(self._values), hex(id(self)))
1764 1767
1765 1768 def items(self, section):
1766 1769 return self._values.get(section, {}).iteritems()
1767 1770
1768 1771 def get(self, section, option):
1769 1772 return self._values.get(section, {}).get(option)
1770 1773
1771 1774 def set(self, section, option, value):
1772 1775 section_values = self._values.setdefault(section, {})
1773 1776 section_values[option] = value
1774 1777
1775 1778 def clear_section(self, section):
1776 1779 self._values[section] = {}
1777 1780
1778 1781 def serialize(self):
1779 1782 """
1780 1783 Creates a list of three tuples (section, key, value) representing
1781 1784 this config object.
1782 1785 """
1783 1786 items = []
1784 1787 for section in self._values:
1785 1788 for option, value in self._values[section].items():
1786 1789 items.append(
1787 1790 (safe_str(section), safe_str(option), safe_str(value)))
1788 1791 return items
1789 1792
1790 1793
1791 1794 class Diff(object):
1792 1795 """
1793 1796 Represents a diff result from a repository backend.
1794 1797
1795 1798 Subclasses have to provide a backend specific value for
1796 1799 :attr:`_header_re` and :attr:`_meta_re`.
1797 1800 """
1798 1801 _meta_re = None
1799 1802 _header_re = None
1800 1803
1801 1804 def __init__(self, raw_diff):
1802 1805 self.raw = raw_diff
1803 1806
1804 1807 def chunks(self):
1805 1808 """
1806 1809 split the diff in chunks of separate --git a/file b/file chunks
1807 1810 to make diffs consistent we must prepend with \n, and make sure
1808 1811 we can detect last chunk as this was also has special rule
1809 1812 """
1810 1813
1811 1814 diff_parts = ('\n' + self.raw).split('\ndiff --git')
1812 1815 header = diff_parts[0]
1813 1816
1814 1817 if self._meta_re:
1815 1818 match = self._meta_re.match(header)
1816 1819
1817 1820 chunks = diff_parts[1:]
1818 1821 total_chunks = len(chunks)
1819 1822
1820 1823 return (
1821 1824 DiffChunk(chunk, self, cur_chunk == total_chunks)
1822 1825 for cur_chunk, chunk in enumerate(chunks, start=1))
1823 1826
1824 1827
1825 1828 class DiffChunk(object):
1826 1829
1827 1830 def __init__(self, chunk, diff, last_chunk):
1828 1831 self._diff = diff
1829 1832
1830 1833 # since we split by \ndiff --git that part is lost from original diff
1831 1834 # we need to re-apply it at the end, EXCEPT ! if it's last chunk
1832 1835 if not last_chunk:
1833 1836 chunk += '\n'
1834 1837
1835 1838 match = self._diff._header_re.match(chunk)
1836 1839 self.header = match.groupdict()
1837 1840 self.diff = chunk[match.end():]
1838 1841 self.raw = chunk
1839 1842
1840 1843
1841 1844 class BasePathPermissionChecker(object):
1842 1845
1843 1846 @staticmethod
1844 1847 def create_from_patterns(includes, excludes):
1845 1848 if includes and '*' in includes and not excludes:
1846 1849 return AllPathPermissionChecker()
1847 1850 elif excludes and '*' in excludes:
1848 1851 return NonePathPermissionChecker()
1849 1852 else:
1850 1853 return PatternPathPermissionChecker(includes, excludes)
1851 1854
1852 1855 @property
1853 1856 def has_full_access(self):
1854 1857 raise NotImplemented()
1855 1858
1856 1859 def has_access(self, path):
1857 1860 raise NotImplemented()
1858 1861
1859 1862
1860 1863 class AllPathPermissionChecker(BasePathPermissionChecker):
1861 1864
1862 1865 @property
1863 1866 def has_full_access(self):
1864 1867 return True
1865 1868
1866 1869 def has_access(self, path):
1867 1870 return True
1868 1871
1869 1872
1870 1873 class NonePathPermissionChecker(BasePathPermissionChecker):
1871 1874
1872 1875 @property
1873 1876 def has_full_access(self):
1874 1877 return False
1875 1878
1876 1879 def has_access(self, path):
1877 1880 return False
1878 1881
1879 1882
1880 1883 class PatternPathPermissionChecker(BasePathPermissionChecker):
1881 1884
1882 1885 def __init__(self, includes, excludes):
1883 1886 self.includes = includes
1884 1887 self.excludes = excludes
1885 1888 self.includes_re = [] if not includes else [
1886 1889 re.compile(fnmatch.translate(pattern)) for pattern in includes]
1887 1890 self.excludes_re = [] if not excludes else [
1888 1891 re.compile(fnmatch.translate(pattern)) for pattern in excludes]
1889 1892
1890 1893 @property
1891 1894 def has_full_access(self):
1892 1895 return '*' in self.includes and not self.excludes
1893 1896
1894 1897 def has_access(self, path):
1895 1898 for regex in self.excludes_re:
1896 1899 if regex.match(path):
1897 1900 return False
1898 1901 for regex in self.includes_re:
1899 1902 if regex.match(path):
1900 1903 return True
1901 1904 return False
@@ -1,754 +1,774 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 comments model for RhodeCode
23 23 """
24 24
25 25 import logging
26 26 import traceback
27 27 import collections
28 28
29 29 from pyramid.threadlocal import get_current_registry, get_current_request
30 30 from sqlalchemy.sql.expression import null
31 31 from sqlalchemy.sql.functions import coalesce
32 32
33 from rhodecode.lib import helpers as h, diffs, channelstream
33 from rhodecode.lib import helpers as h, diffs, channelstream, hooks_utils
34 34 from rhodecode.lib import audit_logger
35 35 from rhodecode.lib.utils2 import extract_mentioned_users, safe_str
36 36 from rhodecode.model import BaseModel
37 37 from rhodecode.model.db import (
38 38 ChangesetComment, User, Notification, PullRequest, AttributeDict)
39 39 from rhodecode.model.notification import NotificationModel
40 40 from rhodecode.model.meta import Session
41 41 from rhodecode.model.settings import VcsSettingsModel
42 42 from rhodecode.model.notification import EmailNotificationModel
43 43 from rhodecode.model.validation_schema.schemas import comment_schema
44 44
45 45
46 46 log = logging.getLogger(__name__)
47 47
48 48
49 49 class CommentsModel(BaseModel):
50 50
51 51 cls = ChangesetComment
52 52
53 53 DIFF_CONTEXT_BEFORE = 3
54 54 DIFF_CONTEXT_AFTER = 3
55 55
56 56 def __get_commit_comment(self, changeset_comment):
57 57 return self._get_instance(ChangesetComment, changeset_comment)
58 58
59 59 def __get_pull_request(self, pull_request):
60 60 return self._get_instance(PullRequest, pull_request)
61 61
62 62 def _extract_mentions(self, s):
63 63 user_objects = []
64 64 for username in extract_mentioned_users(s):
65 65 user_obj = User.get_by_username(username, case_insensitive=True)
66 66 if user_obj:
67 67 user_objects.append(user_obj)
68 68 return user_objects
69 69
70 70 def _get_renderer(self, global_renderer='rst', request=None):
71 71 request = request or get_current_request()
72 72
73 73 try:
74 74 global_renderer = request.call_context.visual.default_renderer
75 75 except AttributeError:
76 76 log.debug("Renderer not set, falling back "
77 77 "to default renderer '%s'", global_renderer)
78 78 except Exception:
79 79 log.error(traceback.format_exc())
80 80 return global_renderer
81 81
82 82 def aggregate_comments(self, comments, versions, show_version, inline=False):
83 83 # group by versions, and count until, and display objects
84 84
85 85 comment_groups = collections.defaultdict(list)
86 86 [comment_groups[
87 87 _co.pull_request_version_id].append(_co) for _co in comments]
88 88
89 89 def yield_comments(pos):
90 90 for co in comment_groups[pos]:
91 91 yield co
92 92
93 93 comment_versions = collections.defaultdict(
94 94 lambda: collections.defaultdict(list))
95 95 prev_prvid = -1
96 96 # fake last entry with None, to aggregate on "latest" version which
97 97 # doesn't have an pull_request_version_id
98 98 for ver in versions + [AttributeDict({'pull_request_version_id': None})]:
99 99 prvid = ver.pull_request_version_id
100 100 if prev_prvid == -1:
101 101 prev_prvid = prvid
102 102
103 103 for co in yield_comments(prvid):
104 104 comment_versions[prvid]['at'].append(co)
105 105
106 106 # save until
107 107 current = comment_versions[prvid]['at']
108 108 prev_until = comment_versions[prev_prvid]['until']
109 109 cur_until = prev_until + current
110 110 comment_versions[prvid]['until'].extend(cur_until)
111 111
112 112 # save outdated
113 113 if inline:
114 114 outdated = [x for x in cur_until
115 115 if x.outdated_at_version(show_version)]
116 116 else:
117 117 outdated = [x for x in cur_until
118 118 if x.older_than_version(show_version)]
119 119 display = [x for x in cur_until if x not in outdated]
120 120
121 121 comment_versions[prvid]['outdated'] = outdated
122 122 comment_versions[prvid]['display'] = display
123 123
124 124 prev_prvid = prvid
125 125
126 126 return comment_versions
127 127
128 128 def get_repository_comments(self, repo, comment_type=None, user=None, commit_id=None):
129 129 qry = Session().query(ChangesetComment) \
130 130 .filter(ChangesetComment.repo == repo)
131 131
132 132 if comment_type and comment_type in ChangesetComment.COMMENT_TYPES:
133 133 qry = qry.filter(ChangesetComment.comment_type == comment_type)
134 134
135 135 if user:
136 136 user = self._get_user(user)
137 137 if user:
138 138 qry = qry.filter(ChangesetComment.user_id == user.user_id)
139 139
140 140 if commit_id:
141 141 qry = qry.filter(ChangesetComment.revision == commit_id)
142 142
143 143 qry = qry.order_by(ChangesetComment.created_on)
144 144 return qry.all()
145 145
146 146 def get_repository_unresolved_todos(self, repo):
147 147 todos = Session().query(ChangesetComment) \
148 148 .filter(ChangesetComment.repo == repo) \
149 149 .filter(ChangesetComment.resolved_by == None) \
150 150 .filter(ChangesetComment.comment_type
151 151 == ChangesetComment.COMMENT_TYPE_TODO)
152 152 todos = todos.all()
153 153
154 154 return todos
155 155
156 156 def get_pull_request_unresolved_todos(self, pull_request, show_outdated=True):
157 157
158 158 todos = Session().query(ChangesetComment) \
159 159 .filter(ChangesetComment.pull_request == pull_request) \
160 160 .filter(ChangesetComment.resolved_by == None) \
161 161 .filter(ChangesetComment.comment_type
162 162 == ChangesetComment.COMMENT_TYPE_TODO)
163 163
164 164 if not show_outdated:
165 165 todos = todos.filter(
166 166 coalesce(ChangesetComment.display_state, '') !=
167 167 ChangesetComment.COMMENT_OUTDATED)
168 168
169 169 todos = todos.all()
170 170
171 171 return todos
172 172
173 173 def get_pull_request_resolved_todos(self, pull_request, show_outdated=True):
174 174
175 175 todos = Session().query(ChangesetComment) \
176 176 .filter(ChangesetComment.pull_request == pull_request) \
177 177 .filter(ChangesetComment.resolved_by != None) \
178 178 .filter(ChangesetComment.comment_type
179 179 == ChangesetComment.COMMENT_TYPE_TODO)
180 180
181 181 if not show_outdated:
182 182 todos = todos.filter(
183 183 coalesce(ChangesetComment.display_state, '') !=
184 184 ChangesetComment.COMMENT_OUTDATED)
185 185
186 186 todos = todos.all()
187 187
188 188 return todos
189 189
190 190 def get_commit_unresolved_todos(self, commit_id, show_outdated=True):
191 191
192 192 todos = Session().query(ChangesetComment) \
193 193 .filter(ChangesetComment.revision == commit_id) \
194 194 .filter(ChangesetComment.resolved_by == None) \
195 195 .filter(ChangesetComment.comment_type
196 196 == ChangesetComment.COMMENT_TYPE_TODO)
197 197
198 198 if not show_outdated:
199 199 todos = todos.filter(
200 200 coalesce(ChangesetComment.display_state, '') !=
201 201 ChangesetComment.COMMENT_OUTDATED)
202 202
203 203 todos = todos.all()
204 204
205 205 return todos
206 206
207 207 def get_commit_resolved_todos(self, commit_id, show_outdated=True):
208 208
209 209 todos = Session().query(ChangesetComment) \
210 210 .filter(ChangesetComment.revision == commit_id) \
211 211 .filter(ChangesetComment.resolved_by != None) \
212 212 .filter(ChangesetComment.comment_type
213 213 == ChangesetComment.COMMENT_TYPE_TODO)
214 214
215 215 if not show_outdated:
216 216 todos = todos.filter(
217 217 coalesce(ChangesetComment.display_state, '') !=
218 218 ChangesetComment.COMMENT_OUTDATED)
219 219
220 220 todos = todos.all()
221 221
222 222 return todos
223 223
224 224 def _log_audit_action(self, action, action_data, auth_user, comment):
225 225 audit_logger.store(
226 226 action=action,
227 227 action_data=action_data,
228 228 user=auth_user,
229 229 repo=comment.repo)
230 230
231 231 def create(self, text, repo, user, commit_id=None, pull_request=None,
232 232 f_path=None, line_no=None, status_change=None,
233 233 status_change_type=None, comment_type=None,
234 234 resolves_comment_id=None, closing_pr=False, send_email=True,
235 235 renderer=None, auth_user=None, extra_recipients=None):
236 236 """
237 237 Creates new comment for commit or pull request.
238 238 IF status_change is not none this comment is associated with a
239 239 status change of commit or commit associated with pull request
240 240
241 241 :param text:
242 242 :param repo:
243 243 :param user:
244 244 :param commit_id:
245 245 :param pull_request:
246 246 :param f_path:
247 247 :param line_no:
248 248 :param status_change: Label for status change
249 249 :param comment_type: Type of comment
250 250 :param resolves_comment_id: id of comment which this one will resolve
251 251 :param status_change_type: type of status change
252 252 :param closing_pr:
253 253 :param send_email:
254 254 :param renderer: pick renderer for this comment
255 255 :param auth_user: current authenticated user calling this method
256 256 :param extra_recipients: list of extra users to be added to recipients
257 257 """
258 258
259 259 if not text:
260 260 log.warning('Missing text for comment, skipping...')
261 261 return
262 262 request = get_current_request()
263 263 _ = request.translate
264 264
265 265 if not renderer:
266 266 renderer = self._get_renderer(request=request)
267 267
268 268 repo = self._get_repo(repo)
269 269 user = self._get_user(user)
270 270 auth_user = auth_user or user
271 271
272 272 schema = comment_schema.CommentSchema()
273 273 validated_kwargs = schema.deserialize(dict(
274 274 comment_body=text,
275 275 comment_type=comment_type,
276 276 comment_file=f_path,
277 277 comment_line=line_no,
278 278 renderer_type=renderer,
279 279 status_change=status_change_type,
280 280 resolves_comment_id=resolves_comment_id,
281 281 repo=repo.repo_id,
282 282 user=user.user_id,
283 283 ))
284 284
285 285 comment = ChangesetComment()
286 286 comment.renderer = validated_kwargs['renderer_type']
287 287 comment.text = validated_kwargs['comment_body']
288 288 comment.f_path = validated_kwargs['comment_file']
289 289 comment.line_no = validated_kwargs['comment_line']
290 290 comment.comment_type = validated_kwargs['comment_type']
291 291
292 292 comment.repo = repo
293 293 comment.author = user
294 294 resolved_comment = self.__get_commit_comment(
295 295 validated_kwargs['resolves_comment_id'])
296 296 # check if the comment actually belongs to this PR
297 297 if resolved_comment and resolved_comment.pull_request and \
298 298 resolved_comment.pull_request != pull_request:
299 299 log.warning('Comment tried to resolved unrelated todo comment: %s',
300 300 resolved_comment)
301 301 # comment not bound to this pull request, forbid
302 302 resolved_comment = None
303 303
304 304 elif resolved_comment and resolved_comment.repo and \
305 305 resolved_comment.repo != repo:
306 306 log.warning('Comment tried to resolved unrelated todo comment: %s',
307 307 resolved_comment)
308 308 # comment not bound to this repo, forbid
309 309 resolved_comment = None
310 310
311 311 comment.resolved_comment = resolved_comment
312 312
313 313 pull_request_id = pull_request
314 314
315 315 commit_obj = None
316 316 pull_request_obj = None
317 317
318 318 if commit_id:
319 319 notification_type = EmailNotificationModel.TYPE_COMMIT_COMMENT
320 320 # do a lookup, so we don't pass something bad here
321 321 commit_obj = repo.scm_instance().get_commit(commit_id=commit_id)
322 322 comment.revision = commit_obj.raw_id
323 323
324 324 elif pull_request_id:
325 325 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST_COMMENT
326 326 pull_request_obj = self.__get_pull_request(pull_request_id)
327 327 comment.pull_request = pull_request_obj
328 328 else:
329 329 raise Exception('Please specify commit or pull_request_id')
330 330
331 331 Session().add(comment)
332 332 Session().flush()
333 333 kwargs = {
334 334 'user': user,
335 335 'renderer_type': renderer,
336 336 'repo_name': repo.repo_name,
337 337 'status_change': status_change,
338 338 'status_change_type': status_change_type,
339 339 'comment_body': text,
340 340 'comment_file': f_path,
341 341 'comment_line': line_no,
342 342 'comment_type': comment_type or 'note',
343 343 'comment_id': comment.comment_id
344 344 }
345 345
346 346 if commit_obj:
347 347 recipients = ChangesetComment.get_users(
348 348 revision=commit_obj.raw_id)
349 349 # add commit author if it's in RhodeCode system
350 350 cs_author = User.get_from_cs_author(commit_obj.author)
351 351 if not cs_author:
352 352 # use repo owner if we cannot extract the author correctly
353 353 cs_author = repo.user
354 354 recipients += [cs_author]
355 355
356 356 commit_comment_url = self.get_url(comment, request=request)
357 357 commit_comment_reply_url = self.get_url(
358 358 comment, request=request,
359 359 anchor='comment-{}/?/ReplyToComment'.format(comment.comment_id))
360 360
361 361 target_repo_url = h.link_to(
362 362 repo.repo_name,
363 363 h.route_url('repo_summary', repo_name=repo.repo_name))
364 364
365 365 # commit specifics
366 366 kwargs.update({
367 367 'commit': commit_obj,
368 368 'commit_message': commit_obj.message,
369 369 'commit_target_repo_url': target_repo_url,
370 370 'commit_comment_url': commit_comment_url,
371 371 'commit_comment_reply_url': commit_comment_reply_url
372 372 })
373 373
374 374 elif pull_request_obj:
375 375 # get the current participants of this pull request
376 376 recipients = ChangesetComment.get_users(
377 377 pull_request_id=pull_request_obj.pull_request_id)
378 378 # add pull request author
379 379 recipients += [pull_request_obj.author]
380 380
381 381 # add the reviewers to notification
382 382 recipients += [x.user for x in pull_request_obj.reviewers]
383 383
384 384 pr_target_repo = pull_request_obj.target_repo
385 385 pr_source_repo = pull_request_obj.source_repo
386 386
387 387 pr_comment_url = self.get_url(comment, request=request)
388 388 pr_comment_reply_url = self.get_url(
389 389 comment, request=request,
390 390 anchor='comment-{}/?/ReplyToComment'.format(comment.comment_id))
391 391
392 392 pr_url = h.route_url(
393 393 'pullrequest_show',
394 394 repo_name=pr_target_repo.repo_name,
395 395 pull_request_id=pull_request_obj.pull_request_id, )
396 396
397 397 # set some variables for email notification
398 398 pr_target_repo_url = h.route_url(
399 399 'repo_summary', repo_name=pr_target_repo.repo_name)
400 400
401 401 pr_source_repo_url = h.route_url(
402 402 'repo_summary', repo_name=pr_source_repo.repo_name)
403 403
404 404 # pull request specifics
405 405 kwargs.update({
406 406 'pull_request': pull_request_obj,
407 407 'pr_id': pull_request_obj.pull_request_id,
408 408 'pull_request_url': pr_url,
409 409 'pull_request_target_repo': pr_target_repo,
410 410 'pull_request_target_repo_url': pr_target_repo_url,
411 411 'pull_request_source_repo': pr_source_repo,
412 412 'pull_request_source_repo_url': pr_source_repo_url,
413 413 'pr_comment_url': pr_comment_url,
414 414 'pr_comment_reply_url': pr_comment_reply_url,
415 415 'pr_closing': closing_pr,
416 416 })
417 417
418 418 recipients += [self._get_user(u) for u in (extra_recipients or [])]
419 419
420 420 if send_email:
421 421 # pre-generate the subject for notification itself
422 422 (subject,
423 423 _h, _e, # we don't care about those
424 424 body_plaintext) = EmailNotificationModel().render_email(
425 425 notification_type, **kwargs)
426 426
427 427 mention_recipients = set(
428 428 self._extract_mentions(text)).difference(recipients)
429 429
430 430 # create notification objects, and emails
431 431 NotificationModel().create(
432 432 created_by=user,
433 433 notification_subject=subject,
434 434 notification_body=body_plaintext,
435 435 notification_type=notification_type,
436 436 recipients=recipients,
437 437 mention_recipients=mention_recipients,
438 438 email_kwargs=kwargs,
439 439 )
440 440
441 441 Session().flush()
442 442 if comment.pull_request:
443 443 action = 'repo.pull_request.comment.create'
444 444 else:
445 445 action = 'repo.commit.comment.create'
446 446
447 447 comment_data = comment.get_api_data()
448 448 self._log_audit_action(
449 449 action, {'data': comment_data}, auth_user, comment)
450 450
451 451 msg_url = ''
452 452 channel = None
453 453 if commit_obj:
454 454 msg_url = commit_comment_url
455 455 repo_name = repo.repo_name
456 456 channel = u'/repo${}$/commit/{}'.format(
457 457 repo_name,
458 458 commit_obj.raw_id
459 459 )
460 460 elif pull_request_obj:
461 461 msg_url = pr_comment_url
462 462 repo_name = pr_target_repo.repo_name
463 463 channel = u'/repo${}$/pr/{}'.format(
464 464 repo_name,
465 465 pull_request_id
466 466 )
467 467
468 468 message = '<strong>{}</strong> {} - ' \
469 469 '<a onclick="window.location=\'{}\';' \
470 470 'window.location.reload()">' \
471 471 '<strong>{}</strong></a>'
472 472 message = message.format(
473 473 user.username, _('made a comment'), msg_url,
474 474 _('Show it now'))
475 475
476 476 channelstream.post_message(
477 477 channel, message, user.username,
478 478 registry=get_current_registry())
479 479
480 480 return comment
481 481
482 482 def delete(self, comment, auth_user):
483 483 """
484 484 Deletes given comment
485 485 """
486 486 comment = self.__get_commit_comment(comment)
487 487 old_data = comment.get_api_data()
488 488 Session().delete(comment)
489 489
490 490 if comment.pull_request:
491 491 action = 'repo.pull_request.comment.delete'
492 492 else:
493 493 action = 'repo.commit.comment.delete'
494 494
495 495 self._log_audit_action(
496 496 action, {'old_data': old_data}, auth_user, comment)
497 497
498 498 return comment
499 499
500 500 def get_all_comments(self, repo_id, revision=None, pull_request=None):
501 501 q = ChangesetComment.query()\
502 502 .filter(ChangesetComment.repo_id == repo_id)
503 503 if revision:
504 504 q = q.filter(ChangesetComment.revision == revision)
505 505 elif pull_request:
506 506 pull_request = self.__get_pull_request(pull_request)
507 507 q = q.filter(ChangesetComment.pull_request == pull_request)
508 508 else:
509 509 raise Exception('Please specify commit or pull_request')
510 510 q = q.order_by(ChangesetComment.created_on)
511 511 return q.all()
512 512
513 513 def get_url(self, comment, request=None, permalink=False, anchor=None):
514 514 if not request:
515 515 request = get_current_request()
516 516
517 517 comment = self.__get_commit_comment(comment)
518 518 if anchor is None:
519 519 anchor = 'comment-{}'.format(comment.comment_id)
520 520
521 521 if comment.pull_request:
522 522 pull_request = comment.pull_request
523 523 if permalink:
524 524 return request.route_url(
525 525 'pull_requests_global',
526 526 pull_request_id=pull_request.pull_request_id,
527 527 _anchor=anchor)
528 528 else:
529 529 return request.route_url(
530 530 'pullrequest_show',
531 531 repo_name=safe_str(pull_request.target_repo.repo_name),
532 532 pull_request_id=pull_request.pull_request_id,
533 533 _anchor=anchor)
534 534
535 535 else:
536 536 repo = comment.repo
537 537 commit_id = comment.revision
538 538
539 539 if permalink:
540 540 return request.route_url(
541 541 'repo_commit', repo_name=safe_str(repo.repo_id),
542 542 commit_id=commit_id,
543 543 _anchor=anchor)
544 544
545 545 else:
546 546 return request.route_url(
547 547 'repo_commit', repo_name=safe_str(repo.repo_name),
548 548 commit_id=commit_id,
549 549 _anchor=anchor)
550 550
551 551 def get_comments(self, repo_id, revision=None, pull_request=None):
552 552 """
553 553 Gets main comments based on revision or pull_request_id
554 554
555 555 :param repo_id:
556 556 :param revision:
557 557 :param pull_request:
558 558 """
559 559
560 560 q = ChangesetComment.query()\
561 561 .filter(ChangesetComment.repo_id == repo_id)\
562 562 .filter(ChangesetComment.line_no == None)\
563 563 .filter(ChangesetComment.f_path == None)
564 564 if revision:
565 565 q = q.filter(ChangesetComment.revision == revision)
566 566 elif pull_request:
567 567 pull_request = self.__get_pull_request(pull_request)
568 568 q = q.filter(ChangesetComment.pull_request == pull_request)
569 569 else:
570 570 raise Exception('Please specify commit or pull_request')
571 571 q = q.order_by(ChangesetComment.created_on)
572 572 return q.all()
573 573
574 574 def get_inline_comments(self, repo_id, revision=None, pull_request=None):
575 575 q = self._get_inline_comments_query(repo_id, revision, pull_request)
576 576 return self._group_comments_by_path_and_line_number(q)
577 577
578 578 def get_inline_comments_count(self, inline_comments, skip_outdated=True,
579 579 version=None):
580 580 inline_cnt = 0
581 581 for fname, per_line_comments in inline_comments.iteritems():
582 582 for lno, comments in per_line_comments.iteritems():
583 583 for comm in comments:
584 584 if not comm.outdated_at_version(version) and skip_outdated:
585 585 inline_cnt += 1
586 586
587 587 return inline_cnt
588 588
589 589 def get_outdated_comments(self, repo_id, pull_request):
590 590 # TODO: johbo: Remove `repo_id`, it is not needed to find the comments
591 591 # of a pull request.
592 592 q = self._all_inline_comments_of_pull_request(pull_request)
593 593 q = q.filter(
594 594 ChangesetComment.display_state ==
595 595 ChangesetComment.COMMENT_OUTDATED
596 596 ).order_by(ChangesetComment.comment_id.asc())
597 597
598 598 return self._group_comments_by_path_and_line_number(q)
599 599
600 600 def _get_inline_comments_query(self, repo_id, revision, pull_request):
601 601 # TODO: johbo: Split this into two methods: One for PR and one for
602 602 # commit.
603 603 if revision:
604 604 q = Session().query(ChangesetComment).filter(
605 605 ChangesetComment.repo_id == repo_id,
606 606 ChangesetComment.line_no != null(),
607 607 ChangesetComment.f_path != null(),
608 608 ChangesetComment.revision == revision)
609 609
610 610 elif pull_request:
611 611 pull_request = self.__get_pull_request(pull_request)
612 612 if not CommentsModel.use_outdated_comments(pull_request):
613 613 q = self._visible_inline_comments_of_pull_request(pull_request)
614 614 else:
615 615 q = self._all_inline_comments_of_pull_request(pull_request)
616 616
617 617 else:
618 618 raise Exception('Please specify commit or pull_request_id')
619 619 q = q.order_by(ChangesetComment.comment_id.asc())
620 620 return q
621 621
622 622 def _group_comments_by_path_and_line_number(self, q):
623 623 comments = q.all()
624 624 paths = collections.defaultdict(lambda: collections.defaultdict(list))
625 625 for co in comments:
626 626 paths[co.f_path][co.line_no].append(co)
627 627 return paths
628 628
629 629 @classmethod
630 630 def needed_extra_diff_context(cls):
631 631 return max(cls.DIFF_CONTEXT_BEFORE, cls.DIFF_CONTEXT_AFTER)
632 632
633 633 def outdate_comments(self, pull_request, old_diff_data, new_diff_data):
634 634 if not CommentsModel.use_outdated_comments(pull_request):
635 635 return
636 636
637 637 comments = self._visible_inline_comments_of_pull_request(pull_request)
638 638 comments_to_outdate = comments.all()
639 639
640 640 for comment in comments_to_outdate:
641 641 self._outdate_one_comment(comment, old_diff_data, new_diff_data)
642 642
643 643 def _outdate_one_comment(self, comment, old_diff_proc, new_diff_proc):
644 644 diff_line = _parse_comment_line_number(comment.line_no)
645 645
646 646 try:
647 647 old_context = old_diff_proc.get_context_of_line(
648 648 path=comment.f_path, diff_line=diff_line)
649 649 new_context = new_diff_proc.get_context_of_line(
650 650 path=comment.f_path, diff_line=diff_line)
651 651 except (diffs.LineNotInDiffException,
652 652 diffs.FileNotInDiffException):
653 653 comment.display_state = ChangesetComment.COMMENT_OUTDATED
654 654 return
655 655
656 656 if old_context == new_context:
657 657 return
658 658
659 659 if self._should_relocate_diff_line(diff_line):
660 660 new_diff_lines = new_diff_proc.find_context(
661 661 path=comment.f_path, context=old_context,
662 662 offset=self.DIFF_CONTEXT_BEFORE)
663 663 if not new_diff_lines:
664 664 comment.display_state = ChangesetComment.COMMENT_OUTDATED
665 665 else:
666 666 new_diff_line = self._choose_closest_diff_line(
667 667 diff_line, new_diff_lines)
668 668 comment.line_no = _diff_to_comment_line_number(new_diff_line)
669 669 else:
670 670 comment.display_state = ChangesetComment.COMMENT_OUTDATED
671 671
672 672 def _should_relocate_diff_line(self, diff_line):
673 673 """
674 674 Checks if relocation shall be tried for the given `diff_line`.
675 675
676 676 If a comment points into the first lines, then we can have a situation
677 677 that after an update another line has been added on top. In this case
678 678 we would find the context still and move the comment around. This
679 679 would be wrong.
680 680 """
681 681 should_relocate = (
682 682 (diff_line.new and diff_line.new > self.DIFF_CONTEXT_BEFORE) or
683 683 (diff_line.old and diff_line.old > self.DIFF_CONTEXT_BEFORE))
684 684 return should_relocate
685 685
686 686 def _choose_closest_diff_line(self, diff_line, new_diff_lines):
687 687 candidate = new_diff_lines[0]
688 688 best_delta = _diff_line_delta(diff_line, candidate)
689 689 for new_diff_line in new_diff_lines[1:]:
690 690 delta = _diff_line_delta(diff_line, new_diff_line)
691 691 if delta < best_delta:
692 692 candidate = new_diff_line
693 693 best_delta = delta
694 694 return candidate
695 695
696 696 def _visible_inline_comments_of_pull_request(self, pull_request):
697 697 comments = self._all_inline_comments_of_pull_request(pull_request)
698 698 comments = comments.filter(
699 699 coalesce(ChangesetComment.display_state, '') !=
700 700 ChangesetComment.COMMENT_OUTDATED)
701 701 return comments
702 702
703 703 def _all_inline_comments_of_pull_request(self, pull_request):
704 704 comments = Session().query(ChangesetComment)\
705 705 .filter(ChangesetComment.line_no != None)\
706 706 .filter(ChangesetComment.f_path != None)\
707 707 .filter(ChangesetComment.pull_request == pull_request)
708 708 return comments
709 709
710 710 def _all_general_comments_of_pull_request(self, pull_request):
711 711 comments = Session().query(ChangesetComment)\
712 712 .filter(ChangesetComment.line_no == None)\
713 713 .filter(ChangesetComment.f_path == None)\
714 714 .filter(ChangesetComment.pull_request == pull_request)
715 715 return comments
716 716
717 717 @staticmethod
718 718 def use_outdated_comments(pull_request):
719 719 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
720 720 settings = settings_model.get_general_settings()
721 721 return settings.get('rhodecode_use_outdated_comments', False)
722 722
723 def trigger_commit_comment_hook(self, repo, user, action, data=None):
724 repo = self._get_repo(repo)
725 target_scm = repo.scm_instance()
726 if action == 'create':
727 trigger_hook = hooks_utils.trigger_comment_commit_hooks
728 elif action == 'edit':
729 # TODO(dan): when this is supported we trigger edit hook too
730 return
731 else:
732 return
733
734 log.debug('Handling repo %s trigger_commit_comment_hook with action %s: %s',
735 repo, action, trigger_hook)
736 trigger_hook(
737 username=user.username,
738 repo_name=repo.repo_name,
739 repo_type=target_scm.alias,
740 repo=repo,
741 data=data)
742
723 743
724 744 def _parse_comment_line_number(line_no):
725 745 """
726 746 Parses line numbers of the form "(o|n)\d+" and returns them in a tuple.
727 747 """
728 748 old_line = None
729 749 new_line = None
730 750 if line_no.startswith('o'):
731 751 old_line = int(line_no[1:])
732 752 elif line_no.startswith('n'):
733 753 new_line = int(line_no[1:])
734 754 else:
735 755 raise ValueError("Comment lines have to start with either 'o' or 'n'.")
736 756 return diffs.DiffLineNumber(old_line, new_line)
737 757
738 758
739 759 def _diff_to_comment_line_number(diff_line):
740 760 if diff_line.new is not None:
741 761 return u'n{}'.format(diff_line.new)
742 762 elif diff_line.old is not None:
743 763 return u'o{}'.format(diff_line.old)
744 764 return u''
745 765
746 766
747 767 def _diff_line_delta(a, b):
748 768 if None not in (a.new, b.new):
749 769 return abs(a.new - b.new)
750 770 elif None not in (a.old, b.old):
751 771 return abs(a.old - b.old)
752 772 else:
753 773 raise ValueError(
754 774 "Cannot compute delta between {} and {}".format(a, b))
@@ -1,1893 +1,1890 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-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 """
23 23 pull request model for RhodeCode
24 24 """
25 25
26 26
27 27 import json
28 28 import logging
29 29 import os
30 30
31 31 import datetime
32 32 import urllib
33 33 import collections
34 34
35 35 from pyramid import compat
36 36 from pyramid.threadlocal import get_current_request
37 37
38 38 from rhodecode import events
39 39 from rhodecode.translation import lazy_ugettext
40 40 from rhodecode.lib import helpers as h, hooks_utils, diffs
41 41 from rhodecode.lib import audit_logger
42 42 from rhodecode.lib.compat import OrderedDict
43 43 from rhodecode.lib.hooks_daemon import prepare_callback_daemon
44 44 from rhodecode.lib.markup_renderer import (
45 45 DEFAULT_COMMENTS_RENDERER, RstTemplateRenderer)
46 46 from rhodecode.lib.utils2 import safe_unicode, safe_str, md5_safe
47 47 from rhodecode.lib.vcs.backends.base import (
48 48 Reference, MergeResponse, MergeFailureReason, UpdateFailureReason)
49 49 from rhodecode.lib.vcs.conf import settings as vcs_settings
50 50 from rhodecode.lib.vcs.exceptions import (
51 51 CommitDoesNotExistError, EmptyRepositoryError)
52 52 from rhodecode.model import BaseModel
53 53 from rhodecode.model.changeset_status import ChangesetStatusModel
54 54 from rhodecode.model.comment import CommentsModel
55 55 from rhodecode.model.db import (
56 56 or_, String, cast, PullRequest, PullRequestReviewers, ChangesetStatus,
57 57 PullRequestVersion, ChangesetComment, Repository, RepoReviewRule)
58 58 from rhodecode.model.meta import Session
59 59 from rhodecode.model.notification import NotificationModel, \
60 60 EmailNotificationModel
61 61 from rhodecode.model.scm import ScmModel
62 62 from rhodecode.model.settings import VcsSettingsModel
63 63
64 64
65 65 log = logging.getLogger(__name__)
66 66
67 67
68 68 # Data structure to hold the response data when updating commits during a pull
69 69 # request update.
70 70 class UpdateResponse(object):
71 71
72 72 def __init__(self, executed, reason, new, old, common_ancestor_id,
73 73 commit_changes, source_changed, target_changed):
74 74
75 75 self.executed = executed
76 76 self.reason = reason
77 77 self.new = new
78 78 self.old = old
79 79 self.common_ancestor_id = common_ancestor_id
80 80 self.changes = commit_changes
81 81 self.source_changed = source_changed
82 82 self.target_changed = target_changed
83 83
84 84
85 85 class PullRequestModel(BaseModel):
86 86
87 87 cls = PullRequest
88 88
89 89 DIFF_CONTEXT = diffs.DEFAULT_CONTEXT
90 90
91 91 UPDATE_STATUS_MESSAGES = {
92 92 UpdateFailureReason.NONE: lazy_ugettext(
93 93 'Pull request update successful.'),
94 94 UpdateFailureReason.UNKNOWN: lazy_ugettext(
95 95 'Pull request update failed because of an unknown error.'),
96 96 UpdateFailureReason.NO_CHANGE: lazy_ugettext(
97 97 'No update needed because the source and target have not changed.'),
98 98 UpdateFailureReason.WRONG_REF_TYPE: lazy_ugettext(
99 99 'Pull request cannot be updated because the reference type is '
100 100 'not supported for an update. Only Branch, Tag or Bookmark is allowed.'),
101 101 UpdateFailureReason.MISSING_TARGET_REF: lazy_ugettext(
102 102 'This pull request cannot be updated because the target '
103 103 'reference is missing.'),
104 104 UpdateFailureReason.MISSING_SOURCE_REF: lazy_ugettext(
105 105 'This pull request cannot be updated because the source '
106 106 'reference is missing.'),
107 107 }
108 108 REF_TYPES = ['bookmark', 'book', 'tag', 'branch']
109 109 UPDATABLE_REF_TYPES = ['bookmark', 'book', 'branch']
110 110
111 111 def __get_pull_request(self, pull_request):
112 112 return self._get_instance((
113 113 PullRequest, PullRequestVersion), pull_request)
114 114
115 115 def _check_perms(self, perms, pull_request, user, api=False):
116 116 if not api:
117 117 return h.HasRepoPermissionAny(*perms)(
118 118 user=user, repo_name=pull_request.target_repo.repo_name)
119 119 else:
120 120 return h.HasRepoPermissionAnyApi(*perms)(
121 121 user=user, repo_name=pull_request.target_repo.repo_name)
122 122
123 123 def check_user_read(self, pull_request, user, api=False):
124 124 _perms = ('repository.admin', 'repository.write', 'repository.read',)
125 125 return self._check_perms(_perms, pull_request, user, api)
126 126
127 127 def check_user_merge(self, pull_request, user, api=False):
128 128 _perms = ('repository.admin', 'repository.write', 'hg.admin',)
129 129 return self._check_perms(_perms, pull_request, user, api)
130 130
131 131 def check_user_update(self, pull_request, user, api=False):
132 132 owner = user.user_id == pull_request.user_id
133 133 return self.check_user_merge(pull_request, user, api) or owner
134 134
135 135 def check_user_delete(self, pull_request, user):
136 136 owner = user.user_id == pull_request.user_id
137 137 _perms = ('repository.admin',)
138 138 return self._check_perms(_perms, pull_request, user) or owner
139 139
140 140 def check_user_change_status(self, pull_request, user, api=False):
141 141 reviewer = user.user_id in [x.user_id for x in
142 142 pull_request.reviewers]
143 143 return self.check_user_update(pull_request, user, api) or reviewer
144 144
145 145 def check_user_comment(self, pull_request, user):
146 146 owner = user.user_id == pull_request.user_id
147 147 return self.check_user_read(pull_request, user) or owner
148 148
149 149 def get(self, pull_request):
150 150 return self.__get_pull_request(pull_request)
151 151
152 152 def _prepare_get_all_query(self, repo_name, search_q=None, source=False,
153 153 statuses=None, opened_by=None, order_by=None,
154 154 order_dir='desc', only_created=False):
155 155 repo = None
156 156 if repo_name:
157 157 repo = self._get_repo(repo_name)
158 158
159 159 q = PullRequest.query()
160 160
161 161 if search_q:
162 162 like_expression = u'%{}%'.format(safe_unicode(search_q))
163 163 q = q.filter(or_(
164 164 cast(PullRequest.pull_request_id, String).ilike(like_expression),
165 165 PullRequest.title.ilike(like_expression),
166 166 PullRequest.description.ilike(like_expression),
167 167 ))
168 168
169 169 # source or target
170 170 if repo and source:
171 171 q = q.filter(PullRequest.source_repo == repo)
172 172 elif repo:
173 173 q = q.filter(PullRequest.target_repo == repo)
174 174
175 175 # closed,opened
176 176 if statuses:
177 177 q = q.filter(PullRequest.status.in_(statuses))
178 178
179 179 # opened by filter
180 180 if opened_by:
181 181 q = q.filter(PullRequest.user_id.in_(opened_by))
182 182
183 183 # only get those that are in "created" state
184 184 if only_created:
185 185 q = q.filter(PullRequest.pull_request_state == PullRequest.STATE_CREATED)
186 186
187 187 if order_by:
188 188 order_map = {
189 189 'name_raw': PullRequest.pull_request_id,
190 190 'id': PullRequest.pull_request_id,
191 191 'title': PullRequest.title,
192 192 'updated_on_raw': PullRequest.updated_on,
193 193 'target_repo': PullRequest.target_repo_id
194 194 }
195 195 if order_dir == 'asc':
196 196 q = q.order_by(order_map[order_by].asc())
197 197 else:
198 198 q = q.order_by(order_map[order_by].desc())
199 199
200 200 return q
201 201
202 202 def count_all(self, repo_name, search_q=None, source=False, statuses=None,
203 203 opened_by=None):
204 204 """
205 205 Count the number of pull requests for a specific repository.
206 206
207 207 :param repo_name: target or source repo
208 208 :param search_q: filter by text
209 209 :param source: boolean flag to specify if repo_name refers to source
210 210 :param statuses: list of pull request statuses
211 211 :param opened_by: author user of the pull request
212 212 :returns: int number of pull requests
213 213 """
214 214 q = self._prepare_get_all_query(
215 215 repo_name, search_q=search_q, source=source, statuses=statuses,
216 216 opened_by=opened_by)
217 217
218 218 return q.count()
219 219
220 220 def get_all(self, repo_name, search_q=None, source=False, statuses=None,
221 221 opened_by=None, offset=0, length=None, order_by=None, order_dir='desc'):
222 222 """
223 223 Get all pull requests for a specific repository.
224 224
225 225 :param repo_name: target or source repo
226 226 :param search_q: filter by text
227 227 :param source: boolean flag to specify if repo_name refers to source
228 228 :param statuses: list of pull request statuses
229 229 :param opened_by: author user of the pull request
230 230 :param offset: pagination offset
231 231 :param length: length of returned list
232 232 :param order_by: order of the returned list
233 233 :param order_dir: 'asc' or 'desc' ordering direction
234 234 :returns: list of pull requests
235 235 """
236 236 q = self._prepare_get_all_query(
237 237 repo_name, search_q=search_q, source=source, statuses=statuses,
238 238 opened_by=opened_by, order_by=order_by, order_dir=order_dir)
239 239
240 240 if length:
241 241 pull_requests = q.limit(length).offset(offset).all()
242 242 else:
243 243 pull_requests = q.all()
244 244
245 245 return pull_requests
246 246
247 247 def count_awaiting_review(self, repo_name, search_q=None, source=False, statuses=None,
248 248 opened_by=None):
249 249 """
250 250 Count the number of pull requests for a specific repository that are
251 251 awaiting review.
252 252
253 253 :param repo_name: target or source repo
254 254 :param search_q: filter by text
255 255 :param source: boolean flag to specify if repo_name refers to source
256 256 :param statuses: list of pull request statuses
257 257 :param opened_by: author user of the pull request
258 258 :returns: int number of pull requests
259 259 """
260 260 pull_requests = self.get_awaiting_review(
261 261 repo_name, search_q=search_q, source=source, statuses=statuses, opened_by=opened_by)
262 262
263 263 return len(pull_requests)
264 264
265 265 def get_awaiting_review(self, repo_name, search_q=None, source=False, statuses=None,
266 266 opened_by=None, offset=0, length=None,
267 267 order_by=None, order_dir='desc'):
268 268 """
269 269 Get all pull requests for a specific repository that are awaiting
270 270 review.
271 271
272 272 :param repo_name: target or source repo
273 273 :param search_q: filter by text
274 274 :param source: boolean flag to specify if repo_name refers to source
275 275 :param statuses: list of pull request statuses
276 276 :param opened_by: author user of the pull request
277 277 :param offset: pagination offset
278 278 :param length: length of returned list
279 279 :param order_by: order of the returned list
280 280 :param order_dir: 'asc' or 'desc' ordering direction
281 281 :returns: list of pull requests
282 282 """
283 283 pull_requests = self.get_all(
284 284 repo_name, search_q=search_q, source=source, statuses=statuses,
285 285 opened_by=opened_by, order_by=order_by, order_dir=order_dir)
286 286
287 287 _filtered_pull_requests = []
288 288 for pr in pull_requests:
289 289 status = pr.calculated_review_status()
290 290 if status in [ChangesetStatus.STATUS_NOT_REVIEWED,
291 291 ChangesetStatus.STATUS_UNDER_REVIEW]:
292 292 _filtered_pull_requests.append(pr)
293 293 if length:
294 294 return _filtered_pull_requests[offset:offset+length]
295 295 else:
296 296 return _filtered_pull_requests
297 297
298 298 def count_awaiting_my_review(self, repo_name, search_q=None, source=False, statuses=None,
299 299 opened_by=None, user_id=None):
300 300 """
301 301 Count the number of pull requests for a specific repository that are
302 302 awaiting review from a specific user.
303 303
304 304 :param repo_name: target or source repo
305 305 :param search_q: filter by text
306 306 :param source: boolean flag to specify if repo_name refers to source
307 307 :param statuses: list of pull request statuses
308 308 :param opened_by: author user of the pull request
309 309 :param user_id: reviewer user of the pull request
310 310 :returns: int number of pull requests
311 311 """
312 312 pull_requests = self.get_awaiting_my_review(
313 313 repo_name, search_q=search_q, source=source, statuses=statuses,
314 314 opened_by=opened_by, user_id=user_id)
315 315
316 316 return len(pull_requests)
317 317
318 318 def get_awaiting_my_review(self, repo_name, search_q=None, source=False, statuses=None,
319 319 opened_by=None, user_id=None, offset=0,
320 320 length=None, order_by=None, order_dir='desc'):
321 321 """
322 322 Get all pull requests for a specific repository that are awaiting
323 323 review from a specific user.
324 324
325 325 :param repo_name: target or source repo
326 326 :param search_q: filter by text
327 327 :param source: boolean flag to specify if repo_name refers to source
328 328 :param statuses: list of pull request statuses
329 329 :param opened_by: author user of the pull request
330 330 :param user_id: reviewer user of the pull request
331 331 :param offset: pagination offset
332 332 :param length: length of returned list
333 333 :param order_by: order of the returned list
334 334 :param order_dir: 'asc' or 'desc' ordering direction
335 335 :returns: list of pull requests
336 336 """
337 337 pull_requests = self.get_all(
338 338 repo_name, search_q=search_q, source=source, statuses=statuses,
339 339 opened_by=opened_by, order_by=order_by, order_dir=order_dir)
340 340
341 341 _my = PullRequestModel().get_not_reviewed(user_id)
342 342 my_participation = []
343 343 for pr in pull_requests:
344 344 if pr in _my:
345 345 my_participation.append(pr)
346 346 _filtered_pull_requests = my_participation
347 347 if length:
348 348 return _filtered_pull_requests[offset:offset+length]
349 349 else:
350 350 return _filtered_pull_requests
351 351
352 352 def get_not_reviewed(self, user_id):
353 353 return [
354 354 x.pull_request for x in PullRequestReviewers.query().filter(
355 355 PullRequestReviewers.user_id == user_id).all()
356 356 ]
357 357
358 358 def _prepare_participating_query(self, user_id=None, statuses=None,
359 359 order_by=None, order_dir='desc'):
360 360 q = PullRequest.query()
361 361 if user_id:
362 362 reviewers_subquery = Session().query(
363 363 PullRequestReviewers.pull_request_id).filter(
364 364 PullRequestReviewers.user_id == user_id).subquery()
365 365 user_filter = or_(
366 366 PullRequest.user_id == user_id,
367 367 PullRequest.pull_request_id.in_(reviewers_subquery)
368 368 )
369 369 q = PullRequest.query().filter(user_filter)
370 370
371 371 # closed,opened
372 372 if statuses:
373 373 q = q.filter(PullRequest.status.in_(statuses))
374 374
375 375 if order_by:
376 376 order_map = {
377 377 'name_raw': PullRequest.pull_request_id,
378 378 'title': PullRequest.title,
379 379 'updated_on_raw': PullRequest.updated_on,
380 380 'target_repo': PullRequest.target_repo_id
381 381 }
382 382 if order_dir == 'asc':
383 383 q = q.order_by(order_map[order_by].asc())
384 384 else:
385 385 q = q.order_by(order_map[order_by].desc())
386 386
387 387 return q
388 388
389 389 def count_im_participating_in(self, user_id=None, statuses=None):
390 390 q = self._prepare_participating_query(user_id, statuses=statuses)
391 391 return q.count()
392 392
393 393 def get_im_participating_in(
394 394 self, user_id=None, statuses=None, offset=0,
395 395 length=None, order_by=None, order_dir='desc'):
396 396 """
397 397 Get all Pull requests that i'm participating in, or i have opened
398 398 """
399 399
400 400 q = self._prepare_participating_query(
401 401 user_id, statuses=statuses, order_by=order_by,
402 402 order_dir=order_dir)
403 403
404 404 if length:
405 405 pull_requests = q.limit(length).offset(offset).all()
406 406 else:
407 407 pull_requests = q.all()
408 408
409 409 return pull_requests
410 410
411 411 def get_versions(self, pull_request):
412 412 """
413 413 returns version of pull request sorted by ID descending
414 414 """
415 415 return PullRequestVersion.query()\
416 416 .filter(PullRequestVersion.pull_request == pull_request)\
417 417 .order_by(PullRequestVersion.pull_request_version_id.asc())\
418 418 .all()
419 419
420 420 def get_pr_version(self, pull_request_id, version=None):
421 421 at_version = None
422 422
423 423 if version and version == 'latest':
424 424 pull_request_ver = PullRequest.get(pull_request_id)
425 425 pull_request_obj = pull_request_ver
426 426 _org_pull_request_obj = pull_request_obj
427 427 at_version = 'latest'
428 428 elif version:
429 429 pull_request_ver = PullRequestVersion.get_or_404(version)
430 430 pull_request_obj = pull_request_ver
431 431 _org_pull_request_obj = pull_request_ver.pull_request
432 432 at_version = pull_request_ver.pull_request_version_id
433 433 else:
434 434 _org_pull_request_obj = pull_request_obj = PullRequest.get_or_404(
435 435 pull_request_id)
436 436
437 437 pull_request_display_obj = PullRequest.get_pr_display_object(
438 438 pull_request_obj, _org_pull_request_obj)
439 439
440 440 return _org_pull_request_obj, pull_request_obj, \
441 441 pull_request_display_obj, at_version
442 442
443 443 def create(self, created_by, source_repo, source_ref, target_repo,
444 444 target_ref, revisions, reviewers, title, description=None,
445 445 description_renderer=None,
446 446 reviewer_data=None, translator=None, auth_user=None):
447 447 translator = translator or get_current_request().translate
448 448
449 449 created_by_user = self._get_user(created_by)
450 450 auth_user = auth_user or created_by_user.AuthUser()
451 451 source_repo = self._get_repo(source_repo)
452 452 target_repo = self._get_repo(target_repo)
453 453
454 454 pull_request = PullRequest()
455 455 pull_request.source_repo = source_repo
456 456 pull_request.source_ref = source_ref
457 457 pull_request.target_repo = target_repo
458 458 pull_request.target_ref = target_ref
459 459 pull_request.revisions = revisions
460 460 pull_request.title = title
461 461 pull_request.description = description
462 462 pull_request.description_renderer = description_renderer
463 463 pull_request.author = created_by_user
464 464 pull_request.reviewer_data = reviewer_data
465 465 pull_request.pull_request_state = pull_request.STATE_CREATING
466 466 Session().add(pull_request)
467 467 Session().flush()
468 468
469 469 reviewer_ids = set()
470 470 # members / reviewers
471 471 for reviewer_object in reviewers:
472 472 user_id, reasons, mandatory, rules = reviewer_object
473 473 user = self._get_user(user_id)
474 474
475 475 # skip duplicates
476 476 if user.user_id in reviewer_ids:
477 477 continue
478 478
479 479 reviewer_ids.add(user.user_id)
480 480
481 481 reviewer = PullRequestReviewers()
482 482 reviewer.user = user
483 483 reviewer.pull_request = pull_request
484 484 reviewer.reasons = reasons
485 485 reviewer.mandatory = mandatory
486 486
487 487 # NOTE(marcink): pick only first rule for now
488 488 rule_id = list(rules)[0] if rules else None
489 489 rule = RepoReviewRule.get(rule_id) if rule_id else None
490 490 if rule:
491 491 review_group = rule.user_group_vote_rule(user_id)
492 492 # we check if this particular reviewer is member of a voting group
493 493 if review_group:
494 494 # NOTE(marcink):
495 495 # can be that user is member of more but we pick the first same,
496 496 # same as default reviewers algo
497 497 review_group = review_group[0]
498 498
499 499 rule_data = {
500 500 'rule_name':
501 501 rule.review_rule_name,
502 502 'rule_user_group_entry_id':
503 503 review_group.repo_review_rule_users_group_id,
504 504 'rule_user_group_name':
505 505 review_group.users_group.users_group_name,
506 506 'rule_user_group_members':
507 507 [x.user.username for x in review_group.users_group.members],
508 508 'rule_user_group_members_id':
509 509 [x.user.user_id for x in review_group.users_group.members],
510 510 }
511 511 # e.g {'vote_rule': -1, 'mandatory': True}
512 512 rule_data.update(review_group.rule_data())
513 513
514 514 reviewer.rule_data = rule_data
515 515
516 516 Session().add(reviewer)
517 517 Session().flush()
518 518
519 519 # Set approval status to "Under Review" for all commits which are
520 520 # part of this pull request.
521 521 ChangesetStatusModel().set_status(
522 522 repo=target_repo,
523 523 status=ChangesetStatus.STATUS_UNDER_REVIEW,
524 524 user=created_by_user,
525 525 pull_request=pull_request
526 526 )
527 527 # we commit early at this point. This has to do with a fact
528 528 # that before queries do some row-locking. And because of that
529 529 # we need to commit and finish transaction before below validate call
530 530 # that for large repos could be long resulting in long row locks
531 531 Session().commit()
532 532
533 533 # prepare workspace, and run initial merge simulation. Set state during that
534 534 # operation
535 535 pull_request = PullRequest.get(pull_request.pull_request_id)
536 536
537 537 # set as merging, for merge simulation, and if finished to created so we mark
538 538 # simulation is working fine
539 539 with pull_request.set_state(PullRequest.STATE_MERGING,
540 540 final_state=PullRequest.STATE_CREATED) as state_obj:
541 541 MergeCheck.validate(
542 542 pull_request, auth_user=auth_user, translator=translator)
543 543
544 544 self.notify_reviewers(pull_request, reviewer_ids)
545 self.trigger_pull_request_hook(
546 pull_request, created_by_user, 'create')
545 self.trigger_pull_request_hook(pull_request, created_by_user, 'create')
547 546
548 547 creation_data = pull_request.get_api_data(with_merge_state=False)
549 548 self._log_audit_action(
550 549 'repo.pull_request.create', {'data': creation_data},
551 550 auth_user, pull_request)
552 551
553 552 return pull_request
554 553
555 554 def trigger_pull_request_hook(self, pull_request, user, action, data=None):
556 555 pull_request = self.__get_pull_request(pull_request)
557 556 target_scm = pull_request.target_repo.scm_instance()
558 557 if action == 'create':
559 trigger_hook = hooks_utils.trigger_log_create_pull_request_hook
558 trigger_hook = hooks_utils.trigger_create_pull_request_hook
560 559 elif action == 'merge':
561 trigger_hook = hooks_utils.trigger_log_merge_pull_request_hook
560 trigger_hook = hooks_utils.trigger_merge_pull_request_hook
562 561 elif action == 'close':
563 trigger_hook = hooks_utils.trigger_log_close_pull_request_hook
562 trigger_hook = hooks_utils.trigger_close_pull_request_hook
564 563 elif action == 'review_status_change':
565 trigger_hook = hooks_utils.trigger_log_review_pull_request_hook
564 trigger_hook = hooks_utils.trigger_review_pull_request_hook
566 565 elif action == 'update':
567 trigger_hook = hooks_utils.trigger_log_update_pull_request_hook
566 trigger_hook = hooks_utils.trigger_update_pull_request_hook
568 567 elif action == 'comment':
569 # dummy hook ! for comment. We want this function to handle all cases
570 def trigger_hook(*args, **kwargs):
571 pass
572 comment = data['comment']
573 events.trigger(events.PullRequestCommentEvent(pull_request, comment))
568 trigger_hook = hooks_utils.trigger_comment_pull_request_hook
574 569 else:
575 570 return
576 571
572 log.debug('Handling pull_request %s trigger_pull_request_hook with action %s and hook: %s',
573 pull_request, action, trigger_hook)
577 574 trigger_hook(
578 575 username=user.username,
579 576 repo_name=pull_request.target_repo.repo_name,
580 repo_alias=target_scm.alias,
577 repo_type=target_scm.alias,
581 578 pull_request=pull_request,
582 579 data=data)
583 580
584 581 def _get_commit_ids(self, pull_request):
585 582 """
586 583 Return the commit ids of the merged pull request.
587 584
588 585 This method is not dealing correctly yet with the lack of autoupdates
589 586 nor with the implicit target updates.
590 587 For example: if a commit in the source repo is already in the target it
591 588 will be reported anyways.
592 589 """
593 590 merge_rev = pull_request.merge_rev
594 591 if merge_rev is None:
595 592 raise ValueError('This pull request was not merged yet')
596 593
597 594 commit_ids = list(pull_request.revisions)
598 595 if merge_rev not in commit_ids:
599 596 commit_ids.append(merge_rev)
600 597
601 598 return commit_ids
602 599
603 600 def merge_repo(self, pull_request, user, extras):
604 601 log.debug("Merging pull request %s", pull_request.pull_request_id)
605 602 extras['user_agent'] = 'internal-merge'
606 603 merge_state = self._merge_pull_request(pull_request, user, extras)
607 604 if merge_state.executed:
608 605 log.debug("Merge was successful, updating the pull request comments.")
609 606 self._comment_and_close_pr(pull_request, user, merge_state)
610 607
611 608 self._log_audit_action(
612 609 'repo.pull_request.merge',
613 610 {'merge_state': merge_state.__dict__},
614 611 user, pull_request)
615 612
616 613 else:
617 614 log.warn("Merge failed, not updating the pull request.")
618 615 return merge_state
619 616
620 617 def _merge_pull_request(self, pull_request, user, extras, merge_msg=None):
621 618 target_vcs = pull_request.target_repo.scm_instance()
622 619 source_vcs = pull_request.source_repo.scm_instance()
623 620
624 621 message = safe_unicode(merge_msg or vcs_settings.MERGE_MESSAGE_TMPL).format(
625 622 pr_id=pull_request.pull_request_id,
626 623 pr_title=pull_request.title,
627 624 source_repo=source_vcs.name,
628 625 source_ref_name=pull_request.source_ref_parts.name,
629 626 target_repo=target_vcs.name,
630 627 target_ref_name=pull_request.target_ref_parts.name,
631 628 )
632 629
633 630 workspace_id = self._workspace_id(pull_request)
634 631 repo_id = pull_request.target_repo.repo_id
635 632 use_rebase = self._use_rebase_for_merging(pull_request)
636 633 close_branch = self._close_branch_before_merging(pull_request)
637 634 user_name = self._user_name_for_merging(pull_request, user)
638 635
639 636 target_ref = self._refresh_reference(
640 637 pull_request.target_ref_parts, target_vcs)
641 638
642 639 callback_daemon, extras = prepare_callback_daemon(
643 640 extras, protocol=vcs_settings.HOOKS_PROTOCOL,
644 641 host=vcs_settings.HOOKS_HOST,
645 642 use_direct_calls=vcs_settings.HOOKS_DIRECT_CALLS)
646 643
647 644 with callback_daemon:
648 645 # TODO: johbo: Implement a clean way to run a config_override
649 646 # for a single call.
650 647 target_vcs.config.set(
651 648 'rhodecode', 'RC_SCM_DATA', json.dumps(extras))
652 649
653 650 merge_state = target_vcs.merge(
654 651 repo_id, workspace_id, target_ref, source_vcs,
655 652 pull_request.source_ref_parts,
656 653 user_name=user_name, user_email=user.email,
657 654 message=message, use_rebase=use_rebase,
658 655 close_branch=close_branch)
659 656 return merge_state
660 657
661 658 def _comment_and_close_pr(self, pull_request, user, merge_state, close_msg=None):
662 659 pull_request.merge_rev = merge_state.merge_ref.commit_id
663 660 pull_request.updated_on = datetime.datetime.now()
664 661 close_msg = close_msg or 'Pull request merged and closed'
665 662
666 663 CommentsModel().create(
667 664 text=safe_unicode(close_msg),
668 665 repo=pull_request.target_repo.repo_id,
669 666 user=user.user_id,
670 667 pull_request=pull_request.pull_request_id,
671 668 f_path=None,
672 669 line_no=None,
673 670 closing_pr=True
674 671 )
675 672
676 673 Session().add(pull_request)
677 674 Session().flush()
678 675 # TODO: paris: replace invalidation with less radical solution
679 676 ScmModel().mark_for_invalidation(
680 677 pull_request.target_repo.repo_name)
681 678 self.trigger_pull_request_hook(pull_request, user, 'merge')
682 679
683 680 def has_valid_update_type(self, pull_request):
684 681 source_ref_type = pull_request.source_ref_parts.type
685 682 return source_ref_type in self.REF_TYPES
686 683
687 684 def update_commits(self, pull_request, updating_user):
688 685 """
689 686 Get the updated list of commits for the pull request
690 687 and return the new pull request version and the list
691 688 of commits processed by this update action
692 689
693 690 updating_user is the user_object who triggered the update
694 691 """
695 692 pull_request = self.__get_pull_request(pull_request)
696 693 source_ref_type = pull_request.source_ref_parts.type
697 694 source_ref_name = pull_request.source_ref_parts.name
698 695 source_ref_id = pull_request.source_ref_parts.commit_id
699 696
700 697 target_ref_type = pull_request.target_ref_parts.type
701 698 target_ref_name = pull_request.target_ref_parts.name
702 699 target_ref_id = pull_request.target_ref_parts.commit_id
703 700
704 701 if not self.has_valid_update_type(pull_request):
705 702 log.debug("Skipping update of pull request %s due to ref type: %s",
706 703 pull_request, source_ref_type)
707 704 return UpdateResponse(
708 705 executed=False,
709 706 reason=UpdateFailureReason.WRONG_REF_TYPE,
710 707 old=pull_request, new=None, common_ancestor_id=None, commit_changes=None,
711 708 source_changed=False, target_changed=False)
712 709
713 710 # source repo
714 711 source_repo = pull_request.source_repo.scm_instance()
715 712
716 713 try:
717 714 source_commit = source_repo.get_commit(commit_id=source_ref_name)
718 715 except CommitDoesNotExistError:
719 716 return UpdateResponse(
720 717 executed=False,
721 718 reason=UpdateFailureReason.MISSING_SOURCE_REF,
722 719 old=pull_request, new=None, common_ancestor_id=None, commit_changes=None,
723 720 source_changed=False, target_changed=False)
724 721
725 722 source_changed = source_ref_id != source_commit.raw_id
726 723
727 724 # target repo
728 725 target_repo = pull_request.target_repo.scm_instance()
729 726
730 727 try:
731 728 target_commit = target_repo.get_commit(commit_id=target_ref_name)
732 729 except CommitDoesNotExistError:
733 730 return UpdateResponse(
734 731 executed=False,
735 732 reason=UpdateFailureReason.MISSING_TARGET_REF,
736 733 old=pull_request, new=None, common_ancestor_id=None, commit_changes=None,
737 734 source_changed=False, target_changed=False)
738 735 target_changed = target_ref_id != target_commit.raw_id
739 736
740 737 if not (source_changed or target_changed):
741 738 log.debug("Nothing changed in pull request %s", pull_request)
742 739 return UpdateResponse(
743 740 executed=False,
744 741 reason=UpdateFailureReason.NO_CHANGE,
745 742 old=pull_request, new=None, common_ancestor_id=None, commit_changes=None,
746 743 source_changed=target_changed, target_changed=source_changed)
747 744
748 745 change_in_found = 'target repo' if target_changed else 'source repo'
749 746 log.debug('Updating pull request because of change in %s detected',
750 747 change_in_found)
751 748
752 749 # Finally there is a need for an update, in case of source change
753 750 # we create a new version, else just an update
754 751 if source_changed:
755 752 pull_request_version = self._create_version_from_snapshot(pull_request)
756 753 self._link_comments_to_version(pull_request_version)
757 754 else:
758 755 try:
759 756 ver = pull_request.versions[-1]
760 757 except IndexError:
761 758 ver = None
762 759
763 760 pull_request.pull_request_version_id = \
764 761 ver.pull_request_version_id if ver else None
765 762 pull_request_version = pull_request
766 763
767 764 try:
768 765 if target_ref_type in self.REF_TYPES:
769 766 target_commit = target_repo.get_commit(target_ref_name)
770 767 else:
771 768 target_commit = target_repo.get_commit(target_ref_id)
772 769 except CommitDoesNotExistError:
773 770 return UpdateResponse(
774 771 executed=False,
775 772 reason=UpdateFailureReason.MISSING_TARGET_REF,
776 773 old=pull_request, new=None, common_ancestor_id=None, commit_changes=None,
777 774 source_changed=source_changed, target_changed=target_changed)
778 775
779 776 # re-compute commit ids
780 777 old_commit_ids = pull_request.revisions
781 778 pre_load = ["author", "date", "message", "branch"]
782 779 commit_ranges = target_repo.compare(
783 780 target_commit.raw_id, source_commit.raw_id, source_repo, merge=True,
784 781 pre_load=pre_load)
785 782
786 783 ancestor_commit_id = source_repo.get_common_ancestor(
787 784 source_commit.raw_id, target_commit.raw_id, target_repo)
788 785
789 786 pull_request.source_ref = '%s:%s:%s' % (
790 787 source_ref_type, source_ref_name, source_commit.raw_id)
791 788 pull_request.target_ref = '%s:%s:%s' % (
792 789 target_ref_type, target_ref_name, ancestor_commit_id)
793 790
794 791 pull_request.revisions = [
795 792 commit.raw_id for commit in reversed(commit_ranges)]
796 793 pull_request.updated_on = datetime.datetime.now()
797 794 Session().add(pull_request)
798 795 new_commit_ids = pull_request.revisions
799 796
800 797 old_diff_data, new_diff_data = self._generate_update_diffs(
801 798 pull_request, pull_request_version)
802 799
803 800 # calculate commit and file changes
804 801 commit_changes = self._calculate_commit_id_changes(
805 802 old_commit_ids, new_commit_ids)
806 803 file_changes = self._calculate_file_changes(
807 804 old_diff_data, new_diff_data)
808 805
809 806 # set comments as outdated if DIFFS changed
810 807 CommentsModel().outdate_comments(
811 808 pull_request, old_diff_data=old_diff_data,
812 809 new_diff_data=new_diff_data)
813 810
814 811 valid_commit_changes = (commit_changes.added or commit_changes.removed)
815 812 file_node_changes = (
816 813 file_changes.added or file_changes.modified or file_changes.removed)
817 814 pr_has_changes = valid_commit_changes or file_node_changes
818 815
819 816 # Add an automatic comment to the pull request, in case
820 817 # anything has changed
821 818 if pr_has_changes:
822 819 update_comment = CommentsModel().create(
823 820 text=self._render_update_message(ancestor_commit_id, commit_changes, file_changes),
824 821 repo=pull_request.target_repo,
825 822 user=pull_request.author,
826 823 pull_request=pull_request,
827 824 send_email=False, renderer=DEFAULT_COMMENTS_RENDERER)
828 825
829 826 # Update status to "Under Review" for added commits
830 827 for commit_id in commit_changes.added:
831 828 ChangesetStatusModel().set_status(
832 829 repo=pull_request.source_repo,
833 830 status=ChangesetStatus.STATUS_UNDER_REVIEW,
834 831 comment=update_comment,
835 832 user=pull_request.author,
836 833 pull_request=pull_request,
837 834 revision=commit_id)
838 835
839 836 # send update email to users
840 837 try:
841 838 self.notify_users(pull_request=pull_request, updating_user=updating_user,
842 839 ancestor_commit_id=ancestor_commit_id,
843 840 commit_changes=commit_changes,
844 841 file_changes=file_changes)
845 842 except Exception:
846 843 log.exception('Failed to send email notification to users')
847 844
848 845 log.debug(
849 846 'Updated pull request %s, added_ids: %s, common_ids: %s, '
850 847 'removed_ids: %s', pull_request.pull_request_id,
851 848 commit_changes.added, commit_changes.common, commit_changes.removed)
852 849 log.debug(
853 850 'Updated pull request with the following file changes: %s',
854 851 file_changes)
855 852
856 853 log.info(
857 854 "Updated pull request %s from commit %s to commit %s, "
858 855 "stored new version %s of this pull request.",
859 856 pull_request.pull_request_id, source_ref_id,
860 857 pull_request.source_ref_parts.commit_id,
861 858 pull_request_version.pull_request_version_id)
862 859 Session().commit()
863 860 self.trigger_pull_request_hook(pull_request, pull_request.author, 'update')
864 861
865 862 return UpdateResponse(
866 863 executed=True, reason=UpdateFailureReason.NONE,
867 864 old=pull_request, new=pull_request_version,
868 865 common_ancestor_id=ancestor_commit_id, commit_changes=commit_changes,
869 866 source_changed=source_changed, target_changed=target_changed)
870 867
871 868 def _create_version_from_snapshot(self, pull_request):
872 869 version = PullRequestVersion()
873 870 version.title = pull_request.title
874 871 version.description = pull_request.description
875 872 version.status = pull_request.status
876 873 version.pull_request_state = pull_request.pull_request_state
877 874 version.created_on = datetime.datetime.now()
878 875 version.updated_on = pull_request.updated_on
879 876 version.user_id = pull_request.user_id
880 877 version.source_repo = pull_request.source_repo
881 878 version.source_ref = pull_request.source_ref
882 879 version.target_repo = pull_request.target_repo
883 880 version.target_ref = pull_request.target_ref
884 881
885 882 version._last_merge_source_rev = pull_request._last_merge_source_rev
886 883 version._last_merge_target_rev = pull_request._last_merge_target_rev
887 884 version.last_merge_status = pull_request.last_merge_status
888 885 version.last_merge_metadata = pull_request.last_merge_metadata
889 886 version.shadow_merge_ref = pull_request.shadow_merge_ref
890 887 version.merge_rev = pull_request.merge_rev
891 888 version.reviewer_data = pull_request.reviewer_data
892 889
893 890 version.revisions = pull_request.revisions
894 891 version.pull_request = pull_request
895 892 Session().add(version)
896 893 Session().flush()
897 894
898 895 return version
899 896
900 897 def _generate_update_diffs(self, pull_request, pull_request_version):
901 898
902 899 diff_context = (
903 900 self.DIFF_CONTEXT +
904 901 CommentsModel.needed_extra_diff_context())
905 902 hide_whitespace_changes = False
906 903 source_repo = pull_request_version.source_repo
907 904 source_ref_id = pull_request_version.source_ref_parts.commit_id
908 905 target_ref_id = pull_request_version.target_ref_parts.commit_id
909 906 old_diff = self._get_diff_from_pr_or_version(
910 907 source_repo, source_ref_id, target_ref_id,
911 908 hide_whitespace_changes=hide_whitespace_changes, diff_context=diff_context)
912 909
913 910 source_repo = pull_request.source_repo
914 911 source_ref_id = pull_request.source_ref_parts.commit_id
915 912 target_ref_id = pull_request.target_ref_parts.commit_id
916 913
917 914 new_diff = self._get_diff_from_pr_or_version(
918 915 source_repo, source_ref_id, target_ref_id,
919 916 hide_whitespace_changes=hide_whitespace_changes, diff_context=diff_context)
920 917
921 918 old_diff_data = diffs.DiffProcessor(old_diff)
922 919 old_diff_data.prepare()
923 920 new_diff_data = diffs.DiffProcessor(new_diff)
924 921 new_diff_data.prepare()
925 922
926 923 return old_diff_data, new_diff_data
927 924
928 925 def _link_comments_to_version(self, pull_request_version):
929 926 """
930 927 Link all unlinked comments of this pull request to the given version.
931 928
932 929 :param pull_request_version: The `PullRequestVersion` to which
933 930 the comments shall be linked.
934 931
935 932 """
936 933 pull_request = pull_request_version.pull_request
937 934 comments = ChangesetComment.query()\
938 935 .filter(
939 936 # TODO: johbo: Should we query for the repo at all here?
940 937 # Pending decision on how comments of PRs are to be related
941 938 # to either the source repo, the target repo or no repo at all.
942 939 ChangesetComment.repo_id == pull_request.target_repo.repo_id,
943 940 ChangesetComment.pull_request == pull_request,
944 941 ChangesetComment.pull_request_version == None)\
945 942 .order_by(ChangesetComment.comment_id.asc())
946 943
947 944 # TODO: johbo: Find out why this breaks if it is done in a bulk
948 945 # operation.
949 946 for comment in comments:
950 947 comment.pull_request_version_id = (
951 948 pull_request_version.pull_request_version_id)
952 949 Session().add(comment)
953 950
954 951 def _calculate_commit_id_changes(self, old_ids, new_ids):
955 952 added = [x for x in new_ids if x not in old_ids]
956 953 common = [x for x in new_ids if x in old_ids]
957 954 removed = [x for x in old_ids if x not in new_ids]
958 955 total = new_ids
959 956 return ChangeTuple(added, common, removed, total)
960 957
961 958 def _calculate_file_changes(self, old_diff_data, new_diff_data):
962 959
963 960 old_files = OrderedDict()
964 961 for diff_data in old_diff_data.parsed_diff:
965 962 old_files[diff_data['filename']] = md5_safe(diff_data['raw_diff'])
966 963
967 964 added_files = []
968 965 modified_files = []
969 966 removed_files = []
970 967 for diff_data in new_diff_data.parsed_diff:
971 968 new_filename = diff_data['filename']
972 969 new_hash = md5_safe(diff_data['raw_diff'])
973 970
974 971 old_hash = old_files.get(new_filename)
975 972 if not old_hash:
976 973 # file is not present in old diff, we have to figure out from parsed diff
977 974 # operation ADD/REMOVE
978 975 operations_dict = diff_data['stats']['ops']
979 976 if diffs.DEL_FILENODE in operations_dict:
980 977 removed_files.append(new_filename)
981 978 else:
982 979 added_files.append(new_filename)
983 980 else:
984 981 if new_hash != old_hash:
985 982 modified_files.append(new_filename)
986 983 # now remove a file from old, since we have seen it already
987 984 del old_files[new_filename]
988 985
989 986 # removed files is when there are present in old, but not in NEW,
990 987 # since we remove old files that are present in new diff, left-overs
991 988 # if any should be the removed files
992 989 removed_files.extend(old_files.keys())
993 990
994 991 return FileChangeTuple(added_files, modified_files, removed_files)
995 992
996 993 def _render_update_message(self, ancestor_commit_id, changes, file_changes):
997 994 """
998 995 render the message using DEFAULT_COMMENTS_RENDERER (RST renderer),
999 996 so it's always looking the same disregarding on which default
1000 997 renderer system is using.
1001 998
1002 999 :param ancestor_commit_id: ancestor raw_id
1003 1000 :param changes: changes named tuple
1004 1001 :param file_changes: file changes named tuple
1005 1002
1006 1003 """
1007 1004 new_status = ChangesetStatus.get_status_lbl(
1008 1005 ChangesetStatus.STATUS_UNDER_REVIEW)
1009 1006
1010 1007 changed_files = (
1011 1008 file_changes.added + file_changes.modified + file_changes.removed)
1012 1009
1013 1010 params = {
1014 1011 'under_review_label': new_status,
1015 1012 'added_commits': changes.added,
1016 1013 'removed_commits': changes.removed,
1017 1014 'changed_files': changed_files,
1018 1015 'added_files': file_changes.added,
1019 1016 'modified_files': file_changes.modified,
1020 1017 'removed_files': file_changes.removed,
1021 1018 'ancestor_commit_id': ancestor_commit_id
1022 1019 }
1023 1020 renderer = RstTemplateRenderer()
1024 1021 return renderer.render('pull_request_update.mako', **params)
1025 1022
1026 1023 def edit(self, pull_request, title, description, description_renderer, user):
1027 1024 pull_request = self.__get_pull_request(pull_request)
1028 1025 old_data = pull_request.get_api_data(with_merge_state=False)
1029 1026 if pull_request.is_closed():
1030 1027 raise ValueError('This pull request is closed')
1031 1028 if title:
1032 1029 pull_request.title = title
1033 1030 pull_request.description = description
1034 1031 pull_request.updated_on = datetime.datetime.now()
1035 1032 pull_request.description_renderer = description_renderer
1036 1033 Session().add(pull_request)
1037 1034 self._log_audit_action(
1038 1035 'repo.pull_request.edit', {'old_data': old_data},
1039 1036 user, pull_request)
1040 1037
1041 1038 def update_reviewers(self, pull_request, reviewer_data, user):
1042 1039 """
1043 1040 Update the reviewers in the pull request
1044 1041
1045 1042 :param pull_request: the pr to update
1046 1043 :param reviewer_data: list of tuples
1047 1044 [(user, ['reason1', 'reason2'], mandatory_flag, [rules])]
1048 1045 """
1049 1046 pull_request = self.__get_pull_request(pull_request)
1050 1047 if pull_request.is_closed():
1051 1048 raise ValueError('This pull request is closed')
1052 1049
1053 1050 reviewers = {}
1054 1051 for user_id, reasons, mandatory, rules in reviewer_data:
1055 1052 if isinstance(user_id, (int, compat.string_types)):
1056 1053 user_id = self._get_user(user_id).user_id
1057 1054 reviewers[user_id] = {
1058 1055 'reasons': reasons, 'mandatory': mandatory}
1059 1056
1060 1057 reviewers_ids = set(reviewers.keys())
1061 1058 current_reviewers = PullRequestReviewers.query()\
1062 1059 .filter(PullRequestReviewers.pull_request ==
1063 1060 pull_request).all()
1064 1061 current_reviewers_ids = set([x.user.user_id for x in current_reviewers])
1065 1062
1066 1063 ids_to_add = reviewers_ids.difference(current_reviewers_ids)
1067 1064 ids_to_remove = current_reviewers_ids.difference(reviewers_ids)
1068 1065
1069 1066 log.debug("Adding %s reviewers", ids_to_add)
1070 1067 log.debug("Removing %s reviewers", ids_to_remove)
1071 1068 changed = False
1072 1069 added_audit_reviewers = []
1073 1070 removed_audit_reviewers = []
1074 1071
1075 1072 for uid in ids_to_add:
1076 1073 changed = True
1077 1074 _usr = self._get_user(uid)
1078 1075 reviewer = PullRequestReviewers()
1079 1076 reviewer.user = _usr
1080 1077 reviewer.pull_request = pull_request
1081 1078 reviewer.reasons = reviewers[uid]['reasons']
1082 1079 # NOTE(marcink): mandatory shouldn't be changed now
1083 1080 # reviewer.mandatory = reviewers[uid]['reasons']
1084 1081 Session().add(reviewer)
1085 1082 added_audit_reviewers.append(reviewer.get_dict())
1086 1083
1087 1084 for uid in ids_to_remove:
1088 1085 changed = True
1089 1086 # NOTE(marcink): we fetch "ALL" reviewers using .all(). This is an edge case
1090 1087 # that prevents and fixes cases that we added the same reviewer twice.
1091 1088 # this CAN happen due to the lack of DB checks
1092 1089 reviewers = PullRequestReviewers.query()\
1093 1090 .filter(PullRequestReviewers.user_id == uid,
1094 1091 PullRequestReviewers.pull_request == pull_request)\
1095 1092 .all()
1096 1093
1097 1094 for obj in reviewers:
1098 1095 added_audit_reviewers.append(obj.get_dict())
1099 1096 Session().delete(obj)
1100 1097
1101 1098 if changed:
1102 1099 Session().expire_all()
1103 1100 pull_request.updated_on = datetime.datetime.now()
1104 1101 Session().add(pull_request)
1105 1102
1106 1103 # finally store audit logs
1107 1104 for user_data in added_audit_reviewers:
1108 1105 self._log_audit_action(
1109 1106 'repo.pull_request.reviewer.add', {'data': user_data},
1110 1107 user, pull_request)
1111 1108 for user_data in removed_audit_reviewers:
1112 1109 self._log_audit_action(
1113 1110 'repo.pull_request.reviewer.delete', {'old_data': user_data},
1114 1111 user, pull_request)
1115 1112
1116 1113 self.notify_reviewers(pull_request, ids_to_add)
1117 1114 return ids_to_add, ids_to_remove
1118 1115
1119 1116 def get_url(self, pull_request, request=None, permalink=False):
1120 1117 if not request:
1121 1118 request = get_current_request()
1122 1119
1123 1120 if permalink:
1124 1121 return request.route_url(
1125 1122 'pull_requests_global',
1126 1123 pull_request_id=pull_request.pull_request_id,)
1127 1124 else:
1128 1125 return request.route_url('pullrequest_show',
1129 1126 repo_name=safe_str(pull_request.target_repo.repo_name),
1130 1127 pull_request_id=pull_request.pull_request_id,)
1131 1128
1132 1129 def get_shadow_clone_url(self, pull_request, request=None):
1133 1130 """
1134 1131 Returns qualified url pointing to the shadow repository. If this pull
1135 1132 request is closed there is no shadow repository and ``None`` will be
1136 1133 returned.
1137 1134 """
1138 1135 if pull_request.is_closed():
1139 1136 return None
1140 1137 else:
1141 1138 pr_url = urllib.unquote(self.get_url(pull_request, request=request))
1142 1139 return safe_unicode('{pr_url}/repository'.format(pr_url=pr_url))
1143 1140
1144 1141 def notify_reviewers(self, pull_request, reviewers_ids):
1145 1142 # notification to reviewers
1146 1143 if not reviewers_ids:
1147 1144 return
1148 1145
1149 1146 log.debug('Notify following reviewers about pull-request %s', reviewers_ids)
1150 1147
1151 1148 pull_request_obj = pull_request
1152 1149 # get the current participants of this pull request
1153 1150 recipients = reviewers_ids
1154 1151 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST
1155 1152
1156 1153 pr_source_repo = pull_request_obj.source_repo
1157 1154 pr_target_repo = pull_request_obj.target_repo
1158 1155
1159 1156 pr_url = h.route_url('pullrequest_show',
1160 1157 repo_name=pr_target_repo.repo_name,
1161 1158 pull_request_id=pull_request_obj.pull_request_id,)
1162 1159
1163 1160 # set some variables for email notification
1164 1161 pr_target_repo_url = h.route_url(
1165 1162 'repo_summary', repo_name=pr_target_repo.repo_name)
1166 1163
1167 1164 pr_source_repo_url = h.route_url(
1168 1165 'repo_summary', repo_name=pr_source_repo.repo_name)
1169 1166
1170 1167 # pull request specifics
1171 1168 pull_request_commits = [
1172 1169 (x.raw_id, x.message)
1173 1170 for x in map(pr_source_repo.get_commit, pull_request.revisions)]
1174 1171
1175 1172 kwargs = {
1176 1173 'user': pull_request.author,
1177 1174 'pull_request': pull_request_obj,
1178 1175 'pull_request_commits': pull_request_commits,
1179 1176
1180 1177 'pull_request_target_repo': pr_target_repo,
1181 1178 'pull_request_target_repo_url': pr_target_repo_url,
1182 1179
1183 1180 'pull_request_source_repo': pr_source_repo,
1184 1181 'pull_request_source_repo_url': pr_source_repo_url,
1185 1182
1186 1183 'pull_request_url': pr_url,
1187 1184 }
1188 1185
1189 1186 # pre-generate the subject for notification itself
1190 1187 (subject,
1191 1188 _h, _e, # we don't care about those
1192 1189 body_plaintext) = EmailNotificationModel().render_email(
1193 1190 notification_type, **kwargs)
1194 1191
1195 1192 # create notification objects, and emails
1196 1193 NotificationModel().create(
1197 1194 created_by=pull_request.author,
1198 1195 notification_subject=subject,
1199 1196 notification_body=body_plaintext,
1200 1197 notification_type=notification_type,
1201 1198 recipients=recipients,
1202 1199 email_kwargs=kwargs,
1203 1200 )
1204 1201
1205 1202 def notify_users(self, pull_request, updating_user, ancestor_commit_id,
1206 1203 commit_changes, file_changes):
1207 1204
1208 1205 updating_user_id = updating_user.user_id
1209 1206 reviewers = set([x.user.user_id for x in pull_request.reviewers])
1210 1207 # NOTE(marcink): send notification to all other users except to
1211 1208 # person who updated the PR
1212 1209 recipients = reviewers.difference(set([updating_user_id]))
1213 1210
1214 1211 log.debug('Notify following recipients about pull-request update %s', recipients)
1215 1212
1216 1213 pull_request_obj = pull_request
1217 1214
1218 1215 # send email about the update
1219 1216 changed_files = (
1220 1217 file_changes.added + file_changes.modified + file_changes.removed)
1221 1218
1222 1219 pr_source_repo = pull_request_obj.source_repo
1223 1220 pr_target_repo = pull_request_obj.target_repo
1224 1221
1225 1222 pr_url = h.route_url('pullrequest_show',
1226 1223 repo_name=pr_target_repo.repo_name,
1227 1224 pull_request_id=pull_request_obj.pull_request_id,)
1228 1225
1229 1226 # set some variables for email notification
1230 1227 pr_target_repo_url = h.route_url(
1231 1228 'repo_summary', repo_name=pr_target_repo.repo_name)
1232 1229
1233 1230 pr_source_repo_url = h.route_url(
1234 1231 'repo_summary', repo_name=pr_source_repo.repo_name)
1235 1232
1236 1233 email_kwargs = {
1237 1234 'date': datetime.datetime.now(),
1238 1235 'updating_user': updating_user,
1239 1236
1240 1237 'pull_request': pull_request_obj,
1241 1238
1242 1239 'pull_request_target_repo': pr_target_repo,
1243 1240 'pull_request_target_repo_url': pr_target_repo_url,
1244 1241
1245 1242 'pull_request_source_repo': pr_source_repo,
1246 1243 'pull_request_source_repo_url': pr_source_repo_url,
1247 1244
1248 1245 'pull_request_url': pr_url,
1249 1246
1250 1247 'ancestor_commit_id': ancestor_commit_id,
1251 1248 'added_commits': commit_changes.added,
1252 1249 'removed_commits': commit_changes.removed,
1253 1250 'changed_files': changed_files,
1254 1251 'added_files': file_changes.added,
1255 1252 'modified_files': file_changes.modified,
1256 1253 'removed_files': file_changes.removed,
1257 1254 }
1258 1255
1259 1256 (subject,
1260 1257 _h, _e, # we don't care about those
1261 1258 body_plaintext) = EmailNotificationModel().render_email(
1262 1259 EmailNotificationModel.TYPE_PULL_REQUEST_UPDATE, **email_kwargs)
1263 1260
1264 1261 # create notification objects, and emails
1265 1262 NotificationModel().create(
1266 1263 created_by=updating_user,
1267 1264 notification_subject=subject,
1268 1265 notification_body=body_plaintext,
1269 1266 notification_type=EmailNotificationModel.TYPE_PULL_REQUEST_UPDATE,
1270 1267 recipients=recipients,
1271 1268 email_kwargs=email_kwargs,
1272 1269 )
1273 1270
1274 1271 def delete(self, pull_request, user):
1275 1272 pull_request = self.__get_pull_request(pull_request)
1276 1273 old_data = pull_request.get_api_data(with_merge_state=False)
1277 1274 self._cleanup_merge_workspace(pull_request)
1278 1275 self._log_audit_action(
1279 1276 'repo.pull_request.delete', {'old_data': old_data},
1280 1277 user, pull_request)
1281 1278 Session().delete(pull_request)
1282 1279
1283 1280 def close_pull_request(self, pull_request, user):
1284 1281 pull_request = self.__get_pull_request(pull_request)
1285 1282 self._cleanup_merge_workspace(pull_request)
1286 1283 pull_request.status = PullRequest.STATUS_CLOSED
1287 1284 pull_request.updated_on = datetime.datetime.now()
1288 1285 Session().add(pull_request)
1289 self.trigger_pull_request_hook(
1290 pull_request, pull_request.author, 'close')
1286 self.trigger_pull_request_hook(pull_request, pull_request.author, 'close')
1291 1287
1292 1288 pr_data = pull_request.get_api_data(with_merge_state=False)
1293 1289 self._log_audit_action(
1294 1290 'repo.pull_request.close', {'data': pr_data}, user, pull_request)
1295 1291
1296 1292 def close_pull_request_with_comment(
1297 1293 self, pull_request, user, repo, message=None, auth_user=None):
1298 1294
1299 1295 pull_request_review_status = pull_request.calculated_review_status()
1300 1296
1301 1297 if pull_request_review_status == ChangesetStatus.STATUS_APPROVED:
1302 1298 # approved only if we have voting consent
1303 1299 status = ChangesetStatus.STATUS_APPROVED
1304 1300 else:
1305 1301 status = ChangesetStatus.STATUS_REJECTED
1306 1302 status_lbl = ChangesetStatus.get_status_lbl(status)
1307 1303
1308 1304 default_message = (
1309 1305 'Closing with status change {transition_icon} {status}.'
1310 1306 ).format(transition_icon='>', status=status_lbl)
1311 1307 text = message or default_message
1312 1308
1313 1309 # create a comment, and link it to new status
1314 1310 comment = CommentsModel().create(
1315 1311 text=text,
1316 1312 repo=repo.repo_id,
1317 1313 user=user.user_id,
1318 1314 pull_request=pull_request.pull_request_id,
1319 1315 status_change=status_lbl,
1320 1316 status_change_type=status,
1321 1317 closing_pr=True,
1322 1318 auth_user=auth_user,
1323 1319 )
1324 1320
1325 1321 # calculate old status before we change it
1326 1322 old_calculated_status = pull_request.calculated_review_status()
1327 1323 ChangesetStatusModel().set_status(
1328 1324 repo.repo_id,
1329 1325 status,
1330 1326 user.user_id,
1331 1327 comment=comment,
1332 1328 pull_request=pull_request.pull_request_id
1333 1329 )
1334 1330
1335 1331 Session().flush()
1336 events.trigger(events.PullRequestCommentEvent(pull_request, comment))
1332
1333 self.trigger_pull_request_hook(pull_request, user, 'comment',
1334 data={'comment': comment})
1335
1337 1336 # we now calculate the status of pull request again, and based on that
1338 1337 # calculation trigger status change. This might happen in cases
1339 1338 # that non-reviewer admin closes a pr, which means his vote doesn't
1340 1339 # change the status, while if he's a reviewer this might change it.
1341 1340 calculated_status = pull_request.calculated_review_status()
1342 1341 if old_calculated_status != calculated_status:
1343 self.trigger_pull_request_hook(
1344 pull_request, user, 'review_status_change',
1345 data={'status': calculated_status})
1342 self.trigger_pull_request_hook(pull_request, user, 'review_status_change',
1343 data={'status': calculated_status})
1346 1344
1347 1345 # finally close the PR
1348 PullRequestModel().close_pull_request(
1349 pull_request.pull_request_id, user)
1346 PullRequestModel().close_pull_request(pull_request.pull_request_id, user)
1350 1347
1351 1348 return comment, status
1352 1349
1353 1350 def merge_status(self, pull_request, translator=None, force_shadow_repo_refresh=False):
1354 1351 _ = translator or get_current_request().translate
1355 1352
1356 1353 if not self._is_merge_enabled(pull_request):
1357 1354 return None, False, _('Server-side pull request merging is disabled.')
1358 1355
1359 1356 if pull_request.is_closed():
1360 1357 return None, False, _('This pull request is closed.')
1361 1358
1362 1359 merge_possible, msg = self._check_repo_requirements(
1363 1360 target=pull_request.target_repo, source=pull_request.source_repo,
1364 1361 translator=_)
1365 1362 if not merge_possible:
1366 1363 return None, merge_possible, msg
1367 1364
1368 1365 try:
1369 1366 merge_response = self._try_merge(
1370 1367 pull_request, force_shadow_repo_refresh=force_shadow_repo_refresh)
1371 1368 log.debug("Merge response: %s", merge_response)
1372 1369 return merge_response, merge_response.possible, merge_response.merge_status_message
1373 1370 except NotImplementedError:
1374 1371 return None, False, _('Pull request merging is not supported.')
1375 1372
1376 1373 def _check_repo_requirements(self, target, source, translator):
1377 1374 """
1378 1375 Check if `target` and `source` have compatible requirements.
1379 1376
1380 1377 Currently this is just checking for largefiles.
1381 1378 """
1382 1379 _ = translator
1383 1380 target_has_largefiles = self._has_largefiles(target)
1384 1381 source_has_largefiles = self._has_largefiles(source)
1385 1382 merge_possible = True
1386 1383 message = u''
1387 1384
1388 1385 if target_has_largefiles != source_has_largefiles:
1389 1386 merge_possible = False
1390 1387 if source_has_largefiles:
1391 1388 message = _(
1392 1389 'Target repository large files support is disabled.')
1393 1390 else:
1394 1391 message = _(
1395 1392 'Source repository large files support is disabled.')
1396 1393
1397 1394 return merge_possible, message
1398 1395
1399 1396 def _has_largefiles(self, repo):
1400 1397 largefiles_ui = VcsSettingsModel(repo=repo).get_ui_settings(
1401 1398 'extensions', 'largefiles')
1402 1399 return largefiles_ui and largefiles_ui[0].active
1403 1400
1404 1401 def _try_merge(self, pull_request, force_shadow_repo_refresh=False):
1405 1402 """
1406 1403 Try to merge the pull request and return the merge status.
1407 1404 """
1408 1405 log.debug(
1409 1406 "Trying out if the pull request %s can be merged. Force_refresh=%s",
1410 1407 pull_request.pull_request_id, force_shadow_repo_refresh)
1411 1408 target_vcs = pull_request.target_repo.scm_instance()
1412 1409 # Refresh the target reference.
1413 1410 try:
1414 1411 target_ref = self._refresh_reference(
1415 1412 pull_request.target_ref_parts, target_vcs)
1416 1413 except CommitDoesNotExistError:
1417 1414 merge_state = MergeResponse(
1418 1415 False, False, None, MergeFailureReason.MISSING_TARGET_REF,
1419 1416 metadata={'target_ref': pull_request.target_ref_parts})
1420 1417 return merge_state
1421 1418
1422 1419 target_locked = pull_request.target_repo.locked
1423 1420 if target_locked and target_locked[0]:
1424 1421 locked_by = 'user:{}'.format(target_locked[0])
1425 1422 log.debug("The target repository is locked by %s.", locked_by)
1426 1423 merge_state = MergeResponse(
1427 1424 False, False, None, MergeFailureReason.TARGET_IS_LOCKED,
1428 1425 metadata={'locked_by': locked_by})
1429 1426 elif force_shadow_repo_refresh or self._needs_merge_state_refresh(
1430 1427 pull_request, target_ref):
1431 1428 log.debug("Refreshing the merge status of the repository.")
1432 1429 merge_state = self._refresh_merge_state(
1433 1430 pull_request, target_vcs, target_ref)
1434 1431 else:
1435 1432 possible = pull_request.last_merge_status == MergeFailureReason.NONE
1436 1433 metadata = {
1437 1434 'unresolved_files': '',
1438 1435 'target_ref': pull_request.target_ref_parts,
1439 1436 'source_ref': pull_request.source_ref_parts,
1440 1437 }
1441 1438 if pull_request.last_merge_metadata:
1442 1439 metadata.update(pull_request.last_merge_metadata)
1443 1440
1444 1441 if not possible and target_ref.type == 'branch':
1445 1442 # NOTE(marcink): case for mercurial multiple heads on branch
1446 1443 heads = target_vcs._heads(target_ref.name)
1447 1444 if len(heads) != 1:
1448 1445 heads = '\n,'.join(target_vcs._heads(target_ref.name))
1449 1446 metadata.update({
1450 1447 'heads': heads
1451 1448 })
1452 1449
1453 1450 merge_state = MergeResponse(
1454 1451 possible, False, None, pull_request.last_merge_status, metadata=metadata)
1455 1452
1456 1453 return merge_state
1457 1454
1458 1455 def _refresh_reference(self, reference, vcs_repository):
1459 1456 if reference.type in self.UPDATABLE_REF_TYPES:
1460 1457 name_or_id = reference.name
1461 1458 else:
1462 1459 name_or_id = reference.commit_id
1463 1460
1464 1461 refreshed_commit = vcs_repository.get_commit(name_or_id)
1465 1462 refreshed_reference = Reference(
1466 1463 reference.type, reference.name, refreshed_commit.raw_id)
1467 1464 return refreshed_reference
1468 1465
1469 1466 def _needs_merge_state_refresh(self, pull_request, target_reference):
1470 1467 return not(
1471 1468 pull_request.revisions and
1472 1469 pull_request.revisions[0] == pull_request._last_merge_source_rev and
1473 1470 target_reference.commit_id == pull_request._last_merge_target_rev)
1474 1471
1475 1472 def _refresh_merge_state(self, pull_request, target_vcs, target_reference):
1476 1473 workspace_id = self._workspace_id(pull_request)
1477 1474 source_vcs = pull_request.source_repo.scm_instance()
1478 1475 repo_id = pull_request.target_repo.repo_id
1479 1476 use_rebase = self._use_rebase_for_merging(pull_request)
1480 1477 close_branch = self._close_branch_before_merging(pull_request)
1481 1478 merge_state = target_vcs.merge(
1482 1479 repo_id, workspace_id,
1483 1480 target_reference, source_vcs, pull_request.source_ref_parts,
1484 1481 dry_run=True, use_rebase=use_rebase,
1485 1482 close_branch=close_branch)
1486 1483
1487 1484 # Do not store the response if there was an unknown error.
1488 1485 if merge_state.failure_reason != MergeFailureReason.UNKNOWN:
1489 1486 pull_request._last_merge_source_rev = \
1490 1487 pull_request.source_ref_parts.commit_id
1491 1488 pull_request._last_merge_target_rev = target_reference.commit_id
1492 1489 pull_request.last_merge_status = merge_state.failure_reason
1493 1490 pull_request.last_merge_metadata = merge_state.metadata
1494 1491
1495 1492 pull_request.shadow_merge_ref = merge_state.merge_ref
1496 1493 Session().add(pull_request)
1497 1494 Session().commit()
1498 1495
1499 1496 return merge_state
1500 1497
1501 1498 def _workspace_id(self, pull_request):
1502 1499 workspace_id = 'pr-%s' % pull_request.pull_request_id
1503 1500 return workspace_id
1504 1501
1505 1502 def generate_repo_data(self, repo, commit_id=None, branch=None,
1506 1503 bookmark=None, translator=None):
1507 1504 from rhodecode.model.repo import RepoModel
1508 1505
1509 1506 all_refs, selected_ref = \
1510 1507 self._get_repo_pullrequest_sources(
1511 1508 repo.scm_instance(), commit_id=commit_id,
1512 1509 branch=branch, bookmark=bookmark, translator=translator)
1513 1510
1514 1511 refs_select2 = []
1515 1512 for element in all_refs:
1516 1513 children = [{'id': x[0], 'text': x[1]} for x in element[0]]
1517 1514 refs_select2.append({'text': element[1], 'children': children})
1518 1515
1519 1516 return {
1520 1517 'user': {
1521 1518 'user_id': repo.user.user_id,
1522 1519 'username': repo.user.username,
1523 1520 'firstname': repo.user.first_name,
1524 1521 'lastname': repo.user.last_name,
1525 1522 'gravatar_link': h.gravatar_url(repo.user.email, 14),
1526 1523 },
1527 1524 'name': repo.repo_name,
1528 1525 'link': RepoModel().get_url(repo),
1529 1526 'description': h.chop_at_smart(repo.description_safe, '\n'),
1530 1527 'refs': {
1531 1528 'all_refs': all_refs,
1532 1529 'selected_ref': selected_ref,
1533 1530 'select2_refs': refs_select2
1534 1531 }
1535 1532 }
1536 1533
1537 1534 def generate_pullrequest_title(self, source, source_ref, target):
1538 1535 return u'{source}#{at_ref} to {target}'.format(
1539 1536 source=source,
1540 1537 at_ref=source_ref,
1541 1538 target=target,
1542 1539 )
1543 1540
1544 1541 def _cleanup_merge_workspace(self, pull_request):
1545 1542 # Merging related cleanup
1546 1543 repo_id = pull_request.target_repo.repo_id
1547 1544 target_scm = pull_request.target_repo.scm_instance()
1548 1545 workspace_id = self._workspace_id(pull_request)
1549 1546
1550 1547 try:
1551 1548 target_scm.cleanup_merge_workspace(repo_id, workspace_id)
1552 1549 except NotImplementedError:
1553 1550 pass
1554 1551
1555 1552 def _get_repo_pullrequest_sources(
1556 1553 self, repo, commit_id=None, branch=None, bookmark=None,
1557 1554 translator=None):
1558 1555 """
1559 1556 Return a structure with repo's interesting commits, suitable for
1560 1557 the selectors in pullrequest controller
1561 1558
1562 1559 :param commit_id: a commit that must be in the list somehow
1563 1560 and selected by default
1564 1561 :param branch: a branch that must be in the list and selected
1565 1562 by default - even if closed
1566 1563 :param bookmark: a bookmark that must be in the list and selected
1567 1564 """
1568 1565 _ = translator or get_current_request().translate
1569 1566
1570 1567 commit_id = safe_str(commit_id) if commit_id else None
1571 1568 branch = safe_unicode(branch) if branch else None
1572 1569 bookmark = safe_unicode(bookmark) if bookmark else None
1573 1570
1574 1571 selected = None
1575 1572
1576 1573 # order matters: first source that has commit_id in it will be selected
1577 1574 sources = []
1578 1575 sources.append(('book', repo.bookmarks.items(), _('Bookmarks'), bookmark))
1579 1576 sources.append(('branch', repo.branches.items(), _('Branches'), branch))
1580 1577
1581 1578 if commit_id:
1582 1579 ref_commit = (h.short_id(commit_id), commit_id)
1583 1580 sources.append(('rev', [ref_commit], _('Commit IDs'), commit_id))
1584 1581
1585 1582 sources.append(
1586 1583 ('branch', repo.branches_closed.items(), _('Closed Branches'), branch),
1587 1584 )
1588 1585
1589 1586 groups = []
1590 1587
1591 1588 for group_key, ref_list, group_name, match in sources:
1592 1589 group_refs = []
1593 1590 for ref_name, ref_id in ref_list:
1594 1591 ref_key = u'{}:{}:{}'.format(group_key, ref_name, ref_id)
1595 1592 group_refs.append((ref_key, ref_name))
1596 1593
1597 1594 if not selected:
1598 1595 if set([commit_id, match]) & set([ref_id, ref_name]):
1599 1596 selected = ref_key
1600 1597
1601 1598 if group_refs:
1602 1599 groups.append((group_refs, group_name))
1603 1600
1604 1601 if not selected:
1605 1602 ref = commit_id or branch or bookmark
1606 1603 if ref:
1607 1604 raise CommitDoesNotExistError(
1608 1605 u'No commit refs could be found matching: {}'.format(ref))
1609 1606 elif repo.DEFAULT_BRANCH_NAME in repo.branches:
1610 1607 selected = u'branch:{}:{}'.format(
1611 1608 safe_unicode(repo.DEFAULT_BRANCH_NAME),
1612 1609 safe_unicode(repo.branches[repo.DEFAULT_BRANCH_NAME])
1613 1610 )
1614 1611 elif repo.commit_ids:
1615 1612 # make the user select in this case
1616 1613 selected = None
1617 1614 else:
1618 1615 raise EmptyRepositoryError()
1619 1616 return groups, selected
1620 1617
1621 1618 def get_diff(self, source_repo, source_ref_id, target_ref_id,
1622 1619 hide_whitespace_changes, diff_context):
1623 1620
1624 1621 return self._get_diff_from_pr_or_version(
1625 1622 source_repo, source_ref_id, target_ref_id,
1626 1623 hide_whitespace_changes=hide_whitespace_changes, diff_context=diff_context)
1627 1624
1628 1625 def _get_diff_from_pr_or_version(
1629 1626 self, source_repo, source_ref_id, target_ref_id,
1630 1627 hide_whitespace_changes, diff_context):
1631 1628
1632 1629 target_commit = source_repo.get_commit(
1633 1630 commit_id=safe_str(target_ref_id))
1634 1631 source_commit = source_repo.get_commit(
1635 1632 commit_id=safe_str(source_ref_id), maybe_unreachable=True)
1636 1633 if isinstance(source_repo, Repository):
1637 1634 vcs_repo = source_repo.scm_instance()
1638 1635 else:
1639 1636 vcs_repo = source_repo
1640 1637
1641 1638 # TODO: johbo: In the context of an update, we cannot reach
1642 1639 # the old commit anymore with our normal mechanisms. It needs
1643 1640 # some sort of special support in the vcs layer to avoid this
1644 1641 # workaround.
1645 1642 if (source_commit.raw_id == vcs_repo.EMPTY_COMMIT_ID and
1646 1643 vcs_repo.alias == 'git'):
1647 1644 source_commit.raw_id = safe_str(source_ref_id)
1648 1645
1649 1646 log.debug('calculating diff between '
1650 1647 'source_ref:%s and target_ref:%s for repo `%s`',
1651 1648 target_ref_id, source_ref_id,
1652 1649 safe_unicode(vcs_repo.path))
1653 1650
1654 1651 vcs_diff = vcs_repo.get_diff(
1655 1652 commit1=target_commit, commit2=source_commit,
1656 1653 ignore_whitespace=hide_whitespace_changes, context=diff_context)
1657 1654 return vcs_diff
1658 1655
1659 1656 def _is_merge_enabled(self, pull_request):
1660 1657 return self._get_general_setting(
1661 1658 pull_request, 'rhodecode_pr_merge_enabled')
1662 1659
1663 1660 def _use_rebase_for_merging(self, pull_request):
1664 1661 repo_type = pull_request.target_repo.repo_type
1665 1662 if repo_type == 'hg':
1666 1663 return self._get_general_setting(
1667 1664 pull_request, 'rhodecode_hg_use_rebase_for_merging')
1668 1665 elif repo_type == 'git':
1669 1666 return self._get_general_setting(
1670 1667 pull_request, 'rhodecode_git_use_rebase_for_merging')
1671 1668
1672 1669 return False
1673 1670
1674 1671 def _user_name_for_merging(self, pull_request, user):
1675 1672 env_user_name_attr = os.environ.get('RC_MERGE_USER_NAME_ATTR', '')
1676 1673 if env_user_name_attr and hasattr(user, env_user_name_attr):
1677 1674 user_name_attr = env_user_name_attr
1678 1675 else:
1679 1676 user_name_attr = 'short_contact'
1680 1677
1681 1678 user_name = getattr(user, user_name_attr)
1682 1679 return user_name
1683 1680
1684 1681 def _close_branch_before_merging(self, pull_request):
1685 1682 repo_type = pull_request.target_repo.repo_type
1686 1683 if repo_type == 'hg':
1687 1684 return self._get_general_setting(
1688 1685 pull_request, 'rhodecode_hg_close_branch_before_merging')
1689 1686 elif repo_type == 'git':
1690 1687 return self._get_general_setting(
1691 1688 pull_request, 'rhodecode_git_close_branch_before_merging')
1692 1689
1693 1690 return False
1694 1691
1695 1692 def _get_general_setting(self, pull_request, settings_key, default=False):
1696 1693 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1697 1694 settings = settings_model.get_general_settings()
1698 1695 return settings.get(settings_key, default)
1699 1696
1700 1697 def _log_audit_action(self, action, action_data, user, pull_request):
1701 1698 audit_logger.store(
1702 1699 action=action,
1703 1700 action_data=action_data,
1704 1701 user=user,
1705 1702 repo=pull_request.target_repo)
1706 1703
1707 1704 def get_reviewer_functions(self):
1708 1705 """
1709 1706 Fetches functions for validation and fetching default reviewers.
1710 1707 If available we use the EE package, else we fallback to CE
1711 1708 package functions
1712 1709 """
1713 1710 try:
1714 1711 from rc_reviewers.utils import get_default_reviewers_data
1715 1712 from rc_reviewers.utils import validate_default_reviewers
1716 1713 except ImportError:
1717 1714 from rhodecode.apps.repository.utils import get_default_reviewers_data
1718 1715 from rhodecode.apps.repository.utils import validate_default_reviewers
1719 1716
1720 1717 return get_default_reviewers_data, validate_default_reviewers
1721 1718
1722 1719
1723 1720 class MergeCheck(object):
1724 1721 """
1725 1722 Perform Merge Checks and returns a check object which stores information
1726 1723 about merge errors, and merge conditions
1727 1724 """
1728 1725 TODO_CHECK = 'todo'
1729 1726 PERM_CHECK = 'perm'
1730 1727 REVIEW_CHECK = 'review'
1731 1728 MERGE_CHECK = 'merge'
1732 1729 WIP_CHECK = 'wip'
1733 1730
1734 1731 def __init__(self):
1735 1732 self.review_status = None
1736 1733 self.merge_possible = None
1737 1734 self.merge_msg = ''
1738 1735 self.merge_response = None
1739 1736 self.failed = None
1740 1737 self.errors = []
1741 1738 self.error_details = OrderedDict()
1742 1739
1743 1740 def __repr__(self):
1744 1741 return '<MergeCheck(possible:{}, failed:{}, errors:{})>'.format(
1745 1742 self.merge_possible, self.failed, self.errors)
1746 1743
1747 1744 def push_error(self, error_type, message, error_key, details):
1748 1745 self.failed = True
1749 1746 self.errors.append([error_type, message])
1750 1747 self.error_details[error_key] = dict(
1751 1748 details=details,
1752 1749 error_type=error_type,
1753 1750 message=message
1754 1751 )
1755 1752
1756 1753 @classmethod
1757 1754 def validate(cls, pull_request, auth_user, translator, fail_early=False,
1758 1755 force_shadow_repo_refresh=False):
1759 1756 _ = translator
1760 1757 merge_check = cls()
1761 1758
1762 1759 # title has WIP:
1763 1760 if pull_request.work_in_progress:
1764 1761 log.debug("MergeCheck: cannot merge, title has wip: marker.")
1765 1762
1766 1763 msg = _('WIP marker in title prevents from accidental merge.')
1767 1764 merge_check.push_error('error', msg, cls.WIP_CHECK, pull_request.title)
1768 1765 if fail_early:
1769 1766 return merge_check
1770 1767
1771 1768 # permissions to merge
1772 1769 user_allowed_to_merge = PullRequestModel().check_user_merge(
1773 1770 pull_request, auth_user)
1774 1771 if not user_allowed_to_merge:
1775 1772 log.debug("MergeCheck: cannot merge, approval is pending.")
1776 1773
1777 1774 msg = _('User `{}` not allowed to perform merge.').format(auth_user.username)
1778 1775 merge_check.push_error('error', msg, cls.PERM_CHECK, auth_user.username)
1779 1776 if fail_early:
1780 1777 return merge_check
1781 1778
1782 1779 # permission to merge into the target branch
1783 1780 target_commit_id = pull_request.target_ref_parts.commit_id
1784 1781 if pull_request.target_ref_parts.type == 'branch':
1785 1782 branch_name = pull_request.target_ref_parts.name
1786 1783 else:
1787 1784 # for mercurial we can always figure out the branch from the commit
1788 1785 # in case of bookmark
1789 1786 target_commit = pull_request.target_repo.get_commit(target_commit_id)
1790 1787 branch_name = target_commit.branch
1791 1788
1792 1789 rule, branch_perm = auth_user.get_rule_and_branch_permission(
1793 1790 pull_request.target_repo.repo_name, branch_name)
1794 1791 if branch_perm and branch_perm == 'branch.none':
1795 1792 msg = _('Target branch `{}` changes rejected by rule {}.').format(
1796 1793 branch_name, rule)
1797 1794 merge_check.push_error('error', msg, cls.PERM_CHECK, auth_user.username)
1798 1795 if fail_early:
1799 1796 return merge_check
1800 1797
1801 1798 # review status, must be always present
1802 1799 review_status = pull_request.calculated_review_status()
1803 1800 merge_check.review_status = review_status
1804 1801
1805 1802 status_approved = review_status == ChangesetStatus.STATUS_APPROVED
1806 1803 if not status_approved:
1807 1804 log.debug("MergeCheck: cannot merge, approval is pending.")
1808 1805
1809 1806 msg = _('Pull request reviewer approval is pending.')
1810 1807
1811 1808 merge_check.push_error('warning', msg, cls.REVIEW_CHECK, review_status)
1812 1809
1813 1810 if fail_early:
1814 1811 return merge_check
1815 1812
1816 1813 # left over TODOs
1817 1814 todos = CommentsModel().get_pull_request_unresolved_todos(pull_request)
1818 1815 if todos:
1819 1816 log.debug("MergeCheck: cannot merge, {} "
1820 1817 "unresolved TODOs left.".format(len(todos)))
1821 1818
1822 1819 if len(todos) == 1:
1823 1820 msg = _('Cannot merge, {} TODO still not resolved.').format(
1824 1821 len(todos))
1825 1822 else:
1826 1823 msg = _('Cannot merge, {} TODOs still not resolved.').format(
1827 1824 len(todos))
1828 1825
1829 1826 merge_check.push_error('warning', msg, cls.TODO_CHECK, todos)
1830 1827
1831 1828 if fail_early:
1832 1829 return merge_check
1833 1830
1834 1831 # merge possible, here is the filesystem simulation + shadow repo
1835 1832 merge_response, merge_status, msg = PullRequestModel().merge_status(
1836 1833 pull_request, translator=translator,
1837 1834 force_shadow_repo_refresh=force_shadow_repo_refresh)
1838 1835
1839 1836 merge_check.merge_possible = merge_status
1840 1837 merge_check.merge_msg = msg
1841 1838 merge_check.merge_response = merge_response
1842 1839
1843 1840 if not merge_status:
1844 1841 log.debug("MergeCheck: cannot merge, pull request merge not possible.")
1845 1842 merge_check.push_error('warning', msg, cls.MERGE_CHECK, None)
1846 1843
1847 1844 if fail_early:
1848 1845 return merge_check
1849 1846
1850 1847 log.debug('MergeCheck: is failed: %s', merge_check.failed)
1851 1848 return merge_check
1852 1849
1853 1850 @classmethod
1854 1851 def get_merge_conditions(cls, pull_request, translator):
1855 1852 _ = translator
1856 1853 merge_details = {}
1857 1854
1858 1855 model = PullRequestModel()
1859 1856 use_rebase = model._use_rebase_for_merging(pull_request)
1860 1857
1861 1858 if use_rebase:
1862 1859 merge_details['merge_strategy'] = dict(
1863 1860 details={},
1864 1861 message=_('Merge strategy: rebase')
1865 1862 )
1866 1863 else:
1867 1864 merge_details['merge_strategy'] = dict(
1868 1865 details={},
1869 1866 message=_('Merge strategy: explicit merge commit')
1870 1867 )
1871 1868
1872 1869 close_branch = model._close_branch_before_merging(pull_request)
1873 1870 if close_branch:
1874 1871 repo_type = pull_request.target_repo.repo_type
1875 1872 close_msg = ''
1876 1873 if repo_type == 'hg':
1877 1874 close_msg = _('Source branch will be closed after merge.')
1878 1875 elif repo_type == 'git':
1879 1876 close_msg = _('Source branch will be deleted after merge.')
1880 1877
1881 1878 merge_details['close_branch'] = dict(
1882 1879 details={},
1883 1880 message=close_msg
1884 1881 )
1885 1882
1886 1883 return merge_details
1887 1884
1888 1885
1889 1886 ChangeTuple = collections.namedtuple(
1890 1887 'ChangeTuple', ['added', 'common', 'removed', 'total'])
1891 1888
1892 1889 FileChangeTuple = collections.namedtuple(
1893 1890 'FileChangeTuple', ['added', 'modified', 'removed'])
@@ -1,1020 +1,1020 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 """
22 22 Scm model for RhodeCode
23 23 """
24 24
25 25 import os.path
26 26 import traceback
27 27 import logging
28 28 import cStringIO
29 29
30 30 from sqlalchemy import func
31 31 from zope.cachedescriptors.property import Lazy as LazyProperty
32 32
33 33 import rhodecode
34 34 from rhodecode.lib.vcs import get_backend
35 35 from rhodecode.lib.vcs.exceptions import RepositoryError, NodeNotChangedError
36 36 from rhodecode.lib.vcs.nodes import FileNode
37 37 from rhodecode.lib.vcs.backends.base import EmptyCommit
38 38 from rhodecode.lib import helpers as h, rc_cache
39 39 from rhodecode.lib.auth import (
40 40 HasRepoPermissionAny, HasRepoGroupPermissionAny,
41 41 HasUserGroupPermissionAny)
42 42 from rhodecode.lib.exceptions import NonRelativePathError, IMCCommitError
43 43 from rhodecode.lib import hooks_utils
44 44 from rhodecode.lib.utils import (
45 45 get_filesystem_repos, make_db_config)
46 46 from rhodecode.lib.utils2 import (safe_str, safe_unicode)
47 47 from rhodecode.lib.system_info import get_system_info
48 48 from rhodecode.model import BaseModel
49 49 from rhodecode.model.db import (
50 50 or_, false,
51 51 Repository, CacheKey, UserFollowing, UserLog, User, RepoGroup,
52 52 PullRequest, FileStore)
53 53 from rhodecode.model.settings import VcsSettingsModel
54 54 from rhodecode.model.validation_schema.validators import url_validator, InvalidCloneUrl
55 55
56 56 log = logging.getLogger(__name__)
57 57
58 58
59 59 class UserTemp(object):
60 60 def __init__(self, user_id):
61 61 self.user_id = user_id
62 62
63 63 def __repr__(self):
64 64 return "<%s('id:%s')>" % (self.__class__.__name__, self.user_id)
65 65
66 66
67 67 class RepoTemp(object):
68 68 def __init__(self, repo_id):
69 69 self.repo_id = repo_id
70 70
71 71 def __repr__(self):
72 72 return "<%s('id:%s')>" % (self.__class__.__name__, self.repo_id)
73 73
74 74
75 75 class SimpleCachedRepoList(object):
76 76 """
77 77 Lighter version of of iteration of repos without the scm initialisation,
78 78 and with cache usage
79 79 """
80 80 def __init__(self, db_repo_list, repos_path, order_by=None, perm_set=None):
81 81 self.db_repo_list = db_repo_list
82 82 self.repos_path = repos_path
83 83 self.order_by = order_by
84 84 self.reversed = (order_by or '').startswith('-')
85 85 if not perm_set:
86 86 perm_set = ['repository.read', 'repository.write',
87 87 'repository.admin']
88 88 self.perm_set = perm_set
89 89
90 90 def __len__(self):
91 91 return len(self.db_repo_list)
92 92
93 93 def __repr__(self):
94 94 return '<%s (%s)>' % (self.__class__.__name__, self.__len__())
95 95
96 96 def __iter__(self):
97 97 for dbr in self.db_repo_list:
98 98 # check permission at this level
99 99 has_perm = HasRepoPermissionAny(*self.perm_set)(
100 100 dbr.repo_name, 'SimpleCachedRepoList check')
101 101 if not has_perm:
102 102 continue
103 103
104 104 tmp_d = {
105 105 'name': dbr.repo_name,
106 106 'dbrepo': dbr.get_dict(),
107 107 'dbrepo_fork': dbr.fork.get_dict() if dbr.fork else {}
108 108 }
109 109 yield tmp_d
110 110
111 111
112 112 class _PermCheckIterator(object):
113 113
114 114 def __init__(
115 115 self, obj_list, obj_attr, perm_set, perm_checker,
116 116 extra_kwargs=None):
117 117 """
118 118 Creates iterator from given list of objects, additionally
119 119 checking permission for them from perm_set var
120 120
121 121 :param obj_list: list of db objects
122 122 :param obj_attr: attribute of object to pass into perm_checker
123 123 :param perm_set: list of permissions to check
124 124 :param perm_checker: callable to check permissions against
125 125 """
126 126 self.obj_list = obj_list
127 127 self.obj_attr = obj_attr
128 128 self.perm_set = perm_set
129 129 self.perm_checker = perm_checker(*self.perm_set)
130 130 self.extra_kwargs = extra_kwargs or {}
131 131
132 132 def __len__(self):
133 133 return len(self.obj_list)
134 134
135 135 def __repr__(self):
136 136 return '<%s (%s)>' % (self.__class__.__name__, self.__len__())
137 137
138 138 def __iter__(self):
139 139 for db_obj in self.obj_list:
140 140 # check permission at this level
141 141 # NOTE(marcink): the __dict__.get() is ~4x faster then getattr()
142 142 name = db_obj.__dict__.get(self.obj_attr, None)
143 143 if not self.perm_checker(name, self.__class__.__name__, **self.extra_kwargs):
144 144 continue
145 145
146 146 yield db_obj
147 147
148 148
149 149 class RepoList(_PermCheckIterator):
150 150
151 151 def __init__(self, db_repo_list, perm_set=None, extra_kwargs=None):
152 152 if not perm_set:
153 153 perm_set = ['repository.read', 'repository.write', 'repository.admin']
154 154
155 155 super(RepoList, self).__init__(
156 156 obj_list=db_repo_list,
157 157 obj_attr='_repo_name', perm_set=perm_set,
158 158 perm_checker=HasRepoPermissionAny,
159 159 extra_kwargs=extra_kwargs)
160 160
161 161
162 162 class RepoGroupList(_PermCheckIterator):
163 163
164 164 def __init__(self, db_repo_group_list, perm_set=None, extra_kwargs=None):
165 165 if not perm_set:
166 166 perm_set = ['group.read', 'group.write', 'group.admin']
167 167
168 168 super(RepoGroupList, self).__init__(
169 169 obj_list=db_repo_group_list,
170 170 obj_attr='_group_name', perm_set=perm_set,
171 171 perm_checker=HasRepoGroupPermissionAny,
172 172 extra_kwargs=extra_kwargs)
173 173
174 174
175 175 class UserGroupList(_PermCheckIterator):
176 176
177 177 def __init__(self, db_user_group_list, perm_set=None, extra_kwargs=None):
178 178 if not perm_set:
179 179 perm_set = ['usergroup.read', 'usergroup.write', 'usergroup.admin']
180 180
181 181 super(UserGroupList, self).__init__(
182 182 obj_list=db_user_group_list,
183 183 obj_attr='users_group_name', perm_set=perm_set,
184 184 perm_checker=HasUserGroupPermissionAny,
185 185 extra_kwargs=extra_kwargs)
186 186
187 187
188 188 class ScmModel(BaseModel):
189 189 """
190 190 Generic Scm Model
191 191 """
192 192
193 193 @LazyProperty
194 194 def repos_path(self):
195 195 """
196 196 Gets the repositories root path from database
197 197 """
198 198
199 199 settings_model = VcsSettingsModel(sa=self.sa)
200 200 return settings_model.get_repos_location()
201 201
202 202 def repo_scan(self, repos_path=None):
203 203 """
204 204 Listing of repositories in given path. This path should not be a
205 205 repository itself. Return a dictionary of repository objects
206 206
207 207 :param repos_path: path to directory containing repositories
208 208 """
209 209
210 210 if repos_path is None:
211 211 repos_path = self.repos_path
212 212
213 213 log.info('scanning for repositories in %s', repos_path)
214 214
215 215 config = make_db_config()
216 216 config.set('extensions', 'largefiles', '')
217 217 repos = {}
218 218
219 219 for name, path in get_filesystem_repos(repos_path, recursive=True):
220 220 # name need to be decomposed and put back together using the /
221 221 # since this is internal storage separator for rhodecode
222 222 name = Repository.normalize_repo_name(name)
223 223
224 224 try:
225 225 if name in repos:
226 226 raise RepositoryError('Duplicate repository name %s '
227 227 'found in %s' % (name, path))
228 228 elif path[0] in rhodecode.BACKENDS:
229 229 backend = get_backend(path[0])
230 230 repos[name] = backend(path[1], config=config,
231 231 with_wire={"cache": False})
232 232 except OSError:
233 233 continue
234 234 log.debug('found %s paths with repositories', len(repos))
235 235 return repos
236 236
237 237 def get_repos(self, all_repos=None, sort_key=None):
238 238 """
239 239 Get all repositories from db and for each repo create it's
240 240 backend instance and fill that backed with information from database
241 241
242 242 :param all_repos: list of repository names as strings
243 243 give specific repositories list, good for filtering
244 244
245 245 :param sort_key: initial sorting of repositories
246 246 """
247 247 if all_repos is None:
248 248 all_repos = self.sa.query(Repository)\
249 249 .filter(Repository.group_id == None)\
250 250 .order_by(func.lower(Repository.repo_name)).all()
251 251 repo_iter = SimpleCachedRepoList(
252 252 all_repos, repos_path=self.repos_path, order_by=sort_key)
253 253 return repo_iter
254 254
255 255 def get_repo_groups(self, all_groups=None):
256 256 if all_groups is None:
257 257 all_groups = RepoGroup.query()\
258 258 .filter(RepoGroup.group_parent_id == None).all()
259 259 return [x for x in RepoGroupList(all_groups)]
260 260
261 261 def mark_for_invalidation(self, repo_name, delete=False):
262 262 """
263 263 Mark caches of this repo invalid in the database. `delete` flag
264 264 removes the cache entries
265 265
266 266 :param repo_name: the repo_name for which caches should be marked
267 267 invalid, or deleted
268 268 :param delete: delete the entry keys instead of setting bool
269 269 flag on them, and also purge caches used by the dogpile
270 270 """
271 271 repo = Repository.get_by_repo_name(repo_name)
272 272
273 273 if repo:
274 274 invalidation_namespace = CacheKey.REPO_INVALIDATION_NAMESPACE.format(
275 275 repo_id=repo.repo_id)
276 276 CacheKey.set_invalidate(invalidation_namespace, delete=delete)
277 277
278 278 repo_id = repo.repo_id
279 279 config = repo._config
280 280 config.set('extensions', 'largefiles', '')
281 281 repo.update_commit_cache(config=config, cs_cache=None)
282 282 if delete:
283 283 cache_namespace_uid = 'cache_repo.{}'.format(repo_id)
284 284 rc_cache.clear_cache_namespace('cache_repo', cache_namespace_uid)
285 285
286 286 def toggle_following_repo(self, follow_repo_id, user_id):
287 287
288 288 f = self.sa.query(UserFollowing)\
289 289 .filter(UserFollowing.follows_repo_id == follow_repo_id)\
290 290 .filter(UserFollowing.user_id == user_id).scalar()
291 291
292 292 if f is not None:
293 293 try:
294 294 self.sa.delete(f)
295 295 return
296 296 except Exception:
297 297 log.error(traceback.format_exc())
298 298 raise
299 299
300 300 try:
301 301 f = UserFollowing()
302 302 f.user_id = user_id
303 303 f.follows_repo_id = follow_repo_id
304 304 self.sa.add(f)
305 305 except Exception:
306 306 log.error(traceback.format_exc())
307 307 raise
308 308
309 309 def toggle_following_user(self, follow_user_id, user_id):
310 310 f = self.sa.query(UserFollowing)\
311 311 .filter(UserFollowing.follows_user_id == follow_user_id)\
312 312 .filter(UserFollowing.user_id == user_id).scalar()
313 313
314 314 if f is not None:
315 315 try:
316 316 self.sa.delete(f)
317 317 return
318 318 except Exception:
319 319 log.error(traceback.format_exc())
320 320 raise
321 321
322 322 try:
323 323 f = UserFollowing()
324 324 f.user_id = user_id
325 325 f.follows_user_id = follow_user_id
326 326 self.sa.add(f)
327 327 except Exception:
328 328 log.error(traceback.format_exc())
329 329 raise
330 330
331 331 def is_following_repo(self, repo_name, user_id, cache=False):
332 332 r = self.sa.query(Repository)\
333 333 .filter(Repository.repo_name == repo_name).scalar()
334 334
335 335 f = self.sa.query(UserFollowing)\
336 336 .filter(UserFollowing.follows_repository == r)\
337 337 .filter(UserFollowing.user_id == user_id).scalar()
338 338
339 339 return f is not None
340 340
341 341 def is_following_user(self, username, user_id, cache=False):
342 342 u = User.get_by_username(username)
343 343
344 344 f = self.sa.query(UserFollowing)\
345 345 .filter(UserFollowing.follows_user == u)\
346 346 .filter(UserFollowing.user_id == user_id).scalar()
347 347
348 348 return f is not None
349 349
350 350 def get_followers(self, repo):
351 351 repo = self._get_repo(repo)
352 352
353 353 return self.sa.query(UserFollowing)\
354 354 .filter(UserFollowing.follows_repository == repo).count()
355 355
356 356 def get_forks(self, repo):
357 357 repo = self._get_repo(repo)
358 358 return self.sa.query(Repository)\
359 359 .filter(Repository.fork == repo).count()
360 360
361 361 def get_pull_requests(self, repo):
362 362 repo = self._get_repo(repo)
363 363 return self.sa.query(PullRequest)\
364 364 .filter(PullRequest.target_repo == repo)\
365 365 .filter(PullRequest.status != PullRequest.STATUS_CLOSED).count()
366 366
367 367 def get_artifacts(self, repo):
368 368 repo = self._get_repo(repo)
369 369 return self.sa.query(FileStore)\
370 370 .filter(FileStore.repo == repo)\
371 371 .filter(or_(FileStore.hidden == None, FileStore.hidden == false())).count()
372 372
373 373 def mark_as_fork(self, repo, fork, user):
374 374 repo = self._get_repo(repo)
375 375 fork = self._get_repo(fork)
376 376 if fork and repo.repo_id == fork.repo_id:
377 377 raise Exception("Cannot set repository as fork of itself")
378 378
379 379 if fork and repo.repo_type != fork.repo_type:
380 380 raise RepositoryError(
381 381 "Cannot set repository as fork of repository with other type")
382 382
383 383 repo.fork = fork
384 384 self.sa.add(repo)
385 385 return repo
386 386
387 387 def pull_changes(self, repo, username, remote_uri=None, validate_uri=True):
388 388 dbrepo = self._get_repo(repo)
389 389 remote_uri = remote_uri or dbrepo.clone_uri
390 390 if not remote_uri:
391 391 raise Exception("This repository doesn't have a clone uri")
392 392
393 393 repo = dbrepo.scm_instance(cache=False)
394 394 repo.config.clear_section('hooks')
395 395
396 396 try:
397 397 # NOTE(marcink): add extra validation so we skip invalid urls
398 398 # this is due this tasks can be executed via scheduler without
399 399 # proper validation of remote_uri
400 400 if validate_uri:
401 401 config = make_db_config(clear_session=False)
402 402 url_validator(remote_uri, dbrepo.repo_type, config)
403 403 except InvalidCloneUrl:
404 404 raise
405 405
406 406 repo_name = dbrepo.repo_name
407 407 try:
408 408 # TODO: we need to make sure those operations call proper hooks !
409 409 repo.fetch(remote_uri)
410 410
411 411 self.mark_for_invalidation(repo_name)
412 412 except Exception:
413 413 log.error(traceback.format_exc())
414 414 raise
415 415
416 416 def push_changes(self, repo, username, remote_uri=None, validate_uri=True):
417 417 dbrepo = self._get_repo(repo)
418 418 remote_uri = remote_uri or dbrepo.push_uri
419 419 if not remote_uri:
420 420 raise Exception("This repository doesn't have a clone uri")
421 421
422 422 repo = dbrepo.scm_instance(cache=False)
423 423 repo.config.clear_section('hooks')
424 424
425 425 try:
426 426 # NOTE(marcink): add extra validation so we skip invalid urls
427 427 # this is due this tasks can be executed via scheduler without
428 428 # proper validation of remote_uri
429 429 if validate_uri:
430 430 config = make_db_config(clear_session=False)
431 431 url_validator(remote_uri, dbrepo.repo_type, config)
432 432 except InvalidCloneUrl:
433 433 raise
434 434
435 435 try:
436 436 repo.push(remote_uri)
437 437 except Exception:
438 438 log.error(traceback.format_exc())
439 439 raise
440 440
441 441 def commit_change(self, repo, repo_name, commit, user, author, message,
442 442 content, f_path):
443 443 """
444 444 Commits changes
445 445
446 446 :param repo: SCM instance
447 447
448 448 """
449 449 user = self._get_user(user)
450 450
451 451 # decoding here will force that we have proper encoded values
452 452 # in any other case this will throw exceptions and deny commit
453 453 content = safe_str(content)
454 454 path = safe_str(f_path)
455 455 # message and author needs to be unicode
456 456 # proper backend should then translate that into required type
457 457 message = safe_unicode(message)
458 458 author = safe_unicode(author)
459 459 imc = repo.in_memory_commit
460 460 imc.change(FileNode(path, content, mode=commit.get_file_mode(f_path)))
461 461 try:
462 462 # TODO: handle pre-push action !
463 463 tip = imc.commit(
464 464 message=message, author=author, parents=[commit],
465 465 branch=commit.branch)
466 466 except Exception as e:
467 467 log.error(traceback.format_exc())
468 468 raise IMCCommitError(str(e))
469 469 finally:
470 470 # always clear caches, if commit fails we want fresh object also
471 471 self.mark_for_invalidation(repo_name)
472 472
473 473 # We trigger the post-push action
474 474 hooks_utils.trigger_post_push_hook(
475 475 username=user.username, action='push_local', hook_type='post_push',
476 repo_name=repo_name, repo_alias=repo.alias, commit_ids=[tip.raw_id])
476 repo_name=repo_name, repo_type=repo.alias, commit_ids=[tip.raw_id])
477 477 return tip
478 478
479 479 def _sanitize_path(self, f_path):
480 480 if f_path.startswith('/') or f_path.startswith('./') or '../' in f_path:
481 481 raise NonRelativePathError('%s is not an relative path' % f_path)
482 482 if f_path:
483 483 f_path = os.path.normpath(f_path)
484 484 return f_path
485 485
486 486 def get_dirnode_metadata(self, request, commit, dir_node):
487 487 if not dir_node.is_dir():
488 488 return []
489 489
490 490 data = []
491 491 for node in dir_node:
492 492 if not node.is_file():
493 493 # we skip file-nodes
494 494 continue
495 495
496 496 last_commit = node.last_commit
497 497 last_commit_date = last_commit.date
498 498 data.append({
499 499 'name': node.name,
500 500 'size': h.format_byte_size_binary(node.size),
501 501 'modified_at': h.format_date(last_commit_date),
502 502 'modified_ts': last_commit_date.isoformat(),
503 503 'revision': last_commit.revision,
504 504 'short_id': last_commit.short_id,
505 505 'message': h.escape(last_commit.message),
506 506 'author': h.escape(last_commit.author),
507 507 'user_profile': h.gravatar_with_user(
508 508 request, last_commit.author),
509 509 })
510 510
511 511 return data
512 512
513 513 def get_nodes(self, repo_name, commit_id, root_path='/', flat=True,
514 514 extended_info=False, content=False, max_file_bytes=None):
515 515 """
516 516 recursive walk in root dir and return a set of all path in that dir
517 517 based on repository walk function
518 518
519 519 :param repo_name: name of repository
520 520 :param commit_id: commit id for which to list nodes
521 521 :param root_path: root path to list
522 522 :param flat: return as a list, if False returns a dict with description
523 523 :param extended_info: show additional info such as md5, binary, size etc
524 524 :param content: add nodes content to the return data
525 525 :param max_file_bytes: will not return file contents over this limit
526 526
527 527 """
528 528 _files = list()
529 529 _dirs = list()
530 530 try:
531 531 _repo = self._get_repo(repo_name)
532 532 commit = _repo.scm_instance().get_commit(commit_id=commit_id)
533 533 root_path = root_path.lstrip('/')
534 534 for __, dirs, files in commit.walk(root_path):
535 535
536 536 for f in files:
537 537 _content = None
538 538 _data = f_name = f.unicode_path
539 539
540 540 if not flat:
541 541 _data = {
542 542 "name": h.escape(f_name),
543 543 "type": "file",
544 544 }
545 545 if extended_info:
546 546 _data.update({
547 547 "md5": f.md5,
548 548 "binary": f.is_binary,
549 549 "size": f.size,
550 550 "extension": f.extension,
551 551 "mimetype": f.mimetype,
552 552 "lines": f.lines()[0]
553 553 })
554 554
555 555 if content:
556 556 over_size_limit = (max_file_bytes is not None
557 557 and f.size > max_file_bytes)
558 558 full_content = None
559 559 if not f.is_binary and not over_size_limit:
560 560 full_content = safe_str(f.content)
561 561
562 562 _data.update({
563 563 "content": full_content,
564 564 })
565 565 _files.append(_data)
566 566
567 567 for d in dirs:
568 568 _data = d_name = d.unicode_path
569 569 if not flat:
570 570 _data = {
571 571 "name": h.escape(d_name),
572 572 "type": "dir",
573 573 }
574 574 if extended_info:
575 575 _data.update({
576 576 "md5": None,
577 577 "binary": None,
578 578 "size": None,
579 579 "extension": None,
580 580 })
581 581 if content:
582 582 _data.update({
583 583 "content": None
584 584 })
585 585 _dirs.append(_data)
586 586 except RepositoryError:
587 587 log.exception("Exception in get_nodes")
588 588 raise
589 589
590 590 return _dirs, _files
591 591
592 592 def get_quick_filter_nodes(self, repo_name, commit_id, root_path='/'):
593 593 """
594 594 Generate files for quick filter in files view
595 595 """
596 596
597 597 _files = list()
598 598 _dirs = list()
599 599 try:
600 600 _repo = self._get_repo(repo_name)
601 601 commit = _repo.scm_instance().get_commit(commit_id=commit_id)
602 602 root_path = root_path.lstrip('/')
603 603 for __, dirs, files in commit.walk(root_path):
604 604
605 605 for f in files:
606 606
607 607 _data = {
608 608 "name": h.escape(f.unicode_path),
609 609 "type": "file",
610 610 }
611 611
612 612 _files.append(_data)
613 613
614 614 for d in dirs:
615 615
616 616 _data = {
617 617 "name": h.escape(d.unicode_path),
618 618 "type": "dir",
619 619 }
620 620
621 621 _dirs.append(_data)
622 622 except RepositoryError:
623 623 log.exception("Exception in get_quick_filter_nodes")
624 624 raise
625 625
626 626 return _dirs, _files
627 627
628 628 def get_node(self, repo_name, commit_id, file_path,
629 629 extended_info=False, content=False, max_file_bytes=None, cache=True):
630 630 """
631 631 retrieve single node from commit
632 632 """
633 633 try:
634 634
635 635 _repo = self._get_repo(repo_name)
636 636 commit = _repo.scm_instance().get_commit(commit_id=commit_id)
637 637
638 638 file_node = commit.get_node(file_path)
639 639 if file_node.is_dir():
640 640 raise RepositoryError('The given path is a directory')
641 641
642 642 _content = None
643 643 f_name = file_node.unicode_path
644 644
645 645 file_data = {
646 646 "name": h.escape(f_name),
647 647 "type": "file",
648 648 }
649 649
650 650 if extended_info:
651 651 file_data.update({
652 652 "extension": file_node.extension,
653 653 "mimetype": file_node.mimetype,
654 654 })
655 655
656 656 if cache:
657 657 md5 = file_node.md5
658 658 is_binary = file_node.is_binary
659 659 size = file_node.size
660 660 else:
661 661 is_binary, md5, size, _content = file_node.metadata_uncached()
662 662
663 663 file_data.update({
664 664 "md5": md5,
665 665 "binary": is_binary,
666 666 "size": size,
667 667 })
668 668
669 669 if content and cache:
670 670 # get content + cache
671 671 size = file_node.size
672 672 over_size_limit = (max_file_bytes is not None and size > max_file_bytes)
673 673 full_content = None
674 674 all_lines = 0
675 675 if not file_node.is_binary and not over_size_limit:
676 676 full_content = safe_unicode(file_node.content)
677 677 all_lines, empty_lines = file_node.count_lines(full_content)
678 678
679 679 file_data.update({
680 680 "content": full_content,
681 681 "lines": all_lines
682 682 })
683 683 elif content:
684 684 # get content *without* cache
685 685 if _content is None:
686 686 is_binary, md5, size, _content = file_node.metadata_uncached()
687 687
688 688 over_size_limit = (max_file_bytes is not None and size > max_file_bytes)
689 689 full_content = None
690 690 all_lines = 0
691 691 if not is_binary and not over_size_limit:
692 692 full_content = safe_unicode(_content)
693 693 all_lines, empty_lines = file_node.count_lines(full_content)
694 694
695 695 file_data.update({
696 696 "content": full_content,
697 697 "lines": all_lines
698 698 })
699 699
700 700 except RepositoryError:
701 701 log.exception("Exception in get_node")
702 702 raise
703 703
704 704 return file_data
705 705
706 706 def get_fts_data(self, repo_name, commit_id, root_path='/'):
707 707 """
708 708 Fetch node tree for usage in full text search
709 709 """
710 710
711 711 tree_info = list()
712 712
713 713 try:
714 714 _repo = self._get_repo(repo_name)
715 715 commit = _repo.scm_instance().get_commit(commit_id=commit_id)
716 716 root_path = root_path.lstrip('/')
717 717 for __, dirs, files in commit.walk(root_path):
718 718
719 719 for f in files:
720 720 is_binary, md5, size, _content = f.metadata_uncached()
721 721 _data = {
722 722 "name": f.unicode_path,
723 723 "md5": md5,
724 724 "extension": f.extension,
725 725 "binary": is_binary,
726 726 "size": size
727 727 }
728 728
729 729 tree_info.append(_data)
730 730
731 731 except RepositoryError:
732 732 log.exception("Exception in get_nodes")
733 733 raise
734 734
735 735 return tree_info
736 736
737 737 def create_nodes(self, user, repo, message, nodes, parent_commit=None,
738 738 author=None, trigger_push_hook=True):
739 739 """
740 740 Commits given multiple nodes into repo
741 741
742 742 :param user: RhodeCode User object or user_id, the commiter
743 743 :param repo: RhodeCode Repository object
744 744 :param message: commit message
745 745 :param nodes: mapping {filename:{'content':content},...}
746 746 :param parent_commit: parent commit, can be empty than it's
747 747 initial commit
748 748 :param author: author of commit, cna be different that commiter
749 749 only for git
750 750 :param trigger_push_hook: trigger push hooks
751 751
752 752 :returns: new commited commit
753 753 """
754 754
755 755 user = self._get_user(user)
756 756 scm_instance = repo.scm_instance(cache=False)
757 757
758 758 processed_nodes = []
759 759 for f_path in nodes:
760 760 f_path = self._sanitize_path(f_path)
761 761 content = nodes[f_path]['content']
762 762 f_path = safe_str(f_path)
763 763 # decoding here will force that we have proper encoded values
764 764 # in any other case this will throw exceptions and deny commit
765 765 if isinstance(content, (basestring,)):
766 766 content = safe_str(content)
767 767 elif isinstance(content, (file, cStringIO.OutputType,)):
768 768 content = content.read()
769 769 else:
770 770 raise Exception('Content is of unrecognized type %s' % (
771 771 type(content)
772 772 ))
773 773 processed_nodes.append((f_path, content))
774 774
775 775 message = safe_unicode(message)
776 776 commiter = user.full_contact
777 777 author = safe_unicode(author) if author else commiter
778 778
779 779 imc = scm_instance.in_memory_commit
780 780
781 781 if not parent_commit:
782 782 parent_commit = EmptyCommit(alias=scm_instance.alias)
783 783
784 784 if isinstance(parent_commit, EmptyCommit):
785 785 # EmptyCommit means we we're editing empty repository
786 786 parents = None
787 787 else:
788 788 parents = [parent_commit]
789 789 # add multiple nodes
790 790 for path, content in processed_nodes:
791 791 imc.add(FileNode(path, content=content))
792 792 # TODO: handle pre push scenario
793 793 tip = imc.commit(message=message,
794 794 author=author,
795 795 parents=parents,
796 796 branch=parent_commit.branch)
797 797
798 798 self.mark_for_invalidation(repo.repo_name)
799 799 if trigger_push_hook:
800 800 hooks_utils.trigger_post_push_hook(
801 801 username=user.username, action='push_local',
802 repo_name=repo.repo_name, repo_alias=scm_instance.alias,
802 repo_name=repo.repo_name, repo_type=scm_instance.alias,
803 803 hook_type='post_push',
804 804 commit_ids=[tip.raw_id])
805 805 return tip
806 806
807 807 def update_nodes(self, user, repo, message, nodes, parent_commit=None,
808 808 author=None, trigger_push_hook=True):
809 809 user = self._get_user(user)
810 810 scm_instance = repo.scm_instance(cache=False)
811 811
812 812 message = safe_unicode(message)
813 813 commiter = user.full_contact
814 814 author = safe_unicode(author) if author else commiter
815 815
816 816 imc = scm_instance.in_memory_commit
817 817
818 818 if not parent_commit:
819 819 parent_commit = EmptyCommit(alias=scm_instance.alias)
820 820
821 821 if isinstance(parent_commit, EmptyCommit):
822 822 # EmptyCommit means we we're editing empty repository
823 823 parents = None
824 824 else:
825 825 parents = [parent_commit]
826 826
827 827 # add multiple nodes
828 828 for _filename, data in nodes.items():
829 829 # new filename, can be renamed from the old one, also sanitaze
830 830 # the path for any hack around relative paths like ../../ etc.
831 831 filename = self._sanitize_path(data['filename'])
832 832 old_filename = self._sanitize_path(_filename)
833 833 content = data['content']
834 834 file_mode = data.get('mode')
835 835 filenode = FileNode(old_filename, content=content, mode=file_mode)
836 836 op = data['op']
837 837 if op == 'add':
838 838 imc.add(filenode)
839 839 elif op == 'del':
840 840 imc.remove(filenode)
841 841 elif op == 'mod':
842 842 if filename != old_filename:
843 843 # TODO: handle renames more efficient, needs vcs lib changes
844 844 imc.remove(filenode)
845 845 imc.add(FileNode(filename, content=content, mode=file_mode))
846 846 else:
847 847 imc.change(filenode)
848 848
849 849 try:
850 850 # TODO: handle pre push scenario commit changes
851 851 tip = imc.commit(message=message,
852 852 author=author,
853 853 parents=parents,
854 854 branch=parent_commit.branch)
855 855 except NodeNotChangedError:
856 856 raise
857 857 except Exception as e:
858 858 log.exception("Unexpected exception during call to imc.commit")
859 859 raise IMCCommitError(str(e))
860 860 finally:
861 861 # always clear caches, if commit fails we want fresh object also
862 862 self.mark_for_invalidation(repo.repo_name)
863 863
864 864 if trigger_push_hook:
865 865 hooks_utils.trigger_post_push_hook(
866 866 username=user.username, action='push_local', hook_type='post_push',
867 repo_name=repo.repo_name, repo_alias=scm_instance.alias,
867 repo_name=repo.repo_name, repo_type=scm_instance.alias,
868 868 commit_ids=[tip.raw_id])
869 869
870 870 return tip
871 871
872 872 def delete_nodes(self, user, repo, message, nodes, parent_commit=None,
873 873 author=None, trigger_push_hook=True):
874 874 """
875 875 Deletes given multiple nodes into `repo`
876 876
877 877 :param user: RhodeCode User object or user_id, the committer
878 878 :param repo: RhodeCode Repository object
879 879 :param message: commit message
880 880 :param nodes: mapping {filename:{'content':content},...}
881 881 :param parent_commit: parent commit, can be empty than it's initial
882 882 commit
883 883 :param author: author of commit, cna be different that commiter only
884 884 for git
885 885 :param trigger_push_hook: trigger push hooks
886 886
887 887 :returns: new commit after deletion
888 888 """
889 889
890 890 user = self._get_user(user)
891 891 scm_instance = repo.scm_instance(cache=False)
892 892
893 893 processed_nodes = []
894 894 for f_path in nodes:
895 895 f_path = self._sanitize_path(f_path)
896 896 # content can be empty but for compatabilty it allows same dicts
897 897 # structure as add_nodes
898 898 content = nodes[f_path].get('content')
899 899 processed_nodes.append((f_path, content))
900 900
901 901 message = safe_unicode(message)
902 902 commiter = user.full_contact
903 903 author = safe_unicode(author) if author else commiter
904 904
905 905 imc = scm_instance.in_memory_commit
906 906
907 907 if not parent_commit:
908 908 parent_commit = EmptyCommit(alias=scm_instance.alias)
909 909
910 910 if isinstance(parent_commit, EmptyCommit):
911 911 # EmptyCommit means we we're editing empty repository
912 912 parents = None
913 913 else:
914 914 parents = [parent_commit]
915 915 # add multiple nodes
916 916 for path, content in processed_nodes:
917 917 imc.remove(FileNode(path, content=content))
918 918
919 919 # TODO: handle pre push scenario
920 920 tip = imc.commit(message=message,
921 921 author=author,
922 922 parents=parents,
923 923 branch=parent_commit.branch)
924 924
925 925 self.mark_for_invalidation(repo.repo_name)
926 926 if trigger_push_hook:
927 927 hooks_utils.trigger_post_push_hook(
928 928 username=user.username, action='push_local', hook_type='post_push',
929 repo_name=repo.repo_name, repo_alias=scm_instance.alias,
929 repo_name=repo.repo_name, repo_type=scm_instance.alias,
930 930 commit_ids=[tip.raw_id])
931 931 return tip
932 932
933 933 def strip(self, repo, commit_id, branch):
934 934 scm_instance = repo.scm_instance(cache=False)
935 935 scm_instance.config.clear_section('hooks')
936 936 scm_instance.strip(commit_id, branch)
937 937 self.mark_for_invalidation(repo.repo_name)
938 938
939 939 def get_unread_journal(self):
940 940 return self.sa.query(UserLog).count()
941 941
942 942 @classmethod
943 943 def backend_landing_ref(cls, repo_type):
944 944 """
945 945 Return a default landing ref based on a repository type.
946 946 """
947 947
948 948 landing_ref = {
949 949 'hg': ('branch:default', 'default'),
950 950 'git': ('branch:master', 'master'),
951 951 'svn': ('rev:tip', 'latest tip'),
952 952 'default': ('rev:tip', 'latest tip'),
953 953 }
954 954
955 955 return landing_ref.get(repo_type) or landing_ref['default']
956 956
957 957 def get_repo_landing_revs(self, translator, repo=None):
958 958 """
959 959 Generates select option with tags branches and bookmarks (for hg only)
960 960 grouped by type
961 961
962 962 :param repo:
963 963 """
964 964 _ = translator
965 965 repo = self._get_repo(repo)
966 966
967 967 if repo:
968 968 repo_type = repo.repo_type
969 969 else:
970 970 repo_type = 'default'
971 971
972 972 default_landing_ref, landing_ref_lbl = self.backend_landing_ref(repo_type)
973 973
974 974 default_ref_options = [
975 975 [default_landing_ref, landing_ref_lbl]
976 976 ]
977 977 default_choices = [
978 978 default_landing_ref
979 979 ]
980 980
981 981 if not repo:
982 982 return default_choices, default_ref_options
983 983
984 984 repo = repo.scm_instance()
985 985
986 986 ref_options = [('rev:tip', 'latest tip')]
987 987 choices = ['rev:tip']
988 988
989 989 # branches
990 990 branch_group = [(u'branch:%s' % safe_unicode(b), safe_unicode(b)) for b in repo.branches]
991 991 if not branch_group:
992 992 # new repo, or without maybe a branch?
993 993 branch_group = default_ref_options
994 994
995 995 branches_group = (branch_group, _("Branches"))
996 996 ref_options.append(branches_group)
997 997 choices.extend([x[0] for x in branches_group[0]])
998 998
999 999 # bookmarks for HG
1000 1000 if repo.alias == 'hg':
1001 1001 bookmarks_group = (
1002 1002 [(u'book:%s' % safe_unicode(b), safe_unicode(b))
1003 1003 for b in repo.bookmarks],
1004 1004 _("Bookmarks"))
1005 1005 ref_options.append(bookmarks_group)
1006 1006 choices.extend([x[0] for x in bookmarks_group[0]])
1007 1007
1008 1008 # tags
1009 1009 tags_group = (
1010 1010 [(u'tag:%s' % safe_unicode(t), safe_unicode(t))
1011 1011 for t in repo.tags],
1012 1012 _("Tags"))
1013 1013 ref_options.append(tags_group)
1014 1014 choices.extend([x[0] for x in tags_group[0]])
1015 1015
1016 1016 return choices, ref_options
1017 1017
1018 1018 def get_server_info(self, environ=None):
1019 1019 server_info = get_system_info(environ)
1020 1020 return server_info
General Comments 0
You need to be logged in to leave comments. Login now