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