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