##// END OF EJS Templates
audit-logs: add push/pull actions to audit logs.
marcink -
r1736:70904f54 default
parent child Browse files
Show More
@@ -1,148 +1,165 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2017-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import logging
22 22 import datetime
23 23
24 24 from rhodecode.model import meta
25 from rhodecode.model.db import User, UserLog
25 from rhodecode.model.db import User, UserLog, Repository
26 26
27 27
28 28 log = logging.getLogger(__name__)
29 29
30 30
31 31 ACTIONS = {
32 32 'user.login.success': {},
33 33 'user.login.failure': {},
34 34 'user.logout': {},
35 35 'user.password.reset_request': {},
36 'user.push': {},
37 'user.pull': {},
36 38
37 39 'repo.add': {},
38 40 'repo.edit': {},
39 41 'repo.edit.permissions': {},
40 42 'repo.commit.strip': {}
41 43 }
42 44
43 45
44 46 class UserWrap(object):
45 47 """
46 48 Fake object used to imitate AuthUser
47 49 """
48 50
49 51 def __init__(self, user_id=None, username=None, ip_addr=None):
50 52 self.user_id = user_id
51 53 self.username = username
52 54 self.ip_addr = ip_addr
53 55
54 56
57 class RepoWrap(object):
58 """
59 Fake object used to imitate RepoObject that audit logger requires
60 """
61
62 def __init__(self, repo_id=None, repo_name=None):
63 self.repo_id = repo_id
64 self.repo_name = repo_name
65
66
55 67 def _store_log(action_name, action_data, user_id, username, user_data,
56 68 ip_address, repository_id, repository_name):
57 69 user_log = UserLog()
58 70 user_log.version = UserLog.VERSION_2
59 71
60 72 user_log.action = action_name
61 73 user_log.action_data = action_data
62 74
63 75 user_log.user_ip = ip_address
64 76
65 77 user_log.user_id = user_id
66 78 user_log.username = username
67 79 user_log.user_data = user_data
68 80
69 81 user_log.repository_id = repository_id
70 82 user_log.repository_name = repository_name
71 83
72 84 user_log.action_date = datetime.datetime.now()
73 85
74 86 log.info('AUDIT: Logging action: `%s` by user:id:%s[%s] ip:%s',
75 87 action_name, user_id, username, ip_address)
76 88
77 89 return user_log
78 90
79 91
80 92 def store(
81 93 action, user, action_data=None, user_data=None, ip_addr=None,
82 94 repo=None, sa_session=None, commit=False):
83 95 """
84 96 Audit logger for various actions made by users, typically this results in a call such::
85 97
86 98 from rhodecode.lib import audit_logger
87 99
88 100 audit_logger.store(action='repo.edit', user=self._rhodecode_user)
89 101 audit_logger.store(action='repo.delete', user=audit_logger.UserWrap(username='itried-to-login', ip_addr='8.8.8.8'))
90 102
91 103 # without an user ?
92 104 audit_user = audit_logger.UserWrap(
93 105 username=self.request.params.get('username'),
94 106 ip_addr=self.request.remote_addr)
95 107 audit_logger.store(action='user.login.failure', user=audit_user)
96 108 """
97 109 from rhodecode.lib.utils2 import safe_unicode
98 110 from rhodecode.lib.auth import AuthUser
99 111
100 112 if action not in ACTIONS:
101 113 raise ValueError('Action `{}` not in valid actions'.format(action))
102 114
103 115 if not sa_session:
104 116 sa_session = meta.Session()
105 117
106 118 try:
107 119 username = getattr(user, 'username', None)
108 120 if not username:
109 121 pass
110 122
111 123 user_id = getattr(user, 'user_id', None)
112 124 if not user_id:
113 125 # maybe we have username ? Try to figure user_id from username
114 126 if username:
115 127 user_id = getattr(
116 128 User.get_by_username(username), 'user_id', None)
117 129
118 130 ip_addr = ip_addr or getattr(user, 'ip_addr', None)
119 131 if not ip_addr:
120 132 pass
121 133
122 134 if not user_data:
123 135 # try to get this from the auth user
124 136 if isinstance(user, AuthUser):
125 137 user_data = {
126 138 'username': user.username,
127 139 'email': user.email,
128 140 }
129 141
142 repository_name = getattr(repo, 'repo_name', None)
130 143 repository_id = getattr(repo, 'repo_id', None)
131 repository_name = getattr(repo, 'repo_name', None)
144 if not repository_id:
145 # maybe we have repo_name ? Try to figure repo_id from repo_name
146 if repository_name:
147 repository_id = getattr(
148 Repository.get_by_repo_name(repository_name), 'repo_id', None)
132 149
133 150 user_log = _store_log(
134 151 action_name=safe_unicode(action),
135 152 action_data=action_data or {},
136 153 user_id=user_id,
137 154 username=username,
138 155 user_data=user_data or {},
139 156 ip_address=safe_unicode(ip_addr),
140 157 repository_id=repository_id,
141 158 repository_name=repository_name
142 159 )
143 160 sa_session.add(user_log)
144 161 if commit:
145 162 sa_session.commit()
146 163
147 164 except Exception:
148 165 log.exception('AUDIT: failed to store audit log')
@@ -1,401 +1,420 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2013-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21
22 22 """
23 23 Set of hooks run by RhodeCode Enterprise
24 24 """
25 25
26 26 import os
27 27 import collections
28 28 import logging
29 29
30 30 import rhodecode
31 31 from rhodecode import events
32 32 from rhodecode.lib import helpers as h
33 from rhodecode.lib import audit_logger
33 34 from rhodecode.lib.utils import action_logger
34 35 from rhodecode.lib.utils2 import safe_str
35 36 from rhodecode.lib.exceptions import HTTPLockedRC, UserCreationError
36 37 from rhodecode.model.db import Repository, User
37 38
38 39 log = logging.getLogger(__name__)
39 40
40 41
41 42 HookResponse = collections.namedtuple('HookResponse', ('status', 'output'))
42 43
43 44
44 45 def is_shadow_repo(extras):
45 46 """
46 47 Returns ``True`` if this is an action executed against a shadow repository.
47 48 """
48 49 return extras['is_shadow_repo']
49 50
50 51
51 52 def _get_scm_size(alias, root_path):
52 53
53 54 if not alias.startswith('.'):
54 55 alias += '.'
55 56
56 57 size_scm, size_root = 0, 0
57 58 for path, unused_dirs, files in os.walk(safe_str(root_path)):
58 59 if path.find(alias) != -1:
59 60 for f in files:
60 61 try:
61 62 size_scm += os.path.getsize(os.path.join(path, f))
62 63 except OSError:
63 64 pass
64 65 else:
65 66 for f in files:
66 67 try:
67 68 size_root += os.path.getsize(os.path.join(path, f))
68 69 except OSError:
69 70 pass
70 71
71 72 size_scm_f = h.format_byte_size_binary(size_scm)
72 73 size_root_f = h.format_byte_size_binary(size_root)
73 74 size_total_f = h.format_byte_size_binary(size_root + size_scm)
74 75
75 76 return size_scm_f, size_root_f, size_total_f
76 77
77 78
78 79 # actual hooks called by Mercurial internally, and GIT by our Python Hooks
79 80 def repo_size(extras):
80 81 """Present size of repository after push."""
81 82 repo = Repository.get_by_repo_name(extras.repository)
82 83 vcs_part = safe_str(u'.%s' % repo.repo_type)
83 84 size_vcs, size_root, size_total = _get_scm_size(vcs_part,
84 85 repo.repo_full_path)
85 86 msg = ('Repository `%s` size summary %s:%s repo:%s total:%s\n'
86 87 % (repo.repo_name, vcs_part, size_vcs, size_root, size_total))
87 88 return HookResponse(0, msg)
88 89
89 90
90 91 def pre_push(extras):
91 92 """
92 93 Hook executed before pushing code.
93 94
94 95 It bans pushing when the repository is locked.
95 96 """
96 97
97 98 usr = User.get_by_username(extras.username)
98 99 output = ''
99 100 if extras.locked_by[0] and usr.user_id != int(extras.locked_by[0]):
100 101 locked_by = User.get(extras.locked_by[0]).username
101 102 reason = extras.locked_by[2]
102 103 # this exception is interpreted in git/hg middlewares and based
103 104 # on that proper return code is server to client
104 105 _http_ret = HTTPLockedRC(
105 106 _locked_by_explanation(extras.repository, locked_by, reason))
106 107 if str(_http_ret.code).startswith('2'):
107 108 # 2xx Codes don't raise exceptions
108 109 output = _http_ret.title
109 110 else:
110 111 raise _http_ret
111 112
112 113 # Propagate to external components. This is done after checking the
113 114 # lock, for consistent behavior.
114 115 if not is_shadow_repo(extras):
115 116 pre_push_extension(repo_store_path=Repository.base_path(), **extras)
116 117 events.trigger(events.RepoPrePushEvent(
117 118 repo_name=extras.repository, extras=extras))
118 119
119 120 return HookResponse(0, output)
120 121
121 122
122 123 def pre_pull(extras):
123 124 """
124 125 Hook executed before pulling the code.
125 126
126 127 It bans pulling when the repository is locked.
127 128 """
128 129
129 130 output = ''
130 131 if extras.locked_by[0]:
131 132 locked_by = User.get(extras.locked_by[0]).username
132 133 reason = extras.locked_by[2]
133 134 # this exception is interpreted in git/hg middlewares and based
134 135 # on that proper return code is server to client
135 136 _http_ret = HTTPLockedRC(
136 137 _locked_by_explanation(extras.repository, locked_by, reason))
137 138 if str(_http_ret.code).startswith('2'):
138 139 # 2xx Codes don't raise exceptions
139 140 output = _http_ret.title
140 141 else:
141 142 raise _http_ret
142 143
143 144 # Propagate to external components. This is done after checking the
144 145 # lock, for consistent behavior.
145 146 if not is_shadow_repo(extras):
146 147 pre_pull_extension(**extras)
147 148 events.trigger(events.RepoPrePullEvent(
148 149 repo_name=extras.repository, extras=extras))
149 150
150 151 return HookResponse(0, output)
151 152
152 153
153 154 def post_pull(extras):
154 155 """Hook executed after client pulls the code."""
155 156 user = User.get_by_username(extras.username)
156 157 action = 'pull'
157 158 action_logger(user, action, extras.repository, extras.ip, commit=True)
158 159
160 audit_user = audit_logger.UserWrap(
161 username=extras.username,
162 ip_addr=extras.ip)
163 audit_logger.store(
164 action='user.pull', action_data={
165 'user_agent': extras.user_agent},
166 user=audit_user, commit=True)
167
159 168 # Propagate to external components.
160 169 if not is_shadow_repo(extras):
161 170 post_pull_extension(**extras)
162 171 events.trigger(events.RepoPullEvent(
163 172 repo_name=extras.repository, extras=extras))
164 173
165 174 output = ''
166 175 # make lock is a tri state False, True, None. We only make lock on True
167 176 if extras.make_lock is True and not is_shadow_repo(extras):
168 177 Repository.lock(Repository.get_by_repo_name(extras.repository),
169 178 user.user_id,
170 179 lock_reason=Repository.LOCK_PULL)
171 180 msg = 'Made lock on repo `%s`' % (extras.repository,)
172 181 output += msg
173 182
174 183 if extras.locked_by[0]:
175 184 locked_by = User.get(extras.locked_by[0]).username
176 185 reason = extras.locked_by[2]
177 186 _http_ret = HTTPLockedRC(
178 187 _locked_by_explanation(extras.repository, locked_by, reason))
179 188 if str(_http_ret.code).startswith('2'):
180 189 # 2xx Codes don't raise exceptions
181 190 output += _http_ret.title
182 191
183 192 return HookResponse(0, output)
184 193
185 194
186 195 def post_push(extras):
187 196 """Hook executed after user pushes to the repository."""
188 197 action_tmpl = extras.action + ':%s'
189 198 commit_ids = extras.commit_ids[:29000]
190 199
191 200 action = action_tmpl % ','.join(commit_ids)
192 201 action_logger(
193 202 extras.username, action, extras.repository, extras.ip, commit=True)
194 203
204 audit_user = audit_logger.UserWrap(
205 username=extras.username,
206 ip_addr=extras.ip)
207 repo = audit_logger.RepoWrap(repo_name=extras.repository)
208 audit_logger.store(
209 action='user.push', action_data={
210 'user_agent': extras.user_agent,
211 'commit_ids': commit_ids[:10000]},
212 user=audit_user, repo=repo, commit=True)
213
195 214 # Propagate to external components.
196 215 if not is_shadow_repo(extras):
197 216 post_push_extension(
198 217 repo_store_path=Repository.base_path(),
199 218 pushed_revs=commit_ids,
200 219 **extras)
201 220 events.trigger(events.RepoPushEvent(
202 221 repo_name=extras.repository,
203 222 pushed_commit_ids=commit_ids,
204 223 extras=extras))
205 224
206 225 output = ''
207 226 # make lock is a tri state False, True, None. We only release lock on False
208 227 if extras.make_lock is False and not is_shadow_repo(extras):
209 228 Repository.unlock(Repository.get_by_repo_name(extras.repository))
210 229 msg = 'Released lock on repo `%s`\n' % extras.repository
211 230 output += msg
212 231
213 232 if extras.locked_by[0]:
214 233 locked_by = User.get(extras.locked_by[0]).username
215 234 reason = extras.locked_by[2]
216 235 _http_ret = HTTPLockedRC(
217 236 _locked_by_explanation(extras.repository, locked_by, reason))
218 237 # TODO: johbo: if not?
219 238 if str(_http_ret.code).startswith('2'):
220 239 # 2xx Codes don't raise exceptions
221 240 output += _http_ret.title
222 241
223 242 output += 'RhodeCode: push completed\n'
224 243
225 244 return HookResponse(0, output)
226 245
227 246
228 247 def _locked_by_explanation(repo_name, user_name, reason):
229 248 message = (
230 249 'Repository `%s` locked by user `%s`. Reason:`%s`'
231 250 % (repo_name, user_name, reason))
232 251 return message
233 252
234 253
235 254 def check_allowed_create_user(user_dict, created_by, **kwargs):
236 255 # pre create hooks
237 256 if pre_create_user.is_active():
238 257 allowed, reason = pre_create_user(created_by=created_by, **user_dict)
239 258 if not allowed:
240 259 raise UserCreationError(reason)
241 260
242 261
243 262 class ExtensionCallback(object):
244 263 """
245 264 Forwards a given call to rcextensions, sanitizes keyword arguments.
246 265
247 266 Does check if there is an extension active for that hook. If it is
248 267 there, it will forward all `kwargs_keys` keyword arguments to the
249 268 extension callback.
250 269 """
251 270
252 271 def __init__(self, hook_name, kwargs_keys):
253 272 self._hook_name = hook_name
254 273 self._kwargs_keys = set(kwargs_keys)
255 274
256 275 def __call__(self, *args, **kwargs):
257 276 log.debug('Calling extension callback for %s', self._hook_name)
258 277
259 278 kwargs_to_pass = dict((key, kwargs[key]) for key in self._kwargs_keys)
260 279 # backward compat for removed api_key for old hooks. THis was it works
261 280 # with older rcextensions that require api_key present
262 281 if self._hook_name in ['CREATE_USER_HOOK', 'DELETE_USER_HOOK']:
263 282 kwargs_to_pass['api_key'] = '_DEPRECATED_'
264 283
265 284 callback = self._get_callback()
266 285 if callback:
267 286 return callback(**kwargs_to_pass)
268 287 else:
269 288 log.debug('extensions callback not found skipping...')
270 289
271 290 def is_active(self):
272 291 return hasattr(rhodecode.EXTENSIONS, self._hook_name)
273 292
274 293 def _get_callback(self):
275 294 return getattr(rhodecode.EXTENSIONS, self._hook_name, None)
276 295
277 296
278 297 pre_pull_extension = ExtensionCallback(
279 298 hook_name='PRE_PULL_HOOK',
280 299 kwargs_keys=(
281 300 'server_url', 'config', 'scm', 'username', 'ip', 'action',
282 301 'repository'))
283 302
284 303
285 304 post_pull_extension = ExtensionCallback(
286 305 hook_name='PULL_HOOK',
287 306 kwargs_keys=(
288 307 'server_url', 'config', 'scm', 'username', 'ip', 'action',
289 308 'repository'))
290 309
291 310
292 311 pre_push_extension = ExtensionCallback(
293 312 hook_name='PRE_PUSH_HOOK',
294 313 kwargs_keys=(
295 314 'server_url', 'config', 'scm', 'username', 'ip', 'action',
296 315 'repository', 'repo_store_path', 'commit_ids'))
297 316
298 317
299 318 post_push_extension = ExtensionCallback(
300 319 hook_name='PUSH_HOOK',
301 320 kwargs_keys=(
302 321 'server_url', 'config', 'scm', 'username', 'ip', 'action',
303 322 'repository', 'repo_store_path', 'pushed_revs'))
304 323
305 324
306 325 pre_create_user = ExtensionCallback(
307 326 hook_name='PRE_CREATE_USER_HOOK',
308 327 kwargs_keys=(
309 328 'username', 'password', 'email', 'firstname', 'lastname', 'active',
310 329 'admin', 'created_by'))
311 330
312 331
313 332 log_create_pull_request = ExtensionCallback(
314 333 hook_name='CREATE_PULL_REQUEST',
315 334 kwargs_keys=(
316 335 'server_url', 'config', 'scm', 'username', 'ip', 'action',
317 336 'repository', 'pull_request_id', 'url', 'title', 'description',
318 337 'status', 'created_on', 'updated_on', 'commit_ids', 'review_status',
319 338 'mergeable', 'source', 'target', 'author', 'reviewers'))
320 339
321 340
322 341 log_merge_pull_request = ExtensionCallback(
323 342 hook_name='MERGE_PULL_REQUEST',
324 343 kwargs_keys=(
325 344 'server_url', 'config', 'scm', 'username', 'ip', 'action',
326 345 'repository', 'pull_request_id', 'url', 'title', 'description',
327 346 'status', 'created_on', 'updated_on', 'commit_ids', 'review_status',
328 347 'mergeable', 'source', 'target', 'author', 'reviewers'))
329 348
330 349
331 350 log_close_pull_request = ExtensionCallback(
332 351 hook_name='CLOSE_PULL_REQUEST',
333 352 kwargs_keys=(
334 353 'server_url', 'config', 'scm', 'username', 'ip', 'action',
335 354 'repository', 'pull_request_id', 'url', 'title', 'description',
336 355 'status', 'created_on', 'updated_on', 'commit_ids', 'review_status',
337 356 'mergeable', 'source', 'target', 'author', 'reviewers'))
338 357
339 358
340 359 log_review_pull_request = ExtensionCallback(
341 360 hook_name='REVIEW_PULL_REQUEST',
342 361 kwargs_keys=(
343 362 'server_url', 'config', 'scm', 'username', 'ip', 'action',
344 363 'repository', 'pull_request_id', 'url', 'title', 'description',
345 364 'status', 'created_on', 'updated_on', 'commit_ids', 'review_status',
346 365 'mergeable', 'source', 'target', 'author', 'reviewers'))
347 366
348 367
349 368 log_update_pull_request = ExtensionCallback(
350 369 hook_name='UPDATE_PULL_REQUEST',
351 370 kwargs_keys=(
352 371 'server_url', 'config', 'scm', 'username', 'ip', 'action',
353 372 'repository', 'pull_request_id', 'url', 'title', 'description',
354 373 'status', 'created_on', 'updated_on', 'commit_ids', 'review_status',
355 374 'mergeable', 'source', 'target', 'author', 'reviewers'))
356 375
357 376
358 377 log_create_user = ExtensionCallback(
359 378 hook_name='CREATE_USER_HOOK',
360 379 kwargs_keys=(
361 380 'username', 'full_name_or_username', 'full_contact', 'user_id',
362 381 'name', 'firstname', 'short_contact', 'admin', 'lastname',
363 382 'ip_addresses', 'extern_type', 'extern_name',
364 383 'email', 'api_keys', 'last_login',
365 384 'full_name', 'active', 'password', 'emails',
366 385 'inherit_default_permissions', 'created_by', 'created_on'))
367 386
368 387
369 388 log_delete_user = ExtensionCallback(
370 389 hook_name='DELETE_USER_HOOK',
371 390 kwargs_keys=(
372 391 'username', 'full_name_or_username', 'full_contact', 'user_id',
373 392 'name', 'firstname', 'short_contact', 'admin', 'lastname',
374 393 'ip_addresses',
375 394 'email', 'last_login',
376 395 'full_name', 'active', 'password', 'emails',
377 396 'inherit_default_permissions', 'deleted_by'))
378 397
379 398
380 399 log_create_repository = ExtensionCallback(
381 400 hook_name='CREATE_REPO_HOOK',
382 401 kwargs_keys=(
383 402 'repo_name', 'repo_type', 'description', 'private', 'created_on',
384 403 'enable_downloads', 'repo_id', 'user_id', 'enable_statistics',
385 404 'clone_uri', 'fork_id', 'group_id', 'created_by'))
386 405
387 406
388 407 log_delete_repository = ExtensionCallback(
389 408 hook_name='DELETE_REPO_HOOK',
390 409 kwargs_keys=(
391 410 'repo_name', 'repo_type', 'description', 'private', 'created_on',
392 411 'enable_downloads', 'repo_id', 'user_id', 'enable_statistics',
393 412 'clone_uri', 'fork_id', 'group_id', 'deleted_by', 'deleted_on'))
394 413
395 414
396 415 log_create_repository_group = ExtensionCallback(
397 416 hook_name='CREATE_REPO_GROUP_HOOK',
398 417 kwargs_keys=(
399 418 'group_name', 'group_parent_id', 'group_description',
400 419 'group_id', 'user_id', 'created_by', 'created_on',
401 420 'enable_locking'))
@@ -1,1095 +1,1097 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import mock
22 22 import pytest
23 23 from webob.exc import HTTPNotFound
24 24
25 25 import rhodecode
26 26 from rhodecode.lib.vcs.nodes import FileNode
27 27 from rhodecode.model.changeset_status import ChangesetStatusModel
28 28 from rhodecode.model.db import (
29 29 PullRequest, ChangesetStatus, UserLog, Notification)
30 30 from rhodecode.model.meta import Session
31 31 from rhodecode.model.pull_request import PullRequestModel
32 32 from rhodecode.model.user import UserModel
33 33 from rhodecode.model.repo import RepoModel
34 34 from rhodecode.tests import (
35 35 assert_session_flash, url, TEST_USER_ADMIN_LOGIN, TEST_USER_REGULAR_LOGIN)
36 36 from rhodecode.tests.utils import AssertResponse
37 37
38 38
39 39 @pytest.mark.usefixtures('app', 'autologin_user')
40 40 @pytest.mark.backends("git", "hg")
41 41 class TestPullrequestsController:
42 42
43 43 def test_index(self, backend):
44 44 self.app.get(url(
45 45 controller='pullrequests', action='index',
46 46 repo_name=backend.repo_name))
47 47
48 48 def test_option_menu_create_pull_request_exists(self, backend):
49 49 repo_name = backend.repo_name
50 50 response = self.app.get(url('summary_home', repo_name=repo_name))
51 51
52 52 create_pr_link = '<a href="%s">Create Pull Request</a>' % url(
53 53 'pullrequest', repo_name=repo_name)
54 54 response.mustcontain(create_pr_link)
55 55
56 56 def test_global_redirect_of_pr(self, backend, pr_util):
57 57 pull_request = pr_util.create_pull_request()
58 58
59 59 response = self.app.get(
60 60 url('pull_requests_global',
61 61 pull_request_id=pull_request.pull_request_id))
62 62
63 63 repo_name = pull_request.target_repo.repo_name
64 64 redirect_url = url('pullrequest_show', repo_name=repo_name,
65 65 pull_request_id=pull_request.pull_request_id)
66 66 assert response.status == '302 Found'
67 67 assert redirect_url in response.location
68 68
69 69 def test_create_pr_form_with_raw_commit_id(self, backend):
70 70 repo = backend.repo
71 71
72 72 self.app.get(
73 73 url(controller='pullrequests', action='index',
74 74 repo_name=repo.repo_name,
75 75 commit=repo.get_commit().raw_id),
76 76 status=200)
77 77
78 78 @pytest.mark.parametrize('pr_merge_enabled', [True, False])
79 79 def test_show(self, pr_util, pr_merge_enabled):
80 80 pull_request = pr_util.create_pull_request(
81 81 mergeable=pr_merge_enabled, enable_notifications=False)
82 82
83 83 response = self.app.get(url(
84 84 controller='pullrequests', action='show',
85 85 repo_name=pull_request.target_repo.scm_instance().name,
86 86 pull_request_id=str(pull_request.pull_request_id)))
87 87
88 88 for commit_id in pull_request.revisions:
89 89 response.mustcontain(commit_id)
90 90
91 91 assert pull_request.target_ref_parts.type in response
92 92 assert pull_request.target_ref_parts.name in response
93 93 target_clone_url = pull_request.target_repo.clone_url()
94 94 assert target_clone_url in response
95 95
96 96 assert 'class="pull-request-merge"' in response
97 97 assert (
98 98 'Server-side pull request merging is disabled.'
99 99 in response) != pr_merge_enabled
100 100
101 101 def test_close_status_visibility(self, pr_util, user_util, csrf_token):
102 102 from rhodecode.tests.functional.test_login import login_url, logut_url
103 103 # Logout
104 104 response = self.app.post(
105 105 logut_url,
106 106 params={'csrf_token': csrf_token})
107 107 # Login as regular user
108 108 response = self.app.post(login_url,
109 109 {'username': TEST_USER_REGULAR_LOGIN,
110 110 'password': 'test12'})
111 111
112 112 pull_request = pr_util.create_pull_request(
113 113 author=TEST_USER_REGULAR_LOGIN)
114 114
115 115 response = self.app.get(url(
116 116 controller='pullrequests', action='show',
117 117 repo_name=pull_request.target_repo.scm_instance().name,
118 118 pull_request_id=str(pull_request.pull_request_id)))
119 119
120 120 response.mustcontain('Server-side pull request merging is disabled.')
121 121
122 122 assert_response = response.assert_response()
123 123 # for regular user without a merge permissions, we don't see it
124 124 assert_response.no_element_exists('#close-pull-request-action')
125 125
126 126 user_util.grant_user_permission_to_repo(
127 127 pull_request.target_repo,
128 128 UserModel().get_by_username(TEST_USER_REGULAR_LOGIN),
129 129 'repository.write')
130 130 response = self.app.get(url(
131 131 controller='pullrequests', action='show',
132 132 repo_name=pull_request.target_repo.scm_instance().name,
133 133 pull_request_id=str(pull_request.pull_request_id)))
134 134
135 135 response.mustcontain('Server-side pull request merging is disabled.')
136 136
137 137 assert_response = response.assert_response()
138 138 # now regular user has a merge permissions, we have CLOSE button
139 139 assert_response.one_element_exists('#close-pull-request-action')
140 140
141 141 def test_show_invalid_commit_id(self, pr_util):
142 142 # Simulating invalid revisions which will cause a lookup error
143 143 pull_request = pr_util.create_pull_request()
144 144 pull_request.revisions = ['invalid']
145 145 Session().add(pull_request)
146 146 Session().commit()
147 147
148 148 response = self.app.get(url(
149 149 controller='pullrequests', action='show',
150 150 repo_name=pull_request.target_repo.scm_instance().name,
151 151 pull_request_id=str(pull_request.pull_request_id)))
152 152
153 153 for commit_id in pull_request.revisions:
154 154 response.mustcontain(commit_id)
155 155
156 156 def test_show_invalid_source_reference(self, pr_util):
157 157 pull_request = pr_util.create_pull_request()
158 158 pull_request.source_ref = 'branch:b:invalid'
159 159 Session().add(pull_request)
160 160 Session().commit()
161 161
162 162 self.app.get(url(
163 163 controller='pullrequests', action='show',
164 164 repo_name=pull_request.target_repo.scm_instance().name,
165 165 pull_request_id=str(pull_request.pull_request_id)))
166 166
167 167 def test_edit_title_description(self, pr_util, csrf_token):
168 168 pull_request = pr_util.create_pull_request()
169 169 pull_request_id = pull_request.pull_request_id
170 170
171 171 response = self.app.post(
172 172 url(controller='pullrequests', action='update',
173 173 repo_name=pull_request.target_repo.repo_name,
174 174 pull_request_id=str(pull_request_id)),
175 175 params={
176 176 'edit_pull_request': 'true',
177 177 '_method': 'put',
178 178 'title': 'New title',
179 179 'description': 'New description',
180 180 'csrf_token': csrf_token})
181 181
182 182 assert_session_flash(
183 183 response, u'Pull request title & description updated.',
184 184 category='success')
185 185
186 186 pull_request = PullRequest.get(pull_request_id)
187 187 assert pull_request.title == 'New title'
188 188 assert pull_request.description == 'New description'
189 189
190 190 def test_edit_title_description_closed(self, pr_util, csrf_token):
191 191 pull_request = pr_util.create_pull_request()
192 192 pull_request_id = pull_request.pull_request_id
193 193 pr_util.close()
194 194
195 195 response = self.app.post(
196 196 url(controller='pullrequests', action='update',
197 197 repo_name=pull_request.target_repo.repo_name,
198 198 pull_request_id=str(pull_request_id)),
199 199 params={
200 200 'edit_pull_request': 'true',
201 201 '_method': 'put',
202 202 'title': 'New title',
203 203 'description': 'New description',
204 204 'csrf_token': csrf_token})
205 205
206 206 assert_session_flash(
207 207 response, u'Cannot update closed pull requests.',
208 208 category='error')
209 209
210 210 def test_update_invalid_source_reference(self, pr_util, csrf_token):
211 211 from rhodecode.lib.vcs.backends.base import UpdateFailureReason
212 212
213 213 pull_request = pr_util.create_pull_request()
214 214 pull_request.source_ref = 'branch:invalid-branch:invalid-commit-id'
215 215 Session().add(pull_request)
216 216 Session().commit()
217 217
218 218 pull_request_id = pull_request.pull_request_id
219 219
220 220 response = self.app.post(
221 221 url(controller='pullrequests', action='update',
222 222 repo_name=pull_request.target_repo.repo_name,
223 223 pull_request_id=str(pull_request_id)),
224 224 params={'update_commits': 'true', '_method': 'put',
225 225 'csrf_token': csrf_token})
226 226
227 227 expected_msg = PullRequestModel.UPDATE_STATUS_MESSAGES[
228 228 UpdateFailureReason.MISSING_SOURCE_REF]
229 229 assert_session_flash(response, expected_msg, category='error')
230 230
231 231 def test_missing_target_reference(self, pr_util, csrf_token):
232 232 from rhodecode.lib.vcs.backends.base import MergeFailureReason
233 233 pull_request = pr_util.create_pull_request(
234 234 approved=True, mergeable=True)
235 235 pull_request.target_ref = 'branch:invalid-branch:invalid-commit-id'
236 236 Session().add(pull_request)
237 237 Session().commit()
238 238
239 239 pull_request_id = pull_request.pull_request_id
240 240 pull_request_url = url(
241 241 controller='pullrequests', action='show',
242 242 repo_name=pull_request.target_repo.repo_name,
243 243 pull_request_id=str(pull_request_id))
244 244
245 245 response = self.app.get(pull_request_url)
246 246
247 247 assertr = AssertResponse(response)
248 248 expected_msg = PullRequestModel.MERGE_STATUS_MESSAGES[
249 249 MergeFailureReason.MISSING_TARGET_REF]
250 250 assertr.element_contains(
251 251 'span[data-role="merge-message"]', str(expected_msg))
252 252
253 253 def test_comment_and_close_pull_request(self, pr_util, csrf_token):
254 254 pull_request = pr_util.create_pull_request(approved=True)
255 255 pull_request_id = pull_request.pull_request_id
256 256 author = pull_request.user_id
257 257 repo = pull_request.target_repo.repo_id
258 258
259 259 self.app.post(
260 260 url(controller='pullrequests',
261 261 action='comment',
262 262 repo_name=pull_request.target_repo.scm_instance().name,
263 263 pull_request_id=str(pull_request_id)),
264 264 params={
265 265 'changeset_status': ChangesetStatus.STATUS_APPROVED,
266 266 'close_pull_request': '1',
267 267 'text': 'Closing a PR',
268 268 'csrf_token': csrf_token},
269 269 status=302)
270 270
271 271 action = 'user_closed_pull_request:%d' % pull_request_id
272 272 journal = UserLog.query()\
273 273 .filter(UserLog.user_id == author)\
274 274 .filter(UserLog.repository_id == repo)\
275 275 .filter(UserLog.action == action)\
276 276 .all()
277 277 assert len(journal) == 1
278 278
279 279 pull_request = PullRequest.get(pull_request_id)
280 280 assert pull_request.is_closed()
281 281
282 282 # check only the latest status, not the review status
283 283 status = ChangesetStatusModel().get_status(
284 284 pull_request.source_repo, pull_request=pull_request)
285 285 assert status == ChangesetStatus.STATUS_APPROVED
286 286
287 287 def test_reject_and_close_pull_request(self, pr_util, csrf_token):
288 288 pull_request = pr_util.create_pull_request()
289 289 pull_request_id = pull_request.pull_request_id
290 290 response = self.app.post(
291 291 url(controller='pullrequests',
292 292 action='update',
293 293 repo_name=pull_request.target_repo.scm_instance().name,
294 294 pull_request_id=str(pull_request.pull_request_id)),
295 295 params={'close_pull_request': 'true', '_method': 'put',
296 296 'csrf_token': csrf_token})
297 297
298 298 pull_request = PullRequest.get(pull_request_id)
299 299
300 300 assert response.json is True
301 301 assert pull_request.is_closed()
302 302
303 303 # check only the latest status, not the review status
304 304 status = ChangesetStatusModel().get_status(
305 305 pull_request.source_repo, pull_request=pull_request)
306 306 assert status == ChangesetStatus.STATUS_REJECTED
307 307
308 308 def test_comment_force_close_pull_request(self, pr_util, csrf_token):
309 309 pull_request = pr_util.create_pull_request()
310 310 pull_request_id = pull_request.pull_request_id
311 311 reviewers_data = [(1, ['reason']), (2, ['reason2'])]
312 312 PullRequestModel().update_reviewers(pull_request_id, reviewers_data)
313 313 author = pull_request.user_id
314 314 repo = pull_request.target_repo.repo_id
315 315 self.app.post(
316 316 url(controller='pullrequests',
317 317 action='comment',
318 318 repo_name=pull_request.target_repo.scm_instance().name,
319 319 pull_request_id=str(pull_request_id)),
320 320 params={
321 321 'changeset_status': 'rejected',
322 322 'close_pull_request': '1',
323 323 'csrf_token': csrf_token},
324 324 status=302)
325 325
326 326 pull_request = PullRequest.get(pull_request_id)
327 327
328 328 action = 'user_closed_pull_request:%d' % pull_request_id
329 329 journal = UserLog.query().filter(
330 330 UserLog.user_id == author,
331 331 UserLog.repository_id == repo,
332 332 UserLog.action == action).all()
333 333 assert len(journal) == 1
334 334
335 335 # check only the latest status, not the review status
336 336 status = ChangesetStatusModel().get_status(
337 337 pull_request.source_repo, pull_request=pull_request)
338 338 assert status == ChangesetStatus.STATUS_REJECTED
339 339
340 340 def test_create_pull_request(self, backend, csrf_token):
341 341 commits = [
342 342 {'message': 'ancestor'},
343 343 {'message': 'change'},
344 344 {'message': 'change2'},
345 345 ]
346 346 commit_ids = backend.create_master_repo(commits)
347 347 target = backend.create_repo(heads=['ancestor'])
348 348 source = backend.create_repo(heads=['change2'])
349 349
350 350 response = self.app.post(
351 351 url(
352 352 controller='pullrequests',
353 353 action='create',
354 354 repo_name=source.repo_name
355 355 ),
356 356 [
357 357 ('source_repo', source.repo_name),
358 358 ('source_ref', 'branch:default:' + commit_ids['change2']),
359 359 ('target_repo', target.repo_name),
360 360 ('target_ref', 'branch:default:' + commit_ids['ancestor']),
361 361 ('pullrequest_desc', 'Description'),
362 362 ('pullrequest_title', 'Title'),
363 363 ('__start__', 'review_members:sequence'),
364 364 ('__start__', 'reviewer:mapping'),
365 365 ('user_id', '1'),
366 366 ('__start__', 'reasons:sequence'),
367 367 ('reason', 'Some reason'),
368 368 ('__end__', 'reasons:sequence'),
369 369 ('__end__', 'reviewer:mapping'),
370 370 ('__end__', 'review_members:sequence'),
371 371 ('__start__', 'revisions:sequence'),
372 372 ('revisions', commit_ids['change']),
373 373 ('revisions', commit_ids['change2']),
374 374 ('__end__', 'revisions:sequence'),
375 375 ('user', ''),
376 376 ('csrf_token', csrf_token),
377 377 ],
378 378 status=302)
379 379
380 380 location = response.headers['Location']
381 381 pull_request_id = int(location.rsplit('/', 1)[1])
382 382 pull_request = PullRequest.get(pull_request_id)
383 383
384 384 # check that we have now both revisions
385 385 assert pull_request.revisions == [commit_ids['change2'], commit_ids['change']]
386 386 assert pull_request.source_ref == 'branch:default:' + commit_ids['change2']
387 387 expected_target_ref = 'branch:default:' + commit_ids['ancestor']
388 388 assert pull_request.target_ref == expected_target_ref
389 389
390 390 def test_reviewer_notifications(self, backend, csrf_token):
391 391 # We have to use the app.post for this test so it will create the
392 392 # notifications properly with the new PR
393 393 commits = [
394 394 {'message': 'ancestor',
395 395 'added': [FileNode('file_A', content='content_of_ancestor')]},
396 396 {'message': 'change',
397 397 'added': [FileNode('file_a', content='content_of_change')]},
398 398 {'message': 'change-child'},
399 399 {'message': 'ancestor-child', 'parents': ['ancestor'],
400 400 'added': [
401 401 FileNode('file_B', content='content_of_ancestor_child')]},
402 402 {'message': 'ancestor-child-2'},
403 403 ]
404 404 commit_ids = backend.create_master_repo(commits)
405 405 target = backend.create_repo(heads=['ancestor-child'])
406 406 source = backend.create_repo(heads=['change'])
407 407
408 408 response = self.app.post(
409 409 url(
410 410 controller='pullrequests',
411 411 action='create',
412 412 repo_name=source.repo_name
413 413 ),
414 414 [
415 415 ('source_repo', source.repo_name),
416 416 ('source_ref', 'branch:default:' + commit_ids['change']),
417 417 ('target_repo', target.repo_name),
418 418 ('target_ref', 'branch:default:' + commit_ids['ancestor-child']),
419 419 ('pullrequest_desc', 'Description'),
420 420 ('pullrequest_title', 'Title'),
421 421 ('__start__', 'review_members:sequence'),
422 422 ('__start__', 'reviewer:mapping'),
423 423 ('user_id', '2'),
424 424 ('__start__', 'reasons:sequence'),
425 425 ('reason', 'Some reason'),
426 426 ('__end__', 'reasons:sequence'),
427 427 ('__end__', 'reviewer:mapping'),
428 428 ('__end__', 'review_members:sequence'),
429 429 ('__start__', 'revisions:sequence'),
430 430 ('revisions', commit_ids['change']),
431 431 ('__end__', 'revisions:sequence'),
432 432 ('user', ''),
433 433 ('csrf_token', csrf_token),
434 434 ],
435 435 status=302)
436 436
437 437 location = response.headers['Location']
438 438 pull_request_id = int(location.rsplit('/', 1)[1])
439 439 pull_request = PullRequest.get(pull_request_id)
440 440
441 441 # Check that a notification was made
442 442 notifications = Notification.query()\
443 443 .filter(Notification.created_by == pull_request.author.user_id,
444 444 Notification.type_ == Notification.TYPE_PULL_REQUEST,
445 445 Notification.subject.contains("wants you to review "
446 446 "pull request #%d"
447 447 % pull_request_id))
448 448 assert len(notifications.all()) == 1
449 449
450 450 # Change reviewers and check that a notification was made
451 451 PullRequestModel().update_reviewers(
452 452 pull_request.pull_request_id, [(1, [])])
453 453 assert len(notifications.all()) == 2
454 454
455 455 def test_create_pull_request_stores_ancestor_commit_id(self, backend,
456 456 csrf_token):
457 457 commits = [
458 458 {'message': 'ancestor',
459 459 'added': [FileNode('file_A', content='content_of_ancestor')]},
460 460 {'message': 'change',
461 461 'added': [FileNode('file_a', content='content_of_change')]},
462 462 {'message': 'change-child'},
463 463 {'message': 'ancestor-child', 'parents': ['ancestor'],
464 464 'added': [
465 465 FileNode('file_B', content='content_of_ancestor_child')]},
466 466 {'message': 'ancestor-child-2'},
467 467 ]
468 468 commit_ids = backend.create_master_repo(commits)
469 469 target = backend.create_repo(heads=['ancestor-child'])
470 470 source = backend.create_repo(heads=['change'])
471 471
472 472 response = self.app.post(
473 473 url(
474 474 controller='pullrequests',
475 475 action='create',
476 476 repo_name=source.repo_name
477 477 ),
478 478 [
479 479 ('source_repo', source.repo_name),
480 480 ('source_ref', 'branch:default:' + commit_ids['change']),
481 481 ('target_repo', target.repo_name),
482 482 ('target_ref', 'branch:default:' + commit_ids['ancestor-child']),
483 483 ('pullrequest_desc', 'Description'),
484 484 ('pullrequest_title', 'Title'),
485 485 ('__start__', 'review_members:sequence'),
486 486 ('__start__', 'reviewer:mapping'),
487 487 ('user_id', '1'),
488 488 ('__start__', 'reasons:sequence'),
489 489 ('reason', 'Some reason'),
490 490 ('__end__', 'reasons:sequence'),
491 491 ('__end__', 'reviewer:mapping'),
492 492 ('__end__', 'review_members:sequence'),
493 493 ('__start__', 'revisions:sequence'),
494 494 ('revisions', commit_ids['change']),
495 495 ('__end__', 'revisions:sequence'),
496 496 ('user', ''),
497 497 ('csrf_token', csrf_token),
498 498 ],
499 499 status=302)
500 500
501 501 location = response.headers['Location']
502 502 pull_request_id = int(location.rsplit('/', 1)[1])
503 503 pull_request = PullRequest.get(pull_request_id)
504 504
505 505 # target_ref has to point to the ancestor's commit_id in order to
506 506 # show the correct diff
507 507 expected_target_ref = 'branch:default:' + commit_ids['ancestor']
508 508 assert pull_request.target_ref == expected_target_ref
509 509
510 510 # Check generated diff contents
511 511 response = response.follow()
512 512 assert 'content_of_ancestor' not in response.body
513 513 assert 'content_of_ancestor-child' not in response.body
514 514 assert 'content_of_change' in response.body
515 515
516 516 def test_merge_pull_request_enabled(self, pr_util, csrf_token):
517 517 # Clear any previous calls to rcextensions
518 518 rhodecode.EXTENSIONS.calls.clear()
519 519
520 520 pull_request = pr_util.create_pull_request(
521 521 approved=True, mergeable=True)
522 522 pull_request_id = pull_request.pull_request_id
523 523 repo_name = pull_request.target_repo.scm_instance().name,
524 524
525 525 response = self.app.post(
526 526 url(controller='pullrequests',
527 527 action='merge',
528 528 repo_name=str(repo_name[0]),
529 529 pull_request_id=str(pull_request_id)),
530 530 params={'csrf_token': csrf_token}).follow()
531 531
532 532 pull_request = PullRequest.get(pull_request_id)
533 533
534 534 assert response.status_int == 200
535 535 assert pull_request.is_closed()
536 536 assert_pull_request_status(
537 537 pull_request, ChangesetStatus.STATUS_APPROVED)
538 538
539 539 # Check the relevant log entries were added
540 user_logs = UserLog.query().order_by('-user_log_id').limit(4)
540 user_logs = UserLog.query() \
541 .filter(UserLog.version == UserLog.VERSION_1) \
542 .order_by('-user_log_id').limit(4)
541 543 actions = [log.action for log in user_logs]
542 544 pr_commit_ids = PullRequestModel()._get_commit_ids(pull_request)
543 545 expected_actions = [
544 546 u'user_closed_pull_request:%d' % pull_request_id,
545 547 u'user_merged_pull_request:%d' % pull_request_id,
546 548 # The action below reflect that the post push actions were executed
547 549 u'user_commented_pull_request:%d' % pull_request_id,
548 550 u'push:%s' % ','.join(pr_commit_ids),
549 551 ]
550 552 assert actions == expected_actions
551 553
552 554 # Check post_push rcextension was really executed
553 555 push_calls = rhodecode.EXTENSIONS.calls['post_push']
554 556 assert len(push_calls) == 1
555 557 unused_last_call_args, last_call_kwargs = push_calls[0]
556 558 assert last_call_kwargs['action'] == 'push'
557 559 assert last_call_kwargs['pushed_revs'] == pr_commit_ids
558 560
559 561 def test_merge_pull_request_disabled(self, pr_util, csrf_token):
560 562 pull_request = pr_util.create_pull_request(mergeable=False)
561 563 pull_request_id = pull_request.pull_request_id
562 564 pull_request = PullRequest.get(pull_request_id)
563 565
564 566 response = self.app.post(
565 567 url(controller='pullrequests',
566 568 action='merge',
567 569 repo_name=pull_request.target_repo.scm_instance().name,
568 570 pull_request_id=str(pull_request.pull_request_id)),
569 571 params={'csrf_token': csrf_token}).follow()
570 572
571 573 assert response.status_int == 200
572 574 response.mustcontain(
573 575 'Merge is not currently possible because of below failed checks.')
574 576 response.mustcontain('Server-side pull request merging is disabled.')
575 577
576 578 @pytest.mark.skip_backends('svn')
577 579 def test_merge_pull_request_not_approved(self, pr_util, csrf_token):
578 580 pull_request = pr_util.create_pull_request(mergeable=True)
579 581 pull_request_id = pull_request.pull_request_id
580 582 repo_name = pull_request.target_repo.scm_instance().name,
581 583
582 584 response = self.app.post(
583 585 url(controller='pullrequests',
584 586 action='merge',
585 587 repo_name=str(repo_name[0]),
586 588 pull_request_id=str(pull_request_id)),
587 589 params={'csrf_token': csrf_token}).follow()
588 590
589 591 assert response.status_int == 200
590 592
591 593 response.mustcontain(
592 594 'Merge is not currently possible because of below failed checks.')
593 595 response.mustcontain('Pull request reviewer approval is pending.')
594 596
595 597 def test_update_source_revision(self, backend, csrf_token):
596 598 commits = [
597 599 {'message': 'ancestor'},
598 600 {'message': 'change'},
599 601 {'message': 'change-2'},
600 602 ]
601 603 commit_ids = backend.create_master_repo(commits)
602 604 target = backend.create_repo(heads=['ancestor'])
603 605 source = backend.create_repo(heads=['change'])
604 606
605 607 # create pr from a in source to A in target
606 608 pull_request = PullRequest()
607 609 pull_request.source_repo = source
608 610 # TODO: johbo: Make sure that we write the source ref this way!
609 611 pull_request.source_ref = 'branch:{branch}:{commit_id}'.format(
610 612 branch=backend.default_branch_name, commit_id=commit_ids['change'])
611 613 pull_request.target_repo = target
612 614
613 615 pull_request.target_ref = 'branch:{branch}:{commit_id}'.format(
614 616 branch=backend.default_branch_name,
615 617 commit_id=commit_ids['ancestor'])
616 618 pull_request.revisions = [commit_ids['change']]
617 619 pull_request.title = u"Test"
618 620 pull_request.description = u"Description"
619 621 pull_request.author = UserModel().get_by_username(
620 622 TEST_USER_ADMIN_LOGIN)
621 623 Session().add(pull_request)
622 624 Session().commit()
623 625 pull_request_id = pull_request.pull_request_id
624 626
625 627 # source has ancestor - change - change-2
626 628 backend.pull_heads(source, heads=['change-2'])
627 629
628 630 # update PR
629 631 self.app.post(
630 632 url(controller='pullrequests', action='update',
631 633 repo_name=target.repo_name,
632 634 pull_request_id=str(pull_request_id)),
633 635 params={'update_commits': 'true', '_method': 'put',
634 636 'csrf_token': csrf_token})
635 637
636 638 # check that we have now both revisions
637 639 pull_request = PullRequest.get(pull_request_id)
638 640 assert pull_request.revisions == [
639 641 commit_ids['change-2'], commit_ids['change']]
640 642
641 643 # TODO: johbo: this should be a test on its own
642 644 response = self.app.get(url(
643 645 controller='pullrequests', action='index',
644 646 repo_name=target.repo_name))
645 647 assert response.status_int == 200
646 648 assert 'Pull request updated to' in response.body
647 649 assert 'with 1 added, 0 removed commits.' in response.body
648 650
649 651 def test_update_target_revision(self, backend, csrf_token):
650 652 commits = [
651 653 {'message': 'ancestor'},
652 654 {'message': 'change'},
653 655 {'message': 'ancestor-new', 'parents': ['ancestor']},
654 656 {'message': 'change-rebased'},
655 657 ]
656 658 commit_ids = backend.create_master_repo(commits)
657 659 target = backend.create_repo(heads=['ancestor'])
658 660 source = backend.create_repo(heads=['change'])
659 661
660 662 # create pr from a in source to A in target
661 663 pull_request = PullRequest()
662 664 pull_request.source_repo = source
663 665 # TODO: johbo: Make sure that we write the source ref this way!
664 666 pull_request.source_ref = 'branch:{branch}:{commit_id}'.format(
665 667 branch=backend.default_branch_name, commit_id=commit_ids['change'])
666 668 pull_request.target_repo = target
667 669 # TODO: johbo: Target ref should be branch based, since tip can jump
668 670 # from branch to branch
669 671 pull_request.target_ref = 'branch:{branch}:{commit_id}'.format(
670 672 branch=backend.default_branch_name,
671 673 commit_id=commit_ids['ancestor'])
672 674 pull_request.revisions = [commit_ids['change']]
673 675 pull_request.title = u"Test"
674 676 pull_request.description = u"Description"
675 677 pull_request.author = UserModel().get_by_username(
676 678 TEST_USER_ADMIN_LOGIN)
677 679 Session().add(pull_request)
678 680 Session().commit()
679 681 pull_request_id = pull_request.pull_request_id
680 682
681 683 # target has ancestor - ancestor-new
682 684 # source has ancestor - ancestor-new - change-rebased
683 685 backend.pull_heads(target, heads=['ancestor-new'])
684 686 backend.pull_heads(source, heads=['change-rebased'])
685 687
686 688 # update PR
687 689 self.app.post(
688 690 url(controller='pullrequests', action='update',
689 691 repo_name=target.repo_name,
690 692 pull_request_id=str(pull_request_id)),
691 693 params={'update_commits': 'true', '_method': 'put',
692 694 'csrf_token': csrf_token},
693 695 status=200)
694 696
695 697 # check that we have now both revisions
696 698 pull_request = PullRequest.get(pull_request_id)
697 699 assert pull_request.revisions == [commit_ids['change-rebased']]
698 700 assert pull_request.target_ref == 'branch:{branch}:{commit_id}'.format(
699 701 branch=backend.default_branch_name,
700 702 commit_id=commit_ids['ancestor-new'])
701 703
702 704 # TODO: johbo: This should be a test on its own
703 705 response = self.app.get(url(
704 706 controller='pullrequests', action='index',
705 707 repo_name=target.repo_name))
706 708 assert response.status_int == 200
707 709 assert 'Pull request updated to' in response.body
708 710 assert 'with 1 added, 1 removed commits.' in response.body
709 711
710 712 def test_update_of_ancestor_reference(self, backend, csrf_token):
711 713 commits = [
712 714 {'message': 'ancestor'},
713 715 {'message': 'change'},
714 716 {'message': 'change-2'},
715 717 {'message': 'ancestor-new', 'parents': ['ancestor']},
716 718 {'message': 'change-rebased'},
717 719 ]
718 720 commit_ids = backend.create_master_repo(commits)
719 721 target = backend.create_repo(heads=['ancestor'])
720 722 source = backend.create_repo(heads=['change'])
721 723
722 724 # create pr from a in source to A in target
723 725 pull_request = PullRequest()
724 726 pull_request.source_repo = source
725 727 # TODO: johbo: Make sure that we write the source ref this way!
726 728 pull_request.source_ref = 'branch:{branch}:{commit_id}'.format(
727 729 branch=backend.default_branch_name,
728 730 commit_id=commit_ids['change'])
729 731 pull_request.target_repo = target
730 732 # TODO: johbo: Target ref should be branch based, since tip can jump
731 733 # from branch to branch
732 734 pull_request.target_ref = 'branch:{branch}:{commit_id}'.format(
733 735 branch=backend.default_branch_name,
734 736 commit_id=commit_ids['ancestor'])
735 737 pull_request.revisions = [commit_ids['change']]
736 738 pull_request.title = u"Test"
737 739 pull_request.description = u"Description"
738 740 pull_request.author = UserModel().get_by_username(
739 741 TEST_USER_ADMIN_LOGIN)
740 742 Session().add(pull_request)
741 743 Session().commit()
742 744 pull_request_id = pull_request.pull_request_id
743 745
744 746 # target has ancestor - ancestor-new
745 747 # source has ancestor - ancestor-new - change-rebased
746 748 backend.pull_heads(target, heads=['ancestor-new'])
747 749 backend.pull_heads(source, heads=['change-rebased'])
748 750
749 751 # update PR
750 752 self.app.post(
751 753 url(controller='pullrequests', action='update',
752 754 repo_name=target.repo_name,
753 755 pull_request_id=str(pull_request_id)),
754 756 params={'update_commits': 'true', '_method': 'put',
755 757 'csrf_token': csrf_token},
756 758 status=200)
757 759
758 760 # Expect the target reference to be updated correctly
759 761 pull_request = PullRequest.get(pull_request_id)
760 762 assert pull_request.revisions == [commit_ids['change-rebased']]
761 763 expected_target_ref = 'branch:{branch}:{commit_id}'.format(
762 764 branch=backend.default_branch_name,
763 765 commit_id=commit_ids['ancestor-new'])
764 766 assert pull_request.target_ref == expected_target_ref
765 767
766 768 def test_remove_pull_request_branch(self, backend_git, csrf_token):
767 769 branch_name = 'development'
768 770 commits = [
769 771 {'message': 'initial-commit'},
770 772 {'message': 'old-feature'},
771 773 {'message': 'new-feature', 'branch': branch_name},
772 774 ]
773 775 repo = backend_git.create_repo(commits)
774 776 commit_ids = backend_git.commit_ids
775 777
776 778 pull_request = PullRequest()
777 779 pull_request.source_repo = repo
778 780 pull_request.target_repo = repo
779 781 pull_request.source_ref = 'branch:{branch}:{commit_id}'.format(
780 782 branch=branch_name, commit_id=commit_ids['new-feature'])
781 783 pull_request.target_ref = 'branch:{branch}:{commit_id}'.format(
782 784 branch=backend_git.default_branch_name,
783 785 commit_id=commit_ids['old-feature'])
784 786 pull_request.revisions = [commit_ids['new-feature']]
785 787 pull_request.title = u"Test"
786 788 pull_request.description = u"Description"
787 789 pull_request.author = UserModel().get_by_username(
788 790 TEST_USER_ADMIN_LOGIN)
789 791 Session().add(pull_request)
790 792 Session().commit()
791 793
792 794 vcs = repo.scm_instance()
793 795 vcs.remove_ref('refs/heads/{}'.format(branch_name))
794 796
795 797 response = self.app.get(url(
796 798 controller='pullrequests', action='show',
797 799 repo_name=repo.repo_name,
798 800 pull_request_id=str(pull_request.pull_request_id)))
799 801
800 802 assert response.status_int == 200
801 803 assert_response = AssertResponse(response)
802 804 assert_response.element_contains(
803 805 '#changeset_compare_view_content .alert strong',
804 806 'Missing commits')
805 807 assert_response.element_contains(
806 808 '#changeset_compare_view_content .alert',
807 809 'This pull request cannot be displayed, because one or more'
808 810 ' commits no longer exist in the source repository.')
809 811
810 812 def test_strip_commits_from_pull_request(
811 813 self, backend, pr_util, csrf_token):
812 814 commits = [
813 815 {'message': 'initial-commit'},
814 816 {'message': 'old-feature'},
815 817 {'message': 'new-feature', 'parents': ['initial-commit']},
816 818 ]
817 819 pull_request = pr_util.create_pull_request(
818 820 commits, target_head='initial-commit', source_head='new-feature',
819 821 revisions=['new-feature'])
820 822
821 823 vcs = pr_util.source_repository.scm_instance()
822 824 if backend.alias == 'git':
823 825 vcs.strip(pr_util.commit_ids['new-feature'], branch_name='master')
824 826 else:
825 827 vcs.strip(pr_util.commit_ids['new-feature'])
826 828
827 829 response = self.app.get(url(
828 830 controller='pullrequests', action='show',
829 831 repo_name=pr_util.target_repository.repo_name,
830 832 pull_request_id=str(pull_request.pull_request_id)))
831 833
832 834 assert response.status_int == 200
833 835 assert_response = AssertResponse(response)
834 836 assert_response.element_contains(
835 837 '#changeset_compare_view_content .alert strong',
836 838 'Missing commits')
837 839 assert_response.element_contains(
838 840 '#changeset_compare_view_content .alert',
839 841 'This pull request cannot be displayed, because one or more'
840 842 ' commits no longer exist in the source repository.')
841 843 assert_response.element_contains(
842 844 '#update_commits',
843 845 'Update commits')
844 846
845 847 def test_strip_commits_and_update(
846 848 self, backend, pr_util, csrf_token):
847 849 commits = [
848 850 {'message': 'initial-commit'},
849 851 {'message': 'old-feature'},
850 852 {'message': 'new-feature', 'parents': ['old-feature']},
851 853 ]
852 854 pull_request = pr_util.create_pull_request(
853 855 commits, target_head='old-feature', source_head='new-feature',
854 856 revisions=['new-feature'], mergeable=True)
855 857
856 858 vcs = pr_util.source_repository.scm_instance()
857 859 if backend.alias == 'git':
858 860 vcs.strip(pr_util.commit_ids['new-feature'], branch_name='master')
859 861 else:
860 862 vcs.strip(pr_util.commit_ids['new-feature'])
861 863
862 864 response = self.app.post(
863 865 url(controller='pullrequests', action='update',
864 866 repo_name=pull_request.target_repo.repo_name,
865 867 pull_request_id=str(pull_request.pull_request_id)),
866 868 params={'update_commits': 'true', '_method': 'put',
867 869 'csrf_token': csrf_token})
868 870
869 871 assert response.status_int == 200
870 872 assert response.body == 'true'
871 873
872 874 # Make sure that after update, it won't raise 500 errors
873 875 response = self.app.get(url(
874 876 controller='pullrequests', action='show',
875 877 repo_name=pr_util.target_repository.repo_name,
876 878 pull_request_id=str(pull_request.pull_request_id)))
877 879
878 880 assert response.status_int == 200
879 881 assert_response = AssertResponse(response)
880 882 assert_response.element_contains(
881 883 '#changeset_compare_view_content .alert strong',
882 884 'Missing commits')
883 885
884 886 def test_branch_is_a_link(self, pr_util):
885 887 pull_request = pr_util.create_pull_request()
886 888 pull_request.source_ref = 'branch:origin:1234567890abcdef'
887 889 pull_request.target_ref = 'branch:target:abcdef1234567890'
888 890 Session().add(pull_request)
889 891 Session().commit()
890 892
891 893 response = self.app.get(url(
892 894 controller='pullrequests', action='show',
893 895 repo_name=pull_request.target_repo.scm_instance().name,
894 896 pull_request_id=str(pull_request.pull_request_id)))
895 897 assert response.status_int == 200
896 898 assert_response = AssertResponse(response)
897 899
898 900 origin = assert_response.get_element('.pr-origininfo .tag')
899 901 origin_children = origin.getchildren()
900 902 assert len(origin_children) == 1
901 903 target = assert_response.get_element('.pr-targetinfo .tag')
902 904 target_children = target.getchildren()
903 905 assert len(target_children) == 1
904 906
905 907 expected_origin_link = url(
906 908 'changelog_home',
907 909 repo_name=pull_request.source_repo.scm_instance().name,
908 910 branch='origin')
909 911 expected_target_link = url(
910 912 'changelog_home',
911 913 repo_name=pull_request.target_repo.scm_instance().name,
912 914 branch='target')
913 915 assert origin_children[0].attrib['href'] == expected_origin_link
914 916 assert origin_children[0].text == 'branch: origin'
915 917 assert target_children[0].attrib['href'] == expected_target_link
916 918 assert target_children[0].text == 'branch: target'
917 919
918 920 def test_bookmark_is_not_a_link(self, pr_util):
919 921 pull_request = pr_util.create_pull_request()
920 922 pull_request.source_ref = 'bookmark:origin:1234567890abcdef'
921 923 pull_request.target_ref = 'bookmark:target:abcdef1234567890'
922 924 Session().add(pull_request)
923 925 Session().commit()
924 926
925 927 response = self.app.get(url(
926 928 controller='pullrequests', action='show',
927 929 repo_name=pull_request.target_repo.scm_instance().name,
928 930 pull_request_id=str(pull_request.pull_request_id)))
929 931 assert response.status_int == 200
930 932 assert_response = AssertResponse(response)
931 933
932 934 origin = assert_response.get_element('.pr-origininfo .tag')
933 935 assert origin.text.strip() == 'bookmark: origin'
934 936 assert origin.getchildren() == []
935 937
936 938 target = assert_response.get_element('.pr-targetinfo .tag')
937 939 assert target.text.strip() == 'bookmark: target'
938 940 assert target.getchildren() == []
939 941
940 942 def test_tag_is_not_a_link(self, pr_util):
941 943 pull_request = pr_util.create_pull_request()
942 944 pull_request.source_ref = 'tag:origin:1234567890abcdef'
943 945 pull_request.target_ref = 'tag:target:abcdef1234567890'
944 946 Session().add(pull_request)
945 947 Session().commit()
946 948
947 949 response = self.app.get(url(
948 950 controller='pullrequests', action='show',
949 951 repo_name=pull_request.target_repo.scm_instance().name,
950 952 pull_request_id=str(pull_request.pull_request_id)))
951 953 assert response.status_int == 200
952 954 assert_response = AssertResponse(response)
953 955
954 956 origin = assert_response.get_element('.pr-origininfo .tag')
955 957 assert origin.text.strip() == 'tag: origin'
956 958 assert origin.getchildren() == []
957 959
958 960 target = assert_response.get_element('.pr-targetinfo .tag')
959 961 assert target.text.strip() == 'tag: target'
960 962 assert target.getchildren() == []
961 963
962 964 def test_description_is_escaped_on_index_page(self, backend, pr_util):
963 965 xss_description = "<script>alert('Hi!')</script>"
964 966 pull_request = pr_util.create_pull_request(description=xss_description)
965 967 response = self.app.get(url(
966 968 controller='pullrequests', action='show_all',
967 969 repo_name=pull_request.target_repo.repo_name))
968 970 response.mustcontain(
969 971 "&lt;script&gt;alert(&#39;Hi!&#39;)&lt;/script&gt;")
970 972
971 973 @pytest.mark.parametrize('mergeable', [True, False])
972 974 def test_shadow_repository_link(
973 975 self, mergeable, pr_util, http_host_stub):
974 976 """
975 977 Check that the pull request summary page displays a link to the shadow
976 978 repository if the pull request is mergeable. If it is not mergeable
977 979 the link should not be displayed.
978 980 """
979 981 pull_request = pr_util.create_pull_request(
980 982 mergeable=mergeable, enable_notifications=False)
981 983 target_repo = pull_request.target_repo.scm_instance()
982 984 pr_id = pull_request.pull_request_id
983 985 shadow_url = '{host}/{repo}/pull-request/{pr_id}/repository'.format(
984 986 host=http_host_stub, repo=target_repo.name, pr_id=pr_id)
985 987
986 988 response = self.app.get(url(
987 989 controller='pullrequests', action='show',
988 990 repo_name=target_repo.name,
989 991 pull_request_id=str(pr_id)))
990 992
991 993 assertr = AssertResponse(response)
992 994 if mergeable:
993 995 assertr.element_value_contains(
994 996 'div.pr-mergeinfo input', shadow_url)
995 997 assertr.element_value_contains(
996 998 'div.pr-mergeinfo input', 'pr-merge')
997 999 else:
998 1000 assertr.no_element_exists('div.pr-mergeinfo')
999 1001
1000 1002
1001 1003 @pytest.mark.usefixtures('app')
1002 1004 @pytest.mark.backends("git", "hg")
1003 1005 class TestPullrequestsControllerDelete(object):
1004 1006 def test_pull_request_delete_button_permissions_admin(
1005 1007 self, autologin_user, user_admin, pr_util):
1006 1008 pull_request = pr_util.create_pull_request(
1007 1009 author=user_admin.username, enable_notifications=False)
1008 1010
1009 1011 response = self.app.get(url(
1010 1012 controller='pullrequests', action='show',
1011 1013 repo_name=pull_request.target_repo.scm_instance().name,
1012 1014 pull_request_id=str(pull_request.pull_request_id)))
1013 1015
1014 1016 response.mustcontain('id="delete_pullrequest"')
1015 1017 response.mustcontain('Confirm to delete this pull request')
1016 1018
1017 1019 def test_pull_request_delete_button_permissions_owner(
1018 1020 self, autologin_regular_user, user_regular, pr_util):
1019 1021 pull_request = pr_util.create_pull_request(
1020 1022 author=user_regular.username, enable_notifications=False)
1021 1023
1022 1024 response = self.app.get(url(
1023 1025 controller='pullrequests', action='show',
1024 1026 repo_name=pull_request.target_repo.scm_instance().name,
1025 1027 pull_request_id=str(pull_request.pull_request_id)))
1026 1028
1027 1029 response.mustcontain('id="delete_pullrequest"')
1028 1030 response.mustcontain('Confirm to delete this pull request')
1029 1031
1030 1032 def test_pull_request_delete_button_permissions_forbidden(
1031 1033 self, autologin_regular_user, user_regular, user_admin, pr_util):
1032 1034 pull_request = pr_util.create_pull_request(
1033 1035 author=user_admin.username, enable_notifications=False)
1034 1036
1035 1037 response = self.app.get(url(
1036 1038 controller='pullrequests', action='show',
1037 1039 repo_name=pull_request.target_repo.scm_instance().name,
1038 1040 pull_request_id=str(pull_request.pull_request_id)))
1039 1041 response.mustcontain(no=['id="delete_pullrequest"'])
1040 1042 response.mustcontain(no=['Confirm to delete this pull request'])
1041 1043
1042 1044 def test_pull_request_delete_button_permissions_can_update_cannot_delete(
1043 1045 self, autologin_regular_user, user_regular, user_admin, pr_util,
1044 1046 user_util):
1045 1047
1046 1048 pull_request = pr_util.create_pull_request(
1047 1049 author=user_admin.username, enable_notifications=False)
1048 1050
1049 1051 user_util.grant_user_permission_to_repo(
1050 1052 pull_request.target_repo, user_regular,
1051 1053 'repository.write')
1052 1054
1053 1055 response = self.app.get(url(
1054 1056 controller='pullrequests', action='show',
1055 1057 repo_name=pull_request.target_repo.scm_instance().name,
1056 1058 pull_request_id=str(pull_request.pull_request_id)))
1057 1059
1058 1060 response.mustcontain('id="open_edit_pullrequest"')
1059 1061 response.mustcontain('id="delete_pullrequest"')
1060 1062 response.mustcontain(no=['Confirm to delete this pull request'])
1061 1063
1062 1064
1063 1065 def assert_pull_request_status(pull_request, expected_status):
1064 1066 status = ChangesetStatusModel().calculated_review_status(
1065 1067 pull_request=pull_request)
1066 1068 assert status == expected_status
1067 1069
1068 1070
1069 1071 @pytest.mark.parametrize('action', ['show_all', 'index', 'create'])
1070 1072 @pytest.mark.usefixtures("autologin_user")
1071 1073 def test_redirects_to_repo_summary_for_svn_repositories(
1072 1074 backend_svn, app, action):
1073 1075 denied_actions = ['show_all', 'index', 'create']
1074 1076 for action in denied_actions:
1075 1077 response = app.get(url(
1076 1078 controller='pullrequests', action=action,
1077 1079 repo_name=backend_svn.repo_name))
1078 1080 assert response.status_int == 302
1079 1081
1080 1082 # Not allowed, redirect to the summary
1081 1083 redirected = response.follow()
1082 1084 summary_url = url('summary_home', repo_name=backend_svn.repo_name)
1083 1085
1084 1086 # URL adds leading slash and path doesn't have it
1085 1087 assert redirected.req.path == summary_url
1086 1088
1087 1089
1088 1090 def test_delete_comment_returns_404_if_comment_does_not_exist(pylonsapp):
1089 1091 # TODO: johbo: Global import not possible because models.forms blows up
1090 1092 from rhodecode.controllers.pullrequests import PullrequestsController
1091 1093 controller = PullrequestsController()
1092 1094 patcher = mock.patch(
1093 1095 'rhodecode.model.db.BaseModel.get', return_value=None)
1094 1096 with pytest.raises(HTTPNotFound), patcher:
1095 1097 controller._delete_comment(1)
General Comments 0
You need to be logged in to leave comments. Login now