##// END OF EJS Templates
pr: Unify clone url generation of shadow repository.
Martin Bornhold -
r897:b8b5fdac default
parent child Browse files
Show More

The requested changes are too big and content was truncated. Show full diff

@@ -1,513 +1,510 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2014-2016 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 SimpleVCS middleware for handling protocol request (push/clone etc.)
23 23 It's implemented with basic auth function
24 24 """
25 25
26 26 import os
27 27 import logging
28 28 import importlib
29 29 import re
30 30 from functools import wraps
31 31
32 32 from paste.httpheaders import REMOTE_USER, AUTH_TYPE
33 33 from webob.exc import (
34 34 HTTPNotFound, HTTPForbidden, HTTPNotAcceptable, HTTPInternalServerError)
35 35
36 36 import rhodecode
37 37 from rhodecode.authentication.base import authenticate, VCS_TYPE
38 38 from rhodecode.lib.auth import AuthUser, HasPermissionAnyMiddleware
39 39 from rhodecode.lib.base import BasicAuth, get_ip_addr, vcs_operation_context
40 40 from rhodecode.lib.exceptions import (
41 41 HTTPLockedRC, HTTPRequirementError, UserCreationError,
42 42 NotAllowedToCreateUserError)
43 43 from rhodecode.lib.hooks_daemon import prepare_callback_daemon
44 44 from rhodecode.lib.middleware import appenlight
45 45 from rhodecode.lib.middleware.utils import scm_app
46 46 from rhodecode.lib.utils import (
47 47 is_valid_repo, get_rhodecode_realm, get_rhodecode_base_path)
48 48 from rhodecode.lib.utils2 import safe_str, fix_PATH, str2bool
49 49 from rhodecode.lib.vcs.conf import settings as vcs_settings
50 50 from rhodecode.lib.vcs.backends import base
51 51 from rhodecode.model import meta
52 52 from rhodecode.model.db import User, Repository, PullRequest
53 53 from rhodecode.model.scm import ScmModel
54 54 from rhodecode.model.pull_request import PullRequestModel
55 55
56 56
57 57 log = logging.getLogger(__name__)
58 58
59 59
60 60 def initialize_generator(factory):
61 61 """
62 62 Initializes the returned generator by draining its first element.
63 63
64 64 This can be used to give a generator an initializer, which is the code
65 65 up to the first yield statement. This decorator enforces that the first
66 66 produced element has the value ``"__init__"`` to make its special
67 67 purpose very explicit in the using code.
68 68 """
69 69
70 70 @wraps(factory)
71 71 def wrapper(*args, **kwargs):
72 72 gen = factory(*args, **kwargs)
73 73 try:
74 74 init = gen.next()
75 75 except StopIteration:
76 76 raise ValueError('Generator must yield at least one element.')
77 77 if init != "__init__":
78 78 raise ValueError('First yielded element must be "__init__".')
79 79 return gen
80 80 return wrapper
81 81
82 82
83 83 class SimpleVCS(object):
84 84 """Common functionality for SCM HTTP handlers."""
85 85
86 86 SCM = 'unknown'
87 87
88 88 acl_repo_name = None
89 89 url_repo_name = None
90 90 vcs_repo_name = None
91 91
92 92 def __init__(self, application, config, registry):
93 93 self.registry = registry
94 94 self.application = application
95 95 self.config = config
96 96 # re-populated by specialized middleware
97 97 self.repo_vcs_config = base.Config()
98 98
99 99 # base path of repo locations
100 100 self.basepath = get_rhodecode_base_path()
101 101 # authenticate this VCS request using authfunc
102 102 auth_ret_code_detection = \
103 103 str2bool(self.config.get('auth_ret_code_detection', False))
104 104 self.authenticate = BasicAuth(
105 105 '', authenticate, registry, config.get('auth_ret_code'),
106 106 auth_ret_code_detection)
107 107 self.ip_addr = '0.0.0.0'
108 108
109 109 def set_repo_names(self, environ):
110 110 """
111 111 This will populate the attributes acl_repo_name, url_repo_name,
112 112 vcs_repo_name and pr_id on the current instance.
113 113 """
114 # TODO: martinb: Unify generation/suffix of clone url. It is currently
115 # used here in the regex, in PullRequest in get_api_data() and
116 # indirectly in routing configuration.
117 114 # TODO: martinb: Move to class or module scope.
118 115 # TODO: martinb: Check if we have to use re.UNICODE.
119 116 # TODO: martinb: Check which chars are allowed for repo/group names.
120 117 # These chars are excluded: '`?=[]\;\'"<>,/~!@#$%^&*()+{}|: '
121 118 # Code from: rhodecode/lib/utils.py:repo_name_slug()
122 119 pr_regex = re.compile(
123 120 '(?P<base_name>(?:[\w-]+)(?:/[\w-]+)*)/' # repo groups
124 121 '(?P<repo_name>[\w-]+)' # target repo name
125 122 '/pull-request/(?P<pr_id>\d+)/repository') # pr suffix
126 123
127 124 # Get url repo name from environment.
128 125 self.url_repo_name = self._get_repository_name(environ)
129 126
130 127 # Check if this is a request to a shadow repository. In case of a
131 128 # shadow repo set vcs_repo_name to the file system path pointing to the
132 129 # shadow repo. And set acl_repo_name to the pull request target repo
133 130 # because we use the target repo for permission checks. Otherwise all
134 131 # names are equal.
135 132 match = pr_regex.match(self.url_repo_name)
136 133 if match:
137 134 # Get pull request instance.
138 135 match_dict = match.groupdict()
139 136 pr_id = match_dict['pr_id']
140 137 pull_request = PullRequest.get(pr_id)
141 138
142 139 # Get file system path to shadow repository.
143 140 workspace_id = PullRequestModel()._workspace_id(pull_request)
144 141 target_vcs = pull_request.target_repo.scm_instance()
145 142 vcs_repo_name = target_vcs._get_shadow_repository_path(
146 143 workspace_id)
147 144
148 145 # Store names for later usage.
149 146 self.pr_id = pr_id
150 147 self.vcs_repo_name = vcs_repo_name
151 148 self.acl_repo_name = pull_request.target_repo.repo_name
152 149 else:
153 150 # All names are equal for normal (non shadow) repositories.
154 151 self.acl_repo_name = self.url_repo_name
155 152 self.vcs_repo_name = self.url_repo_name
156 153 self.pr_id = None
157 154
158 155 @property
159 156 def repo_name(self):
160 157 # TODO: johbo: Remove, switch to correct repo name attribute
161 158 return self.acl_repo_name
162 159
163 160 @property
164 161 def scm_app(self):
165 162 custom_implementation = self.config.get('vcs.scm_app_implementation')
166 163 if custom_implementation and custom_implementation != 'pyro4':
167 164 log.info(
168 165 "Using custom implementation of scm_app: %s",
169 166 custom_implementation)
170 167 scm_app_impl = importlib.import_module(custom_implementation)
171 168 else:
172 169 scm_app_impl = scm_app
173 170 return scm_app_impl
174 171
175 172 def _get_by_id(self, repo_name):
176 173 """
177 174 Gets a special pattern _<ID> from clone url and tries to replace it
178 175 with a repository_name for support of _<ID> non changeable urls
179 176 """
180 177
181 178 data = repo_name.split('/')
182 179 if len(data) >= 2:
183 180 from rhodecode.model.repo import RepoModel
184 181 by_id_match = RepoModel().get_repo_by_id(repo_name)
185 182 if by_id_match:
186 183 data[1] = by_id_match.repo_name
187 184
188 185 return safe_str('/'.join(data))
189 186
190 187 def _invalidate_cache(self, repo_name):
191 188 """
192 189 Set's cache for this repository for invalidation on next access
193 190
194 191 :param repo_name: full repo name, also a cache key
195 192 """
196 193 ScmModel().mark_for_invalidation(repo_name)
197 194
198 195 def is_valid_and_existing_repo(self, repo_name, base_path, scm_type):
199 196 db_repo = Repository.get_by_repo_name(repo_name)
200 197 if not db_repo:
201 198 log.debug('Repository `%s` not found inside the database.',
202 199 repo_name)
203 200 return False
204 201
205 202 if db_repo.repo_type != scm_type:
206 203 log.warning(
207 204 'Repository `%s` have incorrect scm_type, expected %s got %s',
208 205 repo_name, db_repo.repo_type, scm_type)
209 206 return False
210 207
211 208 return is_valid_repo(repo_name, base_path, expect_scm=scm_type)
212 209
213 210 def valid_and_active_user(self, user):
214 211 """
215 212 Checks if that user is not empty, and if it's actually object it checks
216 213 if he's active.
217 214
218 215 :param user: user object or None
219 216 :return: boolean
220 217 """
221 218 if user is None:
222 219 return False
223 220
224 221 elif user.active:
225 222 return True
226 223
227 224 return False
228 225
229 226 def _check_permission(self, action, user, repo_name, ip_addr=None):
230 227 """
231 228 Checks permissions using action (push/pull) user and repository
232 229 name
233 230
234 231 :param action: push or pull action
235 232 :param user: user instance
236 233 :param repo_name: repository name
237 234 """
238 235 # check IP
239 236 inherit = user.inherit_default_permissions
240 237 ip_allowed = AuthUser.check_ip_allowed(user.user_id, ip_addr,
241 238 inherit_from_default=inherit)
242 239 if ip_allowed:
243 240 log.info('Access for IP:%s allowed', ip_addr)
244 241 else:
245 242 return False
246 243
247 244 if action == 'push':
248 245 if not HasPermissionAnyMiddleware('repository.write',
249 246 'repository.admin')(user,
250 247 repo_name):
251 248 return False
252 249
253 250 else:
254 251 # any other action need at least read permission
255 252 if not HasPermissionAnyMiddleware('repository.read',
256 253 'repository.write',
257 254 'repository.admin')(user,
258 255 repo_name):
259 256 return False
260 257
261 258 return True
262 259
263 260 def _check_ssl(self, environ, start_response):
264 261 """
265 262 Checks the SSL check flag and returns False if SSL is not present
266 263 and required True otherwise
267 264 """
268 265 org_proto = environ['wsgi._org_proto']
269 266 # check if we have SSL required ! if not it's a bad request !
270 267 require_ssl = str2bool(self.repo_vcs_config.get('web', 'push_ssl'))
271 268 if require_ssl and org_proto == 'http':
272 269 log.debug('proto is %s and SSL is required BAD REQUEST !',
273 270 org_proto)
274 271 return False
275 272 return True
276 273
277 274 def __call__(self, environ, start_response):
278 275 try:
279 276 return self._handle_request(environ, start_response)
280 277 except Exception:
281 278 log.exception("Exception while handling request")
282 279 appenlight.track_exception(environ)
283 280 return HTTPInternalServerError()(environ, start_response)
284 281 finally:
285 282 meta.Session.remove()
286 283
287 284 def _handle_request(self, environ, start_response):
288 285
289 286 if not self._check_ssl(environ, start_response):
290 287 reason = ('SSL required, while RhodeCode was unable '
291 288 'to detect this as SSL request')
292 289 log.debug('User not allowed to proceed, %s', reason)
293 290 return HTTPNotAcceptable(reason)(environ, start_response)
294 291
295 292 if not self.repo_name:
296 293 log.warning('Repository name is empty: %s', self.repo_name)
297 294 # failed to get repo name, we fail now
298 295 return HTTPNotFound()(environ, start_response)
299 296 log.debug('Extracted repo name is %s', self.repo_name)
300 297
301 298 ip_addr = get_ip_addr(environ)
302 299 username = None
303 300
304 301 # skip passing error to error controller
305 302 environ['pylons.status_code_redirect'] = True
306 303
307 304 # ======================================================================
308 305 # GET ACTION PULL or PUSH
309 306 # ======================================================================
310 307 action = self._get_action(environ)
311 308
312 309 # ======================================================================
313 310 # Check if this is a request to a shadow repository of a pull request.
314 311 # In this case only pull action is allowed.
315 312 # ======================================================================
316 313 if self.pr_id is not None and action != 'pull':
317 314 reason = 'Only pull action is allowed for shadow repositories.'
318 315 log.debug('User not allowed to proceed, %s', reason)
319 316 return HTTPNotAcceptable(reason)(environ, start_response)
320 317
321 318 # ======================================================================
322 319 # CHECK ANONYMOUS PERMISSION
323 320 # ======================================================================
324 321 if action in ['pull', 'push']:
325 322 anonymous_user = User.get_default_user()
326 323 username = anonymous_user.username
327 324 if anonymous_user.active:
328 325 # ONLY check permissions if the user is activated
329 326 anonymous_perm = self._check_permission(
330 327 action, anonymous_user, self.repo_name, ip_addr)
331 328 else:
332 329 anonymous_perm = False
333 330
334 331 if not anonymous_user.active or not anonymous_perm:
335 332 if not anonymous_user.active:
336 333 log.debug('Anonymous access is disabled, running '
337 334 'authentication')
338 335
339 336 if not anonymous_perm:
340 337 log.debug('Not enough credentials to access this '
341 338 'repository as anonymous user')
342 339
343 340 username = None
344 341 # ==============================================================
345 342 # DEFAULT PERM FAILED OR ANONYMOUS ACCESS IS DISABLED SO WE
346 343 # NEED TO AUTHENTICATE AND ASK FOR AUTH USER PERMISSIONS
347 344 # ==============================================================
348 345
349 346 # try to auth based on environ, container auth methods
350 347 log.debug('Running PRE-AUTH for container based authentication')
351 348 pre_auth = authenticate(
352 349 '', '', environ, VCS_TYPE, registry=self.registry)
353 350 if pre_auth and pre_auth.get('username'):
354 351 username = pre_auth['username']
355 352 log.debug('PRE-AUTH got %s as username', username)
356 353
357 354 # If not authenticated by the container, running basic auth
358 355 if not username:
359 356 self.authenticate.realm = get_rhodecode_realm()
360 357
361 358 try:
362 359 result = self.authenticate(environ)
363 360 except (UserCreationError, NotAllowedToCreateUserError) as e:
364 361 log.error(e)
365 362 reason = safe_str(e)
366 363 return HTTPNotAcceptable(reason)(environ, start_response)
367 364
368 365 if isinstance(result, str):
369 366 AUTH_TYPE.update(environ, 'basic')
370 367 REMOTE_USER.update(environ, result)
371 368 username = result
372 369 else:
373 370 return result.wsgi_application(environ, start_response)
374 371
375 372 # ==============================================================
376 373 # CHECK PERMISSIONS FOR THIS REQUEST USING GIVEN USERNAME
377 374 # ==============================================================
378 375 user = User.get_by_username(username)
379 376 if not self.valid_and_active_user(user):
380 377 return HTTPForbidden()(environ, start_response)
381 378 username = user.username
382 379 user.update_lastactivity()
383 380 meta.Session().commit()
384 381
385 382 # check user attributes for password change flag
386 383 user_obj = user
387 384 if user_obj and user_obj.username != User.DEFAULT_USER and \
388 385 user_obj.user_data.get('force_password_change'):
389 386 reason = 'password change required'
390 387 log.debug('User not allowed to authenticate, %s', reason)
391 388 return HTTPNotAcceptable(reason)(environ, start_response)
392 389
393 390 # check permissions for this repository
394 391 perm = self._check_permission(
395 392 action, user, self.repo_name, ip_addr)
396 393 if not perm:
397 394 return HTTPForbidden()(environ, start_response)
398 395
399 396 # extras are injected into UI object and later available
400 397 # in hooks executed by rhodecode
401 398 check_locking = _should_check_locking(environ.get('QUERY_STRING'))
402 399 extras = vcs_operation_context(
403 400 environ, repo_name=self.repo_name, username=username,
404 401 action=action, scm=self.SCM,
405 402 check_locking=check_locking)
406 403
407 404 # ======================================================================
408 405 # REQUEST HANDLING
409 406 # ======================================================================
410 407 str_repo_name = safe_str(self.repo_name)
411 408 repo_path = os.path.join(
412 409 safe_str(self.basepath), safe_str(self.vcs_repo_name))
413 410 log.debug('Repository path is %s', repo_path)
414 411
415 412 fix_PATH()
416 413
417 414 log.info(
418 415 '%s action on %s repo "%s" by "%s" from %s',
419 416 action, self.SCM, str_repo_name, safe_str(username), ip_addr)
420 417
421 418 return self._generate_vcs_response(
422 419 environ, start_response, repo_path, self.url_repo_name, extras, action)
423 420
424 421 @initialize_generator
425 422 def _generate_vcs_response(
426 423 self, environ, start_response, repo_path, repo_name, extras,
427 424 action):
428 425 """
429 426 Returns a generator for the response content.
430 427
431 428 This method is implemented as a generator, so that it can trigger
432 429 the cache validation after all content sent back to the client. It
433 430 also handles the locking exceptions which will be triggered when
434 431 the first chunk is produced by the underlying WSGI application.
435 432 """
436 433 callback_daemon, extras = self._prepare_callback_daemon(extras)
437 434 config = self._create_config(extras, self.acl_repo_name)
438 435 log.debug('HOOKS extras is %s', extras)
439 436 app = self._create_wsgi_app(repo_path, repo_name, config)
440 437
441 438 try:
442 439 with callback_daemon:
443 440 try:
444 441 response = app(environ, start_response)
445 442 finally:
446 443 # This statement works together with the decorator
447 444 # "initialize_generator" above. The decorator ensures that
448 445 # we hit the first yield statement before the generator is
449 446 # returned back to the WSGI server. This is needed to
450 447 # ensure that the call to "app" above triggers the
451 448 # needed callback to "start_response" before the
452 449 # generator is actually used.
453 450 yield "__init__"
454 451
455 452 for chunk in response:
456 453 yield chunk
457 454 except Exception as exc:
458 455 # TODO: johbo: Improve "translating" back the exception.
459 456 if getattr(exc, '_vcs_kind', None) == 'repo_locked':
460 457 exc = HTTPLockedRC(*exc.args)
461 458 _code = rhodecode.CONFIG.get('lock_ret_code')
462 459 log.debug('Repository LOCKED ret code %s!', (_code,))
463 460 elif getattr(exc, '_vcs_kind', None) == 'requirement':
464 461 log.debug(
465 462 'Repository requires features unknown to this Mercurial')
466 463 exc = HTTPRequirementError(*exc.args)
467 464 else:
468 465 raise
469 466
470 467 for chunk in exc(environ, start_response):
471 468 yield chunk
472 469 finally:
473 470 # invalidate cache on push
474 471 try:
475 472 if action == 'push':
476 473 self._invalidate_cache(repo_name)
477 474 finally:
478 475 meta.Session.remove()
479 476
480 477 def _get_repository_name(self, environ):
481 478 """Get repository name out of the environmnent
482 479
483 480 :param environ: WSGI environment
484 481 """
485 482 raise NotImplementedError()
486 483
487 484 def _get_action(self, environ):
488 485 """Map request commands into a pull or push command.
489 486
490 487 :param environ: WSGI environment
491 488 """
492 489 raise NotImplementedError()
493 490
494 491 def _create_wsgi_app(self, repo_path, repo_name, config):
495 492 """Return the WSGI app that will finally handle the request."""
496 493 raise NotImplementedError()
497 494
498 495 def _create_config(self, extras, repo_name):
499 496 """Create a Pyro safe config representation."""
500 497 raise NotImplementedError()
501 498
502 499 def _prepare_callback_daemon(self, extras):
503 500 return prepare_callback_daemon(
504 501 extras, protocol=vcs_settings.HOOKS_PROTOCOL,
505 502 use_direct_calls=vcs_settings.HOOKS_DIRECT_CALLS)
506 503
507 504
508 505 def _should_check_locking(query_string):
509 506 # this is kind of hacky, but due to how mercurial handles client-server
510 507 # server see all operation on commit; bookmarks, phases and
511 508 # obsolescence marker in different transaction, we don't want to check
512 509 # locking on those
513 510 return query_string not in ['cmd=listkeys']
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
@@ -1,1180 +1,1188 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2016 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 from collections import namedtuple
27 27 import json
28 28 import logging
29 29 import datetime
30 30
31 31 from pylons.i18n.translation import _
32 32 from pylons.i18n.translation import lazy_ugettext
33 33
34 34 from rhodecode.lib import helpers as h, hooks_utils, diffs
35 35 from rhodecode.lib.compat import OrderedDict
36 36 from rhodecode.lib.hooks_daemon import prepare_callback_daemon
37 37 from rhodecode.lib.markup_renderer import (
38 38 DEFAULT_COMMENTS_RENDERER, RstTemplateRenderer)
39 39 from rhodecode.lib.utils import action_logger
40 40 from rhodecode.lib.utils2 import safe_unicode, safe_str, md5_safe
41 41 from rhodecode.lib.vcs.backends.base import (
42 42 Reference, MergeResponse, MergeFailureReason)
43 43 from rhodecode.lib.vcs.conf import settings as vcs_settings
44 44 from rhodecode.lib.vcs.exceptions import (
45 45 CommitDoesNotExistError, EmptyRepositoryError)
46 46 from rhodecode.model import BaseModel
47 47 from rhodecode.model.changeset_status import ChangesetStatusModel
48 48 from rhodecode.model.comment import ChangesetCommentsModel
49 49 from rhodecode.model.db import (
50 50 PullRequest, PullRequestReviewers, ChangesetStatus,
51 51 PullRequestVersion, ChangesetComment)
52 52 from rhodecode.model.meta import Session
53 53 from rhodecode.model.notification import NotificationModel, \
54 54 EmailNotificationModel
55 55 from rhodecode.model.scm import ScmModel
56 56 from rhodecode.model.settings import VcsSettingsModel
57 57
58 58
59 59 log = logging.getLogger(__name__)
60 60
61 61
62 62 class PullRequestModel(BaseModel):
63 63
64 64 cls = PullRequest
65 65
66 66 DIFF_CONTEXT = 3
67 67
68 68 MERGE_STATUS_MESSAGES = {
69 69 MergeFailureReason.NONE: lazy_ugettext(
70 70 'This pull request can be automatically merged.'),
71 71 MergeFailureReason.UNKNOWN: lazy_ugettext(
72 72 'This pull request cannot be merged because of an unhandled'
73 73 ' exception.'),
74 74 MergeFailureReason.MERGE_FAILED: lazy_ugettext(
75 75 'This pull request cannot be merged because of conflicts.'),
76 76 MergeFailureReason.PUSH_FAILED: lazy_ugettext(
77 77 'This pull request could not be merged because push to target'
78 78 ' failed.'),
79 79 MergeFailureReason.TARGET_IS_NOT_HEAD: lazy_ugettext(
80 80 'This pull request cannot be merged because the target is not a'
81 81 ' head.'),
82 82 MergeFailureReason.HG_SOURCE_HAS_MORE_BRANCHES: lazy_ugettext(
83 83 'This pull request cannot be merged because the source contains'
84 84 ' more branches than the target.'),
85 85 MergeFailureReason.HG_TARGET_HAS_MULTIPLE_HEADS: lazy_ugettext(
86 86 'This pull request cannot be merged because the target has'
87 87 ' multiple heads.'),
88 88 MergeFailureReason.TARGET_IS_LOCKED: lazy_ugettext(
89 89 'This pull request cannot be merged because the target repository'
90 90 ' is locked.'),
91 91 MergeFailureReason.MISSING_COMMIT: lazy_ugettext(
92 92 'This pull request cannot be merged because the target or the '
93 93 'source reference is missing.'),
94 94 }
95 95
96 96 def __get_pull_request(self, pull_request):
97 97 return self._get_instance(PullRequest, pull_request)
98 98
99 99 def _check_perms(self, perms, pull_request, user, api=False):
100 100 if not api:
101 101 return h.HasRepoPermissionAny(*perms)(
102 102 user=user, repo_name=pull_request.target_repo.repo_name)
103 103 else:
104 104 return h.HasRepoPermissionAnyApi(*perms)(
105 105 user=user, repo_name=pull_request.target_repo.repo_name)
106 106
107 107 def check_user_read(self, pull_request, user, api=False):
108 108 _perms = ('repository.admin', 'repository.write', 'repository.read',)
109 109 return self._check_perms(_perms, pull_request, user, api)
110 110
111 111 def check_user_merge(self, pull_request, user, api=False):
112 112 _perms = ('repository.admin', 'repository.write', 'hg.admin',)
113 113 return self._check_perms(_perms, pull_request, user, api)
114 114
115 115 def check_user_update(self, pull_request, user, api=False):
116 116 owner = user.user_id == pull_request.user_id
117 117 return self.check_user_merge(pull_request, user, api) or owner
118 118
119 119 def check_user_change_status(self, pull_request, user, api=False):
120 120 reviewer = user.user_id in [x.user_id for x in
121 121 pull_request.reviewers]
122 122 return self.check_user_update(pull_request, user, api) or reviewer
123 123
124 124 def get(self, pull_request):
125 125 return self.__get_pull_request(pull_request)
126 126
127 127 def _prepare_get_all_query(self, repo_name, source=False, statuses=None,
128 128 opened_by=None, order_by=None,
129 129 order_dir='desc'):
130 130 repo = self._get_repo(repo_name)
131 131 q = PullRequest.query()
132 132 # source or target
133 133 if source:
134 134 q = q.filter(PullRequest.source_repo == repo)
135 135 else:
136 136 q = q.filter(PullRequest.target_repo == repo)
137 137
138 138 # closed,opened
139 139 if statuses:
140 140 q = q.filter(PullRequest.status.in_(statuses))
141 141
142 142 # opened by filter
143 143 if opened_by:
144 144 q = q.filter(PullRequest.user_id.in_(opened_by))
145 145
146 146 if order_by:
147 147 order_map = {
148 148 'name_raw': PullRequest.pull_request_id,
149 149 'title': PullRequest.title,
150 150 'updated_on_raw': PullRequest.updated_on
151 151 }
152 152 if order_dir == 'asc':
153 153 q = q.order_by(order_map[order_by].asc())
154 154 else:
155 155 q = q.order_by(order_map[order_by].desc())
156 156
157 157 return q
158 158
159 159 def count_all(self, repo_name, source=False, statuses=None,
160 160 opened_by=None):
161 161 """
162 162 Count the number of pull requests for a specific repository.
163 163
164 164 :param repo_name: target or source repo
165 165 :param source: boolean flag to specify if repo_name refers to source
166 166 :param statuses: list of pull request statuses
167 167 :param opened_by: author user of the pull request
168 168 :returns: int number of pull requests
169 169 """
170 170 q = self._prepare_get_all_query(
171 171 repo_name, source=source, statuses=statuses, opened_by=opened_by)
172 172
173 173 return q.count()
174 174
175 175 def get_all(self, repo_name, source=False, statuses=None, opened_by=None,
176 176 offset=0, length=None, order_by=None, order_dir='desc'):
177 177 """
178 178 Get all pull requests for a specific repository.
179 179
180 180 :param repo_name: target or source repo
181 181 :param source: boolean flag to specify if repo_name refers to source
182 182 :param statuses: list of pull request statuses
183 183 :param opened_by: author user of the pull request
184 184 :param offset: pagination offset
185 185 :param length: length of returned list
186 186 :param order_by: order of the returned list
187 187 :param order_dir: 'asc' or 'desc' ordering direction
188 188 :returns: list of pull requests
189 189 """
190 190 q = self._prepare_get_all_query(
191 191 repo_name, source=source, statuses=statuses, opened_by=opened_by,
192 192 order_by=order_by, order_dir=order_dir)
193 193
194 194 if length:
195 195 pull_requests = q.limit(length).offset(offset).all()
196 196 else:
197 197 pull_requests = q.all()
198 198
199 199 return pull_requests
200 200
201 201 def count_awaiting_review(self, repo_name, source=False, statuses=None,
202 202 opened_by=None):
203 203 """
204 204 Count the number of pull requests for a specific repository that are
205 205 awaiting review.
206 206
207 207 :param repo_name: target or source repo
208 208 :param source: boolean flag to specify if repo_name refers to source
209 209 :param statuses: list of pull request statuses
210 210 :param opened_by: author user of the pull request
211 211 :returns: int number of pull requests
212 212 """
213 213 pull_requests = self.get_awaiting_review(
214 214 repo_name, source=source, statuses=statuses, opened_by=opened_by)
215 215
216 216 return len(pull_requests)
217 217
218 218 def get_awaiting_review(self, repo_name, source=False, statuses=None,
219 219 opened_by=None, offset=0, length=None,
220 220 order_by=None, order_dir='desc'):
221 221 """
222 222 Get all pull requests for a specific repository that are awaiting
223 223 review.
224 224
225 225 :param repo_name: target or source repo
226 226 :param source: boolean flag to specify if repo_name refers to source
227 227 :param statuses: list of pull request statuses
228 228 :param opened_by: author user of the pull request
229 229 :param offset: pagination offset
230 230 :param length: length of returned list
231 231 :param order_by: order of the returned list
232 232 :param order_dir: 'asc' or 'desc' ordering direction
233 233 :returns: list of pull requests
234 234 """
235 235 pull_requests = self.get_all(
236 236 repo_name, source=source, statuses=statuses, opened_by=opened_by,
237 237 order_by=order_by, order_dir=order_dir)
238 238
239 239 _filtered_pull_requests = []
240 240 for pr in pull_requests:
241 241 status = pr.calculated_review_status()
242 242 if status in [ChangesetStatus.STATUS_NOT_REVIEWED,
243 243 ChangesetStatus.STATUS_UNDER_REVIEW]:
244 244 _filtered_pull_requests.append(pr)
245 245 if length:
246 246 return _filtered_pull_requests[offset:offset+length]
247 247 else:
248 248 return _filtered_pull_requests
249 249
250 250 def count_awaiting_my_review(self, repo_name, source=False, statuses=None,
251 251 opened_by=None, user_id=None):
252 252 """
253 253 Count the number of pull requests for a specific repository that are
254 254 awaiting review from a specific user.
255 255
256 256 :param repo_name: target or source repo
257 257 :param source: boolean flag to specify if repo_name refers to source
258 258 :param statuses: list of pull request statuses
259 259 :param opened_by: author user of the pull request
260 260 :param user_id: reviewer user of the pull request
261 261 :returns: int number of pull requests
262 262 """
263 263 pull_requests = self.get_awaiting_my_review(
264 264 repo_name, source=source, statuses=statuses, opened_by=opened_by,
265 265 user_id=user_id)
266 266
267 267 return len(pull_requests)
268 268
269 269 def get_awaiting_my_review(self, repo_name, source=False, statuses=None,
270 270 opened_by=None, user_id=None, offset=0,
271 271 length=None, order_by=None, order_dir='desc'):
272 272 """
273 273 Get all pull requests for a specific repository that are awaiting
274 274 review from a specific user.
275 275
276 276 :param repo_name: target or source repo
277 277 :param source: boolean flag to specify if repo_name refers to source
278 278 :param statuses: list of pull request statuses
279 279 :param opened_by: author user of the pull request
280 280 :param user_id: reviewer user of the pull request
281 281 :param offset: pagination offset
282 282 :param length: length of returned list
283 283 :param order_by: order of the returned list
284 284 :param order_dir: 'asc' or 'desc' ordering direction
285 285 :returns: list of pull requests
286 286 """
287 287 pull_requests = self.get_all(
288 288 repo_name, source=source, statuses=statuses, opened_by=opened_by,
289 289 order_by=order_by, order_dir=order_dir)
290 290
291 291 _my = PullRequestModel().get_not_reviewed(user_id)
292 292 my_participation = []
293 293 for pr in pull_requests:
294 294 if pr in _my:
295 295 my_participation.append(pr)
296 296 _filtered_pull_requests = my_participation
297 297 if length:
298 298 return _filtered_pull_requests[offset:offset+length]
299 299 else:
300 300 return _filtered_pull_requests
301 301
302 302 def get_not_reviewed(self, user_id):
303 303 return [
304 304 x.pull_request for x in PullRequestReviewers.query().filter(
305 305 PullRequestReviewers.user_id == user_id).all()
306 306 ]
307 307
308 308 def get_versions(self, pull_request):
309 309 """
310 310 returns version of pull request sorted by ID descending
311 311 """
312 312 return PullRequestVersion.query()\
313 313 .filter(PullRequestVersion.pull_request == pull_request)\
314 314 .order_by(PullRequestVersion.pull_request_version_id.asc())\
315 315 .all()
316 316
317 317 def create(self, created_by, source_repo, source_ref, target_repo,
318 318 target_ref, revisions, reviewers, title, description=None):
319 319 created_by_user = self._get_user(created_by)
320 320 source_repo = self._get_repo(source_repo)
321 321 target_repo = self._get_repo(target_repo)
322 322
323 323 pull_request = PullRequest()
324 324 pull_request.source_repo = source_repo
325 325 pull_request.source_ref = source_ref
326 326 pull_request.target_repo = target_repo
327 327 pull_request.target_ref = target_ref
328 328 pull_request.revisions = revisions
329 329 pull_request.title = title
330 330 pull_request.description = description
331 331 pull_request.author = created_by_user
332 332
333 333 Session().add(pull_request)
334 334 Session().flush()
335 335
336 336 reviewer_ids = set()
337 337 # members / reviewers
338 338 for reviewer_object in reviewers:
339 339 if isinstance(reviewer_object, tuple):
340 340 user_id, reasons = reviewer_object
341 341 else:
342 342 user_id, reasons = reviewer_object, []
343 343
344 344 user = self._get_user(user_id)
345 345 reviewer_ids.add(user.user_id)
346 346
347 347 reviewer = PullRequestReviewers(user, pull_request, reasons)
348 348 Session().add(reviewer)
349 349
350 350 # Set approval status to "Under Review" for all commits which are
351 351 # part of this pull request.
352 352 ChangesetStatusModel().set_status(
353 353 repo=target_repo,
354 354 status=ChangesetStatus.STATUS_UNDER_REVIEW,
355 355 user=created_by_user,
356 356 pull_request=pull_request
357 357 )
358 358
359 359 self.notify_reviewers(pull_request, reviewer_ids)
360 360 self._trigger_pull_request_hook(
361 361 pull_request, created_by_user, 'create')
362 362
363 363 return pull_request
364 364
365 365 def _trigger_pull_request_hook(self, pull_request, user, action):
366 366 pull_request = self.__get_pull_request(pull_request)
367 367 target_scm = pull_request.target_repo.scm_instance()
368 368 if action == 'create':
369 369 trigger_hook = hooks_utils.trigger_log_create_pull_request_hook
370 370 elif action == 'merge':
371 371 trigger_hook = hooks_utils.trigger_log_merge_pull_request_hook
372 372 elif action == 'close':
373 373 trigger_hook = hooks_utils.trigger_log_close_pull_request_hook
374 374 elif action == 'review_status_change':
375 375 trigger_hook = hooks_utils.trigger_log_review_pull_request_hook
376 376 elif action == 'update':
377 377 trigger_hook = hooks_utils.trigger_log_update_pull_request_hook
378 378 else:
379 379 return
380 380
381 381 trigger_hook(
382 382 username=user.username,
383 383 repo_name=pull_request.target_repo.repo_name,
384 384 repo_alias=target_scm.alias,
385 385 pull_request=pull_request)
386 386
387 387 def _get_commit_ids(self, pull_request):
388 388 """
389 389 Return the commit ids of the merged pull request.
390 390
391 391 This method is not dealing correctly yet with the lack of autoupdates
392 392 nor with the implicit target updates.
393 393 For example: if a commit in the source repo is already in the target it
394 394 will be reported anyways.
395 395 """
396 396 merge_rev = pull_request.merge_rev
397 397 if merge_rev is None:
398 398 raise ValueError('This pull request was not merged yet')
399 399
400 400 commit_ids = list(pull_request.revisions)
401 401 if merge_rev not in commit_ids:
402 402 commit_ids.append(merge_rev)
403 403
404 404 return commit_ids
405 405
406 406 def merge(self, pull_request, user, extras):
407 407 log.debug("Merging pull request %s", pull_request.pull_request_id)
408 408 merge_state = self._merge_pull_request(pull_request, user, extras)
409 409 if merge_state.executed:
410 410 log.debug(
411 411 "Merge was successful, updating the pull request comments.")
412 412 self._comment_and_close_pr(pull_request, user, merge_state)
413 413 self._log_action('user_merged_pull_request', user, pull_request)
414 414 else:
415 415 log.warn("Merge failed, not updating the pull request.")
416 416 return merge_state
417 417
418 418 def _merge_pull_request(self, pull_request, user, extras):
419 419 target_vcs = pull_request.target_repo.scm_instance()
420 420 source_vcs = pull_request.source_repo.scm_instance()
421 421 target_ref = self._refresh_reference(
422 422 pull_request.target_ref_parts, target_vcs)
423 423
424 424 message = _(
425 425 'Merge pull request #%(pr_id)s from '
426 426 '%(source_repo)s %(source_ref_name)s\n\n %(pr_title)s') % {
427 427 'pr_id': pull_request.pull_request_id,
428 428 'source_repo': source_vcs.name,
429 429 'source_ref_name': pull_request.source_ref_parts.name,
430 430 'pr_title': pull_request.title
431 431 }
432 432
433 433 workspace_id = self._workspace_id(pull_request)
434 434 use_rebase = self._use_rebase_for_merging(pull_request)
435 435
436 436 callback_daemon, extras = prepare_callback_daemon(
437 437 extras, protocol=vcs_settings.HOOKS_PROTOCOL,
438 438 use_direct_calls=vcs_settings.HOOKS_DIRECT_CALLS)
439 439
440 440 with callback_daemon:
441 441 # TODO: johbo: Implement a clean way to run a config_override
442 442 # for a single call.
443 443 target_vcs.config.set(
444 444 'rhodecode', 'RC_SCM_DATA', json.dumps(extras))
445 445 merge_state = target_vcs.merge(
446 446 target_ref, source_vcs, pull_request.source_ref_parts,
447 447 workspace_id, user_name=user.username,
448 448 user_email=user.email, message=message, use_rebase=use_rebase)
449 449 return merge_state
450 450
451 451 def _comment_and_close_pr(self, pull_request, user, merge_state):
452 452 pull_request.merge_rev = merge_state.merge_commit_id
453 453 pull_request.updated_on = datetime.datetime.now()
454 454
455 455 ChangesetCommentsModel().create(
456 456 text=unicode(_('Pull request merged and closed')),
457 457 repo=pull_request.target_repo.repo_id,
458 458 user=user.user_id,
459 459 pull_request=pull_request.pull_request_id,
460 460 f_path=None,
461 461 line_no=None,
462 462 closing_pr=True
463 463 )
464 464
465 465 Session().add(pull_request)
466 466 Session().flush()
467 467 # TODO: paris: replace invalidation with less radical solution
468 468 ScmModel().mark_for_invalidation(
469 469 pull_request.target_repo.repo_name)
470 470 self._trigger_pull_request_hook(pull_request, user, 'merge')
471 471
472 472 def has_valid_update_type(self, pull_request):
473 473 source_ref_type = pull_request.source_ref_parts.type
474 474 return source_ref_type in ['book', 'branch', 'tag']
475 475
476 476 def update_commits(self, pull_request):
477 477 """
478 478 Get the updated list of commits for the pull request
479 479 and return the new pull request version and the list
480 480 of commits processed by this update action
481 481 """
482 482
483 483 pull_request = self.__get_pull_request(pull_request)
484 484 source_ref_type = pull_request.source_ref_parts.type
485 485 source_ref_name = pull_request.source_ref_parts.name
486 486 source_ref_id = pull_request.source_ref_parts.commit_id
487 487
488 488 if not self.has_valid_update_type(pull_request):
489 489 log.debug(
490 490 "Skipping update of pull request %s due to ref type: %s",
491 491 pull_request, source_ref_type)
492 492 return (None, None)
493 493
494 494 source_repo = pull_request.source_repo.scm_instance()
495 495 source_commit = source_repo.get_commit(commit_id=source_ref_name)
496 496 if source_ref_id == source_commit.raw_id:
497 497 log.debug("Nothing changed in pull request %s", pull_request)
498 498 return (None, None)
499 499
500 500 # Finally there is a need for an update
501 501 pull_request_version = self._create_version_from_snapshot(pull_request)
502 502 self._link_comments_to_version(pull_request_version)
503 503
504 504 target_ref_type = pull_request.target_ref_parts.type
505 505 target_ref_name = pull_request.target_ref_parts.name
506 506 target_ref_id = pull_request.target_ref_parts.commit_id
507 507 target_repo = pull_request.target_repo.scm_instance()
508 508
509 509 if target_ref_type in ('tag', 'branch', 'book'):
510 510 target_commit = target_repo.get_commit(target_ref_name)
511 511 else:
512 512 target_commit = target_repo.get_commit(target_ref_id)
513 513
514 514 # re-compute commit ids
515 515 old_commit_ids = set(pull_request.revisions)
516 516 pre_load = ["author", "branch", "date", "message"]
517 517 commit_ranges = target_repo.compare(
518 518 target_commit.raw_id, source_commit.raw_id, source_repo, merge=True,
519 519 pre_load=pre_load)
520 520
521 521 ancestor = target_repo.get_common_ancestor(
522 522 target_commit.raw_id, source_commit.raw_id, source_repo)
523 523
524 524 pull_request.source_ref = '%s:%s:%s' % (
525 525 source_ref_type, source_ref_name, source_commit.raw_id)
526 526 pull_request.target_ref = '%s:%s:%s' % (
527 527 target_ref_type, target_ref_name, ancestor)
528 528 pull_request.revisions = [
529 529 commit.raw_id for commit in reversed(commit_ranges)]
530 530 pull_request.updated_on = datetime.datetime.now()
531 531 Session().add(pull_request)
532 532 new_commit_ids = set(pull_request.revisions)
533 533
534 534 changes = self._calculate_commit_id_changes(
535 535 old_commit_ids, new_commit_ids)
536 536
537 537 old_diff_data, new_diff_data = self._generate_update_diffs(
538 538 pull_request, pull_request_version)
539 539
540 540 ChangesetCommentsModel().outdate_comments(
541 541 pull_request, old_diff_data=old_diff_data,
542 542 new_diff_data=new_diff_data)
543 543
544 544 file_changes = self._calculate_file_changes(
545 545 old_diff_data, new_diff_data)
546 546
547 547 # Add an automatic comment to the pull request
548 548 update_comment = ChangesetCommentsModel().create(
549 549 text=self._render_update_message(changes, file_changes),
550 550 repo=pull_request.target_repo,
551 551 user=pull_request.author,
552 552 pull_request=pull_request,
553 553 send_email=False, renderer=DEFAULT_COMMENTS_RENDERER)
554 554
555 555 # Update status to "Under Review" for added commits
556 556 for commit_id in changes.added:
557 557 ChangesetStatusModel().set_status(
558 558 repo=pull_request.source_repo,
559 559 status=ChangesetStatus.STATUS_UNDER_REVIEW,
560 560 comment=update_comment,
561 561 user=pull_request.author,
562 562 pull_request=pull_request,
563 563 revision=commit_id)
564 564
565 565 log.debug(
566 566 'Updated pull request %s, added_ids: %s, common_ids: %s, '
567 567 'removed_ids: %s', pull_request.pull_request_id,
568 568 changes.added, changes.common, changes.removed)
569 569 log.debug('Updated pull request with the following file changes: %s',
570 570 file_changes)
571 571
572 572 log.info(
573 573 "Updated pull request %s from commit %s to commit %s, "
574 574 "stored new version %s of this pull request.",
575 575 pull_request.pull_request_id, source_ref_id,
576 576 pull_request.source_ref_parts.commit_id,
577 577 pull_request_version.pull_request_version_id)
578 578 Session().commit()
579 579 self._trigger_pull_request_hook(pull_request, pull_request.author,
580 580 'update')
581 581
582 582 return (pull_request_version, changes)
583 583
584 584 def _create_version_from_snapshot(self, pull_request):
585 585 version = PullRequestVersion()
586 586 version.title = pull_request.title
587 587 version.description = pull_request.description
588 588 version.status = pull_request.status
589 589 version.created_on = pull_request.created_on
590 590 version.updated_on = pull_request.updated_on
591 591 version.user_id = pull_request.user_id
592 592 version.source_repo = pull_request.source_repo
593 593 version.source_ref = pull_request.source_ref
594 594 version.target_repo = pull_request.target_repo
595 595 version.target_ref = pull_request.target_ref
596 596
597 597 version._last_merge_source_rev = pull_request._last_merge_source_rev
598 598 version._last_merge_target_rev = pull_request._last_merge_target_rev
599 599 version._last_merge_status = pull_request._last_merge_status
600 600 version.merge_rev = pull_request.merge_rev
601 601
602 602 version.revisions = pull_request.revisions
603 603 version.pull_request = pull_request
604 604 Session().add(version)
605 605 Session().flush()
606 606
607 607 return version
608 608
609 609 def _generate_update_diffs(self, pull_request, pull_request_version):
610 610 diff_context = (
611 611 self.DIFF_CONTEXT +
612 612 ChangesetCommentsModel.needed_extra_diff_context())
613 613 old_diff = self._get_diff_from_pr_or_version(
614 614 pull_request_version, context=diff_context)
615 615 new_diff = self._get_diff_from_pr_or_version(
616 616 pull_request, context=diff_context)
617 617
618 618 old_diff_data = diffs.DiffProcessor(old_diff)
619 619 old_diff_data.prepare()
620 620 new_diff_data = diffs.DiffProcessor(new_diff)
621 621 new_diff_data.prepare()
622 622
623 623 return old_diff_data, new_diff_data
624 624
625 625 def _link_comments_to_version(self, pull_request_version):
626 626 """
627 627 Link all unlinked comments of this pull request to the given version.
628 628
629 629 :param pull_request_version: The `PullRequestVersion` to which
630 630 the comments shall be linked.
631 631
632 632 """
633 633 pull_request = pull_request_version.pull_request
634 634 comments = ChangesetComment.query().filter(
635 635 # TODO: johbo: Should we query for the repo at all here?
636 636 # Pending decision on how comments of PRs are to be related
637 637 # to either the source repo, the target repo or no repo at all.
638 638 ChangesetComment.repo_id == pull_request.target_repo.repo_id,
639 639 ChangesetComment.pull_request == pull_request,
640 640 ChangesetComment.pull_request_version == None)
641 641
642 642 # TODO: johbo: Find out why this breaks if it is done in a bulk
643 643 # operation.
644 644 for comment in comments:
645 645 comment.pull_request_version_id = (
646 646 pull_request_version.pull_request_version_id)
647 647 Session().add(comment)
648 648
649 649 def _calculate_commit_id_changes(self, old_ids, new_ids):
650 650 added = new_ids.difference(old_ids)
651 651 common = old_ids.intersection(new_ids)
652 652 removed = old_ids.difference(new_ids)
653 653 return ChangeTuple(added, common, removed)
654 654
655 655 def _calculate_file_changes(self, old_diff_data, new_diff_data):
656 656
657 657 old_files = OrderedDict()
658 658 for diff_data in old_diff_data.parsed_diff:
659 659 old_files[diff_data['filename']] = md5_safe(diff_data['raw_diff'])
660 660
661 661 added_files = []
662 662 modified_files = []
663 663 removed_files = []
664 664 for diff_data in new_diff_data.parsed_diff:
665 665 new_filename = diff_data['filename']
666 666 new_hash = md5_safe(diff_data['raw_diff'])
667 667
668 668 old_hash = old_files.get(new_filename)
669 669 if not old_hash:
670 670 # file is not present in old diff, means it's added
671 671 added_files.append(new_filename)
672 672 else:
673 673 if new_hash != old_hash:
674 674 modified_files.append(new_filename)
675 675 # now remove a file from old, since we have seen it already
676 676 del old_files[new_filename]
677 677
678 678 # removed files is when there are present in old, but not in NEW,
679 679 # since we remove old files that are present in new diff, left-overs
680 680 # if any should be the removed files
681 681 removed_files.extend(old_files.keys())
682 682
683 683 return FileChangeTuple(added_files, modified_files, removed_files)
684 684
685 685 def _render_update_message(self, changes, file_changes):
686 686 """
687 687 render the message using DEFAULT_COMMENTS_RENDERER (RST renderer),
688 688 so it's always looking the same disregarding on which default
689 689 renderer system is using.
690 690
691 691 :param changes: changes named tuple
692 692 :param file_changes: file changes named tuple
693 693
694 694 """
695 695 new_status = ChangesetStatus.get_status_lbl(
696 696 ChangesetStatus.STATUS_UNDER_REVIEW)
697 697
698 698 changed_files = (
699 699 file_changes.added + file_changes.modified + file_changes.removed)
700 700
701 701 params = {
702 702 'under_review_label': new_status,
703 703 'added_commits': changes.added,
704 704 'removed_commits': changes.removed,
705 705 'changed_files': changed_files,
706 706 'added_files': file_changes.added,
707 707 'modified_files': file_changes.modified,
708 708 'removed_files': file_changes.removed,
709 709 }
710 710 renderer = RstTemplateRenderer()
711 711 return renderer.render('pull_request_update.mako', **params)
712 712
713 713 def edit(self, pull_request, title, description):
714 714 pull_request = self.__get_pull_request(pull_request)
715 715 if pull_request.is_closed():
716 716 raise ValueError('This pull request is closed')
717 717 if title:
718 718 pull_request.title = title
719 719 pull_request.description = description
720 720 pull_request.updated_on = datetime.datetime.now()
721 721 Session().add(pull_request)
722 722
723 723 def update_reviewers(self, pull_request, reviewer_data):
724 724 """
725 725 Update the reviewers in the pull request
726 726
727 727 :param pull_request: the pr to update
728 728 :param reviewer_data: list of tuples [(user, ['reason1', 'reason2'])]
729 729 """
730 730
731 731 reviewers_reasons = {}
732 732 for user_id, reasons in reviewer_data:
733 733 if isinstance(user_id, (int, basestring)):
734 734 user_id = self._get_user(user_id).user_id
735 735 reviewers_reasons[user_id] = reasons
736 736
737 737 reviewers_ids = set(reviewers_reasons.keys())
738 738 pull_request = self.__get_pull_request(pull_request)
739 739 current_reviewers = PullRequestReviewers.query()\
740 740 .filter(PullRequestReviewers.pull_request ==
741 741 pull_request).all()
742 742 current_reviewers_ids = set([x.user.user_id for x in current_reviewers])
743 743
744 744 ids_to_add = reviewers_ids.difference(current_reviewers_ids)
745 745 ids_to_remove = current_reviewers_ids.difference(reviewers_ids)
746 746
747 747 log.debug("Adding %s reviewers", ids_to_add)
748 748 log.debug("Removing %s reviewers", ids_to_remove)
749 749 changed = False
750 750 for uid in ids_to_add:
751 751 changed = True
752 752 _usr = self._get_user(uid)
753 753 reasons = reviewers_reasons[uid]
754 754 reviewer = PullRequestReviewers(_usr, pull_request, reasons)
755 755 Session().add(reviewer)
756 756
757 757 self.notify_reviewers(pull_request, ids_to_add)
758 758
759 759 for uid in ids_to_remove:
760 760 changed = True
761 761 reviewer = PullRequestReviewers.query()\
762 762 .filter(PullRequestReviewers.user_id == uid,
763 763 PullRequestReviewers.pull_request == pull_request)\
764 764 .scalar()
765 765 if reviewer:
766 766 Session().delete(reviewer)
767 767 if changed:
768 768 pull_request.updated_on = datetime.datetime.now()
769 769 Session().add(pull_request)
770 770
771 771 return ids_to_add, ids_to_remove
772 772
773 773 def get_url(self, pull_request):
774 774 return h.url('pullrequest_show',
775 775 repo_name=safe_str(pull_request.target_repo.repo_name),
776 776 pull_request_id=pull_request.pull_request_id,
777 777 qualified=True)
778 778
779 779 def get_shadow_clone_url(self, pull_request):
780 return u'{url}/repository'.format(url=self.get_url(pull_request))
780 """
781 Returns qualified url pointing to the shadow repository. If this pull
782 request is closed there is no shadow repository and ``None`` will be
783 returned.
784 """
785 if pull_request.is_closed():
786 return None
787 else:
788 return u'{url}/repository'.format(url=self.get_url(pull_request))
781 789
782 790 def notify_reviewers(self, pull_request, reviewers_ids):
783 791 # notification to reviewers
784 792 if not reviewers_ids:
785 793 return
786 794
787 795 pull_request_obj = pull_request
788 796 # get the current participants of this pull request
789 797 recipients = reviewers_ids
790 798 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST
791 799
792 800 pr_source_repo = pull_request_obj.source_repo
793 801 pr_target_repo = pull_request_obj.target_repo
794 802
795 803 pr_url = h.url(
796 804 'pullrequest_show',
797 805 repo_name=pr_target_repo.repo_name,
798 806 pull_request_id=pull_request_obj.pull_request_id,
799 807 qualified=True,)
800 808
801 809 # set some variables for email notification
802 810 pr_target_repo_url = h.url(
803 811 'summary_home',
804 812 repo_name=pr_target_repo.repo_name,
805 813 qualified=True)
806 814
807 815 pr_source_repo_url = h.url(
808 816 'summary_home',
809 817 repo_name=pr_source_repo.repo_name,
810 818 qualified=True)
811 819
812 820 # pull request specifics
813 821 pull_request_commits = [
814 822 (x.raw_id, x.message)
815 823 for x in map(pr_source_repo.get_commit, pull_request.revisions)]
816 824
817 825 kwargs = {
818 826 'user': pull_request.author,
819 827 'pull_request': pull_request_obj,
820 828 'pull_request_commits': pull_request_commits,
821 829
822 830 'pull_request_target_repo': pr_target_repo,
823 831 'pull_request_target_repo_url': pr_target_repo_url,
824 832
825 833 'pull_request_source_repo': pr_source_repo,
826 834 'pull_request_source_repo_url': pr_source_repo_url,
827 835
828 836 'pull_request_url': pr_url,
829 837 }
830 838
831 839 # pre-generate the subject for notification itself
832 840 (subject,
833 841 _h, _e, # we don't care about those
834 842 body_plaintext) = EmailNotificationModel().render_email(
835 843 notification_type, **kwargs)
836 844
837 845 # create notification objects, and emails
838 846 NotificationModel().create(
839 847 created_by=pull_request.author,
840 848 notification_subject=subject,
841 849 notification_body=body_plaintext,
842 850 notification_type=notification_type,
843 851 recipients=recipients,
844 852 email_kwargs=kwargs,
845 853 )
846 854
847 855 def delete(self, pull_request):
848 856 pull_request = self.__get_pull_request(pull_request)
849 857 self._cleanup_merge_workspace(pull_request)
850 858 Session().delete(pull_request)
851 859
852 860 def close_pull_request(self, pull_request, user):
853 861 pull_request = self.__get_pull_request(pull_request)
854 862 self._cleanup_merge_workspace(pull_request)
855 863 pull_request.status = PullRequest.STATUS_CLOSED
856 864 pull_request.updated_on = datetime.datetime.now()
857 865 Session().add(pull_request)
858 866 self._trigger_pull_request_hook(
859 867 pull_request, pull_request.author, 'close')
860 868 self._log_action('user_closed_pull_request', user, pull_request)
861 869
862 870 def close_pull_request_with_comment(self, pull_request, user, repo,
863 871 message=None):
864 872 status = ChangesetStatus.STATUS_REJECTED
865 873
866 874 if not message:
867 875 message = (
868 876 _('Status change %(transition_icon)s %(status)s') % {
869 877 'transition_icon': '>',
870 878 'status': ChangesetStatus.get_status_lbl(status)})
871 879
872 880 internal_message = _('Closing with') + ' ' + message
873 881
874 882 comm = ChangesetCommentsModel().create(
875 883 text=internal_message,
876 884 repo=repo.repo_id,
877 885 user=user.user_id,
878 886 pull_request=pull_request.pull_request_id,
879 887 f_path=None,
880 888 line_no=None,
881 889 status_change=ChangesetStatus.get_status_lbl(status),
882 890 status_change_type=status,
883 891 closing_pr=True
884 892 )
885 893
886 894 ChangesetStatusModel().set_status(
887 895 repo.repo_id,
888 896 status,
889 897 user.user_id,
890 898 comm,
891 899 pull_request=pull_request.pull_request_id
892 900 )
893 901 Session().flush()
894 902
895 903 PullRequestModel().close_pull_request(
896 904 pull_request.pull_request_id, user)
897 905
898 906 def merge_status(self, pull_request):
899 907 if not self._is_merge_enabled(pull_request):
900 908 return False, _('Server-side pull request merging is disabled.')
901 909 if pull_request.is_closed():
902 910 return False, _('This pull request is closed.')
903 911 merge_possible, msg = self._check_repo_requirements(
904 912 target=pull_request.target_repo, source=pull_request.source_repo)
905 913 if not merge_possible:
906 914 return merge_possible, msg
907 915
908 916 try:
909 917 resp = self._try_merge(pull_request)
910 918 status = resp.possible, self.merge_status_message(
911 919 resp.failure_reason)
912 920 except NotImplementedError:
913 921 status = False, _('Pull request merging is not supported.')
914 922
915 923 return status
916 924
917 925 def _check_repo_requirements(self, target, source):
918 926 """
919 927 Check if `target` and `source` have compatible requirements.
920 928
921 929 Currently this is just checking for largefiles.
922 930 """
923 931 target_has_largefiles = self._has_largefiles(target)
924 932 source_has_largefiles = self._has_largefiles(source)
925 933 merge_possible = True
926 934 message = u''
927 935
928 936 if target_has_largefiles != source_has_largefiles:
929 937 merge_possible = False
930 938 if source_has_largefiles:
931 939 message = _(
932 940 'Target repository large files support is disabled.')
933 941 else:
934 942 message = _(
935 943 'Source repository large files support is disabled.')
936 944
937 945 return merge_possible, message
938 946
939 947 def _has_largefiles(self, repo):
940 948 largefiles_ui = VcsSettingsModel(repo=repo).get_ui_settings(
941 949 'extensions', 'largefiles')
942 950 return largefiles_ui and largefiles_ui[0].active
943 951
944 952 def _try_merge(self, pull_request):
945 953 """
946 954 Try to merge the pull request and return the merge status.
947 955 """
948 956 log.debug(
949 957 "Trying out if the pull request %s can be merged.",
950 958 pull_request.pull_request_id)
951 959 target_vcs = pull_request.target_repo.scm_instance()
952 960 target_ref = self._refresh_reference(
953 961 pull_request.target_ref_parts, target_vcs)
954 962
955 963 target_locked = pull_request.target_repo.locked
956 964 if target_locked and target_locked[0]:
957 965 log.debug("The target repository is locked.")
958 966 merge_state = MergeResponse(
959 967 False, False, None, MergeFailureReason.TARGET_IS_LOCKED)
960 968 elif self._needs_merge_state_refresh(pull_request, target_ref):
961 969 log.debug("Refreshing the merge status of the repository.")
962 970 merge_state = self._refresh_merge_state(
963 971 pull_request, target_vcs, target_ref)
964 972 else:
965 973 possible = pull_request.\
966 974 _last_merge_status == MergeFailureReason.NONE
967 975 merge_state = MergeResponse(
968 976 possible, False, None, pull_request._last_merge_status)
969 977 log.debug("Merge response: %s", merge_state)
970 978 return merge_state
971 979
972 980 def _refresh_reference(self, reference, vcs_repository):
973 981 if reference.type in ('branch', 'book'):
974 982 name_or_id = reference.name
975 983 else:
976 984 name_or_id = reference.commit_id
977 985 refreshed_commit = vcs_repository.get_commit(name_or_id)
978 986 refreshed_reference = Reference(
979 987 reference.type, reference.name, refreshed_commit.raw_id)
980 988 return refreshed_reference
981 989
982 990 def _needs_merge_state_refresh(self, pull_request, target_reference):
983 991 return not(
984 992 pull_request.revisions and
985 993 pull_request.revisions[0] == pull_request._last_merge_source_rev and
986 994 target_reference.commit_id == pull_request._last_merge_target_rev)
987 995
988 996 def _refresh_merge_state(self, pull_request, target_vcs, target_reference):
989 997 workspace_id = self._workspace_id(pull_request)
990 998 source_vcs = pull_request.source_repo.scm_instance()
991 999 use_rebase = self._use_rebase_for_merging(pull_request)
992 1000 merge_state = target_vcs.merge(
993 1001 target_reference, source_vcs, pull_request.source_ref_parts,
994 1002 workspace_id, dry_run=True, use_rebase=use_rebase)
995 1003
996 1004 # Do not store the response if there was an unknown error.
997 1005 if merge_state.failure_reason != MergeFailureReason.UNKNOWN:
998 1006 pull_request._last_merge_source_rev = pull_request.\
999 1007 source_ref_parts.commit_id
1000 1008 pull_request._last_merge_target_rev = target_reference.commit_id
1001 1009 pull_request._last_merge_status = (
1002 1010 merge_state.failure_reason)
1003 1011 Session().add(pull_request)
1004 1012 Session().flush()
1005 1013
1006 1014 return merge_state
1007 1015
1008 1016 def _workspace_id(self, pull_request):
1009 1017 workspace_id = 'pr-%s' % pull_request.pull_request_id
1010 1018 return workspace_id
1011 1019
1012 1020 def merge_status_message(self, status_code):
1013 1021 """
1014 1022 Return a human friendly error message for the given merge status code.
1015 1023 """
1016 1024 return self.MERGE_STATUS_MESSAGES[status_code]
1017 1025
1018 1026 def generate_repo_data(self, repo, commit_id=None, branch=None,
1019 1027 bookmark=None):
1020 1028 all_refs, selected_ref = \
1021 1029 self._get_repo_pullrequest_sources(
1022 1030 repo.scm_instance(), commit_id=commit_id,
1023 1031 branch=branch, bookmark=bookmark)
1024 1032
1025 1033 refs_select2 = []
1026 1034 for element in all_refs:
1027 1035 children = [{'id': x[0], 'text': x[1]} for x in element[0]]
1028 1036 refs_select2.append({'text': element[1], 'children': children})
1029 1037
1030 1038 return {
1031 1039 'user': {
1032 1040 'user_id': repo.user.user_id,
1033 1041 'username': repo.user.username,
1034 1042 'firstname': repo.user.firstname,
1035 1043 'lastname': repo.user.lastname,
1036 1044 'gravatar_link': h.gravatar_url(repo.user.email, 14),
1037 1045 },
1038 1046 'description': h.chop_at_smart(repo.description, '\n'),
1039 1047 'refs': {
1040 1048 'all_refs': all_refs,
1041 1049 'selected_ref': selected_ref,
1042 1050 'select2_refs': refs_select2
1043 1051 }
1044 1052 }
1045 1053
1046 1054 def generate_pullrequest_title(self, source, source_ref, target):
1047 1055 return u'{source}#{at_ref} to {target}'.format(
1048 1056 source=source,
1049 1057 at_ref=source_ref,
1050 1058 target=target,
1051 1059 )
1052 1060
1053 1061 def _cleanup_merge_workspace(self, pull_request):
1054 1062 # Merging related cleanup
1055 1063 target_scm = pull_request.target_repo.scm_instance()
1056 1064 workspace_id = 'pr-%s' % pull_request.pull_request_id
1057 1065
1058 1066 try:
1059 1067 target_scm.cleanup_merge_workspace(workspace_id)
1060 1068 except NotImplementedError:
1061 1069 pass
1062 1070
1063 1071 def _get_repo_pullrequest_sources(
1064 1072 self, repo, commit_id=None, branch=None, bookmark=None):
1065 1073 """
1066 1074 Return a structure with repo's interesting commits, suitable for
1067 1075 the selectors in pullrequest controller
1068 1076
1069 1077 :param commit_id: a commit that must be in the list somehow
1070 1078 and selected by default
1071 1079 :param branch: a branch that must be in the list and selected
1072 1080 by default - even if closed
1073 1081 :param bookmark: a bookmark that must be in the list and selected
1074 1082 """
1075 1083
1076 1084 commit_id = safe_str(commit_id) if commit_id else None
1077 1085 branch = safe_str(branch) if branch else None
1078 1086 bookmark = safe_str(bookmark) if bookmark else None
1079 1087
1080 1088 selected = None
1081 1089
1082 1090 # order matters: first source that has commit_id in it will be selected
1083 1091 sources = []
1084 1092 sources.append(('book', repo.bookmarks.items(), _('Bookmarks'), bookmark))
1085 1093 sources.append(('branch', repo.branches.items(), _('Branches'), branch))
1086 1094
1087 1095 if commit_id:
1088 1096 ref_commit = (h.short_id(commit_id), commit_id)
1089 1097 sources.append(('rev', [ref_commit], _('Commit IDs'), commit_id))
1090 1098
1091 1099 sources.append(
1092 1100 ('branch', repo.branches_closed.items(), _('Closed Branches'), branch),
1093 1101 )
1094 1102
1095 1103 groups = []
1096 1104 for group_key, ref_list, group_name, match in sources:
1097 1105 group_refs = []
1098 1106 for ref_name, ref_id in ref_list:
1099 1107 ref_key = '%s:%s:%s' % (group_key, ref_name, ref_id)
1100 1108 group_refs.append((ref_key, ref_name))
1101 1109
1102 1110 if not selected:
1103 1111 if set([commit_id, match]) & set([ref_id, ref_name]):
1104 1112 selected = ref_key
1105 1113
1106 1114 if group_refs:
1107 1115 groups.append((group_refs, group_name))
1108 1116
1109 1117 if not selected:
1110 1118 ref = commit_id or branch or bookmark
1111 1119 if ref:
1112 1120 raise CommitDoesNotExistError(
1113 1121 'No commit refs could be found matching: %s' % ref)
1114 1122 elif repo.DEFAULT_BRANCH_NAME in repo.branches:
1115 1123 selected = 'branch:%s:%s' % (
1116 1124 repo.DEFAULT_BRANCH_NAME,
1117 1125 repo.branches[repo.DEFAULT_BRANCH_NAME]
1118 1126 )
1119 1127 elif repo.commit_ids:
1120 1128 rev = repo.commit_ids[0]
1121 1129 selected = 'rev:%s:%s' % (rev, rev)
1122 1130 else:
1123 1131 raise EmptyRepositoryError()
1124 1132 return groups, selected
1125 1133
1126 1134 def get_diff(self, pull_request, context=DIFF_CONTEXT):
1127 1135 pull_request = self.__get_pull_request(pull_request)
1128 1136 return self._get_diff_from_pr_or_version(pull_request, context=context)
1129 1137
1130 1138 def _get_diff_from_pr_or_version(self, pr_or_version, context):
1131 1139 source_repo = pr_or_version.source_repo
1132 1140
1133 1141 # we swap org/other ref since we run a simple diff on one repo
1134 1142 target_ref_id = pr_or_version.target_ref_parts.commit_id
1135 1143 source_ref_id = pr_or_version.source_ref_parts.commit_id
1136 1144 target_commit = source_repo.get_commit(
1137 1145 commit_id=safe_str(target_ref_id))
1138 1146 source_commit = source_repo.get_commit(commit_id=safe_str(source_ref_id))
1139 1147 vcs_repo = source_repo.scm_instance()
1140 1148
1141 1149 # TODO: johbo: In the context of an update, we cannot reach
1142 1150 # the old commit anymore with our normal mechanisms. It needs
1143 1151 # some sort of special support in the vcs layer to avoid this
1144 1152 # workaround.
1145 1153 if (source_commit.raw_id == vcs_repo.EMPTY_COMMIT_ID and
1146 1154 vcs_repo.alias == 'git'):
1147 1155 source_commit.raw_id = safe_str(source_ref_id)
1148 1156
1149 1157 log.debug('calculating diff between '
1150 1158 'source_ref:%s and target_ref:%s for repo `%s`',
1151 1159 target_ref_id, source_ref_id,
1152 1160 safe_unicode(vcs_repo.path))
1153 1161
1154 1162 vcs_diff = vcs_repo.get_diff(
1155 1163 commit1=target_commit, commit2=source_commit, context=context)
1156 1164 return vcs_diff
1157 1165
1158 1166 def _is_merge_enabled(self, pull_request):
1159 1167 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1160 1168 settings = settings_model.get_general_settings()
1161 1169 return settings.get('rhodecode_pr_merge_enabled', False)
1162 1170
1163 1171 def _use_rebase_for_merging(self, pull_request):
1164 1172 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1165 1173 settings = settings_model.get_general_settings()
1166 1174 return settings.get('rhodecode_hg_use_rebase_for_merging', False)
1167 1175
1168 1176 def _log_action(self, action, user, pull_request):
1169 1177 action_logger(
1170 1178 user,
1171 1179 '{action}:{pr_id}'.format(
1172 1180 action=action, pr_id=pull_request.pull_request_id),
1173 1181 pull_request.target_repo)
1174 1182
1175 1183
1176 1184 ChangeTuple = namedtuple('ChangeTuple',
1177 1185 ['added', 'common', 'removed'])
1178 1186
1179 1187 FileChangeTuple = namedtuple('FileChangeTuple',
1180 1188 ['added', 'modified', 'removed'])
General Comments 0
You need to be logged in to leave comments. Login now