##// END OF EJS Templates
diffs: introducing diff menu for whitespace toggle and context changes
dan -
r3134:0c8f7d31 default
parent child Browse files
Show More
@@ -1,590 +1,502 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2018 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21
22 22 import logging
23 23 import collections
24 24
25 25 from pyramid.httpexceptions import HTTPNotFound, HTTPBadRequest, HTTPFound
26 26 from pyramid.view import view_config
27 27 from pyramid.renderers import render
28 28 from pyramid.response import Response
29 29
30 30 from rhodecode.apps._base import RepoAppView
31 31
32 32 from rhodecode.lib import diffs, codeblocks
33 33 from rhodecode.lib.auth import (
34 34 LoginRequired, HasRepoPermissionAnyDecorator, NotAnonymous, CSRFRequired)
35 35
36 36 from rhodecode.lib.compat import OrderedDict
37 from rhodecode.lib.diffs import cache_diff, load_cached_diff, diff_cache_exist
37 from rhodecode.lib.diffs import (
38 cache_diff, load_cached_diff, diff_cache_exist, get_diff_context,
39 get_diff_whitespace_flag)
38 40 from rhodecode.lib.exceptions import StatusChangeOnClosedPullRequestError
39 41 import rhodecode.lib.helpers as h
40 42 from rhodecode.lib.utils2 import safe_unicode, str2bool
41 43 from rhodecode.lib.vcs.backends.base import EmptyCommit
42 44 from rhodecode.lib.vcs.exceptions import (
43 45 RepositoryError, CommitDoesNotExistError)
44 46 from rhodecode.model.db import ChangesetComment, ChangesetStatus
45 47 from rhodecode.model.changeset_status import ChangesetStatusModel
46 48 from rhodecode.model.comment import CommentsModel
47 49 from rhodecode.model.meta import Session
48 50 from rhodecode.model.settings import VcsSettingsModel
49 51
50 52 log = logging.getLogger(__name__)
51 53
52 54
53 55 def _update_with_GET(params, request):
54 56 for k in ['diff1', 'diff2', 'diff']:
55 57 params[k] += request.GET.getall(k)
56 58
57 59
58 def get_ignore_ws(fid, request):
59 ig_ws_global = request.GET.get('ignorews')
60 ig_ws = filter(lambda k: k.startswith('WS'), request.GET.getall(fid))
61 if ig_ws:
62 try:
63 return int(ig_ws[0].split(':')[-1])
64 except Exception:
65 pass
66 return ig_ws_global
67 60
68 61
69 def _ignorews_url(request, fileid=None):
70 _ = request.translate
71 fileid = str(fileid) if fileid else None
72 params = collections.defaultdict(list)
73 _update_with_GET(params, request)
74 label = _('Show whitespace')
75 tooltiplbl = _('Show whitespace for all diffs')
76 ig_ws = get_ignore_ws(fileid, request)
77 ln_ctx = get_line_ctx(fileid, request)
78
79 if ig_ws is None:
80 params['ignorews'] += [1]
81 label = _('Ignore whitespace')
82 tooltiplbl = _('Ignore whitespace for all diffs')
83 ctx_key = 'context'
84 ctx_val = ln_ctx
85
86 # if we have passed in ln_ctx pass it along to our params
87 if ln_ctx:
88 params[ctx_key] += [ctx_val]
89
90 if fileid:
91 params['anchor'] = 'a_' + fileid
92 return h.link_to(label, request.current_route_path(_query=params),
93 title=tooltiplbl, class_='tooltip')
94
95
96 def get_line_ctx(fid, request):
97 ln_ctx_global = request.GET.get('context')
98 if fid:
99 ln_ctx = filter(lambda k: k.startswith('C'), request.GET.getall(fid))
100 else:
101 _ln_ctx = filter(lambda k: k.startswith('C'), request.GET)
102 ln_ctx = request.GET.get(_ln_ctx[0]) if _ln_ctx else ln_ctx_global
103 if ln_ctx:
104 ln_ctx = [ln_ctx]
105
106 if ln_ctx:
107 retval = ln_ctx[0].split(':')[-1]
108 else:
109 retval = ln_ctx_global
110
111 try:
112 return min(diffs.MAX_CONTEXT, int(retval))
113 except Exception:
114 return 3
115
116
117 def _context_url(request, fileid=None):
118 """
119 Generates a url for context lines.
120
121 :param fileid:
122 """
123
124 _ = request.translate
125 fileid = str(fileid) if fileid else None
126 ig_ws = get_ignore_ws(fileid, request)
127 ln_ctx = (get_line_ctx(fileid, request) or 3) * 2
128
129 params = collections.defaultdict(list)
130 _update_with_GET(params, request)
131
132 if ln_ctx > 0:
133 params['context'] += [ln_ctx]
134
135 if ig_ws:
136 ig_ws_key = 'ignorews'
137 ig_ws_val = 1
138 params[ig_ws_key] += [ig_ws_val]
139
140 lbl = _('Increase context')
141 tooltiplbl = _('Increase context for all diffs')
142
143 if fileid:
144 params['anchor'] = 'a_' + fileid
145 return h.link_to(lbl, request.current_route_path(_query=params),
146 title=tooltiplbl, class_='tooltip')
147
148 62
149 63 class RepoCommitsView(RepoAppView):
150 64 def load_default_context(self):
151 65 c = self._get_local_tmpl_context(include_app_defaults=True)
152 66 c.rhodecode_repo = self.rhodecode_vcs_repo
153 67
154 68 return c
155 69
156 70 def _is_diff_cache_enabled(self, target_repo):
157 71 caching_enabled = self._get_general_setting(
158 72 target_repo, 'rhodecode_diff_cache')
159 73 log.debug('Diff caching enabled: %s', caching_enabled)
160 74 return caching_enabled
161 75
162 76 def _commit(self, commit_id_range, method):
163 77 _ = self.request.translate
164 78 c = self.load_default_context()
165 c.ignorews_url = _ignorews_url
166 c.context_url = _context_url
167 79 c.fulldiff = self.request.GET.get('fulldiff')
168 80
169 81 # fetch global flags of ignore ws or context lines
170 context_lcl = get_line_ctx('', self.request)
171 ign_whitespace_lcl = get_ignore_ws('', self.request)
82 diff_context = get_diff_context(self.request)
83 hide_whitespace_changes = get_diff_whitespace_flag(self.request)
172 84
173 85 # diff_limit will cut off the whole diff if the limit is applied
174 86 # otherwise it will just hide the big files from the front-end
175 87 diff_limit = c.visual.cut_off_limit_diff
176 88 file_limit = c.visual.cut_off_limit_file
177 89
178 90 # get ranges of commit ids if preset
179 91 commit_range = commit_id_range.split('...')[:2]
180 92
181 93 try:
182 94 pre_load = ['affected_files', 'author', 'branch', 'date',
183 95 'message', 'parents']
184 96
185 97 if len(commit_range) == 2:
186 98 commits = self.rhodecode_vcs_repo.get_commits(
187 99 start_id=commit_range[0], end_id=commit_range[1],
188 100 pre_load=pre_load)
189 101 commits = list(commits)
190 102 else:
191 103 commits = [self.rhodecode_vcs_repo.get_commit(
192 104 commit_id=commit_id_range, pre_load=pre_load)]
193 105
194 106 c.commit_ranges = commits
195 107 if not c.commit_ranges:
196 108 raise RepositoryError(
197 109 'The commit range returned an empty result')
198 110 except CommitDoesNotExistError:
199 111 msg = _('No such commit exists for this repository')
200 112 h.flash(msg, category='error')
201 113 raise HTTPNotFound()
202 114 except Exception:
203 115 log.exception("General failure")
204 116 raise HTTPNotFound()
205 117
206 118 c.changes = OrderedDict()
207 119 c.lines_added = 0
208 120 c.lines_deleted = 0
209 121
210 122 # auto collapse if we have more than limit
211 123 collapse_limit = diffs.DiffProcessor._collapse_commits_over
212 124 c.collapse_all_commits = len(c.commit_ranges) > collapse_limit
213 125
214 126 c.commit_statuses = ChangesetStatus.STATUSES
215 127 c.inline_comments = []
216 128 c.files = []
217 129
218 130 c.statuses = []
219 131 c.comments = []
220 132 c.unresolved_comments = []
221 133 if len(c.commit_ranges) == 1:
222 134 commit = c.commit_ranges[0]
223 135 c.comments = CommentsModel().get_comments(
224 136 self.db_repo.repo_id,
225 137 revision=commit.raw_id)
226 138 c.statuses.append(ChangesetStatusModel().get_status(
227 139 self.db_repo.repo_id, commit.raw_id))
228 140 # comments from PR
229 141 statuses = ChangesetStatusModel().get_statuses(
230 142 self.db_repo.repo_id, commit.raw_id,
231 143 with_revisions=True)
232 144 prs = set(st.pull_request for st in statuses
233 145 if st.pull_request is not None)
234 146 # from associated statuses, check the pull requests, and
235 147 # show comments from them
236 148 for pr in prs:
237 149 c.comments.extend(pr.comments)
238 150
239 151 c.unresolved_comments = CommentsModel()\
240 152 .get_commit_unresolved_todos(commit.raw_id)
241 153
242 154 diff = None
243 155 # Iterate over ranges (default commit view is always one commit)
244 156 for commit in c.commit_ranges:
245 157 c.changes[commit.raw_id] = []
246 158
247 159 commit2 = commit
248 160 commit1 = commit.first_parent
249 161
250 162 if method == 'show':
251 163 inline_comments = CommentsModel().get_inline_comments(
252 164 self.db_repo.repo_id, revision=commit.raw_id)
253 165 c.inline_cnt = CommentsModel().get_inline_comments_count(
254 166 inline_comments)
255 167 c.inline_comments = inline_comments
256 168
257 169 cache_path = self.rhodecode_vcs_repo.get_create_shadow_cache_pr_path(
258 170 self.db_repo)
259 171 cache_file_path = diff_cache_exist(
260 172 cache_path, 'diff', commit.raw_id,
261 ign_whitespace_lcl, context_lcl, c.fulldiff)
173 hide_whitespace_changes, diff_context, c.fulldiff)
262 174
263 175 caching_enabled = self._is_diff_cache_enabled(self.db_repo)
264 176 force_recache = str2bool(self.request.GET.get('force_recache'))
265 177
266 178 cached_diff = None
267 179 if caching_enabled:
268 180 cached_diff = load_cached_diff(cache_file_path)
269 181
270 182 has_proper_diff_cache = cached_diff and cached_diff.get('diff')
271 183 if not force_recache and has_proper_diff_cache:
272 184 diffset = cached_diff['diff']
273 185 else:
274 186 vcs_diff = self.rhodecode_vcs_repo.get_diff(
275 187 commit1, commit2,
276 ignore_whitespace=ign_whitespace_lcl,
277 context=context_lcl)
188 ignore_whitespace=hide_whitespace_changes,
189 context=diff_context)
278 190
279 191 diff_processor = diffs.DiffProcessor(
280 192 vcs_diff, format='newdiff', diff_limit=diff_limit,
281 193 file_limit=file_limit, show_full_diff=c.fulldiff)
282 194
283 195 _parsed = diff_processor.prepare()
284 196
285 197 diffset = codeblocks.DiffSet(
286 198 repo_name=self.db_repo_name,
287 199 source_node_getter=codeblocks.diffset_node_getter(commit1),
288 200 target_node_getter=codeblocks.diffset_node_getter(commit2))
289 201
290 202 diffset = self.path_filter.render_patchset_filtered(
291 203 diffset, _parsed, commit1.raw_id, commit2.raw_id)
292 204
293 205 # save cached diff
294 206 if caching_enabled:
295 207 cache_diff(cache_file_path, diffset, None)
296 208
297 209 c.limited_diff = diffset.limited_diff
298 210 c.changes[commit.raw_id] = diffset
299 211 else:
300 212 # TODO(marcink): no cache usage here...
301 213 _diff = self.rhodecode_vcs_repo.get_diff(
302 214 commit1, commit2,
303 ignore_whitespace=ign_whitespace_lcl, context=context_lcl)
215 ignore_whitespace=hide_whitespace_changes, context=diff_context)
304 216 diff_processor = diffs.DiffProcessor(
305 217 _diff, format='newdiff', diff_limit=diff_limit,
306 218 file_limit=file_limit, show_full_diff=c.fulldiff)
307 219 # downloads/raw we only need RAW diff nothing else
308 220 diff = self.path_filter.get_raw_patch(diff_processor)
309 221 c.changes[commit.raw_id] = [None, None, None, None, diff, None, None]
310 222
311 223 # sort comments by how they were generated
312 224 c.comments = sorted(c.comments, key=lambda x: x.comment_id)
313 225
314 226 if len(c.commit_ranges) == 1:
315 227 c.commit = c.commit_ranges[0]
316 228 c.parent_tmpl = ''.join(
317 229 '# Parent %s\n' % x.raw_id for x in c.commit.parents)
318 230
319 231 if method == 'download':
320 232 response = Response(diff)
321 233 response.content_type = 'text/plain'
322 234 response.content_disposition = (
323 235 'attachment; filename=%s.diff' % commit_id_range[:12])
324 236 return response
325 237 elif method == 'patch':
326 238 c.diff = safe_unicode(diff)
327 239 patch = render(
328 240 'rhodecode:templates/changeset/patch_changeset.mako',
329 241 self._get_template_context(c), self.request)
330 242 response = Response(patch)
331 243 response.content_type = 'text/plain'
332 244 return response
333 245 elif method == 'raw':
334 246 response = Response(diff)
335 247 response.content_type = 'text/plain'
336 248 return response
337 249 elif method == 'show':
338 250 if len(c.commit_ranges) == 1:
339 251 html = render(
340 252 'rhodecode:templates/changeset/changeset.mako',
341 253 self._get_template_context(c), self.request)
342 254 return Response(html)
343 255 else:
344 256 c.ancestor = None
345 257 c.target_repo = self.db_repo
346 258 html = render(
347 259 'rhodecode:templates/changeset/changeset_range.mako',
348 260 self._get_template_context(c), self.request)
349 261 return Response(html)
350 262
351 263 raise HTTPBadRequest()
352 264
353 265 @LoginRequired()
354 266 @HasRepoPermissionAnyDecorator(
355 267 'repository.read', 'repository.write', 'repository.admin')
356 268 @view_config(
357 269 route_name='repo_commit', request_method='GET',
358 270 renderer=None)
359 271 def repo_commit_show(self):
360 272 commit_id = self.request.matchdict['commit_id']
361 273 return self._commit(commit_id, method='show')
362 274
363 275 @LoginRequired()
364 276 @HasRepoPermissionAnyDecorator(
365 277 'repository.read', 'repository.write', 'repository.admin')
366 278 @view_config(
367 279 route_name='repo_commit_raw', request_method='GET',
368 280 renderer=None)
369 281 @view_config(
370 282 route_name='repo_commit_raw_deprecated', request_method='GET',
371 283 renderer=None)
372 284 def repo_commit_raw(self):
373 285 commit_id = self.request.matchdict['commit_id']
374 286 return self._commit(commit_id, method='raw')
375 287
376 288 @LoginRequired()
377 289 @HasRepoPermissionAnyDecorator(
378 290 'repository.read', 'repository.write', 'repository.admin')
379 291 @view_config(
380 292 route_name='repo_commit_patch', request_method='GET',
381 293 renderer=None)
382 294 def repo_commit_patch(self):
383 295 commit_id = self.request.matchdict['commit_id']
384 296 return self._commit(commit_id, method='patch')
385 297
386 298 @LoginRequired()
387 299 @HasRepoPermissionAnyDecorator(
388 300 'repository.read', 'repository.write', 'repository.admin')
389 301 @view_config(
390 302 route_name='repo_commit_download', request_method='GET',
391 303 renderer=None)
392 304 def repo_commit_download(self):
393 305 commit_id = self.request.matchdict['commit_id']
394 306 return self._commit(commit_id, method='download')
395 307
396 308 @LoginRequired()
397 309 @NotAnonymous()
398 310 @HasRepoPermissionAnyDecorator(
399 311 'repository.read', 'repository.write', 'repository.admin')
400 312 @CSRFRequired()
401 313 @view_config(
402 314 route_name='repo_commit_comment_create', request_method='POST',
403 315 renderer='json_ext')
404 316 def repo_commit_comment_create(self):
405 317 _ = self.request.translate
406 318 commit_id = self.request.matchdict['commit_id']
407 319
408 320 c = self.load_default_context()
409 321 status = self.request.POST.get('changeset_status', None)
410 322 text = self.request.POST.get('text')
411 323 comment_type = self.request.POST.get('comment_type')
412 324 resolves_comment_id = self.request.POST.get('resolves_comment_id', None)
413 325
414 326 if status:
415 327 text = text or (_('Status change %(transition_icon)s %(status)s')
416 328 % {'transition_icon': '>',
417 329 'status': ChangesetStatus.get_status_lbl(status)})
418 330
419 331 multi_commit_ids = []
420 332 for _commit_id in self.request.POST.get('commit_ids', '').split(','):
421 333 if _commit_id not in ['', None, EmptyCommit.raw_id]:
422 334 if _commit_id not in multi_commit_ids:
423 335 multi_commit_ids.append(_commit_id)
424 336
425 337 commit_ids = multi_commit_ids or [commit_id]
426 338
427 339 comment = None
428 340 for current_id in filter(None, commit_ids):
429 341 comment = CommentsModel().create(
430 342 text=text,
431 343 repo=self.db_repo.repo_id,
432 344 user=self._rhodecode_db_user.user_id,
433 345 commit_id=current_id,
434 346 f_path=self.request.POST.get('f_path'),
435 347 line_no=self.request.POST.get('line'),
436 348 status_change=(ChangesetStatus.get_status_lbl(status)
437 349 if status else None),
438 350 status_change_type=status,
439 351 comment_type=comment_type,
440 352 resolves_comment_id=resolves_comment_id,
441 353 auth_user=self._rhodecode_user
442 354 )
443 355
444 356 # get status if set !
445 357 if status:
446 358 # if latest status was from pull request and it's closed
447 359 # disallow changing status !
448 360 # dont_allow_on_closed_pull_request = True !
449 361
450 362 try:
451 363 ChangesetStatusModel().set_status(
452 364 self.db_repo.repo_id,
453 365 status,
454 366 self._rhodecode_db_user.user_id,
455 367 comment,
456 368 revision=current_id,
457 369 dont_allow_on_closed_pull_request=True
458 370 )
459 371 except StatusChangeOnClosedPullRequestError:
460 372 msg = _('Changing the status of a commit associated with '
461 373 'a closed pull request is not allowed')
462 374 log.exception(msg)
463 375 h.flash(msg, category='warning')
464 376 raise HTTPFound(h.route_path(
465 377 'repo_commit', repo_name=self.db_repo_name,
466 378 commit_id=current_id))
467 379
468 380 # finalize, commit and redirect
469 381 Session().commit()
470 382
471 383 data = {
472 384 'target_id': h.safeid(h.safe_unicode(
473 385 self.request.POST.get('f_path'))),
474 386 }
475 387 if comment:
476 388 c.co = comment
477 389 rendered_comment = render(
478 390 'rhodecode:templates/changeset/changeset_comment_block.mako',
479 391 self._get_template_context(c), self.request)
480 392
481 393 data.update(comment.get_dict())
482 394 data.update({'rendered_text': rendered_comment})
483 395
484 396 return data
485 397
486 398 @LoginRequired()
487 399 @NotAnonymous()
488 400 @HasRepoPermissionAnyDecorator(
489 401 'repository.read', 'repository.write', 'repository.admin')
490 402 @CSRFRequired()
491 403 @view_config(
492 404 route_name='repo_commit_comment_preview', request_method='POST',
493 405 renderer='string', xhr=True)
494 406 def repo_commit_comment_preview(self):
495 407 # Technically a CSRF token is not needed as no state changes with this
496 408 # call. However, as this is a POST is better to have it, so automated
497 409 # tools don't flag it as potential CSRF.
498 410 # Post is required because the payload could be bigger than the maximum
499 411 # allowed by GET.
500 412
501 413 text = self.request.POST.get('text')
502 414 renderer = self.request.POST.get('renderer') or 'rst'
503 415 if text:
504 416 return h.render(text, renderer=renderer, mentions=True)
505 417 return ''
506 418
507 419 @LoginRequired()
508 420 @NotAnonymous()
509 421 @HasRepoPermissionAnyDecorator(
510 422 'repository.read', 'repository.write', 'repository.admin')
511 423 @CSRFRequired()
512 424 @view_config(
513 425 route_name='repo_commit_comment_delete', request_method='POST',
514 426 renderer='json_ext')
515 427 def repo_commit_comment_delete(self):
516 428 commit_id = self.request.matchdict['commit_id']
517 429 comment_id = self.request.matchdict['comment_id']
518 430
519 431 comment = ChangesetComment.get_or_404(comment_id)
520 432 if not comment:
521 433 log.debug('Comment with id:%s not found, skipping', comment_id)
522 434 # comment already deleted in another call probably
523 435 return True
524 436
525 437 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name)
526 438 super_admin = h.HasPermissionAny('hg.admin')()
527 439 comment_owner = (comment.author.user_id == self._rhodecode_db_user.user_id)
528 440 is_repo_comment = comment.repo.repo_name == self.db_repo_name
529 441 comment_repo_admin = is_repo_admin and is_repo_comment
530 442
531 443 if super_admin or comment_owner or comment_repo_admin:
532 444 CommentsModel().delete(comment=comment, auth_user=self._rhodecode_user)
533 445 Session().commit()
534 446 return True
535 447 else:
536 448 log.warning('No permissions for user %s to delete comment_id: %s',
537 449 self._rhodecode_db_user, comment_id)
538 450 raise HTTPNotFound()
539 451
540 452 @LoginRequired()
541 453 @HasRepoPermissionAnyDecorator(
542 454 'repository.read', 'repository.write', 'repository.admin')
543 455 @view_config(
544 456 route_name='repo_commit_data', request_method='GET',
545 457 renderer='json_ext', xhr=True)
546 458 def repo_commit_data(self):
547 459 commit_id = self.request.matchdict['commit_id']
548 460 self.load_default_context()
549 461
550 462 try:
551 463 return self.rhodecode_vcs_repo.get_commit(commit_id=commit_id)
552 464 except CommitDoesNotExistError as e:
553 465 return EmptyCommit(message=str(e))
554 466
555 467 @LoginRequired()
556 468 @HasRepoPermissionAnyDecorator(
557 469 'repository.read', 'repository.write', 'repository.admin')
558 470 @view_config(
559 471 route_name='repo_commit_children', request_method='GET',
560 472 renderer='json_ext', xhr=True)
561 473 def repo_commit_children(self):
562 474 commit_id = self.request.matchdict['commit_id']
563 475 self.load_default_context()
564 476
565 477 try:
566 478 commit = self.rhodecode_vcs_repo.get_commit(commit_id=commit_id)
567 479 children = commit.children
568 480 except CommitDoesNotExistError:
569 481 children = []
570 482
571 483 result = {"results": children}
572 484 return result
573 485
574 486 @LoginRequired()
575 487 @HasRepoPermissionAnyDecorator(
576 488 'repository.read', 'repository.write', 'repository.admin')
577 489 @view_config(
578 490 route_name='repo_commit_parents', request_method='GET',
579 491 renderer='json_ext')
580 492 def repo_commit_parents(self):
581 493 commit_id = self.request.matchdict['commit_id']
582 494 self.load_default_context()
583 495
584 496 try:
585 497 commit = self.rhodecode_vcs_repo.get_commit(commit_id=commit_id)
586 498 parents = commit.parents
587 499 except CommitDoesNotExistError:
588 500 parents = []
589 501 result = {"results": parents}
590 502 return result
@@ -1,313 +1,315 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2018 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21
22 22 import logging
23 23
24 24 from pyramid.httpexceptions import HTTPBadRequest, HTTPNotFound, HTTPFound
25 25 from pyramid.view import view_config
26 26 from pyramid.renderers import render
27 27 from pyramid.response import Response
28 28
29 29 from rhodecode.apps._base import RepoAppView
30 30 from rhodecode.controllers.utils import parse_path_ref, get_commit_from_ref_name
31 31 from rhodecode.lib import helpers as h
32 32 from rhodecode.lib import diffs, codeblocks
33 33 from rhodecode.lib.auth import LoginRequired, HasRepoPermissionAnyDecorator
34 34 from rhodecode.lib.utils import safe_str
35 35 from rhodecode.lib.utils2 import safe_unicode, str2bool
36 36 from rhodecode.lib.vcs.exceptions import (
37 37 EmptyRepositoryError, RepositoryError, RepositoryRequirementError,
38 38 NodeDoesNotExistError)
39 39 from rhodecode.model.db import Repository, ChangesetStatus
40 40
41 41 log = logging.getLogger(__name__)
42 42
43 43
44 44 class RepoCompareView(RepoAppView):
45 45 def load_default_context(self):
46 46 c = self._get_local_tmpl_context(include_app_defaults=True)
47
48 47 c.rhodecode_repo = self.rhodecode_vcs_repo
49
50
51 48 return c
52 49
53 50 def _get_commit_or_redirect(
54 51 self, ref, ref_type, repo, redirect_after=True, partial=False):
55 52 """
56 53 This is a safe way to get a commit. If an error occurs it
57 54 redirects to a commit with a proper message. If partial is set
58 55 then it does not do redirect raise and throws an exception instead.
59 56 """
60 57 _ = self.request.translate
61 58 try:
62 59 return get_commit_from_ref_name(repo, safe_str(ref), ref_type)
63 60 except EmptyRepositoryError:
64 61 if not redirect_after:
65 62 return repo.scm_instance().EMPTY_COMMIT
66 63 h.flash(h.literal(_('There are no commits yet')),
67 64 category='warning')
68 65 if not partial:
69 66 raise HTTPFound(
70 67 h.route_path('repo_summary', repo_name=repo.repo_name))
71 68 raise HTTPBadRequest()
72 69
73 70 except RepositoryError as e:
74 71 log.exception(safe_str(e))
75 72 h.flash(safe_str(h.escape(e)), category='warning')
76 73 if not partial:
77 74 raise HTTPFound(
78 75 h.route_path('repo_summary', repo_name=repo.repo_name))
79 76 raise HTTPBadRequest()
80 77
81 78 @LoginRequired()
82 79 @HasRepoPermissionAnyDecorator(
83 80 'repository.read', 'repository.write', 'repository.admin')
84 81 @view_config(
85 82 route_name='repo_compare_select', request_method='GET',
86 83 renderer='rhodecode:templates/compare/compare_diff.mako')
87 84 def compare_select(self):
88 85 _ = self.request.translate
89 86 c = self.load_default_context()
90 87
91 88 source_repo = self.db_repo_name
92 89 target_repo = self.request.GET.get('target_repo', source_repo)
93 90 c.source_repo = Repository.get_by_repo_name(source_repo)
94 91 c.target_repo = Repository.get_by_repo_name(target_repo)
95 92
96 93 if c.source_repo is None or c.target_repo is None:
97 94 raise HTTPNotFound()
98 95
99 96 c.compare_home = True
100 97 c.commit_ranges = []
101 98 c.collapse_all_commits = False
102 99 c.diffset = None
103 100 c.limited_diff = False
104 101 c.source_ref = c.target_ref = _('Select commit')
105 102 c.source_ref_type = ""
106 103 c.target_ref_type = ""
107 104 c.commit_statuses = ChangesetStatus.STATUSES
108 105 c.preview_mode = False
109 106 c.file_path = None
110 107
111 108 return self._get_template_context(c)
112 109
113 110 @LoginRequired()
114 111 @HasRepoPermissionAnyDecorator(
115 112 'repository.read', 'repository.write', 'repository.admin')
116 113 @view_config(
117 114 route_name='repo_compare', request_method='GET',
118 115 renderer=None)
119 116 def compare(self):
120 117 _ = self.request.translate
121 118 c = self.load_default_context()
122 119
123 120 source_ref_type = self.request.matchdict['source_ref_type']
124 121 source_ref = self.request.matchdict['source_ref']
125 122 target_ref_type = self.request.matchdict['target_ref_type']
126 123 target_ref = self.request.matchdict['target_ref']
127 124
128 125 # source_ref will be evaluated in source_repo
129 126 source_repo_name = self.db_repo_name
130 127 source_path, source_id = parse_path_ref(source_ref)
131 128
132 129 # target_ref will be evaluated in target_repo
133 130 target_repo_name = self.request.GET.get('target_repo', source_repo_name)
134 131 target_path, target_id = parse_path_ref(
135 132 target_ref, default_path=self.request.GET.get('f_path', ''))
136 133
137 134 # if merge is True
138 135 # Show what changes since the shared ancestor commit of target/source
139 136 # the source would get if it was merged with target. Only commits
140 137 # which are in target but not in source will be shown.
141 138 merge = str2bool(self.request.GET.get('merge'))
142 139 # if merge is False
143 140 # Show a raw diff of source/target refs even if no ancestor exists
144 141
145 142 # c.fulldiff disables cut_off_limit
146 143 c.fulldiff = str2bool(self.request.GET.get('fulldiff'))
147 144
145 # fetch global flags of ignore ws or context lines
146 diff_context = diffs.get_diff_context(self.request)
147 hide_whitespace_changes = diffs.get_diff_whitespace_flag(self.request)
148
148 149 c.file_path = target_path
149 150 c.commit_statuses = ChangesetStatus.STATUSES
150 151
151 152 # if partial, returns just compare_commits.html (commits log)
152 153 partial = self.request.is_xhr
153 154
154 155 # swap url for compare_diff page
155 156 c.swap_url = h.route_path(
156 157 'repo_compare',
157 158 repo_name=target_repo_name,
158 159 source_ref_type=target_ref_type,
159 160 source_ref=target_ref,
160 161 target_repo=source_repo_name,
161 162 target_ref_type=source_ref_type,
162 163 target_ref=source_ref,
163 164 _query=dict(merge=merge and '1' or '', f_path=target_path))
164 165
165 166 source_repo = Repository.get_by_repo_name(source_repo_name)
166 167 target_repo = Repository.get_by_repo_name(target_repo_name)
167 168
168 169 if source_repo is None:
169 170 log.error('Could not find the source repo: {}'
170 171 .format(source_repo_name))
171 172 h.flash(_('Could not find the source repo: `{}`')
172 173 .format(h.escape(source_repo_name)), category='error')
173 174 raise HTTPFound(
174 175 h.route_path('repo_compare_select', repo_name=self.db_repo_name))
175 176
176 177 if target_repo is None:
177 178 log.error('Could not find the target repo: {}'
178 179 .format(source_repo_name))
179 180 h.flash(_('Could not find the target repo: `{}`')
180 181 .format(h.escape(target_repo_name)), category='error')
181 182 raise HTTPFound(
182 183 h.route_path('repo_compare_select', repo_name=self.db_repo_name))
183 184
184 185 source_scm = source_repo.scm_instance()
185 186 target_scm = target_repo.scm_instance()
186 187
187 188 source_alias = source_scm.alias
188 189 target_alias = target_scm.alias
189 190 if source_alias != target_alias:
190 191 msg = _('The comparison of two different kinds of remote repos '
191 192 'is not available')
192 193 log.error(msg)
193 194 h.flash(msg, category='error')
194 195 raise HTTPFound(
195 196 h.route_path('repo_compare_select', repo_name=self.db_repo_name))
196 197
197 198 source_commit = self._get_commit_or_redirect(
198 199 ref=source_id, ref_type=source_ref_type, repo=source_repo,
199 200 partial=partial)
200 201 target_commit = self._get_commit_or_redirect(
201 202 ref=target_id, ref_type=target_ref_type, repo=target_repo,
202 203 partial=partial)
203 204
204 205 c.compare_home = False
205 206 c.source_repo = source_repo
206 207 c.target_repo = target_repo
207 208 c.source_ref = source_ref
208 209 c.target_ref = target_ref
209 210 c.source_ref_type = source_ref_type
210 211 c.target_ref_type = target_ref_type
211 212
212 213 pre_load = ["author", "branch", "date", "message"]
213 214 c.ancestor = None
214 215
215 216 if c.file_path:
216 217 if source_commit == target_commit:
217 218 c.commit_ranges = []
218 219 else:
219 220 c.commit_ranges = [target_commit]
220 221 else:
221 222 try:
222 223 c.commit_ranges = source_scm.compare(
223 224 source_commit.raw_id, target_commit.raw_id,
224 225 target_scm, merge, pre_load=pre_load)
225 226 if merge:
226 227 c.ancestor = source_scm.get_common_ancestor(
227 228 source_commit.raw_id, target_commit.raw_id, target_scm)
228 229 except RepositoryRequirementError:
229 230 msg = _('Could not compare repos with different '
230 231 'large file settings')
231 232 log.error(msg)
232 233 if partial:
233 234 return Response(msg)
234 235 h.flash(msg, category='error')
235 236 raise HTTPFound(
236 237 h.route_path('repo_compare_select',
237 238 repo_name=self.db_repo_name))
238 239
239 240 c.statuses = self.db_repo.statuses(
240 241 [x.raw_id for x in c.commit_ranges])
241 242
242 243 # auto collapse if we have more than limit
243 244 collapse_limit = diffs.DiffProcessor._collapse_commits_over
244 245 c.collapse_all_commits = len(c.commit_ranges) > collapse_limit
245 246
246 247 if partial: # for PR ajax commits loader
247 248 if not c.ancestor:
248 249 return Response('') # cannot merge if there is no ancestor
249 250
250 251 html = render(
251 252 'rhodecode:templates/compare/compare_commits.mako',
252 253 self._get_template_context(c), self.request)
253 254 return Response(html)
254 255
255 256 if c.ancestor:
256 257 # case we want a simple diff without incoming commits,
257 258 # previewing what will be merged.
258 259 # Make the diff on target repo (which is known to have target_ref)
259 260 log.debug('Using ancestor %s as source_ref instead of %s',
260 261 c.ancestor, source_ref)
261 262 source_repo = target_repo
262 263 source_commit = target_repo.get_commit(commit_id=c.ancestor)
263 264
264 265 # diff_limit will cut off the whole diff if the limit is applied
265 266 # otherwise it will just hide the big files from the front-end
266 267 diff_limit = c.visual.cut_off_limit_diff
267 268 file_limit = c.visual.cut_off_limit_file
268 269
269 270 log.debug('calculating diff between '
270 271 'source_ref:%s and target_ref:%s for repo `%s`',
271 272 source_commit, target_commit,
272 273 safe_unicode(source_repo.scm_instance().path))
273 274
274 275 if source_commit.repository != target_commit.repository:
275 276 msg = _(
276 277 "Repositories unrelated. "
277 278 "Cannot compare commit %(commit1)s from repository %(repo1)s "
278 279 "with commit %(commit2)s from repository %(repo2)s.") % {
279 280 'commit1': h.show_id(source_commit),
280 281 'repo1': source_repo.repo_name,
281 282 'commit2': h.show_id(target_commit),
282 283 'repo2': target_repo.repo_name,
283 284 }
284 285 h.flash(msg, category='error')
285 286 raise HTTPFound(
286 287 h.route_path('repo_compare_select',
287 288 repo_name=self.db_repo_name))
288 289
289 290 txt_diff = source_repo.scm_instance().get_diff(
290 291 commit1=source_commit, commit2=target_commit,
291 path=target_path, path1=source_path)
292 path=target_path, path1=source_path,
293 ignore_whitespace=hide_whitespace_changes, context=diff_context)
292 294
293 295 diff_processor = diffs.DiffProcessor(
294 296 txt_diff, format='newdiff', diff_limit=diff_limit,
295 297 file_limit=file_limit, show_full_diff=c.fulldiff)
296 298 _parsed = diff_processor.prepare()
297 299
298 300 diffset = codeblocks.DiffSet(
299 301 repo_name=source_repo.repo_name,
300 302 source_node_getter=codeblocks.diffset_node_getter(source_commit),
301 303 target_node_getter=codeblocks.diffset_node_getter(target_commit),
302 304 )
303 305 c.diffset = self.path_filter.render_patchset_filtered(
304 306 diffset, _parsed, source_ref, target_ref)
305 307
306 308 c.preview_mode = merge
307 309 c.source_commit = source_commit
308 310 c.target_commit = target_commit
309 311
310 312 html = render(
311 313 'rhodecode:templates/compare/compare_diff.mako',
312 314 self._get_template_context(c), self.request)
313 315 return Response(html) No newline at end of file
@@ -1,1393 +1,1401 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2018 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 collections
23 23
24 24 import formencode
25 25 import formencode.htmlfill
26 26 import peppercorn
27 27 from pyramid.httpexceptions import (
28 28 HTTPFound, HTTPNotFound, HTTPForbidden, HTTPBadRequest)
29 29 from pyramid.view import view_config
30 30 from pyramid.renderers import render
31 31
32 32 from rhodecode import events
33 33 from rhodecode.apps._base import RepoAppView, DataGridAppView
34 34
35 35 from rhodecode.lib import helpers as h, diffs, codeblocks, channelstream
36 36 from rhodecode.lib.base import vcs_operation_context
37 37 from rhodecode.lib.diffs import load_cached_diff, cache_diff, diff_cache_exist
38 38 from rhodecode.lib.ext_json import json
39 39 from rhodecode.lib.auth import (
40 40 LoginRequired, HasRepoPermissionAny, HasRepoPermissionAnyDecorator,
41 41 NotAnonymous, CSRFRequired)
42 42 from rhodecode.lib.utils2 import str2bool, safe_str, safe_unicode
43 43 from rhodecode.lib.vcs.backends.base import EmptyCommit, UpdateFailureReason
44 44 from rhodecode.lib.vcs.exceptions import (CommitDoesNotExistError,
45 45 RepositoryRequirementError, EmptyRepositoryError)
46 46 from rhodecode.model.changeset_status import ChangesetStatusModel
47 47 from rhodecode.model.comment import CommentsModel
48 48 from rhodecode.model.db import (func, or_, PullRequest, PullRequestVersion,
49 49 ChangesetComment, ChangesetStatus, Repository)
50 50 from rhodecode.model.forms import PullRequestForm
51 51 from rhodecode.model.meta import Session
52 52 from rhodecode.model.pull_request import PullRequestModel, MergeCheck
53 53 from rhodecode.model.scm import ScmModel
54 54
55 55 log = logging.getLogger(__name__)
56 56
57 57
58 58 class RepoPullRequestsView(RepoAppView, DataGridAppView):
59 59
60 60 def load_default_context(self):
61 61 c = self._get_local_tmpl_context(include_app_defaults=True)
62 62 c.REVIEW_STATUS_APPROVED = ChangesetStatus.STATUS_APPROVED
63 63 c.REVIEW_STATUS_REJECTED = ChangesetStatus.STATUS_REJECTED
64 64 # backward compat., we use for OLD PRs a plain renderer
65 65 c.renderer = 'plain'
66 66 return c
67 67
68 68 def _get_pull_requests_list(
69 69 self, repo_name, source, filter_type, opened_by, statuses):
70 70
71 71 draw, start, limit = self._extract_chunk(self.request)
72 72 search_q, order_by, order_dir = self._extract_ordering(self.request)
73 73 _render = self.request.get_partial_renderer(
74 74 'rhodecode:templates/data_table/_dt_elements.mako')
75 75
76 76 # pagination
77 77
78 78 if filter_type == 'awaiting_review':
79 79 pull_requests = PullRequestModel().get_awaiting_review(
80 80 repo_name, source=source, opened_by=opened_by,
81 81 statuses=statuses, offset=start, length=limit,
82 82 order_by=order_by, order_dir=order_dir)
83 83 pull_requests_total_count = PullRequestModel().count_awaiting_review(
84 84 repo_name, source=source, statuses=statuses,
85 85 opened_by=opened_by)
86 86 elif filter_type == 'awaiting_my_review':
87 87 pull_requests = PullRequestModel().get_awaiting_my_review(
88 88 repo_name, source=source, opened_by=opened_by,
89 89 user_id=self._rhodecode_user.user_id, statuses=statuses,
90 90 offset=start, length=limit, order_by=order_by,
91 91 order_dir=order_dir)
92 92 pull_requests_total_count = PullRequestModel().count_awaiting_my_review(
93 93 repo_name, source=source, user_id=self._rhodecode_user.user_id,
94 94 statuses=statuses, opened_by=opened_by)
95 95 else:
96 96 pull_requests = PullRequestModel().get_all(
97 97 repo_name, source=source, opened_by=opened_by,
98 98 statuses=statuses, offset=start, length=limit,
99 99 order_by=order_by, order_dir=order_dir)
100 100 pull_requests_total_count = PullRequestModel().count_all(
101 101 repo_name, source=source, statuses=statuses,
102 102 opened_by=opened_by)
103 103
104 104 data = []
105 105 comments_model = CommentsModel()
106 106 for pr in pull_requests:
107 107 comments = comments_model.get_all_comments(
108 108 self.db_repo.repo_id, pull_request=pr)
109 109
110 110 data.append({
111 111 'name': _render('pullrequest_name',
112 112 pr.pull_request_id, pr.target_repo.repo_name),
113 113 'name_raw': pr.pull_request_id,
114 114 'status': _render('pullrequest_status',
115 115 pr.calculated_review_status()),
116 116 'title': _render(
117 117 'pullrequest_title', pr.title, pr.description),
118 118 'description': h.escape(pr.description),
119 119 'updated_on': _render('pullrequest_updated_on',
120 120 h.datetime_to_time(pr.updated_on)),
121 121 'updated_on_raw': h.datetime_to_time(pr.updated_on),
122 122 'created_on': _render('pullrequest_updated_on',
123 123 h.datetime_to_time(pr.created_on)),
124 124 'created_on_raw': h.datetime_to_time(pr.created_on),
125 125 'author': _render('pullrequest_author',
126 126 pr.author.full_contact, ),
127 127 'author_raw': pr.author.full_name,
128 128 'comments': _render('pullrequest_comments', len(comments)),
129 129 'comments_raw': len(comments),
130 130 'closed': pr.is_closed(),
131 131 })
132 132
133 133 data = ({
134 134 'draw': draw,
135 135 'data': data,
136 136 'recordsTotal': pull_requests_total_count,
137 137 'recordsFiltered': pull_requests_total_count,
138 138 })
139 139 return data
140 140
141 141 def get_recache_flag(self):
142 142 for flag_name in ['force_recache', 'force-recache', 'no-cache']:
143 143 flag_val = self.request.GET.get(flag_name)
144 144 if str2bool(flag_val):
145 145 return True
146 146 return False
147 147
148 148 @LoginRequired()
149 149 @HasRepoPermissionAnyDecorator(
150 150 'repository.read', 'repository.write', 'repository.admin')
151 151 @view_config(
152 152 route_name='pullrequest_show_all', request_method='GET',
153 153 renderer='rhodecode:templates/pullrequests/pullrequests.mako')
154 154 def pull_request_list(self):
155 155 c = self.load_default_context()
156 156
157 157 req_get = self.request.GET
158 158 c.source = str2bool(req_get.get('source'))
159 159 c.closed = str2bool(req_get.get('closed'))
160 160 c.my = str2bool(req_get.get('my'))
161 161 c.awaiting_review = str2bool(req_get.get('awaiting_review'))
162 162 c.awaiting_my_review = str2bool(req_get.get('awaiting_my_review'))
163 163
164 164 c.active = 'open'
165 165 if c.my:
166 166 c.active = 'my'
167 167 if c.closed:
168 168 c.active = 'closed'
169 169 if c.awaiting_review and not c.source:
170 170 c.active = 'awaiting'
171 171 if c.source and not c.awaiting_review:
172 172 c.active = 'source'
173 173 if c.awaiting_my_review:
174 174 c.active = 'awaiting_my'
175 175
176 176 return self._get_template_context(c)
177 177
178 178 @LoginRequired()
179 179 @HasRepoPermissionAnyDecorator(
180 180 'repository.read', 'repository.write', 'repository.admin')
181 181 @view_config(
182 182 route_name='pullrequest_show_all_data', request_method='GET',
183 183 renderer='json_ext', xhr=True)
184 184 def pull_request_list_data(self):
185 185 self.load_default_context()
186 186
187 187 # additional filters
188 188 req_get = self.request.GET
189 189 source = str2bool(req_get.get('source'))
190 190 closed = str2bool(req_get.get('closed'))
191 191 my = str2bool(req_get.get('my'))
192 192 awaiting_review = str2bool(req_get.get('awaiting_review'))
193 193 awaiting_my_review = str2bool(req_get.get('awaiting_my_review'))
194 194
195 195 filter_type = 'awaiting_review' if awaiting_review \
196 196 else 'awaiting_my_review' if awaiting_my_review \
197 197 else None
198 198
199 199 opened_by = None
200 200 if my:
201 201 opened_by = [self._rhodecode_user.user_id]
202 202
203 203 statuses = [PullRequest.STATUS_NEW, PullRequest.STATUS_OPEN]
204 204 if closed:
205 205 statuses = [PullRequest.STATUS_CLOSED]
206 206
207 207 data = self._get_pull_requests_list(
208 208 repo_name=self.db_repo_name, source=source,
209 209 filter_type=filter_type, opened_by=opened_by, statuses=statuses)
210 210
211 211 return data
212 212
213 213 def _is_diff_cache_enabled(self, target_repo):
214 214 caching_enabled = self._get_general_setting(
215 215 target_repo, 'rhodecode_diff_cache')
216 216 log.debug('Diff caching enabled: %s', caching_enabled)
217 217 return caching_enabled
218 218
219 219 def _get_diffset(self, source_repo_name, source_repo,
220 220 source_ref_id, target_ref_id,
221 221 target_commit, source_commit, diff_limit, file_limit,
222 fulldiff):
222 fulldiff, hide_whitespace_changes, diff_context):
223 223
224 224 vcs_diff = PullRequestModel().get_diff(
225 source_repo, source_ref_id, target_ref_id)
225 source_repo, source_ref_id, target_ref_id,
226 hide_whitespace_changes, diff_context)
226 227
227 228 diff_processor = diffs.DiffProcessor(
228 229 vcs_diff, format='newdiff', diff_limit=diff_limit,
229 230 file_limit=file_limit, show_full_diff=fulldiff)
230 231
231 232 _parsed = diff_processor.prepare()
232 233
233 234 diffset = codeblocks.DiffSet(
234 235 repo_name=self.db_repo_name,
235 236 source_repo_name=source_repo_name,
236 237 source_node_getter=codeblocks.diffset_node_getter(target_commit),
237 238 target_node_getter=codeblocks.diffset_node_getter(source_commit),
238 239 )
239 240 diffset = self.path_filter.render_patchset_filtered(
240 241 diffset, _parsed, target_commit.raw_id, source_commit.raw_id)
241 242
242 243 return diffset
243 244
244 245 def _get_range_diffset(self, source_scm, source_repo,
245 246 commit1, commit2, diff_limit, file_limit,
246 fulldiff, ign_whitespace_lcl, context_lcl):
247 fulldiff, hide_whitespace_changes, diff_context):
247 248 vcs_diff = source_scm.get_diff(
248 249 commit1, commit2,
249 ignore_whitespace=ign_whitespace_lcl,
250 context=context_lcl)
250 ignore_whitespace=hide_whitespace_changes,
251 context=diff_context)
251 252
252 253 diff_processor = diffs.DiffProcessor(
253 254 vcs_diff, format='newdiff', diff_limit=diff_limit,
254 255 file_limit=file_limit, show_full_diff=fulldiff)
255 256
256 257 _parsed = diff_processor.prepare()
257 258
258 259 diffset = codeblocks.DiffSet(
259 260 repo_name=source_repo.repo_name,
260 261 source_node_getter=codeblocks.diffset_node_getter(commit1),
261 262 target_node_getter=codeblocks.diffset_node_getter(commit2))
262 263
263 264 diffset = self.path_filter.render_patchset_filtered(
264 265 diffset, _parsed, commit1.raw_id, commit2.raw_id)
265 266
266 267 return diffset
267 268
268 269 @LoginRequired()
269 270 @HasRepoPermissionAnyDecorator(
270 271 'repository.read', 'repository.write', 'repository.admin')
271 272 @view_config(
272 273 route_name='pullrequest_show', request_method='GET',
273 274 renderer='rhodecode:templates/pullrequests/pullrequest_show.mako')
274 275 def pull_request_show(self):
275 276 pull_request_id = self.request.matchdict['pull_request_id']
276 277
277 278 c = self.load_default_context()
278 279
279 280 version = self.request.GET.get('version')
280 281 from_version = self.request.GET.get('from_version') or version
281 282 merge_checks = self.request.GET.get('merge_checks')
282 283 c.fulldiff = str2bool(self.request.GET.get('fulldiff'))
284
285 # fetch global flags of ignore ws or context lines
286 diff_context = diffs.get_diff_context(self.request)
287 hide_whitespace_changes = diffs.get_diff_whitespace_flag(self.request)
288
283 289 force_refresh = str2bool(self.request.GET.get('force_refresh'))
284 290
285 291 (pull_request_latest,
286 292 pull_request_at_ver,
287 293 pull_request_display_obj,
288 294 at_version) = PullRequestModel().get_pr_version(
289 295 pull_request_id, version=version)
290 296 pr_closed = pull_request_latest.is_closed()
291 297
292 298 if pr_closed and (version or from_version):
293 299 # not allow to browse versions
294 300 raise HTTPFound(h.route_path(
295 301 'pullrequest_show', repo_name=self.db_repo_name,
296 302 pull_request_id=pull_request_id))
297 303
298 304 versions = pull_request_display_obj.versions()
299 305 # used to store per-commit range diffs
300 306 c.changes = collections.OrderedDict()
301 307 c.range_diff_on = self.request.GET.get('range-diff') == "1"
302 308
303 309 c.at_version = at_version
304 310 c.at_version_num = (at_version
305 311 if at_version and at_version != 'latest'
306 312 else None)
307 313 c.at_version_pos = ChangesetComment.get_index_from_version(
308 314 c.at_version_num, versions)
309 315
310 316 (prev_pull_request_latest,
311 317 prev_pull_request_at_ver,
312 318 prev_pull_request_display_obj,
313 319 prev_at_version) = PullRequestModel().get_pr_version(
314 320 pull_request_id, version=from_version)
315 321
316 322 c.from_version = prev_at_version
317 323 c.from_version_num = (prev_at_version
318 324 if prev_at_version and prev_at_version != 'latest'
319 325 else None)
320 326 c.from_version_pos = ChangesetComment.get_index_from_version(
321 327 c.from_version_num, versions)
322 328
323 329 # define if we're in COMPARE mode or VIEW at version mode
324 330 compare = at_version != prev_at_version
325 331
326 332 # pull_requests repo_name we opened it against
327 333 # ie. target_repo must match
328 334 if self.db_repo_name != pull_request_at_ver.target_repo.repo_name:
329 335 raise HTTPNotFound()
330 336
331 337 c.shadow_clone_url = PullRequestModel().get_shadow_clone_url(
332 338 pull_request_at_ver)
333 339
334 340 c.pull_request = pull_request_display_obj
335 341 c.renderer = pull_request_at_ver.description_renderer or c.renderer
336 342 c.pull_request_latest = pull_request_latest
337 343
338 344 if compare or (at_version and not at_version == 'latest'):
339 345 c.allowed_to_change_status = False
340 346 c.allowed_to_update = False
341 347 c.allowed_to_merge = False
342 348 c.allowed_to_delete = False
343 349 c.allowed_to_comment = False
344 350 c.allowed_to_close = False
345 351 else:
346 352 can_change_status = PullRequestModel().check_user_change_status(
347 353 pull_request_at_ver, self._rhodecode_user)
348 354 c.allowed_to_change_status = can_change_status and not pr_closed
349 355
350 356 c.allowed_to_update = PullRequestModel().check_user_update(
351 357 pull_request_latest, self._rhodecode_user) and not pr_closed
352 358 c.allowed_to_merge = PullRequestModel().check_user_merge(
353 359 pull_request_latest, self._rhodecode_user) and not pr_closed
354 360 c.allowed_to_delete = PullRequestModel().check_user_delete(
355 361 pull_request_latest, self._rhodecode_user) and not pr_closed
356 362 c.allowed_to_comment = not pr_closed
357 363 c.allowed_to_close = c.allowed_to_merge and not pr_closed
358 364
359 365 c.forbid_adding_reviewers = False
360 366 c.forbid_author_to_review = False
361 367 c.forbid_commit_author_to_review = False
362 368
363 369 if pull_request_latest.reviewer_data and \
364 370 'rules' in pull_request_latest.reviewer_data:
365 371 rules = pull_request_latest.reviewer_data['rules'] or {}
366 372 try:
367 373 c.forbid_adding_reviewers = rules.get(
368 374 'forbid_adding_reviewers')
369 375 c.forbid_author_to_review = rules.get(
370 376 'forbid_author_to_review')
371 377 c.forbid_commit_author_to_review = rules.get(
372 378 'forbid_commit_author_to_review')
373 379 except Exception:
374 380 pass
375 381
376 382 # check merge capabilities
377 383 _merge_check = MergeCheck.validate(
378 384 pull_request_latest, auth_user=self._rhodecode_user,
379 385 translator=self.request.translate,
380 386 force_shadow_repo_refresh=force_refresh)
381 387 c.pr_merge_errors = _merge_check.error_details
382 388 c.pr_merge_possible = not _merge_check.failed
383 389 c.pr_merge_message = _merge_check.merge_msg
384 390
385 391 c.pr_merge_info = MergeCheck.get_merge_conditions(
386 392 pull_request_latest, translator=self.request.translate)
387 393
388 394 c.pull_request_review_status = _merge_check.review_status
389 395 if merge_checks:
390 396 self.request.override_renderer = \
391 397 'rhodecode:templates/pullrequests/pullrequest_merge_checks.mako'
392 398 return self._get_template_context(c)
393 399
394 400 comments_model = CommentsModel()
395 401
396 402 # reviewers and statuses
397 403 c.pull_request_reviewers = pull_request_at_ver.reviewers_statuses()
398 404 allowed_reviewers = [x[0].user_id for x in c.pull_request_reviewers]
399 405
400 406 # GENERAL COMMENTS with versions #
401 407 q = comments_model._all_general_comments_of_pull_request(pull_request_latest)
402 408 q = q.order_by(ChangesetComment.comment_id.asc())
403 409 general_comments = q
404 410
405 411 # pick comments we want to render at current version
406 412 c.comment_versions = comments_model.aggregate_comments(
407 413 general_comments, versions, c.at_version_num)
408 414 c.comments = c.comment_versions[c.at_version_num]['until']
409 415
410 416 # INLINE COMMENTS with versions #
411 417 q = comments_model._all_inline_comments_of_pull_request(pull_request_latest)
412 418 q = q.order_by(ChangesetComment.comment_id.asc())
413 419 inline_comments = q
414 420
415 421 c.inline_versions = comments_model.aggregate_comments(
416 422 inline_comments, versions, c.at_version_num, inline=True)
417 423
418 424 # inject latest version
419 425 latest_ver = PullRequest.get_pr_display_object(
420 426 pull_request_latest, pull_request_latest)
421 427
422 428 c.versions = versions + [latest_ver]
423 429
424 430 # if we use version, then do not show later comments
425 431 # than current version
426 432 display_inline_comments = collections.defaultdict(
427 433 lambda: collections.defaultdict(list))
428 434 for co in inline_comments:
429 435 if c.at_version_num:
430 436 # pick comments that are at least UPTO given version, so we
431 437 # don't render comments for higher version
432 438 should_render = co.pull_request_version_id and \
433 439 co.pull_request_version_id <= c.at_version_num
434 440 else:
435 441 # showing all, for 'latest'
436 442 should_render = True
437 443
438 444 if should_render:
439 445 display_inline_comments[co.f_path][co.line_no].append(co)
440 446
441 447 # load diff data into template context, if we use compare mode then
442 448 # diff is calculated based on changes between versions of PR
443 449
444 450 source_repo = pull_request_at_ver.source_repo
445 451 source_ref_id = pull_request_at_ver.source_ref_parts.commit_id
446 452
447 453 target_repo = pull_request_at_ver.target_repo
448 454 target_ref_id = pull_request_at_ver.target_ref_parts.commit_id
449 455
450 456 if compare:
451 457 # in compare switch the diff base to latest commit from prev version
452 458 target_ref_id = prev_pull_request_display_obj.revisions[0]
453 459
454 460 # despite opening commits for bookmarks/branches/tags, we always
455 461 # convert this to rev to prevent changes after bookmark or branch change
456 462 c.source_ref_type = 'rev'
457 463 c.source_ref = source_ref_id
458 464
459 465 c.target_ref_type = 'rev'
460 466 c.target_ref = target_ref_id
461 467
462 468 c.source_repo = source_repo
463 469 c.target_repo = target_repo
464 470
465 471 c.commit_ranges = []
466 472 source_commit = EmptyCommit()
467 473 target_commit = EmptyCommit()
468 474 c.missing_requirements = False
469 475
470 476 source_scm = source_repo.scm_instance()
471 477 target_scm = target_repo.scm_instance()
472 478
473 479 shadow_scm = None
474 480 try:
475 481 shadow_scm = pull_request_latest.get_shadow_repo()
476 482 except Exception:
477 483 log.debug('Failed to get shadow repo', exc_info=True)
478 484 # try first the existing source_repo, and then shadow
479 485 # repo if we can obtain one
480 486 commits_source_repo = source_scm or shadow_scm
481 487
482 488 c.commits_source_repo = commits_source_repo
483 489 c.ancestor = None # set it to None, to hide it from PR view
484 490
485 491 # empty version means latest, so we keep this to prevent
486 492 # double caching
487 493 version_normalized = version or 'latest'
488 494 from_version_normalized = from_version or 'latest'
489 495
490 496 cache_path = self.rhodecode_vcs_repo.get_create_shadow_cache_pr_path(target_repo)
491 497 cache_file_path = diff_cache_exist(
492 498 cache_path, 'pull_request', pull_request_id, version_normalized,
493 from_version_normalized, source_ref_id, target_ref_id, c.fulldiff)
499 from_version_normalized, source_ref_id, target_ref_id,
500 hide_whitespace_changes, diff_context, c.fulldiff)
494 501
495 502 caching_enabled = self._is_diff_cache_enabled(c.target_repo)
496 503 force_recache = self.get_recache_flag()
497 504
498 505 cached_diff = None
499 506 if caching_enabled:
500 507 cached_diff = load_cached_diff(cache_file_path)
501 508
502 509 has_proper_commit_cache = (
503 510 cached_diff and cached_diff.get('commits')
504 511 and len(cached_diff.get('commits', [])) == 5
505 512 and cached_diff.get('commits')[0]
506 513 and cached_diff.get('commits')[3])
507 514
508 515 if not force_recache and not c.range_diff_on and has_proper_commit_cache:
509 516 diff_commit_cache = \
510 517 (ancestor_commit, commit_cache, missing_requirements,
511 518 source_commit, target_commit) = cached_diff['commits']
512 519 else:
513 520 diff_commit_cache = \
514 521 (ancestor_commit, commit_cache, missing_requirements,
515 522 source_commit, target_commit) = self.get_commits(
516 523 commits_source_repo,
517 524 pull_request_at_ver,
518 525 source_commit,
519 526 source_ref_id,
520 527 source_scm,
521 528 target_commit,
522 529 target_ref_id,
523 530 target_scm)
524 531
525 532 # register our commit range
526 533 for comm in commit_cache.values():
527 534 c.commit_ranges.append(comm)
528 535
529 536 c.missing_requirements = missing_requirements
530 537 c.ancestor_commit = ancestor_commit
531 538 c.statuses = source_repo.statuses(
532 539 [x.raw_id for x in c.commit_ranges])
533 540
534 541 # auto collapse if we have more than limit
535 542 collapse_limit = diffs.DiffProcessor._collapse_commits_over
536 543 c.collapse_all_commits = len(c.commit_ranges) > collapse_limit
537 544 c.compare_mode = compare
538 545
539 546 # diff_limit is the old behavior, will cut off the whole diff
540 547 # if the limit is applied otherwise will just hide the
541 548 # big files from the front-end
542 549 diff_limit = c.visual.cut_off_limit_diff
543 550 file_limit = c.visual.cut_off_limit_file
544 551
545 552 c.missing_commits = False
546 553 if (c.missing_requirements
547 554 or isinstance(source_commit, EmptyCommit)
548 555 or source_commit == target_commit):
549 556
550 557 c.missing_commits = True
551 558 else:
552 559 c.inline_comments = display_inline_comments
553 560
554 561 has_proper_diff_cache = cached_diff and cached_diff.get('commits')
555 562 if not force_recache and has_proper_diff_cache:
556 563 c.diffset = cached_diff['diff']
557 564 (ancestor_commit, commit_cache, missing_requirements,
558 565 source_commit, target_commit) = cached_diff['commits']
559 566 else:
560 567 c.diffset = self._get_diffset(
561 568 c.source_repo.repo_name, commits_source_repo,
562 569 source_ref_id, target_ref_id,
563 570 target_commit, source_commit,
564 diff_limit, file_limit, c.fulldiff)
571 diff_limit, file_limit, c.fulldiff,
572 hide_whitespace_changes, diff_context)
565 573
566 574 # save cached diff
567 575 if caching_enabled:
568 576 cache_diff(cache_file_path, c.diffset, diff_commit_cache)
569 577
570 578 c.limited_diff = c.diffset.limited_diff
571 579
572 580 # calculate removed files that are bound to comments
573 581 comment_deleted_files = [
574 582 fname for fname in display_inline_comments
575 583 if fname not in c.diffset.file_stats]
576 584
577 585 c.deleted_files_comments = collections.defaultdict(dict)
578 586 for fname, per_line_comments in display_inline_comments.items():
579 587 if fname in comment_deleted_files:
580 588 c.deleted_files_comments[fname]['stats'] = 0
581 589 c.deleted_files_comments[fname]['comments'] = list()
582 590 for lno, comments in per_line_comments.items():
583 591 c.deleted_files_comments[fname]['comments'].extend(comments)
584 592
585 593 # maybe calculate the range diff
586 594 if c.range_diff_on:
587 595 # TODO(marcink): set whitespace/context
588 596 context_lcl = 3
589 597 ign_whitespace_lcl = False
590 598
591 599 for commit in c.commit_ranges:
592 600 commit2 = commit
593 601 commit1 = commit.first_parent
594 602
595 603 range_diff_cache_file_path = diff_cache_exist(
596 604 cache_path, 'diff', commit.raw_id,
597 605 ign_whitespace_lcl, context_lcl, c.fulldiff)
598 606
599 607 cached_diff = None
600 608 if caching_enabled:
601 609 cached_diff = load_cached_diff(range_diff_cache_file_path)
602 610
603 611 has_proper_diff_cache = cached_diff and cached_diff.get('diff')
604 612 if not force_recache and has_proper_diff_cache:
605 613 diffset = cached_diff['diff']
606 614 else:
607 615 diffset = self._get_range_diffset(
608 616 source_scm, source_repo,
609 617 commit1, commit2, diff_limit, file_limit,
610 618 c.fulldiff, ign_whitespace_lcl, context_lcl
611 619 )
612 620
613 621 # save cached diff
614 622 if caching_enabled:
615 623 cache_diff(range_diff_cache_file_path, diffset, None)
616 624
617 625 c.changes[commit.raw_id] = diffset
618 626
619 627 # this is a hack to properly display links, when creating PR, the
620 628 # compare view and others uses different notation, and
621 629 # compare_commits.mako renders links based on the target_repo.
622 630 # We need to swap that here to generate it properly on the html side
623 631 c.target_repo = c.source_repo
624 632
625 633 c.commit_statuses = ChangesetStatus.STATUSES
626 634
627 635 c.show_version_changes = not pr_closed
628 636 if c.show_version_changes:
629 637 cur_obj = pull_request_at_ver
630 638 prev_obj = prev_pull_request_at_ver
631 639
632 640 old_commit_ids = prev_obj.revisions
633 641 new_commit_ids = cur_obj.revisions
634 642 commit_changes = PullRequestModel()._calculate_commit_id_changes(
635 643 old_commit_ids, new_commit_ids)
636 644 c.commit_changes_summary = commit_changes
637 645
638 646 # calculate the diff for commits between versions
639 647 c.commit_changes = []
640 648 mark = lambda cs, fw: list(
641 649 h.itertools.izip_longest([], cs, fillvalue=fw))
642 650 for c_type, raw_id in mark(commit_changes.added, 'a') \
643 651 + mark(commit_changes.removed, 'r') \
644 652 + mark(commit_changes.common, 'c'):
645 653
646 654 if raw_id in commit_cache:
647 655 commit = commit_cache[raw_id]
648 656 else:
649 657 try:
650 658 commit = commits_source_repo.get_commit(raw_id)
651 659 except CommitDoesNotExistError:
652 660 # in case we fail extracting still use "dummy" commit
653 661 # for display in commit diff
654 662 commit = h.AttributeDict(
655 663 {'raw_id': raw_id,
656 664 'message': 'EMPTY or MISSING COMMIT'})
657 665 c.commit_changes.append([c_type, commit])
658 666
659 667 # current user review statuses for each version
660 668 c.review_versions = {}
661 669 if self._rhodecode_user.user_id in allowed_reviewers:
662 670 for co in general_comments:
663 671 if co.author.user_id == self._rhodecode_user.user_id:
664 672 status = co.status_change
665 673 if status:
666 674 _ver_pr = status[0].comment.pull_request_version_id
667 675 c.review_versions[_ver_pr] = status[0]
668 676
669 677 return self._get_template_context(c)
670 678
671 679 def get_commits(
672 680 self, commits_source_repo, pull_request_at_ver, source_commit,
673 681 source_ref_id, source_scm, target_commit, target_ref_id, target_scm):
674 682 commit_cache = collections.OrderedDict()
675 683 missing_requirements = False
676 684 try:
677 685 pre_load = ["author", "branch", "date", "message", "parents"]
678 686 show_revs = pull_request_at_ver.revisions
679 687 for rev in show_revs:
680 688 comm = commits_source_repo.get_commit(
681 689 commit_id=rev, pre_load=pre_load)
682 690 commit_cache[comm.raw_id] = comm
683 691
684 692 # Order here matters, we first need to get target, and then
685 693 # the source
686 694 target_commit = commits_source_repo.get_commit(
687 695 commit_id=safe_str(target_ref_id))
688 696
689 697 source_commit = commits_source_repo.get_commit(
690 698 commit_id=safe_str(source_ref_id))
691 699 except CommitDoesNotExistError:
692 700 log.warning(
693 701 'Failed to get commit from `{}` repo'.format(
694 702 commits_source_repo), exc_info=True)
695 703 except RepositoryRequirementError:
696 704 log.warning(
697 705 'Failed to get all required data from repo', exc_info=True)
698 706 missing_requirements = True
699 707 ancestor_commit = None
700 708 try:
701 709 ancestor_id = source_scm.get_common_ancestor(
702 710 source_commit.raw_id, target_commit.raw_id, target_scm)
703 711 ancestor_commit = source_scm.get_commit(ancestor_id)
704 712 except Exception:
705 713 ancestor_commit = None
706 714 return ancestor_commit, commit_cache, missing_requirements, source_commit, target_commit
707 715
708 716 def assure_not_empty_repo(self):
709 717 _ = self.request.translate
710 718
711 719 try:
712 720 self.db_repo.scm_instance().get_commit()
713 721 except EmptyRepositoryError:
714 722 h.flash(h.literal(_('There are no commits yet')),
715 723 category='warning')
716 724 raise HTTPFound(
717 725 h.route_path('repo_summary', repo_name=self.db_repo.repo_name))
718 726
719 727 @LoginRequired()
720 728 @NotAnonymous()
721 729 @HasRepoPermissionAnyDecorator(
722 730 'repository.read', 'repository.write', 'repository.admin')
723 731 @view_config(
724 732 route_name='pullrequest_new', request_method='GET',
725 733 renderer='rhodecode:templates/pullrequests/pullrequest.mako')
726 734 def pull_request_new(self):
727 735 _ = self.request.translate
728 736 c = self.load_default_context()
729 737
730 738 self.assure_not_empty_repo()
731 739 source_repo = self.db_repo
732 740
733 741 commit_id = self.request.GET.get('commit')
734 742 branch_ref = self.request.GET.get('branch')
735 743 bookmark_ref = self.request.GET.get('bookmark')
736 744
737 745 try:
738 746 source_repo_data = PullRequestModel().generate_repo_data(
739 747 source_repo, commit_id=commit_id,
740 748 branch=branch_ref, bookmark=bookmark_ref,
741 749 translator=self.request.translate)
742 750 except CommitDoesNotExistError as e:
743 751 log.exception(e)
744 752 h.flash(_('Commit does not exist'), 'error')
745 753 raise HTTPFound(
746 754 h.route_path('pullrequest_new', repo_name=source_repo.repo_name))
747 755
748 756 default_target_repo = source_repo
749 757
750 758 if source_repo.parent:
751 759 parent_vcs_obj = source_repo.parent.scm_instance()
752 760 if parent_vcs_obj and not parent_vcs_obj.is_empty():
753 761 # change default if we have a parent repo
754 762 default_target_repo = source_repo.parent
755 763
756 764 target_repo_data = PullRequestModel().generate_repo_data(
757 765 default_target_repo, translator=self.request.translate)
758 766
759 767 selected_source_ref = source_repo_data['refs']['selected_ref']
760 768 title_source_ref = ''
761 769 if selected_source_ref:
762 770 title_source_ref = selected_source_ref.split(':', 2)[1]
763 771 c.default_title = PullRequestModel().generate_pullrequest_title(
764 772 source=source_repo.repo_name,
765 773 source_ref=title_source_ref,
766 774 target=default_target_repo.repo_name
767 775 )
768 776
769 777 c.default_repo_data = {
770 778 'source_repo_name': source_repo.repo_name,
771 779 'source_refs_json': json.dumps(source_repo_data),
772 780 'target_repo_name': default_target_repo.repo_name,
773 781 'target_refs_json': json.dumps(target_repo_data),
774 782 }
775 783 c.default_source_ref = selected_source_ref
776 784
777 785 return self._get_template_context(c)
778 786
779 787 @LoginRequired()
780 788 @NotAnonymous()
781 789 @HasRepoPermissionAnyDecorator(
782 790 'repository.read', 'repository.write', 'repository.admin')
783 791 @view_config(
784 792 route_name='pullrequest_repo_refs', request_method='GET',
785 793 renderer='json_ext', xhr=True)
786 794 def pull_request_repo_refs(self):
787 795 self.load_default_context()
788 796 target_repo_name = self.request.matchdict['target_repo_name']
789 797 repo = Repository.get_by_repo_name(target_repo_name)
790 798 if not repo:
791 799 raise HTTPNotFound()
792 800
793 801 target_perm = HasRepoPermissionAny(
794 802 'repository.read', 'repository.write', 'repository.admin')(
795 803 target_repo_name)
796 804 if not target_perm:
797 805 raise HTTPNotFound()
798 806
799 807 return PullRequestModel().generate_repo_data(
800 808 repo, translator=self.request.translate)
801 809
802 810 @LoginRequired()
803 811 @NotAnonymous()
804 812 @HasRepoPermissionAnyDecorator(
805 813 'repository.read', 'repository.write', 'repository.admin')
806 814 @view_config(
807 815 route_name='pullrequest_repo_destinations', request_method='GET',
808 816 renderer='json_ext', xhr=True)
809 817 def pull_request_repo_destinations(self):
810 818 _ = self.request.translate
811 819 filter_query = self.request.GET.get('query')
812 820
813 821 query = Repository.query() \
814 822 .order_by(func.length(Repository.repo_name)) \
815 823 .filter(
816 824 or_(Repository.repo_name == self.db_repo.repo_name,
817 825 Repository.fork_id == self.db_repo.repo_id))
818 826
819 827 if filter_query:
820 828 ilike_expression = u'%{}%'.format(safe_unicode(filter_query))
821 829 query = query.filter(
822 830 Repository.repo_name.ilike(ilike_expression))
823 831
824 832 add_parent = False
825 833 if self.db_repo.parent:
826 834 if filter_query in self.db_repo.parent.repo_name:
827 835 parent_vcs_obj = self.db_repo.parent.scm_instance()
828 836 if parent_vcs_obj and not parent_vcs_obj.is_empty():
829 837 add_parent = True
830 838
831 839 limit = 20 - 1 if add_parent else 20
832 840 all_repos = query.limit(limit).all()
833 841 if add_parent:
834 842 all_repos += [self.db_repo.parent]
835 843
836 844 repos = []
837 845 for obj in ScmModel().get_repos(all_repos):
838 846 repos.append({
839 847 'id': obj['name'],
840 848 'text': obj['name'],
841 849 'type': 'repo',
842 850 'repo_id': obj['dbrepo']['repo_id'],
843 851 'repo_type': obj['dbrepo']['repo_type'],
844 852 'private': obj['dbrepo']['private'],
845 853
846 854 })
847 855
848 856 data = {
849 857 'more': False,
850 858 'results': [{
851 859 'text': _('Repositories'),
852 860 'children': repos
853 861 }] if repos else []
854 862 }
855 863 return data
856 864
857 865 @LoginRequired()
858 866 @NotAnonymous()
859 867 @HasRepoPermissionAnyDecorator(
860 868 'repository.read', 'repository.write', 'repository.admin')
861 869 @CSRFRequired()
862 870 @view_config(
863 871 route_name='pullrequest_create', request_method='POST',
864 872 renderer=None)
865 873 def pull_request_create(self):
866 874 _ = self.request.translate
867 875 self.assure_not_empty_repo()
868 876 self.load_default_context()
869 877
870 878 controls = peppercorn.parse(self.request.POST.items())
871 879
872 880 try:
873 881 form = PullRequestForm(
874 882 self.request.translate, self.db_repo.repo_id)()
875 883 _form = form.to_python(controls)
876 884 except formencode.Invalid as errors:
877 885 if errors.error_dict.get('revisions'):
878 886 msg = 'Revisions: %s' % errors.error_dict['revisions']
879 887 elif errors.error_dict.get('pullrequest_title'):
880 888 msg = errors.error_dict.get('pullrequest_title')
881 889 else:
882 890 msg = _('Error creating pull request: {}').format(errors)
883 891 log.exception(msg)
884 892 h.flash(msg, 'error')
885 893
886 894 # would rather just go back to form ...
887 895 raise HTTPFound(
888 896 h.route_path('pullrequest_new', repo_name=self.db_repo_name))
889 897
890 898 source_repo = _form['source_repo']
891 899 source_ref = _form['source_ref']
892 900 target_repo = _form['target_repo']
893 901 target_ref = _form['target_ref']
894 902 commit_ids = _form['revisions'][::-1]
895 903
896 904 # find the ancestor for this pr
897 905 source_db_repo = Repository.get_by_repo_name(_form['source_repo'])
898 906 target_db_repo = Repository.get_by_repo_name(_form['target_repo'])
899 907
900 908 # re-check permissions again here
901 909 # source_repo we must have read permissions
902 910
903 911 source_perm = HasRepoPermissionAny(
904 912 'repository.read',
905 913 'repository.write', 'repository.admin')(source_db_repo.repo_name)
906 914 if not source_perm:
907 915 msg = _('Not Enough permissions to source repo `{}`.'.format(
908 916 source_db_repo.repo_name))
909 917 h.flash(msg, category='error')
910 918 # copy the args back to redirect
911 919 org_query = self.request.GET.mixed()
912 920 raise HTTPFound(
913 921 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
914 922 _query=org_query))
915 923
916 924 # target repo we must have read permissions, and also later on
917 925 # we want to check branch permissions here
918 926 target_perm = HasRepoPermissionAny(
919 927 'repository.read',
920 928 'repository.write', 'repository.admin')(target_db_repo.repo_name)
921 929 if not target_perm:
922 930 msg = _('Not Enough permissions to target repo `{}`.'.format(
923 931 target_db_repo.repo_name))
924 932 h.flash(msg, category='error')
925 933 # copy the args back to redirect
926 934 org_query = self.request.GET.mixed()
927 935 raise HTTPFound(
928 936 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
929 937 _query=org_query))
930 938
931 939 source_scm = source_db_repo.scm_instance()
932 940 target_scm = target_db_repo.scm_instance()
933 941
934 942 source_commit = source_scm.get_commit(source_ref.split(':')[-1])
935 943 target_commit = target_scm.get_commit(target_ref.split(':')[-1])
936 944
937 945 ancestor = source_scm.get_common_ancestor(
938 946 source_commit.raw_id, target_commit.raw_id, target_scm)
939 947
940 948 # recalculate target ref based on ancestor
941 949 target_ref_type, target_ref_name, __ = _form['target_ref'].split(':')
942 950 target_ref = ':'.join((target_ref_type, target_ref_name, ancestor))
943 951
944 952 get_default_reviewers_data, validate_default_reviewers = \
945 953 PullRequestModel().get_reviewer_functions()
946 954
947 955 # recalculate reviewers logic, to make sure we can validate this
948 956 reviewer_rules = get_default_reviewers_data(
949 957 self._rhodecode_db_user, source_db_repo,
950 958 source_commit, target_db_repo, target_commit)
951 959
952 960 given_reviewers = _form['review_members']
953 961 reviewers = validate_default_reviewers(
954 962 given_reviewers, reviewer_rules)
955 963
956 964 pullrequest_title = _form['pullrequest_title']
957 965 title_source_ref = source_ref.split(':', 2)[1]
958 966 if not pullrequest_title:
959 967 pullrequest_title = PullRequestModel().generate_pullrequest_title(
960 968 source=source_repo,
961 969 source_ref=title_source_ref,
962 970 target=target_repo
963 971 )
964 972
965 973 description = _form['pullrequest_desc']
966 974 description_renderer = _form['description_renderer']
967 975
968 976 try:
969 977 pull_request = PullRequestModel().create(
970 978 created_by=self._rhodecode_user.user_id,
971 979 source_repo=source_repo,
972 980 source_ref=source_ref,
973 981 target_repo=target_repo,
974 982 target_ref=target_ref,
975 983 revisions=commit_ids,
976 984 reviewers=reviewers,
977 985 title=pullrequest_title,
978 986 description=description,
979 987 description_renderer=description_renderer,
980 988 reviewer_data=reviewer_rules,
981 989 auth_user=self._rhodecode_user
982 990 )
983 991 Session().commit()
984 992
985 993 h.flash(_('Successfully opened new pull request'),
986 994 category='success')
987 995 except Exception:
988 996 msg = _('Error occurred during creation of this pull request.')
989 997 log.exception(msg)
990 998 h.flash(msg, category='error')
991 999
992 1000 # copy the args back to redirect
993 1001 org_query = self.request.GET.mixed()
994 1002 raise HTTPFound(
995 1003 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
996 1004 _query=org_query))
997 1005
998 1006 raise HTTPFound(
999 1007 h.route_path('pullrequest_show', repo_name=target_repo,
1000 1008 pull_request_id=pull_request.pull_request_id))
1001 1009
1002 1010 @LoginRequired()
1003 1011 @NotAnonymous()
1004 1012 @HasRepoPermissionAnyDecorator(
1005 1013 'repository.read', 'repository.write', 'repository.admin')
1006 1014 @CSRFRequired()
1007 1015 @view_config(
1008 1016 route_name='pullrequest_update', request_method='POST',
1009 1017 renderer='json_ext')
1010 1018 def pull_request_update(self):
1011 1019 pull_request = PullRequest.get_or_404(
1012 1020 self.request.matchdict['pull_request_id'])
1013 1021 _ = self.request.translate
1014 1022
1015 1023 self.load_default_context()
1016 1024
1017 1025 if pull_request.is_closed():
1018 1026 log.debug('update: forbidden because pull request is closed')
1019 1027 msg = _(u'Cannot update closed pull requests.')
1020 1028 h.flash(msg, category='error')
1021 1029 return True
1022 1030
1023 1031 # only owner or admin can update it
1024 1032 allowed_to_update = PullRequestModel().check_user_update(
1025 1033 pull_request, self._rhodecode_user)
1026 1034 if allowed_to_update:
1027 1035 controls = peppercorn.parse(self.request.POST.items())
1028 1036
1029 1037 if 'review_members' in controls:
1030 1038 self._update_reviewers(
1031 1039 pull_request, controls['review_members'],
1032 1040 pull_request.reviewer_data)
1033 1041 elif str2bool(self.request.POST.get('update_commits', 'false')):
1034 1042 self._update_commits(pull_request)
1035 1043 elif str2bool(self.request.POST.get('edit_pull_request', 'false')):
1036 1044 self._edit_pull_request(pull_request)
1037 1045 else:
1038 1046 raise HTTPBadRequest()
1039 1047 return True
1040 1048 raise HTTPForbidden()
1041 1049
1042 1050 def _edit_pull_request(self, pull_request):
1043 1051 _ = self.request.translate
1044 1052
1045 1053 try:
1046 1054 PullRequestModel().edit(
1047 1055 pull_request,
1048 1056 self.request.POST.get('title'),
1049 1057 self.request.POST.get('description'),
1050 1058 self.request.POST.get('description_renderer'),
1051 1059 self._rhodecode_user)
1052 1060 except ValueError:
1053 1061 msg = _(u'Cannot update closed pull requests.')
1054 1062 h.flash(msg, category='error')
1055 1063 return
1056 1064 else:
1057 1065 Session().commit()
1058 1066
1059 1067 msg = _(u'Pull request title & description updated.')
1060 1068 h.flash(msg, category='success')
1061 1069 return
1062 1070
1063 1071 def _update_commits(self, pull_request):
1064 1072 _ = self.request.translate
1065 1073 resp = PullRequestModel().update_commits(pull_request)
1066 1074
1067 1075 if resp.executed:
1068 1076
1069 1077 if resp.target_changed and resp.source_changed:
1070 1078 changed = 'target and source repositories'
1071 1079 elif resp.target_changed and not resp.source_changed:
1072 1080 changed = 'target repository'
1073 1081 elif not resp.target_changed and resp.source_changed:
1074 1082 changed = 'source repository'
1075 1083 else:
1076 1084 changed = 'nothing'
1077 1085
1078 1086 msg = _(
1079 1087 u'Pull request updated to "{source_commit_id}" with '
1080 1088 u'{count_added} added, {count_removed} removed commits. '
1081 1089 u'Source of changes: {change_source}')
1082 1090 msg = msg.format(
1083 1091 source_commit_id=pull_request.source_ref_parts.commit_id,
1084 1092 count_added=len(resp.changes.added),
1085 1093 count_removed=len(resp.changes.removed),
1086 1094 change_source=changed)
1087 1095 h.flash(msg, category='success')
1088 1096
1089 1097 channel = '/repo${}$/pr/{}'.format(
1090 1098 pull_request.target_repo.repo_name,
1091 1099 pull_request.pull_request_id)
1092 1100 message = msg + (
1093 1101 ' - <a onclick="window.location.reload()">'
1094 1102 '<strong>{}</strong></a>'.format(_('Reload page')))
1095 1103 channelstream.post_message(
1096 1104 channel, message, self._rhodecode_user.username,
1097 1105 registry=self.request.registry)
1098 1106 else:
1099 1107 msg = PullRequestModel.UPDATE_STATUS_MESSAGES[resp.reason]
1100 1108 warning_reasons = [
1101 1109 UpdateFailureReason.NO_CHANGE,
1102 1110 UpdateFailureReason.WRONG_REF_TYPE,
1103 1111 ]
1104 1112 category = 'warning' if resp.reason in warning_reasons else 'error'
1105 1113 h.flash(msg, category=category)
1106 1114
1107 1115 @LoginRequired()
1108 1116 @NotAnonymous()
1109 1117 @HasRepoPermissionAnyDecorator(
1110 1118 'repository.read', 'repository.write', 'repository.admin')
1111 1119 @CSRFRequired()
1112 1120 @view_config(
1113 1121 route_name='pullrequest_merge', request_method='POST',
1114 1122 renderer='json_ext')
1115 1123 def pull_request_merge(self):
1116 1124 """
1117 1125 Merge will perform a server-side merge of the specified
1118 1126 pull request, if the pull request is approved and mergeable.
1119 1127 After successful merging, the pull request is automatically
1120 1128 closed, with a relevant comment.
1121 1129 """
1122 1130 pull_request = PullRequest.get_or_404(
1123 1131 self.request.matchdict['pull_request_id'])
1124 1132
1125 1133 self.load_default_context()
1126 1134 check = MergeCheck.validate(
1127 1135 pull_request, auth_user=self._rhodecode_user,
1128 1136 translator=self.request.translate)
1129 1137 merge_possible = not check.failed
1130 1138
1131 1139 for err_type, error_msg in check.errors:
1132 1140 h.flash(error_msg, category=err_type)
1133 1141
1134 1142 if merge_possible:
1135 1143 log.debug("Pre-conditions checked, trying to merge.")
1136 1144 extras = vcs_operation_context(
1137 1145 self.request.environ, repo_name=pull_request.target_repo.repo_name,
1138 1146 username=self._rhodecode_db_user.username, action='push',
1139 1147 scm=pull_request.target_repo.repo_type)
1140 1148 self._merge_pull_request(
1141 1149 pull_request, self._rhodecode_db_user, extras)
1142 1150 else:
1143 1151 log.debug("Pre-conditions failed, NOT merging.")
1144 1152
1145 1153 raise HTTPFound(
1146 1154 h.route_path('pullrequest_show',
1147 1155 repo_name=pull_request.target_repo.repo_name,
1148 1156 pull_request_id=pull_request.pull_request_id))
1149 1157
1150 1158 def _merge_pull_request(self, pull_request, user, extras):
1151 1159 _ = self.request.translate
1152 1160 merge_resp = PullRequestModel().merge_repo(pull_request, user, extras=extras)
1153 1161
1154 1162 if merge_resp.executed:
1155 1163 log.debug("The merge was successful, closing the pull request.")
1156 1164 PullRequestModel().close_pull_request(
1157 1165 pull_request.pull_request_id, user)
1158 1166 Session().commit()
1159 1167 msg = _('Pull request was successfully merged and closed.')
1160 1168 h.flash(msg, category='success')
1161 1169 else:
1162 1170 log.debug(
1163 1171 "The merge was not successful. Merge response: %s",
1164 1172 merge_resp)
1165 1173 msg = PullRequestModel().merge_status_message(
1166 1174 merge_resp.failure_reason)
1167 1175 h.flash(msg, category='error')
1168 1176
1169 1177 def _update_reviewers(self, pull_request, review_members, reviewer_rules):
1170 1178 _ = self.request.translate
1171 1179 get_default_reviewers_data, validate_default_reviewers = \
1172 1180 PullRequestModel().get_reviewer_functions()
1173 1181
1174 1182 try:
1175 1183 reviewers = validate_default_reviewers(review_members, reviewer_rules)
1176 1184 except ValueError as e:
1177 1185 log.error('Reviewers Validation: {}'.format(e))
1178 1186 h.flash(e, category='error')
1179 1187 return
1180 1188
1181 1189 PullRequestModel().update_reviewers(
1182 1190 pull_request, reviewers, self._rhodecode_user)
1183 1191 h.flash(_('Pull request reviewers updated.'), category='success')
1184 1192 Session().commit()
1185 1193
1186 1194 @LoginRequired()
1187 1195 @NotAnonymous()
1188 1196 @HasRepoPermissionAnyDecorator(
1189 1197 'repository.read', 'repository.write', 'repository.admin')
1190 1198 @CSRFRequired()
1191 1199 @view_config(
1192 1200 route_name='pullrequest_delete', request_method='POST',
1193 1201 renderer='json_ext')
1194 1202 def pull_request_delete(self):
1195 1203 _ = self.request.translate
1196 1204
1197 1205 pull_request = PullRequest.get_or_404(
1198 1206 self.request.matchdict['pull_request_id'])
1199 1207 self.load_default_context()
1200 1208
1201 1209 pr_closed = pull_request.is_closed()
1202 1210 allowed_to_delete = PullRequestModel().check_user_delete(
1203 1211 pull_request, self._rhodecode_user) and not pr_closed
1204 1212
1205 1213 # only owner can delete it !
1206 1214 if allowed_to_delete:
1207 1215 PullRequestModel().delete(pull_request, self._rhodecode_user)
1208 1216 Session().commit()
1209 1217 h.flash(_('Successfully deleted pull request'),
1210 1218 category='success')
1211 1219 raise HTTPFound(h.route_path('pullrequest_show_all',
1212 1220 repo_name=self.db_repo_name))
1213 1221
1214 1222 log.warning('user %s tried to delete pull request without access',
1215 1223 self._rhodecode_user)
1216 1224 raise HTTPNotFound()
1217 1225
1218 1226 @LoginRequired()
1219 1227 @NotAnonymous()
1220 1228 @HasRepoPermissionAnyDecorator(
1221 1229 'repository.read', 'repository.write', 'repository.admin')
1222 1230 @CSRFRequired()
1223 1231 @view_config(
1224 1232 route_name='pullrequest_comment_create', request_method='POST',
1225 1233 renderer='json_ext')
1226 1234 def pull_request_comment_create(self):
1227 1235 _ = self.request.translate
1228 1236
1229 1237 pull_request = PullRequest.get_or_404(
1230 1238 self.request.matchdict['pull_request_id'])
1231 1239 pull_request_id = pull_request.pull_request_id
1232 1240
1233 1241 if pull_request.is_closed():
1234 1242 log.debug('comment: forbidden because pull request is closed')
1235 1243 raise HTTPForbidden()
1236 1244
1237 1245 allowed_to_comment = PullRequestModel().check_user_comment(
1238 1246 pull_request, self._rhodecode_user)
1239 1247 if not allowed_to_comment:
1240 1248 log.debug(
1241 1249 'comment: forbidden because pull request is from forbidden repo')
1242 1250 raise HTTPForbidden()
1243 1251
1244 1252 c = self.load_default_context()
1245 1253
1246 1254 status = self.request.POST.get('changeset_status', None)
1247 1255 text = self.request.POST.get('text')
1248 1256 comment_type = self.request.POST.get('comment_type')
1249 1257 resolves_comment_id = self.request.POST.get('resolves_comment_id', None)
1250 1258 close_pull_request = self.request.POST.get('close_pull_request')
1251 1259
1252 1260 # the logic here should work like following, if we submit close
1253 1261 # pr comment, use `close_pull_request_with_comment` function
1254 1262 # else handle regular comment logic
1255 1263
1256 1264 if close_pull_request:
1257 1265 # only owner or admin or person with write permissions
1258 1266 allowed_to_close = PullRequestModel().check_user_update(
1259 1267 pull_request, self._rhodecode_user)
1260 1268 if not allowed_to_close:
1261 1269 log.debug('comment: forbidden because not allowed to close '
1262 1270 'pull request %s', pull_request_id)
1263 1271 raise HTTPForbidden()
1264 1272 comment, status = PullRequestModel().close_pull_request_with_comment(
1265 1273 pull_request, self._rhodecode_user, self.db_repo, message=text,
1266 1274 auth_user=self._rhodecode_user)
1267 1275 Session().flush()
1268 1276 events.trigger(
1269 1277 events.PullRequestCommentEvent(pull_request, comment))
1270 1278
1271 1279 else:
1272 1280 # regular comment case, could be inline, or one with status.
1273 1281 # for that one we check also permissions
1274 1282
1275 1283 allowed_to_change_status = PullRequestModel().check_user_change_status(
1276 1284 pull_request, self._rhodecode_user)
1277 1285
1278 1286 if status and allowed_to_change_status:
1279 1287 message = (_('Status change %(transition_icon)s %(status)s')
1280 1288 % {'transition_icon': '>',
1281 1289 'status': ChangesetStatus.get_status_lbl(status)})
1282 1290 text = text or message
1283 1291
1284 1292 comment = CommentsModel().create(
1285 1293 text=text,
1286 1294 repo=self.db_repo.repo_id,
1287 1295 user=self._rhodecode_user.user_id,
1288 1296 pull_request=pull_request,
1289 1297 f_path=self.request.POST.get('f_path'),
1290 1298 line_no=self.request.POST.get('line'),
1291 1299 status_change=(ChangesetStatus.get_status_lbl(status)
1292 1300 if status and allowed_to_change_status else None),
1293 1301 status_change_type=(status
1294 1302 if status and allowed_to_change_status else None),
1295 1303 comment_type=comment_type,
1296 1304 resolves_comment_id=resolves_comment_id,
1297 1305 auth_user=self._rhodecode_user
1298 1306 )
1299 1307
1300 1308 if allowed_to_change_status:
1301 1309 # calculate old status before we change it
1302 1310 old_calculated_status = pull_request.calculated_review_status()
1303 1311
1304 1312 # get status if set !
1305 1313 if status:
1306 1314 ChangesetStatusModel().set_status(
1307 1315 self.db_repo.repo_id,
1308 1316 status,
1309 1317 self._rhodecode_user.user_id,
1310 1318 comment,
1311 1319 pull_request=pull_request
1312 1320 )
1313 1321
1314 1322 Session().flush()
1315 1323 # this is somehow required to get access to some relationship
1316 1324 # loaded on comment
1317 1325 Session().refresh(comment)
1318 1326
1319 1327 events.trigger(
1320 1328 events.PullRequestCommentEvent(pull_request, comment))
1321 1329
1322 1330 # we now calculate the status of pull request, and based on that
1323 1331 # calculation we set the commits status
1324 1332 calculated_status = pull_request.calculated_review_status()
1325 1333 if old_calculated_status != calculated_status:
1326 1334 PullRequestModel()._trigger_pull_request_hook(
1327 1335 pull_request, self._rhodecode_user, 'review_status_change')
1328 1336
1329 1337 Session().commit()
1330 1338
1331 1339 data = {
1332 1340 'target_id': h.safeid(h.safe_unicode(
1333 1341 self.request.POST.get('f_path'))),
1334 1342 }
1335 1343 if comment:
1336 1344 c.co = comment
1337 1345 rendered_comment = render(
1338 1346 'rhodecode:templates/changeset/changeset_comment_block.mako',
1339 1347 self._get_template_context(c), self.request)
1340 1348
1341 1349 data.update(comment.get_dict())
1342 1350 data.update({'rendered_text': rendered_comment})
1343 1351
1344 1352 return data
1345 1353
1346 1354 @LoginRequired()
1347 1355 @NotAnonymous()
1348 1356 @HasRepoPermissionAnyDecorator(
1349 1357 'repository.read', 'repository.write', 'repository.admin')
1350 1358 @CSRFRequired()
1351 1359 @view_config(
1352 1360 route_name='pullrequest_comment_delete', request_method='POST',
1353 1361 renderer='json_ext')
1354 1362 def pull_request_comment_delete(self):
1355 1363 pull_request = PullRequest.get_or_404(
1356 1364 self.request.matchdict['pull_request_id'])
1357 1365
1358 1366 comment = ChangesetComment.get_or_404(
1359 1367 self.request.matchdict['comment_id'])
1360 1368 comment_id = comment.comment_id
1361 1369
1362 1370 if pull_request.is_closed():
1363 1371 log.debug('comment: forbidden because pull request is closed')
1364 1372 raise HTTPForbidden()
1365 1373
1366 1374 if not comment:
1367 1375 log.debug('Comment with id:%s not found, skipping', comment_id)
1368 1376 # comment already deleted in another call probably
1369 1377 return True
1370 1378
1371 1379 if comment.pull_request.is_closed():
1372 1380 # don't allow deleting comments on closed pull request
1373 1381 raise HTTPForbidden()
1374 1382
1375 1383 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name)
1376 1384 super_admin = h.HasPermissionAny('hg.admin')()
1377 1385 comment_owner = comment.author.user_id == self._rhodecode_user.user_id
1378 1386 is_repo_comment = comment.repo.repo_name == self.db_repo_name
1379 1387 comment_repo_admin = is_repo_admin and is_repo_comment
1380 1388
1381 1389 if super_admin or comment_owner or comment_repo_admin:
1382 1390 old_calculated_status = comment.pull_request.calculated_review_status()
1383 1391 CommentsModel().delete(comment=comment, auth_user=self._rhodecode_user)
1384 1392 Session().commit()
1385 1393 calculated_status = comment.pull_request.calculated_review_status()
1386 1394 if old_calculated_status != calculated_status:
1387 1395 PullRequestModel()._trigger_pull_request_hook(
1388 1396 comment.pull_request, self._rhodecode_user, 'review_status_change')
1389 1397 return True
1390 1398 else:
1391 1399 log.warning('No permissions for user %s to delete comment_id: %s',
1392 1400 self._rhodecode_db_user, comment_id)
1393 1401 raise HTTPNotFound()
@@ -1,1228 +1,1237 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2018 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 diffing helpers, previously part of vcs
24 24 """
25 25
26 26 import os
27 27 import re
28 28 import bz2
29 29
30 30 import collections
31 31 import difflib
32 32 import logging
33 33 import cPickle as pickle
34 34 from itertools import tee, imap
35 35
36 36 from rhodecode.lib.vcs.exceptions import VCSError
37 37 from rhodecode.lib.vcs.nodes import FileNode, SubModuleNode
38 38 from rhodecode.lib.utils2 import safe_unicode, safe_str
39 39
40 40 log = logging.getLogger(__name__)
41 41
42 42 # define max context, a file with more than this numbers of lines is unusable
43 43 # in browser anyway
44 MAX_CONTEXT = 1024 * 1014
44 MAX_CONTEXT = 20 * 1024
45 DEFAULT_CONTEXT = 3
46
47
48 def get_diff_context(request):
49 return MAX_CONTEXT if request.GET.get('fullcontext', '') == '1' else DEFAULT_CONTEXT
50
51
52 def get_diff_whitespace_flag(request):
53 return request.GET.get('ignorews', '') == '1'
45 54
46 55
47 56 class OPS(object):
48 57 ADD = 'A'
49 58 MOD = 'M'
50 59 DEL = 'D'
51 60
52 61
53 62 def get_gitdiff(filenode_old, filenode_new, ignore_whitespace=True, context=3):
54 63 """
55 64 Returns git style diff between given ``filenode_old`` and ``filenode_new``.
56 65
57 66 :param ignore_whitespace: ignore whitespaces in diff
58 67 """
59 68 # make sure we pass in default context
60 69 context = context or 3
61 70 # protect against IntOverflow when passing HUGE context
62 71 if context > MAX_CONTEXT:
63 72 context = MAX_CONTEXT
64 73
65 74 submodules = filter(lambda o: isinstance(o, SubModuleNode),
66 75 [filenode_new, filenode_old])
67 76 if submodules:
68 77 return ''
69 78
70 79 for filenode in (filenode_old, filenode_new):
71 80 if not isinstance(filenode, FileNode):
72 81 raise VCSError(
73 82 "Given object should be FileNode object, not %s"
74 83 % filenode.__class__)
75 84
76 85 repo = filenode_new.commit.repository
77 86 old_commit = filenode_old.commit or repo.EMPTY_COMMIT
78 87 new_commit = filenode_new.commit
79 88
80 89 vcs_gitdiff = repo.get_diff(
81 90 old_commit, new_commit, filenode_new.path,
82 91 ignore_whitespace, context, path1=filenode_old.path)
83 92 return vcs_gitdiff
84 93
85 94 NEW_FILENODE = 1
86 95 DEL_FILENODE = 2
87 96 MOD_FILENODE = 3
88 97 RENAMED_FILENODE = 4
89 98 COPIED_FILENODE = 5
90 99 CHMOD_FILENODE = 6
91 100 BIN_FILENODE = 7
92 101
93 102
94 103 class LimitedDiffContainer(object):
95 104
96 105 def __init__(self, diff_limit, cur_diff_size, diff):
97 106 self.diff = diff
98 107 self.diff_limit = diff_limit
99 108 self.cur_diff_size = cur_diff_size
100 109
101 110 def __getitem__(self, key):
102 111 return self.diff.__getitem__(key)
103 112
104 113 def __iter__(self):
105 114 for l in self.diff:
106 115 yield l
107 116
108 117
109 118 class Action(object):
110 119 """
111 120 Contains constants for the action value of the lines in a parsed diff.
112 121 """
113 122
114 123 ADD = 'add'
115 124 DELETE = 'del'
116 125 UNMODIFIED = 'unmod'
117 126
118 127 CONTEXT = 'context'
119 128 OLD_NO_NL = 'old-no-nl'
120 129 NEW_NO_NL = 'new-no-nl'
121 130
122 131
123 132 class DiffProcessor(object):
124 133 """
125 134 Give it a unified or git diff and it returns a list of the files that were
126 135 mentioned in the diff together with a dict of meta information that
127 136 can be used to render it in a HTML template.
128 137
129 138 .. note:: Unicode handling
130 139
131 140 The original diffs are a byte sequence and can contain filenames
132 141 in mixed encodings. This class generally returns `unicode` objects
133 142 since the result is intended for presentation to the user.
134 143
135 144 """
136 145 _chunk_re = re.compile(r'^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)')
137 146 _newline_marker = re.compile(r'^\\ No newline at end of file')
138 147
139 148 # used for inline highlighter word split
140 149 _token_re = re.compile(r'()(&gt;|&lt;|&amp;|\W+?)')
141 150
142 151 # collapse ranges of commits over given number
143 152 _collapse_commits_over = 5
144 153
145 154 def __init__(self, diff, format='gitdiff', diff_limit=None,
146 155 file_limit=None, show_full_diff=True):
147 156 """
148 157 :param diff: A `Diff` object representing a diff from a vcs backend
149 158 :param format: format of diff passed, `udiff` or `gitdiff`
150 159 :param diff_limit: define the size of diff that is considered "big"
151 160 based on that parameter cut off will be triggered, set to None
152 161 to show full diff
153 162 """
154 163 self._diff = diff
155 164 self._format = format
156 165 self.adds = 0
157 166 self.removes = 0
158 167 # calculate diff size
159 168 self.diff_limit = diff_limit
160 169 self.file_limit = file_limit
161 170 self.show_full_diff = show_full_diff
162 171 self.cur_diff_size = 0
163 172 self.parsed = False
164 173 self.parsed_diff = []
165 174
166 175 log.debug('Initialized DiffProcessor with %s mode', format)
167 176 if format == 'gitdiff':
168 177 self.differ = self._highlight_line_difflib
169 178 self._parser = self._parse_gitdiff
170 179 else:
171 180 self.differ = self._highlight_line_udiff
172 181 self._parser = self._new_parse_gitdiff
173 182
174 183 def _copy_iterator(self):
175 184 """
176 185 make a fresh copy of generator, we should not iterate thru
177 186 an original as it's needed for repeating operations on
178 187 this instance of DiffProcessor
179 188 """
180 189 self.__udiff, iterator_copy = tee(self.__udiff)
181 190 return iterator_copy
182 191
183 192 def _escaper(self, string):
184 193 """
185 194 Escaper for diff escapes special chars and checks the diff limit
186 195
187 196 :param string:
188 197 """
189 198 self.cur_diff_size += len(string)
190 199
191 200 if not self.show_full_diff and (self.cur_diff_size > self.diff_limit):
192 201 raise DiffLimitExceeded('Diff Limit Exceeded')
193 202
194 203 return string \
195 204 .replace('&', '&amp;')\
196 205 .replace('<', '&lt;')\
197 206 .replace('>', '&gt;')
198 207
199 208 def _line_counter(self, l):
200 209 """
201 210 Checks each line and bumps total adds/removes for this diff
202 211
203 212 :param l:
204 213 """
205 214 if l.startswith('+') and not l.startswith('+++'):
206 215 self.adds += 1
207 216 elif l.startswith('-') and not l.startswith('---'):
208 217 self.removes += 1
209 218 return safe_unicode(l)
210 219
211 220 def _highlight_line_difflib(self, line, next_):
212 221 """
213 222 Highlight inline changes in both lines.
214 223 """
215 224
216 225 if line['action'] == Action.DELETE:
217 226 old, new = line, next_
218 227 else:
219 228 old, new = next_, line
220 229
221 230 oldwords = self._token_re.split(old['line'])
222 231 newwords = self._token_re.split(new['line'])
223 232 sequence = difflib.SequenceMatcher(None, oldwords, newwords)
224 233
225 234 oldfragments, newfragments = [], []
226 235 for tag, i1, i2, j1, j2 in sequence.get_opcodes():
227 236 oldfrag = ''.join(oldwords[i1:i2])
228 237 newfrag = ''.join(newwords[j1:j2])
229 238 if tag != 'equal':
230 239 if oldfrag:
231 240 oldfrag = '<del>%s</del>' % oldfrag
232 241 if newfrag:
233 242 newfrag = '<ins>%s</ins>' % newfrag
234 243 oldfragments.append(oldfrag)
235 244 newfragments.append(newfrag)
236 245
237 246 old['line'] = "".join(oldfragments)
238 247 new['line'] = "".join(newfragments)
239 248
240 249 def _highlight_line_udiff(self, line, next_):
241 250 """
242 251 Highlight inline changes in both lines.
243 252 """
244 253 start = 0
245 254 limit = min(len(line['line']), len(next_['line']))
246 255 while start < limit and line['line'][start] == next_['line'][start]:
247 256 start += 1
248 257 end = -1
249 258 limit -= start
250 259 while -end <= limit and line['line'][end] == next_['line'][end]:
251 260 end -= 1
252 261 end += 1
253 262 if start or end:
254 263 def do(l):
255 264 last = end + len(l['line'])
256 265 if l['action'] == Action.ADD:
257 266 tag = 'ins'
258 267 else:
259 268 tag = 'del'
260 269 l['line'] = '%s<%s>%s</%s>%s' % (
261 270 l['line'][:start],
262 271 tag,
263 272 l['line'][start:last],
264 273 tag,
265 274 l['line'][last:]
266 275 )
267 276 do(line)
268 277 do(next_)
269 278
270 279 def _clean_line(self, line, command):
271 280 if command in ['+', '-', ' ']:
272 281 # only modify the line if it's actually a diff thing
273 282 line = line[1:]
274 283 return line
275 284
276 285 def _parse_gitdiff(self, inline_diff=True):
277 286 _files = []
278 287 diff_container = lambda arg: arg
279 288
280 289 for chunk in self._diff.chunks():
281 290 head = chunk.header
282 291
283 292 diff = imap(self._escaper, self.diff_splitter(chunk.diff))
284 293 raw_diff = chunk.raw
285 294 limited_diff = False
286 295 exceeds_limit = False
287 296
288 297 op = None
289 298 stats = {
290 299 'added': 0,
291 300 'deleted': 0,
292 301 'binary': False,
293 302 'ops': {},
294 303 }
295 304
296 305 if head['deleted_file_mode']:
297 306 op = OPS.DEL
298 307 stats['binary'] = True
299 308 stats['ops'][DEL_FILENODE] = 'deleted file'
300 309
301 310 elif head['new_file_mode']:
302 311 op = OPS.ADD
303 312 stats['binary'] = True
304 313 stats['ops'][NEW_FILENODE] = 'new file %s' % head['new_file_mode']
305 314 else: # modify operation, can be copy, rename or chmod
306 315
307 316 # CHMOD
308 317 if head['new_mode'] and head['old_mode']:
309 318 op = OPS.MOD
310 319 stats['binary'] = True
311 320 stats['ops'][CHMOD_FILENODE] = (
312 321 'modified file chmod %s => %s' % (
313 322 head['old_mode'], head['new_mode']))
314 323 # RENAME
315 324 if head['rename_from'] != head['rename_to']:
316 325 op = OPS.MOD
317 326 stats['binary'] = True
318 327 stats['ops'][RENAMED_FILENODE] = (
319 328 'file renamed from %s to %s' % (
320 329 head['rename_from'], head['rename_to']))
321 330 # COPY
322 331 if head.get('copy_from') and head.get('copy_to'):
323 332 op = OPS.MOD
324 333 stats['binary'] = True
325 334 stats['ops'][COPIED_FILENODE] = (
326 335 'file copied from %s to %s' % (
327 336 head['copy_from'], head['copy_to']))
328 337
329 338 # If our new parsed headers didn't match anything fallback to
330 339 # old style detection
331 340 if op is None:
332 341 if not head['a_file'] and head['b_file']:
333 342 op = OPS.ADD
334 343 stats['binary'] = True
335 344 stats['ops'][NEW_FILENODE] = 'new file'
336 345
337 346 elif head['a_file'] and not head['b_file']:
338 347 op = OPS.DEL
339 348 stats['binary'] = True
340 349 stats['ops'][DEL_FILENODE] = 'deleted file'
341 350
342 351 # it's not ADD not DELETE
343 352 if op is None:
344 353 op = OPS.MOD
345 354 stats['binary'] = True
346 355 stats['ops'][MOD_FILENODE] = 'modified file'
347 356
348 357 # a real non-binary diff
349 358 if head['a_file'] or head['b_file']:
350 359 try:
351 360 raw_diff, chunks, _stats = self._parse_lines(diff)
352 361 stats['binary'] = False
353 362 stats['added'] = _stats[0]
354 363 stats['deleted'] = _stats[1]
355 364 # explicit mark that it's a modified file
356 365 if op == OPS.MOD:
357 366 stats['ops'][MOD_FILENODE] = 'modified file'
358 367 exceeds_limit = len(raw_diff) > self.file_limit
359 368
360 369 # changed from _escaper function so we validate size of
361 370 # each file instead of the whole diff
362 371 # diff will hide big files but still show small ones
363 372 # from my tests, big files are fairly safe to be parsed
364 373 # but the browser is the bottleneck
365 374 if not self.show_full_diff and exceeds_limit:
366 375 raise DiffLimitExceeded('File Limit Exceeded')
367 376
368 377 except DiffLimitExceeded:
369 378 diff_container = lambda _diff: \
370 379 LimitedDiffContainer(
371 380 self.diff_limit, self.cur_diff_size, _diff)
372 381
373 382 exceeds_limit = len(raw_diff) > self.file_limit
374 383 limited_diff = True
375 384 chunks = []
376 385
377 386 else: # GIT format binary patch, or possibly empty diff
378 387 if head['bin_patch']:
379 388 # we have operation already extracted, but we mark simply
380 389 # it's a diff we wont show for binary files
381 390 stats['ops'][BIN_FILENODE] = 'binary diff hidden'
382 391 chunks = []
383 392
384 393 if chunks and not self.show_full_diff and op == OPS.DEL:
385 394 # if not full diff mode show deleted file contents
386 395 # TODO: anderson: if the view is not too big, there is no way
387 396 # to see the content of the file
388 397 chunks = []
389 398
390 399 chunks.insert(0, [{
391 400 'old_lineno': '',
392 401 'new_lineno': '',
393 402 'action': Action.CONTEXT,
394 403 'line': msg,
395 404 } for _op, msg in stats['ops'].iteritems()
396 405 if _op not in [MOD_FILENODE]])
397 406
398 407 _files.append({
399 408 'filename': safe_unicode(head['b_path']),
400 409 'old_revision': head['a_blob_id'],
401 410 'new_revision': head['b_blob_id'],
402 411 'chunks': chunks,
403 412 'raw_diff': safe_unicode(raw_diff),
404 413 'operation': op,
405 414 'stats': stats,
406 415 'exceeds_limit': exceeds_limit,
407 416 'is_limited_diff': limited_diff,
408 417 })
409 418
410 419 sorter = lambda info: {OPS.ADD: 0, OPS.MOD: 1,
411 420 OPS.DEL: 2}.get(info['operation'])
412 421
413 422 if not inline_diff:
414 423 return diff_container(sorted(_files, key=sorter))
415 424
416 425 # highlight inline changes
417 426 for diff_data in _files:
418 427 for chunk in diff_data['chunks']:
419 428 lineiter = iter(chunk)
420 429 try:
421 430 while 1:
422 431 line = lineiter.next()
423 432 if line['action'] not in (
424 433 Action.UNMODIFIED, Action.CONTEXT):
425 434 nextline = lineiter.next()
426 435 if nextline['action'] in ['unmod', 'context'] or \
427 436 nextline['action'] == line['action']:
428 437 continue
429 438 self.differ(line, nextline)
430 439 except StopIteration:
431 440 pass
432 441
433 442 return diff_container(sorted(_files, key=sorter))
434 443
435 444 def _check_large_diff(self):
436 445 log.debug('Diff exceeds current diff_limit of %s', self.diff_limit)
437 446 if not self.show_full_diff and (self.cur_diff_size > self.diff_limit):
438 447 raise DiffLimitExceeded('Diff Limit `%s` Exceeded', self.diff_limit)
439 448
440 449 # FIXME: NEWDIFFS: dan: this replaces _parse_gitdiff
441 450 def _new_parse_gitdiff(self, inline_diff=True):
442 451 _files = []
443 452
444 453 # this can be overriden later to a LimitedDiffContainer type
445 454 diff_container = lambda arg: arg
446 455
447 456 for chunk in self._diff.chunks():
448 457 head = chunk.header
449 458 log.debug('parsing diff %r', head)
450 459
451 460 raw_diff = chunk.raw
452 461 limited_diff = False
453 462 exceeds_limit = False
454 463
455 464 op = None
456 465 stats = {
457 466 'added': 0,
458 467 'deleted': 0,
459 468 'binary': False,
460 469 'old_mode': None,
461 470 'new_mode': None,
462 471 'ops': {},
463 472 }
464 473 if head['old_mode']:
465 474 stats['old_mode'] = head['old_mode']
466 475 if head['new_mode']:
467 476 stats['new_mode'] = head['new_mode']
468 477 if head['b_mode']:
469 478 stats['new_mode'] = head['b_mode']
470 479
471 480 # delete file
472 481 if head['deleted_file_mode']:
473 482 op = OPS.DEL
474 483 stats['binary'] = True
475 484 stats['ops'][DEL_FILENODE] = 'deleted file'
476 485
477 486 # new file
478 487 elif head['new_file_mode']:
479 488 op = OPS.ADD
480 489 stats['binary'] = True
481 490 stats['old_mode'] = None
482 491 stats['new_mode'] = head['new_file_mode']
483 492 stats['ops'][NEW_FILENODE] = 'new file %s' % head['new_file_mode']
484 493
485 494 # modify operation, can be copy, rename or chmod
486 495 else:
487 496 # CHMOD
488 497 if head['new_mode'] and head['old_mode']:
489 498 op = OPS.MOD
490 499 stats['binary'] = True
491 500 stats['ops'][CHMOD_FILENODE] = (
492 501 'modified file chmod %s => %s' % (
493 502 head['old_mode'], head['new_mode']))
494 503
495 504 # RENAME
496 505 if head['rename_from'] != head['rename_to']:
497 506 op = OPS.MOD
498 507 stats['binary'] = True
499 508 stats['renamed'] = (head['rename_from'], head['rename_to'])
500 509 stats['ops'][RENAMED_FILENODE] = (
501 510 'file renamed from %s to %s' % (
502 511 head['rename_from'], head['rename_to']))
503 512 # COPY
504 513 if head.get('copy_from') and head.get('copy_to'):
505 514 op = OPS.MOD
506 515 stats['binary'] = True
507 516 stats['copied'] = (head['copy_from'], head['copy_to'])
508 517 stats['ops'][COPIED_FILENODE] = (
509 518 'file copied from %s to %s' % (
510 519 head['copy_from'], head['copy_to']))
511 520
512 521 # If our new parsed headers didn't match anything fallback to
513 522 # old style detection
514 523 if op is None:
515 524 if not head['a_file'] and head['b_file']:
516 525 op = OPS.ADD
517 526 stats['binary'] = True
518 527 stats['new_file'] = True
519 528 stats['ops'][NEW_FILENODE] = 'new file'
520 529
521 530 elif head['a_file'] and not head['b_file']:
522 531 op = OPS.DEL
523 532 stats['binary'] = True
524 533 stats['ops'][DEL_FILENODE] = 'deleted file'
525 534
526 535 # it's not ADD not DELETE
527 536 if op is None:
528 537 op = OPS.MOD
529 538 stats['binary'] = True
530 539 stats['ops'][MOD_FILENODE] = 'modified file'
531 540
532 541 # a real non-binary diff
533 542 if head['a_file'] or head['b_file']:
534 543 # simulate splitlines, so we keep the line end part
535 544 diff = self.diff_splitter(chunk.diff)
536 545
537 546 # append each file to the diff size
538 547 raw_chunk_size = len(raw_diff)
539 548
540 549 exceeds_limit = raw_chunk_size > self.file_limit
541 550 self.cur_diff_size += raw_chunk_size
542 551
543 552 try:
544 553 # Check each file instead of the whole diff.
545 554 # Diff will hide big files but still show small ones.
546 555 # From the tests big files are fairly safe to be parsed
547 556 # but the browser is the bottleneck.
548 557 if not self.show_full_diff and exceeds_limit:
549 558 log.debug('File `%s` exceeds current file_limit of %s',
550 559 safe_unicode(head['b_path']), self.file_limit)
551 560 raise DiffLimitExceeded(
552 561 'File Limit %s Exceeded', self.file_limit)
553 562
554 563 self._check_large_diff()
555 564
556 565 raw_diff, chunks, _stats = self._new_parse_lines(diff)
557 566 stats['binary'] = False
558 567 stats['added'] = _stats[0]
559 568 stats['deleted'] = _stats[1]
560 569 # explicit mark that it's a modified file
561 570 if op == OPS.MOD:
562 571 stats['ops'][MOD_FILENODE] = 'modified file'
563 572
564 573 except DiffLimitExceeded:
565 574 diff_container = lambda _diff: \
566 575 LimitedDiffContainer(
567 576 self.diff_limit, self.cur_diff_size, _diff)
568 577
569 578 limited_diff = True
570 579 chunks = []
571 580
572 581 else: # GIT format binary patch, or possibly empty diff
573 582 if head['bin_patch']:
574 583 # we have operation already extracted, but we mark simply
575 584 # it's a diff we wont show for binary files
576 585 stats['ops'][BIN_FILENODE] = 'binary diff hidden'
577 586 chunks = []
578 587
579 588 # Hide content of deleted node by setting empty chunks
580 589 if chunks and not self.show_full_diff and op == OPS.DEL:
581 590 # if not full diff mode show deleted file contents
582 591 # TODO: anderson: if the view is not too big, there is no way
583 592 # to see the content of the file
584 593 chunks = []
585 594
586 595 chunks.insert(
587 596 0, [{'old_lineno': '',
588 597 'new_lineno': '',
589 598 'action': Action.CONTEXT,
590 599 'line': msg,
591 600 } for _op, msg in stats['ops'].iteritems()
592 601 if _op not in [MOD_FILENODE]])
593 602
594 603 original_filename = safe_unicode(head['a_path'])
595 604 _files.append({
596 605 'original_filename': original_filename,
597 606 'filename': safe_unicode(head['b_path']),
598 607 'old_revision': head['a_blob_id'],
599 608 'new_revision': head['b_blob_id'],
600 609 'chunks': chunks,
601 610 'raw_diff': safe_unicode(raw_diff),
602 611 'operation': op,
603 612 'stats': stats,
604 613 'exceeds_limit': exceeds_limit,
605 614 'is_limited_diff': limited_diff,
606 615 })
607 616
608 617 sorter = lambda info: {OPS.ADD: 0, OPS.MOD: 1,
609 618 OPS.DEL: 2}.get(info['operation'])
610 619
611 620 return diff_container(sorted(_files, key=sorter))
612 621
613 622 # FIXME: NEWDIFFS: dan: this gets replaced by _new_parse_lines
614 623 def _parse_lines(self, diff_iter):
615 624 """
616 625 Parse the diff an return data for the template.
617 626 """
618 627
619 628 stats = [0, 0]
620 629 chunks = []
621 630 raw_diff = []
622 631
623 632 try:
624 633 line = diff_iter.next()
625 634
626 635 while line:
627 636 raw_diff.append(line)
628 637 lines = []
629 638 chunks.append(lines)
630 639
631 640 match = self._chunk_re.match(line)
632 641
633 642 if not match:
634 643 break
635 644
636 645 gr = match.groups()
637 646 (old_line, old_end,
638 647 new_line, new_end) = [int(x or 1) for x in gr[:-1]]
639 648 old_line -= 1
640 649 new_line -= 1
641 650
642 651 context = len(gr) == 5
643 652 old_end += old_line
644 653 new_end += new_line
645 654
646 655 if context:
647 656 # skip context only if it's first line
648 657 if int(gr[0]) > 1:
649 658 lines.append({
650 659 'old_lineno': '...',
651 660 'new_lineno': '...',
652 661 'action': Action.CONTEXT,
653 662 'line': line,
654 663 })
655 664
656 665 line = diff_iter.next()
657 666
658 667 while old_line < old_end or new_line < new_end:
659 668 command = ' '
660 669 if line:
661 670 command = line[0]
662 671
663 672 affects_old = affects_new = False
664 673
665 674 # ignore those if we don't expect them
666 675 if command in '#@':
667 676 continue
668 677 elif command == '+':
669 678 affects_new = True
670 679 action = Action.ADD
671 680 stats[0] += 1
672 681 elif command == '-':
673 682 affects_old = True
674 683 action = Action.DELETE
675 684 stats[1] += 1
676 685 else:
677 686 affects_old = affects_new = True
678 687 action = Action.UNMODIFIED
679 688
680 689 if not self._newline_marker.match(line):
681 690 old_line += affects_old
682 691 new_line += affects_new
683 692 lines.append({
684 693 'old_lineno': affects_old and old_line or '',
685 694 'new_lineno': affects_new and new_line or '',
686 695 'action': action,
687 696 'line': self._clean_line(line, command)
688 697 })
689 698 raw_diff.append(line)
690 699
691 700 line = diff_iter.next()
692 701
693 702 if self._newline_marker.match(line):
694 703 # we need to append to lines, since this is not
695 704 # counted in the line specs of diff
696 705 lines.append({
697 706 'old_lineno': '...',
698 707 'new_lineno': '...',
699 708 'action': Action.CONTEXT,
700 709 'line': self._clean_line(line, command)
701 710 })
702 711
703 712 except StopIteration:
704 713 pass
705 714 return ''.join(raw_diff), chunks, stats
706 715
707 716 # FIXME: NEWDIFFS: dan: this replaces _parse_lines
708 717 def _new_parse_lines(self, diff_iter):
709 718 """
710 719 Parse the diff an return data for the template.
711 720 """
712 721
713 722 stats = [0, 0]
714 723 chunks = []
715 724 raw_diff = []
716 725
717 726 try:
718 727 line = diff_iter.next()
719 728
720 729 while line:
721 730 raw_diff.append(line)
722 731 # match header e.g @@ -0,0 +1 @@\n'
723 732 match = self._chunk_re.match(line)
724 733
725 734 if not match:
726 735 break
727 736
728 737 gr = match.groups()
729 738 (old_line, old_end,
730 739 new_line, new_end) = [int(x or 1) for x in gr[:-1]]
731 740
732 741 lines = []
733 742 hunk = {
734 743 'section_header': gr[-1],
735 744 'source_start': old_line,
736 745 'source_length': old_end,
737 746 'target_start': new_line,
738 747 'target_length': new_end,
739 748 'lines': lines,
740 749 }
741 750 chunks.append(hunk)
742 751
743 752 old_line -= 1
744 753 new_line -= 1
745 754
746 755 context = len(gr) == 5
747 756 old_end += old_line
748 757 new_end += new_line
749 758
750 759 line = diff_iter.next()
751 760
752 761 while old_line < old_end or new_line < new_end:
753 762 command = ' '
754 763 if line:
755 764 command = line[0]
756 765
757 766 affects_old = affects_new = False
758 767
759 768 # ignore those if we don't expect them
760 769 if command in '#@':
761 770 continue
762 771 elif command == '+':
763 772 affects_new = True
764 773 action = Action.ADD
765 774 stats[0] += 1
766 775 elif command == '-':
767 776 affects_old = True
768 777 action = Action.DELETE
769 778 stats[1] += 1
770 779 else:
771 780 affects_old = affects_new = True
772 781 action = Action.UNMODIFIED
773 782
774 783 if not self._newline_marker.match(line):
775 784 old_line += affects_old
776 785 new_line += affects_new
777 786 lines.append({
778 787 'old_lineno': affects_old and old_line or '',
779 788 'new_lineno': affects_new and new_line or '',
780 789 'action': action,
781 790 'line': self._clean_line(line, command)
782 791 })
783 792 raw_diff.append(line)
784 793
785 794 line = diff_iter.next()
786 795
787 796 if self._newline_marker.match(line):
788 797 # we need to append to lines, since this is not
789 798 # counted in the line specs of diff
790 799 if affects_old:
791 800 action = Action.OLD_NO_NL
792 801 elif affects_new:
793 802 action = Action.NEW_NO_NL
794 803 else:
795 804 raise Exception('invalid context for no newline')
796 805
797 806 lines.append({
798 807 'old_lineno': None,
799 808 'new_lineno': None,
800 809 'action': action,
801 810 'line': self._clean_line(line, command)
802 811 })
803 812
804 813 except StopIteration:
805 814 pass
806 815
807 816 return ''.join(raw_diff), chunks, stats
808 817
809 818 def _safe_id(self, idstring):
810 819 """Make a string safe for including in an id attribute.
811 820
812 821 The HTML spec says that id attributes 'must begin with
813 822 a letter ([A-Za-z]) and may be followed by any number
814 823 of letters, digits ([0-9]), hyphens ("-"), underscores
815 824 ("_"), colons (":"), and periods (".")'. These regexps
816 825 are slightly over-zealous, in that they remove colons
817 826 and periods unnecessarily.
818 827
819 828 Whitespace is transformed into underscores, and then
820 829 anything which is not a hyphen or a character that
821 830 matches \w (alphanumerics and underscore) is removed.
822 831
823 832 """
824 833 # Transform all whitespace to underscore
825 834 idstring = re.sub(r'\s', "_", '%s' % idstring)
826 835 # Remove everything that is not a hyphen or a member of \w
827 836 idstring = re.sub(r'(?!-)\W', "", idstring).lower()
828 837 return idstring
829 838
830 839 @classmethod
831 840 def diff_splitter(cls, string):
832 841 """
833 842 Diff split that emulates .splitlines() but works only on \n
834 843 """
835 844 if not string:
836 845 return
837 846 elif string == '\n':
838 847 yield u'\n'
839 848 else:
840 849
841 850 has_newline = string.endswith('\n')
842 851 elements = string.split('\n')
843 852 if has_newline:
844 853 # skip last element as it's empty string from newlines
845 854 elements = elements[:-1]
846 855
847 856 len_elements = len(elements)
848 857
849 858 for cnt, line in enumerate(elements, start=1):
850 859 last_line = cnt == len_elements
851 860 if last_line and not has_newline:
852 861 yield safe_unicode(line)
853 862 else:
854 863 yield safe_unicode(line) + '\n'
855 864
856 865 def prepare(self, inline_diff=True):
857 866 """
858 867 Prepare the passed udiff for HTML rendering.
859 868
860 869 :return: A list of dicts with diff information.
861 870 """
862 871 parsed = self._parser(inline_diff=inline_diff)
863 872 self.parsed = True
864 873 self.parsed_diff = parsed
865 874 return parsed
866 875
867 876 def as_raw(self, diff_lines=None):
868 877 """
869 878 Returns raw diff as a byte string
870 879 """
871 880 return self._diff.raw
872 881
873 882 def as_html(self, table_class='code-difftable', line_class='line',
874 883 old_lineno_class='lineno old', new_lineno_class='lineno new',
875 884 code_class='code', enable_comments=False, parsed_lines=None):
876 885 """
877 886 Return given diff as html table with customized css classes
878 887 """
879 888 # TODO(marcink): not sure how to pass in translator
880 889 # here in an efficient way, leave the _ for proper gettext extraction
881 890 _ = lambda s: s
882 891
883 892 def _link_to_if(condition, label, url):
884 893 """
885 894 Generates a link if condition is meet or just the label if not.
886 895 """
887 896
888 897 if condition:
889 898 return '''<a href="%(url)s" class="tooltip"
890 899 title="%(title)s">%(label)s</a>''' % {
891 900 'title': _('Click to select line'),
892 901 'url': url,
893 902 'label': label
894 903 }
895 904 else:
896 905 return label
897 906 if not self.parsed:
898 907 self.prepare()
899 908
900 909 diff_lines = self.parsed_diff
901 910 if parsed_lines:
902 911 diff_lines = parsed_lines
903 912
904 913 _html_empty = True
905 914 _html = []
906 915 _html.append('''<table class="%(table_class)s">\n''' % {
907 916 'table_class': table_class
908 917 })
909 918
910 919 for diff in diff_lines:
911 920 for line in diff['chunks']:
912 921 _html_empty = False
913 922 for change in line:
914 923 _html.append('''<tr class="%(lc)s %(action)s">\n''' % {
915 924 'lc': line_class,
916 925 'action': change['action']
917 926 })
918 927 anchor_old_id = ''
919 928 anchor_new_id = ''
920 929 anchor_old = "%(filename)s_o%(oldline_no)s" % {
921 930 'filename': self._safe_id(diff['filename']),
922 931 'oldline_no': change['old_lineno']
923 932 }
924 933 anchor_new = "%(filename)s_n%(oldline_no)s" % {
925 934 'filename': self._safe_id(diff['filename']),
926 935 'oldline_no': change['new_lineno']
927 936 }
928 937 cond_old = (change['old_lineno'] != '...' and
929 938 change['old_lineno'])
930 939 cond_new = (change['new_lineno'] != '...' and
931 940 change['new_lineno'])
932 941 if cond_old:
933 942 anchor_old_id = 'id="%s"' % anchor_old
934 943 if cond_new:
935 944 anchor_new_id = 'id="%s"' % anchor_new
936 945
937 946 if change['action'] != Action.CONTEXT:
938 947 anchor_link = True
939 948 else:
940 949 anchor_link = False
941 950
942 951 ###########################################################
943 952 # COMMENT ICONS
944 953 ###########################################################
945 954 _html.append('''\t<td class="add-comment-line"><span class="add-comment-content">''')
946 955
947 956 if enable_comments and change['action'] != Action.CONTEXT:
948 957 _html.append('''<a href="#"><span class="icon-comment-add"></span></a>''')
949 958
950 959 _html.append('''</span></td><td class="comment-toggle tooltip" title="Toggle Comment Thread"><i class="icon-comment"></i></td>\n''')
951 960
952 961 ###########################################################
953 962 # OLD LINE NUMBER
954 963 ###########################################################
955 964 _html.append('''\t<td %(a_id)s class="%(olc)s">''' % {
956 965 'a_id': anchor_old_id,
957 966 'olc': old_lineno_class
958 967 })
959 968
960 969 _html.append('''%(link)s''' % {
961 970 'link': _link_to_if(anchor_link, change['old_lineno'],
962 971 '#%s' % anchor_old)
963 972 })
964 973 _html.append('''</td>\n''')
965 974 ###########################################################
966 975 # NEW LINE NUMBER
967 976 ###########################################################
968 977
969 978 _html.append('''\t<td %(a_id)s class="%(nlc)s">''' % {
970 979 'a_id': anchor_new_id,
971 980 'nlc': new_lineno_class
972 981 })
973 982
974 983 _html.append('''%(link)s''' % {
975 984 'link': _link_to_if(anchor_link, change['new_lineno'],
976 985 '#%s' % anchor_new)
977 986 })
978 987 _html.append('''</td>\n''')
979 988 ###########################################################
980 989 # CODE
981 990 ###########################################################
982 991 code_classes = [code_class]
983 992 if (not enable_comments or
984 993 change['action'] == Action.CONTEXT):
985 994 code_classes.append('no-comment')
986 995 _html.append('\t<td class="%s">' % ' '.join(code_classes))
987 996 _html.append('''\n\t\t<pre>%(code)s</pre>\n''' % {
988 997 'code': change['line']
989 998 })
990 999
991 1000 _html.append('''\t</td>''')
992 1001 _html.append('''\n</tr>\n''')
993 1002 _html.append('''</table>''')
994 1003 if _html_empty:
995 1004 return None
996 1005 return ''.join(_html)
997 1006
998 1007 def stat(self):
999 1008 """
1000 1009 Returns tuple of added, and removed lines for this instance
1001 1010 """
1002 1011 return self.adds, self.removes
1003 1012
1004 1013 def get_context_of_line(
1005 1014 self, path, diff_line=None, context_before=3, context_after=3):
1006 1015 """
1007 1016 Returns the context lines for the specified diff line.
1008 1017
1009 1018 :type diff_line: :class:`DiffLineNumber`
1010 1019 """
1011 1020 assert self.parsed, "DiffProcessor is not initialized."
1012 1021
1013 1022 if None not in diff_line:
1014 1023 raise ValueError(
1015 1024 "Cannot specify both line numbers: {}".format(diff_line))
1016 1025
1017 1026 file_diff = self._get_file_diff(path)
1018 1027 chunk, idx = self._find_chunk_line_index(file_diff, diff_line)
1019 1028
1020 1029 first_line_to_include = max(idx - context_before, 0)
1021 1030 first_line_after_context = idx + context_after + 1
1022 1031 context_lines = chunk[first_line_to_include:first_line_after_context]
1023 1032
1024 1033 line_contents = [
1025 1034 _context_line(line) for line in context_lines
1026 1035 if _is_diff_content(line)]
1027 1036 # TODO: johbo: Interim fixup, the diff chunks drop the final newline.
1028 1037 # Once they are fixed, we can drop this line here.
1029 1038 if line_contents:
1030 1039 line_contents[-1] = (
1031 1040 line_contents[-1][0], line_contents[-1][1].rstrip('\n') + '\n')
1032 1041 return line_contents
1033 1042
1034 1043 def find_context(self, path, context, offset=0):
1035 1044 """
1036 1045 Finds the given `context` inside of the diff.
1037 1046
1038 1047 Use the parameter `offset` to specify which offset the target line has
1039 1048 inside of the given `context`. This way the correct diff line will be
1040 1049 returned.
1041 1050
1042 1051 :param offset: Shall be used to specify the offset of the main line
1043 1052 within the given `context`.
1044 1053 """
1045 1054 if offset < 0 or offset >= len(context):
1046 1055 raise ValueError(
1047 1056 "Only positive values up to the length of the context "
1048 1057 "minus one are allowed.")
1049 1058
1050 1059 matches = []
1051 1060 file_diff = self._get_file_diff(path)
1052 1061
1053 1062 for chunk in file_diff['chunks']:
1054 1063 context_iter = iter(context)
1055 1064 for line_idx, line in enumerate(chunk):
1056 1065 try:
1057 1066 if _context_line(line) == context_iter.next():
1058 1067 continue
1059 1068 except StopIteration:
1060 1069 matches.append((line_idx, chunk))
1061 1070 context_iter = iter(context)
1062 1071
1063 1072 # Increment position and triger StopIteration
1064 1073 # if we had a match at the end
1065 1074 line_idx += 1
1066 1075 try:
1067 1076 context_iter.next()
1068 1077 except StopIteration:
1069 1078 matches.append((line_idx, chunk))
1070 1079
1071 1080 effective_offset = len(context) - offset
1072 1081 found_at_diff_lines = [
1073 1082 _line_to_diff_line_number(chunk[idx - effective_offset])
1074 1083 for idx, chunk in matches]
1075 1084
1076 1085 return found_at_diff_lines
1077 1086
1078 1087 def _get_file_diff(self, path):
1079 1088 for file_diff in self.parsed_diff:
1080 1089 if file_diff['filename'] == path:
1081 1090 break
1082 1091 else:
1083 1092 raise FileNotInDiffException("File {} not in diff".format(path))
1084 1093 return file_diff
1085 1094
1086 1095 def _find_chunk_line_index(self, file_diff, diff_line):
1087 1096 for chunk in file_diff['chunks']:
1088 1097 for idx, line in enumerate(chunk):
1089 1098 if line['old_lineno'] == diff_line.old:
1090 1099 return chunk, idx
1091 1100 if line['new_lineno'] == diff_line.new:
1092 1101 return chunk, idx
1093 1102 raise LineNotInDiffException(
1094 1103 "The line {} is not part of the diff.".format(diff_line))
1095 1104
1096 1105
1097 1106 def _is_diff_content(line):
1098 1107 return line['action'] in (
1099 1108 Action.UNMODIFIED, Action.ADD, Action.DELETE)
1100 1109
1101 1110
1102 1111 def _context_line(line):
1103 1112 return (line['action'], line['line'])
1104 1113
1105 1114
1106 1115 DiffLineNumber = collections.namedtuple('DiffLineNumber', ['old', 'new'])
1107 1116
1108 1117
1109 1118 def _line_to_diff_line_number(line):
1110 1119 new_line_no = line['new_lineno'] or None
1111 1120 old_line_no = line['old_lineno'] or None
1112 1121 return DiffLineNumber(old=old_line_no, new=new_line_no)
1113 1122
1114 1123
1115 1124 class FileNotInDiffException(Exception):
1116 1125 """
1117 1126 Raised when the context for a missing file is requested.
1118 1127
1119 1128 If you request the context for a line in a file which is not part of the
1120 1129 given diff, then this exception is raised.
1121 1130 """
1122 1131
1123 1132
1124 1133 class LineNotInDiffException(Exception):
1125 1134 """
1126 1135 Raised when the context for a missing line is requested.
1127 1136
1128 1137 If you request the context for a line in a file and this line is not
1129 1138 part of the given diff, then this exception is raised.
1130 1139 """
1131 1140
1132 1141
1133 1142 class DiffLimitExceeded(Exception):
1134 1143 pass
1135 1144
1136 1145
1137 1146 # NOTE(marcink): if diffs.mako change, probably this
1138 1147 # needs a bump to next version
1139 1148 CURRENT_DIFF_VERSION = 'v3'
1140 1149
1141 1150
1142 1151 def _cleanup_cache_file(cached_diff_file):
1143 1152 # cleanup file to not store it "damaged"
1144 1153 try:
1145 1154 os.remove(cached_diff_file)
1146 1155 except Exception:
1147 1156 log.exception('Failed to cleanup path %s', cached_diff_file)
1148 1157
1149 1158
1150 1159 def cache_diff(cached_diff_file, diff, commits):
1151 1160
1152 1161 struct = {
1153 1162 'version': CURRENT_DIFF_VERSION,
1154 1163 'diff': diff,
1155 1164 'commits': commits
1156 1165 }
1157 1166
1158 1167 try:
1159 1168 with bz2.BZ2File(cached_diff_file, 'wb') as f:
1160 1169 pickle.dump(struct, f)
1161 1170 log.debug('Saved diff cache under %s', cached_diff_file)
1162 1171 except Exception:
1163 1172 log.warn('Failed to save cache', exc_info=True)
1164 1173 _cleanup_cache_file(cached_diff_file)
1165 1174
1166 1175
1167 1176 def load_cached_diff(cached_diff_file):
1168 1177
1169 1178 default_struct = {
1170 1179 'version': CURRENT_DIFF_VERSION,
1171 1180 'diff': None,
1172 1181 'commits': None
1173 1182 }
1174 1183
1175 1184 has_cache = os.path.isfile(cached_diff_file)
1176 1185 if not has_cache:
1177 1186 return default_struct
1178 1187
1179 1188 data = None
1180 1189 try:
1181 1190 with bz2.BZ2File(cached_diff_file, 'rb') as f:
1182 1191 data = pickle.load(f)
1183 1192 log.debug('Loaded diff cache from %s', cached_diff_file)
1184 1193 except Exception:
1185 1194 log.warn('Failed to read diff cache file', exc_info=True)
1186 1195
1187 1196 if not data:
1188 1197 data = default_struct
1189 1198
1190 1199 if not isinstance(data, dict):
1191 1200 # old version of data ?
1192 1201 data = default_struct
1193 1202
1194 1203 # check version
1195 1204 if data.get('version') != CURRENT_DIFF_VERSION:
1196 1205 # purge cache
1197 1206 _cleanup_cache_file(cached_diff_file)
1198 1207 return default_struct
1199 1208
1200 1209 return data
1201 1210
1202 1211
1203 1212 def generate_diff_cache_key(*args):
1204 1213 """
1205 1214 Helper to generate a cache key using arguments
1206 1215 """
1207 1216 def arg_mapper(input_param):
1208 1217 input_param = safe_str(input_param)
1209 1218 # we cannot allow '/' in arguments since it would allow
1210 1219 # subdirectory usage
1211 1220 input_param.replace('/', '_')
1212 1221 return input_param or None # prevent empty string arguments
1213 1222
1214 1223 return '_'.join([
1215 1224 '{}' for i in range(len(args))]).format(*map(arg_mapper, args))
1216 1225
1217 1226
1218 1227 def diff_cache_exist(cache_storage, *args):
1219 1228 """
1220 1229 Based on all generated arguments check and return a cache path
1221 1230 """
1222 1231 cache_key = generate_diff_cache_key(*args)
1223 1232 cache_file_path = os.path.join(cache_storage, cache_key)
1224 1233 # prevent path traversal attacks using some param that have e.g '../../'
1225 1234 if not os.path.abspath(cache_file_path).startswith(cache_storage):
1226 1235 raise ValueError('Final path must be within {}'.format(cache_storage))
1227 1236
1228 1237 return cache_file_path
@@ -1,1731 +1,1739 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2018 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21
22 22 """
23 23 pull request model for RhodeCode
24 24 """
25 25
26 26
27 27 import json
28 28 import logging
29 29 import datetime
30 30 import urllib
31 31 import collections
32 32
33 33 from pyramid.threadlocal import get_current_request
34 34
35 35 from rhodecode import events
36 36 from rhodecode.translation import lazy_ugettext#, _
37 37 from rhodecode.lib import helpers as h, hooks_utils, diffs
38 38 from rhodecode.lib import audit_logger
39 39 from rhodecode.lib.compat import OrderedDict
40 40 from rhodecode.lib.hooks_daemon import prepare_callback_daemon
41 41 from rhodecode.lib.markup_renderer import (
42 42 DEFAULT_COMMENTS_RENDERER, RstTemplateRenderer)
43 43 from rhodecode.lib.utils2 import safe_unicode, safe_str, md5_safe
44 44 from rhodecode.lib.vcs.backends.base import (
45 45 Reference, MergeResponse, MergeFailureReason, UpdateFailureReason)
46 46 from rhodecode.lib.vcs.conf import settings as vcs_settings
47 47 from rhodecode.lib.vcs.exceptions import (
48 48 CommitDoesNotExistError, EmptyRepositoryError)
49 49 from rhodecode.model import BaseModel
50 50 from rhodecode.model.changeset_status import ChangesetStatusModel
51 51 from rhodecode.model.comment import CommentsModel
52 52 from rhodecode.model.db import (
53 53 or_, PullRequest, PullRequestReviewers, ChangesetStatus,
54 54 PullRequestVersion, ChangesetComment, Repository, RepoReviewRule)
55 55 from rhodecode.model.meta import Session
56 56 from rhodecode.model.notification import NotificationModel, \
57 57 EmailNotificationModel
58 58 from rhodecode.model.scm import ScmModel
59 59 from rhodecode.model.settings import VcsSettingsModel
60 60
61 61
62 62 log = logging.getLogger(__name__)
63 63
64 64
65 65 # Data structure to hold the response data when updating commits during a pull
66 66 # request update.
67 67 UpdateResponse = collections.namedtuple('UpdateResponse', [
68 68 'executed', 'reason', 'new', 'old', 'changes',
69 69 'source_changed', 'target_changed'])
70 70
71 71
72 72 class PullRequestModel(BaseModel):
73 73
74 74 cls = PullRequest
75 75
76 DIFF_CONTEXT = 3
76 DIFF_CONTEXT = diffs.DEFAULT_CONTEXT
77 77
78 78 MERGE_STATUS_MESSAGES = {
79 79 MergeFailureReason.NONE: lazy_ugettext(
80 80 'This pull request can be automatically merged.'),
81 81 MergeFailureReason.UNKNOWN: lazy_ugettext(
82 82 'This pull request cannot be merged because of an unhandled'
83 83 ' exception.'),
84 84 MergeFailureReason.MERGE_FAILED: lazy_ugettext(
85 85 'This pull request cannot be merged because of merge conflicts.'),
86 86 MergeFailureReason.PUSH_FAILED: lazy_ugettext(
87 87 'This pull request could not be merged because push to target'
88 88 ' failed.'),
89 89 MergeFailureReason.TARGET_IS_NOT_HEAD: lazy_ugettext(
90 90 'This pull request cannot be merged because the target is not a'
91 91 ' head.'),
92 92 MergeFailureReason.HG_SOURCE_HAS_MORE_BRANCHES: lazy_ugettext(
93 93 'This pull request cannot be merged because the source contains'
94 94 ' more branches than the target.'),
95 95 MergeFailureReason.HG_TARGET_HAS_MULTIPLE_HEADS: lazy_ugettext(
96 96 'This pull request cannot be merged because the target has'
97 97 ' multiple heads.'),
98 98 MergeFailureReason.TARGET_IS_LOCKED: lazy_ugettext(
99 99 'This pull request cannot be merged because the target repository'
100 100 ' is locked.'),
101 101 MergeFailureReason._DEPRECATED_MISSING_COMMIT: lazy_ugettext(
102 102 'This pull request cannot be merged because the target or the '
103 103 'source reference is missing.'),
104 104 MergeFailureReason.MISSING_TARGET_REF: lazy_ugettext(
105 105 'This pull request cannot be merged because the target '
106 106 'reference is missing.'),
107 107 MergeFailureReason.MISSING_SOURCE_REF: lazy_ugettext(
108 108 'This pull request cannot be merged because the source '
109 109 'reference is missing.'),
110 110 MergeFailureReason.SUBREPO_MERGE_FAILED: lazy_ugettext(
111 111 'This pull request cannot be merged because of conflicts related '
112 112 'to sub repositories.'),
113 113 }
114 114
115 115 UPDATE_STATUS_MESSAGES = {
116 116 UpdateFailureReason.NONE: lazy_ugettext(
117 117 'Pull request update successful.'),
118 118 UpdateFailureReason.UNKNOWN: lazy_ugettext(
119 119 'Pull request update failed because of an unknown error.'),
120 120 UpdateFailureReason.NO_CHANGE: lazy_ugettext(
121 121 'No update needed because the source and target have not changed.'),
122 122 UpdateFailureReason.WRONG_REF_TYPE: lazy_ugettext(
123 123 'Pull request cannot be updated because the reference type is '
124 124 'not supported for an update. Only Branch, Tag or Bookmark is allowed.'),
125 125 UpdateFailureReason.MISSING_TARGET_REF: lazy_ugettext(
126 126 'This pull request cannot be updated because the target '
127 127 'reference is missing.'),
128 128 UpdateFailureReason.MISSING_SOURCE_REF: lazy_ugettext(
129 129 'This pull request cannot be updated because the source '
130 130 'reference is missing.'),
131 131 }
132 132
133 133 def __get_pull_request(self, pull_request):
134 134 return self._get_instance((
135 135 PullRequest, PullRequestVersion), pull_request)
136 136
137 137 def _check_perms(self, perms, pull_request, user, api=False):
138 138 if not api:
139 139 return h.HasRepoPermissionAny(*perms)(
140 140 user=user, repo_name=pull_request.target_repo.repo_name)
141 141 else:
142 142 return h.HasRepoPermissionAnyApi(*perms)(
143 143 user=user, repo_name=pull_request.target_repo.repo_name)
144 144
145 145 def check_user_read(self, pull_request, user, api=False):
146 146 _perms = ('repository.admin', 'repository.write', 'repository.read',)
147 147 return self._check_perms(_perms, pull_request, user, api)
148 148
149 149 def check_user_merge(self, pull_request, user, api=False):
150 150 _perms = ('repository.admin', 'repository.write', 'hg.admin',)
151 151 return self._check_perms(_perms, pull_request, user, api)
152 152
153 153 def check_user_update(self, pull_request, user, api=False):
154 154 owner = user.user_id == pull_request.user_id
155 155 return self.check_user_merge(pull_request, user, api) or owner
156 156
157 157 def check_user_delete(self, pull_request, user):
158 158 owner = user.user_id == pull_request.user_id
159 159 _perms = ('repository.admin',)
160 160 return self._check_perms(_perms, pull_request, user) or owner
161 161
162 162 def check_user_change_status(self, pull_request, user, api=False):
163 163 reviewer = user.user_id in [x.user_id for x in
164 164 pull_request.reviewers]
165 165 return self.check_user_update(pull_request, user, api) or reviewer
166 166
167 167 def check_user_comment(self, pull_request, user):
168 168 owner = user.user_id == pull_request.user_id
169 169 return self.check_user_read(pull_request, user) or owner
170 170
171 171 def get(self, pull_request):
172 172 return self.__get_pull_request(pull_request)
173 173
174 174 def _prepare_get_all_query(self, repo_name, source=False, statuses=None,
175 175 opened_by=None, order_by=None,
176 176 order_dir='desc'):
177 177 repo = None
178 178 if repo_name:
179 179 repo = self._get_repo(repo_name)
180 180
181 181 q = PullRequest.query()
182 182
183 183 # source or target
184 184 if repo and source:
185 185 q = q.filter(PullRequest.source_repo == repo)
186 186 elif repo:
187 187 q = q.filter(PullRequest.target_repo == repo)
188 188
189 189 # closed,opened
190 190 if statuses:
191 191 q = q.filter(PullRequest.status.in_(statuses))
192 192
193 193 # opened by filter
194 194 if opened_by:
195 195 q = q.filter(PullRequest.user_id.in_(opened_by))
196 196
197 197 if order_by:
198 198 order_map = {
199 199 'name_raw': PullRequest.pull_request_id,
200 200 'title': PullRequest.title,
201 201 'updated_on_raw': PullRequest.updated_on,
202 202 'target_repo': PullRequest.target_repo_id
203 203 }
204 204 if order_dir == 'asc':
205 205 q = q.order_by(order_map[order_by].asc())
206 206 else:
207 207 q = q.order_by(order_map[order_by].desc())
208 208
209 209 return q
210 210
211 211 def count_all(self, repo_name, source=False, statuses=None,
212 212 opened_by=None):
213 213 """
214 214 Count the number of pull requests for a specific repository.
215 215
216 216 :param repo_name: target or source repo
217 217 :param source: boolean flag to specify if repo_name refers to source
218 218 :param statuses: list of pull request statuses
219 219 :param opened_by: author user of the pull request
220 220 :returns: int number of pull requests
221 221 """
222 222 q = self._prepare_get_all_query(
223 223 repo_name, source=source, statuses=statuses, opened_by=opened_by)
224 224
225 225 return q.count()
226 226
227 227 def get_all(self, repo_name, source=False, statuses=None, opened_by=None,
228 228 offset=0, length=None, order_by=None, order_dir='desc'):
229 229 """
230 230 Get all pull requests for a specific repository.
231 231
232 232 :param repo_name: target or source repo
233 233 :param source: boolean flag to specify if repo_name refers to source
234 234 :param statuses: list of pull request statuses
235 235 :param opened_by: author user of the pull request
236 236 :param offset: pagination offset
237 237 :param length: length of returned list
238 238 :param order_by: order of the returned list
239 239 :param order_dir: 'asc' or 'desc' ordering direction
240 240 :returns: list of pull requests
241 241 """
242 242 q = self._prepare_get_all_query(
243 243 repo_name, source=source, statuses=statuses, opened_by=opened_by,
244 244 order_by=order_by, order_dir=order_dir)
245 245
246 246 if length:
247 247 pull_requests = q.limit(length).offset(offset).all()
248 248 else:
249 249 pull_requests = q.all()
250 250
251 251 return pull_requests
252 252
253 253 def count_awaiting_review(self, repo_name, source=False, statuses=None,
254 254 opened_by=None):
255 255 """
256 256 Count the number of pull requests for a specific repository that are
257 257 awaiting review.
258 258
259 259 :param repo_name: target or source repo
260 260 :param source: boolean flag to specify if repo_name refers to source
261 261 :param statuses: list of pull request statuses
262 262 :param opened_by: author user of the pull request
263 263 :returns: int number of pull requests
264 264 """
265 265 pull_requests = self.get_awaiting_review(
266 266 repo_name, source=source, statuses=statuses, opened_by=opened_by)
267 267
268 268 return len(pull_requests)
269 269
270 270 def get_awaiting_review(self, repo_name, source=False, statuses=None,
271 271 opened_by=None, offset=0, length=None,
272 272 order_by=None, order_dir='desc'):
273 273 """
274 274 Get all pull requests for a specific repository that are awaiting
275 275 review.
276 276
277 277 :param repo_name: target or source repo
278 278 :param source: boolean flag to specify if repo_name refers to source
279 279 :param statuses: list of pull request statuses
280 280 :param opened_by: author user of the pull request
281 281 :param offset: pagination offset
282 282 :param length: length of returned list
283 283 :param order_by: order of the returned list
284 284 :param order_dir: 'asc' or 'desc' ordering direction
285 285 :returns: list of pull requests
286 286 """
287 287 pull_requests = self.get_all(
288 288 repo_name, source=source, statuses=statuses, opened_by=opened_by,
289 289 order_by=order_by, order_dir=order_dir)
290 290
291 291 _filtered_pull_requests = []
292 292 for pr in pull_requests:
293 293 status = pr.calculated_review_status()
294 294 if status in [ChangesetStatus.STATUS_NOT_REVIEWED,
295 295 ChangesetStatus.STATUS_UNDER_REVIEW]:
296 296 _filtered_pull_requests.append(pr)
297 297 if length:
298 298 return _filtered_pull_requests[offset:offset+length]
299 299 else:
300 300 return _filtered_pull_requests
301 301
302 302 def count_awaiting_my_review(self, repo_name, source=False, statuses=None,
303 303 opened_by=None, user_id=None):
304 304 """
305 305 Count the number of pull requests for a specific repository that are
306 306 awaiting review from a specific user.
307 307
308 308 :param repo_name: target or source repo
309 309 :param source: boolean flag to specify if repo_name refers to source
310 310 :param statuses: list of pull request statuses
311 311 :param opened_by: author user of the pull request
312 312 :param user_id: reviewer user of the pull request
313 313 :returns: int number of pull requests
314 314 """
315 315 pull_requests = self.get_awaiting_my_review(
316 316 repo_name, source=source, statuses=statuses, opened_by=opened_by,
317 317 user_id=user_id)
318 318
319 319 return len(pull_requests)
320 320
321 321 def get_awaiting_my_review(self, repo_name, source=False, statuses=None,
322 322 opened_by=None, user_id=None, offset=0,
323 323 length=None, order_by=None, order_dir='desc'):
324 324 """
325 325 Get all pull requests for a specific repository that are awaiting
326 326 review from a specific user.
327 327
328 328 :param repo_name: target or source repo
329 329 :param source: boolean flag to specify if repo_name refers to source
330 330 :param statuses: list of pull request statuses
331 331 :param opened_by: author user of the pull request
332 332 :param user_id: reviewer user of the pull request
333 333 :param offset: pagination offset
334 334 :param length: length of returned list
335 335 :param order_by: order of the returned list
336 336 :param order_dir: 'asc' or 'desc' ordering direction
337 337 :returns: list of pull requests
338 338 """
339 339 pull_requests = self.get_all(
340 340 repo_name, source=source, statuses=statuses, opened_by=opened_by,
341 341 order_by=order_by, order_dir=order_dir)
342 342
343 343 _my = PullRequestModel().get_not_reviewed(user_id)
344 344 my_participation = []
345 345 for pr in pull_requests:
346 346 if pr in _my:
347 347 my_participation.append(pr)
348 348 _filtered_pull_requests = my_participation
349 349 if length:
350 350 return _filtered_pull_requests[offset:offset+length]
351 351 else:
352 352 return _filtered_pull_requests
353 353
354 354 def get_not_reviewed(self, user_id):
355 355 return [
356 356 x.pull_request for x in PullRequestReviewers.query().filter(
357 357 PullRequestReviewers.user_id == user_id).all()
358 358 ]
359 359
360 360 def _prepare_participating_query(self, user_id=None, statuses=None,
361 361 order_by=None, order_dir='desc'):
362 362 q = PullRequest.query()
363 363 if user_id:
364 364 reviewers_subquery = Session().query(
365 365 PullRequestReviewers.pull_request_id).filter(
366 366 PullRequestReviewers.user_id == user_id).subquery()
367 367 user_filter = or_(
368 368 PullRequest.user_id == user_id,
369 369 PullRequest.pull_request_id.in_(reviewers_subquery)
370 370 )
371 371 q = PullRequest.query().filter(user_filter)
372 372
373 373 # closed,opened
374 374 if statuses:
375 375 q = q.filter(PullRequest.status.in_(statuses))
376 376
377 377 if order_by:
378 378 order_map = {
379 379 'name_raw': PullRequest.pull_request_id,
380 380 'title': PullRequest.title,
381 381 'updated_on_raw': PullRequest.updated_on,
382 382 'target_repo': PullRequest.target_repo_id
383 383 }
384 384 if order_dir == 'asc':
385 385 q = q.order_by(order_map[order_by].asc())
386 386 else:
387 387 q = q.order_by(order_map[order_by].desc())
388 388
389 389 return q
390 390
391 391 def count_im_participating_in(self, user_id=None, statuses=None):
392 392 q = self._prepare_participating_query(user_id, statuses=statuses)
393 393 return q.count()
394 394
395 395 def get_im_participating_in(
396 396 self, user_id=None, statuses=None, offset=0,
397 397 length=None, order_by=None, order_dir='desc'):
398 398 """
399 399 Get all Pull requests that i'm participating in, or i have opened
400 400 """
401 401
402 402 q = self._prepare_participating_query(
403 403 user_id, statuses=statuses, order_by=order_by,
404 404 order_dir=order_dir)
405 405
406 406 if length:
407 407 pull_requests = q.limit(length).offset(offset).all()
408 408 else:
409 409 pull_requests = q.all()
410 410
411 411 return pull_requests
412 412
413 413 def get_versions(self, pull_request):
414 414 """
415 415 returns version of pull request sorted by ID descending
416 416 """
417 417 return PullRequestVersion.query()\
418 418 .filter(PullRequestVersion.pull_request == pull_request)\
419 419 .order_by(PullRequestVersion.pull_request_version_id.asc())\
420 420 .all()
421 421
422 422 def get_pr_version(self, pull_request_id, version=None):
423 423 at_version = None
424 424
425 425 if version and version == 'latest':
426 426 pull_request_ver = PullRequest.get(pull_request_id)
427 427 pull_request_obj = pull_request_ver
428 428 _org_pull_request_obj = pull_request_obj
429 429 at_version = 'latest'
430 430 elif version:
431 431 pull_request_ver = PullRequestVersion.get_or_404(version)
432 432 pull_request_obj = pull_request_ver
433 433 _org_pull_request_obj = pull_request_ver.pull_request
434 434 at_version = pull_request_ver.pull_request_version_id
435 435 else:
436 436 _org_pull_request_obj = pull_request_obj = PullRequest.get_or_404(
437 437 pull_request_id)
438 438
439 439 pull_request_display_obj = PullRequest.get_pr_display_object(
440 440 pull_request_obj, _org_pull_request_obj)
441 441
442 442 return _org_pull_request_obj, pull_request_obj, \
443 443 pull_request_display_obj, at_version
444 444
445 445 def create(self, created_by, source_repo, source_ref, target_repo,
446 446 target_ref, revisions, reviewers, title, description=None,
447 447 description_renderer=None,
448 448 reviewer_data=None, translator=None, auth_user=None):
449 449 translator = translator or get_current_request().translate
450 450
451 451 created_by_user = self._get_user(created_by)
452 452 auth_user = auth_user or created_by_user.AuthUser()
453 453 source_repo = self._get_repo(source_repo)
454 454 target_repo = self._get_repo(target_repo)
455 455
456 456 pull_request = PullRequest()
457 457 pull_request.source_repo = source_repo
458 458 pull_request.source_ref = source_ref
459 459 pull_request.target_repo = target_repo
460 460 pull_request.target_ref = target_ref
461 461 pull_request.revisions = revisions
462 462 pull_request.title = title
463 463 pull_request.description = description
464 464 pull_request.description_renderer = description_renderer
465 465 pull_request.author = created_by_user
466 466 pull_request.reviewer_data = reviewer_data
467 467
468 468 Session().add(pull_request)
469 469 Session().flush()
470 470
471 471 reviewer_ids = set()
472 472 # members / reviewers
473 473 for reviewer_object in reviewers:
474 474 user_id, reasons, mandatory, rules = reviewer_object
475 475 user = self._get_user(user_id)
476 476
477 477 # skip duplicates
478 478 if user.user_id in reviewer_ids:
479 479 continue
480 480
481 481 reviewer_ids.add(user.user_id)
482 482
483 483 reviewer = PullRequestReviewers()
484 484 reviewer.user = user
485 485 reviewer.pull_request = pull_request
486 486 reviewer.reasons = reasons
487 487 reviewer.mandatory = mandatory
488 488
489 489 # NOTE(marcink): pick only first rule for now
490 490 rule_id = list(rules)[0] if rules else None
491 491 rule = RepoReviewRule.get(rule_id) if rule_id else None
492 492 if rule:
493 493 review_group = rule.user_group_vote_rule(user_id)
494 494 # we check if this particular reviewer is member of a voting group
495 495 if review_group:
496 496 # NOTE(marcink):
497 497 # can be that user is member of more but we pick the first same,
498 498 # same as default reviewers algo
499 499 review_group = review_group[0]
500 500
501 501 rule_data = {
502 502 'rule_name':
503 503 rule.review_rule_name,
504 504 'rule_user_group_entry_id':
505 505 review_group.repo_review_rule_users_group_id,
506 506 'rule_user_group_name':
507 507 review_group.users_group.users_group_name,
508 508 'rule_user_group_members':
509 509 [x.user.username for x in review_group.users_group.members],
510 510 'rule_user_group_members_id':
511 511 [x.user.user_id for x in review_group.users_group.members],
512 512 }
513 513 # e.g {'vote_rule': -1, 'mandatory': True}
514 514 rule_data.update(review_group.rule_data())
515 515
516 516 reviewer.rule_data = rule_data
517 517
518 518 Session().add(reviewer)
519 519 Session().flush()
520 520
521 521 # Set approval status to "Under Review" for all commits which are
522 522 # part of this pull request.
523 523 ChangesetStatusModel().set_status(
524 524 repo=target_repo,
525 525 status=ChangesetStatus.STATUS_UNDER_REVIEW,
526 526 user=created_by_user,
527 527 pull_request=pull_request
528 528 )
529 529 # we commit early at this point. This has to do with a fact
530 530 # that before queries do some row-locking. And because of that
531 531 # we need to commit and finish transation before below validate call
532 532 # that for large repos could be long resulting in long row locks
533 533 Session().commit()
534 534
535 535 # prepare workspace, and run initial merge simulation
536 536 MergeCheck.validate(
537 537 pull_request, auth_user=auth_user, translator=translator)
538 538
539 539 self.notify_reviewers(pull_request, reviewer_ids)
540 540 self._trigger_pull_request_hook(
541 541 pull_request, created_by_user, 'create')
542 542
543 543 creation_data = pull_request.get_api_data(with_merge_state=False)
544 544 self._log_audit_action(
545 545 'repo.pull_request.create', {'data': creation_data},
546 546 auth_user, pull_request)
547 547
548 548 return pull_request
549 549
550 550 def _trigger_pull_request_hook(self, pull_request, user, action):
551 551 pull_request = self.__get_pull_request(pull_request)
552 552 target_scm = pull_request.target_repo.scm_instance()
553 553 if action == 'create':
554 554 trigger_hook = hooks_utils.trigger_log_create_pull_request_hook
555 555 elif action == 'merge':
556 556 trigger_hook = hooks_utils.trigger_log_merge_pull_request_hook
557 557 elif action == 'close':
558 558 trigger_hook = hooks_utils.trigger_log_close_pull_request_hook
559 559 elif action == 'review_status_change':
560 560 trigger_hook = hooks_utils.trigger_log_review_pull_request_hook
561 561 elif action == 'update':
562 562 trigger_hook = hooks_utils.trigger_log_update_pull_request_hook
563 563 else:
564 564 return
565 565
566 566 trigger_hook(
567 567 username=user.username,
568 568 repo_name=pull_request.target_repo.repo_name,
569 569 repo_alias=target_scm.alias,
570 570 pull_request=pull_request)
571 571
572 572 def _get_commit_ids(self, pull_request):
573 573 """
574 574 Return the commit ids of the merged pull request.
575 575
576 576 This method is not dealing correctly yet with the lack of autoupdates
577 577 nor with the implicit target updates.
578 578 For example: if a commit in the source repo is already in the target it
579 579 will be reported anyways.
580 580 """
581 581 merge_rev = pull_request.merge_rev
582 582 if merge_rev is None:
583 583 raise ValueError('This pull request was not merged yet')
584 584
585 585 commit_ids = list(pull_request.revisions)
586 586 if merge_rev not in commit_ids:
587 587 commit_ids.append(merge_rev)
588 588
589 589 return commit_ids
590 590
591 591 def merge_repo(self, pull_request, user, extras):
592 592 log.debug("Merging pull request %s", pull_request.pull_request_id)
593 593 extras['user_agent'] = 'internal-merge'
594 594 merge_state = self._merge_pull_request(pull_request, user, extras)
595 595 if merge_state.executed:
596 596 log.debug(
597 597 "Merge was successful, updating the pull request comments.")
598 598 self._comment_and_close_pr(pull_request, user, merge_state)
599 599
600 600 self._log_audit_action(
601 601 'repo.pull_request.merge',
602 602 {'merge_state': merge_state.__dict__},
603 603 user, pull_request)
604 604
605 605 else:
606 606 log.warn("Merge failed, not updating the pull request.")
607 607 return merge_state
608 608
609 609 def _merge_pull_request(self, pull_request, user, extras, merge_msg=None):
610 610 target_vcs = pull_request.target_repo.scm_instance()
611 611 source_vcs = pull_request.source_repo.scm_instance()
612 612
613 613 message = safe_unicode(merge_msg or vcs_settings.MERGE_MESSAGE_TMPL).format(
614 614 pr_id=pull_request.pull_request_id,
615 615 pr_title=pull_request.title,
616 616 source_repo=source_vcs.name,
617 617 source_ref_name=pull_request.source_ref_parts.name,
618 618 target_repo=target_vcs.name,
619 619 target_ref_name=pull_request.target_ref_parts.name,
620 620 )
621 621
622 622 workspace_id = self._workspace_id(pull_request)
623 623 repo_id = pull_request.target_repo.repo_id
624 624 use_rebase = self._use_rebase_for_merging(pull_request)
625 625 close_branch = self._close_branch_before_merging(pull_request)
626 626
627 627 target_ref = self._refresh_reference(
628 628 pull_request.target_ref_parts, target_vcs)
629 629
630 630 callback_daemon, extras = prepare_callback_daemon(
631 631 extras, protocol=vcs_settings.HOOKS_PROTOCOL,
632 632 host=vcs_settings.HOOKS_HOST,
633 633 use_direct_calls=vcs_settings.HOOKS_DIRECT_CALLS)
634 634
635 635 with callback_daemon:
636 636 # TODO: johbo: Implement a clean way to run a config_override
637 637 # for a single call.
638 638 target_vcs.config.set(
639 639 'rhodecode', 'RC_SCM_DATA', json.dumps(extras))
640 640
641 641 user_name = user.short_contact
642 642 merge_state = target_vcs.merge(
643 643 repo_id, workspace_id, target_ref, source_vcs,
644 644 pull_request.source_ref_parts,
645 645 user_name=user_name, user_email=user.email,
646 646 message=message, use_rebase=use_rebase,
647 647 close_branch=close_branch)
648 648 return merge_state
649 649
650 650 def _comment_and_close_pr(self, pull_request, user, merge_state, close_msg=None):
651 651 pull_request.merge_rev = merge_state.merge_ref.commit_id
652 652 pull_request.updated_on = datetime.datetime.now()
653 653 close_msg = close_msg or 'Pull request merged and closed'
654 654
655 655 CommentsModel().create(
656 656 text=safe_unicode(close_msg),
657 657 repo=pull_request.target_repo.repo_id,
658 658 user=user.user_id,
659 659 pull_request=pull_request.pull_request_id,
660 660 f_path=None,
661 661 line_no=None,
662 662 closing_pr=True
663 663 )
664 664
665 665 Session().add(pull_request)
666 666 Session().flush()
667 667 # TODO: paris: replace invalidation with less radical solution
668 668 ScmModel().mark_for_invalidation(
669 669 pull_request.target_repo.repo_name)
670 670 self._trigger_pull_request_hook(pull_request, user, 'merge')
671 671
672 672 def has_valid_update_type(self, pull_request):
673 673 source_ref_type = pull_request.source_ref_parts.type
674 674 return source_ref_type in ['book', 'branch', 'tag']
675 675
676 676 def update_commits(self, pull_request):
677 677 """
678 678 Get the updated list of commits for the pull request
679 679 and return the new pull request version and the list
680 680 of commits processed by this update action
681 681 """
682 682 pull_request = self.__get_pull_request(pull_request)
683 683 source_ref_type = pull_request.source_ref_parts.type
684 684 source_ref_name = pull_request.source_ref_parts.name
685 685 source_ref_id = pull_request.source_ref_parts.commit_id
686 686
687 687 target_ref_type = pull_request.target_ref_parts.type
688 688 target_ref_name = pull_request.target_ref_parts.name
689 689 target_ref_id = pull_request.target_ref_parts.commit_id
690 690
691 691 if not self.has_valid_update_type(pull_request):
692 692 log.debug(
693 693 "Skipping update of pull request %s due to ref type: %s",
694 694 pull_request, source_ref_type)
695 695 return UpdateResponse(
696 696 executed=False,
697 697 reason=UpdateFailureReason.WRONG_REF_TYPE,
698 698 old=pull_request, new=None, changes=None,
699 699 source_changed=False, target_changed=False)
700 700
701 701 # source repo
702 702 source_repo = pull_request.source_repo.scm_instance()
703 703 try:
704 704 source_commit = source_repo.get_commit(commit_id=source_ref_name)
705 705 except CommitDoesNotExistError:
706 706 return UpdateResponse(
707 707 executed=False,
708 708 reason=UpdateFailureReason.MISSING_SOURCE_REF,
709 709 old=pull_request, new=None, changes=None,
710 710 source_changed=False, target_changed=False)
711 711
712 712 source_changed = source_ref_id != source_commit.raw_id
713 713
714 714 # target repo
715 715 target_repo = pull_request.target_repo.scm_instance()
716 716 try:
717 717 target_commit = target_repo.get_commit(commit_id=target_ref_name)
718 718 except CommitDoesNotExistError:
719 719 return UpdateResponse(
720 720 executed=False,
721 721 reason=UpdateFailureReason.MISSING_TARGET_REF,
722 722 old=pull_request, new=None, changes=None,
723 723 source_changed=False, target_changed=False)
724 724 target_changed = target_ref_id != target_commit.raw_id
725 725
726 726 if not (source_changed or target_changed):
727 727 log.debug("Nothing changed in pull request %s", pull_request)
728 728 return UpdateResponse(
729 729 executed=False,
730 730 reason=UpdateFailureReason.NO_CHANGE,
731 731 old=pull_request, new=None, changes=None,
732 732 source_changed=target_changed, target_changed=source_changed)
733 733
734 734 change_in_found = 'target repo' if target_changed else 'source repo'
735 735 log.debug('Updating pull request because of change in %s detected',
736 736 change_in_found)
737 737
738 738 # Finally there is a need for an update, in case of source change
739 739 # we create a new version, else just an update
740 740 if source_changed:
741 741 pull_request_version = self._create_version_from_snapshot(pull_request)
742 742 self._link_comments_to_version(pull_request_version)
743 743 else:
744 744 try:
745 745 ver = pull_request.versions[-1]
746 746 except IndexError:
747 747 ver = None
748 748
749 749 pull_request.pull_request_version_id = \
750 750 ver.pull_request_version_id if ver else None
751 751 pull_request_version = pull_request
752 752
753 753 try:
754 754 if target_ref_type in ('tag', 'branch', 'book'):
755 755 target_commit = target_repo.get_commit(target_ref_name)
756 756 else:
757 757 target_commit = target_repo.get_commit(target_ref_id)
758 758 except CommitDoesNotExistError:
759 759 return UpdateResponse(
760 760 executed=False,
761 761 reason=UpdateFailureReason.MISSING_TARGET_REF,
762 762 old=pull_request, new=None, changes=None,
763 763 source_changed=source_changed, target_changed=target_changed)
764 764
765 765 # re-compute commit ids
766 766 old_commit_ids = pull_request.revisions
767 767 pre_load = ["author", "branch", "date", "message"]
768 768 commit_ranges = target_repo.compare(
769 769 target_commit.raw_id, source_commit.raw_id, source_repo, merge=True,
770 770 pre_load=pre_load)
771 771
772 772 ancestor = target_repo.get_common_ancestor(
773 773 target_commit.raw_id, source_commit.raw_id, source_repo)
774 774
775 775 pull_request.source_ref = '%s:%s:%s' % (
776 776 source_ref_type, source_ref_name, source_commit.raw_id)
777 777 pull_request.target_ref = '%s:%s:%s' % (
778 778 target_ref_type, target_ref_name, ancestor)
779 779
780 780 pull_request.revisions = [
781 781 commit.raw_id for commit in reversed(commit_ranges)]
782 782 pull_request.updated_on = datetime.datetime.now()
783 783 Session().add(pull_request)
784 784 new_commit_ids = pull_request.revisions
785 785
786 786 old_diff_data, new_diff_data = self._generate_update_diffs(
787 787 pull_request, pull_request_version)
788 788
789 789 # calculate commit and file changes
790 790 changes = self._calculate_commit_id_changes(
791 791 old_commit_ids, new_commit_ids)
792 792 file_changes = self._calculate_file_changes(
793 793 old_diff_data, new_diff_data)
794 794
795 795 # set comments as outdated if DIFFS changed
796 796 CommentsModel().outdate_comments(
797 797 pull_request, old_diff_data=old_diff_data,
798 798 new_diff_data=new_diff_data)
799 799
800 800 commit_changes = (changes.added or changes.removed)
801 801 file_node_changes = (
802 802 file_changes.added or file_changes.modified or file_changes.removed)
803 803 pr_has_changes = commit_changes or file_node_changes
804 804
805 805 # Add an automatic comment to the pull request, in case
806 806 # anything has changed
807 807 if pr_has_changes:
808 808 update_comment = CommentsModel().create(
809 809 text=self._render_update_message(changes, file_changes),
810 810 repo=pull_request.target_repo,
811 811 user=pull_request.author,
812 812 pull_request=pull_request,
813 813 send_email=False, renderer=DEFAULT_COMMENTS_RENDERER)
814 814
815 815 # Update status to "Under Review" for added commits
816 816 for commit_id in changes.added:
817 817 ChangesetStatusModel().set_status(
818 818 repo=pull_request.source_repo,
819 819 status=ChangesetStatus.STATUS_UNDER_REVIEW,
820 820 comment=update_comment,
821 821 user=pull_request.author,
822 822 pull_request=pull_request,
823 823 revision=commit_id)
824 824
825 825 log.debug(
826 826 'Updated pull request %s, added_ids: %s, common_ids: %s, '
827 827 'removed_ids: %s', pull_request.pull_request_id,
828 828 changes.added, changes.common, changes.removed)
829 829 log.debug(
830 830 'Updated pull request with the following file changes: %s',
831 831 file_changes)
832 832
833 833 log.info(
834 834 "Updated pull request %s from commit %s to commit %s, "
835 835 "stored new version %s of this pull request.",
836 836 pull_request.pull_request_id, source_ref_id,
837 837 pull_request.source_ref_parts.commit_id,
838 838 pull_request_version.pull_request_version_id)
839 839 Session().commit()
840 840 self._trigger_pull_request_hook(
841 841 pull_request, pull_request.author, 'update')
842 842
843 843 return UpdateResponse(
844 844 executed=True, reason=UpdateFailureReason.NONE,
845 845 old=pull_request, new=pull_request_version, changes=changes,
846 846 source_changed=source_changed, target_changed=target_changed)
847 847
848 848 def _create_version_from_snapshot(self, pull_request):
849 849 version = PullRequestVersion()
850 850 version.title = pull_request.title
851 851 version.description = pull_request.description
852 852 version.status = pull_request.status
853 853 version.created_on = datetime.datetime.now()
854 854 version.updated_on = pull_request.updated_on
855 855 version.user_id = pull_request.user_id
856 856 version.source_repo = pull_request.source_repo
857 857 version.source_ref = pull_request.source_ref
858 858 version.target_repo = pull_request.target_repo
859 859 version.target_ref = pull_request.target_ref
860 860
861 861 version._last_merge_source_rev = pull_request._last_merge_source_rev
862 862 version._last_merge_target_rev = pull_request._last_merge_target_rev
863 863 version.last_merge_status = pull_request.last_merge_status
864 864 version.shadow_merge_ref = pull_request.shadow_merge_ref
865 865 version.merge_rev = pull_request.merge_rev
866 866 version.reviewer_data = pull_request.reviewer_data
867 867
868 868 version.revisions = pull_request.revisions
869 869 version.pull_request = pull_request
870 870 Session().add(version)
871 871 Session().flush()
872 872
873 873 return version
874 874
875 875 def _generate_update_diffs(self, pull_request, pull_request_version):
876 876
877 877 diff_context = (
878 878 self.DIFF_CONTEXT +
879 879 CommentsModel.needed_extra_diff_context())
880
880 hide_whitespace_changes = False
881 881 source_repo = pull_request_version.source_repo
882 882 source_ref_id = pull_request_version.source_ref_parts.commit_id
883 883 target_ref_id = pull_request_version.target_ref_parts.commit_id
884 884 old_diff = self._get_diff_from_pr_or_version(
885 source_repo, source_ref_id, target_ref_id, context=diff_context)
885 source_repo, source_ref_id, target_ref_id,
886 hide_whitespace_changes=hide_whitespace_changes, diff_context=diff_context)
886 887
887 888 source_repo = pull_request.source_repo
888 889 source_ref_id = pull_request.source_ref_parts.commit_id
889 890 target_ref_id = pull_request.target_ref_parts.commit_id
890 891
891 892 new_diff = self._get_diff_from_pr_or_version(
892 source_repo, source_ref_id, target_ref_id, context=diff_context)
893 source_repo, source_ref_id, target_ref_id,
894 hide_whitespace_changes=hide_whitespace_changes, diff_context=diff_context)
893 895
894 896 old_diff_data = diffs.DiffProcessor(old_diff)
895 897 old_diff_data.prepare()
896 898 new_diff_data = diffs.DiffProcessor(new_diff)
897 899 new_diff_data.prepare()
898 900
899 901 return old_diff_data, new_diff_data
900 902
901 903 def _link_comments_to_version(self, pull_request_version):
902 904 """
903 905 Link all unlinked comments of this pull request to the given version.
904 906
905 907 :param pull_request_version: The `PullRequestVersion` to which
906 908 the comments shall be linked.
907 909
908 910 """
909 911 pull_request = pull_request_version.pull_request
910 912 comments = ChangesetComment.query()\
911 913 .filter(
912 914 # TODO: johbo: Should we query for the repo at all here?
913 915 # Pending decision on how comments of PRs are to be related
914 916 # to either the source repo, the target repo or no repo at all.
915 917 ChangesetComment.repo_id == pull_request.target_repo.repo_id,
916 918 ChangesetComment.pull_request == pull_request,
917 919 ChangesetComment.pull_request_version == None)\
918 920 .order_by(ChangesetComment.comment_id.asc())
919 921
920 922 # TODO: johbo: Find out why this breaks if it is done in a bulk
921 923 # operation.
922 924 for comment in comments:
923 925 comment.pull_request_version_id = (
924 926 pull_request_version.pull_request_version_id)
925 927 Session().add(comment)
926 928
927 929 def _calculate_commit_id_changes(self, old_ids, new_ids):
928 930 added = [x for x in new_ids if x not in old_ids]
929 931 common = [x for x in new_ids if x in old_ids]
930 932 removed = [x for x in old_ids if x not in new_ids]
931 933 total = new_ids
932 934 return ChangeTuple(added, common, removed, total)
933 935
934 936 def _calculate_file_changes(self, old_diff_data, new_diff_data):
935 937
936 938 old_files = OrderedDict()
937 939 for diff_data in old_diff_data.parsed_diff:
938 940 old_files[diff_data['filename']] = md5_safe(diff_data['raw_diff'])
939 941
940 942 added_files = []
941 943 modified_files = []
942 944 removed_files = []
943 945 for diff_data in new_diff_data.parsed_diff:
944 946 new_filename = diff_data['filename']
945 947 new_hash = md5_safe(diff_data['raw_diff'])
946 948
947 949 old_hash = old_files.get(new_filename)
948 950 if not old_hash:
949 951 # file is not present in old diff, means it's added
950 952 added_files.append(new_filename)
951 953 else:
952 954 if new_hash != old_hash:
953 955 modified_files.append(new_filename)
954 956 # now remove a file from old, since we have seen it already
955 957 del old_files[new_filename]
956 958
957 959 # removed files is when there are present in old, but not in NEW,
958 960 # since we remove old files that are present in new diff, left-overs
959 961 # if any should be the removed files
960 962 removed_files.extend(old_files.keys())
961 963
962 964 return FileChangeTuple(added_files, modified_files, removed_files)
963 965
964 966 def _render_update_message(self, changes, file_changes):
965 967 """
966 968 render the message using DEFAULT_COMMENTS_RENDERER (RST renderer),
967 969 so it's always looking the same disregarding on which default
968 970 renderer system is using.
969 971
970 972 :param changes: changes named tuple
971 973 :param file_changes: file changes named tuple
972 974
973 975 """
974 976 new_status = ChangesetStatus.get_status_lbl(
975 977 ChangesetStatus.STATUS_UNDER_REVIEW)
976 978
977 979 changed_files = (
978 980 file_changes.added + file_changes.modified + file_changes.removed)
979 981
980 982 params = {
981 983 'under_review_label': new_status,
982 984 'added_commits': changes.added,
983 985 'removed_commits': changes.removed,
984 986 'changed_files': changed_files,
985 987 'added_files': file_changes.added,
986 988 'modified_files': file_changes.modified,
987 989 'removed_files': file_changes.removed,
988 990 }
989 991 renderer = RstTemplateRenderer()
990 992 return renderer.render('pull_request_update.mako', **params)
991 993
992 994 def edit(self, pull_request, title, description, description_renderer, user):
993 995 pull_request = self.__get_pull_request(pull_request)
994 996 old_data = pull_request.get_api_data(with_merge_state=False)
995 997 if pull_request.is_closed():
996 998 raise ValueError('This pull request is closed')
997 999 if title:
998 1000 pull_request.title = title
999 1001 pull_request.description = description
1000 1002 pull_request.updated_on = datetime.datetime.now()
1001 1003 pull_request.description_renderer = description_renderer
1002 1004 Session().add(pull_request)
1003 1005 self._log_audit_action(
1004 1006 'repo.pull_request.edit', {'old_data': old_data},
1005 1007 user, pull_request)
1006 1008
1007 1009 def update_reviewers(self, pull_request, reviewer_data, user):
1008 1010 """
1009 1011 Update the reviewers in the pull request
1010 1012
1011 1013 :param pull_request: the pr to update
1012 1014 :param reviewer_data: list of tuples
1013 1015 [(user, ['reason1', 'reason2'], mandatory_flag, [rules])]
1014 1016 """
1015 1017 pull_request = self.__get_pull_request(pull_request)
1016 1018 if pull_request.is_closed():
1017 1019 raise ValueError('This pull request is closed')
1018 1020
1019 1021 reviewers = {}
1020 1022 for user_id, reasons, mandatory, rules in reviewer_data:
1021 1023 if isinstance(user_id, (int, basestring)):
1022 1024 user_id = self._get_user(user_id).user_id
1023 1025 reviewers[user_id] = {
1024 1026 'reasons': reasons, 'mandatory': mandatory}
1025 1027
1026 1028 reviewers_ids = set(reviewers.keys())
1027 1029 current_reviewers = PullRequestReviewers.query()\
1028 1030 .filter(PullRequestReviewers.pull_request ==
1029 1031 pull_request).all()
1030 1032 current_reviewers_ids = set([x.user.user_id for x in current_reviewers])
1031 1033
1032 1034 ids_to_add = reviewers_ids.difference(current_reviewers_ids)
1033 1035 ids_to_remove = current_reviewers_ids.difference(reviewers_ids)
1034 1036
1035 1037 log.debug("Adding %s reviewers", ids_to_add)
1036 1038 log.debug("Removing %s reviewers", ids_to_remove)
1037 1039 changed = False
1038 1040 for uid in ids_to_add:
1039 1041 changed = True
1040 1042 _usr = self._get_user(uid)
1041 1043 reviewer = PullRequestReviewers()
1042 1044 reviewer.user = _usr
1043 1045 reviewer.pull_request = pull_request
1044 1046 reviewer.reasons = reviewers[uid]['reasons']
1045 1047 # NOTE(marcink): mandatory shouldn't be changed now
1046 1048 # reviewer.mandatory = reviewers[uid]['reasons']
1047 1049 Session().add(reviewer)
1048 1050 self._log_audit_action(
1049 1051 'repo.pull_request.reviewer.add', {'data': reviewer.get_dict()},
1050 1052 user, pull_request)
1051 1053
1052 1054 for uid in ids_to_remove:
1053 1055 changed = True
1054 1056 reviewers = PullRequestReviewers.query()\
1055 1057 .filter(PullRequestReviewers.user_id == uid,
1056 1058 PullRequestReviewers.pull_request == pull_request)\
1057 1059 .all()
1058 1060 # use .all() in case we accidentally added the same person twice
1059 1061 # this CAN happen due to the lack of DB checks
1060 1062 for obj in reviewers:
1061 1063 old_data = obj.get_dict()
1062 1064 Session().delete(obj)
1063 1065 self._log_audit_action(
1064 1066 'repo.pull_request.reviewer.delete',
1065 1067 {'old_data': old_data}, user, pull_request)
1066 1068
1067 1069 if changed:
1068 1070 pull_request.updated_on = datetime.datetime.now()
1069 1071 Session().add(pull_request)
1070 1072
1071 1073 self.notify_reviewers(pull_request, ids_to_add)
1072 1074 return ids_to_add, ids_to_remove
1073 1075
1074 1076 def get_url(self, pull_request, request=None, permalink=False):
1075 1077 if not request:
1076 1078 request = get_current_request()
1077 1079
1078 1080 if permalink:
1079 1081 return request.route_url(
1080 1082 'pull_requests_global',
1081 1083 pull_request_id=pull_request.pull_request_id,)
1082 1084 else:
1083 1085 return request.route_url('pullrequest_show',
1084 1086 repo_name=safe_str(pull_request.target_repo.repo_name),
1085 1087 pull_request_id=pull_request.pull_request_id,)
1086 1088
1087 1089 def get_shadow_clone_url(self, pull_request, request=None):
1088 1090 """
1089 1091 Returns qualified url pointing to the shadow repository. If this pull
1090 1092 request is closed there is no shadow repository and ``None`` will be
1091 1093 returned.
1092 1094 """
1093 1095 if pull_request.is_closed():
1094 1096 return None
1095 1097 else:
1096 1098 pr_url = urllib.unquote(self.get_url(pull_request, request=request))
1097 1099 return safe_unicode('{pr_url}/repository'.format(pr_url=pr_url))
1098 1100
1099 1101 def notify_reviewers(self, pull_request, reviewers_ids):
1100 1102 # notification to reviewers
1101 1103 if not reviewers_ids:
1102 1104 return
1103 1105
1104 1106 pull_request_obj = pull_request
1105 1107 # get the current participants of this pull request
1106 1108 recipients = reviewers_ids
1107 1109 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST
1108 1110
1109 1111 pr_source_repo = pull_request_obj.source_repo
1110 1112 pr_target_repo = pull_request_obj.target_repo
1111 1113
1112 1114 pr_url = h.route_url('pullrequest_show',
1113 1115 repo_name=pr_target_repo.repo_name,
1114 1116 pull_request_id=pull_request_obj.pull_request_id,)
1115 1117
1116 1118 # set some variables for email notification
1117 1119 pr_target_repo_url = h.route_url(
1118 1120 'repo_summary', repo_name=pr_target_repo.repo_name)
1119 1121
1120 1122 pr_source_repo_url = h.route_url(
1121 1123 'repo_summary', repo_name=pr_source_repo.repo_name)
1122 1124
1123 1125 # pull request specifics
1124 1126 pull_request_commits = [
1125 1127 (x.raw_id, x.message)
1126 1128 for x in map(pr_source_repo.get_commit, pull_request.revisions)]
1127 1129
1128 1130 kwargs = {
1129 1131 'user': pull_request.author,
1130 1132 'pull_request': pull_request_obj,
1131 1133 'pull_request_commits': pull_request_commits,
1132 1134
1133 1135 'pull_request_target_repo': pr_target_repo,
1134 1136 'pull_request_target_repo_url': pr_target_repo_url,
1135 1137
1136 1138 'pull_request_source_repo': pr_source_repo,
1137 1139 'pull_request_source_repo_url': pr_source_repo_url,
1138 1140
1139 1141 'pull_request_url': pr_url,
1140 1142 }
1141 1143
1142 1144 # pre-generate the subject for notification itself
1143 1145 (subject,
1144 1146 _h, _e, # we don't care about those
1145 1147 body_plaintext) = EmailNotificationModel().render_email(
1146 1148 notification_type, **kwargs)
1147 1149
1148 1150 # create notification objects, and emails
1149 1151 NotificationModel().create(
1150 1152 created_by=pull_request.author,
1151 1153 notification_subject=subject,
1152 1154 notification_body=body_plaintext,
1153 1155 notification_type=notification_type,
1154 1156 recipients=recipients,
1155 1157 email_kwargs=kwargs,
1156 1158 )
1157 1159
1158 1160 def delete(self, pull_request, user):
1159 1161 pull_request = self.__get_pull_request(pull_request)
1160 1162 old_data = pull_request.get_api_data(with_merge_state=False)
1161 1163 self._cleanup_merge_workspace(pull_request)
1162 1164 self._log_audit_action(
1163 1165 'repo.pull_request.delete', {'old_data': old_data},
1164 1166 user, pull_request)
1165 1167 Session().delete(pull_request)
1166 1168
1167 1169 def close_pull_request(self, pull_request, user):
1168 1170 pull_request = self.__get_pull_request(pull_request)
1169 1171 self._cleanup_merge_workspace(pull_request)
1170 1172 pull_request.status = PullRequest.STATUS_CLOSED
1171 1173 pull_request.updated_on = datetime.datetime.now()
1172 1174 Session().add(pull_request)
1173 1175 self._trigger_pull_request_hook(
1174 1176 pull_request, pull_request.author, 'close')
1175 1177
1176 1178 pr_data = pull_request.get_api_data(with_merge_state=False)
1177 1179 self._log_audit_action(
1178 1180 'repo.pull_request.close', {'data': pr_data}, user, pull_request)
1179 1181
1180 1182 def close_pull_request_with_comment(
1181 1183 self, pull_request, user, repo, message=None, auth_user=None):
1182 1184
1183 1185 pull_request_review_status = pull_request.calculated_review_status()
1184 1186
1185 1187 if pull_request_review_status == ChangesetStatus.STATUS_APPROVED:
1186 1188 # approved only if we have voting consent
1187 1189 status = ChangesetStatus.STATUS_APPROVED
1188 1190 else:
1189 1191 status = ChangesetStatus.STATUS_REJECTED
1190 1192 status_lbl = ChangesetStatus.get_status_lbl(status)
1191 1193
1192 1194 default_message = (
1193 1195 'Closing with status change {transition_icon} {status}.'
1194 1196 ).format(transition_icon='>', status=status_lbl)
1195 1197 text = message or default_message
1196 1198
1197 1199 # create a comment, and link it to new status
1198 1200 comment = CommentsModel().create(
1199 1201 text=text,
1200 1202 repo=repo.repo_id,
1201 1203 user=user.user_id,
1202 1204 pull_request=pull_request.pull_request_id,
1203 1205 status_change=status_lbl,
1204 1206 status_change_type=status,
1205 1207 closing_pr=True,
1206 1208 auth_user=auth_user,
1207 1209 )
1208 1210
1209 1211 # calculate old status before we change it
1210 1212 old_calculated_status = pull_request.calculated_review_status()
1211 1213 ChangesetStatusModel().set_status(
1212 1214 repo.repo_id,
1213 1215 status,
1214 1216 user.user_id,
1215 1217 comment=comment,
1216 1218 pull_request=pull_request.pull_request_id
1217 1219 )
1218 1220
1219 1221 Session().flush()
1220 1222 events.trigger(events.PullRequestCommentEvent(pull_request, comment))
1221 1223 # we now calculate the status of pull request again, and based on that
1222 1224 # calculation trigger status change. This might happen in cases
1223 1225 # that non-reviewer admin closes a pr, which means his vote doesn't
1224 1226 # change the status, while if he's a reviewer this might change it.
1225 1227 calculated_status = pull_request.calculated_review_status()
1226 1228 if old_calculated_status != calculated_status:
1227 1229 self._trigger_pull_request_hook(
1228 1230 pull_request, user, 'review_status_change')
1229 1231
1230 1232 # finally close the PR
1231 1233 PullRequestModel().close_pull_request(
1232 1234 pull_request.pull_request_id, user)
1233 1235
1234 1236 return comment, status
1235 1237
1236 1238 def merge_status(self, pull_request, translator=None,
1237 1239 force_shadow_repo_refresh=False):
1238 1240 _ = translator or get_current_request().translate
1239 1241
1240 1242 if not self._is_merge_enabled(pull_request):
1241 1243 return False, _('Server-side pull request merging is disabled.')
1242 1244 if pull_request.is_closed():
1243 1245 return False, _('This pull request is closed.')
1244 1246 merge_possible, msg = self._check_repo_requirements(
1245 1247 target=pull_request.target_repo, source=pull_request.source_repo,
1246 1248 translator=_)
1247 1249 if not merge_possible:
1248 1250 return merge_possible, msg
1249 1251
1250 1252 try:
1251 1253 resp = self._try_merge(
1252 1254 pull_request,
1253 1255 force_shadow_repo_refresh=force_shadow_repo_refresh)
1254 1256 log.debug("Merge response: %s", resp)
1255 1257 status = resp.possible, self.merge_status_message(
1256 1258 resp.failure_reason)
1257 1259 except NotImplementedError:
1258 1260 status = False, _('Pull request merging is not supported.')
1259 1261
1260 1262 return status
1261 1263
1262 1264 def _check_repo_requirements(self, target, source, translator):
1263 1265 """
1264 1266 Check if `target` and `source` have compatible requirements.
1265 1267
1266 1268 Currently this is just checking for largefiles.
1267 1269 """
1268 1270 _ = translator
1269 1271 target_has_largefiles = self._has_largefiles(target)
1270 1272 source_has_largefiles = self._has_largefiles(source)
1271 1273 merge_possible = True
1272 1274 message = u''
1273 1275
1274 1276 if target_has_largefiles != source_has_largefiles:
1275 1277 merge_possible = False
1276 1278 if source_has_largefiles:
1277 1279 message = _(
1278 1280 'Target repository large files support is disabled.')
1279 1281 else:
1280 1282 message = _(
1281 1283 'Source repository large files support is disabled.')
1282 1284
1283 1285 return merge_possible, message
1284 1286
1285 1287 def _has_largefiles(self, repo):
1286 1288 largefiles_ui = VcsSettingsModel(repo=repo).get_ui_settings(
1287 1289 'extensions', 'largefiles')
1288 1290 return largefiles_ui and largefiles_ui[0].active
1289 1291
1290 1292 def _try_merge(self, pull_request, force_shadow_repo_refresh=False):
1291 1293 """
1292 1294 Try to merge the pull request and return the merge status.
1293 1295 """
1294 1296 log.debug(
1295 1297 "Trying out if the pull request %s can be merged. Force_refresh=%s",
1296 1298 pull_request.pull_request_id, force_shadow_repo_refresh)
1297 1299 target_vcs = pull_request.target_repo.scm_instance()
1298 1300
1299 1301 # Refresh the target reference.
1300 1302 try:
1301 1303 target_ref = self._refresh_reference(
1302 1304 pull_request.target_ref_parts, target_vcs)
1303 1305 except CommitDoesNotExistError:
1304 1306 merge_state = MergeResponse(
1305 1307 False, False, None, MergeFailureReason.MISSING_TARGET_REF)
1306 1308 return merge_state
1307 1309
1308 1310 target_locked = pull_request.target_repo.locked
1309 1311 if target_locked and target_locked[0]:
1310 1312 log.debug("The target repository is locked.")
1311 1313 merge_state = MergeResponse(
1312 1314 False, False, None, MergeFailureReason.TARGET_IS_LOCKED)
1313 1315 elif force_shadow_repo_refresh or self._needs_merge_state_refresh(
1314 1316 pull_request, target_ref):
1315 1317 log.debug("Refreshing the merge status of the repository.")
1316 1318 merge_state = self._refresh_merge_state(
1317 1319 pull_request, target_vcs, target_ref)
1318 1320 else:
1319 1321 possible = pull_request.\
1320 1322 last_merge_status == MergeFailureReason.NONE
1321 1323 merge_state = MergeResponse(
1322 1324 possible, False, None, pull_request.last_merge_status)
1323 1325
1324 1326 return merge_state
1325 1327
1326 1328 def _refresh_reference(self, reference, vcs_repository):
1327 1329 if reference.type in ('branch', 'book'):
1328 1330 name_or_id = reference.name
1329 1331 else:
1330 1332 name_or_id = reference.commit_id
1331 1333 refreshed_commit = vcs_repository.get_commit(name_or_id)
1332 1334 refreshed_reference = Reference(
1333 1335 reference.type, reference.name, refreshed_commit.raw_id)
1334 1336 return refreshed_reference
1335 1337
1336 1338 def _needs_merge_state_refresh(self, pull_request, target_reference):
1337 1339 return not(
1338 1340 pull_request.revisions and
1339 1341 pull_request.revisions[0] == pull_request._last_merge_source_rev and
1340 1342 target_reference.commit_id == pull_request._last_merge_target_rev)
1341 1343
1342 1344 def _refresh_merge_state(self, pull_request, target_vcs, target_reference):
1343 1345 workspace_id = self._workspace_id(pull_request)
1344 1346 source_vcs = pull_request.source_repo.scm_instance()
1345 1347 repo_id = pull_request.target_repo.repo_id
1346 1348 use_rebase = self._use_rebase_for_merging(pull_request)
1347 1349 close_branch = self._close_branch_before_merging(pull_request)
1348 1350 merge_state = target_vcs.merge(
1349 1351 repo_id, workspace_id,
1350 1352 target_reference, source_vcs, pull_request.source_ref_parts,
1351 1353 dry_run=True, use_rebase=use_rebase,
1352 1354 close_branch=close_branch)
1353 1355
1354 1356 # Do not store the response if there was an unknown error.
1355 1357 if merge_state.failure_reason != MergeFailureReason.UNKNOWN:
1356 1358 pull_request._last_merge_source_rev = \
1357 1359 pull_request.source_ref_parts.commit_id
1358 1360 pull_request._last_merge_target_rev = target_reference.commit_id
1359 1361 pull_request.last_merge_status = merge_state.failure_reason
1360 1362 pull_request.shadow_merge_ref = merge_state.merge_ref
1361 1363 Session().add(pull_request)
1362 1364 Session().commit()
1363 1365
1364 1366 return merge_state
1365 1367
1366 1368 def _workspace_id(self, pull_request):
1367 1369 workspace_id = 'pr-%s' % pull_request.pull_request_id
1368 1370 return workspace_id
1369 1371
1370 1372 def merge_status_message(self, status_code):
1371 1373 """
1372 1374 Return a human friendly error message for the given merge status code.
1373 1375 """
1374 1376 return self.MERGE_STATUS_MESSAGES[status_code]
1375 1377
1376 1378 def generate_repo_data(self, repo, commit_id=None, branch=None,
1377 1379 bookmark=None, translator=None):
1378 1380 from rhodecode.model.repo import RepoModel
1379 1381
1380 1382 all_refs, selected_ref = \
1381 1383 self._get_repo_pullrequest_sources(
1382 1384 repo.scm_instance(), commit_id=commit_id,
1383 1385 branch=branch, bookmark=bookmark, translator=translator)
1384 1386
1385 1387 refs_select2 = []
1386 1388 for element in all_refs:
1387 1389 children = [{'id': x[0], 'text': x[1]} for x in element[0]]
1388 1390 refs_select2.append({'text': element[1], 'children': children})
1389 1391
1390 1392 return {
1391 1393 'user': {
1392 1394 'user_id': repo.user.user_id,
1393 1395 'username': repo.user.username,
1394 1396 'firstname': repo.user.first_name,
1395 1397 'lastname': repo.user.last_name,
1396 1398 'gravatar_link': h.gravatar_url(repo.user.email, 14),
1397 1399 },
1398 1400 'name': repo.repo_name,
1399 1401 'link': RepoModel().get_url(repo),
1400 1402 'description': h.chop_at_smart(repo.description_safe, '\n'),
1401 1403 'refs': {
1402 1404 'all_refs': all_refs,
1403 1405 'selected_ref': selected_ref,
1404 1406 'select2_refs': refs_select2
1405 1407 }
1406 1408 }
1407 1409
1408 1410 def generate_pullrequest_title(self, source, source_ref, target):
1409 1411 return u'{source}#{at_ref} to {target}'.format(
1410 1412 source=source,
1411 1413 at_ref=source_ref,
1412 1414 target=target,
1413 1415 )
1414 1416
1415 1417 def _cleanup_merge_workspace(self, pull_request):
1416 1418 # Merging related cleanup
1417 1419 repo_id = pull_request.target_repo.repo_id
1418 1420 target_scm = pull_request.target_repo.scm_instance()
1419 1421 workspace_id = self._workspace_id(pull_request)
1420 1422
1421 1423 try:
1422 1424 target_scm.cleanup_merge_workspace(repo_id, workspace_id)
1423 1425 except NotImplementedError:
1424 1426 pass
1425 1427
1426 1428 def _get_repo_pullrequest_sources(
1427 1429 self, repo, commit_id=None, branch=None, bookmark=None,
1428 1430 translator=None):
1429 1431 """
1430 1432 Return a structure with repo's interesting commits, suitable for
1431 1433 the selectors in pullrequest controller
1432 1434
1433 1435 :param commit_id: a commit that must be in the list somehow
1434 1436 and selected by default
1435 1437 :param branch: a branch that must be in the list and selected
1436 1438 by default - even if closed
1437 1439 :param bookmark: a bookmark that must be in the list and selected
1438 1440 """
1439 1441 _ = translator or get_current_request().translate
1440 1442
1441 1443 commit_id = safe_str(commit_id) if commit_id else None
1442 1444 branch = safe_str(branch) if branch else None
1443 1445 bookmark = safe_str(bookmark) if bookmark else None
1444 1446
1445 1447 selected = None
1446 1448
1447 1449 # order matters: first source that has commit_id in it will be selected
1448 1450 sources = []
1449 1451 sources.append(('book', repo.bookmarks.items(), _('Bookmarks'), bookmark))
1450 1452 sources.append(('branch', repo.branches.items(), _('Branches'), branch))
1451 1453
1452 1454 if commit_id:
1453 1455 ref_commit = (h.short_id(commit_id), commit_id)
1454 1456 sources.append(('rev', [ref_commit], _('Commit IDs'), commit_id))
1455 1457
1456 1458 sources.append(
1457 1459 ('branch', repo.branches_closed.items(), _('Closed Branches'), branch),
1458 1460 )
1459 1461
1460 1462 groups = []
1461 1463 for group_key, ref_list, group_name, match in sources:
1462 1464 group_refs = []
1463 1465 for ref_name, ref_id in ref_list:
1464 1466 ref_key = '%s:%s:%s' % (group_key, ref_name, ref_id)
1465 1467 group_refs.append((ref_key, ref_name))
1466 1468
1467 1469 if not selected:
1468 1470 if set([commit_id, match]) & set([ref_id, ref_name]):
1469 1471 selected = ref_key
1470 1472
1471 1473 if group_refs:
1472 1474 groups.append((group_refs, group_name))
1473 1475
1474 1476 if not selected:
1475 1477 ref = commit_id or branch or bookmark
1476 1478 if ref:
1477 1479 raise CommitDoesNotExistError(
1478 1480 'No commit refs could be found matching: %s' % ref)
1479 1481 elif repo.DEFAULT_BRANCH_NAME in repo.branches:
1480 1482 selected = 'branch:%s:%s' % (
1481 1483 repo.DEFAULT_BRANCH_NAME,
1482 1484 repo.branches[repo.DEFAULT_BRANCH_NAME]
1483 1485 )
1484 1486 elif repo.commit_ids:
1485 1487 # make the user select in this case
1486 1488 selected = None
1487 1489 else:
1488 1490 raise EmptyRepositoryError()
1489 1491 return groups, selected
1490 1492
1491 def get_diff(self, source_repo, source_ref_id, target_ref_id, context=DIFF_CONTEXT):
1493 def get_diff(self, source_repo, source_ref_id, target_ref_id,
1494 hide_whitespace_changes, diff_context):
1495
1492 1496 return self._get_diff_from_pr_or_version(
1493 source_repo, source_ref_id, target_ref_id, context=context)
1497 source_repo, source_ref_id, target_ref_id,
1498 hide_whitespace_changes=hide_whitespace_changes, diff_context=diff_context)
1494 1499
1495 1500 def _get_diff_from_pr_or_version(
1496 self, source_repo, source_ref_id, target_ref_id, context):
1501 self, source_repo, source_ref_id, target_ref_id,
1502 hide_whitespace_changes, diff_context):
1503
1497 1504 target_commit = source_repo.get_commit(
1498 1505 commit_id=safe_str(target_ref_id))
1499 1506 source_commit = source_repo.get_commit(
1500 1507 commit_id=safe_str(source_ref_id))
1501 1508 if isinstance(source_repo, Repository):
1502 1509 vcs_repo = source_repo.scm_instance()
1503 1510 else:
1504 1511 vcs_repo = source_repo
1505 1512
1506 1513 # TODO: johbo: In the context of an update, we cannot reach
1507 1514 # the old commit anymore with our normal mechanisms. It needs
1508 1515 # some sort of special support in the vcs layer to avoid this
1509 1516 # workaround.
1510 1517 if (source_commit.raw_id == vcs_repo.EMPTY_COMMIT_ID and
1511 1518 vcs_repo.alias == 'git'):
1512 1519 source_commit.raw_id = safe_str(source_ref_id)
1513 1520
1514 1521 log.debug('calculating diff between '
1515 1522 'source_ref:%s and target_ref:%s for repo `%s`',
1516 1523 target_ref_id, source_ref_id,
1517 1524 safe_unicode(vcs_repo.path))
1518 1525
1519 1526 vcs_diff = vcs_repo.get_diff(
1520 commit1=target_commit, commit2=source_commit, context=context)
1527 commit1=target_commit, commit2=source_commit,
1528 ignore_whitespace=hide_whitespace_changes, context=diff_context)
1521 1529 return vcs_diff
1522 1530
1523 1531 def _is_merge_enabled(self, pull_request):
1524 1532 return self._get_general_setting(
1525 1533 pull_request, 'rhodecode_pr_merge_enabled')
1526 1534
1527 1535 def _use_rebase_for_merging(self, pull_request):
1528 1536 repo_type = pull_request.target_repo.repo_type
1529 1537 if repo_type == 'hg':
1530 1538 return self._get_general_setting(
1531 1539 pull_request, 'rhodecode_hg_use_rebase_for_merging')
1532 1540 elif repo_type == 'git':
1533 1541 return self._get_general_setting(
1534 1542 pull_request, 'rhodecode_git_use_rebase_for_merging')
1535 1543
1536 1544 return False
1537 1545
1538 1546 def _close_branch_before_merging(self, pull_request):
1539 1547 repo_type = pull_request.target_repo.repo_type
1540 1548 if repo_type == 'hg':
1541 1549 return self._get_general_setting(
1542 1550 pull_request, 'rhodecode_hg_close_branch_before_merging')
1543 1551 elif repo_type == 'git':
1544 1552 return self._get_general_setting(
1545 1553 pull_request, 'rhodecode_git_close_branch_before_merging')
1546 1554
1547 1555 return False
1548 1556
1549 1557 def _get_general_setting(self, pull_request, settings_key, default=False):
1550 1558 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1551 1559 settings = settings_model.get_general_settings()
1552 1560 return settings.get(settings_key, default)
1553 1561
1554 1562 def _log_audit_action(self, action, action_data, user, pull_request):
1555 1563 audit_logger.store(
1556 1564 action=action,
1557 1565 action_data=action_data,
1558 1566 user=user,
1559 1567 repo=pull_request.target_repo)
1560 1568
1561 1569 def get_reviewer_functions(self):
1562 1570 """
1563 1571 Fetches functions for validation and fetching default reviewers.
1564 1572 If available we use the EE package, else we fallback to CE
1565 1573 package functions
1566 1574 """
1567 1575 try:
1568 1576 from rc_reviewers.utils import get_default_reviewers_data
1569 1577 from rc_reviewers.utils import validate_default_reviewers
1570 1578 except ImportError:
1571 1579 from rhodecode.apps.repository.utils import \
1572 1580 get_default_reviewers_data
1573 1581 from rhodecode.apps.repository.utils import \
1574 1582 validate_default_reviewers
1575 1583
1576 1584 return get_default_reviewers_data, validate_default_reviewers
1577 1585
1578 1586
1579 1587 class MergeCheck(object):
1580 1588 """
1581 1589 Perform Merge Checks and returns a check object which stores information
1582 1590 about merge errors, and merge conditions
1583 1591 """
1584 1592 TODO_CHECK = 'todo'
1585 1593 PERM_CHECK = 'perm'
1586 1594 REVIEW_CHECK = 'review'
1587 1595 MERGE_CHECK = 'merge'
1588 1596
1589 1597 def __init__(self):
1590 1598 self.review_status = None
1591 1599 self.merge_possible = None
1592 1600 self.merge_msg = ''
1593 1601 self.failed = None
1594 1602 self.errors = []
1595 1603 self.error_details = OrderedDict()
1596 1604
1597 1605 def push_error(self, error_type, message, error_key, details):
1598 1606 self.failed = True
1599 1607 self.errors.append([error_type, message])
1600 1608 self.error_details[error_key] = dict(
1601 1609 details=details,
1602 1610 error_type=error_type,
1603 1611 message=message
1604 1612 )
1605 1613
1606 1614 @classmethod
1607 1615 def validate(cls, pull_request, auth_user, translator, fail_early=False,
1608 1616 force_shadow_repo_refresh=False):
1609 1617 _ = translator
1610 1618 merge_check = cls()
1611 1619
1612 1620 # permissions to merge
1613 1621 user_allowed_to_merge = PullRequestModel().check_user_merge(
1614 1622 pull_request, auth_user)
1615 1623 if not user_allowed_to_merge:
1616 1624 log.debug("MergeCheck: cannot merge, approval is pending.")
1617 1625
1618 1626 msg = _('User `{}` not allowed to perform merge.').format(auth_user.username)
1619 1627 merge_check.push_error('error', msg, cls.PERM_CHECK, auth_user.username)
1620 1628 if fail_early:
1621 1629 return merge_check
1622 1630
1623 1631 # permission to merge into the target branch
1624 1632 target_commit_id = pull_request.target_ref_parts.commit_id
1625 1633 if pull_request.target_ref_parts.type == 'branch':
1626 1634 branch_name = pull_request.target_ref_parts.name
1627 1635 else:
1628 1636 # for mercurial we can always figure out the branch from the commit
1629 1637 # in case of bookmark
1630 1638 target_commit = pull_request.target_repo.get_commit(target_commit_id)
1631 1639 branch_name = target_commit.branch
1632 1640
1633 1641 rule, branch_perm = auth_user.get_rule_and_branch_permission(
1634 1642 pull_request.target_repo.repo_name, branch_name)
1635 1643 if branch_perm and branch_perm == 'branch.none':
1636 1644 msg = _('Target branch `{}` changes rejected by rule {}.').format(
1637 1645 branch_name, rule)
1638 1646 merge_check.push_error('error', msg, cls.PERM_CHECK, auth_user.username)
1639 1647 if fail_early:
1640 1648 return merge_check
1641 1649
1642 1650 # review status, must be always present
1643 1651 review_status = pull_request.calculated_review_status()
1644 1652 merge_check.review_status = review_status
1645 1653
1646 1654 status_approved = review_status == ChangesetStatus.STATUS_APPROVED
1647 1655 if not status_approved:
1648 1656 log.debug("MergeCheck: cannot merge, approval is pending.")
1649 1657
1650 1658 msg = _('Pull request reviewer approval is pending.')
1651 1659
1652 1660 merge_check.push_error(
1653 1661 'warning', msg, cls.REVIEW_CHECK, review_status)
1654 1662
1655 1663 if fail_early:
1656 1664 return merge_check
1657 1665
1658 1666 # left over TODOs
1659 1667 todos = CommentsModel().get_unresolved_todos(pull_request)
1660 1668 if todos:
1661 1669 log.debug("MergeCheck: cannot merge, {} "
1662 1670 "unresolved todos left.".format(len(todos)))
1663 1671
1664 1672 if len(todos) == 1:
1665 1673 msg = _('Cannot merge, {} TODO still not resolved.').format(
1666 1674 len(todos))
1667 1675 else:
1668 1676 msg = _('Cannot merge, {} TODOs still not resolved.').format(
1669 1677 len(todos))
1670 1678
1671 1679 merge_check.push_error('warning', msg, cls.TODO_CHECK, todos)
1672 1680
1673 1681 if fail_early:
1674 1682 return merge_check
1675 1683
1676 1684 # merge possible, here is the filesystem simulation + shadow repo
1677 1685 merge_status, msg = PullRequestModel().merge_status(
1678 1686 pull_request, translator=translator,
1679 1687 force_shadow_repo_refresh=force_shadow_repo_refresh)
1680 1688 merge_check.merge_possible = merge_status
1681 1689 merge_check.merge_msg = msg
1682 1690 if not merge_status:
1683 1691 log.debug(
1684 1692 "MergeCheck: cannot merge, pull request merge not possible.")
1685 1693 merge_check.push_error('warning', msg, cls.MERGE_CHECK, None)
1686 1694
1687 1695 if fail_early:
1688 1696 return merge_check
1689 1697
1690 1698 log.debug('MergeCheck: is failed: %s', merge_check.failed)
1691 1699 return merge_check
1692 1700
1693 1701 @classmethod
1694 1702 def get_merge_conditions(cls, pull_request, translator):
1695 1703 _ = translator
1696 1704 merge_details = {}
1697 1705
1698 1706 model = PullRequestModel()
1699 1707 use_rebase = model._use_rebase_for_merging(pull_request)
1700 1708
1701 1709 if use_rebase:
1702 1710 merge_details['merge_strategy'] = dict(
1703 1711 details={},
1704 1712 message=_('Merge strategy: rebase')
1705 1713 )
1706 1714 else:
1707 1715 merge_details['merge_strategy'] = dict(
1708 1716 details={},
1709 1717 message=_('Merge strategy: explicit merge commit')
1710 1718 )
1711 1719
1712 1720 close_branch = model._close_branch_before_merging(pull_request)
1713 1721 if close_branch:
1714 1722 repo_type = pull_request.target_repo.repo_type
1715 1723 if repo_type == 'hg':
1716 1724 close_msg = _('Source branch will be closed after merge.')
1717 1725 elif repo_type == 'git':
1718 1726 close_msg = _('Source branch will be deleted after merge.')
1719 1727
1720 1728 merge_details['close_branch'] = dict(
1721 1729 details={},
1722 1730 message=close_msg
1723 1731 )
1724 1732
1725 1733 return merge_details
1726 1734
1727 1735 ChangeTuple = collections.namedtuple(
1728 1736 'ChangeTuple', ['added', 'common', 'removed', 'total'])
1729 1737
1730 1738 FileChangeTuple = collections.namedtuple(
1731 1739 'FileChangeTuple', ['added', 'modified', 'removed'])
@@ -1,354 +1,350 b''
1 1 ## -*- coding: utf-8 -*-
2 2
3 3 <%inherit file="/base/base.mako"/>
4 4 <%namespace name="diff_block" file="/changeset/diff_block.mako"/>
5 5
6 6 <%def name="title()">
7 7 ${_('%s Commit') % c.repo_name} - ${h.show_id(c.commit)}
8 8 %if c.rhodecode_name:
9 9 &middot; ${h.branding(c.rhodecode_name)}
10 10 %endif
11 11 </%def>
12 12
13 13 <%def name="menu_bar_nav()">
14 14 ${self.menu_items(active='repositories')}
15 15 </%def>
16 16
17 17 <%def name="menu_bar_subnav()">
18 18 ${self.repo_menu(active='changelog')}
19 19 </%def>
20 20
21 21 <%def name="main()">
22 22 <script>
23 23 // TODO: marcink switch this to pyroutes
24 24 AJAX_COMMENT_DELETE_URL = "${h.route_path('repo_commit_comment_delete',repo_name=c.repo_name,commit_id=c.commit.raw_id,comment_id='__COMMENT_ID__')}";
25 25 templateContext.commit_data.commit_id = "${c.commit.raw_id}";
26 26 </script>
27 27 <div class="box">
28 28 <div class="title">
29 29 ${self.repo_page_title(c.rhodecode_db_repo)}
30 30 </div>
31 31
32 32 <div id="changeset_compare_view_content" class="summary changeset">
33 33 <div class="summary-detail">
34 34 <div class="summary-detail-header">
35 35 <div class="breadcrumbs files_location">
36 36 <h4>
37 37 ${_('Commit')}
38 38
39 39 <code>
40 40 ${h.show_id(c.commit)}
41 41 </code>
42 42 <i class="tooltip icon-clipboard clipboard-action" data-clipboard-text="${c.commit.raw_id}" title="${_('Copy the full commit id')}"></i>
43 43 % if hasattr(c.commit, 'phase'):
44 44 <span class="tag phase-${c.commit.phase} tooltip" title="${_('Commit phase')}">${c.commit.phase}</span>
45 45 % endif
46 46
47 47 ## obsolete commits
48 48 % if hasattr(c.commit, 'obsolete'):
49 49 % if c.commit.obsolete:
50 50 <span class="tag obsolete-${c.commit.obsolete} tooltip" title="${_('Evolve State')}">${_('obsolete')}</span>
51 51 % endif
52 52 % endif
53 53
54 54 ## hidden commits
55 55 % if hasattr(c.commit, 'hidden'):
56 56 % if c.commit.hidden:
57 57 <span class="tag hidden-${c.commit.hidden} tooltip" title="${_('Evolve State')}">${_('hidden')}</span>
58 58 % endif
59 59 % endif
60 60 </h4>
61 61
62 62 </div>
63 63 <div class="pull-right">
64 64 <span id="parent_link">
65 65 <a href="#parentCommit" title="${_('Parent Commit')}"><i class="icon-left icon-no-margin"></i>${_('parent')}</a>
66 66 </span>
67 67 |
68 68 <span id="child_link">
69 69 <a href="#childCommit" title="${_('Child Commit')}">${_('child')}<i class="icon-right icon-no-margin"></i></a>
70 70 </span>
71 71 </div>
72 72 </div>
73 73
74 74 <div class="fieldset">
75 75 <div class="left-label">
76 76 ${_('Description')}:
77 77 </div>
78 78 <div class="right-content">
79 79 <div id="trimmed_message_box" class="commit">${h.urlify_commit_message(c.commit.message,c.repo_name)}</div>
80 80 <div id="message_expand" style="display:none;">
81 81 ${_('Expand')}
82 82 </div>
83 83 </div>
84 84 </div>
85 85
86 86 %if c.statuses:
87 87 <div class="fieldset">
88 88 <div class="left-label">
89 89 ${_('Commit status')}:
90 90 </div>
91 91 <div class="right-content">
92 92 <div class="changeset-status-ico">
93 93 <div class="${'flag_status %s' % c.statuses[0]} pull-left"></div>
94 94 </div>
95 95 <div title="${_('Commit status')}" class="changeset-status-lbl">[${h.commit_status_lbl(c.statuses[0])}]</div>
96 96 </div>
97 97 </div>
98 98 %endif
99 99
100 100 <div class="fieldset">
101 101 <div class="left-label">
102 102 ${_('References')}:
103 103 </div>
104 104 <div class="right-content">
105 105 <div class="tags">
106 106
107 107 %if c.commit.merge:
108 108 <span class="mergetag tag">
109 109 <i class="icon-merge"></i>${_('merge')}
110 110 </span>
111 111 %endif
112 112
113 113 %if h.is_hg(c.rhodecode_repo):
114 114 %for book in c.commit.bookmarks:
115 115 <span class="booktag tag" title="${h.tooltip(_('Bookmark %s') % book)}">
116 116 <a href="${h.route_path('repo_files:default_path',repo_name=c.repo_name,commit_id=c.commit.raw_id,_query=dict(at=book))}"><i class="icon-bookmark"></i>${h.shorter(book)}</a>
117 117 </span>
118 118 %endfor
119 119 %endif
120 120
121 121 %for tag in c.commit.tags:
122 122 <span class="tagtag tag" title="${h.tooltip(_('Tag %s') % tag)}">
123 123 <a href="${h.route_path('repo_files:default_path',repo_name=c.repo_name,commit_id=c.commit.raw_id,_query=dict(at=tag))}"><i class="icon-tag"></i>${tag}</a>
124 124 </span>
125 125 %endfor
126 126
127 127 %if c.commit.branch:
128 128 <span class="branchtag tag" title="${h.tooltip(_('Branch %s') % c.commit.branch)}">
129 129 <a href="${h.route_path('repo_files:default_path',repo_name=c.repo_name,commit_id=c.commit.raw_id,_query=dict(at=c.commit.branch))}"><i class="icon-code-fork"></i>${h.shorter(c.commit.branch)}</a>
130 130 </span>
131 131 %endif
132 132 </div>
133 133 </div>
134 134 </div>
135 135
136 136 <div class="fieldset">
137 137 <div class="left-label">
138 138 ${_('Diff options')}:
139 139 </div>
140 140 <div class="right-content">
141 141 <div class="diff-actions">
142 142 <a href="${h.route_path('repo_commit_raw',repo_name=c.repo_name,commit_id=c.commit.raw_id)}" class="tooltip" title="${h.tooltip(_('Raw diff'))}">
143 143 ${_('Raw Diff')}
144 144 </a>
145 145 |
146 146 <a href="${h.route_path('repo_commit_patch',repo_name=c.repo_name,commit_id=c.commit.raw_id)}" class="tooltip" title="${h.tooltip(_('Patch diff'))}">
147 147 ${_('Patch Diff')}
148 148 </a>
149 149 |
150 150 <a href="${h.route_path('repo_commit_download',repo_name=c.repo_name,commit_id=c.commit.raw_id,_query=dict(diff='download'))}" class="tooltip" title="${h.tooltip(_('Download diff'))}">
151 151 ${_('Download Diff')}
152 152 </a>
153 |
154 ${c.ignorews_url(request)}
155 |
156 ${c.context_url(request)}
157 153 </div>
158 154 </div>
159 155 </div>
160 156
161 157 <div class="fieldset">
162 158 <div class="left-label">
163 159 ${_('Comments')}:
164 160 </div>
165 161 <div class="right-content">
166 162 <div class="comments-number">
167 163 %if c.comments:
168 164 <a href="#comments">${_ungettext("%d Commit comment", "%d Commit comments", len(c.comments)) % len(c.comments)}</a>,
169 165 %else:
170 166 ${_ungettext("%d Commit comment", "%d Commit comments", len(c.comments)) % len(c.comments)}
171 167 %endif
172 168 %if c.inline_cnt:
173 169 <a href="#" onclick="return Rhodecode.comments.nextComment();" id="inline-comments-counter">${_ungettext("%d Inline Comment", "%d Inline Comments", c.inline_cnt) % c.inline_cnt}</a>
174 170 %else:
175 171 ${_ungettext("%d Inline Comment", "%d Inline Comments", c.inline_cnt) % c.inline_cnt}
176 172 %endif
177 173 </div>
178 174 </div>
179 175 </div>
180 176
181 177 <div class="fieldset">
182 178 <div class="left-label">
183 179 ${_('Unresolved TODOs')}:
184 180 </div>
185 181 <div class="right-content">
186 182 <div class="comments-number">
187 183 % if c.unresolved_comments:
188 184 % for co in c.unresolved_comments:
189 185 <a class="permalink" href="#comment-${co.comment_id}" onclick="Rhodecode.comments.scrollToComment($('#comment-${co.comment_id}'))"> #${co.comment_id}</a>${'' if loop.last else ','}
190 186 % endfor
191 187 % else:
192 188 ${_('There are no unresolved TODOs')}
193 189 % endif
194 190 </div>
195 191 </div>
196 192 </div>
197 193
198 194 </div> <!-- end summary-detail -->
199 195
200 196 <div id="commit-stats" class="sidebar-right">
201 197 <div class="summary-detail-header">
202 198 <h4 class="item">
203 199 ${_('Author')}
204 200 </h4>
205 201 </div>
206 202 <div class="sidebar-right-content">
207 203 ${self.gravatar_with_user(c.commit.author)}
208 204 <div class="user-inline-data">- ${h.age_component(c.commit.date)}</div>
209 205 </div>
210 206 </div><!-- end sidebar -->
211 207 </div> <!-- end summary -->
212 208 <div class="cs_files">
213 209 <%namespace name="cbdiffs" file="/codeblocks/diffs.mako"/>
214 210 ${cbdiffs.render_diffset_menu(c.changes[c.commit.raw_id])}
215 211 ${cbdiffs.render_diffset(
216 212 c.changes[c.commit.raw_id], commit=c.commit, use_comments=True,inline_comments=c.inline_comments )}
217 213 </div>
218 214
219 215 ## template for inline comment form
220 216 <%namespace name="comment" file="/changeset/changeset_file_comment.mako"/>
221 217
222 218 ## render comments
223 219 ${comment.generate_comments(c.comments)}
224 220
225 221 ## main comment form and it status
226 222 ${comment.comments(h.route_path('repo_commit_comment_create', repo_name=c.repo_name, commit_id=c.commit.raw_id),
227 223 h.commit_status(c.rhodecode_db_repo, c.commit.raw_id))}
228 224 </div>
229 225
230 226 ## FORM FOR MAKING JS ACTION AS CHANGESET COMMENTS
231 227 <script type="text/javascript">
232 228
233 229 $(document).ready(function() {
234 230
235 231 var boxmax = parseInt($('#trimmed_message_box').css('max-height'), 10);
236 232 if($('#trimmed_message_box').height() === boxmax){
237 233 $('#message_expand').show();
238 234 }
239 235
240 236 $('#message_expand').on('click', function(e){
241 237 $('#trimmed_message_box').css('max-height', 'none');
242 238 $(this).hide();
243 239 });
244 240
245 241 $('.show-inline-comments').on('click', function(e){
246 242 var boxid = $(this).attr('data-comment-id');
247 243 var button = $(this);
248 244
249 245 if(button.hasClass("comments-visible")) {
250 246 $('#{0} .inline-comments'.format(boxid)).each(function(index){
251 247 $(this).hide();
252 248 });
253 249 button.removeClass("comments-visible");
254 250 } else {
255 251 $('#{0} .inline-comments'.format(boxid)).each(function(index){
256 252 $(this).show();
257 253 });
258 254 button.addClass("comments-visible");
259 255 }
260 256 });
261 257
262 258
263 259 // next links
264 260 $('#child_link').on('click', function(e){
265 261 // fetch via ajax what is going to be the next link, if we have
266 262 // >1 links show them to user to choose
267 263 if(!$('#child_link').hasClass('disabled')){
268 264 $.ajax({
269 265 url: '${h.route_path('repo_commit_children',repo_name=c.repo_name, commit_id=c.commit.raw_id)}',
270 266 success: function(data) {
271 267 if(data.results.length === 0){
272 268 $('#child_link').html("${_('No Child Commits')}").addClass('disabled');
273 269 }
274 270 if(data.results.length === 1){
275 271 var commit = data.results[0];
276 272 window.location = pyroutes.url('repo_commit', {'repo_name': '${c.repo_name}','commit_id': commit.raw_id});
277 273 }
278 274 else if(data.results.length === 2){
279 275 $('#child_link').addClass('disabled');
280 276 $('#child_link').addClass('double');
281 277 var _html = '';
282 278 _html +='<a title="__title__" href="__url__">__rev__</a> '
283 279 .replace('__rev__','r{0}:{1}'.format(data.results[0].revision, data.results[0].raw_id.substr(0,6)))
284 280 .replace('__title__', data.results[0].message)
285 281 .replace('__url__', pyroutes.url('repo_commit', {'repo_name': '${c.repo_name}','commit_id': data.results[0].raw_id}));
286 282 _html +=' | ';
287 283 _html +='<a title="__title__" href="__url__">__rev__</a> '
288 284 .replace('__rev__','r{0}:{1}'.format(data.results[1].revision, data.results[1].raw_id.substr(0,6)))
289 285 .replace('__title__', data.results[1].message)
290 286 .replace('__url__', pyroutes.url('repo_commit', {'repo_name': '${c.repo_name}','commit_id': data.results[1].raw_id}));
291 287 $('#child_link').html(_html);
292 288 }
293 289 }
294 290 });
295 291 e.preventDefault();
296 292 }
297 293 });
298 294
299 295 // prev links
300 296 $('#parent_link').on('click', function(e){
301 297 // fetch via ajax what is going to be the next link, if we have
302 298 // >1 links show them to user to choose
303 299 if(!$('#parent_link').hasClass('disabled')){
304 300 $.ajax({
305 301 url: '${h.route_path("repo_commit_parents",repo_name=c.repo_name, commit_id=c.commit.raw_id)}',
306 302 success: function(data) {
307 303 if(data.results.length === 0){
308 304 $('#parent_link').html('${_('No Parent Commits')}').addClass('disabled');
309 305 }
310 306 if(data.results.length === 1){
311 307 var commit = data.results[0];
312 308 window.location = pyroutes.url('repo_commit', {'repo_name': '${c.repo_name}','commit_id': commit.raw_id});
313 309 }
314 310 else if(data.results.length === 2){
315 311 $('#parent_link').addClass('disabled');
316 312 $('#parent_link').addClass('double');
317 313 var _html = '';
318 314 _html +='<a title="__title__" href="__url__">Parent __rev__</a>'
319 315 .replace('__rev__','r{0}:{1}'.format(data.results[0].revision, data.results[0].raw_id.substr(0,6)))
320 316 .replace('__title__', data.results[0].message)
321 317 .replace('__url__', pyroutes.url('repo_commit', {'repo_name': '${c.repo_name}','commit_id': data.results[0].raw_id}));
322 318 _html +=' | ';
323 319 _html +='<a title="__title__" href="__url__">Parent __rev__</a>'
324 320 .replace('__rev__','r{0}:{1}'.format(data.results[1].revision, data.results[1].raw_id.substr(0,6)))
325 321 .replace('__title__', data.results[1].message)
326 322 .replace('__url__', pyroutes.url('repo_commit', {'repo_name': '${c.repo_name}','commit_id': data.results[1].raw_id}));
327 323 $('#parent_link').html(_html);
328 324 }
329 325 }
330 326 });
331 327 e.preventDefault();
332 328 }
333 329 });
334 330
335 331 if (location.hash) {
336 332 var result = splitDelimitedHash(location.hash);
337 333 var line = $('html').find(result.loc);
338 334 if (line.length > 0){
339 335 offsetScroll(line, 70);
340 336 }
341 337 }
342 338
343 339 // browse tree @ revision
344 340 $('#files_link').on('click', function(e){
345 341 window.location = '${h.route_path('repo_files:default_path',repo_name=c.repo_name, commit_id=c.commit.raw_id)}';
346 342 e.preventDefault();
347 343 });
348 344
349 345 // inject comments into their proper positions
350 346 var file_comments = $('.inline-comment-placeholder');
351 347 })
352 348 </script>
353 349
354 350 </%def>
@@ -1,131 +1,108 b''
1 1 ## -*- coding: utf-8 -*-
2 2 <%inherit file="/base/base.mako"/>
3 3
4 4 <%def name="title()">
5 5 ${_('%s Commits') % c.repo_name} -
6 6 r${c.commit_ranges[0].idx}:${h.short_id(c.commit_ranges[0].raw_id)}
7 7 ...
8 8 r${c.commit_ranges[-1].idx}:${h.short_id(c.commit_ranges[-1].raw_id)}
9 9 ${_ungettext('(%s commit)','(%s commits)', len(c.commit_ranges)) % len(c.commit_ranges)}
10 10 %if c.rhodecode_name:
11 11 &middot; ${h.branding(c.rhodecode_name)}
12 12 %endif
13 13 </%def>
14 14
15 15 <%def name="breadcrumbs_links()">
16 16 ${_('Commits')} -
17 17 r${c.commit_ranges[0].idx}:${h.short_id(c.commit_ranges[0].raw_id)}
18 18 ...
19 19 r${c.commit_ranges[-1].idx}:${h.short_id(c.commit_ranges[-1].raw_id)}
20 20 ${_ungettext('(%s commit)','(%s commits)', len(c.commit_ranges)) % len(c.commit_ranges)}
21 21 </%def>
22 22
23 23 <%def name="menu_bar_nav()">
24 24 ${self.menu_items(active='repositories')}
25 25 </%def>
26 26
27 27 <%def name="menu_bar_subnav()">
28 28 ${self.repo_menu(active='changelog')}
29 29 </%def>
30 30
31 31 <%def name="main()">
32 32 <div class="summary-header">
33 33 <div class="title">
34 34 ${self.repo_page_title(c.rhodecode_db_repo)}
35 35 </div>
36 36 </div>
37 37
38 38
39 39 <div class="summary changeset">
40 40 <div class="summary-detail">
41 41 <div class="summary-detail-header">
42 42 <span class="breadcrumbs files_location">
43 43 <h4>
44 44 ${_('Commit Range')}
45 45 <code>
46 46 r${c.commit_ranges[0].idx}:${h.short_id(c.commit_ranges[0].raw_id)}...r${c.commit_ranges[-1].idx}:${h.short_id(c.commit_ranges[-1].raw_id)}
47 47 </code>
48 48 </h4>
49 49 </span>
50 50 </div>
51 51
52 52 <div class="fieldset">
53 53 <div class="left-label">
54 54 ${_('Diff option')}:
55 55 </div>
56 56 <div class="right-content">
57 57 <div class="btn btn-primary">
58 58 <a href="${h.route_path('repo_compare',
59 59 repo_name=c.repo_name,
60 60 source_ref_type='rev',
61 61 source_ref=getattr(c.commit_ranges[0].parents[0] if c.commit_ranges[0].parents else h.EmptyCommit(), 'raw_id'),
62 62 target_ref_type='rev',
63 63 target_ref=c.commit_ranges[-1].raw_id)}"
64 64 >
65 65 ${_('Show combined compare')}
66 66 </a>
67 67 </div>
68 68 </div>
69 69 </div>
70 70
71 <%doc>
72 ##TODO(marcink): implement this and diff menus
73 <div class="fieldset">
74 <div class="left-label">
75 ${_('Diff options')}:
76 </div>
77 <div class="right-content">
78 <div class="diff-actions">
79 <a href="${h.route_path('repo_commit_raw',repo_name=c.repo_name,commit_id='?')}" class="tooltip" title="${h.tooltip(_('Raw diff'))}">
80 ${_('Raw Diff')}
81 </a>
82 |
83 <a href="${h.route_path('repo_commit_patch',repo_name=c.repo_name,commit_id='?')}" class="tooltip" title="${h.tooltip(_('Patch diff'))}">
84 ${_('Patch Diff')}
85 </a>
86 |
87 <a href="${h.route_path('repo_commit_download',repo_name=c.repo_name,commit_id='?',_query=dict(diff='download'))}" class="tooltip" title="${h.tooltip(_('Download diff'))}">
88 ${_('Download Diff')}
89 </a>
90 </div>
91 </div>
92 </div>
93 </%doc>
94 71 </div> <!-- end summary-detail -->
95 72
96 73 </div> <!-- end summary -->
97 74
98 75 <div id="changeset_compare_view_content">
99 76 <div class="pull-left">
100 77 <div class="btn-group">
101 78 <a
102 79 class="btn"
103 80 href="#"
104 81 onclick="$('.compare_select').show();$('.compare_select_hidden').hide(); return false">
105 82 ${_ungettext('Expand %s commit','Expand %s commits', len(c.commit_ranges)) % len(c.commit_ranges)}
106 83 </a>
107 84 <a
108 85 class="btn"
109 86 href="#"
110 87 onclick="$('.compare_select').hide();$('.compare_select_hidden').show(); return false">
111 88 ${_ungettext('Collapse %s commit','Collapse %s commits', len(c.commit_ranges)) % len(c.commit_ranges)}
112 89 </a>
113 90 </div>
114 91 </div>
115 92 ## Commit range generated below
116 93 <%include file="../compare/compare_commits.mako"/>
117 94 <div class="cs_files">
118 95 <%namespace name="cbdiffs" file="/codeblocks/diffs.mako"/>
119 96 <%namespace name="comment" file="/changeset/changeset_file_comment.mako"/>
120 97 <%namespace name="diff_block" file="/changeset/diff_block.mako"/>
121 98 ${cbdiffs.render_diffset_menu()}
122 99 %for commit in c.commit_ranges:
123 100 ${cbdiffs.render_diffset(
124 101 diffset=c.changes[commit.raw_id],
125 102 collapse_when_files_over=5,
126 103 commit=commit,
127 104 )}
128 105 %endfor
129 106 </div>
130 107 </div>
131 108 </%def>
@@ -1,971 +1,1016 b''
1 1 <%namespace name="commentblock" file="/changeset/changeset_file_comment.mako"/>
2 2
3 3 <%def name="diff_line_anchor(commit, filename, line, type)"><%
4 4 return '%s_%s_%i' % (h.md5_safe(commit+filename), type, line)
5 5 %></%def>
6 6
7 7 <%def name="action_class(action)">
8 8 <%
9 9 return {
10 10 '-': 'cb-deletion',
11 11 '+': 'cb-addition',
12 12 ' ': 'cb-context',
13 13 }.get(action, 'cb-empty')
14 14 %>
15 15 </%def>
16 16
17 17 <%def name="op_class(op_id)">
18 18 <%
19 19 return {
20 20 DEL_FILENODE: 'deletion', # file deleted
21 21 BIN_FILENODE: 'warning' # binary diff hidden
22 22 }.get(op_id, 'addition')
23 23 %>
24 24 </%def>
25 25
26 26
27 27
28 28 <%def name="render_diffset(diffset, commit=None,
29 29
30 30 # collapse all file diff entries when there are more than this amount of files in the diff
31 31 collapse_when_files_over=20,
32 32
33 33 # collapse lines in the diff when more than this amount of lines changed in the file diff
34 34 lines_changed_limit=500,
35 35
36 36 # add a ruler at to the output
37 37 ruler_at_chars=0,
38 38
39 39 # show inline comments
40 40 use_comments=False,
41 41
42 42 # disable new comments
43 43 disable_new_comments=False,
44 44
45 45 # special file-comments that were deleted in previous versions
46 46 # it's used for showing outdated comments for deleted files in a PR
47 47 deleted_files_comments=None,
48 48
49 49 # for cache purpose
50 50 inline_comments=None,
51 51
52 52 )">
53 53 %if use_comments:
54 54 <div id="cb-comments-inline-container-template" class="js-template">
55 55 ${inline_comments_container([], inline_comments)}
56 56 </div>
57 57 <div class="js-template" id="cb-comment-inline-form-template">
58 58 <div class="comment-inline-form ac">
59 59
60 60 %if c.rhodecode_user.username != h.DEFAULT_USER:
61 61 ## render template for inline comments
62 62 ${commentblock.comment_form(form_type='inline')}
63 63 %else:
64 64 ${h.form('', class_='inline-form comment-form-login', method='get')}
65 65 <div class="pull-left">
66 66 <div class="comment-help pull-right">
67 67 ${_('You need to be logged in to leave comments.')} <a href="${h.route_path('login', _query={'came_from': h.current_route_path(request)})}">${_('Login now')}</a>
68 68 </div>
69 69 </div>
70 70 <div class="comment-button pull-right">
71 71 <button type="button" class="cb-comment-cancel" onclick="return Rhodecode.comments.cancelComment(this);">
72 72 ${_('Cancel')}
73 73 </button>
74 74 </div>
75 75 <div class="clearfix"></div>
76 76 ${h.end_form()}
77 77 %endif
78 78 </div>
79 79 </div>
80 80
81 81 %endif
82 82 <%
83 83 collapse_all = len(diffset.files) > collapse_when_files_over
84 84 %>
85 85
86 86 %if c.user_session_attrs["diffmode"] == 'sideside':
87 87 <style>
88 88 .wrapper {
89 89 max-width: 1600px !important;
90 90 }
91 91 </style>
92 92 %endif
93 93
94 94 %if ruler_at_chars:
95 95 <style>
96 96 .diff table.cb .cb-content:after {
97 97 content: "";
98 98 border-left: 1px solid blue;
99 99 position: absolute;
100 100 top: 0;
101 101 height: 18px;
102 102 opacity: .2;
103 103 z-index: 10;
104 104 //## +5 to account for diff action (+/-)
105 105 left: ${ruler_at_chars + 5}ch;
106 106 </style>
107 107 %endif
108 108
109 109 <div class="diffset ${disable_new_comments and 'diffset-comments-disabled'}">
110 110 <div class="diffset-heading ${diffset.limited_diff and 'diffset-heading-warning' or ''}">
111 111 %if commit:
112 112 <div class="pull-right">
113 113 <a class="btn tooltip" title="${h.tooltip(_('Browse Files at revision {}').format(commit.raw_id))}" href="${h.route_path('repo_files',repo_name=diffset.repo_name, commit_id=commit.raw_id, f_path='')}">
114 114 ${_('Browse Files')}
115 115 </a>
116 116 </div>
117 117 %endif
118 118 <h2 class="clearinner">
119 119 ## invidual commit
120 120 % if commit:
121 121 <a class="tooltip revision" title="${h.tooltip(commit.message)}" href="${h.route_path('repo_commit',repo_name=diffset.repo_name,commit_id=commit.raw_id)}">${('r%s:%s' % (commit.idx,h.short_id(commit.raw_id)))}</a> -
122 122 ${h.age_component(commit.date)}
123 123 % if diffset.limited_diff:
124 124 - ${_('The requested commit is too big and content was truncated.')}
125 125 ${_ungettext('%(num)s file changed.', '%(num)s files changed.', diffset.changed_files) % {'num': diffset.changed_files}}
126 126 <a href="${h.current_route_path(request, fulldiff=1)}" onclick="return confirm('${_("Showing a big diff might take some time and resources, continue?")}')">${_('Show full diff')}</a>
127 127 % elif hasattr(c, 'commit_ranges') and len(c.commit_ranges) > 1:
128 128 ## compare diff, has no file-selector and we want to show stats anyway
129 129 ${_ungettext('{num} file changed: {linesadd} inserted, ''{linesdel} deleted',
130 130 '{num} files changed: {linesadd} inserted, {linesdel} deleted', diffset.changed_files) \
131 131 .format(num=diffset.changed_files, linesadd=diffset.lines_added, linesdel=diffset.lines_deleted)}
132 132 % endif
133 133 % else:
134 134 ## pull requests/compare
135 135 ${_('File Changes')}
136 136 % endif
137 137
138 138 </h2>
139 139 </div>
140 140
141 141 %if diffset.has_hidden_changes:
142 142 <p class="empty_data">${_('Some changes may be hidden')}</p>
143 143 %elif not diffset.files:
144 144 <p class="empty_data">${_('No files')}</p>
145 145 %endif
146 146
147 147 <div class="filediffs">
148 148
149 149 ## initial value could be marked as False later on
150 150 <% over_lines_changed_limit = False %>
151 151 %for i, filediff in enumerate(diffset.files):
152 152
153 153 <%
154 154 lines_changed = filediff.patch['stats']['added'] + filediff.patch['stats']['deleted']
155 155 over_lines_changed_limit = lines_changed > lines_changed_limit
156 156 %>
157 157 ## anchor with support of sticky header
158 158 <div class="anchor" id="a_${h.FID(filediff.raw_id, filediff.patch['filename'])}"></div>
159 159
160 160 <input ${(collapse_all and 'checked' or '')} class="filediff-collapse-state" id="filediff-collapse-${id(filediff)}" type="checkbox" onchange="updateSticky();">
161 161 <div
162 162 class="filediff"
163 163 data-f-path="${filediff.patch['filename']}"
164 164 data-anchor-id="${h.FID(filediff.raw_id, filediff.patch['filename'])}"
165 165 >
166 166 <label for="filediff-collapse-${id(filediff)}" class="filediff-heading">
167 167 <div class="filediff-collapse-indicator"></div>
168 168 ${diff_ops(filediff)}
169 169 </label>
170 170
171 171 ${diff_menu(filediff, use_comments=use_comments)}
172 172 <table data-f-path="${filediff.patch['filename']}" data-anchor-id="${h.FID(filediff.raw_id, filediff.patch['filename'])}" class="code-visible-block cb cb-diff-${c.user_session_attrs["diffmode"]} code-highlight ${(over_lines_changed_limit and 'cb-collapsed' or '')}">
173 173
174 174 ## new/deleted/empty content case
175 175 % if not filediff.hunks:
176 176 ## Comment container, on "fakes" hunk that contains all data to render comments
177 177 ${render_hunk_lines(filediff, c.user_session_attrs["diffmode"], filediff.hunk_ops, use_comments=use_comments, inline_comments=inline_comments)}
178 178 % endif
179 179
180 180 %if filediff.limited_diff:
181 181 <tr class="cb-warning cb-collapser">
182 182 <td class="cb-text" ${(c.user_session_attrs["diffmode"] == 'unified' and 'colspan=4' or 'colspan=6')}>
183 183 ${_('The requested commit is too big and content was truncated.')} <a href="${h.current_route_path(request, fulldiff=1)}" onclick="return confirm('${_("Showing a big diff might take some time and resources, continue?")}')">${_('Show full diff')}</a>
184 184 </td>
185 185 </tr>
186 186 %else:
187 187 %if over_lines_changed_limit:
188 188 <tr class="cb-warning cb-collapser">
189 189 <td class="cb-text" ${(c.user_session_attrs["diffmode"] == 'unified' and 'colspan=4' or 'colspan=6')}>
190 190 ${_('This diff has been collapsed as it changes many lines, (%i lines changed)' % lines_changed)}
191 191 <a href="#" class="cb-expand"
192 192 onclick="$(this).closest('table').removeClass('cb-collapsed'); updateSticky(); return false;">${_('Show them')}
193 193 </a>
194 194 <a href="#" class="cb-collapse"
195 195 onclick="$(this).closest('table').addClass('cb-collapsed'); updateSticky(); return false;">${_('Hide them')}
196 196 </a>
197 197 </td>
198 198 </tr>
199 199 %endif
200 200 %endif
201 201
202 202 % for hunk in filediff.hunks:
203 203 <tr class="cb-hunk">
204 204 <td ${(c.user_session_attrs["diffmode"] == 'unified' and 'colspan=3' or '')}>
205 205 ## TODO: dan: add ajax loading of more context here
206 206 ## <a href="#">
207 207 <i class="icon-more"></i>
208 208 ## </a>
209 209 </td>
210 210 <td ${(c.user_session_attrs["diffmode"] == 'sideside' and 'colspan=5' or '')}>
211 211 @@
212 212 -${hunk.source_start},${hunk.source_length}
213 213 +${hunk.target_start},${hunk.target_length}
214 214 ${hunk.section_header}
215 215 </td>
216 216 </tr>
217 217 ${render_hunk_lines(filediff, c.user_session_attrs["diffmode"], hunk, use_comments=use_comments, inline_comments=inline_comments)}
218 218 % endfor
219 219
220 220 <% unmatched_comments = (inline_comments or {}).get(filediff.patch['filename'], {}) %>
221 221
222 222 ## outdated comments that do not fit into currently displayed lines
223 223 % for lineno, comments in unmatched_comments.items():
224 224
225 225 %if c.user_session_attrs["diffmode"] == 'unified':
226 226 % if loop.index == 0:
227 227 <tr class="cb-hunk">
228 228 <td colspan="3"></td>
229 229 <td>
230 230 <div>
231 231 ${_('Unmatched inline comments below')}
232 232 </div>
233 233 </td>
234 234 </tr>
235 235 % endif
236 236 <tr class="cb-line">
237 237 <td class="cb-data cb-context"></td>
238 238 <td class="cb-lineno cb-context"></td>
239 239 <td class="cb-lineno cb-context"></td>
240 240 <td class="cb-content cb-context">
241 241 ${inline_comments_container(comments, inline_comments)}
242 242 </td>
243 243 </tr>
244 244 %elif c.user_session_attrs["diffmode"] == 'sideside':
245 245 % if loop.index == 0:
246 246 <tr class="cb-comment-info">
247 247 <td colspan="2"></td>
248 248 <td class="cb-line">
249 249 <div>
250 250 ${_('Unmatched inline comments below')}
251 251 </div>
252 252 </td>
253 253 <td colspan="2"></td>
254 254 <td class="cb-line">
255 255 <div>
256 256 ${_('Unmatched comments below')}
257 257 </div>
258 258 </td>
259 259 </tr>
260 260 % endif
261 261 <tr class="cb-line">
262 262 <td class="cb-data cb-context"></td>
263 263 <td class="cb-lineno cb-context"></td>
264 264 <td class="cb-content cb-context">
265 265 % if lineno.startswith('o'):
266 266 ${inline_comments_container(comments, inline_comments)}
267 267 % endif
268 268 </td>
269 269
270 270 <td class="cb-data cb-context"></td>
271 271 <td class="cb-lineno cb-context"></td>
272 272 <td class="cb-content cb-context">
273 273 % if lineno.startswith('n'):
274 274 ${inline_comments_container(comments, inline_comments)}
275 275 % endif
276 276 </td>
277 277 </tr>
278 278 %endif
279 279
280 280 % endfor
281 281
282 282 </table>
283 283 </div>
284 284 %endfor
285 285
286 286 ## outdated comments that are made for a file that has been deleted
287 287 % for filename, comments_dict in (deleted_files_comments or {}).items():
288 288 <%
289 289 display_state = 'display: none'
290 290 open_comments_in_file = [x for x in comments_dict['comments'] if x.outdated is False]
291 291 if open_comments_in_file:
292 292 display_state = ''
293 293 %>
294 294 <div class="filediffs filediff-outdated" style="${display_state}">
295 295 <input ${(collapse_all and 'checked' or '')} class="filediff-collapse-state" id="filediff-collapse-${id(filename)}" type="checkbox" onchange="updateSticky();">
296 296 <div class="filediff" data-f-path="${filename}" id="a_${h.FID(filediff.raw_id, filename)}">
297 297 <label for="filediff-collapse-${id(filename)}" class="filediff-heading">
298 298 <div class="filediff-collapse-indicator"></div>
299 299 <span class="pill">
300 300 ## file was deleted
301 301 <strong>${filename}</strong>
302 302 </span>
303 303 <span class="pill-group" style="float: left">
304 304 ## file op, doesn't need translation
305 305 <span class="pill" op="removed">removed in this version</span>
306 306 </span>
307 307 <a class="pill filediff-anchor" href="#a_${h.FID(filediff.raw_id, filename)}">ΒΆ</a>
308 308 <span class="pill-group" style="float: right">
309 309 <span class="pill" op="deleted">-${comments_dict['stats']}</span>
310 310 </span>
311 311 </label>
312 312
313 313 <table class="cb cb-diff-${c.user_session_attrs["diffmode"]} code-highlight ${over_lines_changed_limit and 'cb-collapsed' or ''}">
314 314 <tr>
315 315 % if c.user_session_attrs["diffmode"] == 'unified':
316 316 <td></td>
317 317 %endif
318 318
319 319 <td></td>
320 320 <td class="cb-text cb-${op_class(BIN_FILENODE)}" ${(c.user_session_attrs["diffmode"] == 'unified' and 'colspan=4' or 'colspan=5')}>
321 321 ${_('File was deleted in this version. There are still outdated/unresolved comments attached to it.')}
322 322 </td>
323 323 </tr>
324 324 %if c.user_session_attrs["diffmode"] == 'unified':
325 325 <tr class="cb-line">
326 326 <td class="cb-data cb-context"></td>
327 327 <td class="cb-lineno cb-context"></td>
328 328 <td class="cb-lineno cb-context"></td>
329 329 <td class="cb-content cb-context">
330 330 ${inline_comments_container(comments_dict['comments'], inline_comments)}
331 331 </td>
332 332 </tr>
333 333 %elif c.user_session_attrs["diffmode"] == 'sideside':
334 334 <tr class="cb-line">
335 335 <td class="cb-data cb-context"></td>
336 336 <td class="cb-lineno cb-context"></td>
337 337 <td class="cb-content cb-context"></td>
338 338
339 339 <td class="cb-data cb-context"></td>
340 340 <td class="cb-lineno cb-context"></td>
341 341 <td class="cb-content cb-context">
342 342 ${inline_comments_container(comments_dict['comments'], inline_comments)}
343 343 </td>
344 344 </tr>
345 345 %endif
346 346 </table>
347 347 </div>
348 348 </div>
349 349 % endfor
350 350
351 351 </div>
352 352 </div>
353 353 </%def>
354 354
355 355 <%def name="diff_ops(filediff)">
356 356 <%
357 357 from rhodecode.lib.diffs import NEW_FILENODE, DEL_FILENODE, \
358 358 MOD_FILENODE, RENAMED_FILENODE, CHMOD_FILENODE, BIN_FILENODE, COPIED_FILENODE
359 359 %>
360 360 <span class="pill">
361 361 %if filediff.source_file_path and filediff.target_file_path:
362 362 %if filediff.source_file_path != filediff.target_file_path:
363 363 ## file was renamed, or copied
364 364 %if RENAMED_FILENODE in filediff.patch['stats']['ops']:
365 365 <strong>${filediff.target_file_path}</strong> β¬… <del>${filediff.source_file_path}</del>
366 366 <% final_path = filediff.target_file_path %>
367 367 %elif COPIED_FILENODE in filediff.patch['stats']['ops']:
368 368 <strong>${filediff.target_file_path}</strong> β¬… ${filediff.source_file_path}
369 369 <% final_path = filediff.target_file_path %>
370 370 %endif
371 371 %else:
372 372 ## file was modified
373 373 <strong>${filediff.source_file_path}</strong>
374 374 <% final_path = filediff.source_file_path %>
375 375 %endif
376 376 %else:
377 377 %if filediff.source_file_path:
378 378 ## file was deleted
379 379 <strong>${filediff.source_file_path}</strong>
380 380 <% final_path = filediff.source_file_path %>
381 381 %else:
382 382 ## file was added
383 383 <strong>${filediff.target_file_path}</strong>
384 384 <% final_path = filediff.target_file_path %>
385 385 %endif
386 386 %endif
387 387 <i style="color: #aaa" class="tooltip icon-clipboard clipboard-action" data-clipboard-text="${final_path}" title="${_('Copy the full path')}" onclick="return false;"></i>
388 388 </span>
389 389 ## anchor link
390 390 <a class="pill filediff-anchor" href="#a_${h.FID(filediff.raw_id, filediff.patch['filename'])}">ΒΆ</a>
391 391
392 392 <span class="pill-group" style="float: right">
393 393
394 394 ## ops pills
395 395 %if filediff.limited_diff:
396 396 <span class="pill tooltip" op="limited" title="The stats for this diff are not complete">limited diff</span>
397 397 %endif
398 398
399 399 %if NEW_FILENODE in filediff.patch['stats']['ops']:
400 400 <span class="pill" op="created">created</span>
401 401 %if filediff['target_mode'].startswith('120'):
402 402 <span class="pill" op="symlink">symlink</span>
403 403 %else:
404 404 <span class="pill" op="mode">${nice_mode(filediff['target_mode'])}</span>
405 405 %endif
406 406 %endif
407 407
408 408 %if RENAMED_FILENODE in filediff.patch['stats']['ops']:
409 409 <span class="pill" op="renamed">renamed</span>
410 410 %endif
411 411
412 412 %if COPIED_FILENODE in filediff.patch['stats']['ops']:
413 413 <span class="pill" op="copied">copied</span>
414 414 %endif
415 415
416 416 %if DEL_FILENODE in filediff.patch['stats']['ops']:
417 417 <span class="pill" op="removed">removed</span>
418 418 %endif
419 419
420 420 %if CHMOD_FILENODE in filediff.patch['stats']['ops']:
421 421 <span class="pill" op="mode">
422 422 ${nice_mode(filediff['source_mode'])} ➑ ${nice_mode(filediff['target_mode'])}
423 423 </span>
424 424 %endif
425 425
426 426 %if BIN_FILENODE in filediff.patch['stats']['ops']:
427 427 <span class="pill" op="binary">binary</span>
428 428 %if MOD_FILENODE in filediff.patch['stats']['ops']:
429 429 <span class="pill" op="modified">modified</span>
430 430 %endif
431 431 %endif
432 432
433 433 <span class="pill" op="added">${('+' if filediff.patch['stats']['added'] else '')}${filediff.patch['stats']['added']}</span>
434 434 <span class="pill" op="deleted">${((h.safe_int(filediff.patch['stats']['deleted']) or 0) * -1)}</span>
435 435
436 436 </span>
437 437
438 438 </%def>
439 439
440 440 <%def name="nice_mode(filemode)">
441 441 ${(filemode.startswith('100') and filemode[3:] or filemode)}
442 442 </%def>
443 443
444 444 <%def name="diff_menu(filediff, use_comments=False)">
445 445 <div class="filediff-menu">
446 446 %if filediff.diffset.source_ref:
447 447 %if filediff.operation in ['D', 'M']:
448 448 <a
449 449 class="tooltip"
450 450 href="${h.route_path('repo_files',repo_name=filediff.diffset.repo_name,commit_id=filediff.diffset.source_ref,f_path=filediff.source_file_path)}"
451 451 title="${h.tooltip(_('Show file at commit: %(commit_id)s') % {'commit_id': filediff.diffset.source_ref[:12]})}"
452 452 >
453 453 ${_('Show file before')}
454 454 </a> |
455 455 %else:
456 456 <span
457 457 class="tooltip"
458 458 title="${h.tooltip(_('File no longer present at commit: %(commit_id)s') % {'commit_id': filediff.diffset.source_ref[:12]})}"
459 459 >
460 460 ${_('Show file before')}
461 461 </span> |
462 462 %endif
463 463 %if filediff.operation in ['A', 'M']:
464 464 <a
465 465 class="tooltip"
466 466 href="${h.route_path('repo_files',repo_name=filediff.diffset.source_repo_name,commit_id=filediff.diffset.target_ref,f_path=filediff.target_file_path)}"
467 467 title="${h.tooltip(_('Show file at commit: %(commit_id)s') % {'commit_id': filediff.diffset.target_ref[:12]})}"
468 468 >
469 469 ${_('Show file after')}
470 </a> |
470 </a>
471 471 %else:
472 472 <span
473 473 class="tooltip"
474 474 title="${h.tooltip(_('File no longer present at commit: %(commit_id)s') % {'commit_id': filediff.diffset.target_ref[:12]})}"
475 475 >
476 476 ${_('Show file after')}
477 </span> |
478 %endif
479 <a
480 class="tooltip"
481 title="${h.tooltip(_('Raw diff'))}"
482 href="${h.route_path('repo_files_diff',repo_name=filediff.diffset.repo_name,f_path=filediff.target_file_path, _query=dict(diff2=filediff.diffset.target_ref,diff1=filediff.diffset.source_ref,diff='raw'))}"
483 >
484 ${_('Raw diff')}
485 </a> |
486 <a
487 class="tooltip"
488 title="${h.tooltip(_('Download diff'))}"
489 href="${h.route_path('repo_files_diff',repo_name=filediff.diffset.repo_name,f_path=filediff.target_file_path, _query=dict(diff2=filediff.diffset.target_ref,diff1=filediff.diffset.source_ref,diff='download'))}"
490 >
491 ${_('Download diff')}
492 </a>
493 % if use_comments:
494 |
495 % endif
496
497 ## TODO: dan: refactor ignorews_url and context_url into the diff renderer same as diffmode=unified/sideside. Also use ajax to load more context (by clicking hunks)
498 %if hasattr(c, 'ignorews_url'):
499 ${c.ignorews_url(request, h.FID(filediff.raw_id, filediff.patch['filename']))}
500 %endif
501 %if hasattr(c, 'context_url'):
502 ${c.context_url(request, h.FID(filediff.raw_id, filediff.patch['filename']))}
477 </span>
503 478 %endif
504 479
505 480 %if use_comments:
481 |
506 482 <a href="#" onclick="return Rhodecode.comments.toggleComments(this);">
507 483 <span class="show-comment-button">${_('Show comments')}</span><span class="hide-comment-button">${_('Hide comments')}</span>
508 484 </a>
509 485 %endif
486
510 487 %endif
511 488 </div>
512 489 </%def>
513 490
514 491
515 492 <%def name="inline_comments_container(comments, inline_comments)">
516 493 <div class="inline-comments">
517 494 %for comment in comments:
518 495 ${commentblock.comment_block(comment, inline=True)}
519 496 %endfor
520 497 % if comments and comments[-1].outdated:
521 498 <span class="btn btn-secondary cb-comment-add-button comment-outdated}"
522 499 style="display: none;}">
523 500 ${_('Add another comment')}
524 501 </span>
525 502 % else:
526 503 <span onclick="return Rhodecode.comments.createComment(this)"
527 504 class="btn btn-secondary cb-comment-add-button">
528 505 ${_('Add another comment')}
529 506 </span>
530 507 % endif
531 508
532 509 </div>
533 510 </%def>
534 511
535 512 <%!
536 513 def get_comments_for(diff_type, comments, filename, line_version, line_number):
537 514 if hasattr(filename, 'unicode_path'):
538 515 filename = filename.unicode_path
539 516
540 517 if not isinstance(filename, basestring):
541 518 return None
542 519
543 520 line_key = '{}{}'.format(line_version, line_number) ## e.g o37, n12
544 521
545 522 if comments and filename in comments:
546 523 file_comments = comments[filename]
547 524 if line_key in file_comments:
548 525 data = file_comments.pop(line_key)
549 526 return data
550 527 %>
551 528
552 529 <%def name="render_hunk_lines_sideside(filediff, hunk, use_comments=False, inline_comments=None)">
553 530 %for i, line in enumerate(hunk.sideside):
554 531 <%
555 532 old_line_anchor, new_line_anchor = None, None
556 533
557 534 if line.original.lineno:
558 535 old_line_anchor = diff_line_anchor(filediff.raw_id, hunk.source_file_path, line.original.lineno, 'o')
559 536 if line.modified.lineno:
560 537 new_line_anchor = diff_line_anchor(filediff.raw_id, hunk.target_file_path, line.modified.lineno, 'n')
561 538 %>
562 539
563 540 <tr class="cb-line">
564 541 <td class="cb-data ${action_class(line.original.action)}"
565 542 data-line-no="${line.original.lineno}"
566 543 >
567 544 <div>
568 545
569 546 <% line_old_comments = None %>
570 547 %if line.original.get_comment_args:
571 548 <% line_old_comments = get_comments_for('side-by-side', inline_comments, *line.original.get_comment_args) %>
572 549 %endif
573 550 %if line_old_comments:
574 551 <% has_outdated = any([x.outdated for x in line_old_comments]) %>
575 552 % if has_outdated:
576 553 <i title="${_('comments including outdated')}:${len(line_old_comments)}" class="icon-comment_toggle" onclick="return Rhodecode.comments.toggleLineComments(this)"></i>
577 554 % else:
578 555 <i title="${_('comments')}: ${len(line_old_comments)}" class="icon-comment" onclick="return Rhodecode.comments.toggleLineComments(this)"></i>
579 556 % endif
580 557 %endif
581 558 </div>
582 559 </td>
583 560 <td class="cb-lineno ${action_class(line.original.action)}"
584 561 data-line-no="${line.original.lineno}"
585 562 %if old_line_anchor:
586 563 id="${old_line_anchor}"
587 564 %endif
588 565 >
589 566 %if line.original.lineno:
590 567 <a name="${old_line_anchor}" href="#${old_line_anchor}">${line.original.lineno}</a>
591 568 %endif
592 569 </td>
593 570 <td class="cb-content ${action_class(line.original.action)}"
594 571 data-line-no="o${line.original.lineno}"
595 572 >
596 573 %if use_comments and line.original.lineno:
597 574 ${render_add_comment_button()}
598 575 %endif
599 576 <span class="cb-code">${line.original.action} ${line.original.content or '' | n}</span>
600 577
601 578 %if use_comments and line.original.lineno and line_old_comments:
602 579 ${inline_comments_container(line_old_comments, inline_comments)}
603 580 %endif
604 581
605 582 </td>
606 583 <td class="cb-data ${action_class(line.modified.action)}"
607 584 data-line-no="${line.modified.lineno}"
608 585 >
609 586 <div>
610 587
611 588 %if line.modified.get_comment_args:
612 589 <% line_new_comments = get_comments_for('side-by-side', inline_comments, *line.modified.get_comment_args) %>
613 590 %else:
614 591 <% line_new_comments = None%>
615 592 %endif
616 593 %if line_new_comments:
617 594 <% has_outdated = any([x.outdated for x in line_new_comments]) %>
618 595 % if has_outdated:
619 596 <i title="${_('comments including outdated')}:${len(line_new_comments)}" class="icon-comment_toggle" onclick="return Rhodecode.comments.toggleLineComments(this)"></i>
620 597 % else:
621 598 <i title="${_('comments')}: ${len(line_new_comments)}" class="icon-comment" onclick="return Rhodecode.comments.toggleLineComments(this)"></i>
622 599 % endif
623 600 %endif
624 601 </div>
625 602 </td>
626 603 <td class="cb-lineno ${action_class(line.modified.action)}"
627 604 data-line-no="${line.modified.lineno}"
628 605 %if new_line_anchor:
629 606 id="${new_line_anchor}"
630 607 %endif
631 608 >
632 609 %if line.modified.lineno:
633 610 <a name="${new_line_anchor}" href="#${new_line_anchor}">${line.modified.lineno}</a>
634 611 %endif
635 612 </td>
636 613 <td class="cb-content ${action_class(line.modified.action)}"
637 614 data-line-no="n${line.modified.lineno}"
638 615 >
639 616 %if use_comments and line.modified.lineno:
640 617 ${render_add_comment_button()}
641 618 %endif
642 619 <span class="cb-code">${line.modified.action} ${line.modified.content or '' | n}</span>
643 620 %if use_comments and line.modified.lineno and line_new_comments:
644 621 ${inline_comments_container(line_new_comments, inline_comments)}
645 622 %endif
646 623 </td>
647 624 </tr>
648 625 %endfor
649 626 </%def>
650 627
651 628
652 629 <%def name="render_hunk_lines_unified(filediff, hunk, use_comments=False, inline_comments=None)">
653 630 %for old_line_no, new_line_no, action, content, comments_args in hunk.unified:
654 631
655 632 <%
656 633 old_line_anchor, new_line_anchor = None, None
657 634 if old_line_no:
658 635 old_line_anchor = diff_line_anchor(filediff.raw_id, hunk.source_file_path, old_line_no, 'o')
659 636 if new_line_no:
660 637 new_line_anchor = diff_line_anchor(filediff.raw_id, hunk.target_file_path, new_line_no, 'n')
661 638 %>
662 639 <tr class="cb-line">
663 640 <td class="cb-data ${action_class(action)}">
664 641 <div>
665 642
666 643 %if comments_args:
667 644 <% comments = get_comments_for('unified', inline_comments, *comments_args) %>
668 645 %else:
669 646 <% comments = None %>
670 647 %endif
671 648
672 649 % if comments:
673 650 <% has_outdated = any([x.outdated for x in comments]) %>
674 651 % if has_outdated:
675 652 <i title="${_('comments including outdated')}:${len(comments)}" class="icon-comment_toggle" onclick="return Rhodecode.comments.toggleLineComments(this)"></i>
676 653 % else:
677 654 <i title="${_('comments')}: ${len(comments)}" class="icon-comment" onclick="return Rhodecode.comments.toggleLineComments(this)"></i>
678 655 % endif
679 656 % endif
680 657 </div>
681 658 </td>
682 659 <td class="cb-lineno ${action_class(action)}"
683 660 data-line-no="${old_line_no}"
684 661 %if old_line_anchor:
685 662 id="${old_line_anchor}"
686 663 %endif
687 664 >
688 665 %if old_line_anchor:
689 666 <a name="${old_line_anchor}" href="#${old_line_anchor}">${old_line_no}</a>
690 667 %endif
691 668 </td>
692 669 <td class="cb-lineno ${action_class(action)}"
693 670 data-line-no="${new_line_no}"
694 671 %if new_line_anchor:
695 672 id="${new_line_anchor}"
696 673 %endif
697 674 >
698 675 %if new_line_anchor:
699 676 <a name="${new_line_anchor}" href="#${new_line_anchor}">${new_line_no}</a>
700 677 %endif
701 678 </td>
702 679 <td class="cb-content ${action_class(action)}"
703 680 data-line-no="${(new_line_no and 'n' or 'o')}${(new_line_no or old_line_no)}"
704 681 >
705 682 %if use_comments:
706 683 ${render_add_comment_button()}
707 684 %endif
708 685 <span class="cb-code">${action} ${content or '' | n}</span>
709 686 %if use_comments and comments:
710 687 ${inline_comments_container(comments, inline_comments)}
711 688 %endif
712 689 </td>
713 690 </tr>
714 691 %endfor
715 692 </%def>
716 693
717 694
718 695 <%def name="render_hunk_lines(filediff, diff_mode, hunk, use_comments, inline_comments)">
719 696 % if diff_mode == 'unified':
720 697 ${render_hunk_lines_unified(filediff, hunk, use_comments=use_comments, inline_comments=inline_comments)}
721 698 % elif diff_mode == 'sideside':
722 699 ${render_hunk_lines_sideside(filediff, hunk, use_comments=use_comments, inline_comments=inline_comments)}
723 700 % else:
724 701 <tr class="cb-line">
725 702 <td>unknown diff mode</td>
726 703 </tr>
727 704 % endif
728 705 </%def>file changes
729 706
730 707
731 708 <%def name="render_add_comment_button()">
732 709 <button class="btn btn-small btn-primary cb-comment-box-opener" onclick="return Rhodecode.comments.createComment(this)">
733 710 <span><i class="icon-comment"></i></span>
734 711 </button>
735 712 </%def>
736 713
737 714 <%def name="render_diffset_menu(diffset=None, range_diff_on=None)">
738 715
739 716 <div id="diff-file-sticky" class="diffset-menu clearinner">
740 717 ## auto adjustable
741 718 <div class="sidebar__inner">
742 719 <div class="sidebar__bar">
743 720 <div class="pull-right">
744 721 <div class="btn-group">
745 722
723 ## DIFF OPTIONS via Select2
724 <div class="pull-left">
725 ${h.hidden('diff_menu')}
726 </div>
727
746 728 <a
747 729 class="btn ${(c.user_session_attrs["diffmode"] == 'sideside' and 'btn-primary')} tooltip"
748 730 title="${h.tooltip(_('View side by side'))}"
749 731 href="${h.current_route_path(request, diffmode='sideside')}">
750 732 <span>${_('Side by Side')}</span>
751 733 </a>
734
752 735 <a
753 736 class="btn ${(c.user_session_attrs["diffmode"] == 'unified' and 'btn-primary')} tooltip"
754 737 title="${h.tooltip(_('View unified'))}" href="${h.current_route_path(request, diffmode='unified')}">
755 738 <span>${_('Unified')}</span>
756 739 </a>
740
757 741 % if range_diff_on is True:
758 742 <a
759 743 title="${_('Turn off: Show the diff as commit range')}"
760 744 class="btn btn-primary"
761 745 href="${h.current_route_path(request, **{"range-diff":"0"})}">
762 746 <span>${_('Range Diff')}</span>
763 747 </a>
764 748 % elif range_diff_on is False:
765 749 <a
766 750 title="${_('Show the diff as commit range')}"
767 751 class="btn"
768 752 href="${h.current_route_path(request, **{"range-diff":"1"})}">
769 753 <span>${_('Range Diff')}</span>
770 754 </a>
771 755 % endif
772 756 </div>
773 757 </div>
774 758 <div class="pull-left">
775 759 <div class="btn-group">
776 760 <div class="pull-left">
777 761 ${h.hidden('file_filter')}
778 762 </div>
779 763 <a
780 764 class="btn"
781 765 href="#"
782 766 onclick="$('input[class=filediff-collapse-state]').prop('checked', false); updateSticky(); return false">${_('Expand All Files')}</a>
783 767 <a
784 768 class="btn"
785 769 href="#"
786 770 onclick="$('input[class=filediff-collapse-state]').prop('checked', true); updateSticky(); return false">${_('Collapse All Files')}</a>
787 <a
788 class="btn"
789 href="#"
790 onclick="updateSticky();return Rhodecode.comments.toggleWideMode(this)">${_('Wide Mode Diff')}</a>
791
792 771 </div>
793 772 </div>
794 773 </div>
795 774 <div class="fpath-placeholder">
796 775 <i class="icon-file-text"></i>
797 776 <strong class="fpath-placeholder-text">
798 777 Context file:
799 778 </strong>
800 779 </div>
801 780 <div class="sidebar_inner_shadow"></div>
802 781 </div>
803 782 </div>
804 783
805 784 % if diffset:
806 785
807 786 %if diffset.limited_diff:
808 787 <% file_placeholder = _ungettext('%(num)s file changed', '%(num)s files changed', diffset.changed_files) % {'num': diffset.changed_files} %>
809 788 %else:
810 789 <% file_placeholder = _ungettext('%(num)s file changed: %(linesadd)s inserted, ''%(linesdel)s deleted', '%(num)s files changed: %(linesadd)s inserted, %(linesdel)s deleted', diffset.changed_files) % {'num': diffset.changed_files, 'linesadd': diffset.lines_added, 'linesdel': diffset.lines_deleted}%>
811 790 %endif
812 791 ## case on range-diff placeholder needs to be updated
813 792 % if range_diff_on is True:
814 793 <% file_placeholder = _('Disabled on range diff') %>
815 794 % endif
816 795
817 796 <script>
818 797
819 798 var feedFilesOptions = function (query, initialData) {
820 799 var data = {results: []};
821 800 var isQuery = typeof query.term !== 'undefined';
822 801
823 802 var section = _gettext('Changed files');
824 803 var filteredData = [];
825 804
826 805 //filter results
827 806 $.each(initialData.results, function (idx, value) {
828 807
829 808 if (!isQuery || query.term.length === 0 || value.text.toUpperCase().indexOf(query.term.toUpperCase()) >= 0) {
830 809 filteredData.push({
831 810 'id': this.id,
832 811 'text': this.text,
833 812 "ops": this.ops,
834 813 })
835 814 }
836 815
837 816 });
838 817
839 818 data.results = filteredData;
840 819
841 820 query.callback(data);
842 821 };
843 822
844 823 var formatFileResult = function(result, container, query, escapeMarkup) {
845 824 return function(data, escapeMarkup) {
846 825 var container = '<div class="filelist" style="padding-right:100px">{0}</div>';
847 826 var tmpl = '<span style="margin-right:-50px"><strong>{0}</strong></span>'.format(escapeMarkup(data['text']));
848 827 var pill = '<span class="pill-group" style="float: right;margin-right: -100px">' +
849 828 '<span class="pill" op="added">{0}</span>' +
850 829 '<span class="pill" op="deleted">{1}</span>' +
851 830 '</span>'
852 831 ;
853 832 var added = data['ops']['added'];
854 833 if (added === 0) {
855 834 // don't show +0
856 835 added = 0;
857 836 } else {
858 837 added = '+' + added;
859 838 }
860 839
861 840 var deleted = -1*data['ops']['deleted'];
862 841
863 842 tmpl += pill.format(added, deleted);
864 843 return container.format(tmpl);
865 844
866 845 }(result, escapeMarkup);
867 846 };
868 847 var preloadData = {
869 848 results: [
870 849 % for filediff in diffset.files:
871 850 {id:"a_${h.FID(filediff.raw_id, filediff.patch['filename'])}",
872 851 text:"${filediff.patch['filename']}",
873 852 ops:${h.json.dumps(filediff.patch['stats'])|n}}${('' if loop.last else ',')}
874 853 % endfor
875 854 ]
876 855 };
877 856
878 857 $(document).ready(function () {
879 858
880 859 var fileFilter = $("#file_filter").select2({
881 860 'dropdownAutoWidth': true,
882 861 'width': 'auto',
883 862 'placeholder': "${file_placeholder}",
884 863 containerCssClass: "drop-menu",
885 864 dropdownCssClass: "drop-menu-dropdown",
886 865 data: preloadData,
887 866 query: function(query) {
888 867 feedFilesOptions(query, preloadData);
889 868 },
890 869 formatResult: formatFileResult
891 870 });
892 871 % if range_diff_on is True:
893 872 fileFilter.select2("enable", false);
894 873
895 874 % endif
896 875
897 876 $("#file_filter").on('click', function (e) {
898 877 e.preventDefault();
899 878 var selected = $('#file_filter').select2('data');
900 879 var idSelector = "#"+selected.id;
901 880 window.location.hash = idSelector;
902 881 // expand the container if we quick-select the field
903 882 $(idSelector).next().prop('checked', false);
904 883 updateSticky()
905 884 });
906 885
907 886 var contextPrefix = _gettext('Context file: ');
908 887 ## sticky sidebar
909 888 var sidebarElement = document.getElementById('diff-file-sticky');
910 889 sidebar = new StickySidebar(sidebarElement, {
911 890 topSpacing: 0,
912 891 bottomSpacing: 0,
913 892 innerWrapperSelector: '.sidebar__inner'
914 893 });
915 894 sidebarElement.addEventListener('affixed.static.stickySidebar', function () {
916 895 // reset our file so it's not holding new value
917 896 $('.fpath-placeholder-text').html(contextPrefix)
918 897 });
919 898
920 899 updateSticky = function () {
921 900 sidebar.updateSticky();
922 901 Waypoint.refreshAll();
923 902 };
924 903
925 904 var animateText = $.debounce(100, function(fPath, anchorId) {
926 905 // animate setting the text
927 906 var callback = function () {
928 907 $('.fpath-placeholder-text').animate({'opacity': 1.00}, 200)
929 908 $('.fpath-placeholder-text').html(contextPrefix + '<a href="#a_' + anchorId + '">' + fPath + '</a>')
930 909 };
931 910 $('.fpath-placeholder-text').animate({'opacity': 0.15}, 200, callback);
932 911 });
933 912
934 913 ## dynamic file waypoints
935 914 var setFPathInfo = function(fPath, anchorId){
936 915 animateText(fPath, anchorId)
937 916 };
938 917
939 918 var codeBlock = $('.filediff');
940 919 // forward waypoint
941 920 codeBlock.waypoint(
942 921 function(direction) {
943 922 if (direction === "down"){
944 923 setFPathInfo($(this.element).data('fPath'), $(this.element).data('anchorId'))
945 924 }
946 925 }, {
947 926 offset: 70,
948 927 context: '.fpath-placeholder'
949 928 }
950 929 );
951 930
952 931 // backward waypoint
953 932 codeBlock.waypoint(
954 933 function(direction) {
955 934 if (direction === "up"){
956 935 setFPathInfo($(this.element).data('fPath'), $(this.element).data('anchorId'))
957 936 }
958 937 }, {
959 938 offset: function () {
960 939 return -this.element.clientHeight + 90
961 940 },
962 941 context: '.fpath-placeholder'
963 942 }
964 943 );
965 944
945 var preloadData = {
946 results: [
947 ## Wide diff mode
948 {
949 id: 1,
950 text: _gettext('Toggle Wide Mode Diff'),
951 action: function () {
952 updateSticky();
953 Rhodecode.comments.toggleWideMode(this);
954 return null;
955 },
956 url: null,
957 },
958
959 ## Whitespace change
960 % if request.GET.get('ignorews', '') == '1':
961 {
962 id: 2,
963 text: _gettext('Show whitespace changes'),
964 action: function () {},
965 url: "${h.current_route_path(request, ignorews=0)|n}"
966 },
967 % else:
968 {
969 id: 2,
970 text: _gettext('Hide whitespace changes'),
971 action: function () {},
972 url: "${h.current_route_path(request, ignorews=1)|n}"
973 },
974 % endif
975
976 ## FULL CONTEXT
977 % if request.GET.get('fullcontext', '') == '1':
978 {
979 id: 3,
980 text: _gettext('Hide full context diff'),
981 action: function () {},
982 url: "${h.current_route_path(request, fullcontext=0)|n}"
983 },
984 % else:
985 {
986 id: 3,
987 text: _gettext('Show full context diff'),
988 action: function () {},
989 url: "${h.current_route_path(request, fullcontext=1)|n}"
990 },
991 % endif
992
993 ]
994 };
995
996 $("#diff_menu").select2({
997 minimumResultsForSearch: -1,
998 containerCssClass: "drop-menu",
999 dropdownCssClass: "drop-menu-dropdown",
1000 dropdownAutoWidth: true,
1001 data: preloadData,
1002 placeholder: "${_('Diff Options')}",
1003 });
1004 $("#diff_menu").on('select2-selecting', function (e) {
1005 e.choice.action();
1006 if (e.choice.url !== null) {
1007 window.location = e.choice.url
1008 }
1009 });
1010
966 1011 });
967 1012
968 1013 </script>
969 1014 % endif
970 1015
971 1016 </%def> No newline at end of file
@@ -1,333 +1,309 b''
1 1 ## -*- coding: utf-8 -*-
2 2 <%inherit file="/base/base.mako"/>
3 3 <%namespace name="cbdiffs" file="/codeblocks/diffs.mako"/>
4 4
5 5 <%def name="title()">
6 6 %if c.compare_home:
7 7 ${_('%s Compare') % c.repo_name}
8 8 %else:
9 9 ${_('%s Compare') % c.repo_name} - ${'%s@%s' % (c.source_repo.repo_name, c.source_ref)} &gt; ${'%s@%s' % (c.target_repo.repo_name, c.target_ref)}
10 10 %endif
11 11 %if c.rhodecode_name:
12 12 &middot; ${h.branding(c.rhodecode_name)}
13 13 %endif
14 14 </%def>
15 15
16 16 <%def name="breadcrumbs_links()">
17 17 ${_ungettext('%s commit','%s commits', len(c.commit_ranges)) % len(c.commit_ranges)}
18 18 </%def>
19 19
20 20 <%def name="menu_bar_nav()">
21 21 ${self.menu_items(active='repositories')}
22 22 </%def>
23 23
24 24 <%def name="menu_bar_subnav()">
25 25 ${self.repo_menu(active='compare')}
26 26 </%def>
27 27
28 28 <%def name="main()">
29 29 <script type="text/javascript">
30 30 // set fake commitId on this commit-range page
31 31 templateContext.commit_data.commit_id = "${h.EmptyCommit().raw_id}";
32 32 </script>
33 33
34 34 <div class="box">
35 35 <div class="title">
36 36 ${self.repo_page_title(c.rhodecode_db_repo)}
37 37 </div>
38 38
39 39 <div class="summary changeset">
40 40 <div class="summary-detail">
41 41 <div class="summary-detail-header">
42 42 <span class="breadcrumbs files_location">
43 43 <h4>
44 44 ${_('Compare Commits')}
45 45 % if c.file_path:
46 46 ${_('for file')} <a href="#${'a_' + h.FID('',c.file_path)}">${c.file_path}</a>
47 47 % endif
48 48
49 49 % if c.commit_ranges:
50 50 <code>
51 51 r${c.source_commit.idx}:${h.short_id(c.source_commit.raw_id)}...r${c.target_commit.idx}:${h.short_id(c.target_commit.raw_id)}
52 52 </code>
53 53 % endif
54 54 </h4>
55 55 </span>
56 56 </div>
57 57
58 58 <div class="fieldset">
59 59 <div class="left-label">
60 60 ${_('Target')}:
61 61 </div>
62 62 <div class="right-content">
63 63 <div>
64 64 <div class="code-header" >
65 65 <div class="compare_header">
66 66 ## The hidden elements are replaced with a select2 widget
67 67 ${h.hidden('compare_source')}
68 68 </div>
69 69 </div>
70 70 </div>
71 71 </div>
72 72 </div>
73 73
74 74 <div class="fieldset">
75 75 <div class="left-label">
76 76 ${_('Source')}:
77 77 </div>
78 78 <div class="right-content">
79 79 <div>
80 80 <div class="code-header" >
81 81 <div class="compare_header">
82 82 ## The hidden elements are replaced with a select2 widget
83 83 ${h.hidden('compare_target')}
84 84 </div>
85 85 </div>
86 86 </div>
87 87 </div>
88 88 </div>
89 89
90 90 <div class="fieldset">
91 91 <div class="left-label">
92 92 ${_('Actions')}:
93 93 </div>
94 94 <div class="right-content">
95 95 <div>
96 96 <div class="code-header" >
97 97 <div class="compare_header">
98 98
99 99 <div class="compare-buttons">
100 100 % if c.compare_home:
101 101 <a id="compare_revs" class="btn btn-primary"> ${_('Compare Commits')}</a>
102 102
103 103 <a class="btn disabled tooltip" disabled="disabled" title="${_('Action unavailable in current view')}">${_('Swap')}</a>
104 104 <a class="btn disabled tooltip" disabled="disabled" title="${_('Action unavailable in current view')}">${_('Comment')}</a>
105 105 <div id="changeset_compare_view_content">
106 106 <div class="help-block">${_('Compare commits, branches, bookmarks or tags.')}</div>
107 107 </div>
108 108
109 109 % elif c.preview_mode:
110 110 <a class="btn disabled tooltip" disabled="disabled" title="${_('Action unavailable in current view')}">${_('Compare Commits')}</a>
111 111 <a class="btn disabled tooltip" disabled="disabled" title="${_('Action unavailable in current view')}">${_('Swap')}</a>
112 112 <a class="btn disabled tooltip" disabled="disabled" title="${_('Action unavailable in current view')}">${_('Comment')}</a>
113 113
114 114 % else:
115 115 <a id="compare_revs" class="btn btn-primary"> ${_('Compare Commits')}</a>
116 116 <a id="btn-swap" class="btn btn-primary" href="${c.swap_url}">${_('Swap')}</a>
117 117
118 118 ## allow comment only if there are commits to comment on
119 119 % if c.diffset and c.diffset.files and c.commit_ranges:
120 120 <a id="compare_changeset_status_toggle" class="btn btn-primary">${_('Comment')}</a>
121 121 % else:
122 122 <a class="btn disabled tooltip" disabled="disabled" title="${_('Action unavailable in current view')}">${_('Comment')}</a>
123 123 % endif
124 124 % endif
125 125 </div>
126 126 </div>
127 127 </div>
128 128 </div>
129 129 </div>
130 130 </div>
131 131
132 <%doc>
133 ##TODO(marcink): implement this and diff menus
134 <div class="fieldset">
135 <div class="left-label">
136 ${_('Diff options')}:
137 </div>
138 <div class="right-content">
139 <div class="diff-actions">
140 <a href="${h.route_path('repo_commit_raw',repo_name=c.repo_name,commit_id='?')}" class="tooltip" title="${h.tooltip(_('Raw diff'))}">
141 ${_('Raw Diff')}
142 </a>
143 |
144 <a href="${h.route_path('repo_commit_patch',repo_name=c.repo_name,commit_id='?')}" class="tooltip" title="${h.tooltip(_('Patch diff'))}">
145 ${_('Patch Diff')}
146 </a>
147 |
148 <a href="${h.route_path('repo_commit_download',repo_name=c.repo_name,commit_id='?',_query=dict(diff='download'))}" class="tooltip" title="${h.tooltip(_('Download diff'))}">
149 ${_('Download Diff')}
150 </a>
151 </div>
152 </div>
153 </div>
154 </%doc>
155
156 132 ## commit status form
157 133 <div class="fieldset" id="compare_changeset_status" style="display: none; margin-bottom: -80px;">
158 134 <div class="left-label">
159 135 ${_('Commit status')}:
160 136 </div>
161 137 <div class="right-content">
162 138 <%namespace name="comment" file="/changeset/changeset_file_comment.mako"/>
163 139 ## main comment form and it status
164 140 <%
165 141 def revs(_revs):
166 142 form_inputs = []
167 143 for cs in _revs:
168 144 tmpl = '<input type="hidden" data-commit-id="%(cid)s" name="commit_ids" value="%(cid)s">' % {'cid': cs.raw_id}
169 145 form_inputs.append(tmpl)
170 146 return form_inputs
171 147 %>
172 148 <div>
173 149 ${comment.comments(h.route_path('repo_commit_comment_create', repo_name=c.repo_name, commit_id='0'*16), None, is_compare=True, form_extras=revs(c.commit_ranges))}
174 150 </div>
175 151 </div>
176 152 </div>
177 153
178 154 </div> <!-- end summary-detail -->
179 155 </div> <!-- end summary -->
180 156
181 157 ## use JS script to load it quickly before potentially large diffs render long time
182 158 ## this prevents from situation when large diffs block rendering of select2 fields
183 159 <script type="text/javascript">
184 160
185 161 var cache = {};
186 162
187 163 var formatSelection = function(repoName){
188 164 return function(data, container, escapeMarkup) {
189 165 var selection = data ? this.text(data) : "";
190 166 return escapeMarkup('{0}@{1}'.format(repoName, selection));
191 167 }
192 168 };
193 169
194 170 var feedCompareData = function(query, cachedValue){
195 171 var data = {results: []};
196 172 //filter results
197 173 $.each(cachedValue.results, function() {
198 174 var section = this.text;
199 175 var children = [];
200 176 $.each(this.children, function() {
201 177 if (query.term.length === 0 || this.text.toUpperCase().indexOf(query.term.toUpperCase()) >= 0) {
202 178 children.push({
203 179 'id': this.id,
204 180 'text': this.text,
205 181 'type': this.type
206 182 })
207 183 }
208 184 });
209 185 data.results.push({
210 186 'text': section,
211 187 'children': children
212 188 })
213 189 });
214 190 //push the typed in changeset
215 191 data.results.push({
216 192 'text': _gettext('specify commit'),
217 193 'children': [{
218 194 'id': query.term,
219 195 'text': query.term,
220 196 'type': 'rev'
221 197 }]
222 198 });
223 199 query.callback(data);
224 200 };
225 201
226 202 var loadCompareData = function(repoName, query, cache){
227 203 $.ajax({
228 204 url: pyroutes.url('repo_refs_data', {'repo_name': repoName}),
229 205 data: {},
230 206 dataType: 'json',
231 207 type: 'GET',
232 208 success: function(data) {
233 209 cache[repoName] = data;
234 210 query.callback({results: data.results});
235 211 }
236 212 })
237 213 };
238 214
239 215 var enable_fields = ${"false" if c.preview_mode else "true"};
240 216 $("#compare_source").select2({
241 217 placeholder: "${'%s@%s' % (c.source_repo.repo_name, c.source_ref)}",
242 218 containerCssClass: "drop-menu",
243 219 dropdownCssClass: "drop-menu-dropdown",
244 220 formatSelection: formatSelection("${c.source_repo.repo_name}"),
245 221 dropdownAutoWidth: true,
246 222 query: function(query) {
247 223 var repoName = '${c.source_repo.repo_name}';
248 224 var cachedValue = cache[repoName];
249 225
250 226 if (cachedValue){
251 227 feedCompareData(query, cachedValue);
252 228 }
253 229 else {
254 230 loadCompareData(repoName, query, cache);
255 231 }
256 232 }
257 233 }).select2("enable", enable_fields);
258 234
259 235 $("#compare_target").select2({
260 236 placeholder: "${'%s@%s' % (c.target_repo.repo_name, c.target_ref)}",
261 237 dropdownAutoWidth: true,
262 238 containerCssClass: "drop-menu",
263 239 dropdownCssClass: "drop-menu-dropdown",
264 240 formatSelection: formatSelection("${c.target_repo.repo_name}"),
265 241 query: function(query) {
266 242 var repoName = '${c.target_repo.repo_name}';
267 243 var cachedValue = cache[repoName];
268 244
269 245 if (cachedValue){
270 246 feedCompareData(query, cachedValue);
271 247 }
272 248 else {
273 249 loadCompareData(repoName, query, cache);
274 250 }
275 251 }
276 252 }).select2("enable", enable_fields);
277 253 var initial_compare_source = {id: "${c.source_ref}", type:"${c.source_ref_type}"};
278 254 var initial_compare_target = {id: "${c.target_ref}", type:"${c.target_ref_type}"};
279 255
280 256 $('#compare_revs').on('click', function(e) {
281 257 var source = $('#compare_source').select2('data') || initial_compare_source;
282 258 var target = $('#compare_target').select2('data') || initial_compare_target;
283 259 if (source && target) {
284 260 var url_data = {
285 261 repo_name: "${c.repo_name}",
286 262 source_ref: source.id,
287 263 source_ref_type: source.type,
288 264 target_ref: target.id,
289 265 target_ref_type: target.type
290 266 };
291 267 window.location = pyroutes.url('repo_compare', url_data);
292 268 }
293 269 });
294 270 $('#compare_changeset_status_toggle').on('click', function(e) {
295 271 $('#compare_changeset_status').toggle();
296 272 });
297 273
298 274 </script>
299 275
300 276 ## table diff data
301 277 <div class="table">
302 278
303 279
304 280 % if not c.compare_home:
305 281 <div id="changeset_compare_view_content">
306 282 <div class="pull-left">
307 283 <div class="btn-group">
308 284 <a
309 285 class="btn"
310 286 href="#"
311 287 onclick="$('.compare_select').show();$('.compare_select_hidden').hide(); return false">
312 288 ${_ungettext('Expand %s commit','Expand %s commits', len(c.commit_ranges)) % len(c.commit_ranges)}
313 289 </a>
314 290 <a
315 291 class="btn"
316 292 href="#"
317 293 onclick="$('.compare_select').hide();$('.compare_select_hidden').show(); return false">
318 294 ${_ungettext('Collapse %s commit','Collapse %s commits', len(c.commit_ranges)) % len(c.commit_ranges)}
319 295 </a>
320 296 </div>
321 297 </div>
322 298 <div style="padding:0 10px 10px 0px" class="pull-left"></div>
323 299 ## commit compare generated below
324 300 <%include file="compare_commits.mako"/>
325 301 ${cbdiffs.render_diffset_menu(c.diffset)}
326 302 ${cbdiffs.render_diffset(c.diffset)}
327 303 </div>
328 304 % endif
329 305
330 306 </div>
331 307 </div>
332 308
333 309 </%def>
@@ -1,870 +1,871 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2018 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 import textwrap
24 24
25 25 import rhodecode
26 26 from rhodecode.lib.utils2 import safe_unicode
27 27 from rhodecode.lib.vcs.backends import get_backend
28 28 from rhodecode.lib.vcs.backends.base import (
29 29 MergeResponse, MergeFailureReason, Reference)
30 30 from rhodecode.lib.vcs.exceptions import RepositoryError
31 31 from rhodecode.lib.vcs.nodes import FileNode
32 32 from rhodecode.model.comment import CommentsModel
33 33 from rhodecode.model.db import PullRequest, Session
34 34 from rhodecode.model.pull_request import PullRequestModel
35 35 from rhodecode.model.user import UserModel
36 36 from rhodecode.tests import TEST_USER_ADMIN_LOGIN
37 37
38 38
39 39 pytestmark = [
40 40 pytest.mark.backends("git", "hg"),
41 41 ]
42 42
43 43
44 44 @pytest.mark.usefixtures('config_stub')
45 45 class TestPullRequestModel(object):
46 46
47 47 @pytest.fixture
48 48 def pull_request(self, request, backend, pr_util):
49 49 """
50 50 A pull request combined with multiples patches.
51 51 """
52 52 BackendClass = get_backend(backend.alias)
53 53 self.merge_patcher = mock.patch.object(
54 54 BackendClass, 'merge', return_value=MergeResponse(
55 55 False, False, None, MergeFailureReason.UNKNOWN))
56 56 self.workspace_remove_patcher = mock.patch.object(
57 57 BackendClass, 'cleanup_merge_workspace')
58 58
59 59 self.workspace_remove_mock = self.workspace_remove_patcher.start()
60 60 self.merge_mock = self.merge_patcher.start()
61 61 self.comment_patcher = mock.patch(
62 62 'rhodecode.model.changeset_status.ChangesetStatusModel.set_status')
63 63 self.comment_patcher.start()
64 64 self.notification_patcher = mock.patch(
65 65 'rhodecode.model.notification.NotificationModel.create')
66 66 self.notification_patcher.start()
67 67 self.helper_patcher = mock.patch(
68 68 'rhodecode.lib.helpers.route_path')
69 69 self.helper_patcher.start()
70 70
71 71 self.hook_patcher = mock.patch.object(PullRequestModel,
72 72 '_trigger_pull_request_hook')
73 73 self.hook_mock = self.hook_patcher.start()
74 74
75 75 self.invalidation_patcher = mock.patch(
76 76 'rhodecode.model.pull_request.ScmModel.mark_for_invalidation')
77 77 self.invalidation_mock = self.invalidation_patcher.start()
78 78
79 79 self.pull_request = pr_util.create_pull_request(
80 80 mergeable=True, name_suffix=u'Δ…Δ‡')
81 81 self.source_commit = self.pull_request.source_ref_parts.commit_id
82 82 self.target_commit = self.pull_request.target_ref_parts.commit_id
83 83 self.workspace_id = 'pr-%s' % self.pull_request.pull_request_id
84 84 self.repo_id = self.pull_request.target_repo.repo_id
85 85
86 86 @request.addfinalizer
87 87 def cleanup_pull_request():
88 88 calls = [mock.call(
89 89 self.pull_request, self.pull_request.author, 'create')]
90 90 self.hook_mock.assert_has_calls(calls)
91 91
92 92 self.workspace_remove_patcher.stop()
93 93 self.merge_patcher.stop()
94 94 self.comment_patcher.stop()
95 95 self.notification_patcher.stop()
96 96 self.helper_patcher.stop()
97 97 self.hook_patcher.stop()
98 98 self.invalidation_patcher.stop()
99 99
100 100 return self.pull_request
101 101
102 102 def test_get_all(self, pull_request):
103 103 prs = PullRequestModel().get_all(pull_request.target_repo)
104 104 assert isinstance(prs, list)
105 105 assert len(prs) == 1
106 106
107 107 def test_count_all(self, pull_request):
108 108 pr_count = PullRequestModel().count_all(pull_request.target_repo)
109 109 assert pr_count == 1
110 110
111 111 def test_get_awaiting_review(self, pull_request):
112 112 prs = PullRequestModel().get_awaiting_review(pull_request.target_repo)
113 113 assert isinstance(prs, list)
114 114 assert len(prs) == 1
115 115
116 116 def test_count_awaiting_review(self, pull_request):
117 117 pr_count = PullRequestModel().count_awaiting_review(
118 118 pull_request.target_repo)
119 119 assert pr_count == 1
120 120
121 121 def test_get_awaiting_my_review(self, pull_request):
122 122 PullRequestModel().update_reviewers(
123 123 pull_request, [(pull_request.author, ['author'], False, [])],
124 124 pull_request.author)
125 125 prs = PullRequestModel().get_awaiting_my_review(
126 126 pull_request.target_repo, user_id=pull_request.author.user_id)
127 127 assert isinstance(prs, list)
128 128 assert len(prs) == 1
129 129
130 130 def test_count_awaiting_my_review(self, pull_request):
131 131 PullRequestModel().update_reviewers(
132 132 pull_request, [(pull_request.author, ['author'], False, [])],
133 133 pull_request.author)
134 134 pr_count = PullRequestModel().count_awaiting_my_review(
135 135 pull_request.target_repo, user_id=pull_request.author.user_id)
136 136 assert pr_count == 1
137 137
138 138 def test_delete_calls_cleanup_merge(self, pull_request):
139 139 repo_id = pull_request.target_repo.repo_id
140 140 PullRequestModel().delete(pull_request, pull_request.author)
141 141
142 142 self.workspace_remove_mock.assert_called_once_with(
143 143 repo_id, self.workspace_id)
144 144
145 145 def test_close_calls_cleanup_and_hook(self, pull_request):
146 146 PullRequestModel().close_pull_request(
147 147 pull_request, pull_request.author)
148 148 repo_id = pull_request.target_repo.repo_id
149 149
150 150 self.workspace_remove_mock.assert_called_once_with(
151 151 repo_id, self.workspace_id)
152 152 self.hook_mock.assert_called_with(
153 153 self.pull_request, self.pull_request.author, 'close')
154 154
155 155 def test_merge_status(self, pull_request):
156 156 self.merge_mock.return_value = MergeResponse(
157 157 True, False, None, MergeFailureReason.NONE)
158 158
159 159 assert pull_request._last_merge_source_rev is None
160 160 assert pull_request._last_merge_target_rev is None
161 161 assert pull_request.last_merge_status is None
162 162
163 163 status, msg = PullRequestModel().merge_status(pull_request)
164 164 assert status is True
165 165 assert msg.eval() == 'This pull request can be automatically merged.'
166 166 self.merge_mock.assert_called_with(
167 167 self.repo_id, self.workspace_id,
168 168 pull_request.target_ref_parts,
169 169 pull_request.source_repo.scm_instance(),
170 170 pull_request.source_ref_parts, dry_run=True,
171 171 use_rebase=False, close_branch=False)
172 172
173 173 assert pull_request._last_merge_source_rev == self.source_commit
174 174 assert pull_request._last_merge_target_rev == self.target_commit
175 175 assert pull_request.last_merge_status is MergeFailureReason.NONE
176 176
177 177 self.merge_mock.reset_mock()
178 178 status, msg = PullRequestModel().merge_status(pull_request)
179 179 assert status is True
180 180 assert msg.eval() == 'This pull request can be automatically merged.'
181 181 assert self.merge_mock.called is False
182 182
183 183 def test_merge_status_known_failure(self, pull_request):
184 184 self.merge_mock.return_value = MergeResponse(
185 185 False, False, None, MergeFailureReason.MERGE_FAILED)
186 186
187 187 assert pull_request._last_merge_source_rev is None
188 188 assert pull_request._last_merge_target_rev is None
189 189 assert pull_request.last_merge_status is None
190 190
191 191 status, msg = PullRequestModel().merge_status(pull_request)
192 192 assert status is False
193 193 assert (
194 194 msg.eval() ==
195 195 'This pull request cannot be merged because of merge conflicts.')
196 196 self.merge_mock.assert_called_with(
197 197 self.repo_id, self.workspace_id,
198 198 pull_request.target_ref_parts,
199 199 pull_request.source_repo.scm_instance(),
200 200 pull_request.source_ref_parts, dry_run=True,
201 201 use_rebase=False, close_branch=False)
202 202
203 203 assert pull_request._last_merge_source_rev == self.source_commit
204 204 assert pull_request._last_merge_target_rev == self.target_commit
205 205 assert (
206 206 pull_request.last_merge_status is MergeFailureReason.MERGE_FAILED)
207 207
208 208 self.merge_mock.reset_mock()
209 209 status, msg = PullRequestModel().merge_status(pull_request)
210 210 assert status is False
211 211 assert (
212 212 msg.eval() ==
213 213 'This pull request cannot be merged because of merge conflicts.')
214 214 assert self.merge_mock.called is False
215 215
216 216 def test_merge_status_unknown_failure(self, pull_request):
217 217 self.merge_mock.return_value = MergeResponse(
218 218 False, False, None, MergeFailureReason.UNKNOWN)
219 219
220 220 assert pull_request._last_merge_source_rev is None
221 221 assert pull_request._last_merge_target_rev is None
222 222 assert pull_request.last_merge_status is None
223 223
224 224 status, msg = PullRequestModel().merge_status(pull_request)
225 225 assert status is False
226 226 assert msg.eval() == (
227 227 'This pull request cannot be merged because of an unhandled'
228 228 ' exception.')
229 229 self.merge_mock.assert_called_with(
230 230 self.repo_id, self.workspace_id,
231 231 pull_request.target_ref_parts,
232 232 pull_request.source_repo.scm_instance(),
233 233 pull_request.source_ref_parts, dry_run=True,
234 234 use_rebase=False, close_branch=False)
235 235
236 236 assert pull_request._last_merge_source_rev is None
237 237 assert pull_request._last_merge_target_rev is None
238 238 assert pull_request.last_merge_status is None
239 239
240 240 self.merge_mock.reset_mock()
241 241 status, msg = PullRequestModel().merge_status(pull_request)
242 242 assert status is False
243 243 assert msg.eval() == (
244 244 'This pull request cannot be merged because of an unhandled'
245 245 ' exception.')
246 246 assert self.merge_mock.called is True
247 247
248 248 def test_merge_status_when_target_is_locked(self, pull_request):
249 249 pull_request.target_repo.locked = [1, u'12345.50', 'lock_web']
250 250 status, msg = PullRequestModel().merge_status(pull_request)
251 251 assert status is False
252 252 assert msg.eval() == (
253 253 'This pull request cannot be merged because the target repository'
254 254 ' is locked.')
255 255
256 256 def test_merge_status_requirements_check_target(self, pull_request):
257 257
258 258 def has_largefiles(self, repo):
259 259 return repo == pull_request.source_repo
260 260
261 261 patcher = mock.patch.object(
262 262 PullRequestModel, '_has_largefiles', has_largefiles)
263 263 with patcher:
264 264 status, msg = PullRequestModel().merge_status(pull_request)
265 265
266 266 assert status is False
267 267 assert msg == 'Target repository large files support is disabled.'
268 268
269 269 def test_merge_status_requirements_check_source(self, pull_request):
270 270
271 271 def has_largefiles(self, repo):
272 272 return repo == pull_request.target_repo
273 273
274 274 patcher = mock.patch.object(
275 275 PullRequestModel, '_has_largefiles', has_largefiles)
276 276 with patcher:
277 277 status, msg = PullRequestModel().merge_status(pull_request)
278 278
279 279 assert status is False
280 280 assert msg == 'Source repository large files support is disabled.'
281 281
282 282 def test_merge(self, pull_request, merge_extras):
283 283 user = UserModel().get_by_username(TEST_USER_ADMIN_LOGIN)
284 284 merge_ref = Reference(
285 285 'type', 'name', '6126b7bfcc82ad2d3deaee22af926b082ce54cc6')
286 286 self.merge_mock.return_value = MergeResponse(
287 287 True, True, merge_ref, MergeFailureReason.NONE)
288 288
289 289 merge_extras['repository'] = pull_request.target_repo.repo_name
290 290 PullRequestModel().merge_repo(
291 291 pull_request, pull_request.author, extras=merge_extras)
292 292
293 293 message = (
294 294 u'Merge pull request #{pr_id} from {source_repo} {source_ref_name}'
295 295 u'\n\n {pr_title}'.format(
296 296 pr_id=pull_request.pull_request_id,
297 297 source_repo=safe_unicode(
298 298 pull_request.source_repo.scm_instance().name),
299 299 source_ref_name=pull_request.source_ref_parts.name,
300 300 pr_title=safe_unicode(pull_request.title)
301 301 )
302 302 )
303 303 self.merge_mock.assert_called_with(
304 304 self.repo_id, self.workspace_id,
305 305 pull_request.target_ref_parts,
306 306 pull_request.source_repo.scm_instance(),
307 307 pull_request.source_ref_parts,
308 308 user_name=user.short_contact, user_email=user.email, message=message,
309 309 use_rebase=False, close_branch=False
310 310 )
311 311 self.invalidation_mock.assert_called_once_with(
312 312 pull_request.target_repo.repo_name)
313 313
314 314 self.hook_mock.assert_called_with(
315 315 self.pull_request, self.pull_request.author, 'merge')
316 316
317 317 pull_request = PullRequest.get(pull_request.pull_request_id)
318 318 assert (
319 319 pull_request.merge_rev ==
320 320 '6126b7bfcc82ad2d3deaee22af926b082ce54cc6')
321 321
322 322 def test_merge_failed(self, pull_request, merge_extras):
323 323 user = UserModel().get_by_username(TEST_USER_ADMIN_LOGIN)
324 324 merge_ref = Reference(
325 325 'type', 'name', '6126b7bfcc82ad2d3deaee22af926b082ce54cc6')
326 326 self.merge_mock.return_value = MergeResponse(
327 327 False, False, merge_ref, MergeFailureReason.MERGE_FAILED)
328 328
329 329 merge_extras['repository'] = pull_request.target_repo.repo_name
330 330 PullRequestModel().merge_repo(
331 331 pull_request, pull_request.author, extras=merge_extras)
332 332
333 333 message = (
334 334 u'Merge pull request #{pr_id} from {source_repo} {source_ref_name}'
335 335 u'\n\n {pr_title}'.format(
336 336 pr_id=pull_request.pull_request_id,
337 337 source_repo=safe_unicode(
338 338 pull_request.source_repo.scm_instance().name),
339 339 source_ref_name=pull_request.source_ref_parts.name,
340 340 pr_title=safe_unicode(pull_request.title)
341 341 )
342 342 )
343 343 self.merge_mock.assert_called_with(
344 344 self.repo_id, self.workspace_id,
345 345 pull_request.target_ref_parts,
346 346 pull_request.source_repo.scm_instance(),
347 347 pull_request.source_ref_parts,
348 348 user_name=user.short_contact, user_email=user.email, message=message,
349 349 use_rebase=False, close_branch=False
350 350 )
351 351
352 352 pull_request = PullRequest.get(pull_request.pull_request_id)
353 353 assert self.invalidation_mock.called is False
354 354 assert pull_request.merge_rev is None
355 355
356 356 def test_get_commit_ids(self, pull_request):
357 357 # The PR has been not merget yet, so expect an exception
358 358 with pytest.raises(ValueError):
359 359 PullRequestModel()._get_commit_ids(pull_request)
360 360
361 361 # Merge revision is in the revisions list
362 362 pull_request.merge_rev = pull_request.revisions[0]
363 363 commit_ids = PullRequestModel()._get_commit_ids(pull_request)
364 364 assert commit_ids == pull_request.revisions
365 365
366 366 # Merge revision is not in the revisions list
367 367 pull_request.merge_rev = 'f000' * 10
368 368 commit_ids = PullRequestModel()._get_commit_ids(pull_request)
369 369 assert commit_ids == pull_request.revisions + [pull_request.merge_rev]
370 370
371 371 def test_get_diff_from_pr_version(self, pull_request):
372 372 source_repo = pull_request.source_repo
373 373 source_ref_id = pull_request.source_ref_parts.commit_id
374 374 target_ref_id = pull_request.target_ref_parts.commit_id
375 375 diff = PullRequestModel()._get_diff_from_pr_or_version(
376 source_repo, source_ref_id, target_ref_id, context=6)
376 source_repo, source_ref_id, target_ref_id,
377 hide_whitespace_changes=False, diff_context=6)
377 378 assert 'file_1' in diff.raw
378 379
379 380 def test_generate_title_returns_unicode(self):
380 381 title = PullRequestModel().generate_pullrequest_title(
381 382 source='source-dummy',
382 383 source_ref='source-ref-dummy',
383 384 target='target-dummy',
384 385 )
385 386 assert type(title) == unicode
386 387
387 388
388 389 @pytest.mark.usefixtures('config_stub')
389 390 class TestIntegrationMerge(object):
390 391 @pytest.mark.parametrize('extra_config', (
391 392 {'vcs.hooks.protocol': 'http', 'vcs.hooks.direct_calls': False},
392 393 ))
393 394 def test_merge_triggers_push_hooks(
394 395 self, pr_util, user_admin, capture_rcextensions, merge_extras,
395 396 extra_config):
396 397
397 398 pull_request = pr_util.create_pull_request(
398 399 approved=True, mergeable=True)
399 400 # TODO: johbo: Needed for sqlite, try to find an automatic way for it
400 401 merge_extras['repository'] = pull_request.target_repo.repo_name
401 402 Session().commit()
402 403
403 404 with mock.patch.dict(rhodecode.CONFIG, extra_config, clear=False):
404 405 merge_state = PullRequestModel().merge_repo(
405 406 pull_request, user_admin, extras=merge_extras)
406 407
407 408 assert merge_state.executed
408 409 assert '_pre_push_hook' in capture_rcextensions
409 410 assert '_push_hook' in capture_rcextensions
410 411
411 412 def test_merge_can_be_rejected_by_pre_push_hook(
412 413 self, pr_util, user_admin, capture_rcextensions, merge_extras):
413 414 pull_request = pr_util.create_pull_request(
414 415 approved=True, mergeable=True)
415 416 # TODO: johbo: Needed for sqlite, try to find an automatic way for it
416 417 merge_extras['repository'] = pull_request.target_repo.repo_name
417 418 Session().commit()
418 419
419 420 with mock.patch('rhodecode.EXTENSIONS.PRE_PUSH_HOOK') as pre_pull:
420 421 pre_pull.side_effect = RepositoryError("Disallow push!")
421 422 merge_status = PullRequestModel().merge_repo(
422 423 pull_request, user_admin, extras=merge_extras)
423 424
424 425 assert not merge_status.executed
425 426 assert 'pre_push' not in capture_rcextensions
426 427 assert 'post_push' not in capture_rcextensions
427 428
428 429 def test_merge_fails_if_target_is_locked(
429 430 self, pr_util, user_regular, merge_extras):
430 431 pull_request = pr_util.create_pull_request(
431 432 approved=True, mergeable=True)
432 433 locked_by = [user_regular.user_id + 1, 12345.50, 'lock_web']
433 434 pull_request.target_repo.locked = locked_by
434 435 # TODO: johbo: Check if this can work based on the database, currently
435 436 # all data is pre-computed, that's why just updating the DB is not
436 437 # enough.
437 438 merge_extras['locked_by'] = locked_by
438 439 merge_extras['repository'] = pull_request.target_repo.repo_name
439 440 # TODO: johbo: Needed for sqlite, try to find an automatic way for it
440 441 Session().commit()
441 442 merge_status = PullRequestModel().merge_repo(
442 443 pull_request, user_regular, extras=merge_extras)
443 444 assert not merge_status.executed
444 445
445 446
446 447 @pytest.mark.parametrize('use_outdated, inlines_count, outdated_count', [
447 448 (False, 1, 0),
448 449 (True, 0, 1),
449 450 ])
450 451 def test_outdated_comments(
451 452 pr_util, use_outdated, inlines_count, outdated_count, config_stub):
452 453 pull_request = pr_util.create_pull_request()
453 454 pr_util.create_inline_comment(file_path='not_in_updated_diff')
454 455
455 456 with outdated_comments_patcher(use_outdated) as outdated_comment_mock:
456 457 pr_util.add_one_commit()
457 458 assert_inline_comments(
458 459 pull_request, visible=inlines_count, outdated=outdated_count)
459 460 outdated_comment_mock.assert_called_with(pull_request)
460 461
461 462
462 463 @pytest.fixture
463 464 def merge_extras(user_regular):
464 465 """
465 466 Context for the vcs operation when running a merge.
466 467 """
467 468 extras = {
468 469 'ip': '127.0.0.1',
469 470 'username': user_regular.username,
470 471 'user_id': user_regular.user_id,
471 472 'action': 'push',
472 473 'repository': 'fake_target_repo_name',
473 474 'scm': 'git',
474 475 'config': 'fake_config_ini_path',
475 476 'repo_store': '',
476 477 'make_lock': None,
477 478 'locked_by': [None, None, None],
478 479 'server_url': 'http://test.example.com:5000',
479 480 'hooks': ['push', 'pull'],
480 481 'is_shadow_repo': False,
481 482 }
482 483 return extras
483 484
484 485
485 486 @pytest.mark.usefixtures('config_stub')
486 487 class TestUpdateCommentHandling(object):
487 488
488 489 @pytest.fixture(autouse=True, scope='class')
489 490 def enable_outdated_comments(self, request, baseapp):
490 491 config_patch = mock.patch.dict(
491 492 'rhodecode.CONFIG', {'rhodecode_use_outdated_comments': True})
492 493 config_patch.start()
493 494
494 495 @request.addfinalizer
495 496 def cleanup():
496 497 config_patch.stop()
497 498
498 499 def test_comment_stays_unflagged_on_unchanged_diff(self, pr_util):
499 500 commits = [
500 501 {'message': 'a'},
501 502 {'message': 'b', 'added': [FileNode('file_b', 'test_content\n')]},
502 503 {'message': 'c', 'added': [FileNode('file_c', 'test_content\n')]},
503 504 ]
504 505 pull_request = pr_util.create_pull_request(
505 506 commits=commits, target_head='a', source_head='b', revisions=['b'])
506 507 pr_util.create_inline_comment(file_path='file_b')
507 508 pr_util.add_one_commit(head='c')
508 509
509 510 assert_inline_comments(pull_request, visible=1, outdated=0)
510 511
511 512 def test_comment_stays_unflagged_on_change_above(self, pr_util):
512 513 original_content = ''.join(
513 514 ['line {}\n'.format(x) for x in range(1, 11)])
514 515 updated_content = 'new_line_at_top\n' + original_content
515 516 commits = [
516 517 {'message': 'a'},
517 518 {'message': 'b', 'added': [FileNode('file_b', original_content)]},
518 519 {'message': 'c', 'changed': [FileNode('file_b', updated_content)]},
519 520 ]
520 521 pull_request = pr_util.create_pull_request(
521 522 commits=commits, target_head='a', source_head='b', revisions=['b'])
522 523
523 524 with outdated_comments_patcher():
524 525 comment = pr_util.create_inline_comment(
525 526 line_no=u'n8', file_path='file_b')
526 527 pr_util.add_one_commit(head='c')
527 528
528 529 assert_inline_comments(pull_request, visible=1, outdated=0)
529 530 assert comment.line_no == u'n9'
530 531
531 532 def test_comment_stays_unflagged_on_change_below(self, pr_util):
532 533 original_content = ''.join(['line {}\n'.format(x) for x in range(10)])
533 534 updated_content = original_content + 'new_line_at_end\n'
534 535 commits = [
535 536 {'message': 'a'},
536 537 {'message': 'b', 'added': [FileNode('file_b', original_content)]},
537 538 {'message': 'c', 'changed': [FileNode('file_b', updated_content)]},
538 539 ]
539 540 pull_request = pr_util.create_pull_request(
540 541 commits=commits, target_head='a', source_head='b', revisions=['b'])
541 542 pr_util.create_inline_comment(file_path='file_b')
542 543 pr_util.add_one_commit(head='c')
543 544
544 545 assert_inline_comments(pull_request, visible=1, outdated=0)
545 546
546 547 @pytest.mark.parametrize('line_no', ['n4', 'o4', 'n10', 'o9'])
547 548 def test_comment_flagged_on_change_around_context(self, pr_util, line_no):
548 549 base_lines = ['line {}\n'.format(x) for x in range(1, 13)]
549 550 change_lines = list(base_lines)
550 551 change_lines.insert(6, 'line 6a added\n')
551 552
552 553 # Changes on the last line of sight
553 554 update_lines = list(change_lines)
554 555 update_lines[0] = 'line 1 changed\n'
555 556 update_lines[-1] = 'line 12 changed\n'
556 557
557 558 def file_b(lines):
558 559 return FileNode('file_b', ''.join(lines))
559 560
560 561 commits = [
561 562 {'message': 'a', 'added': [file_b(base_lines)]},
562 563 {'message': 'b', 'changed': [file_b(change_lines)]},
563 564 {'message': 'c', 'changed': [file_b(update_lines)]},
564 565 ]
565 566
566 567 pull_request = pr_util.create_pull_request(
567 568 commits=commits, target_head='a', source_head='b', revisions=['b'])
568 569 pr_util.create_inline_comment(line_no=line_no, file_path='file_b')
569 570
570 571 with outdated_comments_patcher():
571 572 pr_util.add_one_commit(head='c')
572 573 assert_inline_comments(pull_request, visible=0, outdated=1)
573 574
574 575 @pytest.mark.parametrize("change, content", [
575 576 ('changed', 'changed\n'),
576 577 ('removed', ''),
577 578 ], ids=['changed', 'removed'])
578 579 def test_comment_flagged_on_change(self, pr_util, change, content):
579 580 commits = [
580 581 {'message': 'a'},
581 582 {'message': 'b', 'added': [FileNode('file_b', 'test_content\n')]},
582 583 {'message': 'c', change: [FileNode('file_b', content)]},
583 584 ]
584 585 pull_request = pr_util.create_pull_request(
585 586 commits=commits, target_head='a', source_head='b', revisions=['b'])
586 587 pr_util.create_inline_comment(file_path='file_b')
587 588
588 589 with outdated_comments_patcher():
589 590 pr_util.add_one_commit(head='c')
590 591 assert_inline_comments(pull_request, visible=0, outdated=1)
591 592
592 593
593 594 @pytest.mark.usefixtures('config_stub')
594 595 class TestUpdateChangedFiles(object):
595 596
596 597 def test_no_changes_on_unchanged_diff(self, pr_util):
597 598 commits = [
598 599 {'message': 'a'},
599 600 {'message': 'b',
600 601 'added': [FileNode('file_b', 'test_content b\n')]},
601 602 {'message': 'c',
602 603 'added': [FileNode('file_c', 'test_content c\n')]},
603 604 ]
604 605 # open a PR from a to b, adding file_b
605 606 pull_request = pr_util.create_pull_request(
606 607 commits=commits, target_head='a', source_head='b', revisions=['b'],
607 608 name_suffix='per-file-review')
608 609
609 610 # modify PR adding new file file_c
610 611 pr_util.add_one_commit(head='c')
611 612
612 613 assert_pr_file_changes(
613 614 pull_request,
614 615 added=['file_c'],
615 616 modified=[],
616 617 removed=[])
617 618
618 619 def test_modify_and_undo_modification_diff(self, pr_util):
619 620 commits = [
620 621 {'message': 'a'},
621 622 {'message': 'b',
622 623 'added': [FileNode('file_b', 'test_content b\n')]},
623 624 {'message': 'c',
624 625 'changed': [FileNode('file_b', 'test_content b modified\n')]},
625 626 {'message': 'd',
626 627 'changed': [FileNode('file_b', 'test_content b\n')]},
627 628 ]
628 629 # open a PR from a to b, adding file_b
629 630 pull_request = pr_util.create_pull_request(
630 631 commits=commits, target_head='a', source_head='b', revisions=['b'],
631 632 name_suffix='per-file-review')
632 633
633 634 # modify PR modifying file file_b
634 635 pr_util.add_one_commit(head='c')
635 636
636 637 assert_pr_file_changes(
637 638 pull_request,
638 639 added=[],
639 640 modified=['file_b'],
640 641 removed=[])
641 642
642 643 # move the head again to d, which rollbacks change,
643 644 # meaning we should indicate no changes
644 645 pr_util.add_one_commit(head='d')
645 646
646 647 assert_pr_file_changes(
647 648 pull_request,
648 649 added=[],
649 650 modified=[],
650 651 removed=[])
651 652
652 653 def test_updated_all_files_in_pr(self, pr_util):
653 654 commits = [
654 655 {'message': 'a'},
655 656 {'message': 'b', 'added': [
656 657 FileNode('file_a', 'test_content a\n'),
657 658 FileNode('file_b', 'test_content b\n'),
658 659 FileNode('file_c', 'test_content c\n')]},
659 660 {'message': 'c', 'changed': [
660 661 FileNode('file_a', 'test_content a changed\n'),
661 662 FileNode('file_b', 'test_content b changed\n'),
662 663 FileNode('file_c', 'test_content c changed\n')]},
663 664 ]
664 665 # open a PR from a to b, changing 3 files
665 666 pull_request = pr_util.create_pull_request(
666 667 commits=commits, target_head='a', source_head='b', revisions=['b'],
667 668 name_suffix='per-file-review')
668 669
669 670 pr_util.add_one_commit(head='c')
670 671
671 672 assert_pr_file_changes(
672 673 pull_request,
673 674 added=[],
674 675 modified=['file_a', 'file_b', 'file_c'],
675 676 removed=[])
676 677
677 678 def test_updated_and_removed_all_files_in_pr(self, pr_util):
678 679 commits = [
679 680 {'message': 'a'},
680 681 {'message': 'b', 'added': [
681 682 FileNode('file_a', 'test_content a\n'),
682 683 FileNode('file_b', 'test_content b\n'),
683 684 FileNode('file_c', 'test_content c\n')]},
684 685 {'message': 'c', 'removed': [
685 686 FileNode('file_a', 'test_content a changed\n'),
686 687 FileNode('file_b', 'test_content b changed\n'),
687 688 FileNode('file_c', 'test_content c changed\n')]},
688 689 ]
689 690 # open a PR from a to b, removing 3 files
690 691 pull_request = pr_util.create_pull_request(
691 692 commits=commits, target_head='a', source_head='b', revisions=['b'],
692 693 name_suffix='per-file-review')
693 694
694 695 pr_util.add_one_commit(head='c')
695 696
696 697 assert_pr_file_changes(
697 698 pull_request,
698 699 added=[],
699 700 modified=[],
700 701 removed=['file_a', 'file_b', 'file_c'])
701 702
702 703
703 704 def test_update_writes_snapshot_into_pull_request_version(pr_util, config_stub):
704 705 model = PullRequestModel()
705 706 pull_request = pr_util.create_pull_request()
706 707 pr_util.update_source_repository()
707 708
708 709 model.update_commits(pull_request)
709 710
710 711 # Expect that it has a version entry now
711 712 assert len(model.get_versions(pull_request)) == 1
712 713
713 714
714 715 def test_update_skips_new_version_if_unchanged(pr_util, config_stub):
715 716 pull_request = pr_util.create_pull_request()
716 717 model = PullRequestModel()
717 718 model.update_commits(pull_request)
718 719
719 720 # Expect that it still has no versions
720 721 assert len(model.get_versions(pull_request)) == 0
721 722
722 723
723 724 def test_update_assigns_comments_to_the_new_version(pr_util, config_stub):
724 725 model = PullRequestModel()
725 726 pull_request = pr_util.create_pull_request()
726 727 comment = pr_util.create_comment()
727 728 pr_util.update_source_repository()
728 729
729 730 model.update_commits(pull_request)
730 731
731 732 # Expect that the comment is linked to the pr version now
732 733 assert comment.pull_request_version == model.get_versions(pull_request)[0]
733 734
734 735
735 736 def test_update_adds_a_comment_to_the_pull_request_about_the_change(pr_util, config_stub):
736 737 model = PullRequestModel()
737 738 pull_request = pr_util.create_pull_request()
738 739 pr_util.update_source_repository()
739 740 pr_util.update_source_repository()
740 741
741 742 model.update_commits(pull_request)
742 743
743 744 # Expect to find a new comment about the change
744 745 expected_message = textwrap.dedent(
745 746 """\
746 747 Pull request updated. Auto status change to |under_review|
747 748
748 749 .. role:: added
749 750 .. role:: removed
750 751 .. parsed-literal::
751 752
752 753 Changed commits:
753 754 * :added:`1 added`
754 755 * :removed:`0 removed`
755 756
756 757 Changed files:
757 758 * `A file_2 <#a_c--92ed3b5f07b4>`_
758 759
759 760 .. |under_review| replace:: *"Under Review"*"""
760 761 )
761 762 pull_request_comments = sorted(
762 763 pull_request.comments, key=lambda c: c.modified_at)
763 764 update_comment = pull_request_comments[-1]
764 765 assert update_comment.text == expected_message
765 766
766 767
767 768 def test_create_version_from_snapshot_updates_attributes(pr_util, config_stub):
768 769 pull_request = pr_util.create_pull_request()
769 770
770 771 # Avoiding default values
771 772 pull_request.status = PullRequest.STATUS_CLOSED
772 773 pull_request._last_merge_source_rev = "0" * 40
773 774 pull_request._last_merge_target_rev = "1" * 40
774 775 pull_request.last_merge_status = 1
775 776 pull_request.merge_rev = "2" * 40
776 777
777 778 # Remember automatic values
778 779 created_on = pull_request.created_on
779 780 updated_on = pull_request.updated_on
780 781
781 782 # Create a new version of the pull request
782 783 version = PullRequestModel()._create_version_from_snapshot(pull_request)
783 784
784 785 # Check attributes
785 786 assert version.title == pr_util.create_parameters['title']
786 787 assert version.description == pr_util.create_parameters['description']
787 788 assert version.status == PullRequest.STATUS_CLOSED
788 789
789 790 # versions get updated created_on
790 791 assert version.created_on != created_on
791 792
792 793 assert version.updated_on == updated_on
793 794 assert version.user_id == pull_request.user_id
794 795 assert version.revisions == pr_util.create_parameters['revisions']
795 796 assert version.source_repo == pr_util.source_repository
796 797 assert version.source_ref == pr_util.create_parameters['source_ref']
797 798 assert version.target_repo == pr_util.target_repository
798 799 assert version.target_ref == pr_util.create_parameters['target_ref']
799 800 assert version._last_merge_source_rev == pull_request._last_merge_source_rev
800 801 assert version._last_merge_target_rev == pull_request._last_merge_target_rev
801 802 assert version.last_merge_status == pull_request.last_merge_status
802 803 assert version.merge_rev == pull_request.merge_rev
803 804 assert version.pull_request == pull_request
804 805
805 806
806 807 def test_link_comments_to_version_only_updates_unlinked_comments(pr_util, config_stub):
807 808 version1 = pr_util.create_version_of_pull_request()
808 809 comment_linked = pr_util.create_comment(linked_to=version1)
809 810 comment_unlinked = pr_util.create_comment()
810 811 version2 = pr_util.create_version_of_pull_request()
811 812
812 813 PullRequestModel()._link_comments_to_version(version2)
813 814
814 815 # Expect that only the new comment is linked to version2
815 816 assert (
816 817 comment_unlinked.pull_request_version_id ==
817 818 version2.pull_request_version_id)
818 819 assert (
819 820 comment_linked.pull_request_version_id ==
820 821 version1.pull_request_version_id)
821 822 assert (
822 823 comment_unlinked.pull_request_version_id !=
823 824 comment_linked.pull_request_version_id)
824 825
825 826
826 827 def test_calculate_commits():
827 828 old_ids = [1, 2, 3]
828 829 new_ids = [1, 3, 4, 5]
829 830 change = PullRequestModel()._calculate_commit_id_changes(old_ids, new_ids)
830 831 assert change.added == [4, 5]
831 832 assert change.common == [1, 3]
832 833 assert change.removed == [2]
833 834 assert change.total == [1, 3, 4, 5]
834 835
835 836
836 837 def assert_inline_comments(pull_request, visible=None, outdated=None):
837 838 if visible is not None:
838 839 inline_comments = CommentsModel().get_inline_comments(
839 840 pull_request.target_repo.repo_id, pull_request=pull_request)
840 841 inline_cnt = CommentsModel().get_inline_comments_count(
841 842 inline_comments)
842 843 assert inline_cnt == visible
843 844 if outdated is not None:
844 845 outdated_comments = CommentsModel().get_outdated_comments(
845 846 pull_request.target_repo.repo_id, pull_request)
846 847 assert len(outdated_comments) == outdated
847 848
848 849
849 850 def assert_pr_file_changes(
850 851 pull_request, added=None, modified=None, removed=None):
851 852 pr_versions = PullRequestModel().get_versions(pull_request)
852 853 # always use first version, ie original PR to calculate changes
853 854 pull_request_version = pr_versions[0]
854 855 old_diff_data, new_diff_data = PullRequestModel()._generate_update_diffs(
855 856 pull_request, pull_request_version)
856 857 file_changes = PullRequestModel()._calculate_file_changes(
857 858 old_diff_data, new_diff_data)
858 859
859 860 assert added == file_changes.added, \
860 861 'expected added:%s vs value:%s' % (added, file_changes.added)
861 862 assert modified == file_changes.modified, \
862 863 'expected modified:%s vs value:%s' % (modified, file_changes.modified)
863 864 assert removed == file_changes.removed, \
864 865 'expected removed:%s vs value:%s' % (removed, file_changes.removed)
865 866
866 867
867 868 def outdated_comments_patcher(use_outdated=True):
868 869 return mock.patch.object(
869 870 CommentsModel, 'use_outdated_comments',
870 871 return_value=use_outdated)
@@ -1,70 +1,71 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2018 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 pytest
22 22
23 23 from rhodecode.lib.vcs.nodes import FileNode
24 24 from rhodecode.lib.vcs.exceptions import CommitDoesNotExistError
25 25 from rhodecode.model.pull_request import PullRequestModel
26 26
27 27
28 28 @pytest.mark.usefixtures('config_stub')
29 29 @pytest.mark.backends('git')
30 30 class TestGetDiffForPrOrVersion(object):
31 31
32 32 def test_works_for_missing_git_references(self, pr_util):
33 33 pull_request = self._prepare_pull_request(pr_util)
34 34 removed_commit_id = pr_util.remove_one_commit()
35 35 self.assert_commit_cannot_be_accessed(removed_commit_id, pull_request)
36 36
37 37 self.assert_diff_can_be_fetched(pull_request)
38 38
39 39 def test_works_for_missing_git_references_during_update(self, pr_util):
40 40 pull_request = self._prepare_pull_request(pr_util)
41 41 removed_commit_id = pr_util.remove_one_commit()
42 42 self.assert_commit_cannot_be_accessed(removed_commit_id, pull_request)
43 43
44 44 pr_version = PullRequestModel().get_versions(pull_request)[0]
45 45 self.assert_diff_can_be_fetched(pr_version)
46 46
47 47 def _prepare_pull_request(self, pr_util):
48 48 commits = [
49 49 {'message': 'a'},
50 50 {'message': 'b', 'added': [FileNode('file_b', 'test_content\n')]},
51 51 {'message': 'c', 'added': [FileNode('file_c', 'test_content\n')]},
52 52 ]
53 53 pull_request = pr_util.create_pull_request(
54 54 commits=commits, target_head='a', source_head='c',
55 55 revisions=['b', 'c'])
56 56 return pull_request
57 57
58 58 def assert_diff_can_be_fetched(self, pr_or_version):
59 59 source_repo = pr_or_version.source_repo
60 60 source_ref_id = pr_or_version.source_ref_parts.commit_id
61 61 target_ref_id = pr_or_version.target_ref_parts.commit_id
62 62 diff = PullRequestModel()._get_diff_from_pr_or_version(
63 source_repo, source_ref_id, target_ref_id, context=6)
63 source_repo, source_ref_id, target_ref_id,
64 hide_whitespace_changes=False, diff_context=6)
64 65 assert 'file_b' in diff.raw
65 66
66 67 def assert_commit_cannot_be_accessed(
67 68 self, removed_commit_id, pull_request):
68 69 source_vcs = pull_request.source_repo.scm_instance()
69 70 with pytest.raises(CommitDoesNotExistError):
70 71 source_vcs.get_commit(commit_id=removed_commit_id)
General Comments 0
You need to be logged in to leave comments. Login now