##// END OF EJS Templates
pull-requests: added observers, and fix few problems with versioned comments
marcink -
r4481:d52ab7ab default
parent child Browse files
Show More

The requested changes are too big and content was truncated. Show full diff

1 NO CONTENT: new file 100644
The requested commit or file is too big and content was truncated. Show full diff
@@ -0,0 +1,52 b''
1 # -*- coding: utf-8 -*-
2
3 import logging
4 from sqlalchemy import *
5
6 from alembic.migration import MigrationContext
7 from alembic.operations import Operations
8
9 from rhodecode.lib.dbmigrate.versions import _reset_base
10 from rhodecode.model import meta, init_model_encryption
11
12
13 log = logging.getLogger(__name__)
14
15
16 def upgrade(migrate_engine):
17 """
18 Upgrade operations go here.
19 Don't create your own engine; bind migrate_engine to your metadata
20 """
21 _reset_base(migrate_engine)
22 from rhodecode.lib.dbmigrate.schema import db_4_20_0_0 as db
23
24 init_model_encryption(db)
25
26 context = MigrationContext.configure(migrate_engine.connect())
27 op = Operations(context)
28
29 table = db.PullRequestReviewers.__table__
30 with op.batch_alter_table(table.name) as batch_op:
31 new_column = Column('role', Unicode(255), nullable=True)
32 batch_op.add_column(new_column)
33
34 _fill_reviewers_role(db, op, meta.Session)
35
36
37 def downgrade(migrate_engine):
38 meta = MetaData()
39 meta.bind = migrate_engine
40
41
42 def fixups(models, _SESSION):
43 pass
44
45
46 def _fill_reviewers_role(models, op, session):
47 params = {'role': 'reviewer'}
48 query = text(
49 'UPDATE pull_request_reviewers SET role = :role'
50 ).bindparams(**params)
51 op.execute(query)
52 session().commit()
@@ -1,60 +1,60 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2020 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 os
22 22 from collections import OrderedDict
23 23
24 24 import sys
25 25 import platform
26 26
27 27 VERSION = tuple(open(os.path.join(
28 28 os.path.dirname(__file__), 'VERSION')).read().split('.'))
29 29
30 30 BACKENDS = OrderedDict()
31 31
32 32 BACKENDS['hg'] = 'Mercurial repository'
33 33 BACKENDS['git'] = 'Git repository'
34 34 BACKENDS['svn'] = 'Subversion repository'
35 35
36 36
37 37 CELERY_ENABLED = False
38 38 CELERY_EAGER = False
39 39
40 40 # link to config for pyramid
41 41 CONFIG = {}
42 42
43 43 # Populated with the settings dictionary from application init in
44 44 # rhodecode.conf.environment.load_pyramid_environment
45 45 PYRAMID_SETTINGS = {}
46 46
47 47 # Linked module for extensions
48 48 EXTENSIONS = {}
49 49
50 50 __version__ = ('.'.join((str(each) for each in VERSION[:3])))
51 __dbversion__ = 108 # defines current db version for migrations
51 __dbversion__ = 109 # defines current db version for migrations
52 52 __platform__ = platform.system()
53 53 __license__ = 'AGPLv3, and Commercial License'
54 54 __author__ = 'RhodeCode GmbH'
55 55 __url__ = 'https://code.rhodecode.com'
56 56
57 57 is_windows = __platform__ in ['Windows']
58 58 is_unix = not is_windows
59 59 is_test = False
60 60 disable_error_handler = False
@@ -1,723 +1,723 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21
22 22 import logging
23 23
24 24 from pyramid.httpexceptions import (
25 25 HTTPNotFound, HTTPBadRequest, HTTPFound, HTTPForbidden, HTTPConflict)
26 26 from pyramid.view import view_config
27 27 from pyramid.renderers import render
28 28 from pyramid.response import Response
29 29
30 30 from rhodecode.apps._base import RepoAppView
31 31 from rhodecode.apps.file_store import utils as store_utils
32 32 from rhodecode.apps.file_store.exceptions import FileNotAllowedException, FileOverSizeException
33 33
34 34 from rhodecode.lib import diffs, codeblocks
35 35 from rhodecode.lib.auth import (
36 36 LoginRequired, HasRepoPermissionAnyDecorator, NotAnonymous, CSRFRequired)
37 37
38 38 from rhodecode.lib.compat import OrderedDict
39 39 from rhodecode.lib.diffs import (
40 40 cache_diff, load_cached_diff, diff_cache_exist, get_diff_context,
41 41 get_diff_whitespace_flag)
42 42 from rhodecode.lib.exceptions import StatusChangeOnClosedPullRequestError, CommentVersionMismatch
43 43 import rhodecode.lib.helpers as h
44 44 from rhodecode.lib.utils2 import safe_unicode, str2bool
45 45 from rhodecode.lib.vcs.backends.base import EmptyCommit
46 46 from rhodecode.lib.vcs.exceptions import (
47 47 RepositoryError, CommitDoesNotExistError)
48 48 from rhodecode.model.db import ChangesetComment, ChangesetStatus, FileStore, \
49 49 ChangesetCommentHistory
50 50 from rhodecode.model.changeset_status import ChangesetStatusModel
51 51 from rhodecode.model.comment import CommentsModel
52 52 from rhodecode.model.meta import Session
53 53 from rhodecode.model.settings import VcsSettingsModel
54 54
55 55 log = logging.getLogger(__name__)
56 56
57 57
58 58 def _update_with_GET(params, request):
59 59 for k in ['diff1', 'diff2', 'diff']:
60 60 params[k] += request.GET.getall(k)
61 61
62 62
63 63 class RepoCommitsView(RepoAppView):
64 64 def load_default_context(self):
65 65 c = self._get_local_tmpl_context(include_app_defaults=True)
66 66 c.rhodecode_repo = self.rhodecode_vcs_repo
67 67
68 68 return c
69 69
70 70 def _is_diff_cache_enabled(self, target_repo):
71 71 caching_enabled = self._get_general_setting(
72 72 target_repo, 'rhodecode_diff_cache')
73 73 log.debug('Diff caching enabled: %s', caching_enabled)
74 74 return caching_enabled
75 75
76 76 def _commit(self, commit_id_range, method):
77 77 _ = self.request.translate
78 78 c = self.load_default_context()
79 79 c.fulldiff = self.request.GET.get('fulldiff')
80 80
81 81 # fetch global flags of ignore ws or context lines
82 82 diff_context = get_diff_context(self.request)
83 83 hide_whitespace_changes = get_diff_whitespace_flag(self.request)
84 84
85 85 # diff_limit will cut off the whole diff if the limit is applied
86 86 # otherwise it will just hide the big files from the front-end
87 87 diff_limit = c.visual.cut_off_limit_diff
88 88 file_limit = c.visual.cut_off_limit_file
89 89
90 90 # get ranges of commit ids if preset
91 91 commit_range = commit_id_range.split('...')[:2]
92 92
93 93 try:
94 94 pre_load = ['affected_files', 'author', 'branch', 'date',
95 95 'message', 'parents']
96 96 if self.rhodecode_vcs_repo.alias == 'hg':
97 97 pre_load += ['hidden', 'obsolete', 'phase']
98 98
99 99 if len(commit_range) == 2:
100 100 commits = self.rhodecode_vcs_repo.get_commits(
101 101 start_id=commit_range[0], end_id=commit_range[1],
102 102 pre_load=pre_load, translate_tags=False)
103 103 commits = list(commits)
104 104 else:
105 105 commits = [self.rhodecode_vcs_repo.get_commit(
106 106 commit_id=commit_id_range, pre_load=pre_load)]
107 107
108 108 c.commit_ranges = commits
109 109 if not c.commit_ranges:
110 110 raise RepositoryError('The commit range returned an empty result')
111 111 except CommitDoesNotExistError as e:
112 112 msg = _('No such commit exists. Org exception: `{}`').format(e)
113 113 h.flash(msg, category='error')
114 114 raise HTTPNotFound()
115 115 except Exception:
116 116 log.exception("General failure")
117 117 raise HTTPNotFound()
118 118
119 119 c.changes = OrderedDict()
120 120 c.lines_added = 0
121 121 c.lines_deleted = 0
122 122
123 123 # auto collapse if we have more than limit
124 124 collapse_limit = diffs.DiffProcessor._collapse_commits_over
125 125 c.collapse_all_commits = len(c.commit_ranges) > collapse_limit
126 126
127 127 c.commit_statuses = ChangesetStatus.STATUSES
128 128 c.inline_comments = []
129 129 c.files = []
130 130
131 131 c.statuses = []
132 132 c.comments = []
133 133 c.unresolved_comments = []
134 134 c.resolved_comments = []
135 135 if len(c.commit_ranges) == 1:
136 136 commit = c.commit_ranges[0]
137 137 c.comments = CommentsModel().get_comments(
138 138 self.db_repo.repo_id,
139 139 revision=commit.raw_id)
140 140 c.statuses.append(ChangesetStatusModel().get_status(
141 141 self.db_repo.repo_id, commit.raw_id))
142 142 # comments from PR
143 143 statuses = ChangesetStatusModel().get_statuses(
144 144 self.db_repo.repo_id, commit.raw_id,
145 145 with_revisions=True)
146 146 prs = set(st.pull_request for st in statuses
147 147 if st.pull_request is not None)
148 148 # from associated statuses, check the pull requests, and
149 149 # show comments from them
150 150 for pr in prs:
151 151 c.comments.extend(pr.comments)
152 152
153 153 c.unresolved_comments = CommentsModel()\
154 154 .get_commit_unresolved_todos(commit.raw_id)
155 155 c.resolved_comments = CommentsModel()\
156 156 .get_commit_resolved_todos(commit.raw_id)
157 157
158 158 diff = None
159 159 # Iterate over ranges (default commit view is always one commit)
160 160 for commit in c.commit_ranges:
161 161 c.changes[commit.raw_id] = []
162 162
163 163 commit2 = commit
164 164 commit1 = commit.first_parent
165 165
166 166 if method == 'show':
167 167 inline_comments = CommentsModel().get_inline_comments(
168 168 self.db_repo.repo_id, revision=commit.raw_id)
169 c.inline_cnt = CommentsModel().get_inline_comments_count(
170 inline_comments)
169 c.inline_cnt = len(CommentsModel().get_inline_comments_as_list(
170 inline_comments))
171 171 c.inline_comments = inline_comments
172 172
173 173 cache_path = self.rhodecode_vcs_repo.get_create_shadow_cache_pr_path(
174 174 self.db_repo)
175 175 cache_file_path = diff_cache_exist(
176 176 cache_path, 'diff', commit.raw_id,
177 177 hide_whitespace_changes, diff_context, c.fulldiff)
178 178
179 179 caching_enabled = self._is_diff_cache_enabled(self.db_repo)
180 180 force_recache = str2bool(self.request.GET.get('force_recache'))
181 181
182 182 cached_diff = None
183 183 if caching_enabled:
184 184 cached_diff = load_cached_diff(cache_file_path)
185 185
186 186 has_proper_diff_cache = cached_diff and cached_diff.get('diff')
187 187 if not force_recache and has_proper_diff_cache:
188 188 diffset = cached_diff['diff']
189 189 else:
190 190 vcs_diff = self.rhodecode_vcs_repo.get_diff(
191 191 commit1, commit2,
192 192 ignore_whitespace=hide_whitespace_changes,
193 193 context=diff_context)
194 194
195 195 diff_processor = diffs.DiffProcessor(
196 196 vcs_diff, format='newdiff', diff_limit=diff_limit,
197 197 file_limit=file_limit, show_full_diff=c.fulldiff)
198 198
199 199 _parsed = diff_processor.prepare()
200 200
201 201 diffset = codeblocks.DiffSet(
202 202 repo_name=self.db_repo_name,
203 203 source_node_getter=codeblocks.diffset_node_getter(commit1),
204 204 target_node_getter=codeblocks.diffset_node_getter(commit2))
205 205
206 206 diffset = self.path_filter.render_patchset_filtered(
207 207 diffset, _parsed, commit1.raw_id, commit2.raw_id)
208 208
209 209 # save cached diff
210 210 if caching_enabled:
211 211 cache_diff(cache_file_path, diffset, None)
212 212
213 213 c.limited_diff = diffset.limited_diff
214 214 c.changes[commit.raw_id] = diffset
215 215 else:
216 216 # TODO(marcink): no cache usage here...
217 217 _diff = self.rhodecode_vcs_repo.get_diff(
218 218 commit1, commit2,
219 219 ignore_whitespace=hide_whitespace_changes, context=diff_context)
220 220 diff_processor = diffs.DiffProcessor(
221 221 _diff, format='newdiff', diff_limit=diff_limit,
222 222 file_limit=file_limit, show_full_diff=c.fulldiff)
223 223 # downloads/raw we only need RAW diff nothing else
224 224 diff = self.path_filter.get_raw_patch(diff_processor)
225 225 c.changes[commit.raw_id] = [None, None, None, None, diff, None, None]
226 226
227 227 # sort comments by how they were generated
228 228 c.comments = sorted(c.comments, key=lambda x: x.comment_id)
229 229
230 230 if len(c.commit_ranges) == 1:
231 231 c.commit = c.commit_ranges[0]
232 232 c.parent_tmpl = ''.join(
233 233 '# Parent %s\n' % x.raw_id for x in c.commit.parents)
234 234
235 235 if method == 'download':
236 236 response = Response(diff)
237 237 response.content_type = 'text/plain'
238 238 response.content_disposition = (
239 239 'attachment; filename=%s.diff' % commit_id_range[:12])
240 240 return response
241 241 elif method == 'patch':
242 242 c.diff = safe_unicode(diff)
243 243 patch = render(
244 244 'rhodecode:templates/changeset/patch_changeset.mako',
245 245 self._get_template_context(c), self.request)
246 246 response = Response(patch)
247 247 response.content_type = 'text/plain'
248 248 return response
249 249 elif method == 'raw':
250 250 response = Response(diff)
251 251 response.content_type = 'text/plain'
252 252 return response
253 253 elif method == 'show':
254 254 if len(c.commit_ranges) == 1:
255 255 html = render(
256 256 'rhodecode:templates/changeset/changeset.mako',
257 257 self._get_template_context(c), self.request)
258 258 return Response(html)
259 259 else:
260 260 c.ancestor = None
261 261 c.target_repo = self.db_repo
262 262 html = render(
263 263 'rhodecode:templates/changeset/changeset_range.mako',
264 264 self._get_template_context(c), self.request)
265 265 return Response(html)
266 266
267 267 raise HTTPBadRequest()
268 268
269 269 @LoginRequired()
270 270 @HasRepoPermissionAnyDecorator(
271 271 'repository.read', 'repository.write', 'repository.admin')
272 272 @view_config(
273 273 route_name='repo_commit', request_method='GET',
274 274 renderer=None)
275 275 def repo_commit_show(self):
276 276 commit_id = self.request.matchdict['commit_id']
277 277 return self._commit(commit_id, method='show')
278 278
279 279 @LoginRequired()
280 280 @HasRepoPermissionAnyDecorator(
281 281 'repository.read', 'repository.write', 'repository.admin')
282 282 @view_config(
283 283 route_name='repo_commit_raw', request_method='GET',
284 284 renderer=None)
285 285 @view_config(
286 286 route_name='repo_commit_raw_deprecated', request_method='GET',
287 287 renderer=None)
288 288 def repo_commit_raw(self):
289 289 commit_id = self.request.matchdict['commit_id']
290 290 return self._commit(commit_id, method='raw')
291 291
292 292 @LoginRequired()
293 293 @HasRepoPermissionAnyDecorator(
294 294 'repository.read', 'repository.write', 'repository.admin')
295 295 @view_config(
296 296 route_name='repo_commit_patch', request_method='GET',
297 297 renderer=None)
298 298 def repo_commit_patch(self):
299 299 commit_id = self.request.matchdict['commit_id']
300 300 return self._commit(commit_id, method='patch')
301 301
302 302 @LoginRequired()
303 303 @HasRepoPermissionAnyDecorator(
304 304 'repository.read', 'repository.write', 'repository.admin')
305 305 @view_config(
306 306 route_name='repo_commit_download', request_method='GET',
307 307 renderer=None)
308 308 def repo_commit_download(self):
309 309 commit_id = self.request.matchdict['commit_id']
310 310 return self._commit(commit_id, method='download')
311 311
312 312 @LoginRequired()
313 313 @NotAnonymous()
314 314 @HasRepoPermissionAnyDecorator(
315 315 'repository.read', 'repository.write', 'repository.admin')
316 316 @CSRFRequired()
317 317 @view_config(
318 318 route_name='repo_commit_comment_create', request_method='POST',
319 319 renderer='json_ext')
320 320 def repo_commit_comment_create(self):
321 321 _ = self.request.translate
322 322 commit_id = self.request.matchdict['commit_id']
323 323
324 324 c = self.load_default_context()
325 325 status = self.request.POST.get('changeset_status', None)
326 326 text = self.request.POST.get('text')
327 327 comment_type = self.request.POST.get('comment_type')
328 328 resolves_comment_id = self.request.POST.get('resolves_comment_id', None)
329 329
330 330 if status:
331 331 text = text or (_('Status change %(transition_icon)s %(status)s')
332 332 % {'transition_icon': '>',
333 333 'status': ChangesetStatus.get_status_lbl(status)})
334 334
335 335 multi_commit_ids = []
336 336 for _commit_id in self.request.POST.get('commit_ids', '').split(','):
337 337 if _commit_id not in ['', None, EmptyCommit.raw_id]:
338 338 if _commit_id not in multi_commit_ids:
339 339 multi_commit_ids.append(_commit_id)
340 340
341 341 commit_ids = multi_commit_ids or [commit_id]
342 342
343 343 comment = None
344 344 for current_id in filter(None, commit_ids):
345 345 comment = CommentsModel().create(
346 346 text=text,
347 347 repo=self.db_repo.repo_id,
348 348 user=self._rhodecode_db_user.user_id,
349 349 commit_id=current_id,
350 350 f_path=self.request.POST.get('f_path'),
351 351 line_no=self.request.POST.get('line'),
352 352 status_change=(ChangesetStatus.get_status_lbl(status)
353 353 if status else None),
354 354 status_change_type=status,
355 355 comment_type=comment_type,
356 356 resolves_comment_id=resolves_comment_id,
357 357 auth_user=self._rhodecode_user
358 358 )
359 359
360 360 # get status if set !
361 361 if status:
362 362 # if latest status was from pull request and it's closed
363 363 # disallow changing status !
364 364 # dont_allow_on_closed_pull_request = True !
365 365
366 366 try:
367 367 ChangesetStatusModel().set_status(
368 368 self.db_repo.repo_id,
369 369 status,
370 370 self._rhodecode_db_user.user_id,
371 371 comment,
372 372 revision=current_id,
373 373 dont_allow_on_closed_pull_request=True
374 374 )
375 375 except StatusChangeOnClosedPullRequestError:
376 376 msg = _('Changing the status of a commit associated with '
377 377 'a closed pull request is not allowed')
378 378 log.exception(msg)
379 379 h.flash(msg, category='warning')
380 380 raise HTTPFound(h.route_path(
381 381 'repo_commit', repo_name=self.db_repo_name,
382 382 commit_id=current_id))
383 383
384 384 commit = self.db_repo.get_commit(current_id)
385 385 CommentsModel().trigger_commit_comment_hook(
386 386 self.db_repo, self._rhodecode_user, 'create',
387 387 data={'comment': comment, 'commit': commit})
388 388
389 389 # finalize, commit and redirect
390 390 Session().commit()
391 391
392 392 data = {
393 393 'target_id': h.safeid(h.safe_unicode(
394 394 self.request.POST.get('f_path'))),
395 395 }
396 396 if comment:
397 397 c.co = comment
398 398 rendered_comment = render(
399 399 'rhodecode:templates/changeset/changeset_comment_block.mako',
400 400 self._get_template_context(c), self.request)
401 401
402 402 data.update(comment.get_dict())
403 403 data.update({'rendered_text': rendered_comment})
404 404
405 405 return data
406 406
407 407 @LoginRequired()
408 408 @NotAnonymous()
409 409 @HasRepoPermissionAnyDecorator(
410 410 'repository.read', 'repository.write', 'repository.admin')
411 411 @CSRFRequired()
412 412 @view_config(
413 413 route_name='repo_commit_comment_preview', request_method='POST',
414 414 renderer='string', xhr=True)
415 415 def repo_commit_comment_preview(self):
416 416 # Technically a CSRF token is not needed as no state changes with this
417 417 # call. However, as this is a POST is better to have it, so automated
418 418 # tools don't flag it as potential CSRF.
419 419 # Post is required because the payload could be bigger than the maximum
420 420 # allowed by GET.
421 421
422 422 text = self.request.POST.get('text')
423 423 renderer = self.request.POST.get('renderer') or 'rst'
424 424 if text:
425 425 return h.render(text, renderer=renderer, mentions=True,
426 426 repo_name=self.db_repo_name)
427 427 return ''
428 428
429 429 @LoginRequired()
430 430 @NotAnonymous()
431 431 @HasRepoPermissionAnyDecorator(
432 432 'repository.read', 'repository.write', 'repository.admin')
433 433 @CSRFRequired()
434 434 @view_config(
435 435 route_name='repo_commit_comment_history_view', request_method='POST',
436 436 renderer='string', xhr=True)
437 437 def repo_commit_comment_history_view(self):
438 438 c = self.load_default_context()
439 439
440 440 comment_history_id = self.request.matchdict['comment_history_id']
441 441 comment_history = ChangesetCommentHistory.get_or_404(comment_history_id)
442 442 is_repo_comment = comment_history.comment.repo.repo_id == self.db_repo.repo_id
443 443
444 444 if is_repo_comment:
445 445 c.comment_history = comment_history
446 446
447 447 rendered_comment = render(
448 448 'rhodecode:templates/changeset/comment_history.mako',
449 449 self._get_template_context(c)
450 450 , self.request)
451 451 return rendered_comment
452 452 else:
453 453 log.warning('No permissions for user %s to show comment_history_id: %s',
454 454 self._rhodecode_db_user, comment_history_id)
455 455 raise HTTPNotFound()
456 456
457 457 @LoginRequired()
458 458 @NotAnonymous()
459 459 @HasRepoPermissionAnyDecorator(
460 460 'repository.read', 'repository.write', 'repository.admin')
461 461 @CSRFRequired()
462 462 @view_config(
463 463 route_name='repo_commit_comment_attachment_upload', request_method='POST',
464 464 renderer='json_ext', xhr=True)
465 465 def repo_commit_comment_attachment_upload(self):
466 466 c = self.load_default_context()
467 467 upload_key = 'attachment'
468 468
469 469 file_obj = self.request.POST.get(upload_key)
470 470
471 471 if file_obj is None:
472 472 self.request.response.status = 400
473 473 return {'store_fid': None,
474 474 'access_path': None,
475 475 'error': '{} data field is missing'.format(upload_key)}
476 476
477 477 if not hasattr(file_obj, 'filename'):
478 478 self.request.response.status = 400
479 479 return {'store_fid': None,
480 480 'access_path': None,
481 481 'error': 'filename cannot be read from the data field'}
482 482
483 483 filename = file_obj.filename
484 484 file_display_name = filename
485 485
486 486 metadata = {
487 487 'user_uploaded': {'username': self._rhodecode_user.username,
488 488 'user_id': self._rhodecode_user.user_id,
489 489 'ip': self._rhodecode_user.ip_addr}}
490 490
491 491 # TODO(marcink): allow .ini configuration for allowed_extensions, and file-size
492 492 allowed_extensions = [
493 493 'gif', '.jpeg', '.jpg', '.png', '.docx', '.gz', '.log', '.pdf',
494 494 '.pptx', '.txt', '.xlsx', '.zip']
495 495 max_file_size = 10 * 1024 * 1024 # 10MB, also validated via dropzone.js
496 496
497 497 try:
498 498 storage = store_utils.get_file_storage(self.request.registry.settings)
499 499 store_uid, metadata = storage.save_file(
500 500 file_obj.file, filename, extra_metadata=metadata,
501 501 extensions=allowed_extensions, max_filesize=max_file_size)
502 502 except FileNotAllowedException:
503 503 self.request.response.status = 400
504 504 permitted_extensions = ', '.join(allowed_extensions)
505 505 error_msg = 'File `{}` is not allowed. ' \
506 506 'Only following extensions are permitted: {}'.format(
507 507 filename, permitted_extensions)
508 508 return {'store_fid': None,
509 509 'access_path': None,
510 510 'error': error_msg}
511 511 except FileOverSizeException:
512 512 self.request.response.status = 400
513 513 limit_mb = h.format_byte_size_binary(max_file_size)
514 514 return {'store_fid': None,
515 515 'access_path': None,
516 516 'error': 'File {} is exceeding allowed limit of {}.'.format(
517 517 filename, limit_mb)}
518 518
519 519 try:
520 520 entry = FileStore.create(
521 521 file_uid=store_uid, filename=metadata["filename"],
522 522 file_hash=metadata["sha256"], file_size=metadata["size"],
523 523 file_display_name=file_display_name,
524 524 file_description=u'comment attachment `{}`'.format(safe_unicode(filename)),
525 525 hidden=True, check_acl=True, user_id=self._rhodecode_user.user_id,
526 526 scope_repo_id=self.db_repo.repo_id
527 527 )
528 528 Session().add(entry)
529 529 Session().commit()
530 530 log.debug('Stored upload in DB as %s', entry)
531 531 except Exception:
532 532 log.exception('Failed to store file %s', filename)
533 533 self.request.response.status = 400
534 534 return {'store_fid': None,
535 535 'access_path': None,
536 536 'error': 'File {} failed to store in DB.'.format(filename)}
537 537
538 538 Session().commit()
539 539
540 540 return {
541 541 'store_fid': store_uid,
542 542 'access_path': h.route_path(
543 543 'download_file', fid=store_uid),
544 544 'fqn_access_path': h.route_url(
545 545 'download_file', fid=store_uid),
546 546 'repo_access_path': h.route_path(
547 547 'repo_artifacts_get', repo_name=self.db_repo_name, uid=store_uid),
548 548 'repo_fqn_access_path': h.route_url(
549 549 'repo_artifacts_get', repo_name=self.db_repo_name, uid=store_uid),
550 550 }
551 551
552 552 @LoginRequired()
553 553 @NotAnonymous()
554 554 @HasRepoPermissionAnyDecorator(
555 555 'repository.read', 'repository.write', 'repository.admin')
556 556 @CSRFRequired()
557 557 @view_config(
558 558 route_name='repo_commit_comment_delete', request_method='POST',
559 559 renderer='json_ext')
560 560 def repo_commit_comment_delete(self):
561 561 commit_id = self.request.matchdict['commit_id']
562 562 comment_id = self.request.matchdict['comment_id']
563 563
564 564 comment = ChangesetComment.get_or_404(comment_id)
565 565 if not comment:
566 566 log.debug('Comment with id:%s not found, skipping', comment_id)
567 567 # comment already deleted in another call probably
568 568 return True
569 569
570 570 if comment.immutable:
571 571 # don't allow deleting comments that are immutable
572 572 raise HTTPForbidden()
573 573
574 574 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name)
575 575 super_admin = h.HasPermissionAny('hg.admin')()
576 576 comment_owner = (comment.author.user_id == self._rhodecode_db_user.user_id)
577 577 is_repo_comment = comment.repo.repo_id == self.db_repo.repo_id
578 578 comment_repo_admin = is_repo_admin and is_repo_comment
579 579
580 580 if super_admin or comment_owner or comment_repo_admin:
581 581 CommentsModel().delete(comment=comment, auth_user=self._rhodecode_user)
582 582 Session().commit()
583 583 return True
584 584 else:
585 585 log.warning('No permissions for user %s to delete comment_id: %s',
586 586 self._rhodecode_db_user, comment_id)
587 587 raise HTTPNotFound()
588 588
589 589 @LoginRequired()
590 590 @NotAnonymous()
591 591 @HasRepoPermissionAnyDecorator(
592 592 'repository.read', 'repository.write', 'repository.admin')
593 593 @CSRFRequired()
594 594 @view_config(
595 595 route_name='repo_commit_comment_edit', request_method='POST',
596 596 renderer='json_ext')
597 597 def repo_commit_comment_edit(self):
598 598 self.load_default_context()
599 599
600 600 comment_id = self.request.matchdict['comment_id']
601 601 comment = ChangesetComment.get_or_404(comment_id)
602 602
603 603 if comment.immutable:
604 604 # don't allow deleting comments that are immutable
605 605 raise HTTPForbidden()
606 606
607 607 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name)
608 608 super_admin = h.HasPermissionAny('hg.admin')()
609 609 comment_owner = (comment.author.user_id == self._rhodecode_db_user.user_id)
610 610 is_repo_comment = comment.repo.repo_id == self.db_repo.repo_id
611 611 comment_repo_admin = is_repo_admin and is_repo_comment
612 612
613 613 if super_admin or comment_owner or comment_repo_admin:
614 614 text = self.request.POST.get('text')
615 615 version = self.request.POST.get('version')
616 616 if text == comment.text:
617 617 log.warning(
618 618 'Comment(repo): '
619 619 'Trying to create new version '
620 620 'with the same comment body {}'.format(
621 621 comment_id,
622 622 )
623 623 )
624 624 raise HTTPNotFound()
625 625
626 626 if version.isdigit():
627 627 version = int(version)
628 628 else:
629 629 log.warning(
630 630 'Comment(repo): Wrong version type {} {} '
631 631 'for comment {}'.format(
632 632 version,
633 633 type(version),
634 634 comment_id,
635 635 )
636 636 )
637 637 raise HTTPNotFound()
638 638
639 639 try:
640 640 comment_history = CommentsModel().edit(
641 641 comment_id=comment_id,
642 642 text=text,
643 643 auth_user=self._rhodecode_user,
644 644 version=version,
645 645 )
646 646 except CommentVersionMismatch:
647 647 raise HTTPConflict()
648 648
649 649 if not comment_history:
650 650 raise HTTPNotFound()
651 651
652 652 commit_id = self.request.matchdict['commit_id']
653 653 commit = self.db_repo.get_commit(commit_id)
654 654 CommentsModel().trigger_commit_comment_hook(
655 655 self.db_repo, self._rhodecode_user, 'edit',
656 656 data={'comment': comment, 'commit': commit})
657 657
658 658 Session().commit()
659 659 return {
660 660 'comment_history_id': comment_history.comment_history_id,
661 661 'comment_id': comment.comment_id,
662 662 'comment_version': comment_history.version,
663 663 'comment_author_username': comment_history.author.username,
664 664 'comment_author_gravatar': h.gravatar_url(comment_history.author.email, 16),
665 665 'comment_created_on': h.age_component(comment_history.created_on,
666 666 time_is_local=True),
667 667 }
668 668 else:
669 669 log.warning('No permissions for user %s to edit comment_id: %s',
670 670 self._rhodecode_db_user, comment_id)
671 671 raise HTTPNotFound()
672 672
673 673 @LoginRequired()
674 674 @HasRepoPermissionAnyDecorator(
675 675 'repository.read', 'repository.write', 'repository.admin')
676 676 @view_config(
677 677 route_name='repo_commit_data', request_method='GET',
678 678 renderer='json_ext', xhr=True)
679 679 def repo_commit_data(self):
680 680 commit_id = self.request.matchdict['commit_id']
681 681 self.load_default_context()
682 682
683 683 try:
684 684 return self.rhodecode_vcs_repo.get_commit(commit_id=commit_id)
685 685 except CommitDoesNotExistError as e:
686 686 return EmptyCommit(message=str(e))
687 687
688 688 @LoginRequired()
689 689 @HasRepoPermissionAnyDecorator(
690 690 'repository.read', 'repository.write', 'repository.admin')
691 691 @view_config(
692 692 route_name='repo_commit_children', request_method='GET',
693 693 renderer='json_ext', xhr=True)
694 694 def repo_commit_children(self):
695 695 commit_id = self.request.matchdict['commit_id']
696 696 self.load_default_context()
697 697
698 698 try:
699 699 commit = self.rhodecode_vcs_repo.get_commit(commit_id=commit_id)
700 700 children = commit.children
701 701 except CommitDoesNotExistError:
702 702 children = []
703 703
704 704 result = {"results": children}
705 705 return result
706 706
707 707 @LoginRequired()
708 708 @HasRepoPermissionAnyDecorator(
709 709 'repository.read', 'repository.write', 'repository.admin')
710 710 @view_config(
711 711 route_name='repo_commit_parents', request_method='GET',
712 712 renderer='json_ext')
713 713 def repo_commit_parents(self):
714 714 commit_id = self.request.matchdict['commit_id']
715 715 self.load_default_context()
716 716
717 717 try:
718 718 commit = self.rhodecode_vcs_repo.get_commit(commit_id=commit_id)
719 719 parents = commit.parents
720 720 except CommitDoesNotExistError:
721 721 parents = []
722 722 result = {"results": parents}
723 723 return result
@@ -1,840 +1,840 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2020 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 import datetime
25 25
26 26 import logging
27 27 import traceback
28 28 import collections
29 29
30 30 from pyramid.threadlocal import get_current_registry, get_current_request
31 31 from sqlalchemy.sql.expression import null
32 32 from sqlalchemy.sql.functions import coalesce
33 33
34 34 from rhodecode.lib import helpers as h, diffs, channelstream, hooks_utils
35 35 from rhodecode.lib import audit_logger
36 36 from rhodecode.lib.exceptions import CommentVersionMismatch
37 37 from rhodecode.lib.utils2 import extract_mentioned_users, safe_str, safe_int
38 38 from rhodecode.model import BaseModel
39 39 from rhodecode.model.db import (
40 40 ChangesetComment,
41 41 User,
42 42 Notification,
43 43 PullRequest,
44 44 AttributeDict,
45 45 ChangesetCommentHistory,
46 46 )
47 47 from rhodecode.model.notification import NotificationModel
48 48 from rhodecode.model.meta import Session
49 49 from rhodecode.model.settings import VcsSettingsModel
50 50 from rhodecode.model.notification import EmailNotificationModel
51 51 from rhodecode.model.validation_schema.schemas import comment_schema
52 52
53 53
54 54 log = logging.getLogger(__name__)
55 55
56 56
57 57 class CommentsModel(BaseModel):
58 58
59 59 cls = ChangesetComment
60 60
61 61 DIFF_CONTEXT_BEFORE = 3
62 62 DIFF_CONTEXT_AFTER = 3
63 63
64 64 def __get_commit_comment(self, changeset_comment):
65 65 return self._get_instance(ChangesetComment, changeset_comment)
66 66
67 67 def __get_pull_request(self, pull_request):
68 68 return self._get_instance(PullRequest, pull_request)
69 69
70 70 def _extract_mentions(self, s):
71 71 user_objects = []
72 72 for username in extract_mentioned_users(s):
73 73 user_obj = User.get_by_username(username, case_insensitive=True)
74 74 if user_obj:
75 75 user_objects.append(user_obj)
76 76 return user_objects
77 77
78 78 def _get_renderer(self, global_renderer='rst', request=None):
79 79 request = request or get_current_request()
80 80
81 81 try:
82 82 global_renderer = request.call_context.visual.default_renderer
83 83 except AttributeError:
84 84 log.debug("Renderer not set, falling back "
85 85 "to default renderer '%s'", global_renderer)
86 86 except Exception:
87 87 log.error(traceback.format_exc())
88 88 return global_renderer
89 89
90 90 def aggregate_comments(self, comments, versions, show_version, inline=False):
91 91 # group by versions, and count until, and display objects
92 92
93 93 comment_groups = collections.defaultdict(list)
94 94 [comment_groups[
95 95 _co.pull_request_version_id].append(_co) for _co in comments]
96 96
97 97 def yield_comments(pos):
98 98 for co in comment_groups[pos]:
99 99 yield co
100 100
101 101 comment_versions = collections.defaultdict(
102 102 lambda: collections.defaultdict(list))
103 103 prev_prvid = -1
104 104 # fake last entry with None, to aggregate on "latest" version which
105 105 # doesn't have an pull_request_version_id
106 106 for ver in versions + [AttributeDict({'pull_request_version_id': None})]:
107 107 prvid = ver.pull_request_version_id
108 108 if prev_prvid == -1:
109 109 prev_prvid = prvid
110 110
111 111 for co in yield_comments(prvid):
112 112 comment_versions[prvid]['at'].append(co)
113 113
114 114 # save until
115 115 current = comment_versions[prvid]['at']
116 116 prev_until = comment_versions[prev_prvid]['until']
117 117 cur_until = prev_until + current
118 118 comment_versions[prvid]['until'].extend(cur_until)
119 119
120 120 # save outdated
121 121 if inline:
122 122 outdated = [x for x in cur_until
123 123 if x.outdated_at_version(show_version)]
124 124 else:
125 125 outdated = [x for x in cur_until
126 126 if x.older_than_version(show_version)]
127 127 display = [x for x in cur_until if x not in outdated]
128 128
129 129 comment_versions[prvid]['outdated'] = outdated
130 130 comment_versions[prvid]['display'] = display
131 131
132 132 prev_prvid = prvid
133 133
134 134 return comment_versions
135 135
136 136 def get_repository_comments(self, repo, comment_type=None, user=None, commit_id=None):
137 137 qry = Session().query(ChangesetComment) \
138 138 .filter(ChangesetComment.repo == repo)
139 139
140 140 if comment_type and comment_type in ChangesetComment.COMMENT_TYPES:
141 141 qry = qry.filter(ChangesetComment.comment_type == comment_type)
142 142
143 143 if user:
144 144 user = self._get_user(user)
145 145 if user:
146 146 qry = qry.filter(ChangesetComment.user_id == user.user_id)
147 147
148 148 if commit_id:
149 149 qry = qry.filter(ChangesetComment.revision == commit_id)
150 150
151 151 qry = qry.order_by(ChangesetComment.created_on)
152 152 return qry.all()
153 153
154 154 def get_repository_unresolved_todos(self, repo):
155 155 todos = Session().query(ChangesetComment) \
156 156 .filter(ChangesetComment.repo == repo) \
157 157 .filter(ChangesetComment.resolved_by == None) \
158 158 .filter(ChangesetComment.comment_type
159 159 == ChangesetComment.COMMENT_TYPE_TODO)
160 160 todos = todos.all()
161 161
162 162 return todos
163 163
164 164 def get_pull_request_unresolved_todos(self, pull_request, show_outdated=True):
165 165
166 166 todos = Session().query(ChangesetComment) \
167 167 .filter(ChangesetComment.pull_request == pull_request) \
168 168 .filter(ChangesetComment.resolved_by == None) \
169 169 .filter(ChangesetComment.comment_type
170 170 == ChangesetComment.COMMENT_TYPE_TODO)
171 171
172 172 if not show_outdated:
173 173 todos = todos.filter(
174 174 coalesce(ChangesetComment.display_state, '') !=
175 175 ChangesetComment.COMMENT_OUTDATED)
176 176
177 177 todos = todos.all()
178 178
179 179 return todos
180 180
181 181 def get_pull_request_resolved_todos(self, pull_request, show_outdated=True):
182 182
183 183 todos = Session().query(ChangesetComment) \
184 184 .filter(ChangesetComment.pull_request == pull_request) \
185 185 .filter(ChangesetComment.resolved_by != None) \
186 186 .filter(ChangesetComment.comment_type
187 187 == ChangesetComment.COMMENT_TYPE_TODO)
188 188
189 189 if not show_outdated:
190 190 todos = todos.filter(
191 191 coalesce(ChangesetComment.display_state, '') !=
192 192 ChangesetComment.COMMENT_OUTDATED)
193 193
194 194 todos = todos.all()
195 195
196 196 return todos
197 197
198 198 def get_commit_unresolved_todos(self, commit_id, show_outdated=True):
199 199
200 200 todos = Session().query(ChangesetComment) \
201 201 .filter(ChangesetComment.revision == commit_id) \
202 202 .filter(ChangesetComment.resolved_by == None) \
203 203 .filter(ChangesetComment.comment_type
204 204 == ChangesetComment.COMMENT_TYPE_TODO)
205 205
206 206 if not show_outdated:
207 207 todos = todos.filter(
208 208 coalesce(ChangesetComment.display_state, '') !=
209 209 ChangesetComment.COMMENT_OUTDATED)
210 210
211 211 todos = todos.all()
212 212
213 213 return todos
214 214
215 215 def get_commit_resolved_todos(self, commit_id, show_outdated=True):
216 216
217 217 todos = Session().query(ChangesetComment) \
218 218 .filter(ChangesetComment.revision == commit_id) \
219 219 .filter(ChangesetComment.resolved_by != None) \
220 220 .filter(ChangesetComment.comment_type
221 221 == ChangesetComment.COMMENT_TYPE_TODO)
222 222
223 223 if not show_outdated:
224 224 todos = todos.filter(
225 225 coalesce(ChangesetComment.display_state, '') !=
226 226 ChangesetComment.COMMENT_OUTDATED)
227 227
228 228 todos = todos.all()
229 229
230 230 return todos
231 231
232 232 def _log_audit_action(self, action, action_data, auth_user, comment):
233 233 audit_logger.store(
234 234 action=action,
235 235 action_data=action_data,
236 236 user=auth_user,
237 237 repo=comment.repo)
238 238
239 239 def create(self, text, repo, user, commit_id=None, pull_request=None,
240 240 f_path=None, line_no=None, status_change=None,
241 241 status_change_type=None, comment_type=None,
242 242 resolves_comment_id=None, closing_pr=False, send_email=True,
243 243 renderer=None, auth_user=None, extra_recipients=None):
244 244 """
245 245 Creates new comment for commit or pull request.
246 246 IF status_change is not none this comment is associated with a
247 247 status change of commit or commit associated with pull request
248 248
249 249 :param text:
250 250 :param repo:
251 251 :param user:
252 252 :param commit_id:
253 253 :param pull_request:
254 254 :param f_path:
255 255 :param line_no:
256 256 :param status_change: Label for status change
257 257 :param comment_type: Type of comment
258 258 :param resolves_comment_id: id of comment which this one will resolve
259 259 :param status_change_type: type of status change
260 260 :param closing_pr:
261 261 :param send_email:
262 262 :param renderer: pick renderer for this comment
263 263 :param auth_user: current authenticated user calling this method
264 264 :param extra_recipients: list of extra users to be added to recipients
265 265 """
266 266
267 267 if not text:
268 268 log.warning('Missing text for comment, skipping...')
269 269 return
270 270 request = get_current_request()
271 271 _ = request.translate
272 272
273 273 if not renderer:
274 274 renderer = self._get_renderer(request=request)
275 275
276 276 repo = self._get_repo(repo)
277 277 user = self._get_user(user)
278 278 auth_user = auth_user or user
279 279
280 280 schema = comment_schema.CommentSchema()
281 281 validated_kwargs = schema.deserialize(dict(
282 282 comment_body=text,
283 283 comment_type=comment_type,
284 284 comment_file=f_path,
285 285 comment_line=line_no,
286 286 renderer_type=renderer,
287 287 status_change=status_change_type,
288 288 resolves_comment_id=resolves_comment_id,
289 289 repo=repo.repo_id,
290 290 user=user.user_id,
291 291 ))
292 292
293 293 comment = ChangesetComment()
294 294 comment.renderer = validated_kwargs['renderer_type']
295 295 comment.text = validated_kwargs['comment_body']
296 296 comment.f_path = validated_kwargs['comment_file']
297 297 comment.line_no = validated_kwargs['comment_line']
298 298 comment.comment_type = validated_kwargs['comment_type']
299 299
300 300 comment.repo = repo
301 301 comment.author = user
302 302 resolved_comment = self.__get_commit_comment(
303 303 validated_kwargs['resolves_comment_id'])
304 304 # check if the comment actually belongs to this PR
305 305 if resolved_comment and resolved_comment.pull_request and \
306 306 resolved_comment.pull_request != pull_request:
307 307 log.warning('Comment tried to resolved unrelated todo comment: %s',
308 308 resolved_comment)
309 309 # comment not bound to this pull request, forbid
310 310 resolved_comment = None
311 311
312 312 elif resolved_comment and resolved_comment.repo and \
313 313 resolved_comment.repo != repo:
314 314 log.warning('Comment tried to resolved unrelated todo comment: %s',
315 315 resolved_comment)
316 316 # comment not bound to this repo, forbid
317 317 resolved_comment = None
318 318
319 319 comment.resolved_comment = resolved_comment
320 320
321 321 pull_request_id = pull_request
322 322
323 323 commit_obj = None
324 324 pull_request_obj = None
325 325
326 326 if commit_id:
327 327 notification_type = EmailNotificationModel.TYPE_COMMIT_COMMENT
328 328 # do a lookup, so we don't pass something bad here
329 329 commit_obj = repo.scm_instance().get_commit(commit_id=commit_id)
330 330 comment.revision = commit_obj.raw_id
331 331
332 332 elif pull_request_id:
333 333 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST_COMMENT
334 334 pull_request_obj = self.__get_pull_request(pull_request_id)
335 335 comment.pull_request = pull_request_obj
336 336 else:
337 337 raise Exception('Please specify commit or pull_request_id')
338 338
339 339 Session().add(comment)
340 340 Session().flush()
341 341 kwargs = {
342 342 'user': user,
343 343 'renderer_type': renderer,
344 344 'repo_name': repo.repo_name,
345 345 'status_change': status_change,
346 346 'status_change_type': status_change_type,
347 347 'comment_body': text,
348 348 'comment_file': f_path,
349 349 'comment_line': line_no,
350 350 'comment_type': comment_type or 'note',
351 351 'comment_id': comment.comment_id
352 352 }
353 353
354 354 if commit_obj:
355 355 recipients = ChangesetComment.get_users(
356 356 revision=commit_obj.raw_id)
357 357 # add commit author if it's in RhodeCode system
358 358 cs_author = User.get_from_cs_author(commit_obj.author)
359 359 if not cs_author:
360 360 # use repo owner if we cannot extract the author correctly
361 361 cs_author = repo.user
362 362 recipients += [cs_author]
363 363
364 364 commit_comment_url = self.get_url(comment, request=request)
365 365 commit_comment_reply_url = self.get_url(
366 366 comment, request=request,
367 367 anchor='comment-{}/?/ReplyToComment'.format(comment.comment_id))
368 368
369 369 target_repo_url = h.link_to(
370 370 repo.repo_name,
371 371 h.route_url('repo_summary', repo_name=repo.repo_name))
372 372
373 373 commit_url = h.route_url('repo_commit', repo_name=repo.repo_name,
374 374 commit_id=commit_id)
375 375
376 376 # commit specifics
377 377 kwargs.update({
378 378 'commit': commit_obj,
379 379 'commit_message': commit_obj.message,
380 380 'commit_target_repo_url': target_repo_url,
381 381 'commit_comment_url': commit_comment_url,
382 382 'commit_comment_reply_url': commit_comment_reply_url,
383 383 'commit_url': commit_url,
384 384 'thread_ids': [commit_url, commit_comment_url],
385 385 })
386 386
387 387 elif pull_request_obj:
388 388 # get the current participants of this pull request
389 389 recipients = ChangesetComment.get_users(
390 390 pull_request_id=pull_request_obj.pull_request_id)
391 391 # add pull request author
392 392 recipients += [pull_request_obj.author]
393 393
394 394 # add the reviewers to notification
395 395 recipients += [x.user for x in pull_request_obj.reviewers]
396 396
397 397 pr_target_repo = pull_request_obj.target_repo
398 398 pr_source_repo = pull_request_obj.source_repo
399 399
400 400 pr_comment_url = self.get_url(comment, request=request)
401 401 pr_comment_reply_url = self.get_url(
402 402 comment, request=request,
403 403 anchor='comment-{}/?/ReplyToComment'.format(comment.comment_id))
404 404
405 405 pr_url = h.route_url(
406 406 'pullrequest_show',
407 407 repo_name=pr_target_repo.repo_name,
408 408 pull_request_id=pull_request_obj.pull_request_id, )
409 409
410 410 # set some variables for email notification
411 411 pr_target_repo_url = h.route_url(
412 412 'repo_summary', repo_name=pr_target_repo.repo_name)
413 413
414 414 pr_source_repo_url = h.route_url(
415 415 'repo_summary', repo_name=pr_source_repo.repo_name)
416 416
417 417 # pull request specifics
418 418 kwargs.update({
419 419 'pull_request': pull_request_obj,
420 420 'pr_id': pull_request_obj.pull_request_id,
421 421 'pull_request_url': pr_url,
422 422 'pull_request_target_repo': pr_target_repo,
423 423 'pull_request_target_repo_url': pr_target_repo_url,
424 424 'pull_request_source_repo': pr_source_repo,
425 425 'pull_request_source_repo_url': pr_source_repo_url,
426 426 'pr_comment_url': pr_comment_url,
427 427 'pr_comment_reply_url': pr_comment_reply_url,
428 428 'pr_closing': closing_pr,
429 429 'thread_ids': [pr_url, pr_comment_url],
430 430 })
431 431
432 432 recipients += [self._get_user(u) for u in (extra_recipients or [])]
433 433
434 434 if send_email:
435 435 # pre-generate the subject for notification itself
436 436 (subject, _e, body_plaintext) = EmailNotificationModel().render_email(
437 437 notification_type, **kwargs)
438 438
439 439 mention_recipients = set(
440 440 self._extract_mentions(text)).difference(recipients)
441 441
442 442 # create notification objects, and emails
443 443 NotificationModel().create(
444 444 created_by=user,
445 445 notification_subject=subject,
446 446 notification_body=body_plaintext,
447 447 notification_type=notification_type,
448 448 recipients=recipients,
449 449 mention_recipients=mention_recipients,
450 450 email_kwargs=kwargs,
451 451 )
452 452
453 453 Session().flush()
454 454 if comment.pull_request:
455 455 action = 'repo.pull_request.comment.create'
456 456 else:
457 457 action = 'repo.commit.comment.create'
458 458
459 459 comment_data = comment.get_api_data()
460 460 self._log_audit_action(
461 461 action, {'data': comment_data}, auth_user, comment)
462 462
463 463 msg_url = ''
464 464 channel = None
465 465 if commit_obj:
466 466 msg_url = commit_comment_url
467 467 repo_name = repo.repo_name
468 468 channel = u'/repo${}$/commit/{}'.format(
469 469 repo_name,
470 470 commit_obj.raw_id
471 471 )
472 472 elif pull_request_obj:
473 473 msg_url = pr_comment_url
474 474 repo_name = pr_target_repo.repo_name
475 475 channel = u'/repo${}$/pr/{}'.format(
476 476 repo_name,
477 477 pull_request_id
478 478 )
479 479
480 480 message = '<strong>{}</strong> {} - ' \
481 481 '<a onclick="window.location=\'{}\';' \
482 482 'window.location.reload()">' \
483 483 '<strong>{}</strong></a>'
484 484 message = message.format(
485 485 user.username, _('made a comment'), msg_url,
486 486 _('Show it now'))
487 487
488 488 channelstream.post_message(
489 489 channel, message, user.username,
490 490 registry=get_current_registry())
491 491
492 492 return comment
493 493
494 494 def edit(self, comment_id, text, auth_user, version):
495 495 """
496 496 Change existing comment for commit or pull request.
497 497
498 498 :param comment_id:
499 499 :param text:
500 500 :param auth_user: current authenticated user calling this method
501 501 :param version: last comment version
502 502 """
503 503 if not text:
504 504 log.warning('Missing text for comment, skipping...')
505 505 return
506 506
507 507 comment = ChangesetComment.get(comment_id)
508 508 old_comment_text = comment.text
509 509 comment.text = text
510 510 comment.modified_at = datetime.datetime.now()
511 511 version = safe_int(version)
512 512
513 513 # NOTE(marcink): this returns initial comment + edits, so v2 from ui
514 514 # would return 3 here
515 515 comment_version = ChangesetCommentHistory.get_version(comment_id)
516 516
517 517 if isinstance(version, (int, long)) and (comment_version - version) != 1:
518 518 log.warning(
519 519 'Version mismatch comment_version {} submitted {}, skipping'.format(
520 520 comment_version-1, # -1 since note above
521 521 version
522 522 )
523 523 )
524 524 raise CommentVersionMismatch()
525 525
526 526 comment_history = ChangesetCommentHistory()
527 527 comment_history.comment_id = comment_id
528 528 comment_history.version = comment_version
529 529 comment_history.created_by_user_id = auth_user.user_id
530 530 comment_history.text = old_comment_text
531 531 # TODO add email notification
532 532 Session().add(comment_history)
533 533 Session().add(comment)
534 534 Session().flush()
535 535
536 536 if comment.pull_request:
537 537 action = 'repo.pull_request.comment.edit'
538 538 else:
539 539 action = 'repo.commit.comment.edit'
540 540
541 541 comment_data = comment.get_api_data()
542 542 comment_data['old_comment_text'] = old_comment_text
543 543 self._log_audit_action(
544 544 action, {'data': comment_data}, auth_user, comment)
545 545
546 546 return comment_history
547 547
548 548 def delete(self, comment, auth_user):
549 549 """
550 550 Deletes given comment
551 551 """
552 552 comment = self.__get_commit_comment(comment)
553 553 old_data = comment.get_api_data()
554 554 Session().delete(comment)
555 555
556 556 if comment.pull_request:
557 557 action = 'repo.pull_request.comment.delete'
558 558 else:
559 559 action = 'repo.commit.comment.delete'
560 560
561 561 self._log_audit_action(
562 562 action, {'old_data': old_data}, auth_user, comment)
563 563
564 564 return comment
565 565
566 566 def get_all_comments(self, repo_id, revision=None, pull_request=None):
567 567 q = ChangesetComment.query()\
568 568 .filter(ChangesetComment.repo_id == repo_id)
569 569 if revision:
570 570 q = q.filter(ChangesetComment.revision == revision)
571 571 elif pull_request:
572 572 pull_request = self.__get_pull_request(pull_request)
573 573 q = q.filter(ChangesetComment.pull_request == pull_request)
574 574 else:
575 575 raise Exception('Please specify commit or pull_request')
576 576 q = q.order_by(ChangesetComment.created_on)
577 577 return q.all()
578 578
579 579 def get_url(self, comment, request=None, permalink=False, anchor=None):
580 580 if not request:
581 581 request = get_current_request()
582 582
583 583 comment = self.__get_commit_comment(comment)
584 584 if anchor is None:
585 585 anchor = 'comment-{}'.format(comment.comment_id)
586 586
587 587 if comment.pull_request:
588 588 pull_request = comment.pull_request
589 589 if permalink:
590 590 return request.route_url(
591 591 'pull_requests_global',
592 592 pull_request_id=pull_request.pull_request_id,
593 593 _anchor=anchor)
594 594 else:
595 595 return request.route_url(
596 596 'pullrequest_show',
597 597 repo_name=safe_str(pull_request.target_repo.repo_name),
598 598 pull_request_id=pull_request.pull_request_id,
599 599 _anchor=anchor)
600 600
601 601 else:
602 602 repo = comment.repo
603 603 commit_id = comment.revision
604 604
605 605 if permalink:
606 606 return request.route_url(
607 607 'repo_commit', repo_name=safe_str(repo.repo_id),
608 608 commit_id=commit_id,
609 609 _anchor=anchor)
610 610
611 611 else:
612 612 return request.route_url(
613 613 'repo_commit', repo_name=safe_str(repo.repo_name),
614 614 commit_id=commit_id,
615 615 _anchor=anchor)
616 616
617 617 def get_comments(self, repo_id, revision=None, pull_request=None):
618 618 """
619 619 Gets main comments based on revision or pull_request_id
620 620
621 621 :param repo_id:
622 622 :param revision:
623 623 :param pull_request:
624 624 """
625 625
626 626 q = ChangesetComment.query()\
627 627 .filter(ChangesetComment.repo_id == repo_id)\
628 628 .filter(ChangesetComment.line_no == None)\
629 629 .filter(ChangesetComment.f_path == None)
630 630 if revision:
631 631 q = q.filter(ChangesetComment.revision == revision)
632 632 elif pull_request:
633 633 pull_request = self.__get_pull_request(pull_request)
634 634 q = q.filter(ChangesetComment.pull_request == pull_request)
635 635 else:
636 636 raise Exception('Please specify commit or pull_request')
637 637 q = q.order_by(ChangesetComment.created_on)
638 638 return q.all()
639 639
640 640 def get_inline_comments(self, repo_id, revision=None, pull_request=None):
641 641 q = self._get_inline_comments_query(repo_id, revision, pull_request)
642 642 return self._group_comments_by_path_and_line_number(q)
643 643
644 def get_inline_comments_count(self, inline_comments, skip_outdated=True,
644 def get_inline_comments_as_list(self, inline_comments, skip_outdated=True,
645 645 version=None):
646 646 inline_cnt = 0
647 647 for fname, per_line_comments in inline_comments.iteritems():
648 648 for lno, comments in per_line_comments.iteritems():
649 649 for comm in comments:
650 650 if not comm.outdated_at_version(version) and skip_outdated:
651 651 inline_cnt += 1
652 652
653 653 return inline_cnt
654 654
655 655 def get_outdated_comments(self, repo_id, pull_request):
656 656 # TODO: johbo: Remove `repo_id`, it is not needed to find the comments
657 657 # of a pull request.
658 658 q = self._all_inline_comments_of_pull_request(pull_request)
659 659 q = q.filter(
660 660 ChangesetComment.display_state ==
661 661 ChangesetComment.COMMENT_OUTDATED
662 662 ).order_by(ChangesetComment.comment_id.asc())
663 663
664 664 return self._group_comments_by_path_and_line_number(q)
665 665
666 666 def _get_inline_comments_query(self, repo_id, revision, pull_request):
667 667 # TODO: johbo: Split this into two methods: One for PR and one for
668 668 # commit.
669 669 if revision:
670 670 q = Session().query(ChangesetComment).filter(
671 671 ChangesetComment.repo_id == repo_id,
672 672 ChangesetComment.line_no != null(),
673 673 ChangesetComment.f_path != null(),
674 674 ChangesetComment.revision == revision)
675 675
676 676 elif pull_request:
677 677 pull_request = self.__get_pull_request(pull_request)
678 678 if not CommentsModel.use_outdated_comments(pull_request):
679 679 q = self._visible_inline_comments_of_pull_request(pull_request)
680 680 else:
681 681 q = self._all_inline_comments_of_pull_request(pull_request)
682 682
683 683 else:
684 684 raise Exception('Please specify commit or pull_request_id')
685 685 q = q.order_by(ChangesetComment.comment_id.asc())
686 686 return q
687 687
688 688 def _group_comments_by_path_and_line_number(self, q):
689 689 comments = q.all()
690 690 paths = collections.defaultdict(lambda: collections.defaultdict(list))
691 691 for co in comments:
692 692 paths[co.f_path][co.line_no].append(co)
693 693 return paths
694 694
695 695 @classmethod
696 696 def needed_extra_diff_context(cls):
697 697 return max(cls.DIFF_CONTEXT_BEFORE, cls.DIFF_CONTEXT_AFTER)
698 698
699 699 def outdate_comments(self, pull_request, old_diff_data, new_diff_data):
700 700 if not CommentsModel.use_outdated_comments(pull_request):
701 701 return
702 702
703 703 comments = self._visible_inline_comments_of_pull_request(pull_request)
704 704 comments_to_outdate = comments.all()
705 705
706 706 for comment in comments_to_outdate:
707 707 self._outdate_one_comment(comment, old_diff_data, new_diff_data)
708 708
709 709 def _outdate_one_comment(self, comment, old_diff_proc, new_diff_proc):
710 710 diff_line = _parse_comment_line_number(comment.line_no)
711 711
712 712 try:
713 713 old_context = old_diff_proc.get_context_of_line(
714 714 path=comment.f_path, diff_line=diff_line)
715 715 new_context = new_diff_proc.get_context_of_line(
716 716 path=comment.f_path, diff_line=diff_line)
717 717 except (diffs.LineNotInDiffException,
718 718 diffs.FileNotInDiffException):
719 719 comment.display_state = ChangesetComment.COMMENT_OUTDATED
720 720 return
721 721
722 722 if old_context == new_context:
723 723 return
724 724
725 725 if self._should_relocate_diff_line(diff_line):
726 726 new_diff_lines = new_diff_proc.find_context(
727 727 path=comment.f_path, context=old_context,
728 728 offset=self.DIFF_CONTEXT_BEFORE)
729 729 if not new_diff_lines:
730 730 comment.display_state = ChangesetComment.COMMENT_OUTDATED
731 731 else:
732 732 new_diff_line = self._choose_closest_diff_line(
733 733 diff_line, new_diff_lines)
734 734 comment.line_no = _diff_to_comment_line_number(new_diff_line)
735 735 else:
736 736 comment.display_state = ChangesetComment.COMMENT_OUTDATED
737 737
738 738 def _should_relocate_diff_line(self, diff_line):
739 739 """
740 740 Checks if relocation shall be tried for the given `diff_line`.
741 741
742 742 If a comment points into the first lines, then we can have a situation
743 743 that after an update another line has been added on top. In this case
744 744 we would find the context still and move the comment around. This
745 745 would be wrong.
746 746 """
747 747 should_relocate = (
748 748 (diff_line.new and diff_line.new > self.DIFF_CONTEXT_BEFORE) or
749 749 (diff_line.old and diff_line.old > self.DIFF_CONTEXT_BEFORE))
750 750 return should_relocate
751 751
752 752 def _choose_closest_diff_line(self, diff_line, new_diff_lines):
753 753 candidate = new_diff_lines[0]
754 754 best_delta = _diff_line_delta(diff_line, candidate)
755 755 for new_diff_line in new_diff_lines[1:]:
756 756 delta = _diff_line_delta(diff_line, new_diff_line)
757 757 if delta < best_delta:
758 758 candidate = new_diff_line
759 759 best_delta = delta
760 760 return candidate
761 761
762 762 def _visible_inline_comments_of_pull_request(self, pull_request):
763 763 comments = self._all_inline_comments_of_pull_request(pull_request)
764 764 comments = comments.filter(
765 765 coalesce(ChangesetComment.display_state, '') !=
766 766 ChangesetComment.COMMENT_OUTDATED)
767 767 return comments
768 768
769 769 def _all_inline_comments_of_pull_request(self, pull_request):
770 770 comments = Session().query(ChangesetComment)\
771 771 .filter(ChangesetComment.line_no != None)\
772 772 .filter(ChangesetComment.f_path != None)\
773 773 .filter(ChangesetComment.pull_request == pull_request)
774 774 return comments
775 775
776 776 def _all_general_comments_of_pull_request(self, pull_request):
777 777 comments = Session().query(ChangesetComment)\
778 778 .filter(ChangesetComment.line_no == None)\
779 779 .filter(ChangesetComment.f_path == None)\
780 780 .filter(ChangesetComment.pull_request == pull_request)
781 781
782 782 return comments
783 783
784 784 @staticmethod
785 785 def use_outdated_comments(pull_request):
786 786 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
787 787 settings = settings_model.get_general_settings()
788 788 return settings.get('rhodecode_use_outdated_comments', False)
789 789
790 790 def trigger_commit_comment_hook(self, repo, user, action, data=None):
791 791 repo = self._get_repo(repo)
792 792 target_scm = repo.scm_instance()
793 793 if action == 'create':
794 794 trigger_hook = hooks_utils.trigger_comment_commit_hooks
795 795 elif action == 'edit':
796 796 trigger_hook = hooks_utils.trigger_comment_commit_edit_hooks
797 797 else:
798 798 return
799 799
800 800 log.debug('Handling repo %s trigger_commit_comment_hook with action %s: %s',
801 801 repo, action, trigger_hook)
802 802 trigger_hook(
803 803 username=user.username,
804 804 repo_name=repo.repo_name,
805 805 repo_type=target_scm.alias,
806 806 repo=repo,
807 807 data=data)
808 808
809 809
810 810 def _parse_comment_line_number(line_no):
811 811 """
812 812 Parses line numbers of the form "(o|n)\d+" and returns them in a tuple.
813 813 """
814 814 old_line = None
815 815 new_line = None
816 816 if line_no.startswith('o'):
817 817 old_line = int(line_no[1:])
818 818 elif line_no.startswith('n'):
819 819 new_line = int(line_no[1:])
820 820 else:
821 821 raise ValueError("Comment lines have to start with either 'o' or 'n'.")
822 822 return diffs.DiffLineNumber(old_line, new_line)
823 823
824 824
825 825 def _diff_to_comment_line_number(diff_line):
826 826 if diff_line.new is not None:
827 827 return u'n{}'.format(diff_line.new)
828 828 elif diff_line.old is not None:
829 829 return u'o{}'.format(diff_line.old)
830 830 return u''
831 831
832 832
833 833 def _diff_line_delta(a, b):
834 834 if None not in (a.new, b.new):
835 835 return abs(a.new - b.new)
836 836 elif None not in (a.old, b.old):
837 837 return abs(a.old - b.old)
838 838 else:
839 839 raise ValueError(
840 840 "Cannot compute delta between {} and {}".format(a, b))
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
General Comments 0
You need to be logged in to leave comments. Login now