##// END OF EJS Templates
pull-requests: prepare the migration of pull request to pyramid....
marcink -
r1813:07e2beb0 default
parent child Browse files
Show More
@@ -1,184 +1,584 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import logging
22 22
23 import collections
24 from pyramid.httpexceptions import HTTPFound, HTTPNotFound
23 25 from pyramid.view import view_config
24 26
25 27 from rhodecode.apps._base import RepoAppView, DataGridAppView
26 from rhodecode.lib import helpers as h
27 from rhodecode.lib import audit_logger
28 from rhodecode.lib import helpers as h, diffs, codeblocks
28 29 from rhodecode.lib.auth import (
29 30 LoginRequired, HasRepoPermissionAnyDecorator)
30 31 from rhodecode.lib.utils import PartialRenderer
31 from rhodecode.lib.utils2 import str2bool
32 from rhodecode.lib.utils2 import str2bool, safe_int, safe_str
33 from rhodecode.lib.vcs.backends.base import EmptyCommit
34 from rhodecode.lib.vcs.exceptions import CommitDoesNotExistError, \
35 RepositoryRequirementError, NodeDoesNotExistError
32 36 from rhodecode.model.comment import CommentsModel
33 from rhodecode.model.db import PullRequest
34 from rhodecode.model.pull_request import PullRequestModel
37 from rhodecode.model.db import PullRequest, PullRequestVersion, \
38 ChangesetComment, ChangesetStatus
39 from rhodecode.model.pull_request import PullRequestModel, MergeCheck
35 40
36 41 log = logging.getLogger(__name__)
37 42
38 43
39 44 class RepoPullRequestsView(RepoAppView, DataGridAppView):
40 45
41 46 def load_default_context(self):
42 c = self._get_local_tmpl_context()
43
47 c = self._get_local_tmpl_context(include_app_defaults=True)
44 48 # TODO(marcink): remove repo_info and use c.rhodecode_db_repo instead
45 49 c.repo_info = self.db_repo
46
50 c.REVIEW_STATUS_APPROVED = ChangesetStatus.STATUS_APPROVED
51 c.REVIEW_STATUS_REJECTED = ChangesetStatus.STATUS_REJECTED
47 52 self._register_global_c(c)
48 53 return c
49 54
50 55 def _get_pull_requests_list(
51 56 self, repo_name, source, filter_type, opened_by, statuses):
52 57
53 58 draw, start, limit = self._extract_chunk(self.request)
54 59 search_q, order_by, order_dir = self._extract_ordering(self.request)
55 60 _render = PartialRenderer('data_table/_dt_elements.mako')
56 61
57 62 # pagination
58 63
59 64 if filter_type == 'awaiting_review':
60 65 pull_requests = PullRequestModel().get_awaiting_review(
61 66 repo_name, source=source, opened_by=opened_by,
62 67 statuses=statuses, offset=start, length=limit,
63 68 order_by=order_by, order_dir=order_dir)
64 69 pull_requests_total_count = PullRequestModel().count_awaiting_review(
65 70 repo_name, source=source, statuses=statuses,
66 71 opened_by=opened_by)
67 72 elif filter_type == 'awaiting_my_review':
68 73 pull_requests = PullRequestModel().get_awaiting_my_review(
69 74 repo_name, source=source, opened_by=opened_by,
70 75 user_id=self._rhodecode_user.user_id, statuses=statuses,
71 76 offset=start, length=limit, order_by=order_by,
72 77 order_dir=order_dir)
73 78 pull_requests_total_count = PullRequestModel().count_awaiting_my_review(
74 79 repo_name, source=source, user_id=self._rhodecode_user.user_id,
75 80 statuses=statuses, opened_by=opened_by)
76 81 else:
77 82 pull_requests = PullRequestModel().get_all(
78 83 repo_name, source=source, opened_by=opened_by,
79 84 statuses=statuses, offset=start, length=limit,
80 85 order_by=order_by, order_dir=order_dir)
81 86 pull_requests_total_count = PullRequestModel().count_all(
82 87 repo_name, source=source, statuses=statuses,
83 88 opened_by=opened_by)
84 89
85 90 data = []
86 91 comments_model = CommentsModel()
87 92 for pr in pull_requests:
88 93 comments = comments_model.get_all_comments(
89 94 self.db_repo.repo_id, pull_request=pr)
90 95
91 96 data.append({
92 97 'name': _render('pullrequest_name',
93 98 pr.pull_request_id, pr.target_repo.repo_name),
94 99 'name_raw': pr.pull_request_id,
95 100 'status': _render('pullrequest_status',
96 101 pr.calculated_review_status()),
97 102 'title': _render(
98 103 'pullrequest_title', pr.title, pr.description),
99 104 'description': h.escape(pr.description),
100 105 'updated_on': _render('pullrequest_updated_on',
101 106 h.datetime_to_time(pr.updated_on)),
102 107 'updated_on_raw': h.datetime_to_time(pr.updated_on),
103 108 'created_on': _render('pullrequest_updated_on',
104 109 h.datetime_to_time(pr.created_on)),
105 110 'created_on_raw': h.datetime_to_time(pr.created_on),
106 111 'author': _render('pullrequest_author',
107 112 pr.author.full_contact, ),
108 113 'author_raw': pr.author.full_name,
109 114 'comments': _render('pullrequest_comments', len(comments)),
110 115 'comments_raw': len(comments),
111 116 'closed': pr.is_closed(),
112 117 })
113 118
114 119 data = ({
115 120 'draw': draw,
116 121 'data': data,
117 122 'recordsTotal': pull_requests_total_count,
118 123 'recordsFiltered': pull_requests_total_count,
119 124 })
120 125 return data
121 126
122 127 @LoginRequired()
123 128 @HasRepoPermissionAnyDecorator(
124 129 'repository.read', 'repository.write', 'repository.admin')
125 130 @view_config(
126 131 route_name='pullrequest_show_all', request_method='GET',
127 132 renderer='rhodecode:templates/pullrequests/pullrequests.mako')
128 133 def pull_request_list(self):
129 134 c = self.load_default_context()
130 135
131 136 req_get = self.request.GET
132 137 c.source = str2bool(req_get.get('source'))
133 138 c.closed = str2bool(req_get.get('closed'))
134 139 c.my = str2bool(req_get.get('my'))
135 140 c.awaiting_review = str2bool(req_get.get('awaiting_review'))
136 141 c.awaiting_my_review = str2bool(req_get.get('awaiting_my_review'))
137 142
138 143 c.active = 'open'
139 144 if c.my:
140 145 c.active = 'my'
141 146 if c.closed:
142 147 c.active = 'closed'
143 148 if c.awaiting_review and not c.source:
144 149 c.active = 'awaiting'
145 150 if c.source and not c.awaiting_review:
146 151 c.active = 'source'
147 152 if c.awaiting_my_review:
148 153 c.active = 'awaiting_my'
149 154
150 155 return self._get_template_context(c)
151 156
152 157 @LoginRequired()
153 158 @HasRepoPermissionAnyDecorator(
154 159 'repository.read', 'repository.write', 'repository.admin')
155 160 @view_config(
156 161 route_name='pullrequest_show_all_data', request_method='GET',
157 162 renderer='json_ext', xhr=True)
158 163 def pull_request_list_data(self):
159 164
160 165 # additional filters
161 166 req_get = self.request.GET
162 167 source = str2bool(req_get.get('source'))
163 168 closed = str2bool(req_get.get('closed'))
164 169 my = str2bool(req_get.get('my'))
165 170 awaiting_review = str2bool(req_get.get('awaiting_review'))
166 171 awaiting_my_review = str2bool(req_get.get('awaiting_my_review'))
167 172
168 173 filter_type = 'awaiting_review' if awaiting_review \
169 174 else 'awaiting_my_review' if awaiting_my_review \
170 175 else None
171 176
172 177 opened_by = None
173 178 if my:
174 179 opened_by = [self._rhodecode_user.user_id]
175 180
176 181 statuses = [PullRequest.STATUS_NEW, PullRequest.STATUS_OPEN]
177 182 if closed:
178 183 statuses = [PullRequest.STATUS_CLOSED]
179 184
180 185 data = self._get_pull_requests_list(
181 186 repo_name=self.db_repo_name, source=source,
182 187 filter_type=filter_type, opened_by=opened_by, statuses=statuses)
183 188
184 189 return data
190
191 def _get_pr_version(self, pull_request_id, version=None):
192 pull_request_id = safe_int(pull_request_id)
193 at_version = None
194
195 if version and version == 'latest':
196 pull_request_ver = PullRequest.get(pull_request_id)
197 pull_request_obj = pull_request_ver
198 _org_pull_request_obj = pull_request_obj
199 at_version = 'latest'
200 elif version:
201 pull_request_ver = PullRequestVersion.get_or_404(version)
202 pull_request_obj = pull_request_ver
203 _org_pull_request_obj = pull_request_ver.pull_request
204 at_version = pull_request_ver.pull_request_version_id
205 else:
206 _org_pull_request_obj = pull_request_obj = PullRequest.get_or_404(
207 pull_request_id)
208
209 pull_request_display_obj = PullRequest.get_pr_display_object(
210 pull_request_obj, _org_pull_request_obj)
211
212 return _org_pull_request_obj, pull_request_obj, \
213 pull_request_display_obj, at_version
214
215 def _get_diffset(self, source_repo_name, source_repo,
216 source_ref_id, target_ref_id,
217 target_commit, source_commit, diff_limit, fulldiff,
218 file_limit, display_inline_comments):
219
220 vcs_diff = PullRequestModel().get_diff(
221 source_repo, source_ref_id, target_ref_id)
222
223 diff_processor = diffs.DiffProcessor(
224 vcs_diff, format='newdiff', diff_limit=diff_limit,
225 file_limit=file_limit, show_full_diff=fulldiff)
226
227 _parsed = diff_processor.prepare()
228
229 def _node_getter(commit):
230 def get_node(fname):
231 try:
232 return commit.get_node(fname)
233 except NodeDoesNotExistError:
234 return None
235
236 return get_node
237
238 diffset = codeblocks.DiffSet(
239 repo_name=self.db_repo_name,
240 source_repo_name=source_repo_name,
241 source_node_getter=_node_getter(target_commit),
242 target_node_getter=_node_getter(source_commit),
243 comments=display_inline_comments
244 )
245 diffset = diffset.render_patchset(
246 _parsed, target_commit.raw_id, source_commit.raw_id)
247
248 return diffset
249
250 @LoginRequired()
251 @HasRepoPermissionAnyDecorator(
252 'repository.read', 'repository.write', 'repository.admin')
253 # @view_config(
254 # route_name='pullrequest_show', request_method='GET',
255 # renderer='rhodecode:templates/pullrequests/pullrequest_show.mako')
256 def pull_request_show(self):
257 pull_request_id = safe_int(
258 self.request.matchdict.get('pull_request_id'))
259 c = self.load_default_context()
260
261 version = self.request.GET.get('version')
262 from_version = self.request.GET.get('from_version') or version
263 merge_checks = self.request.GET.get('merge_checks')
264 c.fulldiff = str2bool(self.request.GET.get('fulldiff'))
265
266 (pull_request_latest,
267 pull_request_at_ver,
268 pull_request_display_obj,
269 at_version) = self._get_pr_version(
270 pull_request_id, version=version)
271 pr_closed = pull_request_latest.is_closed()
272
273 if pr_closed and (version or from_version):
274 # not allow to browse versions
275 raise HTTPFound(h.route_path(
276 'pullrequest_show', repo_name=self.db_repo_name,
277 pull_request_id=pull_request_id))
278
279 versions = pull_request_display_obj.versions()
280
281 c.at_version = at_version
282 c.at_version_num = (at_version
283 if at_version and at_version != 'latest'
284 else None)
285 c.at_version_pos = ChangesetComment.get_index_from_version(
286 c.at_version_num, versions)
287
288 (prev_pull_request_latest,
289 prev_pull_request_at_ver,
290 prev_pull_request_display_obj,
291 prev_at_version) = self._get_pr_version(
292 pull_request_id, version=from_version)
293
294 c.from_version = prev_at_version
295 c.from_version_num = (prev_at_version
296 if prev_at_version and prev_at_version != 'latest'
297 else None)
298 c.from_version_pos = ChangesetComment.get_index_from_version(
299 c.from_version_num, versions)
300
301 # define if we're in COMPARE mode or VIEW at version mode
302 compare = at_version != prev_at_version
303
304 # pull_requests repo_name we opened it against
305 # ie. target_repo must match
306 if self.db_repo_name != pull_request_at_ver.target_repo.repo_name:
307 raise HTTPNotFound()
308
309 c.shadow_clone_url = PullRequestModel().get_shadow_clone_url(
310 pull_request_at_ver)
311
312 c.pull_request = pull_request_display_obj
313 c.pull_request_latest = pull_request_latest
314
315 if compare or (at_version and not at_version == 'latest'):
316 c.allowed_to_change_status = False
317 c.allowed_to_update = False
318 c.allowed_to_merge = False
319 c.allowed_to_delete = False
320 c.allowed_to_comment = False
321 c.allowed_to_close = False
322 else:
323 can_change_status = PullRequestModel().check_user_change_status(
324 pull_request_at_ver, self._rhodecode_user)
325 c.allowed_to_change_status = can_change_status and not pr_closed
326
327 c.allowed_to_update = PullRequestModel().check_user_update(
328 pull_request_latest, self._rhodecode_user) and not pr_closed
329 c.allowed_to_merge = PullRequestModel().check_user_merge(
330 pull_request_latest, self._rhodecode_user) and not pr_closed
331 c.allowed_to_delete = PullRequestModel().check_user_delete(
332 pull_request_latest, self._rhodecode_user) and not pr_closed
333 c.allowed_to_comment = not pr_closed
334 c.allowed_to_close = c.allowed_to_merge and not pr_closed
335
336 c.forbid_adding_reviewers = False
337 c.forbid_author_to_review = False
338 c.forbid_commit_author_to_review = False
339
340 if pull_request_latest.reviewer_data and \
341 'rules' in pull_request_latest.reviewer_data:
342 rules = pull_request_latest.reviewer_data['rules'] or {}
343 try:
344 c.forbid_adding_reviewers = rules.get(
345 'forbid_adding_reviewers')
346 c.forbid_author_to_review = rules.get(
347 'forbid_author_to_review')
348 c.forbid_commit_author_to_review = rules.get(
349 'forbid_commit_author_to_review')
350 except Exception:
351 pass
352
353 # check merge capabilities
354 _merge_check = MergeCheck.validate(
355 pull_request_latest, user=self._rhodecode_user)
356 c.pr_merge_errors = _merge_check.error_details
357 c.pr_merge_possible = not _merge_check.failed
358 c.pr_merge_message = _merge_check.merge_msg
359
360 c.pull_request_review_status = _merge_check.review_status
361 if merge_checks:
362 self.request.override_renderer = \
363 'rhodecode:templates/pullrequests/pullrequest_merge_checks.mako'
364 return self._get_template_context(c)
365
366 comments_model = CommentsModel()
367
368 # reviewers and statuses
369 c.pull_request_reviewers = pull_request_at_ver.reviewers_statuses()
370 allowed_reviewers = [x[0].user_id for x in c.pull_request_reviewers]
371
372 # GENERAL COMMENTS with versions #
373 q = comments_model._all_general_comments_of_pull_request(pull_request_latest)
374 q = q.order_by(ChangesetComment.comment_id.asc())
375 general_comments = q
376
377 # pick comments we want to render at current version
378 c.comment_versions = comments_model.aggregate_comments(
379 general_comments, versions, c.at_version_num)
380 c.comments = c.comment_versions[c.at_version_num]['until']
381
382 # INLINE COMMENTS with versions #
383 q = comments_model._all_inline_comments_of_pull_request(pull_request_latest)
384 q = q.order_by(ChangesetComment.comment_id.asc())
385 inline_comments = q
386
387 c.inline_versions = comments_model.aggregate_comments(
388 inline_comments, versions, c.at_version_num, inline=True)
389
390 # inject latest version
391 latest_ver = PullRequest.get_pr_display_object(
392 pull_request_latest, pull_request_latest)
393
394 c.versions = versions + [latest_ver]
395
396 # if we use version, then do not show later comments
397 # than current version
398 display_inline_comments = collections.defaultdict(
399 lambda: collections.defaultdict(list))
400 for co in inline_comments:
401 if c.at_version_num:
402 # pick comments that are at least UPTO given version, so we
403 # don't render comments for higher version
404 should_render = co.pull_request_version_id and \
405 co.pull_request_version_id <= c.at_version_num
406 else:
407 # showing all, for 'latest'
408 should_render = True
409
410 if should_render:
411 display_inline_comments[co.f_path][co.line_no].append(co)
412
413 # load diff data into template context, if we use compare mode then
414 # diff is calculated based on changes between versions of PR
415
416 source_repo = pull_request_at_ver.source_repo
417 source_ref_id = pull_request_at_ver.source_ref_parts.commit_id
418
419 target_repo = pull_request_at_ver.target_repo
420 target_ref_id = pull_request_at_ver.target_ref_parts.commit_id
421
422 if compare:
423 # in compare switch the diff base to latest commit from prev version
424 target_ref_id = prev_pull_request_display_obj.revisions[0]
425
426 # despite opening commits for bookmarks/branches/tags, we always
427 # convert this to rev to prevent changes after bookmark or branch change
428 c.source_ref_type = 'rev'
429 c.source_ref = source_ref_id
430
431 c.target_ref_type = 'rev'
432 c.target_ref = target_ref_id
433
434 c.source_repo = source_repo
435 c.target_repo = target_repo
436
437 c.commit_ranges = []
438 source_commit = EmptyCommit()
439 target_commit = EmptyCommit()
440 c.missing_requirements = False
441
442 source_scm = source_repo.scm_instance()
443 target_scm = target_repo.scm_instance()
444
445 # try first shadow repo, fallback to regular repo
446 try:
447 commits_source_repo = pull_request_latest.get_shadow_repo()
448 except Exception:
449 log.debug('Failed to get shadow repo', exc_info=True)
450 commits_source_repo = source_scm
451
452 c.commits_source_repo = commits_source_repo
453 commit_cache = {}
454 try:
455 pre_load = ["author", "branch", "date", "message"]
456 show_revs = pull_request_at_ver.revisions
457 for rev in show_revs:
458 comm = commits_source_repo.get_commit(
459 commit_id=rev, pre_load=pre_load)
460 c.commit_ranges.append(comm)
461 commit_cache[comm.raw_id] = comm
462
463 # Order here matters, we first need to get target, and then
464 # the source
465 target_commit = commits_source_repo.get_commit(
466 commit_id=safe_str(target_ref_id))
467
468 source_commit = commits_source_repo.get_commit(
469 commit_id=safe_str(source_ref_id))
470
471 except CommitDoesNotExistError:
472 log.warning(
473 'Failed to get commit from `{}` repo'.format(
474 commits_source_repo), exc_info=True)
475 except RepositoryRequirementError:
476 log.warning(
477 'Failed to get all required data from repo', exc_info=True)
478 c.missing_requirements = True
479
480 c.ancestor = None # set it to None, to hide it from PR view
481
482 try:
483 ancestor_id = source_scm.get_common_ancestor(
484 source_commit.raw_id, target_commit.raw_id, target_scm)
485 c.ancestor_commit = source_scm.get_commit(ancestor_id)
486 except Exception:
487 c.ancestor_commit = None
488
489 c.statuses = source_repo.statuses(
490 [x.raw_id for x in c.commit_ranges])
491
492 # auto collapse if we have more than limit
493 collapse_limit = diffs.DiffProcessor._collapse_commits_over
494 c.collapse_all_commits = len(c.commit_ranges) > collapse_limit
495 c.compare_mode = compare
496
497 # diff_limit is the old behavior, will cut off the whole diff
498 # if the limit is applied otherwise will just hide the
499 # big files from the front-end
500 diff_limit = c.visual.cut_off_limit_diff
501 file_limit = c.visual.cut_off_limit_file
502
503 c.missing_commits = False
504 if (c.missing_requirements
505 or isinstance(source_commit, EmptyCommit)
506 or source_commit == target_commit):
507
508 c.missing_commits = True
509 else:
510
511 c.diffset = self._get_diffset(
512 c.source_repo.repo_name, commits_source_repo,
513 source_ref_id, target_ref_id,
514 target_commit, source_commit,
515 diff_limit, c.fulldiff, file_limit, display_inline_comments)
516
517 c.limited_diff = c.diffset.limited_diff
518
519 # calculate removed files that are bound to comments
520 comment_deleted_files = [
521 fname for fname in display_inline_comments
522 if fname not in c.diffset.file_stats]
523
524 c.deleted_files_comments = collections.defaultdict(dict)
525 for fname, per_line_comments in display_inline_comments.items():
526 if fname in comment_deleted_files:
527 c.deleted_files_comments[fname]['stats'] = 0
528 c.deleted_files_comments[fname]['comments'] = list()
529 for lno, comments in per_line_comments.items():
530 c.deleted_files_comments[fname]['comments'].extend(
531 comments)
532
533 # this is a hack to properly display links, when creating PR, the
534 # compare view and others uses different notation, and
535 # compare_commits.mako renders links based on the target_repo.
536 # We need to swap that here to generate it properly on the html side
537 c.target_repo = c.source_repo
538
539 c.commit_statuses = ChangesetStatus.STATUSES
540
541 c.show_version_changes = not pr_closed
542 if c.show_version_changes:
543 cur_obj = pull_request_at_ver
544 prev_obj = prev_pull_request_at_ver
545
546 old_commit_ids = prev_obj.revisions
547 new_commit_ids = cur_obj.revisions
548 commit_changes = PullRequestModel()._calculate_commit_id_changes(
549 old_commit_ids, new_commit_ids)
550 c.commit_changes_summary = commit_changes
551
552 # calculate the diff for commits between versions
553 c.commit_changes = []
554 mark = lambda cs, fw: list(
555 h.itertools.izip_longest([], cs, fillvalue=fw))
556 for c_type, raw_id in mark(commit_changes.added, 'a') \
557 + mark(commit_changes.removed, 'r') \
558 + mark(commit_changes.common, 'c'):
559
560 if raw_id in commit_cache:
561 commit = commit_cache[raw_id]
562 else:
563 try:
564 commit = commits_source_repo.get_commit(raw_id)
565 except CommitDoesNotExistError:
566 # in case we fail extracting still use "dummy" commit
567 # for display in commit diff
568 commit = h.AttributeDict(
569 {'raw_id': raw_id,
570 'message': 'EMPTY or MISSING COMMIT'})
571 c.commit_changes.append([c_type, commit])
572
573 # current user review statuses for each version
574 c.review_versions = {}
575 if self._rhodecode_user.user_id in allowed_reviewers:
576 for co in general_comments:
577 if co.author.user_id == self._rhodecode_user.user_id:
578 # each comment has a status change
579 status = co.status_change
580 if status:
581 _ver_pr = status[0].comment.pull_request_version_id
582 c.review_versions[_ver_pr] = status[0]
583
584 return self._get_template_context(c)
@@ -1,1009 +1,1012 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 pull requests controller for rhodecode for initializing pull requests
23 23 """
24 24 import types
25 25
26 26 import peppercorn
27 27 import formencode
28 28 import logging
29 29 import collections
30 30
31 31 from webob.exc import HTTPNotFound, HTTPForbidden, HTTPBadRequest
32 32 from pylons import request, tmpl_context as c, url
33 33 from pylons.controllers.util import redirect
34 34 from pylons.i18n.translation import _
35 35 from pyramid.threadlocal import get_current_registry
36 36 from sqlalchemy.sql import func
37 37 from sqlalchemy.sql.expression import or_
38 38
39 39 from rhodecode import events
40 40 from rhodecode.lib import auth, diffs, helpers as h, codeblocks
41 41 from rhodecode.lib.ext_json import json
42 42 from rhodecode.lib.base import (
43 43 BaseRepoController, render, vcs_operation_context)
44 44 from rhodecode.lib.auth import (
45 45 LoginRequired, HasRepoPermissionAnyDecorator, NotAnonymous,
46 46 HasAcceptedRepoType, XHRRequired)
47 47 from rhodecode.lib.channelstream import channelstream_request
48 48 from rhodecode.lib.utils import jsonify
49 49 from rhodecode.lib.utils2 import (
50 50 safe_int, safe_str, str2bool, safe_unicode)
51 51 from rhodecode.lib.vcs.backends.base import (
52 52 EmptyCommit, UpdateFailureReason, EmptyRepository)
53 53 from rhodecode.lib.vcs.exceptions import (
54 54 EmptyRepositoryError, CommitDoesNotExistError, RepositoryRequirementError,
55 55 NodeDoesNotExistError)
56 56
57 57 from rhodecode.model.changeset_status import ChangesetStatusModel
58 58 from rhodecode.model.comment import CommentsModel
59 59 from rhodecode.model.db import (PullRequest, ChangesetStatus, ChangesetComment,
60 60 Repository, PullRequestVersion)
61 61 from rhodecode.model.forms import PullRequestForm
62 62 from rhodecode.model.meta import Session
63 63 from rhodecode.model.pull_request import PullRequestModel, MergeCheck
64 64
65 65 log = logging.getLogger(__name__)
66 66
67 67
68 68 class PullrequestsController(BaseRepoController):
69 69
70 70 def __before__(self):
71 71 super(PullrequestsController, self).__before__()
72 72 c.REVIEW_STATUS_APPROVED = ChangesetStatus.STATUS_APPROVED
73 73 c.REVIEW_STATUS_REJECTED = ChangesetStatus.STATUS_REJECTED
74 74
75 75 @LoginRequired()
76 76 @NotAnonymous()
77 77 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
78 78 'repository.admin')
79 79 @HasAcceptedRepoType('git', 'hg')
80 80 def index(self):
81 81 source_repo = c.rhodecode_db_repo
82 82
83 83 try:
84 84 source_repo.scm_instance().get_commit()
85 85 except EmptyRepositoryError:
86 86 h.flash(h.literal(_('There are no commits yet')),
87 87 category='warning')
88 88 redirect(h.route_path('repo_summary', repo_name=source_repo.repo_name))
89 89
90 90 commit_id = request.GET.get('commit')
91 91 branch_ref = request.GET.get('branch')
92 92 bookmark_ref = request.GET.get('bookmark')
93 93
94 94 try:
95 95 source_repo_data = PullRequestModel().generate_repo_data(
96 96 source_repo, commit_id=commit_id,
97 97 branch=branch_ref, bookmark=bookmark_ref)
98 98 except CommitDoesNotExistError as e:
99 99 log.exception(e)
100 100 h.flash(_('Commit does not exist'), 'error')
101 101 redirect(url('pullrequest_home', repo_name=source_repo.repo_name))
102 102
103 103 default_target_repo = source_repo
104 104
105 105 if source_repo.parent:
106 106 parent_vcs_obj = source_repo.parent.scm_instance()
107 107 if parent_vcs_obj and not parent_vcs_obj.is_empty():
108 108 # change default if we have a parent repo
109 109 default_target_repo = source_repo.parent
110 110
111 111 target_repo_data = PullRequestModel().generate_repo_data(
112 112 default_target_repo)
113 113
114 114 selected_source_ref = source_repo_data['refs']['selected_ref']
115 115
116 116 title_source_ref = selected_source_ref.split(':', 2)[1]
117 117 c.default_title = PullRequestModel().generate_pullrequest_title(
118 118 source=source_repo.repo_name,
119 119 source_ref=title_source_ref,
120 120 target=default_target_repo.repo_name
121 121 )
122 122
123 123 c.default_repo_data = {
124 124 'source_repo_name': source_repo.repo_name,
125 125 'source_refs_json': json.dumps(source_repo_data),
126 126 'target_repo_name': default_target_repo.repo_name,
127 127 'target_refs_json': json.dumps(target_repo_data),
128 128 }
129 129 c.default_source_ref = selected_source_ref
130 130
131 131 return render('/pullrequests/pullrequest.mako')
132 132
133 133 @LoginRequired()
134 134 @NotAnonymous()
135 135 @XHRRequired()
136 136 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
137 137 'repository.admin')
138 138 @jsonify
139 139 def get_repo_refs(self, repo_name, target_repo_name):
140 140 repo = Repository.get_by_repo_name(target_repo_name)
141 141 if not repo:
142 142 raise HTTPNotFound
143 143 return PullRequestModel().generate_repo_data(repo)
144 144
145 145 @LoginRequired()
146 146 @NotAnonymous()
147 147 @XHRRequired()
148 148 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
149 149 'repository.admin')
150 150 @jsonify
151 151 def get_repo_destinations(self, repo_name):
152 152 repo = Repository.get_by_repo_name(repo_name)
153 153 if not repo:
154 154 raise HTTPNotFound
155 155 filter_query = request.GET.get('query')
156 156
157 157 query = Repository.query() \
158 158 .order_by(func.length(Repository.repo_name)) \
159 159 .filter(or_(
160 160 Repository.repo_name == repo.repo_name,
161 161 Repository.fork_id == repo.repo_id))
162 162
163 163 if filter_query:
164 164 ilike_expression = u'%{}%'.format(safe_unicode(filter_query))
165 165 query = query.filter(
166 166 Repository.repo_name.ilike(ilike_expression))
167 167
168 168 add_parent = False
169 169 if repo.parent:
170 170 if filter_query in repo.parent.repo_name:
171 171 parent_vcs_obj = repo.parent.scm_instance()
172 172 if parent_vcs_obj and not parent_vcs_obj.is_empty():
173 173 add_parent = True
174 174
175 175 limit = 20 - 1 if add_parent else 20
176 176 all_repos = query.limit(limit).all()
177 177 if add_parent:
178 178 all_repos += [repo.parent]
179 179
180 180 repos = []
181 181 for obj in self.scm_model.get_repos(all_repos):
182 182 repos.append({
183 183 'id': obj['name'],
184 184 'text': obj['name'],
185 185 'type': 'repo',
186 186 'obj': obj['dbrepo']
187 187 })
188 188
189 189 data = {
190 190 'more': False,
191 191 'results': [{
192 192 'text': _('Repositories'),
193 193 'children': repos
194 194 }] if repos else []
195 195 }
196 196 return data
197 197
198 198 @LoginRequired()
199 199 @NotAnonymous()
200 200 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
201 201 'repository.admin')
202 202 @HasAcceptedRepoType('git', 'hg')
203 203 @auth.CSRFRequired()
204 204 def create(self, repo_name):
205 205 repo = Repository.get_by_repo_name(repo_name)
206 206 if not repo:
207 207 raise HTTPNotFound
208 208
209 209 controls = peppercorn.parse(request.POST.items())
210 210
211 211 try:
212 212 _form = PullRequestForm(repo.repo_id)().to_python(controls)
213 213 except formencode.Invalid as errors:
214 214 if errors.error_dict.get('revisions'):
215 215 msg = 'Revisions: %s' % errors.error_dict['revisions']
216 216 elif errors.error_dict.get('pullrequest_title'):
217 217 msg = _('Pull request requires a title with min. 3 chars')
218 218 else:
219 219 msg = _('Error creating pull request: {}').format(errors)
220 220 log.exception(msg)
221 221 h.flash(msg, 'error')
222 222
223 223 # would rather just go back to form ...
224 224 return redirect(url('pullrequest_home', repo_name=repo_name))
225 225
226 226 source_repo = _form['source_repo']
227 227 source_ref = _form['source_ref']
228 228 target_repo = _form['target_repo']
229 229 target_ref = _form['target_ref']
230 230 commit_ids = _form['revisions'][::-1]
231 231
232 232 # find the ancestor for this pr
233 233 source_db_repo = Repository.get_by_repo_name(_form['source_repo'])
234 234 target_db_repo = Repository.get_by_repo_name(_form['target_repo'])
235 235
236 236 source_scm = source_db_repo.scm_instance()
237 237 target_scm = target_db_repo.scm_instance()
238 238
239 239 source_commit = source_scm.get_commit(source_ref.split(':')[-1])
240 240 target_commit = target_scm.get_commit(target_ref.split(':')[-1])
241 241
242 242 ancestor = source_scm.get_common_ancestor(
243 243 source_commit.raw_id, target_commit.raw_id, target_scm)
244 244
245 245 target_ref_type, target_ref_name, __ = _form['target_ref'].split(':')
246 246 target_ref = ':'.join((target_ref_type, target_ref_name, ancestor))
247 247
248 248 pullrequest_title = _form['pullrequest_title']
249 249 title_source_ref = source_ref.split(':', 2)[1]
250 250 if not pullrequest_title:
251 251 pullrequest_title = PullRequestModel().generate_pullrequest_title(
252 252 source=source_repo,
253 253 source_ref=title_source_ref,
254 254 target=target_repo
255 255 )
256 256
257 257 description = _form['pullrequest_desc']
258 258
259 259 get_default_reviewers_data, validate_default_reviewers = \
260 260 PullRequestModel().get_reviewer_functions()
261 261
262 262 # recalculate reviewers logic, to make sure we can validate this
263 263 reviewer_rules = get_default_reviewers_data(
264 264 c.rhodecode_user.get_instance(), source_db_repo,
265 265 source_commit, target_db_repo, target_commit)
266 266
267 267 given_reviewers = _form['review_members']
268 268 reviewers = validate_default_reviewers(given_reviewers, reviewer_rules)
269 269
270 270 try:
271 271 pull_request = PullRequestModel().create(
272 272 c.rhodecode_user.user_id, source_repo, source_ref, target_repo,
273 273 target_ref, commit_ids, reviewers, pullrequest_title,
274 274 description, reviewer_rules
275 275 )
276 276 Session().commit()
277 277 h.flash(_('Successfully opened new pull request'),
278 278 category='success')
279 279 except Exception as e:
280 280 msg = _('Error occurred during creation of this pull request.')
281 281 log.exception(msg)
282 282 h.flash(msg, category='error')
283 283 return redirect(url('pullrequest_home', repo_name=repo_name))
284 284
285 return redirect(url('pullrequest_show', repo_name=target_repo,
286 pull_request_id=pull_request.pull_request_id))
285 raise HTTPFound(
286 h.route_path('pullrequest_show', repo_name=target_repo,
287 pull_request_id=pull_request.pull_request_id))
287 288
288 289 @LoginRequired()
289 290 @NotAnonymous()
290 291 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
291 292 'repository.admin')
292 293 @auth.CSRFRequired()
293 294 @jsonify
294 295 def update(self, repo_name, pull_request_id):
295 296 pull_request_id = safe_int(pull_request_id)
296 297 pull_request = PullRequest.get_or_404(pull_request_id)
297 298 # only owner or admin can update it
298 299 allowed_to_update = PullRequestModel().check_user_update(
299 300 pull_request, c.rhodecode_user)
300 301 if allowed_to_update:
301 302 controls = peppercorn.parse(request.POST.items())
302 303
303 304 if 'review_members' in controls:
304 305 self._update_reviewers(
305 306 pull_request_id, controls['review_members'],
306 307 pull_request.reviewer_data)
307 308 elif str2bool(request.POST.get('update_commits', 'false')):
308 309 self._update_commits(pull_request)
309 310 elif str2bool(request.POST.get('edit_pull_request', 'false')):
310 311 self._edit_pull_request(pull_request)
311 312 else:
312 313 raise HTTPBadRequest()
313 314 return True
314 315 raise HTTPForbidden()
315 316
316 317 def _edit_pull_request(self, pull_request):
317 318 try:
318 319 PullRequestModel().edit(
319 320 pull_request, request.POST.get('title'),
320 321 request.POST.get('description'), c.rhodecode_user)
321 322 except ValueError:
322 323 msg = _(u'Cannot update closed pull requests.')
323 324 h.flash(msg, category='error')
324 325 return
325 326 else:
326 327 Session().commit()
327 328
328 329 msg = _(u'Pull request title & description updated.')
329 330 h.flash(msg, category='success')
330 331 return
331 332
332 333 def _update_commits(self, pull_request):
333 334 resp = PullRequestModel().update_commits(pull_request)
334 335
335 336 if resp.executed:
336 337
337 338 if resp.target_changed and resp.source_changed:
338 339 changed = 'target and source repositories'
339 340 elif resp.target_changed and not resp.source_changed:
340 341 changed = 'target repository'
341 342 elif not resp.target_changed and resp.source_changed:
342 343 changed = 'source repository'
343 344 else:
344 345 changed = 'nothing'
345 346
346 347 msg = _(
347 348 u'Pull request updated to "{source_commit_id}" with '
348 349 u'{count_added} added, {count_removed} removed commits. '
349 350 u'Source of changes: {change_source}')
350 351 msg = msg.format(
351 352 source_commit_id=pull_request.source_ref_parts.commit_id,
352 353 count_added=len(resp.changes.added),
353 354 count_removed=len(resp.changes.removed),
354 355 change_source=changed)
355 356 h.flash(msg, category='success')
356 357
357 358 registry = get_current_registry()
358 359 rhodecode_plugins = getattr(registry, 'rhodecode_plugins', {})
359 360 channelstream_config = rhodecode_plugins.get('channelstream', {})
360 361 if channelstream_config.get('enabled'):
361 362 message = msg + (
362 363 ' - <a onclick="window.location.reload()">'
363 364 '<strong>{}</strong></a>'.format(_('Reload page')))
364 365 channel = '/repo${}$/pr/{}'.format(
365 366 pull_request.target_repo.repo_name,
366 367 pull_request.pull_request_id
367 368 )
368 369 payload = {
369 370 'type': 'message',
370 371 'user': 'system',
371 372 'exclude_users': [request.user.username],
372 373 'channel': channel,
373 374 'message': {
374 375 'message': message,
375 376 'level': 'success',
376 377 'topic': '/notifications'
377 378 }
378 379 }
379 380 channelstream_request(
380 381 channelstream_config, [payload], '/message',
381 382 raise_exc=False)
382 383 else:
383 384 msg = PullRequestModel.UPDATE_STATUS_MESSAGES[resp.reason]
384 385 warning_reasons = [
385 386 UpdateFailureReason.NO_CHANGE,
386 387 UpdateFailureReason.WRONG_REF_TYPE,
387 388 ]
388 389 category = 'warning' if resp.reason in warning_reasons else 'error'
389 390 h.flash(msg, category=category)
390 391
391 392 @auth.CSRFRequired()
392 393 @LoginRequired()
393 394 @NotAnonymous()
394 395 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
395 396 'repository.admin')
396 397 def merge(self, repo_name, pull_request_id):
397 398 """
398 399 POST /{repo_name}/pull-request/{pull_request_id}
399 400
400 401 Merge will perform a server-side merge of the specified
401 402 pull request, if the pull request is approved and mergeable.
402 403 After successful merging, the pull request is automatically
403 404 closed, with a relevant comment.
404 405 """
405 406 pull_request_id = safe_int(pull_request_id)
406 407 pull_request = PullRequest.get_or_404(pull_request_id)
407 408 user = c.rhodecode_user
408 409
409 410 check = MergeCheck.validate(pull_request, user)
410 411 merge_possible = not check.failed
411 412
412 413 for err_type, error_msg in check.errors:
413 414 h.flash(error_msg, category=err_type)
414 415
415 416 if merge_possible:
416 417 log.debug("Pre-conditions checked, trying to merge.")
417 418 extras = vcs_operation_context(
418 419 request.environ, repo_name=pull_request.target_repo.repo_name,
419 420 username=user.username, action='push',
420 421 scm=pull_request.target_repo.repo_type)
421 422 self._merge_pull_request(pull_request, user, extras)
422 423
423 return redirect(url(
424 'pullrequest_show',
425 repo_name=pull_request.target_repo.repo_name,
426 pull_request_id=pull_request.pull_request_id))
424 raise HTTPFound(
425 h.route_path('pullrequest_show',
426 repo_name=pull_request.target_repo.repo_name,
427 pull_request_id=pull_request.pull_request_id))
427 428
428 429 def _merge_pull_request(self, pull_request, user, extras):
429 430 merge_resp = PullRequestModel().merge(
430 431 pull_request, user, extras=extras)
431 432
432 433 if merge_resp.executed:
433 434 log.debug("The merge was successful, closing the pull request.")
434 435 PullRequestModel().close_pull_request(
435 436 pull_request.pull_request_id, user)
436 437 Session().commit()
437 438 msg = _('Pull request was successfully merged and closed.')
438 439 h.flash(msg, category='success')
439 440 else:
440 441 log.debug(
441 442 "The merge was not successful. Merge response: %s",
442 443 merge_resp)
443 444 msg = PullRequestModel().merge_status_message(
444 445 merge_resp.failure_reason)
445 446 h.flash(msg, category='error')
446 447
447 448 def _update_reviewers(self, pull_request_id, review_members, reviewer_rules):
448 449
449 450 get_default_reviewers_data, validate_default_reviewers = \
450 451 PullRequestModel().get_reviewer_functions()
451 452
452 453 try:
453 454 reviewers = validate_default_reviewers(review_members, reviewer_rules)
454 455 except ValueError as e:
455 456 log.error('Reviewers Validation: {}'.format(e))
456 457 h.flash(e, category='error')
457 458 return
458 459
459 460 PullRequestModel().update_reviewers(
460 461 pull_request_id, reviewers, c.rhodecode_user)
461 462 h.flash(_('Pull request reviewers updated.'), category='success')
462 463 Session().commit()
463 464
464 465 @LoginRequired()
465 466 @NotAnonymous()
466 467 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
467 468 'repository.admin')
468 469 @auth.CSRFRequired()
469 470 @jsonify
470 471 def delete(self, repo_name, pull_request_id):
471 472 pull_request_id = safe_int(pull_request_id)
472 473 pull_request = PullRequest.get_or_404(pull_request_id)
473 474
474 475 pr_closed = pull_request.is_closed()
475 476 allowed_to_delete = PullRequestModel().check_user_delete(
476 477 pull_request, c.rhodecode_user) and not pr_closed
477 478
478 479 # only owner can delete it !
479 480 if allowed_to_delete:
480 481 PullRequestModel().delete(pull_request, c.rhodecode_user)
481 482 Session().commit()
482 483 h.flash(_('Successfully deleted pull request'),
483 484 category='success')
484 485 return redirect(url('my_account_pullrequests'))
485 486
486 487 h.flash(_('Your are not allowed to delete this pull request'),
487 488 category='error')
488 489 raise HTTPForbidden()
489 490
490 491 def _get_pr_version(self, pull_request_id, version=None):
491 492 pull_request_id = safe_int(pull_request_id)
492 493 at_version = None
493 494
494 495 if version and version == 'latest':
495 496 pull_request_ver = PullRequest.get(pull_request_id)
496 497 pull_request_obj = pull_request_ver
497 498 _org_pull_request_obj = pull_request_obj
498 499 at_version = 'latest'
499 500 elif version:
500 501 pull_request_ver = PullRequestVersion.get_or_404(version)
501 502 pull_request_obj = pull_request_ver
502 503 _org_pull_request_obj = pull_request_ver.pull_request
503 504 at_version = pull_request_ver.pull_request_version_id
504 505 else:
505 506 _org_pull_request_obj = pull_request_obj = PullRequest.get_or_404(
506 507 pull_request_id)
507 508
508 509 pull_request_display_obj = PullRequest.get_pr_display_object(
509 510 pull_request_obj, _org_pull_request_obj)
510 511
511 512 return _org_pull_request_obj, pull_request_obj, \
512 513 pull_request_display_obj, at_version
513 514
514 515 def _get_diffset(
515 516 self, source_repo, source_ref_id, target_ref_id, target_commit,
516 517 source_commit, diff_limit, file_limit, display_inline_comments):
517 518 vcs_diff = PullRequestModel().get_diff(
518 519 source_repo, source_ref_id, target_ref_id)
519 520
520 521 diff_processor = diffs.DiffProcessor(
521 522 vcs_diff, format='newdiff', diff_limit=diff_limit,
522 523 file_limit=file_limit, show_full_diff=c.fulldiff)
523 524
524 525 _parsed = diff_processor.prepare()
525 526
526 527 def _node_getter(commit):
527 528 def get_node(fname):
528 529 try:
529 530 return commit.get_node(fname)
530 531 except NodeDoesNotExistError:
531 532 return None
532 533
533 534 return get_node
534 535
535 536 diffset = codeblocks.DiffSet(
536 537 repo_name=c.repo_name,
537 538 source_repo_name=c.source_repo.repo_name,
538 539 source_node_getter=_node_getter(target_commit),
539 540 target_node_getter=_node_getter(source_commit),
540 541 comments=display_inline_comments
541 542 )
542 543 diffset = diffset.render_patchset(
543 544 _parsed, target_commit.raw_id, source_commit.raw_id)
544 545
545 546 return diffset
546 547
547 548 @LoginRequired()
548 549 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
549 550 'repository.admin')
550 551 def show(self, repo_name, pull_request_id):
551 552 pull_request_id = safe_int(pull_request_id)
552 553 version = request.GET.get('version')
553 554 from_version = request.GET.get('from_version') or version
554 555 merge_checks = request.GET.get('merge_checks')
555 556 c.fulldiff = str2bool(request.GET.get('fulldiff'))
556 557
557 558 (pull_request_latest,
558 559 pull_request_at_ver,
559 560 pull_request_display_obj,
560 561 at_version) = self._get_pr_version(
561 562 pull_request_id, version=version)
562 563 pr_closed = pull_request_latest.is_closed()
563 564
564 565 if pr_closed and (version or from_version):
565 566 # not allow to browse versions
566 567 return redirect(h.url('pullrequest_show', repo_name=repo_name,
567 568 pull_request_id=pull_request_id))
568 569
569 570 versions = pull_request_display_obj.versions()
570 571
571 572 c.at_version = at_version
572 573 c.at_version_num = (at_version
573 574 if at_version and at_version != 'latest'
574 575 else None)
575 576 c.at_version_pos = ChangesetComment.get_index_from_version(
576 577 c.at_version_num, versions)
577 578
578 579 (prev_pull_request_latest,
579 580 prev_pull_request_at_ver,
580 581 prev_pull_request_display_obj,
581 582 prev_at_version) = self._get_pr_version(
582 583 pull_request_id, version=from_version)
583 584
584 585 c.from_version = prev_at_version
585 586 c.from_version_num = (prev_at_version
586 587 if prev_at_version and prev_at_version != 'latest'
587 588 else None)
588 589 c.from_version_pos = ChangesetComment.get_index_from_version(
589 590 c.from_version_num, versions)
590 591
591 592 # define if we're in COMPARE mode or VIEW at version mode
592 593 compare = at_version != prev_at_version
593 594
594 595 # pull_requests repo_name we opened it against
595 596 # ie. target_repo must match
596 597 if repo_name != pull_request_at_ver.target_repo.repo_name:
597 598 raise HTTPNotFound
598 599
599 600 c.shadow_clone_url = PullRequestModel().get_shadow_clone_url(
600 601 pull_request_at_ver)
601 602
602 603 c.pull_request = pull_request_display_obj
603 604 c.pull_request_latest = pull_request_latest
604 605
605 606 if compare or (at_version and not at_version == 'latest'):
606 607 c.allowed_to_change_status = False
607 608 c.allowed_to_update = False
608 609 c.allowed_to_merge = False
609 610 c.allowed_to_delete = False
610 611 c.allowed_to_comment = False
611 612 c.allowed_to_close = False
612 613 else:
613 614 can_change_status = PullRequestModel().check_user_change_status(
614 615 pull_request_at_ver, c.rhodecode_user)
615 616 c.allowed_to_change_status = can_change_status and not pr_closed
616 617
617 618 c.allowed_to_update = PullRequestModel().check_user_update(
618 619 pull_request_latest, c.rhodecode_user) and not pr_closed
619 620 c.allowed_to_merge = PullRequestModel().check_user_merge(
620 621 pull_request_latest, c.rhodecode_user) and not pr_closed
621 622 c.allowed_to_delete = PullRequestModel().check_user_delete(
622 623 pull_request_latest, c.rhodecode_user) and not pr_closed
623 624 c.allowed_to_comment = not pr_closed
624 625 c.allowed_to_close = c.allowed_to_merge and not pr_closed
625 626
626 627 c.forbid_adding_reviewers = False
627 628 c.forbid_author_to_review = False
628 629 c.forbid_commit_author_to_review = False
629 630
630 631 if pull_request_latest.reviewer_data and \
631 632 'rules' in pull_request_latest.reviewer_data:
632 633 rules = pull_request_latest.reviewer_data['rules'] or {}
633 634 try:
634 635 c.forbid_adding_reviewers = rules.get(
635 636 'forbid_adding_reviewers')
636 637 c.forbid_author_to_review = rules.get(
637 638 'forbid_author_to_review')
638 639 c.forbid_commit_author_to_review = rules.get(
639 640 'forbid_commit_author_to_review')
640 641 except Exception:
641 642 pass
642 643
643 644 # check merge capabilities
644 645 _merge_check = MergeCheck.validate(
645 646 pull_request_latest, user=c.rhodecode_user)
646 647 c.pr_merge_errors = _merge_check.error_details
647 648 c.pr_merge_possible = not _merge_check.failed
648 649 c.pr_merge_message = _merge_check.merge_msg
649 650
650 651 c.pull_request_review_status = _merge_check.review_status
651 652 if merge_checks:
652 653 return render('/pullrequests/pullrequest_merge_checks.mako')
653 654
654 655 comments_model = CommentsModel()
655 656
656 657 # reviewers and statuses
657 658 c.pull_request_reviewers = pull_request_at_ver.reviewers_statuses()
658 659 allowed_reviewers = [x[0].user_id for x in c.pull_request_reviewers]
659 660
660 661 # GENERAL COMMENTS with versions #
661 662 q = comments_model._all_general_comments_of_pull_request(pull_request_latest)
662 663 q = q.order_by(ChangesetComment.comment_id.asc())
663 664 general_comments = q
664 665
665 666 # pick comments we want to render at current version
666 667 c.comment_versions = comments_model.aggregate_comments(
667 668 general_comments, versions, c.at_version_num)
668 669 c.comments = c.comment_versions[c.at_version_num]['until']
669 670
670 671 # INLINE COMMENTS with versions #
671 672 q = comments_model._all_inline_comments_of_pull_request(pull_request_latest)
672 673 q = q.order_by(ChangesetComment.comment_id.asc())
673 674 inline_comments = q
674 675
675 676 c.inline_versions = comments_model.aggregate_comments(
676 677 inline_comments, versions, c.at_version_num, inline=True)
677 678
678 679 # inject latest version
679 680 latest_ver = PullRequest.get_pr_display_object(
680 681 pull_request_latest, pull_request_latest)
681 682
682 683 c.versions = versions + [latest_ver]
683 684
684 685 # if we use version, then do not show later comments
685 686 # than current version
686 687 display_inline_comments = collections.defaultdict(
687 688 lambda: collections.defaultdict(list))
688 689 for co in inline_comments:
689 690 if c.at_version_num:
690 691 # pick comments that are at least UPTO given version, so we
691 692 # don't render comments for higher version
692 693 should_render = co.pull_request_version_id and \
693 694 co.pull_request_version_id <= c.at_version_num
694 695 else:
695 696 # showing all, for 'latest'
696 697 should_render = True
697 698
698 699 if should_render:
699 700 display_inline_comments[co.f_path][co.line_no].append(co)
700 701
701 702 # load diff data into template context, if we use compare mode then
702 703 # diff is calculated based on changes between versions of PR
703 704
704 705 source_repo = pull_request_at_ver.source_repo
705 706 source_ref_id = pull_request_at_ver.source_ref_parts.commit_id
706 707
707 708 target_repo = pull_request_at_ver.target_repo
708 709 target_ref_id = pull_request_at_ver.target_ref_parts.commit_id
709 710
710 711 if compare:
711 712 # in compare switch the diff base to latest commit from prev version
712 713 target_ref_id = prev_pull_request_display_obj.revisions[0]
713 714
714 715 # despite opening commits for bookmarks/branches/tags, we always
715 716 # convert this to rev to prevent changes after bookmark or branch change
716 717 c.source_ref_type = 'rev'
717 718 c.source_ref = source_ref_id
718 719
719 720 c.target_ref_type = 'rev'
720 721 c.target_ref = target_ref_id
721 722
722 723 c.source_repo = source_repo
723 724 c.target_repo = target_repo
724 725
725 726 # diff_limit is the old behavior, will cut off the whole diff
726 727 # if the limit is applied otherwise will just hide the
727 728 # big files from the front-end
728 729 diff_limit = self.cut_off_limit_diff
729 730 file_limit = self.cut_off_limit_file
730 731
731 732 c.commit_ranges = []
732 733 source_commit = EmptyCommit()
733 734 target_commit = EmptyCommit()
734 735 c.missing_requirements = False
735 736
736 737 source_scm = source_repo.scm_instance()
737 738 target_scm = target_repo.scm_instance()
738 739
739 740 # try first shadow repo, fallback to regular repo
740 741 try:
741 742 commits_source_repo = pull_request_latest.get_shadow_repo()
742 743 except Exception:
743 744 log.debug('Failed to get shadow repo', exc_info=True)
744 745 commits_source_repo = source_scm
745 746
746 747 c.commits_source_repo = commits_source_repo
747 748 commit_cache = {}
748 749 try:
749 750 pre_load = ["author", "branch", "date", "message"]
750 751 show_revs = pull_request_at_ver.revisions
751 752 for rev in show_revs:
752 753 comm = commits_source_repo.get_commit(
753 754 commit_id=rev, pre_load=pre_load)
754 755 c.commit_ranges.append(comm)
755 756 commit_cache[comm.raw_id] = comm
756 757
757 758 # Order here matters, we first need to get target, and then
758 759 # the source
759 760 target_commit = commits_source_repo.get_commit(
760 761 commit_id=safe_str(target_ref_id))
761 762
762 763 source_commit = commits_source_repo.get_commit(
763 764 commit_id=safe_str(source_ref_id))
764 765
765 766 except CommitDoesNotExistError:
766 767 log.warning(
767 768 'Failed to get commit from `{}` repo'.format(
768 769 commits_source_repo), exc_info=True)
769 770 except RepositoryRequirementError:
770 771 log.warning(
771 772 'Failed to get all required data from repo', exc_info=True)
772 773 c.missing_requirements = True
773 774
774 775 c.ancestor = None # set it to None, to hide it from PR view
775 776
776 777 try:
777 778 ancestor_id = source_scm.get_common_ancestor(
778 779 source_commit.raw_id, target_commit.raw_id, target_scm)
779 780 c.ancestor_commit = source_scm.get_commit(ancestor_id)
780 781 except Exception:
781 782 c.ancestor_commit = None
782 783
783 784 c.statuses = source_repo.statuses(
784 785 [x.raw_id for x in c.commit_ranges])
785 786
786 787 # auto collapse if we have more than limit
787 788 collapse_limit = diffs.DiffProcessor._collapse_commits_over
788 789 c.collapse_all_commits = len(c.commit_ranges) > collapse_limit
789 790 c.compare_mode = compare
790 791
791 792 c.missing_commits = False
792 793 if (c.missing_requirements or isinstance(source_commit, EmptyCommit)
793 794 or source_commit == target_commit):
794 795
795 796 c.missing_commits = True
796 797 else:
797 798
798 799 c.diffset = self._get_diffset(
799 800 commits_source_repo, source_ref_id, target_ref_id,
800 801 target_commit, source_commit,
801 802 diff_limit, file_limit, display_inline_comments)
802 803
803 804 c.limited_diff = c.diffset.limited_diff
804 805
805 806 # calculate removed files that are bound to comments
806 807 comment_deleted_files = [
807 808 fname for fname in display_inline_comments
808 809 if fname not in c.diffset.file_stats]
809 810
810 811 c.deleted_files_comments = collections.defaultdict(dict)
811 812 for fname, per_line_comments in display_inline_comments.items():
812 813 if fname in comment_deleted_files:
813 814 c.deleted_files_comments[fname]['stats'] = 0
814 815 c.deleted_files_comments[fname]['comments'] = list()
815 816 for lno, comments in per_line_comments.items():
816 817 c.deleted_files_comments[fname]['comments'].extend(
817 818 comments)
818 819
819 820 # this is a hack to properly display links, when creating PR, the
820 821 # compare view and others uses different notation, and
821 822 # compare_commits.mako renders links based on the target_repo.
822 823 # We need to swap that here to generate it properly on the html side
823 824 c.target_repo = c.source_repo
824 825
825 826 c.commit_statuses = ChangesetStatus.STATUSES
826 827
827 828 c.show_version_changes = not pr_closed
828 829 if c.show_version_changes:
829 830 cur_obj = pull_request_at_ver
830 831 prev_obj = prev_pull_request_at_ver
831 832
832 833 old_commit_ids = prev_obj.revisions
833 834 new_commit_ids = cur_obj.revisions
834 835 commit_changes = PullRequestModel()._calculate_commit_id_changes(
835 836 old_commit_ids, new_commit_ids)
836 837 c.commit_changes_summary = commit_changes
837 838
838 839 # calculate the diff for commits between versions
839 840 c.commit_changes = []
840 841 mark = lambda cs, fw: list(
841 842 h.itertools.izip_longest([], cs, fillvalue=fw))
842 843 for c_type, raw_id in mark(commit_changes.added, 'a') \
843 844 + mark(commit_changes.removed, 'r') \
844 845 + mark(commit_changes.common, 'c'):
845 846
846 847 if raw_id in commit_cache:
847 848 commit = commit_cache[raw_id]
848 849 else:
849 850 try:
850 851 commit = commits_source_repo.get_commit(raw_id)
851 852 except CommitDoesNotExistError:
852 853 # in case we fail extracting still use "dummy" commit
853 854 # for display in commit diff
854 855 commit = h.AttributeDict(
855 856 {'raw_id': raw_id,
856 857 'message': 'EMPTY or MISSING COMMIT'})
857 858 c.commit_changes.append([c_type, commit])
858 859
859 860 # current user review statuses for each version
860 861 c.review_versions = {}
861 862 if c.rhodecode_user.user_id in allowed_reviewers:
862 863 for co in general_comments:
863 864 if co.author.user_id == c.rhodecode_user.user_id:
864 865 # each comment has a status change
865 866 status = co.status_change
866 867 if status:
867 868 _ver_pr = status[0].comment.pull_request_version_id
868 869 c.review_versions[_ver_pr] = status[0]
869 870
870 871 return render('/pullrequests/pullrequest_show.mako')
871 872
872 873 @LoginRequired()
873 874 @NotAnonymous()
874 875 @HasRepoPermissionAnyDecorator(
875 876 'repository.read', 'repository.write', 'repository.admin')
876 877 @auth.CSRFRequired()
877 878 @jsonify
878 879 def comment(self, repo_name, pull_request_id):
879 880 pull_request_id = safe_int(pull_request_id)
880 881 pull_request = PullRequest.get_or_404(pull_request_id)
881 882 if pull_request.is_closed():
882 883 log.debug('comment: forbidden because pull request is closed')
883 884 raise HTTPForbidden()
884 885
885 886 status = request.POST.get('changeset_status', None)
886 887 text = request.POST.get('text')
887 888 comment_type = request.POST.get('comment_type')
888 889 resolves_comment_id = request.POST.get('resolves_comment_id', None)
889 890 close_pull_request = request.POST.get('close_pull_request')
890 891
891 892 # the logic here should work like following, if we submit close
892 893 # pr comment, use `close_pull_request_with_comment` function
893 894 # else handle regular comment logic
894 895 user = c.rhodecode_user
895 896 repo = c.rhodecode_db_repo
896 897
897 898 if close_pull_request:
898 899 # only owner or admin or person with write permissions
899 900 allowed_to_close = PullRequestModel().check_user_update(
900 901 pull_request, c.rhodecode_user)
901 902 if not allowed_to_close:
902 903 log.debug('comment: forbidden because not allowed to close '
903 904 'pull request %s', pull_request_id)
904 905 raise HTTPForbidden()
905 906 comment, status = PullRequestModel().close_pull_request_with_comment(
906 907 pull_request, user, repo, message=text)
907 908 Session().flush()
908 909 events.trigger(
909 910 events.PullRequestCommentEvent(pull_request, comment))
910 911
911 912 else:
912 913 # regular comment case, could be inline, or one with status.
913 914 # for that one we check also permissions
914 915
915 916 allowed_to_change_status = PullRequestModel().check_user_change_status(
916 917 pull_request, c.rhodecode_user)
917 918
918 919 if status and allowed_to_change_status:
919 920 message = (_('Status change %(transition_icon)s %(status)s')
920 921 % {'transition_icon': '>',
921 922 'status': ChangesetStatus.get_status_lbl(status)})
922 923 text = text or message
923 924
924 925 comment = CommentsModel().create(
925 926 text=text,
926 927 repo=c.rhodecode_db_repo.repo_id,
927 928 user=c.rhodecode_user.user_id,
928 929 pull_request=pull_request_id,
929 930 f_path=request.POST.get('f_path'),
930 931 line_no=request.POST.get('line'),
931 932 status_change=(ChangesetStatus.get_status_lbl(status)
932 933 if status and allowed_to_change_status else None),
933 934 status_change_type=(status
934 935 if status and allowed_to_change_status else None),
935 936 comment_type=comment_type,
936 937 resolves_comment_id=resolves_comment_id
937 938 )
938 939
939 940 if allowed_to_change_status:
940 941 # calculate old status before we change it
941 942 old_calculated_status = pull_request.calculated_review_status()
942 943
943 944 # get status if set !
944 945 if status:
945 946 ChangesetStatusModel().set_status(
946 947 c.rhodecode_db_repo.repo_id,
947 948 status,
948 949 c.rhodecode_user.user_id,
949 950 comment,
950 951 pull_request=pull_request_id
951 952 )
952 953
953 954 Session().flush()
954 955 events.trigger(
955 956 events.PullRequestCommentEvent(pull_request, comment))
956 957
957 958 # we now calculate the status of pull request, and based on that
958 959 # calculation we set the commits status
959 960 calculated_status = pull_request.calculated_review_status()
960 961 if old_calculated_status != calculated_status:
961 962 PullRequestModel()._trigger_pull_request_hook(
962 963 pull_request, c.rhodecode_user, 'review_status_change')
963 964
964 965 Session().commit()
965 966
966 967 if not request.is_xhr:
967 return redirect(h.url('pullrequest_show', repo_name=repo_name,
968 pull_request_id=pull_request_id))
968 raise HTTPFound(
969 h.route_path('pullrequest_show',
970 repo_name=repo_name,
971 pull_request_id=pull_request_id))
969 972
970 973 data = {
971 974 'target_id': h.safeid(h.safe_unicode(request.POST.get('f_path'))),
972 975 }
973 976 if comment:
974 977 c.co = comment
975 978 rendered_comment = render('changeset/changeset_comment_block.mako')
976 979 data.update(comment.get_dict())
977 980 data.update({'rendered_text': rendered_comment})
978 981
979 982 return data
980 983
981 984 @LoginRequired()
982 985 @NotAnonymous()
983 986 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
984 987 'repository.admin')
985 988 @auth.CSRFRequired()
986 989 @jsonify
987 990 def delete_comment(self, repo_name, comment_id):
988 991 return self._delete_comment(comment_id)
989 992
990 993 def _delete_comment(self, comment_id):
991 994 comment_id = safe_int(comment_id)
992 995 co = ChangesetComment.get_or_404(comment_id)
993 996 if co.pull_request.is_closed():
994 997 # don't allow deleting comments on closed pull request
995 998 raise HTTPForbidden()
996 999
997 1000 is_owner = co.author.user_id == c.rhodecode_user.user_id
998 1001 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(c.repo_name)
999 1002 if h.HasPermissionAny('hg.admin')() or is_repo_admin or is_owner:
1000 1003 old_calculated_status = co.pull_request.calculated_review_status()
1001 1004 CommentsModel().delete(comment=co, user=c.rhodecode_user)
1002 1005 Session().commit()
1003 1006 calculated_status = co.pull_request.calculated_review_status()
1004 1007 if old_calculated_status != calculated_status:
1005 1008 PullRequestModel()._trigger_pull_request_hook(
1006 1009 co.pull_request, c.rhodecode_user, 'review_status_change')
1007 1010 return True
1008 1011 else:
1009 1012 raise HTTPForbidden()
@@ -1,315 +1,316 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import logging
22 22
23 23 from pylons import url
24 24 from pylons.i18n.translation import _
25 25 from webhelpers.html.builder import literal
26 26 from webhelpers.html.tags import link_to
27 27
28 28 from rhodecode.lib.utils2 import AttributeDict
29 29 from rhodecode.lib.vcs.backends.base import BaseCommit
30 30 from rhodecode.lib.vcs.exceptions import CommitDoesNotExistError
31 31
32 32
33 33 log = logging.getLogger(__name__)
34 34
35 35
36 36 def action_parser(user_log, feed=False, parse_cs=False):
37 37 """
38 38 This helper will action_map the specified string action into translated
39 39 fancy names with icons and links
40 40
41 41 :param user_log: user log instance
42 42 :param feed: use output for feeds (no html and fancy icons)
43 43 :param parse_cs: parse Changesets into VCS instances
44 44 """
45 45 ap = ActionParser(user_log, feed=False, parse_commits=False)
46 46 return ap.callbacks()
47 47
48 48
49 49 class ActionParser(object):
50 50
51 51 commits_limit = 3 # display this amount always
52 52 commits_top_limit = 50 # show up to this amount of commits hidden
53 53
54 54 def __init__(self, user_log, feed=False, parse_commits=False):
55 55 self.user_log = user_log
56 56 self.feed = feed
57 57 self.parse_commits = parse_commits
58 58
59 59 self.action = user_log.action
60 60 self.action_params = ' '
61 61 x = self.action.split(':', 1)
62 62 if len(x) > 1:
63 63 self.action, self.action_params = x
64 64
65 65 def callbacks(self):
66 66 action_str = self.action_map.get(self.action, self.action)
67 67 if self.feed:
68 68 action = action_str[0].replace('[', '').replace(']', '')
69 69 else:
70 70 action = action_str[0]\
71 71 .replace('[', '<span class="journal_highlight">')\
72 72 .replace(']', '</span>')
73 73
74 74 action_params_func = _no_params_func
75 75 if callable(action_str[1]):
76 76 action_params_func = action_str[1]
77 77
78 78 # returned callbacks we need to call to get
79 79 return [
80 80 lambda: literal(action), action_params_func,
81 81 self.action_parser_icon]
82 82
83 83 @property
84 84 def action_map(self):
85 85
86 86 # action : translated str, callback(extractor), icon
87 87 action_map = {
88 88 'user_deleted_repo': (
89 89 _('[deleted] repository'),
90 90 None, 'icon-trash'),
91 91 'user_created_repo': (
92 92 _('[created] repository'),
93 93 None, 'icon-plus icon-plus-colored'),
94 94 'user_created_fork': (
95 95 _('[created] repository as fork'),
96 96 None, 'icon-code-fork'),
97 97 'user_forked_repo': (
98 98 _('[forked] repository'),
99 99 self.get_fork_name, 'icon-code-fork'),
100 100 'user_updated_repo': (
101 101 _('[updated] repository'),
102 102 None, 'icon-pencil icon-pencil-colored'),
103 103 'user_downloaded_archive': (
104 104 _('[downloaded] archive from repository'),
105 105 self.get_archive_name, 'icon-download-alt'),
106 106 'admin_deleted_repo': (
107 107 _('[delete] repository'),
108 108 None, 'icon-trash'),
109 109 'admin_created_repo': (
110 110 _('[created] repository'),
111 111 None, 'icon-plus icon-plus-colored'),
112 112 'admin_forked_repo': (
113 113 _('[forked] repository'),
114 114 None, 'icon-code-fork icon-fork-colored'),
115 115 'admin_updated_repo': (
116 116 _('[updated] repository'),
117 117 None, 'icon-pencil icon-pencil-colored'),
118 118 'admin_created_user': (
119 119 _('[created] user'),
120 120 self.get_user_name, 'icon-user icon-user-colored'),
121 121 'admin_updated_user': (
122 122 _('[updated] user'),
123 123 self.get_user_name, 'icon-user icon-user-colored'),
124 124 'admin_created_users_group': (
125 125 _('[created] user group'),
126 126 self.get_users_group, 'icon-pencil icon-pencil-colored'),
127 127 'admin_updated_users_group': (
128 128 _('[updated] user group'),
129 129 self.get_users_group, 'icon-pencil icon-pencil-colored'),
130 130 'user_commented_revision': (
131 131 _('[commented] on commit in repository'),
132 132 self.get_cs_links, 'icon-comment icon-comment-colored'),
133 133 'user_commented_pull_request': (
134 134 _('[commented] on pull request for'),
135 135 self.get_pull_request, 'icon-comment icon-comment-colored'),
136 136 'user_closed_pull_request': (
137 137 _('[closed] pull request for'),
138 138 self.get_pull_request, 'icon-check'),
139 139 'user_merged_pull_request': (
140 140 _('[merged] pull request for'),
141 141 self.get_pull_request, 'icon-check'),
142 142 'push': (
143 143 _('[pushed] into'),
144 144 self.get_cs_links, 'icon-arrow-up'),
145 145 'push_local': (
146 146 _('[committed via RhodeCode] into repository'),
147 147 self.get_cs_links, 'icon-pencil icon-pencil-colored'),
148 148 'push_remote': (
149 149 _('[pulled from remote] into repository'),
150 150 self.get_cs_links, 'icon-arrow-up'),
151 151 'pull': (
152 152 _('[pulled] from'),
153 153 None, 'icon-arrow-down'),
154 154 'started_following_repo': (
155 155 _('[started following] repository'),
156 156 None, 'icon-heart icon-heart-colored'),
157 157 'stopped_following_repo': (
158 158 _('[stopped following] repository'),
159 159 None, 'icon-heart-empty icon-heart-colored'),
160 160 }
161 161 return action_map
162 162
163 163 def get_fork_name(self):
164 164 from rhodecode.lib import helpers as h
165 165 repo_name = self.action_params
166 166 _url = h.route_path('repo_summary', repo_name=repo_name)
167 167 return _('fork name %s') % link_to(self.action_params, _url)
168 168
169 169 def get_user_name(self):
170 170 user_name = self.action_params
171 171 return user_name
172 172
173 173 def get_users_group(self):
174 174 group_name = self.action_params
175 175 return group_name
176 176
177 177 def get_pull_request(self):
178 from rhodecode.lib import helpers as h
178 179 pull_request_id = self.action_params
179 180 if self.is_deleted():
180 181 repo_name = self.user_log.repository_name
181 182 else:
182 183 repo_name = self.user_log.repository.repo_name
183 184 return link_to(
184 185 _('Pull request #%s') % pull_request_id,
185 url('pullrequest_show', repo_name=repo_name,
186 pull_request_id=pull_request_id))
186 h.route_path('pullrequest_show', repo_name=repo_name,
187 pull_request_id=pull_request_id))
187 188
188 189 def get_archive_name(self):
189 190 archive_name = self.action_params
190 191 return archive_name
191 192
192 193 def action_parser_icon(self):
193 194 tmpl = """<i class="%s" alt="%s"></i>"""
194 195 ico = self.action_map.get(self.action, ['', '', ''])[2]
195 196 return literal(tmpl % (ico, self.action))
196 197
197 198 def get_cs_links(self):
198 199 if self.is_deleted():
199 200 return self.action_params
200 201
201 202 repo_name = self.user_log.repository.repo_name
202 203 commit_ids = self.action_params.split(',')
203 204 commits = self.get_commits(commit_ids)
204 205
205 206 link_generator = (
206 207 self.lnk(commit, repo_name)
207 208 for commit in commits[:self.commits_limit])
208 209 commit_links = [" " + ', '.join(link_generator)]
209 210 _op1, _name1 = _get_op(commit_ids[0])
210 211 _op2, _name2 = _get_op(commit_ids[-1])
211 212
212 213 commit_id_range = '%s...%s' % (_name1, _name2)
213 214
214 215 compare_view = (
215 216 ' <div class="compare_view tooltip" title="%s">'
216 217 '<a href="%s">%s</a> </div>' % (
217 218 _('Show all combined commits %s->%s') % (
218 219 commit_ids[0][:12], commit_ids[-1][:12]
219 220 ),
220 221 url('changeset_home', repo_name=repo_name,
221 222 revision=commit_id_range), _('compare view')
222 223 )
223 224 )
224 225
225 226 if len(commit_ids) > self.commits_limit:
226 227 more_count = len(commit_ids) - self.commits_limit
227 228 commit_links.append(
228 229 _(' and %(num)s more commits') % {'num': more_count}
229 230 )
230 231
231 232 if len(commits) > 1:
232 233 commit_links.append(compare_view)
233 234 return ''.join(commit_links)
234 235
235 236 def get_commits(self, commit_ids):
236 237 commits = []
237 238 if not filter(lambda v: v != '', commit_ids):
238 239 return commits
239 240
240 241 repo = None
241 242 if self.parse_commits:
242 243 repo = self.user_log.repository.scm_instance()
243 244
244 245 for commit_id in commit_ids[:self.commits_top_limit]:
245 246 _op, _name = _get_op(commit_id)
246 247
247 248 # we want parsed commits, or new log store format is bad
248 249 if self.parse_commits:
249 250 try:
250 251 commit = repo.get_commit(commit_id=commit_id)
251 252 commits.append(commit)
252 253 except CommitDoesNotExistError:
253 254 log.error(
254 255 'cannot find commit id %s in this repository',
255 256 commit_id)
256 257 commits.append(commit_id)
257 258 continue
258 259 else:
259 260 fake_commit = AttributeDict({
260 261 'short_id': commit_id[:12],
261 262 'raw_id': commit_id,
262 263 'message': '',
263 264 'op': _op,
264 265 'ref_name': _name
265 266 })
266 267 commits.append(fake_commit)
267 268
268 269 return commits
269 270
270 271 def lnk(self, commit_or_id, repo_name):
271 272 from rhodecode.lib.helpers import tooltip
272 273
273 274 if isinstance(commit_or_id, (BaseCommit, AttributeDict)):
274 275 lazy_cs = True
275 276 if (getattr(commit_or_id, 'op', None) and
276 277 getattr(commit_or_id, 'ref_name', None)):
277 278 lazy_cs = False
278 279 lbl = '?'
279 280 if commit_or_id.op == 'delete_branch':
280 281 lbl = '%s' % _('Deleted branch: %s') % commit_or_id.ref_name
281 282 title = ''
282 283 elif commit_or_id.op == 'tag':
283 284 lbl = '%s' % _('Created tag: %s') % commit_or_id.ref_name
284 285 title = ''
285 286 _url = '#'
286 287
287 288 else:
288 289 lbl = '%s' % (commit_or_id.short_id[:8])
289 290 _url = url('changeset_home', repo_name=repo_name,
290 291 revision=commit_or_id.raw_id)
291 292 title = tooltip(commit_or_id.message)
292 293 else:
293 294 # commit cannot be found/striped/removed etc.
294 295 lbl = ('%s' % commit_or_id)[:12]
295 296 _url = '#'
296 297 title = _('Commit not found')
297 298 if self.parse_commits:
298 299 return link_to(lbl, _url, title=title, class_='tooltip')
299 300 return link_to(lbl, _url, raw_id=commit_or_id.raw_id, repo_name=repo_name,
300 301 class_='lazy-cs' if lazy_cs else '')
301 302
302 303 def is_deleted(self):
303 304 return self.user_log.repository is None
304 305
305 306
306 307 def _no_params_func():
307 308 return ""
308 309
309 310
310 311 def _get_op(commit_id):
311 312 _op = None
312 313 _name = commit_id
313 314 if len(commit_id.split('=>')) == 2:
314 315 _op, _name = commit_id.split('=>')
315 316 return _op, _name
@@ -1,596 +1,601 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 The base Controller API
23 23 Provides the BaseController class for subclassing. And usage in different
24 24 controllers
25 25 """
26 26
27 27 import logging
28 28 import socket
29 29
30 30 import ipaddress
31 31 import pyramid.threadlocal
32 32
33 33 from paste.auth.basic import AuthBasicAuthenticator
34 34 from paste.httpexceptions import HTTPUnauthorized, HTTPForbidden, get_exception
35 35 from paste.httpheaders import WWW_AUTHENTICATE, AUTHORIZATION
36 36 from pylons import config, tmpl_context as c, request, session, url
37 37 from pylons.controllers import WSGIController
38 38 from pylons.controllers.util import redirect
39 39 from pylons.i18n import translation
40 40 # marcink: don't remove this import
41 41 from pylons.templating import render_mako as render # noqa
42 42 from pylons.i18n.translation import _
43 43 from webob.exc import HTTPFound
44 44
45 45
46 46 import rhodecode
47 47 from rhodecode.authentication.base import VCS_TYPE
48 48 from rhodecode.lib import auth, utils2
49 49 from rhodecode.lib import helpers as h
50 50 from rhodecode.lib.auth import AuthUser, CookieStoreWrapper
51 51 from rhodecode.lib.exceptions import UserCreationError
52 52 from rhodecode.lib.utils import (
53 53 get_repo_slug, set_rhodecode_config, password_changed,
54 54 get_enabled_hook_classes)
55 55 from rhodecode.lib.utils2 import (
56 56 str2bool, safe_unicode, AttributeDict, safe_int, md5, aslist)
57 57 from rhodecode.lib.vcs.exceptions import RepositoryRequirementError
58 58 from rhodecode.model import meta
59 59 from rhodecode.model.db import Repository, User, ChangesetComment
60 60 from rhodecode.model.notification import NotificationModel
61 61 from rhodecode.model.scm import ScmModel
62 62 from rhodecode.model.settings import VcsSettingsModel, SettingsModel
63 63
64 64
65 65 log = logging.getLogger(__name__)
66 66
67 67
68 68 def _filter_proxy(ip):
69 69 """
70 70 Passed in IP addresses in HEADERS can be in a special format of multiple
71 71 ips. Those comma separated IPs are passed from various proxies in the
72 72 chain of request processing. The left-most being the original client.
73 73 We only care about the first IP which came from the org. client.
74 74
75 75 :param ip: ip string from headers
76 76 """
77 77 if ',' in ip:
78 78 _ips = ip.split(',')
79 79 _first_ip = _ips[0].strip()
80 80 log.debug('Got multiple IPs %s, using %s', ','.join(_ips), _first_ip)
81 81 return _first_ip
82 82 return ip
83 83
84 84
85 85 def _filter_port(ip):
86 86 """
87 87 Removes a port from ip, there are 4 main cases to handle here.
88 88 - ipv4 eg. 127.0.0.1
89 89 - ipv6 eg. ::1
90 90 - ipv4+port eg. 127.0.0.1:8080
91 91 - ipv6+port eg. [::1]:8080
92 92
93 93 :param ip:
94 94 """
95 95 def is_ipv6(ip_addr):
96 96 if hasattr(socket, 'inet_pton'):
97 97 try:
98 98 socket.inet_pton(socket.AF_INET6, ip_addr)
99 99 except socket.error:
100 100 return False
101 101 else:
102 102 # fallback to ipaddress
103 103 try:
104 104 ipaddress.IPv6Address(ip_addr)
105 105 except Exception:
106 106 return False
107 107 return True
108 108
109 109 if ':' not in ip: # must be ipv4 pure ip
110 110 return ip
111 111
112 112 if '[' in ip and ']' in ip: # ipv6 with port
113 113 return ip.split(']')[0][1:].lower()
114 114
115 115 # must be ipv6 or ipv4 with port
116 116 if is_ipv6(ip):
117 117 return ip
118 118 else:
119 119 ip, _port = ip.split(':')[:2] # means ipv4+port
120 120 return ip
121 121
122 122
123 123 def get_ip_addr(environ):
124 124 proxy_key = 'HTTP_X_REAL_IP'
125 125 proxy_key2 = 'HTTP_X_FORWARDED_FOR'
126 126 def_key = 'REMOTE_ADDR'
127 127 _filters = lambda x: _filter_port(_filter_proxy(x))
128 128
129 129 ip = environ.get(proxy_key)
130 130 if ip:
131 131 return _filters(ip)
132 132
133 133 ip = environ.get(proxy_key2)
134 134 if ip:
135 135 return _filters(ip)
136 136
137 137 ip = environ.get(def_key, '0.0.0.0')
138 138 return _filters(ip)
139 139
140 140
141 141 def get_server_ip_addr(environ, log_errors=True):
142 142 hostname = environ.get('SERVER_NAME')
143 143 try:
144 144 return socket.gethostbyname(hostname)
145 145 except Exception as e:
146 146 if log_errors:
147 147 # in some cases this lookup is not possible, and we don't want to
148 148 # make it an exception in logs
149 149 log.exception('Could not retrieve server ip address: %s', e)
150 150 return hostname
151 151
152 152
153 153 def get_server_port(environ):
154 154 return environ.get('SERVER_PORT')
155 155
156 156
157 157 def get_access_path(environ):
158 158 path = environ.get('PATH_INFO')
159 159 org_req = environ.get('pylons.original_request')
160 160 if org_req:
161 161 path = org_req.environ.get('PATH_INFO')
162 162 return path
163 163
164 164
165 165 def get_user_agent(environ):
166 166 return environ.get('HTTP_USER_AGENT')
167 167
168 168
169 169 def vcs_operation_context(
170 170 environ, repo_name, username, action, scm, check_locking=True,
171 171 is_shadow_repo=False):
172 172 """
173 173 Generate the context for a vcs operation, e.g. push or pull.
174 174
175 175 This context is passed over the layers so that hooks triggered by the
176 176 vcs operation know details like the user, the user's IP address etc.
177 177
178 178 :param check_locking: Allows to switch of the computation of the locking
179 179 data. This serves mainly the need of the simplevcs middleware to be
180 180 able to disable this for certain operations.
181 181
182 182 """
183 183 # Tri-state value: False: unlock, None: nothing, True: lock
184 184 make_lock = None
185 185 locked_by = [None, None, None]
186 186 is_anonymous = username == User.DEFAULT_USER
187 187 if not is_anonymous and check_locking:
188 188 log.debug('Checking locking on repository "%s"', repo_name)
189 189 user = User.get_by_username(username)
190 190 repo = Repository.get_by_repo_name(repo_name)
191 191 make_lock, __, locked_by = repo.get_locking_state(
192 192 action, user.user_id)
193 193
194 194 settings_model = VcsSettingsModel(repo=repo_name)
195 195 ui_settings = settings_model.get_ui_settings()
196 196
197 197 extras = {
198 198 'ip': get_ip_addr(environ),
199 199 'username': username,
200 200 'action': action,
201 201 'repository': repo_name,
202 202 'scm': scm,
203 203 'config': rhodecode.CONFIG['__file__'],
204 204 'make_lock': make_lock,
205 205 'locked_by': locked_by,
206 206 'server_url': utils2.get_server_url(environ),
207 207 'user_agent': get_user_agent(environ),
208 208 'hooks': get_enabled_hook_classes(ui_settings),
209 209 'is_shadow_repo': is_shadow_repo,
210 210 }
211 211 return extras
212 212
213 213
214 214 class BasicAuth(AuthBasicAuthenticator):
215 215
216 216 def __init__(self, realm, authfunc, registry, auth_http_code=None,
217 217 initial_call_detection=False, acl_repo_name=None):
218 218 self.realm = realm
219 219 self.initial_call = initial_call_detection
220 220 self.authfunc = authfunc
221 221 self.registry = registry
222 222 self.acl_repo_name = acl_repo_name
223 223 self._rc_auth_http_code = auth_http_code
224 224
225 225 def _get_response_from_code(self, http_code):
226 226 try:
227 227 return get_exception(safe_int(http_code))
228 228 except Exception:
229 229 log.exception('Failed to fetch response for code %s' % http_code)
230 230 return HTTPForbidden
231 231
232 232 def build_authentication(self):
233 233 head = WWW_AUTHENTICATE.tuples('Basic realm="%s"' % self.realm)
234 234 if self._rc_auth_http_code and not self.initial_call:
235 235 # return alternative HTTP code if alternative http return code
236 236 # is specified in RhodeCode config, but ONLY if it's not the
237 237 # FIRST call
238 238 custom_response_klass = self._get_response_from_code(
239 239 self._rc_auth_http_code)
240 240 return custom_response_klass(headers=head)
241 241 return HTTPUnauthorized(headers=head)
242 242
243 243 def authenticate(self, environ):
244 244 authorization = AUTHORIZATION(environ)
245 245 if not authorization:
246 246 return self.build_authentication()
247 247 (authmeth, auth) = authorization.split(' ', 1)
248 248 if 'basic' != authmeth.lower():
249 249 return self.build_authentication()
250 250 auth = auth.strip().decode('base64')
251 251 _parts = auth.split(':', 1)
252 252 if len(_parts) == 2:
253 253 username, password = _parts
254 254 if self.authfunc(
255 255 username, password, environ, VCS_TYPE,
256 256 registry=self.registry, acl_repo_name=self.acl_repo_name):
257 257 return username
258 258 if username and password:
259 259 # we mark that we actually executed authentication once, at
260 260 # that point we can use the alternative auth code
261 261 self.initial_call = False
262 262
263 263 return self.build_authentication()
264 264
265 265 __call__ = authenticate
266 266
267 267
268 268 def attach_context_attributes(context, request, user_id, attach_to_request=False):
269 269 """
270 270 Attach variables into template context called `c`, please note that
271 271 request could be pylons or pyramid request in here.
272 272 """
273 273 rc_config = SettingsModel().get_all_settings(cache=True)
274 274
275 275 context.rhodecode_version = rhodecode.__version__
276 276 context.rhodecode_edition = config.get('rhodecode.edition')
277 277 # unique secret + version does not leak the version but keep consistency
278 278 context.rhodecode_version_hash = md5(
279 279 config.get('beaker.session.secret', '') +
280 280 rhodecode.__version__)[:8]
281 281
282 282 # Default language set for the incoming request
283 283 context.language = translation.get_lang()[0]
284 284
285 285 # Visual options
286 286 context.visual = AttributeDict({})
287 287
288 288 # DB stored Visual Items
289 289 context.visual.show_public_icon = str2bool(
290 290 rc_config.get('rhodecode_show_public_icon'))
291 291 context.visual.show_private_icon = str2bool(
292 292 rc_config.get('rhodecode_show_private_icon'))
293 293 context.visual.stylify_metatags = str2bool(
294 294 rc_config.get('rhodecode_stylify_metatags'))
295 295 context.visual.dashboard_items = safe_int(
296 296 rc_config.get('rhodecode_dashboard_items', 100))
297 297 context.visual.admin_grid_items = safe_int(
298 298 rc_config.get('rhodecode_admin_grid_items', 100))
299 299 context.visual.repository_fields = str2bool(
300 300 rc_config.get('rhodecode_repository_fields'))
301 301 context.visual.show_version = str2bool(
302 302 rc_config.get('rhodecode_show_version'))
303 303 context.visual.use_gravatar = str2bool(
304 304 rc_config.get('rhodecode_use_gravatar'))
305 305 context.visual.gravatar_url = rc_config.get('rhodecode_gravatar_url')
306 306 context.visual.default_renderer = rc_config.get(
307 307 'rhodecode_markup_renderer', 'rst')
308 308 context.visual.comment_types = ChangesetComment.COMMENT_TYPES
309 309 context.visual.rhodecode_support_url = \
310 310 rc_config.get('rhodecode_support_url') or h.route_url('rhodecode_support')
311 311
312 312 context.pre_code = rc_config.get('rhodecode_pre_code')
313 313 context.post_code = rc_config.get('rhodecode_post_code')
314 314 context.rhodecode_name = rc_config.get('rhodecode_title')
315 315 context.default_encodings = aslist(config.get('default_encoding'), sep=',')
316 316 # if we have specified default_encoding in the request, it has more
317 317 # priority
318 318 if request.GET.get('default_encoding'):
319 319 context.default_encodings.insert(0, request.GET.get('default_encoding'))
320 320 context.clone_uri_tmpl = rc_config.get('rhodecode_clone_uri_tmpl')
321 321
322 322 # INI stored
323 323 context.labs_active = str2bool(
324 324 config.get('labs_settings_active', 'false'))
325 325 context.visual.allow_repo_location_change = str2bool(
326 326 config.get('allow_repo_location_change', True))
327 327 context.visual.allow_custom_hooks_settings = str2bool(
328 328 config.get('allow_custom_hooks_settings', True))
329 329 context.debug_style = str2bool(config.get('debug_style', False))
330 330
331 331 context.rhodecode_instanceid = config.get('instance_id')
332 332
333 context.visual.cut_off_limit_diff = safe_int(
334 config.get('cut_off_limit_diff'))
335 context.visual.cut_off_limit_file = safe_int(
336 config.get('cut_off_limit_file'))
337
333 338 # AppEnlight
334 339 context.appenlight_enabled = str2bool(config.get('appenlight', 'false'))
335 340 context.appenlight_api_public_key = config.get(
336 341 'appenlight.api_public_key', '')
337 342 context.appenlight_server_url = config.get('appenlight.server_url', '')
338 343
339 344 # JS template context
340 345 context.template_context = {
341 346 'repo_name': None,
342 347 'repo_type': None,
343 348 'repo_landing_commit': None,
344 349 'rhodecode_user': {
345 350 'username': None,
346 351 'email': None,
347 352 'notification_status': False
348 353 },
349 354 'visual': {
350 355 'default_renderer': None
351 356 },
352 357 'commit_data': {
353 358 'commit_id': None
354 359 },
355 360 'pull_request_data': {'pull_request_id': None},
356 361 'timeago': {
357 362 'refresh_time': 120 * 1000,
358 363 'cutoff_limit': 1000 * 60 * 60 * 24 * 7
359 364 },
360 365 'pylons_dispatch': {
361 366 # 'controller': request.environ['pylons.routes_dict']['controller'],
362 367 # 'action': request.environ['pylons.routes_dict']['action'],
363 368 },
364 369 'pyramid_dispatch': {
365 370
366 371 },
367 372 'extra': {'plugins': {}}
368 373 }
369 374 # END CONFIG VARS
370 375
371 376 # TODO: This dosn't work when called from pylons compatibility tween.
372 377 # Fix this and remove it from base controller.
373 378 # context.repo_name = get_repo_slug(request) # can be empty
374 379
375 380 diffmode = 'sideside'
376 381 if request.GET.get('diffmode'):
377 382 if request.GET['diffmode'] == 'unified':
378 383 diffmode = 'unified'
379 384 elif request.session.get('diffmode'):
380 385 diffmode = request.session['diffmode']
381 386
382 387 context.diffmode = diffmode
383 388
384 389 if request.session.get('diffmode') != diffmode:
385 390 request.session['diffmode'] = diffmode
386 391
387 392 context.csrf_token = auth.get_csrf_token()
388 393 context.backends = rhodecode.BACKENDS.keys()
389 394 context.backends.sort()
390 395 context.unread_notifications = NotificationModel().get_unread_cnt_for_user(user_id)
391 396 if attach_to_request:
392 397 request.call_context = context
393 398 else:
394 399 context.pyramid_request = pyramid.threadlocal.get_current_request()
395 400
396 401
397 402
398 403 def get_auth_user(environ):
399 404 ip_addr = get_ip_addr(environ)
400 405 # make sure that we update permissions each time we call controller
401 406 _auth_token = (request.GET.get('auth_token', '') or
402 407 request.GET.get('api_key', ''))
403 408
404 409 if _auth_token:
405 410 # when using API_KEY we assume user exists, and
406 411 # doesn't need auth based on cookies.
407 412 auth_user = AuthUser(api_key=_auth_token, ip_addr=ip_addr)
408 413 authenticated = False
409 414 else:
410 415 cookie_store = CookieStoreWrapper(session.get('rhodecode_user'))
411 416 try:
412 417 auth_user = AuthUser(user_id=cookie_store.get('user_id', None),
413 418 ip_addr=ip_addr)
414 419 except UserCreationError as e:
415 420 h.flash(e, 'error')
416 421 # container auth or other auth functions that create users
417 422 # on the fly can throw this exception signaling that there's
418 423 # issue with user creation, explanation should be provided
419 424 # in Exception itself. We then create a simple blank
420 425 # AuthUser
421 426 auth_user = AuthUser(ip_addr=ip_addr)
422 427
423 428 if password_changed(auth_user, session):
424 429 session.invalidate()
425 430 cookie_store = CookieStoreWrapper(session.get('rhodecode_user'))
426 431 auth_user = AuthUser(ip_addr=ip_addr)
427 432
428 433 authenticated = cookie_store.get('is_authenticated')
429 434
430 435 if not auth_user.is_authenticated and auth_user.is_user_object:
431 436 # user is not authenticated and not empty
432 437 auth_user.set_authenticated(authenticated)
433 438
434 439 return auth_user
435 440
436 441
437 442 class BaseController(WSGIController):
438 443
439 444 def __before__(self):
440 445 """
441 446 __before__ is called before controller methods and after __call__
442 447 """
443 448 # on each call propagate settings calls into global settings.
444 449 set_rhodecode_config(config)
445 450 attach_context_attributes(c, request, c.rhodecode_user.user_id)
446 451
447 452 # TODO: Remove this when fixed in attach_context_attributes()
448 453 c.repo_name = get_repo_slug(request) # can be empty
449 454
450 455 self.cut_off_limit_diff = safe_int(config.get('cut_off_limit_diff'))
451 456 self.cut_off_limit_file = safe_int(config.get('cut_off_limit_file'))
452 457 self.sa = meta.Session
453 458 self.scm_model = ScmModel(self.sa)
454 459
455 460 # set user language
456 461 user_lang = getattr(c.pyramid_request, '_LOCALE_', None)
457 462 if user_lang:
458 463 translation.set_lang(user_lang)
459 464 log.debug('set language to %s for user %s',
460 465 user_lang, self._rhodecode_user)
461 466
462 467 def _dispatch_redirect(self, with_url, environ, start_response):
463 468 resp = HTTPFound(with_url)
464 469 environ['SCRIPT_NAME'] = '' # handle prefix middleware
465 470 environ['PATH_INFO'] = with_url
466 471 return resp(environ, start_response)
467 472
468 473 def __call__(self, environ, start_response):
469 474 """Invoke the Controller"""
470 475 # WSGIController.__call__ dispatches to the Controller method
471 476 # the request is routed to. This routing information is
472 477 # available in environ['pylons.routes_dict']
473 478 from rhodecode.lib import helpers as h
474 479
475 480 # Provide the Pylons context to Pyramid's debugtoolbar if it asks
476 481 if environ.get('debugtoolbar.wants_pylons_context', False):
477 482 environ['debugtoolbar.pylons_context'] = c._current_obj()
478 483
479 484 _route_name = '.'.join([environ['pylons.routes_dict']['controller'],
480 485 environ['pylons.routes_dict']['action']])
481 486
482 487 self.rc_config = SettingsModel().get_all_settings(cache=True)
483 488 self.ip_addr = get_ip_addr(environ)
484 489
485 490 # The rhodecode auth user is looked up and passed through the
486 491 # environ by the pylons compatibility tween in pyramid.
487 492 # So we can just grab it from there.
488 493 auth_user = environ['rc_auth_user']
489 494
490 495 # set globals for auth user
491 496 request.user = auth_user
492 497 c.rhodecode_user = self._rhodecode_user = auth_user
493 498
494 499 log.info('IP: %s User: %s accessed %s [%s]' % (
495 500 self.ip_addr, auth_user, safe_unicode(get_access_path(environ)),
496 501 _route_name)
497 502 )
498 503
499 504 user_obj = auth_user.get_instance()
500 505 if user_obj and user_obj.user_data.get('force_password_change'):
501 506 h.flash('You are required to change your password', 'warning',
502 507 ignore_duplicate=True)
503 508 return self._dispatch_redirect(
504 509 url('my_account_password'), environ, start_response)
505 510
506 511 return WSGIController.__call__(self, environ, start_response)
507 512
508 513
509 514 class BaseRepoController(BaseController):
510 515 """
511 516 Base class for controllers responsible for loading all needed data for
512 517 repository loaded items are
513 518
514 519 c.rhodecode_repo: instance of scm repository
515 520 c.rhodecode_db_repo: instance of db
516 521 c.repository_requirements_missing: shows that repository specific data
517 522 could not be displayed due to the missing requirements
518 523 c.repository_pull_requests: show number of open pull requests
519 524 """
520 525
521 526 def __before__(self):
522 527 super(BaseRepoController, self).__before__()
523 528 if c.repo_name: # extracted from routes
524 529 db_repo = Repository.get_by_repo_name(c.repo_name)
525 530 if not db_repo:
526 531 return
527 532
528 533 log.debug(
529 534 'Found repository in database %s with state `%s`',
530 535 safe_unicode(db_repo), safe_unicode(db_repo.repo_state))
531 536 route = getattr(request.environ.get('routes.route'), 'name', '')
532 537
533 538 # allow to delete repos that are somehow damages in filesystem
534 539 if route in ['delete_repo']:
535 540 return
536 541
537 542 if db_repo.repo_state in [Repository.STATE_PENDING]:
538 543 if route in ['repo_creating_home']:
539 544 return
540 545 check_url = url('repo_creating_home', repo_name=c.repo_name)
541 546 return redirect(check_url)
542 547
543 548 self.rhodecode_db_repo = db_repo
544 549
545 550 missing_requirements = False
546 551 try:
547 552 self.rhodecode_repo = self.rhodecode_db_repo.scm_instance()
548 553 except RepositoryRequirementError as e:
549 554 missing_requirements = True
550 555 self._handle_missing_requirements(e)
551 556
552 557 if self.rhodecode_repo is None and not missing_requirements:
553 558 log.error('%s this repository is present in database but it '
554 559 'cannot be created as an scm instance', c.repo_name)
555 560
556 561 h.flash(_(
557 562 "The repository at %(repo_name)s cannot be located.") %
558 563 {'repo_name': c.repo_name},
559 564 category='error', ignore_duplicate=True)
560 565 redirect(h.route_path('home'))
561 566
562 567 # update last change according to VCS data
563 568 if not missing_requirements:
564 569 commit = db_repo.get_commit(
565 570 pre_load=["author", "date", "message", "parents"])
566 571 db_repo.update_commit_cache(commit)
567 572
568 573 # Prepare context
569 574 c.rhodecode_db_repo = db_repo
570 575 c.rhodecode_repo = self.rhodecode_repo
571 576 c.repository_requirements_missing = missing_requirements
572 577
573 578 self._update_global_counters(self.scm_model, db_repo)
574 579
575 580 def _update_global_counters(self, scm_model, db_repo):
576 581 """
577 582 Base variables that are exposed to every page of repository
578 583 """
579 584 c.repository_pull_requests = scm_model.get_pull_requests(db_repo)
580 585
581 586 def _handle_missing_requirements(self, error):
582 587 self.rhodecode_repo = None
583 588 log.error(
584 589 'Requirements are missing for repository %s: %s',
585 590 c.repo_name, error.message)
586 591
587 592 summary_url = h.route_path('repo_summary', repo_name=c.repo_name)
588 593 statistics_url = url('edit_repo_statistics', repo_name=c.repo_name)
589 594 settings_update_url = url('repo', repo_name=c.repo_name)
590 595 path = request.path
591 596 should_redirect = (
592 597 path not in (summary_url, settings_update_url)
593 598 and '/settings' not in path or path == statistics_url
594 599 )
595 600 if should_redirect:
596 601 redirect(summary_url)
@@ -1,666 +1,665 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 comments model for RhodeCode
23 23 """
24 24
25 25 import logging
26 26 import traceback
27 27 import collections
28 28
29 29 from datetime import datetime
30 30
31 31 from pylons.i18n.translation import _
32 32 from pyramid.threadlocal import get_current_registry, get_current_request
33 33 from sqlalchemy.sql.expression import null
34 34 from sqlalchemy.sql.functions import coalesce
35 35
36 36 from rhodecode.lib import helpers as h, diffs
37 37 from rhodecode.lib import audit_logger
38 38 from rhodecode.lib.channelstream import channelstream_request
39 39 from rhodecode.lib.utils2 import extract_mentioned_users, safe_str
40 40 from rhodecode.model import BaseModel
41 41 from rhodecode.model.db import (
42 42 ChangesetComment, User, Notification, PullRequest, AttributeDict)
43 43 from rhodecode.model.notification import NotificationModel
44 44 from rhodecode.model.meta import Session
45 45 from rhodecode.model.settings import VcsSettingsModel
46 46 from rhodecode.model.notification import EmailNotificationModel
47 47 from rhodecode.model.validation_schema.schemas import comment_schema
48 48
49 49
50 50 log = logging.getLogger(__name__)
51 51
52 52
53 53 class CommentsModel(BaseModel):
54 54
55 55 cls = ChangesetComment
56 56
57 57 DIFF_CONTEXT_BEFORE = 3
58 58 DIFF_CONTEXT_AFTER = 3
59 59
60 60 def __get_commit_comment(self, changeset_comment):
61 61 return self._get_instance(ChangesetComment, changeset_comment)
62 62
63 63 def __get_pull_request(self, pull_request):
64 64 return self._get_instance(PullRequest, pull_request)
65 65
66 66 def _extract_mentions(self, s):
67 67 user_objects = []
68 68 for username in extract_mentioned_users(s):
69 69 user_obj = User.get_by_username(username, case_insensitive=True)
70 70 if user_obj:
71 71 user_objects.append(user_obj)
72 72 return user_objects
73 73
74 74 def _get_renderer(self, global_renderer='rst'):
75 75 try:
76 76 # try reading from visual context
77 77 from pylons import tmpl_context
78 78 global_renderer = tmpl_context.visual.default_renderer
79 79 except AttributeError:
80 80 log.debug("Renderer not set, falling back "
81 81 "to default renderer '%s'", global_renderer)
82 82 except Exception:
83 83 log.error(traceback.format_exc())
84 84 return global_renderer
85 85
86 86 def aggregate_comments(self, comments, versions, show_version, inline=False):
87 87 # group by versions, and count until, and display objects
88 88
89 89 comment_groups = collections.defaultdict(list)
90 90 [comment_groups[
91 91 _co.pull_request_version_id].append(_co) for _co in comments]
92 92
93 93 def yield_comments(pos):
94 94 for co in comment_groups[pos]:
95 95 yield co
96 96
97 97 comment_versions = collections.defaultdict(
98 98 lambda: collections.defaultdict(list))
99 99 prev_prvid = -1
100 100 # fake last entry with None, to aggregate on "latest" version which
101 101 # doesn't have an pull_request_version_id
102 102 for ver in versions + [AttributeDict({'pull_request_version_id': None})]:
103 103 prvid = ver.pull_request_version_id
104 104 if prev_prvid == -1:
105 105 prev_prvid = prvid
106 106
107 107 for co in yield_comments(prvid):
108 108 comment_versions[prvid]['at'].append(co)
109 109
110 110 # save until
111 111 current = comment_versions[prvid]['at']
112 112 prev_until = comment_versions[prev_prvid]['until']
113 113 cur_until = prev_until + current
114 114 comment_versions[prvid]['until'].extend(cur_until)
115 115
116 116 # save outdated
117 117 if inline:
118 118 outdated = [x for x in cur_until
119 119 if x.outdated_at_version(show_version)]
120 120 else:
121 121 outdated = [x for x in cur_until
122 122 if x.older_than_version(show_version)]
123 123 display = [x for x in cur_until if x not in outdated]
124 124
125 125 comment_versions[prvid]['outdated'] = outdated
126 126 comment_versions[prvid]['display'] = display
127 127
128 128 prev_prvid = prvid
129 129
130 130 return comment_versions
131 131
132 132 def get_unresolved_todos(self, pull_request, show_outdated=True):
133 133
134 134 todos = Session().query(ChangesetComment) \
135 135 .filter(ChangesetComment.pull_request == pull_request) \
136 136 .filter(ChangesetComment.resolved_by == None) \
137 137 .filter(ChangesetComment.comment_type
138 138 == ChangesetComment.COMMENT_TYPE_TODO)
139 139
140 140 if not show_outdated:
141 141 todos = todos.filter(
142 142 coalesce(ChangesetComment.display_state, '') !=
143 143 ChangesetComment.COMMENT_OUTDATED)
144 144
145 145 todos = todos.all()
146 146
147 147 return todos
148 148
149 149 def get_commit_unresolved_todos(self, commit_id, show_outdated=True):
150 150
151 151 todos = Session().query(ChangesetComment) \
152 152 .filter(ChangesetComment.revision == commit_id) \
153 153 .filter(ChangesetComment.resolved_by == None) \
154 154 .filter(ChangesetComment.comment_type
155 155 == ChangesetComment.COMMENT_TYPE_TODO)
156 156
157 157 if not show_outdated:
158 158 todos = todos.filter(
159 159 coalesce(ChangesetComment.display_state, '') !=
160 160 ChangesetComment.COMMENT_OUTDATED)
161 161
162 162 todos = todos.all()
163 163
164 164 return todos
165 165
166 166 def _log_audit_action(self, action, action_data, user, comment):
167 167 audit_logger.store(
168 168 action=action,
169 169 action_data=action_data,
170 170 user=user,
171 171 repo=comment.repo)
172 172
173 173 def create(self, text, repo, user, commit_id=None, pull_request=None,
174 174 f_path=None, line_no=None, status_change=None,
175 175 status_change_type=None, comment_type=None,
176 176 resolves_comment_id=None, closing_pr=False, send_email=True,
177 177 renderer=None):
178 178 """
179 179 Creates new comment for commit or pull request.
180 180 IF status_change is not none this comment is associated with a
181 181 status change of commit or commit associated with pull request
182 182
183 183 :param text:
184 184 :param repo:
185 185 :param user:
186 186 :param commit_id:
187 187 :param pull_request:
188 188 :param f_path:
189 189 :param line_no:
190 190 :param status_change: Label for status change
191 191 :param comment_type: Type of comment
192 192 :param status_change_type: type of status change
193 193 :param closing_pr:
194 194 :param send_email:
195 195 :param renderer: pick renderer for this comment
196 196 """
197 197 if not text:
198 198 log.warning('Missing text for comment, skipping...')
199 199 return
200 200
201 201 if not renderer:
202 202 renderer = self._get_renderer()
203 203
204 204 repo = self._get_repo(repo)
205 205 user = self._get_user(user)
206 206
207 207 schema = comment_schema.CommentSchema()
208 208 validated_kwargs = schema.deserialize(dict(
209 209 comment_body=text,
210 210 comment_type=comment_type,
211 211 comment_file=f_path,
212 212 comment_line=line_no,
213 213 renderer_type=renderer,
214 214 status_change=status_change_type,
215 215 resolves_comment_id=resolves_comment_id,
216 216 repo=repo.repo_id,
217 217 user=user.user_id,
218 218 ))
219 219
220 220 comment = ChangesetComment()
221 221 comment.renderer = validated_kwargs['renderer_type']
222 222 comment.text = validated_kwargs['comment_body']
223 223 comment.f_path = validated_kwargs['comment_file']
224 224 comment.line_no = validated_kwargs['comment_line']
225 225 comment.comment_type = validated_kwargs['comment_type']
226 226
227 227 comment.repo = repo
228 228 comment.author = user
229 229 comment.resolved_comment = self.__get_commit_comment(
230 230 validated_kwargs['resolves_comment_id'])
231 231
232 232 pull_request_id = pull_request
233 233
234 234 commit_obj = None
235 235 pull_request_obj = None
236 236
237 237 if commit_id:
238 238 notification_type = EmailNotificationModel.TYPE_COMMIT_COMMENT
239 239 # do a lookup, so we don't pass something bad here
240 240 commit_obj = repo.scm_instance().get_commit(commit_id=commit_id)
241 241 comment.revision = commit_obj.raw_id
242 242
243 243 elif pull_request_id:
244 244 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST_COMMENT
245 245 pull_request_obj = self.__get_pull_request(pull_request_id)
246 246 comment.pull_request = pull_request_obj
247 247 else:
248 248 raise Exception('Please specify commit or pull_request_id')
249 249
250 250 Session().add(comment)
251 251 Session().flush()
252 252 kwargs = {
253 253 'user': user,
254 254 'renderer_type': renderer,
255 255 'repo_name': repo.repo_name,
256 256 'status_change': status_change,
257 257 'status_change_type': status_change_type,
258 258 'comment_body': text,
259 259 'comment_file': f_path,
260 260 'comment_line': line_no,
261 261 'comment_type': comment_type or 'note'
262 262 }
263 263
264 264 if commit_obj:
265 265 recipients = ChangesetComment.get_users(
266 266 revision=commit_obj.raw_id)
267 267 # add commit author if it's in RhodeCode system
268 268 cs_author = User.get_from_cs_author(commit_obj.author)
269 269 if not cs_author:
270 270 # use repo owner if we cannot extract the author correctly
271 271 cs_author = repo.user
272 272 recipients += [cs_author]
273 273
274 274 commit_comment_url = self.get_url(comment)
275 275
276 276 target_repo_url = h.link_to(
277 277 repo.repo_name,
278 278 h.route_url('repo_summary', repo_name=repo.repo_name))
279 279
280 280 # commit specifics
281 281 kwargs.update({
282 282 'commit': commit_obj,
283 283 'commit_message': commit_obj.message,
284 284 'commit_target_repo': target_repo_url,
285 285 'commit_comment_url': commit_comment_url,
286 286 })
287 287
288 288 elif pull_request_obj:
289 289 # get the current participants of this pull request
290 290 recipients = ChangesetComment.get_users(
291 291 pull_request_id=pull_request_obj.pull_request_id)
292 292 # add pull request author
293 293 recipients += [pull_request_obj.author]
294 294
295 295 # add the reviewers to notification
296 296 recipients += [x.user for x in pull_request_obj.reviewers]
297 297
298 298 pr_target_repo = pull_request_obj.target_repo
299 299 pr_source_repo = pull_request_obj.source_repo
300 300
301 301 pr_comment_url = h.url(
302 302 'pullrequest_show',
303 303 repo_name=pr_target_repo.repo_name,
304 304 pull_request_id=pull_request_obj.pull_request_id,
305 305 anchor='comment-%s' % comment.comment_id,
306 306 qualified=True,)
307 307
308 308 # set some variables for email notification
309 309 pr_target_repo_url = h.route_url(
310 310 'repo_summary', repo_name=pr_target_repo.repo_name)
311 311
312 312 pr_source_repo_url = h.route_url(
313 313 'repo_summary', repo_name=pr_source_repo.repo_name)
314 314
315 315 # pull request specifics
316 316 kwargs.update({
317 317 'pull_request': pull_request_obj,
318 318 'pr_id': pull_request_obj.pull_request_id,
319 319 'pr_target_repo': pr_target_repo,
320 320 'pr_target_repo_url': pr_target_repo_url,
321 321 'pr_source_repo': pr_source_repo,
322 322 'pr_source_repo_url': pr_source_repo_url,
323 323 'pr_comment_url': pr_comment_url,
324 324 'pr_closing': closing_pr,
325 325 })
326 326 if send_email:
327 327 # pre-generate the subject for notification itself
328 328 (subject,
329 329 _h, _e, # we don't care about those
330 330 body_plaintext) = EmailNotificationModel().render_email(
331 331 notification_type, **kwargs)
332 332
333 333 mention_recipients = set(
334 334 self._extract_mentions(text)).difference(recipients)
335 335
336 336 # create notification objects, and emails
337 337 NotificationModel().create(
338 338 created_by=user,
339 339 notification_subject=subject,
340 340 notification_body=body_plaintext,
341 341 notification_type=notification_type,
342 342 recipients=recipients,
343 343 mention_recipients=mention_recipients,
344 344 email_kwargs=kwargs,
345 345 )
346 346
347 347 Session().flush()
348 348 if comment.pull_request:
349 349 action = 'repo.pull_request.comment.create'
350 350 else:
351 351 action = 'repo.commit.comment.create'
352 352
353 353 comment_data = comment.get_api_data()
354 354 self._log_audit_action(
355 355 action, {'data': comment_data}, user, comment)
356 356
357 357 registry = get_current_registry()
358 358 rhodecode_plugins = getattr(registry, 'rhodecode_plugins', {})
359 359 channelstream_config = rhodecode_plugins.get('channelstream', {})
360 360 msg_url = ''
361 361 if commit_obj:
362 362 msg_url = commit_comment_url
363 363 repo_name = repo.repo_name
364 364 elif pull_request_obj:
365 365 msg_url = pr_comment_url
366 366 repo_name = pr_target_repo.repo_name
367 367
368 368 if channelstream_config.get('enabled'):
369 369 message = '<strong>{}</strong> {} - ' \
370 370 '<a onclick="window.location=\'{}\';' \
371 371 'window.location.reload()">' \
372 372 '<strong>{}</strong></a>'
373 373 message = message.format(
374 374 user.username, _('made a comment'), msg_url,
375 375 _('Show it now'))
376 376 channel = '/repo${}$/pr/{}'.format(
377 377 repo_name,
378 378 pull_request_id
379 379 )
380 380 payload = {
381 381 'type': 'message',
382 382 'timestamp': datetime.utcnow(),
383 383 'user': 'system',
384 384 'exclude_users': [user.username],
385 385 'channel': channel,
386 386 'message': {
387 387 'message': message,
388 388 'level': 'info',
389 389 'topic': '/notifications'
390 390 }
391 391 }
392 392 channelstream_request(channelstream_config, [payload],
393 393 '/message', raise_exc=False)
394 394
395 395 return comment
396 396
397 397 def delete(self, comment, user):
398 398 """
399 399 Deletes given comment
400 400 """
401 401 comment = self.__get_commit_comment(comment)
402 402 old_data = comment.get_api_data()
403 403 Session().delete(comment)
404 404
405 405 if comment.pull_request:
406 406 action = 'repo.pull_request.comment.delete'
407 407 else:
408 408 action = 'repo.commit.comment.delete'
409 409
410 410 self._log_audit_action(
411 411 action, {'old_data': old_data}, user, comment)
412 412
413 413 return comment
414 414
415 415 def get_all_comments(self, repo_id, revision=None, pull_request=None):
416 416 q = ChangesetComment.query()\
417 417 .filter(ChangesetComment.repo_id == repo_id)
418 418 if revision:
419 419 q = q.filter(ChangesetComment.revision == revision)
420 420 elif pull_request:
421 421 pull_request = self.__get_pull_request(pull_request)
422 422 q = q.filter(ChangesetComment.pull_request == pull_request)
423 423 else:
424 424 raise Exception('Please specify commit or pull_request')
425 425 q = q.order_by(ChangesetComment.created_on)
426 426 return q.all()
427 427
428 428 def get_url(self, comment, request=None, permalink=False):
429 429 if not request:
430 430 request = get_current_request()
431 431
432 432 comment = self.__get_commit_comment(comment)
433 433 if comment.pull_request:
434 434 pull_request = comment.pull_request
435 435 if permalink:
436 436 return request.route_url(
437 437 'pull_requests_global',
438 438 pull_request_id=pull_request.pull_request_id,
439 439 _anchor='comment-%s' % comment.comment_id)
440 440 else:
441 return request.route_url(
442 'pullrequest_show',
441 return request.route_url('pullrequest_show',
443 442 repo_name=safe_str(pull_request.target_repo.repo_name),
444 443 pull_request_id=pull_request.pull_request_id,
445 444 _anchor='comment-%s' % comment.comment_id)
446 445
447 446 else:
448 447 repo = comment.repo
449 448 commit_id = comment.revision
450 449
451 450 if permalink:
452 451 return request.route_url(
453 452 'repo_commit', repo_name=safe_str(repo.repo_id),
454 453 commit_id=commit_id,
455 454 _anchor='comment-%s' % comment.comment_id)
456 455
457 456 else:
458 457 return request.route_url(
459 458 'repo_commit', repo_name=safe_str(repo.repo_name),
460 459 commit_id=commit_id,
461 460 _anchor='comment-%s' % comment.comment_id)
462 461
463 462 def get_comments(self, repo_id, revision=None, pull_request=None):
464 463 """
465 464 Gets main comments based on revision or pull_request_id
466 465
467 466 :param repo_id:
468 467 :param revision:
469 468 :param pull_request:
470 469 """
471 470
472 471 q = ChangesetComment.query()\
473 472 .filter(ChangesetComment.repo_id == repo_id)\
474 473 .filter(ChangesetComment.line_no == None)\
475 474 .filter(ChangesetComment.f_path == None)
476 475 if revision:
477 476 q = q.filter(ChangesetComment.revision == revision)
478 477 elif pull_request:
479 478 pull_request = self.__get_pull_request(pull_request)
480 479 q = q.filter(ChangesetComment.pull_request == pull_request)
481 480 else:
482 481 raise Exception('Please specify commit or pull_request')
483 482 q = q.order_by(ChangesetComment.created_on)
484 483 return q.all()
485 484
486 485 def get_inline_comments(self, repo_id, revision=None, pull_request=None):
487 486 q = self._get_inline_comments_query(repo_id, revision, pull_request)
488 487 return self._group_comments_by_path_and_line_number(q)
489 488
490 489 def get_inline_comments_count(self, inline_comments, skip_outdated=True,
491 490 version=None):
492 491 inline_cnt = 0
493 492 for fname, per_line_comments in inline_comments.iteritems():
494 493 for lno, comments in per_line_comments.iteritems():
495 494 for comm in comments:
496 495 if not comm.outdated_at_version(version) and skip_outdated:
497 496 inline_cnt += 1
498 497
499 498 return inline_cnt
500 499
501 500 def get_outdated_comments(self, repo_id, pull_request):
502 501 # TODO: johbo: Remove `repo_id`, it is not needed to find the comments
503 502 # of a pull request.
504 503 q = self._all_inline_comments_of_pull_request(pull_request)
505 504 q = q.filter(
506 505 ChangesetComment.display_state ==
507 506 ChangesetComment.COMMENT_OUTDATED
508 507 ).order_by(ChangesetComment.comment_id.asc())
509 508
510 509 return self._group_comments_by_path_and_line_number(q)
511 510
512 511 def _get_inline_comments_query(self, repo_id, revision, pull_request):
513 512 # TODO: johbo: Split this into two methods: One for PR and one for
514 513 # commit.
515 514 if revision:
516 515 q = Session().query(ChangesetComment).filter(
517 516 ChangesetComment.repo_id == repo_id,
518 517 ChangesetComment.line_no != null(),
519 518 ChangesetComment.f_path != null(),
520 519 ChangesetComment.revision == revision)
521 520
522 521 elif pull_request:
523 522 pull_request = self.__get_pull_request(pull_request)
524 523 if not CommentsModel.use_outdated_comments(pull_request):
525 524 q = self._visible_inline_comments_of_pull_request(pull_request)
526 525 else:
527 526 q = self._all_inline_comments_of_pull_request(pull_request)
528 527
529 528 else:
530 529 raise Exception('Please specify commit or pull_request_id')
531 530 q = q.order_by(ChangesetComment.comment_id.asc())
532 531 return q
533 532
534 533 def _group_comments_by_path_and_line_number(self, q):
535 534 comments = q.all()
536 535 paths = collections.defaultdict(lambda: collections.defaultdict(list))
537 536 for co in comments:
538 537 paths[co.f_path][co.line_no].append(co)
539 538 return paths
540 539
541 540 @classmethod
542 541 def needed_extra_diff_context(cls):
543 542 return max(cls.DIFF_CONTEXT_BEFORE, cls.DIFF_CONTEXT_AFTER)
544 543
545 544 def outdate_comments(self, pull_request, old_diff_data, new_diff_data):
546 545 if not CommentsModel.use_outdated_comments(pull_request):
547 546 return
548 547
549 548 comments = self._visible_inline_comments_of_pull_request(pull_request)
550 549 comments_to_outdate = comments.all()
551 550
552 551 for comment in comments_to_outdate:
553 552 self._outdate_one_comment(comment, old_diff_data, new_diff_data)
554 553
555 554 def _outdate_one_comment(self, comment, old_diff_proc, new_diff_proc):
556 555 diff_line = _parse_comment_line_number(comment.line_no)
557 556
558 557 try:
559 558 old_context = old_diff_proc.get_context_of_line(
560 559 path=comment.f_path, diff_line=diff_line)
561 560 new_context = new_diff_proc.get_context_of_line(
562 561 path=comment.f_path, diff_line=diff_line)
563 562 except (diffs.LineNotInDiffException,
564 563 diffs.FileNotInDiffException):
565 564 comment.display_state = ChangesetComment.COMMENT_OUTDATED
566 565 return
567 566
568 567 if old_context == new_context:
569 568 return
570 569
571 570 if self._should_relocate_diff_line(diff_line):
572 571 new_diff_lines = new_diff_proc.find_context(
573 572 path=comment.f_path, context=old_context,
574 573 offset=self.DIFF_CONTEXT_BEFORE)
575 574 if not new_diff_lines:
576 575 comment.display_state = ChangesetComment.COMMENT_OUTDATED
577 576 else:
578 577 new_diff_line = self._choose_closest_diff_line(
579 578 diff_line, new_diff_lines)
580 579 comment.line_no = _diff_to_comment_line_number(new_diff_line)
581 580 else:
582 581 comment.display_state = ChangesetComment.COMMENT_OUTDATED
583 582
584 583 def _should_relocate_diff_line(self, diff_line):
585 584 """
586 585 Checks if relocation shall be tried for the given `diff_line`.
587 586
588 587 If a comment points into the first lines, then we can have a situation
589 588 that after an update another line has been added on top. In this case
590 589 we would find the context still and move the comment around. This
591 590 would be wrong.
592 591 """
593 592 should_relocate = (
594 593 (diff_line.new and diff_line.new > self.DIFF_CONTEXT_BEFORE) or
595 594 (diff_line.old and diff_line.old > self.DIFF_CONTEXT_BEFORE))
596 595 return should_relocate
597 596
598 597 def _choose_closest_diff_line(self, diff_line, new_diff_lines):
599 598 candidate = new_diff_lines[0]
600 599 best_delta = _diff_line_delta(diff_line, candidate)
601 600 for new_diff_line in new_diff_lines[1:]:
602 601 delta = _diff_line_delta(diff_line, new_diff_line)
603 602 if delta < best_delta:
604 603 candidate = new_diff_line
605 604 best_delta = delta
606 605 return candidate
607 606
608 607 def _visible_inline_comments_of_pull_request(self, pull_request):
609 608 comments = self._all_inline_comments_of_pull_request(pull_request)
610 609 comments = comments.filter(
611 610 coalesce(ChangesetComment.display_state, '') !=
612 611 ChangesetComment.COMMENT_OUTDATED)
613 612 return comments
614 613
615 614 def _all_inline_comments_of_pull_request(self, pull_request):
616 615 comments = Session().query(ChangesetComment)\
617 616 .filter(ChangesetComment.line_no != None)\
618 617 .filter(ChangesetComment.f_path != None)\
619 618 .filter(ChangesetComment.pull_request == pull_request)
620 619 return comments
621 620
622 621 def _all_general_comments_of_pull_request(self, pull_request):
623 622 comments = Session().query(ChangesetComment)\
624 623 .filter(ChangesetComment.line_no == None)\
625 624 .filter(ChangesetComment.f_path == None)\
626 625 .filter(ChangesetComment.pull_request == pull_request)
627 626 return comments
628 627
629 628 @staticmethod
630 629 def use_outdated_comments(pull_request):
631 630 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
632 631 settings = settings_model.get_general_settings()
633 632 return settings.get('rhodecode_use_outdated_comments', False)
634 633
635 634
636 635 def _parse_comment_line_number(line_no):
637 636 """
638 637 Parses line numbers of the form "(o|n)\d+" and returns them in a tuple.
639 638 """
640 639 old_line = None
641 640 new_line = None
642 641 if line_no.startswith('o'):
643 642 old_line = int(line_no[1:])
644 643 elif line_no.startswith('n'):
645 644 new_line = int(line_no[1:])
646 645 else:
647 646 raise ValueError("Comment lines have to start with either 'o' or 'n'.")
648 647 return diffs.DiffLineNumber(old_line, new_line)
649 648
650 649
651 650 def _diff_to_comment_line_number(diff_line):
652 651 if diff_line.new is not None:
653 652 return u'n{}'.format(diff_line.new)
654 653 elif diff_line.old is not None:
655 654 return u'o{}'.format(diff_line.old)
656 655 return u''
657 656
658 657
659 658 def _diff_line_delta(a, b):
660 659 if None not in (a.new, b.new):
661 660 return abs(a.new - b.new)
662 661 elif None not in (a.old, b.old):
663 662 return abs(a.old - b.old)
664 663 else:
665 664 raise ValueError(
666 665 "Cannot compute delta between {} and {}".format(a, b))
@@ -1,1554 +1,1551 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21
22 22 """
23 23 pull request model for RhodeCode
24 24 """
25 25
26 26 from collections import namedtuple
27 27 import json
28 28 import logging
29 29 import datetime
30 30 import urllib
31 31
32 32 from pylons.i18n.translation import _
33 33 from pylons.i18n.translation import lazy_ugettext
34 34 from pyramid.threadlocal import get_current_request
35 35 from sqlalchemy import or_
36 36
37 37 from rhodecode import events
38 38 from rhodecode.lib import helpers as h, hooks_utils, diffs
39 39 from rhodecode.lib import audit_logger
40 40 from rhodecode.lib.compat import OrderedDict
41 41 from rhodecode.lib.hooks_daemon import prepare_callback_daemon
42 42 from rhodecode.lib.markup_renderer import (
43 43 DEFAULT_COMMENTS_RENDERER, RstTemplateRenderer)
44 44 from rhodecode.lib.utils2 import safe_unicode, safe_str, md5_safe
45 45 from rhodecode.lib.vcs.backends.base import (
46 46 Reference, MergeResponse, MergeFailureReason, UpdateFailureReason)
47 47 from rhodecode.lib.vcs.conf import settings as vcs_settings
48 48 from rhodecode.lib.vcs.exceptions import (
49 49 CommitDoesNotExistError, EmptyRepositoryError)
50 50 from rhodecode.model import BaseModel
51 51 from rhodecode.model.changeset_status import ChangesetStatusModel
52 52 from rhodecode.model.comment import CommentsModel
53 53 from rhodecode.model.db import (
54 54 PullRequest, PullRequestReviewers, ChangesetStatus,
55 55 PullRequestVersion, ChangesetComment, Repository)
56 56 from rhodecode.model.meta import Session
57 57 from rhodecode.model.notification import NotificationModel, \
58 58 EmailNotificationModel
59 59 from rhodecode.model.scm import ScmModel
60 60 from rhodecode.model.settings import VcsSettingsModel
61 61
62 62
63 63 log = logging.getLogger(__name__)
64 64
65 65
66 66 # Data structure to hold the response data when updating commits during a pull
67 67 # request update.
68 68 UpdateResponse = namedtuple('UpdateResponse', [
69 69 'executed', 'reason', 'new', 'old', 'changes',
70 70 'source_changed', 'target_changed'])
71 71
72 72
73 73 class PullRequestModel(BaseModel):
74 74
75 75 cls = PullRequest
76 76
77 77 DIFF_CONTEXT = 3
78 78
79 79 MERGE_STATUS_MESSAGES = {
80 80 MergeFailureReason.NONE: lazy_ugettext(
81 81 'This pull request can be automatically merged.'),
82 82 MergeFailureReason.UNKNOWN: lazy_ugettext(
83 83 'This pull request cannot be merged because of an unhandled'
84 84 ' exception.'),
85 85 MergeFailureReason.MERGE_FAILED: lazy_ugettext(
86 86 'This pull request cannot be merged because of merge conflicts.'),
87 87 MergeFailureReason.PUSH_FAILED: lazy_ugettext(
88 88 'This pull request could not be merged because push to target'
89 89 ' failed.'),
90 90 MergeFailureReason.TARGET_IS_NOT_HEAD: lazy_ugettext(
91 91 'This pull request cannot be merged because the target is not a'
92 92 ' head.'),
93 93 MergeFailureReason.HG_SOURCE_HAS_MORE_BRANCHES: lazy_ugettext(
94 94 'This pull request cannot be merged because the source contains'
95 95 ' more branches than the target.'),
96 96 MergeFailureReason.HG_TARGET_HAS_MULTIPLE_HEADS: lazy_ugettext(
97 97 'This pull request cannot be merged because the target has'
98 98 ' multiple heads.'),
99 99 MergeFailureReason.TARGET_IS_LOCKED: lazy_ugettext(
100 100 'This pull request cannot be merged because the target repository'
101 101 ' is locked.'),
102 102 MergeFailureReason._DEPRECATED_MISSING_COMMIT: lazy_ugettext(
103 103 'This pull request cannot be merged because the target or the '
104 104 'source reference is missing.'),
105 105 MergeFailureReason.MISSING_TARGET_REF: lazy_ugettext(
106 106 'This pull request cannot be merged because the target '
107 107 'reference is missing.'),
108 108 MergeFailureReason.MISSING_SOURCE_REF: lazy_ugettext(
109 109 'This pull request cannot be merged because the source '
110 110 'reference is missing.'),
111 111 MergeFailureReason.SUBREPO_MERGE_FAILED: lazy_ugettext(
112 112 'This pull request cannot be merged because of conflicts related '
113 113 'to sub repositories.'),
114 114 }
115 115
116 116 UPDATE_STATUS_MESSAGES = {
117 117 UpdateFailureReason.NONE: lazy_ugettext(
118 118 'Pull request update successful.'),
119 119 UpdateFailureReason.UNKNOWN: lazy_ugettext(
120 120 'Pull request update failed because of an unknown error.'),
121 121 UpdateFailureReason.NO_CHANGE: lazy_ugettext(
122 122 'No update needed because the source and target have not changed.'),
123 123 UpdateFailureReason.WRONG_REF_TYPE: lazy_ugettext(
124 124 'Pull request cannot be updated because the reference type is '
125 125 'not supported for an update. Only Branch, Tag or Bookmark is allowed.'),
126 126 UpdateFailureReason.MISSING_TARGET_REF: lazy_ugettext(
127 127 'This pull request cannot be updated because the target '
128 128 'reference is missing.'),
129 129 UpdateFailureReason.MISSING_SOURCE_REF: lazy_ugettext(
130 130 'This pull request cannot be updated because the source '
131 131 'reference is missing.'),
132 132 }
133 133
134 134 def __get_pull_request(self, pull_request):
135 135 return self._get_instance((
136 136 PullRequest, PullRequestVersion), pull_request)
137 137
138 138 def _check_perms(self, perms, pull_request, user, api=False):
139 139 if not api:
140 140 return h.HasRepoPermissionAny(*perms)(
141 141 user=user, repo_name=pull_request.target_repo.repo_name)
142 142 else:
143 143 return h.HasRepoPermissionAnyApi(*perms)(
144 144 user=user, repo_name=pull_request.target_repo.repo_name)
145 145
146 146 def check_user_read(self, pull_request, user, api=False):
147 147 _perms = ('repository.admin', 'repository.write', 'repository.read',)
148 148 return self._check_perms(_perms, pull_request, user, api)
149 149
150 150 def check_user_merge(self, pull_request, user, api=False):
151 151 _perms = ('repository.admin', 'repository.write', 'hg.admin',)
152 152 return self._check_perms(_perms, pull_request, user, api)
153 153
154 154 def check_user_update(self, pull_request, user, api=False):
155 155 owner = user.user_id == pull_request.user_id
156 156 return self.check_user_merge(pull_request, user, api) or owner
157 157
158 158 def check_user_delete(self, pull_request, user):
159 159 owner = user.user_id == pull_request.user_id
160 160 _perms = ('repository.admin',)
161 161 return self._check_perms(_perms, pull_request, user) or owner
162 162
163 163 def check_user_change_status(self, pull_request, user, api=False):
164 164 reviewer = user.user_id in [x.user_id for x in
165 165 pull_request.reviewers]
166 166 return self.check_user_update(pull_request, user, api) or reviewer
167 167
168 168 def get(self, pull_request):
169 169 return self.__get_pull_request(pull_request)
170 170
171 171 def _prepare_get_all_query(self, repo_name, source=False, statuses=None,
172 172 opened_by=None, order_by=None,
173 173 order_dir='desc'):
174 174 repo = None
175 175 if repo_name:
176 176 repo = self._get_repo(repo_name)
177 177
178 178 q = PullRequest.query()
179 179
180 180 # source or target
181 181 if repo and source:
182 182 q = q.filter(PullRequest.source_repo == repo)
183 183 elif repo:
184 184 q = q.filter(PullRequest.target_repo == repo)
185 185
186 186 # closed,opened
187 187 if statuses:
188 188 q = q.filter(PullRequest.status.in_(statuses))
189 189
190 190 # opened by filter
191 191 if opened_by:
192 192 q = q.filter(PullRequest.user_id.in_(opened_by))
193 193
194 194 if order_by:
195 195 order_map = {
196 196 'name_raw': PullRequest.pull_request_id,
197 197 'title': PullRequest.title,
198 198 'updated_on_raw': PullRequest.updated_on,
199 199 'target_repo': PullRequest.target_repo_id
200 200 }
201 201 if order_dir == 'asc':
202 202 q = q.order_by(order_map[order_by].asc())
203 203 else:
204 204 q = q.order_by(order_map[order_by].desc())
205 205
206 206 return q
207 207
208 208 def count_all(self, repo_name, source=False, statuses=None,
209 209 opened_by=None):
210 210 """
211 211 Count the number of pull requests for a specific repository.
212 212
213 213 :param repo_name: target or source repo
214 214 :param source: boolean flag to specify if repo_name refers to source
215 215 :param statuses: list of pull request statuses
216 216 :param opened_by: author user of the pull request
217 217 :returns: int number of pull requests
218 218 """
219 219 q = self._prepare_get_all_query(
220 220 repo_name, source=source, statuses=statuses, opened_by=opened_by)
221 221
222 222 return q.count()
223 223
224 224 def get_all(self, repo_name, source=False, statuses=None, opened_by=None,
225 225 offset=0, length=None, order_by=None, order_dir='desc'):
226 226 """
227 227 Get all pull requests for a specific repository.
228 228
229 229 :param repo_name: target or source repo
230 230 :param source: boolean flag to specify if repo_name refers to source
231 231 :param statuses: list of pull request statuses
232 232 :param opened_by: author user of the pull request
233 233 :param offset: pagination offset
234 234 :param length: length of returned list
235 235 :param order_by: order of the returned list
236 236 :param order_dir: 'asc' or 'desc' ordering direction
237 237 :returns: list of pull requests
238 238 """
239 239 q = self._prepare_get_all_query(
240 240 repo_name, source=source, statuses=statuses, opened_by=opened_by,
241 241 order_by=order_by, order_dir=order_dir)
242 242
243 243 if length:
244 244 pull_requests = q.limit(length).offset(offset).all()
245 245 else:
246 246 pull_requests = q.all()
247 247
248 248 return pull_requests
249 249
250 250 def count_awaiting_review(self, repo_name, source=False, statuses=None,
251 251 opened_by=None):
252 252 """
253 253 Count the number of pull requests for a specific repository that are
254 254 awaiting review.
255 255
256 256 :param repo_name: target or source repo
257 257 :param source: boolean flag to specify if repo_name refers to source
258 258 :param statuses: list of pull request statuses
259 259 :param opened_by: author user of the pull request
260 260 :returns: int number of pull requests
261 261 """
262 262 pull_requests = self.get_awaiting_review(
263 263 repo_name, source=source, statuses=statuses, opened_by=opened_by)
264 264
265 265 return len(pull_requests)
266 266
267 267 def get_awaiting_review(self, repo_name, source=False, statuses=None,
268 268 opened_by=None, offset=0, length=None,
269 269 order_by=None, order_dir='desc'):
270 270 """
271 271 Get all pull requests for a specific repository that are awaiting
272 272 review.
273 273
274 274 :param repo_name: target or source repo
275 275 :param source: boolean flag to specify if repo_name refers to source
276 276 :param statuses: list of pull request statuses
277 277 :param opened_by: author user of the pull request
278 278 :param offset: pagination offset
279 279 :param length: length of returned list
280 280 :param order_by: order of the returned list
281 281 :param order_dir: 'asc' or 'desc' ordering direction
282 282 :returns: list of pull requests
283 283 """
284 284 pull_requests = self.get_all(
285 285 repo_name, source=source, statuses=statuses, opened_by=opened_by,
286 286 order_by=order_by, order_dir=order_dir)
287 287
288 288 _filtered_pull_requests = []
289 289 for pr in pull_requests:
290 290 status = pr.calculated_review_status()
291 291 if status in [ChangesetStatus.STATUS_NOT_REVIEWED,
292 292 ChangesetStatus.STATUS_UNDER_REVIEW]:
293 293 _filtered_pull_requests.append(pr)
294 294 if length:
295 295 return _filtered_pull_requests[offset:offset+length]
296 296 else:
297 297 return _filtered_pull_requests
298 298
299 299 def count_awaiting_my_review(self, repo_name, source=False, statuses=None,
300 300 opened_by=None, user_id=None):
301 301 """
302 302 Count the number of pull requests for a specific repository that are
303 303 awaiting review from a specific user.
304 304
305 305 :param repo_name: target or source repo
306 306 :param source: boolean flag to specify if repo_name refers to source
307 307 :param statuses: list of pull request statuses
308 308 :param opened_by: author user of the pull request
309 309 :param user_id: reviewer user of the pull request
310 310 :returns: int number of pull requests
311 311 """
312 312 pull_requests = self.get_awaiting_my_review(
313 313 repo_name, source=source, statuses=statuses, opened_by=opened_by,
314 314 user_id=user_id)
315 315
316 316 return len(pull_requests)
317 317
318 318 def get_awaiting_my_review(self, repo_name, source=False, statuses=None,
319 319 opened_by=None, user_id=None, offset=0,
320 320 length=None, order_by=None, order_dir='desc'):
321 321 """
322 322 Get all pull requests for a specific repository that are awaiting
323 323 review from a specific user.
324 324
325 325 :param repo_name: target or source repo
326 326 :param source: boolean flag to specify if repo_name refers to source
327 327 :param statuses: list of pull request statuses
328 328 :param opened_by: author user of the pull request
329 329 :param user_id: reviewer user of the pull request
330 330 :param offset: pagination offset
331 331 :param length: length of returned list
332 332 :param order_by: order of the returned list
333 333 :param order_dir: 'asc' or 'desc' ordering direction
334 334 :returns: list of pull requests
335 335 """
336 336 pull_requests = self.get_all(
337 337 repo_name, source=source, statuses=statuses, opened_by=opened_by,
338 338 order_by=order_by, order_dir=order_dir)
339 339
340 340 _my = PullRequestModel().get_not_reviewed(user_id)
341 341 my_participation = []
342 342 for pr in pull_requests:
343 343 if pr in _my:
344 344 my_participation.append(pr)
345 345 _filtered_pull_requests = my_participation
346 346 if length:
347 347 return _filtered_pull_requests[offset:offset+length]
348 348 else:
349 349 return _filtered_pull_requests
350 350
351 351 def get_not_reviewed(self, user_id):
352 352 return [
353 353 x.pull_request for x in PullRequestReviewers.query().filter(
354 354 PullRequestReviewers.user_id == user_id).all()
355 355 ]
356 356
357 357 def _prepare_participating_query(self, user_id=None, statuses=None,
358 358 order_by=None, order_dir='desc'):
359 359 q = PullRequest.query()
360 360 if user_id:
361 361 reviewers_subquery = Session().query(
362 362 PullRequestReviewers.pull_request_id).filter(
363 363 PullRequestReviewers.user_id == user_id).subquery()
364 364 user_filter= or_(
365 365 PullRequest.user_id == user_id,
366 366 PullRequest.pull_request_id.in_(reviewers_subquery)
367 367 )
368 368 q = PullRequest.query().filter(user_filter)
369 369
370 370 # closed,opened
371 371 if statuses:
372 372 q = q.filter(PullRequest.status.in_(statuses))
373 373
374 374 if order_by:
375 375 order_map = {
376 376 'name_raw': PullRequest.pull_request_id,
377 377 'title': PullRequest.title,
378 378 'updated_on_raw': PullRequest.updated_on,
379 379 'target_repo': PullRequest.target_repo_id
380 380 }
381 381 if order_dir == 'asc':
382 382 q = q.order_by(order_map[order_by].asc())
383 383 else:
384 384 q = q.order_by(order_map[order_by].desc())
385 385
386 386 return q
387 387
388 388 def count_im_participating_in(self, user_id=None, statuses=None):
389 389 q = self._prepare_participating_query(user_id, statuses=statuses)
390 390 return q.count()
391 391
392 392 def get_im_participating_in(
393 393 self, user_id=None, statuses=None, offset=0,
394 394 length=None, order_by=None, order_dir='desc'):
395 395 """
396 396 Get all Pull requests that i'm participating in, or i have opened
397 397 """
398 398
399 399 q = self._prepare_participating_query(
400 400 user_id, statuses=statuses, order_by=order_by,
401 401 order_dir=order_dir)
402 402
403 403 if length:
404 404 pull_requests = q.limit(length).offset(offset).all()
405 405 else:
406 406 pull_requests = q.all()
407 407
408 408 return pull_requests
409 409
410 410 def get_versions(self, pull_request):
411 411 """
412 412 returns version of pull request sorted by ID descending
413 413 """
414 414 return PullRequestVersion.query()\
415 415 .filter(PullRequestVersion.pull_request == pull_request)\
416 416 .order_by(PullRequestVersion.pull_request_version_id.asc())\
417 417 .all()
418 418
419 419 def create(self, created_by, source_repo, source_ref, target_repo,
420 420 target_ref, revisions, reviewers, title, description=None,
421 421 reviewer_data=None):
422 422
423 423 created_by_user = self._get_user(created_by)
424 424 source_repo = self._get_repo(source_repo)
425 425 target_repo = self._get_repo(target_repo)
426 426
427 427 pull_request = PullRequest()
428 428 pull_request.source_repo = source_repo
429 429 pull_request.source_ref = source_ref
430 430 pull_request.target_repo = target_repo
431 431 pull_request.target_ref = target_ref
432 432 pull_request.revisions = revisions
433 433 pull_request.title = title
434 434 pull_request.description = description
435 435 pull_request.author = created_by_user
436 436 pull_request.reviewer_data = reviewer_data
437 437
438 438 Session().add(pull_request)
439 439 Session().flush()
440 440
441 441 reviewer_ids = set()
442 442 # members / reviewers
443 443 for reviewer_object in reviewers:
444 444 user_id, reasons, mandatory = reviewer_object
445 445 user = self._get_user(user_id)
446 446
447 447 # skip duplicates
448 448 if user.user_id in reviewer_ids:
449 449 continue
450 450
451 451 reviewer_ids.add(user.user_id)
452 452
453 453 reviewer = PullRequestReviewers()
454 454 reviewer.user = user
455 455 reviewer.pull_request = pull_request
456 456 reviewer.reasons = reasons
457 457 reviewer.mandatory = mandatory
458 458 Session().add(reviewer)
459 459
460 460 # Set approval status to "Under Review" for all commits which are
461 461 # part of this pull request.
462 462 ChangesetStatusModel().set_status(
463 463 repo=target_repo,
464 464 status=ChangesetStatus.STATUS_UNDER_REVIEW,
465 465 user=created_by_user,
466 466 pull_request=pull_request
467 467 )
468 468
469 469 self.notify_reviewers(pull_request, reviewer_ids)
470 470 self._trigger_pull_request_hook(
471 471 pull_request, created_by_user, 'create')
472 472
473 473 creation_data = pull_request.get_api_data(with_merge_state=False)
474 474 self._log_audit_action(
475 475 'repo.pull_request.create', {'data': creation_data},
476 476 created_by_user, pull_request)
477 477
478 478 return pull_request
479 479
480 480 def _trigger_pull_request_hook(self, pull_request, user, action):
481 481 pull_request = self.__get_pull_request(pull_request)
482 482 target_scm = pull_request.target_repo.scm_instance()
483 483 if action == 'create':
484 484 trigger_hook = hooks_utils.trigger_log_create_pull_request_hook
485 485 elif action == 'merge':
486 486 trigger_hook = hooks_utils.trigger_log_merge_pull_request_hook
487 487 elif action == 'close':
488 488 trigger_hook = hooks_utils.trigger_log_close_pull_request_hook
489 489 elif action == 'review_status_change':
490 490 trigger_hook = hooks_utils.trigger_log_review_pull_request_hook
491 491 elif action == 'update':
492 492 trigger_hook = hooks_utils.trigger_log_update_pull_request_hook
493 493 else:
494 494 return
495 495
496 496 trigger_hook(
497 497 username=user.username,
498 498 repo_name=pull_request.target_repo.repo_name,
499 499 repo_alias=target_scm.alias,
500 500 pull_request=pull_request)
501 501
502 502 def _get_commit_ids(self, pull_request):
503 503 """
504 504 Return the commit ids of the merged pull request.
505 505
506 506 This method is not dealing correctly yet with the lack of autoupdates
507 507 nor with the implicit target updates.
508 508 For example: if a commit in the source repo is already in the target it
509 509 will be reported anyways.
510 510 """
511 511 merge_rev = pull_request.merge_rev
512 512 if merge_rev is None:
513 513 raise ValueError('This pull request was not merged yet')
514 514
515 515 commit_ids = list(pull_request.revisions)
516 516 if merge_rev not in commit_ids:
517 517 commit_ids.append(merge_rev)
518 518
519 519 return commit_ids
520 520
521 521 def merge(self, pull_request, user, extras):
522 522 log.debug("Merging pull request %s", pull_request.pull_request_id)
523 523 merge_state = self._merge_pull_request(pull_request, user, extras)
524 524 if merge_state.executed:
525 525 log.debug(
526 526 "Merge was successful, updating the pull request comments.")
527 527 self._comment_and_close_pr(pull_request, user, merge_state)
528 528
529 529 self._log_audit_action(
530 530 'repo.pull_request.merge',
531 531 {'merge_state': merge_state.__dict__},
532 532 user, pull_request)
533 533
534 534 else:
535 535 log.warn("Merge failed, not updating the pull request.")
536 536 return merge_state
537 537
538 538 def _merge_pull_request(self, pull_request, user, extras):
539 539 target_vcs = pull_request.target_repo.scm_instance()
540 540 source_vcs = pull_request.source_repo.scm_instance()
541 541 target_ref = self._refresh_reference(
542 542 pull_request.target_ref_parts, target_vcs)
543 543
544 544 message = _(
545 545 'Merge pull request #%(pr_id)s from '
546 546 '%(source_repo)s %(source_ref_name)s\n\n %(pr_title)s') % {
547 547 'pr_id': pull_request.pull_request_id,
548 548 'source_repo': source_vcs.name,
549 549 'source_ref_name': pull_request.source_ref_parts.name,
550 550 'pr_title': pull_request.title
551 551 }
552 552
553 553 workspace_id = self._workspace_id(pull_request)
554 554 use_rebase = self._use_rebase_for_merging(pull_request)
555 555
556 556 callback_daemon, extras = prepare_callback_daemon(
557 557 extras, protocol=vcs_settings.HOOKS_PROTOCOL,
558 558 use_direct_calls=vcs_settings.HOOKS_DIRECT_CALLS)
559 559
560 560 with callback_daemon:
561 561 # TODO: johbo: Implement a clean way to run a config_override
562 562 # for a single call.
563 563 target_vcs.config.set(
564 564 'rhodecode', 'RC_SCM_DATA', json.dumps(extras))
565 565 merge_state = target_vcs.merge(
566 566 target_ref, source_vcs, pull_request.source_ref_parts,
567 567 workspace_id, user_name=user.username,
568 568 user_email=user.email, message=message, use_rebase=use_rebase)
569 569 return merge_state
570 570
571 571 def _comment_and_close_pr(self, pull_request, user, merge_state):
572 572 pull_request.merge_rev = merge_state.merge_ref.commit_id
573 573 pull_request.updated_on = datetime.datetime.now()
574 574
575 575 CommentsModel().create(
576 576 text=unicode(_('Pull request merged and closed')),
577 577 repo=pull_request.target_repo.repo_id,
578 578 user=user.user_id,
579 579 pull_request=pull_request.pull_request_id,
580 580 f_path=None,
581 581 line_no=None,
582 582 closing_pr=True
583 583 )
584 584
585 585 Session().add(pull_request)
586 586 Session().flush()
587 587 # TODO: paris: replace invalidation with less radical solution
588 588 ScmModel().mark_for_invalidation(
589 589 pull_request.target_repo.repo_name)
590 590 self._trigger_pull_request_hook(pull_request, user, 'merge')
591 591
592 592 def has_valid_update_type(self, pull_request):
593 593 source_ref_type = pull_request.source_ref_parts.type
594 594 return source_ref_type in ['book', 'branch', 'tag']
595 595
596 596 def update_commits(self, pull_request):
597 597 """
598 598 Get the updated list of commits for the pull request
599 599 and return the new pull request version and the list
600 600 of commits processed by this update action
601 601 """
602 602 pull_request = self.__get_pull_request(pull_request)
603 603 source_ref_type = pull_request.source_ref_parts.type
604 604 source_ref_name = pull_request.source_ref_parts.name
605 605 source_ref_id = pull_request.source_ref_parts.commit_id
606 606
607 607 target_ref_type = pull_request.target_ref_parts.type
608 608 target_ref_name = pull_request.target_ref_parts.name
609 609 target_ref_id = pull_request.target_ref_parts.commit_id
610 610
611 611 if not self.has_valid_update_type(pull_request):
612 612 log.debug(
613 613 "Skipping update of pull request %s due to ref type: %s",
614 614 pull_request, source_ref_type)
615 615 return UpdateResponse(
616 616 executed=False,
617 617 reason=UpdateFailureReason.WRONG_REF_TYPE,
618 618 old=pull_request, new=None, changes=None,
619 619 source_changed=False, target_changed=False)
620 620
621 621 # source repo
622 622 source_repo = pull_request.source_repo.scm_instance()
623 623 try:
624 624 source_commit = source_repo.get_commit(commit_id=source_ref_name)
625 625 except CommitDoesNotExistError:
626 626 return UpdateResponse(
627 627 executed=False,
628 628 reason=UpdateFailureReason.MISSING_SOURCE_REF,
629 629 old=pull_request, new=None, changes=None,
630 630 source_changed=False, target_changed=False)
631 631
632 632 source_changed = source_ref_id != source_commit.raw_id
633 633
634 634 # target repo
635 635 target_repo = pull_request.target_repo.scm_instance()
636 636 try:
637 637 target_commit = target_repo.get_commit(commit_id=target_ref_name)
638 638 except CommitDoesNotExistError:
639 639 return UpdateResponse(
640 640 executed=False,
641 641 reason=UpdateFailureReason.MISSING_TARGET_REF,
642 642 old=pull_request, new=None, changes=None,
643 643 source_changed=False, target_changed=False)
644 644 target_changed = target_ref_id != target_commit.raw_id
645 645
646 646 if not (source_changed or target_changed):
647 647 log.debug("Nothing changed in pull request %s", pull_request)
648 648 return UpdateResponse(
649 649 executed=False,
650 650 reason=UpdateFailureReason.NO_CHANGE,
651 651 old=pull_request, new=None, changes=None,
652 652 source_changed=target_changed, target_changed=source_changed)
653 653
654 654 change_in_found = 'target repo' if target_changed else 'source repo'
655 655 log.debug('Updating pull request because of change in %s detected',
656 656 change_in_found)
657 657
658 658 # Finally there is a need for an update, in case of source change
659 659 # we create a new version, else just an update
660 660 if source_changed:
661 661 pull_request_version = self._create_version_from_snapshot(pull_request)
662 662 self._link_comments_to_version(pull_request_version)
663 663 else:
664 664 try:
665 665 ver = pull_request.versions[-1]
666 666 except IndexError:
667 667 ver = None
668 668
669 669 pull_request.pull_request_version_id = \
670 670 ver.pull_request_version_id if ver else None
671 671 pull_request_version = pull_request
672 672
673 673 try:
674 674 if target_ref_type in ('tag', 'branch', 'book'):
675 675 target_commit = target_repo.get_commit(target_ref_name)
676 676 else:
677 677 target_commit = target_repo.get_commit(target_ref_id)
678 678 except CommitDoesNotExistError:
679 679 return UpdateResponse(
680 680 executed=False,
681 681 reason=UpdateFailureReason.MISSING_TARGET_REF,
682 682 old=pull_request, new=None, changes=None,
683 683 source_changed=source_changed, target_changed=target_changed)
684 684
685 685 # re-compute commit ids
686 686 old_commit_ids = pull_request.revisions
687 687 pre_load = ["author", "branch", "date", "message"]
688 688 commit_ranges = target_repo.compare(
689 689 target_commit.raw_id, source_commit.raw_id, source_repo, merge=True,
690 690 pre_load=pre_load)
691 691
692 692 ancestor = target_repo.get_common_ancestor(
693 693 target_commit.raw_id, source_commit.raw_id, source_repo)
694 694
695 695 pull_request.source_ref = '%s:%s:%s' % (
696 696 source_ref_type, source_ref_name, source_commit.raw_id)
697 697 pull_request.target_ref = '%s:%s:%s' % (
698 698 target_ref_type, target_ref_name, ancestor)
699 699
700 700 pull_request.revisions = [
701 701 commit.raw_id for commit in reversed(commit_ranges)]
702 702 pull_request.updated_on = datetime.datetime.now()
703 703 Session().add(pull_request)
704 704 new_commit_ids = pull_request.revisions
705 705
706 706 old_diff_data, new_diff_data = self._generate_update_diffs(
707 707 pull_request, pull_request_version)
708 708
709 709 # calculate commit and file changes
710 710 changes = self._calculate_commit_id_changes(
711 711 old_commit_ids, new_commit_ids)
712 712 file_changes = self._calculate_file_changes(
713 713 old_diff_data, new_diff_data)
714 714
715 715 # set comments as outdated if DIFFS changed
716 716 CommentsModel().outdate_comments(
717 717 pull_request, old_diff_data=old_diff_data,
718 718 new_diff_data=new_diff_data)
719 719
720 720 commit_changes = (changes.added or changes.removed)
721 721 file_node_changes = (
722 722 file_changes.added or file_changes.modified or file_changes.removed)
723 723 pr_has_changes = commit_changes or file_node_changes
724 724
725 725 # Add an automatic comment to the pull request, in case
726 726 # anything has changed
727 727 if pr_has_changes:
728 728 update_comment = CommentsModel().create(
729 729 text=self._render_update_message(changes, file_changes),
730 730 repo=pull_request.target_repo,
731 731 user=pull_request.author,
732 732 pull_request=pull_request,
733 733 send_email=False, renderer=DEFAULT_COMMENTS_RENDERER)
734 734
735 735 # Update status to "Under Review" for added commits
736 736 for commit_id in changes.added:
737 737 ChangesetStatusModel().set_status(
738 738 repo=pull_request.source_repo,
739 739 status=ChangesetStatus.STATUS_UNDER_REVIEW,
740 740 comment=update_comment,
741 741 user=pull_request.author,
742 742 pull_request=pull_request,
743 743 revision=commit_id)
744 744
745 745 log.debug(
746 746 'Updated pull request %s, added_ids: %s, common_ids: %s, '
747 747 'removed_ids: %s', pull_request.pull_request_id,
748 748 changes.added, changes.common, changes.removed)
749 749 log.debug(
750 750 'Updated pull request with the following file changes: %s',
751 751 file_changes)
752 752
753 753 log.info(
754 754 "Updated pull request %s from commit %s to commit %s, "
755 755 "stored new version %s of this pull request.",
756 756 pull_request.pull_request_id, source_ref_id,
757 757 pull_request.source_ref_parts.commit_id,
758 758 pull_request_version.pull_request_version_id)
759 759 Session().commit()
760 760 self._trigger_pull_request_hook(
761 761 pull_request, pull_request.author, 'update')
762 762
763 763 return UpdateResponse(
764 764 executed=True, reason=UpdateFailureReason.NONE,
765 765 old=pull_request, new=pull_request_version, changes=changes,
766 766 source_changed=source_changed, target_changed=target_changed)
767 767
768 768 def _create_version_from_snapshot(self, pull_request):
769 769 version = PullRequestVersion()
770 770 version.title = pull_request.title
771 771 version.description = pull_request.description
772 772 version.status = pull_request.status
773 773 version.created_on = datetime.datetime.now()
774 774 version.updated_on = pull_request.updated_on
775 775 version.user_id = pull_request.user_id
776 776 version.source_repo = pull_request.source_repo
777 777 version.source_ref = pull_request.source_ref
778 778 version.target_repo = pull_request.target_repo
779 779 version.target_ref = pull_request.target_ref
780 780
781 781 version._last_merge_source_rev = pull_request._last_merge_source_rev
782 782 version._last_merge_target_rev = pull_request._last_merge_target_rev
783 783 version._last_merge_status = pull_request._last_merge_status
784 784 version.shadow_merge_ref = pull_request.shadow_merge_ref
785 785 version.merge_rev = pull_request.merge_rev
786 786 version.reviewer_data = pull_request.reviewer_data
787 787
788 788 version.revisions = pull_request.revisions
789 789 version.pull_request = pull_request
790 790 Session().add(version)
791 791 Session().flush()
792 792
793 793 return version
794 794
795 795 def _generate_update_diffs(self, pull_request, pull_request_version):
796 796
797 797 diff_context = (
798 798 self.DIFF_CONTEXT +
799 799 CommentsModel.needed_extra_diff_context())
800 800
801 801 source_repo = pull_request_version.source_repo
802 802 source_ref_id = pull_request_version.source_ref_parts.commit_id
803 803 target_ref_id = pull_request_version.target_ref_parts.commit_id
804 804 old_diff = self._get_diff_from_pr_or_version(
805 805 source_repo, source_ref_id, target_ref_id, context=diff_context)
806 806
807 807 source_repo = pull_request.source_repo
808 808 source_ref_id = pull_request.source_ref_parts.commit_id
809 809 target_ref_id = pull_request.target_ref_parts.commit_id
810 810
811 811 new_diff = self._get_diff_from_pr_or_version(
812 812 source_repo, source_ref_id, target_ref_id, context=diff_context)
813 813
814 814 old_diff_data = diffs.DiffProcessor(old_diff)
815 815 old_diff_data.prepare()
816 816 new_diff_data = diffs.DiffProcessor(new_diff)
817 817 new_diff_data.prepare()
818 818
819 819 return old_diff_data, new_diff_data
820 820
821 821 def _link_comments_to_version(self, pull_request_version):
822 822 """
823 823 Link all unlinked comments of this pull request to the given version.
824 824
825 825 :param pull_request_version: The `PullRequestVersion` to which
826 826 the comments shall be linked.
827 827
828 828 """
829 829 pull_request = pull_request_version.pull_request
830 830 comments = ChangesetComment.query()\
831 831 .filter(
832 832 # TODO: johbo: Should we query for the repo at all here?
833 833 # Pending decision on how comments of PRs are to be related
834 834 # to either the source repo, the target repo or no repo at all.
835 835 ChangesetComment.repo_id == pull_request.target_repo.repo_id,
836 836 ChangesetComment.pull_request == pull_request,
837 837 ChangesetComment.pull_request_version == None)\
838 838 .order_by(ChangesetComment.comment_id.asc())
839 839
840 840 # TODO: johbo: Find out why this breaks if it is done in a bulk
841 841 # operation.
842 842 for comment in comments:
843 843 comment.pull_request_version_id = (
844 844 pull_request_version.pull_request_version_id)
845 845 Session().add(comment)
846 846
847 847 def _calculate_commit_id_changes(self, old_ids, new_ids):
848 848 added = [x for x in new_ids if x not in old_ids]
849 849 common = [x for x in new_ids if x in old_ids]
850 850 removed = [x for x in old_ids if x not in new_ids]
851 851 total = new_ids
852 852 return ChangeTuple(added, common, removed, total)
853 853
854 854 def _calculate_file_changes(self, old_diff_data, new_diff_data):
855 855
856 856 old_files = OrderedDict()
857 857 for diff_data in old_diff_data.parsed_diff:
858 858 old_files[diff_data['filename']] = md5_safe(diff_data['raw_diff'])
859 859
860 860 added_files = []
861 861 modified_files = []
862 862 removed_files = []
863 863 for diff_data in new_diff_data.parsed_diff:
864 864 new_filename = diff_data['filename']
865 865 new_hash = md5_safe(diff_data['raw_diff'])
866 866
867 867 old_hash = old_files.get(new_filename)
868 868 if not old_hash:
869 869 # file is not present in old diff, means it's added
870 870 added_files.append(new_filename)
871 871 else:
872 872 if new_hash != old_hash:
873 873 modified_files.append(new_filename)
874 874 # now remove a file from old, since we have seen it already
875 875 del old_files[new_filename]
876 876
877 877 # removed files is when there are present in old, but not in NEW,
878 878 # since we remove old files that are present in new diff, left-overs
879 879 # if any should be the removed files
880 880 removed_files.extend(old_files.keys())
881 881
882 882 return FileChangeTuple(added_files, modified_files, removed_files)
883 883
884 884 def _render_update_message(self, changes, file_changes):
885 885 """
886 886 render the message using DEFAULT_COMMENTS_RENDERER (RST renderer),
887 887 so it's always looking the same disregarding on which default
888 888 renderer system is using.
889 889
890 890 :param changes: changes named tuple
891 891 :param file_changes: file changes named tuple
892 892
893 893 """
894 894 new_status = ChangesetStatus.get_status_lbl(
895 895 ChangesetStatus.STATUS_UNDER_REVIEW)
896 896
897 897 changed_files = (
898 898 file_changes.added + file_changes.modified + file_changes.removed)
899 899
900 900 params = {
901 901 'under_review_label': new_status,
902 902 'added_commits': changes.added,
903 903 'removed_commits': changes.removed,
904 904 'changed_files': changed_files,
905 905 'added_files': file_changes.added,
906 906 'modified_files': file_changes.modified,
907 907 'removed_files': file_changes.removed,
908 908 }
909 909 renderer = RstTemplateRenderer()
910 910 return renderer.render('pull_request_update.mako', **params)
911 911
912 912 def edit(self, pull_request, title, description, user):
913 913 pull_request = self.__get_pull_request(pull_request)
914 914 old_data = pull_request.get_api_data(with_merge_state=False)
915 915 if pull_request.is_closed():
916 916 raise ValueError('This pull request is closed')
917 917 if title:
918 918 pull_request.title = title
919 919 pull_request.description = description
920 920 pull_request.updated_on = datetime.datetime.now()
921 921 Session().add(pull_request)
922 922 self._log_audit_action(
923 923 'repo.pull_request.edit', {'old_data': old_data},
924 924 user, pull_request)
925 925
926 926 def update_reviewers(self, pull_request, reviewer_data, user):
927 927 """
928 928 Update the reviewers in the pull request
929 929
930 930 :param pull_request: the pr to update
931 931 :param reviewer_data: list of tuples
932 932 [(user, ['reason1', 'reason2'], mandatory_flag)]
933 933 """
934 934
935 935 reviewers = {}
936 936 for user_id, reasons, mandatory in reviewer_data:
937 937 if isinstance(user_id, (int, basestring)):
938 938 user_id = self._get_user(user_id).user_id
939 939 reviewers[user_id] = {
940 940 'reasons': reasons, 'mandatory': mandatory}
941 941
942 942 reviewers_ids = set(reviewers.keys())
943 943 pull_request = self.__get_pull_request(pull_request)
944 944 current_reviewers = PullRequestReviewers.query()\
945 945 .filter(PullRequestReviewers.pull_request ==
946 946 pull_request).all()
947 947 current_reviewers_ids = set([x.user.user_id for x in current_reviewers])
948 948
949 949 ids_to_add = reviewers_ids.difference(current_reviewers_ids)
950 950 ids_to_remove = current_reviewers_ids.difference(reviewers_ids)
951 951
952 952 log.debug("Adding %s reviewers", ids_to_add)
953 953 log.debug("Removing %s reviewers", ids_to_remove)
954 954 changed = False
955 955 for uid in ids_to_add:
956 956 changed = True
957 957 _usr = self._get_user(uid)
958 958 reviewer = PullRequestReviewers()
959 959 reviewer.user = _usr
960 960 reviewer.pull_request = pull_request
961 961 reviewer.reasons = reviewers[uid]['reasons']
962 962 # NOTE(marcink): mandatory shouldn't be changed now
963 963 # reviewer.mandatory = reviewers[uid]['reasons']
964 964 Session().add(reviewer)
965 965 self._log_audit_action(
966 966 'repo.pull_request.reviewer.add', {'data': reviewer.get_dict()},
967 967 user, pull_request)
968 968
969 969 for uid in ids_to_remove:
970 970 changed = True
971 971 reviewers = PullRequestReviewers.query()\
972 972 .filter(PullRequestReviewers.user_id == uid,
973 973 PullRequestReviewers.pull_request == pull_request)\
974 974 .all()
975 975 # use .all() in case we accidentally added the same person twice
976 976 # this CAN happen due to the lack of DB checks
977 977 for obj in reviewers:
978 978 old_data = obj.get_dict()
979 979 Session().delete(obj)
980 980 self._log_audit_action(
981 981 'repo.pull_request.reviewer.delete',
982 982 {'old_data': old_data}, user, pull_request)
983 983
984 984 if changed:
985 985 pull_request.updated_on = datetime.datetime.now()
986 986 Session().add(pull_request)
987 987
988 988 self.notify_reviewers(pull_request, ids_to_add)
989 989 return ids_to_add, ids_to_remove
990 990
991 991 def get_url(self, pull_request, request=None, permalink=False):
992 992 if not request:
993 993 request = get_current_request()
994 994
995 995 if permalink:
996 996 return request.route_url(
997 997 'pull_requests_global',
998 998 pull_request_id=pull_request.pull_request_id,)
999 999 else:
1000 return request.route_url(
1001 'pullrequest_show',
1000 return request.route_url('pullrequest_show',
1002 1001 repo_name=safe_str(pull_request.target_repo.repo_name),
1003 1002 pull_request_id=pull_request.pull_request_id,)
1004 1003
1005 1004 def get_shadow_clone_url(self, pull_request):
1006 1005 """
1007 1006 Returns qualified url pointing to the shadow repository. If this pull
1008 1007 request is closed there is no shadow repository and ``None`` will be
1009 1008 returned.
1010 1009 """
1011 1010 if pull_request.is_closed():
1012 1011 return None
1013 1012 else:
1014 1013 pr_url = urllib.unquote(self.get_url(pull_request))
1015 1014 return safe_unicode('{pr_url}/repository'.format(pr_url=pr_url))
1016 1015
1017 1016 def notify_reviewers(self, pull_request, reviewers_ids):
1018 1017 # notification to reviewers
1019 1018 if not reviewers_ids:
1020 1019 return
1021 1020
1022 1021 pull_request_obj = pull_request
1023 1022 # get the current participants of this pull request
1024 1023 recipients = reviewers_ids
1025 1024 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST
1026 1025
1027 1026 pr_source_repo = pull_request_obj.source_repo
1028 1027 pr_target_repo = pull_request_obj.target_repo
1029 1028
1030 pr_url = h.url(
1031 'pullrequest_show',
1029 pr_url = h.route_url('pullrequest_show',
1032 1030 repo_name=pr_target_repo.repo_name,
1033 pull_request_id=pull_request_obj.pull_request_id,
1034 qualified=True,)
1031 pull_request_id=pull_request_obj.pull_request_id,)
1035 1032
1036 1033 # set some variables for email notification
1037 1034 pr_target_repo_url = h.route_url(
1038 1035 'repo_summary', repo_name=pr_target_repo.repo_name)
1039 1036
1040 1037 pr_source_repo_url = h.route_url(
1041 1038 'repo_summary', repo_name=pr_source_repo.repo_name)
1042 1039
1043 1040 # pull request specifics
1044 1041 pull_request_commits = [
1045 1042 (x.raw_id, x.message)
1046 1043 for x in map(pr_source_repo.get_commit, pull_request.revisions)]
1047 1044
1048 1045 kwargs = {
1049 1046 'user': pull_request.author,
1050 1047 'pull_request': pull_request_obj,
1051 1048 'pull_request_commits': pull_request_commits,
1052 1049
1053 1050 'pull_request_target_repo': pr_target_repo,
1054 1051 'pull_request_target_repo_url': pr_target_repo_url,
1055 1052
1056 1053 'pull_request_source_repo': pr_source_repo,
1057 1054 'pull_request_source_repo_url': pr_source_repo_url,
1058 1055
1059 1056 'pull_request_url': pr_url,
1060 1057 }
1061 1058
1062 1059 # pre-generate the subject for notification itself
1063 1060 (subject,
1064 1061 _h, _e, # we don't care about those
1065 1062 body_plaintext) = EmailNotificationModel().render_email(
1066 1063 notification_type, **kwargs)
1067 1064
1068 1065 # create notification objects, and emails
1069 1066 NotificationModel().create(
1070 1067 created_by=pull_request.author,
1071 1068 notification_subject=subject,
1072 1069 notification_body=body_plaintext,
1073 1070 notification_type=notification_type,
1074 1071 recipients=recipients,
1075 1072 email_kwargs=kwargs,
1076 1073 )
1077 1074
1078 1075 def delete(self, pull_request, user):
1079 1076 pull_request = self.__get_pull_request(pull_request)
1080 1077 old_data = pull_request.get_api_data(with_merge_state=False)
1081 1078 self._cleanup_merge_workspace(pull_request)
1082 1079 self._log_audit_action(
1083 1080 'repo.pull_request.delete', {'old_data': old_data},
1084 1081 user, pull_request)
1085 1082 Session().delete(pull_request)
1086 1083
1087 1084 def close_pull_request(self, pull_request, user):
1088 1085 pull_request = self.__get_pull_request(pull_request)
1089 1086 self._cleanup_merge_workspace(pull_request)
1090 1087 pull_request.status = PullRequest.STATUS_CLOSED
1091 1088 pull_request.updated_on = datetime.datetime.now()
1092 1089 Session().add(pull_request)
1093 1090 self._trigger_pull_request_hook(
1094 1091 pull_request, pull_request.author, 'close')
1095 1092 self._log_audit_action(
1096 1093 'repo.pull_request.close', {}, user, pull_request)
1097 1094
1098 1095 def close_pull_request_with_comment(
1099 1096 self, pull_request, user, repo, message=None):
1100 1097
1101 1098 pull_request_review_status = pull_request.calculated_review_status()
1102 1099
1103 1100 if pull_request_review_status == ChangesetStatus.STATUS_APPROVED:
1104 1101 # approved only if we have voting consent
1105 1102 status = ChangesetStatus.STATUS_APPROVED
1106 1103 else:
1107 1104 status = ChangesetStatus.STATUS_REJECTED
1108 1105 status_lbl = ChangesetStatus.get_status_lbl(status)
1109 1106
1110 1107 default_message = (
1111 1108 _('Closing with status change {transition_icon} {status}.')
1112 1109 ).format(transition_icon='>', status=status_lbl)
1113 1110 text = message or default_message
1114 1111
1115 1112 # create a comment, and link it to new status
1116 1113 comment = CommentsModel().create(
1117 1114 text=text,
1118 1115 repo=repo.repo_id,
1119 1116 user=user.user_id,
1120 1117 pull_request=pull_request.pull_request_id,
1121 1118 status_change=status_lbl,
1122 1119 status_change_type=status,
1123 1120 closing_pr=True
1124 1121 )
1125 1122
1126 1123 # calculate old status before we change it
1127 1124 old_calculated_status = pull_request.calculated_review_status()
1128 1125 ChangesetStatusModel().set_status(
1129 1126 repo.repo_id,
1130 1127 status,
1131 1128 user.user_id,
1132 1129 comment=comment,
1133 1130 pull_request=pull_request.pull_request_id
1134 1131 )
1135 1132
1136 1133 Session().flush()
1137 1134 events.trigger(events.PullRequestCommentEvent(pull_request, comment))
1138 1135 # we now calculate the status of pull request again, and based on that
1139 1136 # calculation trigger status change. This might happen in cases
1140 1137 # that non-reviewer admin closes a pr, which means his vote doesn't
1141 1138 # change the status, while if he's a reviewer this might change it.
1142 1139 calculated_status = pull_request.calculated_review_status()
1143 1140 if old_calculated_status != calculated_status:
1144 1141 self._trigger_pull_request_hook(
1145 1142 pull_request, user, 'review_status_change')
1146 1143
1147 1144 # finally close the PR
1148 1145 PullRequestModel().close_pull_request(
1149 1146 pull_request.pull_request_id, user)
1150 1147
1151 1148 return comment, status
1152 1149
1153 1150 def merge_status(self, pull_request):
1154 1151 if not self._is_merge_enabled(pull_request):
1155 1152 return False, _('Server-side pull request merging is disabled.')
1156 1153 if pull_request.is_closed():
1157 1154 return False, _('This pull request is closed.')
1158 1155 merge_possible, msg = self._check_repo_requirements(
1159 1156 target=pull_request.target_repo, source=pull_request.source_repo)
1160 1157 if not merge_possible:
1161 1158 return merge_possible, msg
1162 1159
1163 1160 try:
1164 1161 resp = self._try_merge(pull_request)
1165 1162 log.debug("Merge response: %s", resp)
1166 1163 status = resp.possible, self.merge_status_message(
1167 1164 resp.failure_reason)
1168 1165 except NotImplementedError:
1169 1166 status = False, _('Pull request merging is not supported.')
1170 1167
1171 1168 return status
1172 1169
1173 1170 def _check_repo_requirements(self, target, source):
1174 1171 """
1175 1172 Check if `target` and `source` have compatible requirements.
1176 1173
1177 1174 Currently this is just checking for largefiles.
1178 1175 """
1179 1176 target_has_largefiles = self._has_largefiles(target)
1180 1177 source_has_largefiles = self._has_largefiles(source)
1181 1178 merge_possible = True
1182 1179 message = u''
1183 1180
1184 1181 if target_has_largefiles != source_has_largefiles:
1185 1182 merge_possible = False
1186 1183 if source_has_largefiles:
1187 1184 message = _(
1188 1185 'Target repository large files support is disabled.')
1189 1186 else:
1190 1187 message = _(
1191 1188 'Source repository large files support is disabled.')
1192 1189
1193 1190 return merge_possible, message
1194 1191
1195 1192 def _has_largefiles(self, repo):
1196 1193 largefiles_ui = VcsSettingsModel(repo=repo).get_ui_settings(
1197 1194 'extensions', 'largefiles')
1198 1195 return largefiles_ui and largefiles_ui[0].active
1199 1196
1200 1197 def _try_merge(self, pull_request):
1201 1198 """
1202 1199 Try to merge the pull request and return the merge status.
1203 1200 """
1204 1201 log.debug(
1205 1202 "Trying out if the pull request %s can be merged.",
1206 1203 pull_request.pull_request_id)
1207 1204 target_vcs = pull_request.target_repo.scm_instance()
1208 1205
1209 1206 # Refresh the target reference.
1210 1207 try:
1211 1208 target_ref = self._refresh_reference(
1212 1209 pull_request.target_ref_parts, target_vcs)
1213 1210 except CommitDoesNotExistError:
1214 1211 merge_state = MergeResponse(
1215 1212 False, False, None, MergeFailureReason.MISSING_TARGET_REF)
1216 1213 return merge_state
1217 1214
1218 1215 target_locked = pull_request.target_repo.locked
1219 1216 if target_locked and target_locked[0]:
1220 1217 log.debug("The target repository is locked.")
1221 1218 merge_state = MergeResponse(
1222 1219 False, False, None, MergeFailureReason.TARGET_IS_LOCKED)
1223 1220 elif self._needs_merge_state_refresh(pull_request, target_ref):
1224 1221 log.debug("Refreshing the merge status of the repository.")
1225 1222 merge_state = self._refresh_merge_state(
1226 1223 pull_request, target_vcs, target_ref)
1227 1224 else:
1228 1225 possible = pull_request.\
1229 1226 _last_merge_status == MergeFailureReason.NONE
1230 1227 merge_state = MergeResponse(
1231 1228 possible, False, None, pull_request._last_merge_status)
1232 1229
1233 1230 return merge_state
1234 1231
1235 1232 def _refresh_reference(self, reference, vcs_repository):
1236 1233 if reference.type in ('branch', 'book'):
1237 1234 name_or_id = reference.name
1238 1235 else:
1239 1236 name_or_id = reference.commit_id
1240 1237 refreshed_commit = vcs_repository.get_commit(name_or_id)
1241 1238 refreshed_reference = Reference(
1242 1239 reference.type, reference.name, refreshed_commit.raw_id)
1243 1240 return refreshed_reference
1244 1241
1245 1242 def _needs_merge_state_refresh(self, pull_request, target_reference):
1246 1243 return not(
1247 1244 pull_request.revisions and
1248 1245 pull_request.revisions[0] == pull_request._last_merge_source_rev and
1249 1246 target_reference.commit_id == pull_request._last_merge_target_rev)
1250 1247
1251 1248 def _refresh_merge_state(self, pull_request, target_vcs, target_reference):
1252 1249 workspace_id = self._workspace_id(pull_request)
1253 1250 source_vcs = pull_request.source_repo.scm_instance()
1254 1251 use_rebase = self._use_rebase_for_merging(pull_request)
1255 1252 merge_state = target_vcs.merge(
1256 1253 target_reference, source_vcs, pull_request.source_ref_parts,
1257 1254 workspace_id, dry_run=True, use_rebase=use_rebase)
1258 1255
1259 1256 # Do not store the response if there was an unknown error.
1260 1257 if merge_state.failure_reason != MergeFailureReason.UNKNOWN:
1261 1258 pull_request._last_merge_source_rev = \
1262 1259 pull_request.source_ref_parts.commit_id
1263 1260 pull_request._last_merge_target_rev = target_reference.commit_id
1264 1261 pull_request._last_merge_status = merge_state.failure_reason
1265 1262 pull_request.shadow_merge_ref = merge_state.merge_ref
1266 1263 Session().add(pull_request)
1267 1264 Session().commit()
1268 1265
1269 1266 return merge_state
1270 1267
1271 1268 def _workspace_id(self, pull_request):
1272 1269 workspace_id = 'pr-%s' % pull_request.pull_request_id
1273 1270 return workspace_id
1274 1271
1275 1272 def merge_status_message(self, status_code):
1276 1273 """
1277 1274 Return a human friendly error message for the given merge status code.
1278 1275 """
1279 1276 return self.MERGE_STATUS_MESSAGES[status_code]
1280 1277
1281 1278 def generate_repo_data(self, repo, commit_id=None, branch=None,
1282 1279 bookmark=None):
1283 1280 all_refs, selected_ref = \
1284 1281 self._get_repo_pullrequest_sources(
1285 1282 repo.scm_instance(), commit_id=commit_id,
1286 1283 branch=branch, bookmark=bookmark)
1287 1284
1288 1285 refs_select2 = []
1289 1286 for element in all_refs:
1290 1287 children = [{'id': x[0], 'text': x[1]} for x in element[0]]
1291 1288 refs_select2.append({'text': element[1], 'children': children})
1292 1289
1293 1290 return {
1294 1291 'user': {
1295 1292 'user_id': repo.user.user_id,
1296 1293 'username': repo.user.username,
1297 1294 'firstname': repo.user.firstname,
1298 1295 'lastname': repo.user.lastname,
1299 1296 'gravatar_link': h.gravatar_url(repo.user.email, 14),
1300 1297 },
1301 1298 'description': h.chop_at_smart(repo.description, '\n'),
1302 1299 'refs': {
1303 1300 'all_refs': all_refs,
1304 1301 'selected_ref': selected_ref,
1305 1302 'select2_refs': refs_select2
1306 1303 }
1307 1304 }
1308 1305
1309 1306 def generate_pullrequest_title(self, source, source_ref, target):
1310 1307 return u'{source}#{at_ref} to {target}'.format(
1311 1308 source=source,
1312 1309 at_ref=source_ref,
1313 1310 target=target,
1314 1311 )
1315 1312
1316 1313 def _cleanup_merge_workspace(self, pull_request):
1317 1314 # Merging related cleanup
1318 1315 target_scm = pull_request.target_repo.scm_instance()
1319 1316 workspace_id = 'pr-%s' % pull_request.pull_request_id
1320 1317
1321 1318 try:
1322 1319 target_scm.cleanup_merge_workspace(workspace_id)
1323 1320 except NotImplementedError:
1324 1321 pass
1325 1322
1326 1323 def _get_repo_pullrequest_sources(
1327 1324 self, repo, commit_id=None, branch=None, bookmark=None):
1328 1325 """
1329 1326 Return a structure with repo's interesting commits, suitable for
1330 1327 the selectors in pullrequest controller
1331 1328
1332 1329 :param commit_id: a commit that must be in the list somehow
1333 1330 and selected by default
1334 1331 :param branch: a branch that must be in the list and selected
1335 1332 by default - even if closed
1336 1333 :param bookmark: a bookmark that must be in the list and selected
1337 1334 """
1338 1335
1339 1336 commit_id = safe_str(commit_id) if commit_id else None
1340 1337 branch = safe_str(branch) if branch else None
1341 1338 bookmark = safe_str(bookmark) if bookmark else None
1342 1339
1343 1340 selected = None
1344 1341
1345 1342 # order matters: first source that has commit_id in it will be selected
1346 1343 sources = []
1347 1344 sources.append(('book', repo.bookmarks.items(), _('Bookmarks'), bookmark))
1348 1345 sources.append(('branch', repo.branches.items(), _('Branches'), branch))
1349 1346
1350 1347 if commit_id:
1351 1348 ref_commit = (h.short_id(commit_id), commit_id)
1352 1349 sources.append(('rev', [ref_commit], _('Commit IDs'), commit_id))
1353 1350
1354 1351 sources.append(
1355 1352 ('branch', repo.branches_closed.items(), _('Closed Branches'), branch),
1356 1353 )
1357 1354
1358 1355 groups = []
1359 1356 for group_key, ref_list, group_name, match in sources:
1360 1357 group_refs = []
1361 1358 for ref_name, ref_id in ref_list:
1362 1359 ref_key = '%s:%s:%s' % (group_key, ref_name, ref_id)
1363 1360 group_refs.append((ref_key, ref_name))
1364 1361
1365 1362 if not selected:
1366 1363 if set([commit_id, match]) & set([ref_id, ref_name]):
1367 1364 selected = ref_key
1368 1365
1369 1366 if group_refs:
1370 1367 groups.append((group_refs, group_name))
1371 1368
1372 1369 if not selected:
1373 1370 ref = commit_id or branch or bookmark
1374 1371 if ref:
1375 1372 raise CommitDoesNotExistError(
1376 1373 'No commit refs could be found matching: %s' % ref)
1377 1374 elif repo.DEFAULT_BRANCH_NAME in repo.branches:
1378 1375 selected = 'branch:%s:%s' % (
1379 1376 repo.DEFAULT_BRANCH_NAME,
1380 1377 repo.branches[repo.DEFAULT_BRANCH_NAME]
1381 1378 )
1382 1379 elif repo.commit_ids:
1383 1380 rev = repo.commit_ids[0]
1384 1381 selected = 'rev:%s:%s' % (rev, rev)
1385 1382 else:
1386 1383 raise EmptyRepositoryError()
1387 1384 return groups, selected
1388 1385
1389 1386 def get_diff(self, source_repo, source_ref_id, target_ref_id, context=DIFF_CONTEXT):
1390 1387 return self._get_diff_from_pr_or_version(
1391 1388 source_repo, source_ref_id, target_ref_id, context=context)
1392 1389
1393 1390 def _get_diff_from_pr_or_version(
1394 1391 self, source_repo, source_ref_id, target_ref_id, context):
1395 1392 target_commit = source_repo.get_commit(
1396 1393 commit_id=safe_str(target_ref_id))
1397 1394 source_commit = source_repo.get_commit(
1398 1395 commit_id=safe_str(source_ref_id))
1399 1396 if isinstance(source_repo, Repository):
1400 1397 vcs_repo = source_repo.scm_instance()
1401 1398 else:
1402 1399 vcs_repo = source_repo
1403 1400
1404 1401 # TODO: johbo: In the context of an update, we cannot reach
1405 1402 # the old commit anymore with our normal mechanisms. It needs
1406 1403 # some sort of special support in the vcs layer to avoid this
1407 1404 # workaround.
1408 1405 if (source_commit.raw_id == vcs_repo.EMPTY_COMMIT_ID and
1409 1406 vcs_repo.alias == 'git'):
1410 1407 source_commit.raw_id = safe_str(source_ref_id)
1411 1408
1412 1409 log.debug('calculating diff between '
1413 1410 'source_ref:%s and target_ref:%s for repo `%s`',
1414 1411 target_ref_id, source_ref_id,
1415 1412 safe_unicode(vcs_repo.path))
1416 1413
1417 1414 vcs_diff = vcs_repo.get_diff(
1418 1415 commit1=target_commit, commit2=source_commit, context=context)
1419 1416 return vcs_diff
1420 1417
1421 1418 def _is_merge_enabled(self, pull_request):
1422 1419 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1423 1420 settings = settings_model.get_general_settings()
1424 1421 return settings.get('rhodecode_pr_merge_enabled', False)
1425 1422
1426 1423 def _use_rebase_for_merging(self, pull_request):
1427 1424 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1428 1425 settings = settings_model.get_general_settings()
1429 1426 return settings.get('rhodecode_hg_use_rebase_for_merging', False)
1430 1427
1431 1428 def _log_audit_action(self, action, action_data, user, pull_request):
1432 1429 audit_logger.store(
1433 1430 action=action,
1434 1431 action_data=action_data,
1435 1432 user=user,
1436 1433 repo=pull_request.target_repo)
1437 1434
1438 1435 def get_reviewer_functions(self):
1439 1436 """
1440 1437 Fetches functions for validation and fetching default reviewers.
1441 1438 If available we use the EE package, else we fallback to CE
1442 1439 package functions
1443 1440 """
1444 1441 try:
1445 1442 from rc_reviewers.utils import get_default_reviewers_data
1446 1443 from rc_reviewers.utils import validate_default_reviewers
1447 1444 except ImportError:
1448 1445 from rhodecode.apps.repository.utils import \
1449 1446 get_default_reviewers_data
1450 1447 from rhodecode.apps.repository.utils import \
1451 1448 validate_default_reviewers
1452 1449
1453 1450 return get_default_reviewers_data, validate_default_reviewers
1454 1451
1455 1452
1456 1453 class MergeCheck(object):
1457 1454 """
1458 1455 Perform Merge Checks and returns a check object which stores information
1459 1456 about merge errors, and merge conditions
1460 1457 """
1461 1458 TODO_CHECK = 'todo'
1462 1459 PERM_CHECK = 'perm'
1463 1460 REVIEW_CHECK = 'review'
1464 1461 MERGE_CHECK = 'merge'
1465 1462
1466 1463 def __init__(self):
1467 1464 self.review_status = None
1468 1465 self.merge_possible = None
1469 1466 self.merge_msg = ''
1470 1467 self.failed = None
1471 1468 self.errors = []
1472 1469 self.error_details = OrderedDict()
1473 1470
1474 1471 def push_error(self, error_type, message, error_key, details):
1475 1472 self.failed = True
1476 1473 self.errors.append([error_type, message])
1477 1474 self.error_details[error_key] = dict(
1478 1475 details=details,
1479 1476 error_type=error_type,
1480 1477 message=message
1481 1478 )
1482 1479
1483 1480 @classmethod
1484 1481 def validate(cls, pull_request, user, fail_early=False, translator=None):
1485 1482 # if migrated to pyramid...
1486 1483 # _ = lambda: translator or _ # use passed in translator if any
1487 1484
1488 1485 merge_check = cls()
1489 1486
1490 1487 # permissions to merge
1491 1488 user_allowed_to_merge = PullRequestModel().check_user_merge(
1492 1489 pull_request, user)
1493 1490 if not user_allowed_to_merge:
1494 1491 log.debug("MergeCheck: cannot merge, approval is pending.")
1495 1492
1496 1493 msg = _('User `{}` not allowed to perform merge.').format(user.username)
1497 1494 merge_check.push_error('error', msg, cls.PERM_CHECK, user.username)
1498 1495 if fail_early:
1499 1496 return merge_check
1500 1497
1501 1498 # review status, must be always present
1502 1499 review_status = pull_request.calculated_review_status()
1503 1500 merge_check.review_status = review_status
1504 1501
1505 1502 status_approved = review_status == ChangesetStatus.STATUS_APPROVED
1506 1503 if not status_approved:
1507 1504 log.debug("MergeCheck: cannot merge, approval is pending.")
1508 1505
1509 1506 msg = _('Pull request reviewer approval is pending.')
1510 1507
1511 1508 merge_check.push_error(
1512 1509 'warning', msg, cls.REVIEW_CHECK, review_status)
1513 1510
1514 1511 if fail_early:
1515 1512 return merge_check
1516 1513
1517 1514 # left over TODOs
1518 1515 todos = CommentsModel().get_unresolved_todos(pull_request)
1519 1516 if todos:
1520 1517 log.debug("MergeCheck: cannot merge, {} "
1521 1518 "unresolved todos left.".format(len(todos)))
1522 1519
1523 1520 if len(todos) == 1:
1524 1521 msg = _('Cannot merge, {} TODO still not resolved.').format(
1525 1522 len(todos))
1526 1523 else:
1527 1524 msg = _('Cannot merge, {} TODOs still not resolved.').format(
1528 1525 len(todos))
1529 1526
1530 1527 merge_check.push_error('warning', msg, cls.TODO_CHECK, todos)
1531 1528
1532 1529 if fail_early:
1533 1530 return merge_check
1534 1531
1535 1532 # merge possible
1536 1533 merge_status, msg = PullRequestModel().merge_status(pull_request)
1537 1534 merge_check.merge_possible = merge_status
1538 1535 merge_check.merge_msg = msg
1539 1536 if not merge_status:
1540 1537 log.debug(
1541 1538 "MergeCheck: cannot merge, pull request merge not possible.")
1542 1539 merge_check.push_error('warning', msg, cls.MERGE_CHECK, None)
1543 1540
1544 1541 if fail_early:
1545 1542 return merge_check
1546 1543
1547 1544 return merge_check
1548 1545
1549 1546
1550 1547 ChangeTuple = namedtuple('ChangeTuple',
1551 1548 ['added', 'common', 'removed', 'total'])
1552 1549
1553 1550 FileChangeTuple = namedtuple('FileChangeTuple',
1554 1551 ['added', 'modified', 'removed'])
@@ -1,142 +1,142 b''
1 1 ## small box that displays changed/added/removed details fetched by AJAX
2 2 <%namespace name="base" file="/base/base.mako"/>
3 3
4 4
5 5 % if c.prev_page:
6 6 <tr>
7 7 <td colspan="9" class="load-more-commits">
8 8 <a class="prev-commits" href="#loadPrevCommits" onclick="commitsController.loadPrev(this, ${c.prev_page}, '${c.branch_name}');return false">
9 9 ${_('load previous')}
10 10 </a>
11 11 </td>
12 12 </tr>
13 13 % endif
14 14
15 15 % for cnt,commit in enumerate(c.pagination):
16 16 <tr id="sha_${commit.raw_id}" class="changelogRow container ${'tablerow%s' % (cnt%2)}">
17 17
18 18 <td class="td-checkbox">
19 19 ${h.checkbox(commit.raw_id,class_="commit-range")}
20 20 </td>
21 21 <td class="td-status">
22 22
23 23 %if c.statuses.get(commit.raw_id):
24 24 <div class="changeset-status-ico">
25 25 %if c.statuses.get(commit.raw_id)[2]:
26 <a class="tooltip" title="${_('Commit status: %s\nClick to open associated pull request #%s') % (h.commit_status_lbl(c.statuses.get(commit.raw_id)[0]), c.statuses.get(commit.raw_id)[2])}" href="${h.url('pullrequest_show',repo_name=c.statuses.get(commit.raw_id)[3],pull_request_id=c.statuses.get(commit.raw_id)[2])}">
26 <a class="tooltip" title="${_('Commit status: %s\nClick to open associated pull request #%s') % (h.commit_status_lbl(c.statuses.get(commit.raw_id)[0]), c.statuses.get(commit.raw_id)[2])}" href="${h.route_path('pullrequest_show',repo_name=c.statuses.get(commit.raw_id)[3],pull_request_id=c.statuses.get(commit.raw_id)[2])}">
27 27 <div class="${'flag_status %s' % c.statuses.get(commit.raw_id)[0]}"></div>
28 28 </a>
29 29 %else:
30 30 <a class="tooltip" title="${_('Commit status: %s') % h.commit_status_lbl(c.statuses.get(commit.raw_id)[0])}" href="${h.url('changeset_home',repo_name=c.repo_name,revision=commit.raw_id,anchor='comment-%s' % c.comments[commit.raw_id][0].comment_id)}">
31 31 <div class="${'flag_status %s' % c.statuses.get(commit.raw_id)[0]}"></div>
32 32 </a>
33 33 %endif
34 34 </div>
35 35 %else:
36 36 <div class="tooltip flag_status not_reviewed" title="${_('Commit status: Not Reviewed')}"></div>
37 37 %endif
38 38 </td>
39 39 <td class="td-comments comments-col">
40 40 %if c.comments.get(commit.raw_id):
41 41 <a title="${_('Commit has comments')}" href="${h.url('changeset_home',repo_name=c.repo_name,revision=commit.raw_id,anchor='comment-%s' % c.comments[commit.raw_id][0].comment_id)}">
42 42 <i class="icon-comment"></i> ${len(c.comments[commit.raw_id])}
43 43 </a>
44 44 %endif
45 45 </td>
46 46 <td class="td-hash">
47 47 <code>
48 48 <a href="${h.url('changeset_home',repo_name=c.repo_name,revision=commit.raw_id)}">
49 49 <span class="${'commit_hash obsolete' if getattr(commit, 'obsolete', None) else 'commit_hash'}">${h.show_id(commit)}</span>
50 50 </a>
51 51 % if hasattr(commit, 'phase'):
52 52 % if commit.phase != 'public':
53 53 <span class="tag phase-${commit.phase} tooltip" title="${_('Commit phase')}">${commit.phase}</span>
54 54 % endif
55 55 % endif
56 56
57 57 ## obsolete commits
58 58 % if hasattr(commit, 'obsolete'):
59 59 % if commit.obsolete:
60 60 <span class="tag obsolete-${commit.obsolete} tooltip" title="${_('Evolve State')}">${_('obsolete')}</span>
61 61 % endif
62 62 % endif
63 63
64 64 ## hidden commits
65 65 % if hasattr(commit, 'hidden'):
66 66 % if commit.hidden:
67 67 <span class="tag obsolete-${commit.hidden} tooltip" title="${_('Evolve State')}">${_('hidden')}</span>
68 68 % endif
69 69 % endif
70 70
71 71 </code>
72 72 </td>
73 73 <td class="td-message expand_commit" data-commit-id="${commit.raw_id}" title="${_('Expand commit message')}" onclick="commitsController.expandCommit(this); return false">
74 74 <div class="show_more_col">
75 75 <i class="show_more"></i>&nbsp;
76 76 </div>
77 77 </td>
78 78 <td class="td-description mid">
79 79 <div class="log-container truncate-wrap">
80 80 <div class="message truncate" id="c-${commit.raw_id}">${h.urlify_commit_message(commit.message, c.repo_name)}</div>
81 81 </div>
82 82 </td>
83 83
84 84 <td class="td-time">
85 85 ${h.age_component(commit.date)}
86 86 </td>
87 87 <td class="td-user">
88 88 ${base.gravatar_with_user(commit.author)}
89 89 </td>
90 90
91 91 <td class="td-tags tags-col">
92 92 <div id="t-${commit.raw_id}">
93 93
94 94 ## merge
95 95 %if commit.merge:
96 96 <span class="tag mergetag">
97 97 <i class="icon-merge"></i>${_('merge')}
98 98 </span>
99 99 %endif
100 100
101 101 ## branch
102 102 %if commit.branch:
103 103 <span class="tag branchtag" title="${_('Branch %s') % commit.branch}">
104 104 <a href="${h.url('changelog_home',repo_name=c.repo_name,branch=commit.branch)}"><i class="icon-code-fork"></i>${h.shorter(commit.branch)}</a>
105 105 </span>
106 106 %endif
107 107
108 108 ## bookmarks
109 109 %if h.is_hg(c.rhodecode_repo):
110 110 %for book in commit.bookmarks:
111 111 <span class="tag booktag" title="${_('Bookmark %s') % book}">
112 112 <a href="${h.url('files_home',repo_name=c.repo_name,revision=commit.raw_id)}"><i class="icon-bookmark"></i>${h.shorter(book)}</a>
113 113 </span>
114 114 %endfor
115 115 %endif
116 116
117 117 ## tags
118 118 %for tag in commit.tags:
119 119 <span class="tag tagtag" title="${_('Tag %s') % tag}">
120 120 <a href="${h.url('files_home',repo_name=c.repo_name,revision=commit.raw_id)}"><i class="icon-tag"></i>${h.shorter(tag)}</a>
121 121 </span>
122 122 %endfor
123 123
124 124 </div>
125 125 </td>
126 126 </tr>
127 127 % endfor
128 128
129 129 % if c.next_page:
130 130 <tr>
131 131 <td colspan="9" class="load-more-commits">
132 132 <a class="next-commits" href="#loadNextCommits" onclick="commitsController.loadNext(this, ${c.next_page}, '${c.branch_name}');return false">
133 133 ${_('load next')}
134 134 </a>
135 135 </td>
136 136 </tr>
137 137 % endif
138 138 <tr class="chunk-graph-data" style="display:none"
139 139 data-graph='${c.graph_data|n}'
140 140 data-node='${c.prev_page}:${c.next_page}'
141 141 data-commits='${c.graph_commits|n}'>
142 142 </tr> No newline at end of file
@@ -1,405 +1,405 b''
1 1 ## -*- coding: utf-8 -*-
2 2 ## usage:
3 3 ## <%namespace name="comment" file="/changeset/changeset_file_comment.mako"/>
4 4 ## ${comment.comment_block(comment)}
5 5 ##
6 6 <%namespace name="base" file="/base/base.mako"/>
7 7
8 8 <%def name="comment_block(comment, inline=False)">
9 9 <% pr_index_ver = comment.get_index_version(getattr(c, 'versions', [])) %>
10 10 % if inline:
11 11 <% outdated_at_ver = comment.outdated_at_version(getattr(c, 'at_version_num', None)) %>
12 12 % else:
13 13 <% outdated_at_ver = comment.older_than_version(getattr(c, 'at_version_num', None)) %>
14 14 % endif
15 15
16 16
17 17 <div class="comment
18 18 ${'comment-inline' if inline else 'comment-general'}
19 19 ${'comment-outdated' if outdated_at_ver else 'comment-current'}"
20 20 id="comment-${comment.comment_id}"
21 21 line="${comment.line_no}"
22 22 data-comment-id="${comment.comment_id}"
23 23 data-comment-type="${comment.comment_type}"
24 24 data-comment-inline=${h.json.dumps(inline)}
25 25 style="${'display: none;' if outdated_at_ver else ''}">
26 26
27 27 <div class="meta">
28 28 <div class="comment-type-label">
29 29 <div class="comment-label ${comment.comment_type or 'note'}" id="comment-label-${comment.comment_id}">
30 30 % if comment.comment_type == 'todo':
31 31 % if comment.resolved:
32 32 <div class="resolved tooltip" title="${_('Resolved by comment #{}').format(comment.resolved.comment_id)}">
33 33 <a href="#comment-${comment.resolved.comment_id}">${comment.comment_type}</a>
34 34 </div>
35 35 % else:
36 36 <div class="resolved tooltip" style="display: none">
37 37 <span>${comment.comment_type}</span>
38 38 </div>
39 39 <div class="resolve tooltip" onclick="return Rhodecode.comments.createResolutionComment(${comment.comment_id});" title="${_('Click to resolve this comment')}">
40 40 ${comment.comment_type}
41 41 </div>
42 42 % endif
43 43 % else:
44 44 % if comment.resolved_comment:
45 45 fix
46 46 % else:
47 47 ${comment.comment_type or 'note'}
48 48 % endif
49 49 % endif
50 50 </div>
51 51 </div>
52 52
53 53 <div class="author ${'author-inline' if inline else 'author-general'}">
54 54 ${base.gravatar_with_user(comment.author.email, 16)}
55 55 </div>
56 56 <div class="date">
57 57 ${h.age_component(comment.modified_at, time_is_local=True)}
58 58 </div>
59 59 % if inline:
60 60 <span></span>
61 61 % else:
62 62 <div class="status-change">
63 63 % if comment.pull_request:
64 <a href="${h.url('pullrequest_show',repo_name=comment.pull_request.target_repo.repo_name,pull_request_id=comment.pull_request.pull_request_id)}">
64 <a href="${h.route_path('pullrequest_show',repo_name=comment.pull_request.target_repo.repo_name,pull_request_id=comment.pull_request.pull_request_id)}">
65 65 % if comment.status_change:
66 66 ${_('pull request #%s') % comment.pull_request.pull_request_id}:
67 67 % else:
68 68 ${_('pull request #%s') % comment.pull_request.pull_request_id}
69 69 % endif
70 70 </a>
71 71 % else:
72 72 % if comment.status_change:
73 73 ${_('Status change on commit')}:
74 74 % endif
75 75 % endif
76 76 </div>
77 77 % endif
78 78
79 79 % if comment.status_change:
80 80 <div class="${'flag_status %s' % comment.status_change[0].status}"></div>
81 81 <div title="${_('Commit status')}" class="changeset-status-lbl">
82 82 ${comment.status_change[0].status_lbl}
83 83 </div>
84 84 % endif
85 85
86 86 % if comment.resolved_comment:
87 87 <a class="has-spacer-before" href="#comment-${comment.resolved_comment.comment_id}" onclick="Rhodecode.comments.scrollToComment($('#comment-${comment.resolved_comment.comment_id}'), 0, ${h.json.dumps(comment.resolved_comment.outdated)})">
88 88 ${_('resolves comment #{}').format(comment.resolved_comment.comment_id)}
89 89 </a>
90 90 % endif
91 91
92 92 <a class="permalink" href="#comment-${comment.comment_id}"> &para;</a>
93 93
94 94 <div class="comment-links-block">
95 95 % if comment.pull_request and comment.pull_request.author.user_id == comment.author.user_id:
96 96 <span class="tag authortag tooltip" title="${_('Pull request author')}">
97 97 ${_('author')}
98 98 </span>
99 99 |
100 100 % endif
101 101 % if inline:
102 102 <div class="pr-version-inline">
103 103 <a href="${h.url.current(version=comment.pull_request_version_id, anchor='comment-{}'.format(comment.comment_id))}">
104 104 % if outdated_at_ver:
105 105 <code class="pr-version-num" title="${_('Outdated comment from pull request version {0}').format(pr_index_ver)}">
106 106 outdated ${'v{}'.format(pr_index_ver)} |
107 107 </code>
108 108 % elif pr_index_ver:
109 109 <code class="pr-version-num" title="${_('Comment from pull request version {0}').format(pr_index_ver)}">
110 110 ${'v{}'.format(pr_index_ver)} |
111 111 </code>
112 112 % endif
113 113 </a>
114 114 </div>
115 115 % else:
116 116 % if comment.pull_request_version_id and pr_index_ver:
117 117 |
118 118 <div class="pr-version">
119 119 % if comment.outdated:
120 120 <a href="?version=${comment.pull_request_version_id}#comment-${comment.comment_id}">
121 121 ${_('Outdated comment from pull request version {}').format(pr_index_ver)}
122 122 </a>
123 123 % else:
124 124 <div title="${_('Comment from pull request version {0}').format(pr_index_ver)}">
125 <a href="${h.url('pullrequest_show',repo_name=comment.pull_request.target_repo.repo_name,pull_request_id=comment.pull_request.pull_request_id, version=comment.pull_request_version_id)}">
125 <a href="${h.route_path('pullrequest_show',repo_name=comment.pull_request.target_repo.repo_name,pull_request_id=comment.pull_request.pull_request_id, version=comment.pull_request_version_id)}">
126 126 <code class="pr-version-num">
127 127 ${'v{}'.format(pr_index_ver)}
128 128 </code>
129 129 </a>
130 130 </div>
131 131 % endif
132 132 </div>
133 133 % endif
134 134 % endif
135 135
136 136 ## show delete comment if it's not a PR (regular comments) or it's PR that is not closed
137 137 ## only super-admin, repo admin OR comment owner can delete, also hide delete if currently viewed comment is outdated
138 138 %if not outdated_at_ver and (not comment.pull_request or (comment.pull_request and not comment.pull_request.is_closed())):
139 139 ## permissions to delete
140 140 %if h.HasPermissionAny('hg.admin')() or h.HasRepoPermissionAny('repository.admin')(c.repo_name) or comment.author.user_id == c.rhodecode_user.user_id:
141 141 ## TODO: dan: add edit comment here
142 142 <a onclick="return Rhodecode.comments.deleteComment(this);" class="delete-comment"> ${_('Delete')}</a>
143 143 %else:
144 144 <button class="btn-link" disabled="disabled"> ${_('Delete')}</button>
145 145 %endif
146 146 %else:
147 147 <button class="btn-link" disabled="disabled"> ${_('Delete')}</button>
148 148 %endif
149 149
150 150 % if outdated_at_ver:
151 151 | <a onclick="return Rhodecode.comments.prevOutdatedComment(this);" class="prev-comment"> ${_('Prev')}</a>
152 152 | <a onclick="return Rhodecode.comments.nextOutdatedComment(this);" class="next-comment"> ${_('Next')}</a>
153 153 % else:
154 154 | <a onclick="return Rhodecode.comments.prevComment(this);" class="prev-comment"> ${_('Prev')}</a>
155 155 | <a onclick="return Rhodecode.comments.nextComment(this);" class="next-comment"> ${_('Next')}</a>
156 156 % endif
157 157
158 158 </div>
159 159 </div>
160 160 <div class="text">
161 161 ${h.render(comment.text, renderer=comment.renderer, mentions=True)}
162 162 </div>
163 163
164 164 </div>
165 165 </%def>
166 166
167 167 ## generate main comments
168 168 <%def name="generate_comments(comments, include_pull_request=False, is_pull_request=False)">
169 169 <div class="general-comments" id="comments">
170 170 %for comment in comments:
171 171 <div id="comment-tr-${comment.comment_id}">
172 172 ## only render comments that are not from pull request, or from
173 173 ## pull request and a status change
174 174 %if not comment.pull_request or (comment.pull_request and comment.status_change) or include_pull_request:
175 175 ${comment_block(comment)}
176 176 %endif
177 177 </div>
178 178 %endfor
179 179 ## to anchor ajax comments
180 180 <div id="injected_page_comments"></div>
181 181 </div>
182 182 </%def>
183 183
184 184
185 185 <%def name="comments(post_url, cur_status, is_pull_request=False, is_compare=False, change_status=True, form_extras=None)">
186 186
187 187 <div class="comments">
188 188 <%
189 189 if is_pull_request:
190 190 placeholder = _('Leave a comment on this Pull Request.')
191 191 elif is_compare:
192 192 placeholder = _('Leave a comment on {} commits in this range.').format(len(form_extras))
193 193 else:
194 194 placeholder = _('Leave a comment on this Commit.')
195 195 %>
196 196
197 197 % if c.rhodecode_user.username != h.DEFAULT_USER:
198 198 <div class="js-template" id="cb-comment-general-form-template">
199 199 ## template generated for injection
200 200 ${comment_form(form_type='general', review_statuses=c.commit_statuses, form_extras=form_extras)}
201 201 </div>
202 202
203 203 <div id="cb-comment-general-form-placeholder" class="comment-form ac">
204 204 ## inject form here
205 205 </div>
206 206 <script type="text/javascript">
207 207 var lineNo = 'general';
208 208 var resolvesCommentId = null;
209 209 var generalCommentForm = Rhodecode.comments.createGeneralComment(
210 210 lineNo, "${placeholder}", resolvesCommentId);
211 211
212 212 // set custom success callback on rangeCommit
213 213 % if is_compare:
214 214 generalCommentForm.setHandleFormSubmit(function(o) {
215 215 var self = generalCommentForm;
216 216
217 217 var text = self.cm.getValue();
218 218 var status = self.getCommentStatus();
219 219 var commentType = self.getCommentType();
220 220
221 221 if (text === "" && !status) {
222 222 return;
223 223 }
224 224
225 225 // we can pick which commits we want to make the comment by
226 226 // selecting them via click on preview pane, this will alter the hidden inputs
227 227 var cherryPicked = $('#changeset_compare_view_content .compare_select.hl').length > 0;
228 228
229 229 var commitIds = [];
230 230 $('#changeset_compare_view_content .compare_select').each(function(el) {
231 231 var commitId = this.id.replace('row-', '');
232 232 if ($(this).hasClass('hl') || !cherryPicked) {
233 233 $("input[data-commit-id='{0}']".format(commitId)).val(commitId);
234 234 commitIds.push(commitId);
235 235 } else {
236 236 $("input[data-commit-id='{0}']".format(commitId)).val('')
237 237 }
238 238 });
239 239
240 240 self.setActionButtonsDisabled(true);
241 241 self.cm.setOption("readOnly", true);
242 242 var postData = {
243 243 'text': text,
244 244 'changeset_status': status,
245 245 'comment_type': commentType,
246 246 'commit_ids': commitIds,
247 247 'csrf_token': CSRF_TOKEN
248 248 };
249 249
250 250 var submitSuccessCallback = function(o) {
251 251 location.reload(true);
252 252 };
253 253 var submitFailCallback = function(){
254 254 self.resetCommentFormState(text)
255 255 };
256 256 self.submitAjaxPOST(
257 257 self.submitUrl, postData, submitSuccessCallback, submitFailCallback);
258 258 });
259 259 % endif
260 260
261 261
262 262 </script>
263 263 % else:
264 264 ## form state when not logged in
265 265 <div class="comment-form ac">
266 266
267 267 <div class="comment-area">
268 268 <div class="comment-area-header">
269 269 <ul class="nav-links clearfix">
270 270 <li class="active">
271 271 <a class="disabled" href="#edit-btn" disabled="disabled" onclick="return false">${_('Write')}</a>
272 272 </li>
273 273 <li class="">
274 274 <a class="disabled" href="#preview-btn" disabled="disabled" onclick="return false">${_('Preview')}</a>
275 275 </li>
276 276 </ul>
277 277 </div>
278 278
279 279 <div class="comment-area-write" style="display: block;">
280 280 <div id="edit-container">
281 281 <div style="padding: 40px 0">
282 282 ${_('You need to be logged in to leave comments.')}
283 283 <a href="${h.route_path('login', _query={'came_from': h.url.current()})}">${_('Login now')}</a>
284 284 </div>
285 285 </div>
286 286 <div id="preview-container" class="clearfix" style="display: none;">
287 287 <div id="preview-box" class="preview-box"></div>
288 288 </div>
289 289 </div>
290 290
291 291 <div class="comment-area-footer">
292 292 <div class="toolbar">
293 293 <div class="toolbar-text">
294 294 </div>
295 295 </div>
296 296 </div>
297 297 </div>
298 298
299 299 <div class="comment-footer">
300 300 </div>
301 301
302 302 </div>
303 303 % endif
304 304
305 305 <script type="text/javascript">
306 306 bindToggleButtons();
307 307 </script>
308 308 </div>
309 309 </%def>
310 310
311 311
312 312 <%def name="comment_form(form_type, form_id='', lineno_id='{1}', review_statuses=None, form_extras=None)">
313 313 ## comment injected based on assumption that user is logged in
314 314
315 315 <form ${'id="{}"'.format(form_id) if form_id else '' |n} action="#" method="GET">
316 316
317 317 <div class="comment-area">
318 318 <div class="comment-area-header">
319 319 <ul class="nav-links clearfix">
320 320 <li class="active">
321 321 <a href="#edit-btn" tabindex="-1" id="edit-btn_${lineno_id}">${_('Write')}</a>
322 322 </li>
323 323 <li class="">
324 324 <a href="#preview-btn" tabindex="-1" id="preview-btn_${lineno_id}">${_('Preview')}</a>
325 325 </li>
326 326 <li class="pull-right">
327 327 <select class="comment-type" id="comment_type_${lineno_id}" name="comment_type">
328 328 % for val in c.visual.comment_types:
329 329 <option value="${val}">${val.upper()}</option>
330 330 % endfor
331 331 </select>
332 332 </li>
333 333 </ul>
334 334 </div>
335 335
336 336 <div class="comment-area-write" style="display: block;">
337 337 <div id="edit-container_${lineno_id}">
338 338 <textarea id="text_${lineno_id}" name="text" class="comment-block-ta ac-input"></textarea>
339 339 </div>
340 340 <div id="preview-container_${lineno_id}" class="clearfix" style="display: none;">
341 341 <div id="preview-box_${lineno_id}" class="preview-box"></div>
342 342 </div>
343 343 </div>
344 344
345 345 <div class="comment-area-footer">
346 346 <div class="toolbar">
347 347 <div class="toolbar-text">
348 348 ${(_('Comments parsed using %s syntax with %s, and %s actions support.') % (
349 349 ('<a href="%s">%s</a>' % (h.route_url('%s_help' % c.visual.default_renderer), c.visual.default_renderer.upper())),
350 350 ('<span class="tooltip" title="%s">@mention</span>' % _('Use @username inside this text to send notification to this RhodeCode user')),
351 351 ('<span class="tooltip" title="%s">`/`</span>' % _('Start typing with / for certain actions to be triggered via text box.'))
352 352 )
353 353 )|n}
354 354 </div>
355 355 </div>
356 356 </div>
357 357 </div>
358 358
359 359 <div class="comment-footer">
360 360
361 361 % if review_statuses:
362 362 <div class="status_box">
363 363 <select id="change_status_${lineno_id}" name="changeset_status">
364 364 <option></option> ## Placeholder
365 365 % for status, lbl in review_statuses:
366 366 <option value="${status}" data-status="${status}">${lbl}</option>
367 367 %if is_pull_request and change_status and status in ('approved', 'rejected'):
368 368 <option value="${status}_closed" data-status="${status}">${lbl} & ${_('Closed')}</option>
369 369 %endif
370 370 % endfor
371 371 </select>
372 372 </div>
373 373 % endif
374 374
375 375 ## inject extra inputs into the form
376 376 % if form_extras and isinstance(form_extras, (list, tuple)):
377 377 <div id="comment_form_extras">
378 378 % for form_ex_el in form_extras:
379 379 ${form_ex_el|n}
380 380 % endfor
381 381 </div>
382 382 % endif
383 383
384 384 <div class="action-buttons">
385 385 ## inline for has a file, and line-number together with cancel hide button.
386 386 % if form_type == 'inline':
387 387 <input type="hidden" name="f_path" value="{0}">
388 388 <input type="hidden" name="line" value="${lineno_id}">
389 389 <button type="button" class="cb-comment-cancel" onclick="return Rhodecode.comments.cancelComment(this);">
390 390 ${_('Cancel')}
391 391 </button>
392 392 % endif
393 393
394 394 % if form_type != 'inline':
395 395 <div class="action-buttons-extra"></div>
396 396 % endif
397 397
398 398 ${h.submit('save', _('Comment'), class_='btn btn-success comment-button-input')}
399 399
400 400 </div>
401 401 </div>
402 402
403 403 </form>
404 404
405 405 </%def> No newline at end of file
@@ -1,317 +1,317 b''
1 1 ## DATA TABLE RE USABLE ELEMENTS
2 2 ## usage:
3 3 ## <%namespace name="dt" file="/data_table/_dt_elements.mako"/>
4 4 <%namespace name="base" file="/base/base.mako"/>
5 5
6 6 ## REPOSITORY RENDERERS
7 7 <%def name="quick_menu(repo_name)">
8 8 <i class="pointer icon-more"></i>
9 9 <div class="menu_items_container hidden">
10 10 <ul class="menu_items">
11 11 <li>
12 12 <a title="${_('Summary')}" href="${h.route_path('repo_summary',repo_name=repo_name)}">
13 13 <span>${_('Summary')}</span>
14 14 </a>
15 15 </li>
16 16 <li>
17 17 <a title="${_('Changelog')}" href="${h.url('changelog_home',repo_name=repo_name)}">
18 18 <span>${_('Changelog')}</span>
19 19 </a>
20 20 </li>
21 21 <li>
22 22 <a title="${_('Files')}" href="${h.url('files_home',repo_name=repo_name)}">
23 23 <span>${_('Files')}</span>
24 24 </a>
25 25 </li>
26 26 <li>
27 27 <a title="${_('Fork')}" href="${h.url('repo_fork_home',repo_name=repo_name)}">
28 28 <span>${_('Fork')}</span>
29 29 </a>
30 30 </li>
31 31 </ul>
32 32 </div>
33 33 </%def>
34 34
35 35 <%def name="repo_name(name,rtype,rstate,private,fork_of,short_name=False,admin=False)">
36 36 <%
37 37 def get_name(name,short_name=short_name):
38 38 if short_name:
39 39 return name.split('/')[-1]
40 40 else:
41 41 return name
42 42 %>
43 43 <div class="${'repo_state_pending' if rstate == 'repo_state_pending' else ''} truncate">
44 44 ##NAME
45 45 <a href="${h.route_path('edit_repo',repo_name=name) if admin else h.route_path('repo_summary',repo_name=name)}">
46 46
47 47 ##TYPE OF REPO
48 48 %if h.is_hg(rtype):
49 49 <span title="${_('Mercurial repository')}"><i class="icon-hg"></i></span>
50 50 %elif h.is_git(rtype):
51 51 <span title="${_('Git repository')}"><i class="icon-git"></i></span>
52 52 %elif h.is_svn(rtype):
53 53 <span title="${_('Subversion repository')}"><i class="icon-svn"></i></span>
54 54 %endif
55 55
56 56 ##PRIVATE/PUBLIC
57 57 %if private and c.visual.show_private_icon:
58 58 <i class="icon-lock" title="${_('Private repository')}"></i>
59 59 %elif not private and c.visual.show_public_icon:
60 60 <i class="icon-unlock-alt" title="${_('Public repository')}"></i>
61 61 %else:
62 62 <span></span>
63 63 %endif
64 64 ${get_name(name)}
65 65 </a>
66 66 %if fork_of:
67 67 <a href="${h.route_path('repo_summary',repo_name=fork_of.repo_name)}"><i class="icon-code-fork"></i></a>
68 68 %endif
69 69 %if rstate == 'repo_state_pending':
70 70 <i class="icon-cogs" title="${_('Repository creating in progress...')}"></i>
71 71 %endif
72 72 </div>
73 73 </%def>
74 74
75 75 <%def name="repo_desc(description)">
76 76 <div class="truncate-wrap">${description}</div>
77 77 </%def>
78 78
79 79 <%def name="last_change(last_change)">
80 80 ${h.age_component(last_change)}
81 81 </%def>
82 82
83 83 <%def name="revision(name,rev,tip,author,last_msg)">
84 84 <div>
85 85 %if rev >= 0:
86 86 <code><a title="${h.tooltip('%s:\n\n%s' % (author,last_msg))}" class="tooltip" href="${h.url('changeset_home',repo_name=name,revision=tip)}">${'r%s:%s' % (rev,h.short_id(tip))}</a></code>
87 87 %else:
88 88 ${_('No commits yet')}
89 89 %endif
90 90 </div>
91 91 </%def>
92 92
93 93 <%def name="rss(name)">
94 94 %if c.rhodecode_user.username != h.DEFAULT_USER:
95 95 <a title="${_('Subscribe to %s rss feed')% name}" href="${h.url('rss_feed_home',repo_name=name,auth_token=c.rhodecode_user.feed_token)}"><i class="icon-rss-sign"></i></a>
96 96 %else:
97 97 <a title="${_('Subscribe to %s rss feed')% name}" href="${h.url('rss_feed_home',repo_name=name)}"><i class="icon-rss-sign"></i></a>
98 98 %endif
99 99 </%def>
100 100
101 101 <%def name="atom(name)">
102 102 %if c.rhodecode_user.username != h.DEFAULT_USER:
103 103 <a title="${_('Subscribe to %s atom feed')% name}" href="${h.url('atom_feed_home',repo_name=name,auth_token=c.rhodecode_user.feed_token)}"><i class="icon-rss-sign"></i></a>
104 104 %else:
105 105 <a title="${_('Subscribe to %s atom feed')% name}" href="${h.url('atom_feed_home',repo_name=name)}"><i class="icon-rss-sign"></i></a>
106 106 %endif
107 107 </%def>
108 108
109 109 <%def name="user_gravatar(email, size=16)">
110 110 <div class="rc-user tooltip" title="${h.author_string(email)}">
111 111 ${base.gravatar(email, 16)}
112 112 </div>
113 113 </%def>
114 114
115 115 <%def name="repo_actions(repo_name, super_user=True)">
116 116 <div>
117 117 <div class="grid_edit">
118 118 <a href="${h.route_path('edit_repo',repo_name=repo_name)}" title="${_('Edit')}">
119 119 <i class="icon-pencil"></i>Edit</a>
120 120 </div>
121 121 <div class="grid_delete">
122 122 ${h.secure_form(h.route_path('edit_repo_advanced_delete', repo_name=repo_name), method='POST')}
123 123 ${h.submit('remove_%s' % repo_name,_('Delete'),class_="btn btn-link btn-danger",
124 124 onclick="return confirm('"+_('Confirm to delete this repository: %s') % repo_name+"');")}
125 125 ${h.end_form()}
126 126 </div>
127 127 </div>
128 128 </%def>
129 129
130 130 <%def name="repo_state(repo_state)">
131 131 <div>
132 132 %if repo_state == 'repo_state_pending':
133 133 <div class="tag tag4">${_('Creating')}</div>
134 134 %elif repo_state == 'repo_state_created':
135 135 <div class="tag tag1">${_('Created')}</div>
136 136 %else:
137 137 <div class="tag alert2" title="${repo_state}">invalid</div>
138 138 %endif
139 139 </div>
140 140 </%def>
141 141
142 142
143 143 ## REPO GROUP RENDERERS
144 144 <%def name="quick_repo_group_menu(repo_group_name)">
145 145 <i class="pointer icon-more"></i>
146 146 <div class="menu_items_container hidden">
147 147 <ul class="menu_items">
148 148 <li>
149 149 <a href="${h.route_path('repo_group_home', repo_group_name=repo_group_name)}">
150 150 <span class="icon">
151 151 <i class="icon-file-text"></i>
152 152 </span>
153 153 <span>${_('Summary')}</span>
154 154 </a>
155 155 </li>
156 156
157 157 </ul>
158 158 </div>
159 159 </%def>
160 160
161 161 <%def name="repo_group_name(repo_group_name, children_groups=None)">
162 162 <div>
163 163 <a href="${h.route_path('repo_group_home', repo_group_name=repo_group_name)}">
164 164 <i class="icon-folder-close" title="${_('Repository group')}"></i>
165 165 %if children_groups:
166 166 ${h.literal(' &raquo; '.join(children_groups))}
167 167 %else:
168 168 ${repo_group_name}
169 169 %endif
170 170 </a>
171 171 </div>
172 172 </%def>
173 173
174 174 <%def name="repo_group_desc(description)">
175 175 <div class="truncate-wrap">${description}</div>
176 176 </%def>
177 177
178 178 <%def name="repo_group_actions(repo_group_id, repo_group_name, gr_count)">
179 179 <div class="grid_edit">
180 180 <a href="${h.url('edit_repo_group',group_name=repo_group_name)}" title="${_('Edit')}">Edit</a>
181 181 </div>
182 182 <div class="grid_delete">
183 183 ${h.secure_form(h.url('delete_repo_group', group_name=repo_group_name),method='delete')}
184 184 ${h.submit('remove_%s' % repo_group_name,_('Delete'),class_="btn btn-link btn-danger",
185 185 onclick="return confirm('"+ungettext('Confirm to delete this group: %s with %s repository','Confirm to delete this group: %s with %s repositories',gr_count) % (repo_group_name, gr_count)+"');")}
186 186 ${h.end_form()}
187 187 </div>
188 188 </%def>
189 189
190 190
191 191 <%def name="user_actions(user_id, username)">
192 192 <div class="grid_edit">
193 193 <a href="${h.url('edit_user',user_id=user_id)}" title="${_('Edit')}">
194 194 <i class="icon-pencil"></i>Edit</a>
195 195 </div>
196 196 <div class="grid_delete">
197 197 ${h.secure_form(h.url('delete_user', user_id=user_id),method='delete')}
198 198 ${h.submit('remove_',_('Delete'),id="remove_user_%s" % user_id, class_="btn btn-link btn-danger",
199 199 onclick="return confirm('"+_('Confirm to delete this user: %s') % username+"');")}
200 200 ${h.end_form()}
201 201 </div>
202 202 </%def>
203 203
204 204 <%def name="user_group_actions(user_group_id, user_group_name)">
205 205 <div class="grid_edit">
206 206 <a href="${h.url('edit_users_group', user_group_id=user_group_id)}" title="${_('Edit')}">Edit</a>
207 207 </div>
208 208 <div class="grid_delete">
209 209 ${h.secure_form(h.url('delete_users_group', user_group_id=user_group_id),method='delete')}
210 210 ${h.submit('remove_',_('Delete'),id="remove_group_%s" % user_group_id, class_="btn btn-link btn-danger",
211 211 onclick="return confirm('"+_('Confirm to delete this user group: %s') % user_group_name+"');")}
212 212 ${h.end_form()}
213 213 </div>
214 214 </%def>
215 215
216 216
217 217 <%def name="user_name(user_id, username)">
218 218 ${h.link_to(h.person(username, 'username_or_name_or_email'), h.url('edit_user', user_id=user_id))}
219 219 </%def>
220 220
221 221 <%def name="user_profile(username)">
222 222 ${base.gravatar_with_user(username, 16)}
223 223 </%def>
224 224
225 225 <%def name="user_group_name(user_group_id, user_group_name)">
226 226 <div>
227 227 <a href="${h.url('edit_users_group', user_group_id=user_group_id)}">
228 228 <i class="icon-group" title="${_('User group')}"></i> ${user_group_name}</a>
229 229 </div>
230 230 </%def>
231 231
232 232
233 233 ## GISTS
234 234
235 235 <%def name="gist_gravatar(full_contact)">
236 236 <div class="gist_gravatar">
237 237 ${base.gravatar(full_contact, 30)}
238 238 </div>
239 239 </%def>
240 240
241 241 <%def name="gist_access_id(gist_access_id, full_contact)">
242 242 <div>
243 243 <b>
244 244 <a href="${h.url('gist',gist_id=gist_access_id)}">gist: ${gist_access_id}</a>
245 245 </b>
246 246 </div>
247 247 </%def>
248 248
249 249 <%def name="gist_author(full_contact, created_on, expires)">
250 250 ${base.gravatar_with_user(full_contact, 16)}
251 251 </%def>
252 252
253 253
254 254 <%def name="gist_created(created_on)">
255 255 <div class="created">
256 256 ${h.age_component(created_on, time_is_local=True)}
257 257 </div>
258 258 </%def>
259 259
260 260 <%def name="gist_expires(expires)">
261 261 <div class="created">
262 262 %if expires == -1:
263 263 ${_('never')}
264 264 %else:
265 265 ${h.age_component(h.time_to_utcdatetime(expires))}
266 266 %endif
267 267 </div>
268 268 </%def>
269 269
270 270 <%def name="gist_type(gist_type)">
271 271 %if gist_type != 'public':
272 272 <div class="tag">${_('Private')}</div>
273 273 %endif
274 274 </%def>
275 275
276 276 <%def name="gist_description(gist_description)">
277 277 ${gist_description}
278 278 </%def>
279 279
280 280
281 281 ## PULL REQUESTS GRID RENDERERS
282 282
283 283 <%def name="pullrequest_target_repo(repo_name)">
284 284 <div class="truncate">
285 285 ${h.link_to(repo_name,h.route_path('repo_summary',repo_name=repo_name))}
286 286 </div>
287 287 </%def>
288 288 <%def name="pullrequest_status(status)">
289 289 <div class="${'flag_status %s' % status} pull-left"></div>
290 290 </%def>
291 291
292 292 <%def name="pullrequest_title(title, description)">
293 293 ${title} <br/>
294 294 ${h.shorter(description, 40)}
295 295 </%def>
296 296
297 297 <%def name="pullrequest_comments(comments_nr)">
298 298 <i class="icon-comment"></i> ${comments_nr}
299 299 </%def>
300 300
301 301 <%def name="pullrequest_name(pull_request_id, target_repo_name, short=False)">
302 <a href="${h.url('pullrequest_show',repo_name=target_repo_name,pull_request_id=pull_request_id)}">
302 <a href="${h.route_path('pullrequest_show',repo_name=target_repo_name,pull_request_id=pull_request_id)}">
303 303 % if short:
304 304 #${pull_request_id}
305 305 % else:
306 306 ${_('Pull request #%(pr_number)s') % {'pr_number': pull_request_id,}}
307 307 % endif
308 308 </a>
309 309 </%def>
310 310
311 311 <%def name="pullrequest_updated_on(updated_on)">
312 312 ${h.age_component(h.time_to_utcdatetime(updated_on))}
313 313 </%def>
314 314
315 315 <%def name="pullrequest_author(full_contact)">
316 316 ${base.gravatar_with_user(full_contact, 16)}
317 317 </%def>
@@ -1,136 +1,136 b''
1 1 ## -*- coding: utf-8 -*-
2 2 <%namespace name="base" file="/base/base.mako"/>
3 3 %if c.repo_commits:
4 4 <table class="rctable repo_summary table_disp">
5 5 <tr>
6 6
7 7 <th class="status" colspan="2"></th>
8 8 <th>${_('Commit')}</th>
9 9 <th>${_('Commit message')}</th>
10 10 <th>${_('Age')}</th>
11 11 <th>${_('Author')}</th>
12 12 <th>${_('Refs')}</th>
13 13 </tr>
14 14 %for cnt,cs in enumerate(c.repo_commits):
15 15 <tr class="parity${cnt%2}">
16 16
17 17 <td class="td-status">
18 18 %if c.statuses.get(cs.raw_id):
19 19 <div class="changeset-status-ico shortlog">
20 20 %if c.statuses.get(cs.raw_id)[2]:
21 <a class="tooltip" title="${_('Commit status: %s\nClick to open associated pull request #%s') % (c.statuses.get(cs.raw_id)[0], c.statuses.get(cs.raw_id)[2])}" href="${h.url('pullrequest_show',repo_name=c.statuses.get(cs.raw_id)[3],pull_request_id=c.statuses.get(cs.raw_id)[2])}">
21 <a class="tooltip" title="${_('Commit status: %s\nClick to open associated pull request #%s') % (c.statuses.get(cs.raw_id)[0], c.statuses.get(cs.raw_id)[2])}" href="${h.route_path('pullrequest_show',repo_name=c.statuses.get(cs.raw_id)[3],pull_request_id=c.statuses.get(cs.raw_id)[2])}">
22 22 <div class="${'flag_status %s' % c.statuses.get(cs.raw_id)[0]}"></div>
23 23 </a>
24 24 %else:
25 25 <a class="tooltip" title="${_('Commit status: %s') % h.commit_status_lbl(c.statuses.get(cs.raw_id)[0])}" href="${h.url('changeset_home',repo_name=c.repo_name,revision=cs.raw_id,anchor='comment-%s' % c.comments[cs.raw_id][0].comment_id)}">
26 26 <div class="${'flag_status %s' % c.statuses.get(cs.raw_id)[0]}"></div>
27 27 </a>
28 28 %endif
29 29 </div>
30 30 %else:
31 31 <div class="tooltip flag_status not_reviewed" title="${_('Commit status: Not Reviewed')}"></div>
32 32 %endif
33 33 </td>
34 34 <td class="td-comments">
35 35 %if c.comments.get(cs.raw_id,[]):
36 36 <a title="${_('Commit has comments')}" href="${h.url('changeset_home',repo_name=c.repo_name,revision=cs.raw_id,anchor='comment-%s' % c.comments[cs.raw_id][0].comment_id)}">
37 37 <i class="icon-comment"></i> ${len(c.comments[cs.raw_id])}
38 38 </a>
39 39 %endif
40 40 </td>
41 41 <td class="td-commit">
42 42 <pre><a href="${h.url('changeset_home', repo_name=c.repo_name, revision=cs.raw_id)}">${h.show_id(cs)}</a></pre>
43 43 </td>
44 44
45 45 <td class="td-description mid">
46 46 <div class="log-container truncate-wrap">
47 47 <div class="message truncate" id="c-${cs.raw_id}">${h.urlify_commit_message(cs.message, c.repo_name)}</div>
48 48 </div>
49 49 </td>
50 50
51 51 <td class="td-time">
52 52 ${h.age_component(cs.date)}
53 53 </td>
54 54 <td class="td-user author">
55 55 ${base.gravatar_with_user(cs.author)}
56 56 </td>
57 57
58 58 <td class="td-tags">
59 59 <div class="autoexpand">
60 60 %if h.is_hg(c.rhodecode_repo):
61 61 %for book in cs.bookmarks:
62 62 <span class="booktag tag" title="${_('Bookmark %s') % book}">
63 63 <a href="${h.url('files_home',repo_name=c.repo_name,revision=cs.raw_id)}"><i class="icon-bookmark"></i>${h.shorter(book)}</a>
64 64 </span>
65 65 %endfor
66 66 %endif
67 67 ## tags
68 68 %for tag in cs.tags:
69 69 <span class="tagtag tag" title="${_('Tag %s') % tag}">
70 70 <a href="${h.url('files_home',repo_name=c.repo_name,revision=cs.raw_id)}"><i class="icon-tag"></i>${h.shorter(tag)}</a>
71 71 </span>
72 72 %endfor
73 73
74 74 ## branch
75 75 %if cs.branch:
76 76 <span class="branchtag tag" title="${_('Branch %s') % cs.branch}">
77 77 <a href="${h.url('changelog_home',repo_name=c.repo_name,branch=cs.branch)}"><i class="icon-code-fork"></i>${h.shorter(cs.branch)}</a>
78 78 </span>
79 79 %endif
80 80 </div>
81 81 </td>
82 82 </tr>
83 83 %endfor
84 84
85 85 </table>
86 86
87 87 <script type="text/javascript">
88 88 $(document).pjax('#shortlog_data .pager_link','#shortlog_data', {timeout: 2000, scrollTo: false });
89 89 $(document).on('pjax:success', function(){ timeagoActivate(); });
90 90 </script>
91 91
92 92 <div class="pagination-wh pagination-left">
93 93 ${c.repo_commits.pager('$link_previous ~2~ $link_next')}
94 94 </div>
95 95 %else:
96 96
97 97 %if h.HasRepoPermissionAny('repository.write','repository.admin')(c.repo_name):
98 98 <div class="quick_start">
99 99 <div class="fieldset">
100 100 <div class="left-label">${_('Add or upload files directly via RhodeCode:')}</div>
101 101 <div class="right-content">
102 102 <div id="add_node_id" class="add_node">
103 103 <a href="${h.url('files_add_home',repo_name=c.repo_name,revision=0,f_path='', anchor='edit')}" class="btn btn-default">${_('Add New File')}</a>
104 104 </div>
105 105 </div>
106 106 %endif
107 107 </div>
108 108
109 109 %if not h.is_svn(c.rhodecode_repo):
110 110 <div class="fieldset">
111 111 <div class="left-label">${_('Push new repo:')}</div>
112 112 <div class="right-content">
113 113 <pre>
114 114 ${c.rhodecode_repo.alias} clone ${c.clone_repo_url}
115 115 ${c.rhodecode_repo.alias} add README # add first file
116 116 ${c.rhodecode_repo.alias} commit -m "Initial" # commit with message
117 117 ${c.rhodecode_repo.alias} push ${'origin master' if h.is_git(c.rhodecode_repo) else ''} # push changes back
118 118 </pre>
119 119 </div>
120 120 </div>
121 121 <div class="fieldset">
122 122 <div class="left-label">${_('Existing repository?')}</div>
123 123 <div class="right-content">
124 124 <pre>
125 125 %if h.is_git(c.rhodecode_repo):
126 126 git remote add origin ${c.clone_repo_url}
127 127 git push -u origin master
128 128 %else:
129 129 hg push ${c.clone_repo_url}
130 130 %endif
131 131 </pre>
132 132 </div>
133 133 </div>
134 134 %endif
135 135 </div>
136 136 %endif
General Comments 0
You need to be logged in to leave comments. Login now