##// END OF EJS Templates
comments[security]: make an additional check to forbid solving comments from other repo scope.
ergo -
r3546:4b3c3d76 default
parent child Browse files
Show More
@@ -1,690 +1,700 b''
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2
2
3 # Copyright (C) 2011-2019 RhodeCode GmbH
3 # Copyright (C) 2011-2019 RhodeCode GmbH
4 #
4 #
5 # This program is free software: you can redistribute it and/or modify
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
7 # (only), as published by the Free Software Foundation.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU Affero General Public License
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
16 #
17 # This program is dual-licensed. If you wish to learn more about the
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
20
21 """
21 """
22 comments model for RhodeCode
22 comments model for RhodeCode
23 """
23 """
24
24
25 import logging
25 import logging
26 import traceback
26 import traceback
27 import collections
27 import collections
28
28
29 from pyramid.threadlocal import get_current_registry, get_current_request
29 from pyramid.threadlocal import get_current_registry, get_current_request
30 from sqlalchemy.sql.expression import null
30 from sqlalchemy.sql.expression import null
31 from sqlalchemy.sql.functions import coalesce
31 from sqlalchemy.sql.functions import coalesce
32
32
33 from rhodecode.lib import helpers as h, diffs, channelstream
33 from rhodecode.lib import helpers as h, diffs, channelstream
34 from rhodecode.lib import audit_logger
34 from rhodecode.lib import audit_logger
35 from rhodecode.lib.utils2 import extract_mentioned_users, safe_str
35 from rhodecode.lib.utils2 import extract_mentioned_users, safe_str
36 from rhodecode.model import BaseModel
36 from rhodecode.model import BaseModel
37 from rhodecode.model.db import (
37 from rhodecode.model.db import (
38 ChangesetComment, User, Notification, PullRequest, AttributeDict)
38 ChangesetComment, User, Notification, PullRequest, AttributeDict)
39 from rhodecode.model.notification import NotificationModel
39 from rhodecode.model.notification import NotificationModel
40 from rhodecode.model.meta import Session
40 from rhodecode.model.meta import Session
41 from rhodecode.model.settings import VcsSettingsModel
41 from rhodecode.model.settings import VcsSettingsModel
42 from rhodecode.model.notification import EmailNotificationModel
42 from rhodecode.model.notification import EmailNotificationModel
43 from rhodecode.model.validation_schema.schemas import comment_schema
43 from rhodecode.model.validation_schema.schemas import comment_schema
44
44
45
45
46 log = logging.getLogger(__name__)
46 log = logging.getLogger(__name__)
47
47
48
48
49 class CommentsModel(BaseModel):
49 class CommentsModel(BaseModel):
50
50
51 cls = ChangesetComment
51 cls = ChangesetComment
52
52
53 DIFF_CONTEXT_BEFORE = 3
53 DIFF_CONTEXT_BEFORE = 3
54 DIFF_CONTEXT_AFTER = 3
54 DIFF_CONTEXT_AFTER = 3
55
55
56 def __get_commit_comment(self, changeset_comment):
56 def __get_commit_comment(self, changeset_comment):
57 return self._get_instance(ChangesetComment, changeset_comment)
57 return self._get_instance(ChangesetComment, changeset_comment)
58
58
59 def __get_pull_request(self, pull_request):
59 def __get_pull_request(self, pull_request):
60 return self._get_instance(PullRequest, pull_request)
60 return self._get_instance(PullRequest, pull_request)
61
61
62 def _extract_mentions(self, s):
62 def _extract_mentions(self, s):
63 user_objects = []
63 user_objects = []
64 for username in extract_mentioned_users(s):
64 for username in extract_mentioned_users(s):
65 user_obj = User.get_by_username(username, case_insensitive=True)
65 user_obj = User.get_by_username(username, case_insensitive=True)
66 if user_obj:
66 if user_obj:
67 user_objects.append(user_obj)
67 user_objects.append(user_obj)
68 return user_objects
68 return user_objects
69
69
70 def _get_renderer(self, global_renderer='rst', request=None):
70 def _get_renderer(self, global_renderer='rst', request=None):
71 request = request or get_current_request()
71 request = request or get_current_request()
72
72
73 try:
73 try:
74 global_renderer = request.call_context.visual.default_renderer
74 global_renderer = request.call_context.visual.default_renderer
75 except AttributeError:
75 except AttributeError:
76 log.debug("Renderer not set, falling back "
76 log.debug("Renderer not set, falling back "
77 "to default renderer '%s'", global_renderer)
77 "to default renderer '%s'", global_renderer)
78 except Exception:
78 except Exception:
79 log.error(traceback.format_exc())
79 log.error(traceback.format_exc())
80 return global_renderer
80 return global_renderer
81
81
82 def aggregate_comments(self, comments, versions, show_version, inline=False):
82 def aggregate_comments(self, comments, versions, show_version, inline=False):
83 # group by versions, and count until, and display objects
83 # group by versions, and count until, and display objects
84
84
85 comment_groups = collections.defaultdict(list)
85 comment_groups = collections.defaultdict(list)
86 [comment_groups[
86 [comment_groups[
87 _co.pull_request_version_id].append(_co) for _co in comments]
87 _co.pull_request_version_id].append(_co) for _co in comments]
88
88
89 def yield_comments(pos):
89 def yield_comments(pos):
90 for co in comment_groups[pos]:
90 for co in comment_groups[pos]:
91 yield co
91 yield co
92
92
93 comment_versions = collections.defaultdict(
93 comment_versions = collections.defaultdict(
94 lambda: collections.defaultdict(list))
94 lambda: collections.defaultdict(list))
95 prev_prvid = -1
95 prev_prvid = -1
96 # fake last entry with None, to aggregate on "latest" version which
96 # fake last entry with None, to aggregate on "latest" version which
97 # doesn't have an pull_request_version_id
97 # doesn't have an pull_request_version_id
98 for ver in versions + [AttributeDict({'pull_request_version_id': None})]:
98 for ver in versions + [AttributeDict({'pull_request_version_id': None})]:
99 prvid = ver.pull_request_version_id
99 prvid = ver.pull_request_version_id
100 if prev_prvid == -1:
100 if prev_prvid == -1:
101 prev_prvid = prvid
101 prev_prvid = prvid
102
102
103 for co in yield_comments(prvid):
103 for co in yield_comments(prvid):
104 comment_versions[prvid]['at'].append(co)
104 comment_versions[prvid]['at'].append(co)
105
105
106 # save until
106 # save until
107 current = comment_versions[prvid]['at']
107 current = comment_versions[prvid]['at']
108 prev_until = comment_versions[prev_prvid]['until']
108 prev_until = comment_versions[prev_prvid]['until']
109 cur_until = prev_until + current
109 cur_until = prev_until + current
110 comment_versions[prvid]['until'].extend(cur_until)
110 comment_versions[prvid]['until'].extend(cur_until)
111
111
112 # save outdated
112 # save outdated
113 if inline:
113 if inline:
114 outdated = [x for x in cur_until
114 outdated = [x for x in cur_until
115 if x.outdated_at_version(show_version)]
115 if x.outdated_at_version(show_version)]
116 else:
116 else:
117 outdated = [x for x in cur_until
117 outdated = [x for x in cur_until
118 if x.older_than_version(show_version)]
118 if x.older_than_version(show_version)]
119 display = [x for x in cur_until if x not in outdated]
119 display = [x for x in cur_until if x not in outdated]
120
120
121 comment_versions[prvid]['outdated'] = outdated
121 comment_versions[prvid]['outdated'] = outdated
122 comment_versions[prvid]['display'] = display
122 comment_versions[prvid]['display'] = display
123
123
124 prev_prvid = prvid
124 prev_prvid = prvid
125
125
126 return comment_versions
126 return comment_versions
127
127
128 def get_repository_comments(self, repo, comment_type=None, user=None, commit_id=None):
128 def get_repository_comments(self, repo, comment_type=None, user=None, commit_id=None):
129 qry = Session().query(ChangesetComment) \
129 qry = Session().query(ChangesetComment) \
130 .filter(ChangesetComment.repo == repo)
130 .filter(ChangesetComment.repo == repo)
131
131
132 if comment_type and comment_type in ChangesetComment.COMMENT_TYPES:
132 if comment_type and comment_type in ChangesetComment.COMMENT_TYPES:
133 qry = qry.filter(ChangesetComment.comment_type == comment_type)
133 qry = qry.filter(ChangesetComment.comment_type == comment_type)
134
134
135 if user:
135 if user:
136 user = self._get_user(user)
136 user = self._get_user(user)
137 if user:
137 if user:
138 qry = qry.filter(ChangesetComment.user_id == user.user_id)
138 qry = qry.filter(ChangesetComment.user_id == user.user_id)
139
139
140 if commit_id:
140 if commit_id:
141 qry = qry.filter(ChangesetComment.revision == commit_id)
141 qry = qry.filter(ChangesetComment.revision == commit_id)
142
142
143 qry = qry.order_by(ChangesetComment.created_on)
143 qry = qry.order_by(ChangesetComment.created_on)
144 return qry.all()
144 return qry.all()
145
145
146 def get_repository_unresolved_todos(self, repo):
146 def get_repository_unresolved_todos(self, repo):
147 todos = Session().query(ChangesetComment) \
147 todos = Session().query(ChangesetComment) \
148 .filter(ChangesetComment.repo == repo) \
148 .filter(ChangesetComment.repo == repo) \
149 .filter(ChangesetComment.resolved_by == None) \
149 .filter(ChangesetComment.resolved_by == None) \
150 .filter(ChangesetComment.comment_type
150 .filter(ChangesetComment.comment_type
151 == ChangesetComment.COMMENT_TYPE_TODO)
151 == ChangesetComment.COMMENT_TYPE_TODO)
152 todos = todos.all()
152 todos = todos.all()
153
153
154 return todos
154 return todos
155
155
156 def get_pull_request_unresolved_todos(self, pull_request, show_outdated=True):
156 def get_pull_request_unresolved_todos(self, pull_request, show_outdated=True):
157
157
158 todos = Session().query(ChangesetComment) \
158 todos = Session().query(ChangesetComment) \
159 .filter(ChangesetComment.pull_request == pull_request) \
159 .filter(ChangesetComment.pull_request == pull_request) \
160 .filter(ChangesetComment.resolved_by == None) \
160 .filter(ChangesetComment.resolved_by == None) \
161 .filter(ChangesetComment.comment_type
161 .filter(ChangesetComment.comment_type
162 == ChangesetComment.COMMENT_TYPE_TODO)
162 == ChangesetComment.COMMENT_TYPE_TODO)
163
163
164 if not show_outdated:
164 if not show_outdated:
165 todos = todos.filter(
165 todos = todos.filter(
166 coalesce(ChangesetComment.display_state, '') !=
166 coalesce(ChangesetComment.display_state, '') !=
167 ChangesetComment.COMMENT_OUTDATED)
167 ChangesetComment.COMMENT_OUTDATED)
168
168
169 todos = todos.all()
169 todos = todos.all()
170
170
171 return todos
171 return todos
172
172
173 def get_commit_unresolved_todos(self, commit_id, show_outdated=True):
173 def get_commit_unresolved_todos(self, commit_id, show_outdated=True):
174
174
175 todos = Session().query(ChangesetComment) \
175 todos = Session().query(ChangesetComment) \
176 .filter(ChangesetComment.revision == commit_id) \
176 .filter(ChangesetComment.revision == commit_id) \
177 .filter(ChangesetComment.resolved_by == None) \
177 .filter(ChangesetComment.resolved_by == None) \
178 .filter(ChangesetComment.comment_type
178 .filter(ChangesetComment.comment_type
179 == ChangesetComment.COMMENT_TYPE_TODO)
179 == ChangesetComment.COMMENT_TYPE_TODO)
180
180
181 if not show_outdated:
181 if not show_outdated:
182 todos = todos.filter(
182 todos = todos.filter(
183 coalesce(ChangesetComment.display_state, '') !=
183 coalesce(ChangesetComment.display_state, '') !=
184 ChangesetComment.COMMENT_OUTDATED)
184 ChangesetComment.COMMENT_OUTDATED)
185
185
186 todos = todos.all()
186 todos = todos.all()
187
187
188 return todos
188 return todos
189
189
190 def _log_audit_action(self, action, action_data, auth_user, comment):
190 def _log_audit_action(self, action, action_data, auth_user, comment):
191 audit_logger.store(
191 audit_logger.store(
192 action=action,
192 action=action,
193 action_data=action_data,
193 action_data=action_data,
194 user=auth_user,
194 user=auth_user,
195 repo=comment.repo)
195 repo=comment.repo)
196
196
197 def create(self, text, repo, user, commit_id=None, pull_request=None,
197 def create(self, text, repo, user, commit_id=None, pull_request=None,
198 f_path=None, line_no=None, status_change=None,
198 f_path=None, line_no=None, status_change=None,
199 status_change_type=None, comment_type=None,
199 status_change_type=None, comment_type=None,
200 resolves_comment_id=None, closing_pr=False, send_email=True,
200 resolves_comment_id=None, closing_pr=False, send_email=True,
201 renderer=None, auth_user=None):
201 renderer=None, auth_user=None):
202 """
202 """
203 Creates new comment for commit or pull request.
203 Creates new comment for commit or pull request.
204 IF status_change is not none this comment is associated with a
204 IF status_change is not none this comment is associated with a
205 status change of commit or commit associated with pull request
205 status change of commit or commit associated with pull request
206
206
207 :param text:
207 :param text:
208 :param repo:
208 :param repo:
209 :param user:
209 :param user:
210 :param commit_id:
210 :param commit_id:
211 :param pull_request:
211 :param pull_request:
212 :param f_path:
212 :param f_path:
213 :param line_no:
213 :param line_no:
214 :param status_change: Label for status change
214 :param status_change: Label for status change
215 :param comment_type: Type of comment
215 :param comment_type: Type of comment
216 :param status_change_type: type of status change
216 :param status_change_type: type of status change
217 :param closing_pr:
217 :param closing_pr:
218 :param send_email:
218 :param send_email:
219 :param renderer: pick renderer for this comment
219 :param renderer: pick renderer for this comment
220 """
220 """
221
221
222 if not text:
222 if not text:
223 log.warning('Missing text for comment, skipping...')
223 log.warning('Missing text for comment, skipping...')
224 return
224 return
225 request = get_current_request()
225 request = get_current_request()
226 _ = request.translate
226 _ = request.translate
227
227
228 if not renderer:
228 if not renderer:
229 renderer = self._get_renderer(request=request)
229 renderer = self._get_renderer(request=request)
230
230
231 repo = self._get_repo(repo)
231 repo = self._get_repo(repo)
232 user = self._get_user(user)
232 user = self._get_user(user)
233 auth_user = auth_user or user
233 auth_user = auth_user or user
234
234
235 schema = comment_schema.CommentSchema()
235 schema = comment_schema.CommentSchema()
236 validated_kwargs = schema.deserialize(dict(
236 validated_kwargs = schema.deserialize(dict(
237 comment_body=text,
237 comment_body=text,
238 comment_type=comment_type,
238 comment_type=comment_type,
239 comment_file=f_path,
239 comment_file=f_path,
240 comment_line=line_no,
240 comment_line=line_no,
241 renderer_type=renderer,
241 renderer_type=renderer,
242 status_change=status_change_type,
242 status_change=status_change_type,
243 resolves_comment_id=resolves_comment_id,
243 resolves_comment_id=resolves_comment_id,
244 repo=repo.repo_id,
244 repo=repo.repo_id,
245 user=user.user_id,
245 user=user.user_id,
246 ))
246 ))
247
247
248 comment = ChangesetComment()
248 comment = ChangesetComment()
249 comment.renderer = validated_kwargs['renderer_type']
249 comment.renderer = validated_kwargs['renderer_type']
250 comment.text = validated_kwargs['comment_body']
250 comment.text = validated_kwargs['comment_body']
251 comment.f_path = validated_kwargs['comment_file']
251 comment.f_path = validated_kwargs['comment_file']
252 comment.line_no = validated_kwargs['comment_line']
252 comment.line_no = validated_kwargs['comment_line']
253 comment.comment_type = validated_kwargs['comment_type']
253 comment.comment_type = validated_kwargs['comment_type']
254
254
255 comment.repo = repo
255 comment.repo = repo
256 comment.author = user
256 comment.author = user
257 resolved_comment = self.__get_commit_comment(
257 resolved_comment = self.__get_commit_comment(
258 validated_kwargs['resolves_comment_id'])
258 validated_kwargs['resolves_comment_id'])
259 # check if the comment actually belongs to this PR
259 # check if the comment actually belongs to this PR
260 if resolved_comment and resolved_comment.pull_request and \
260 if resolved_comment and resolved_comment.pull_request and \
261 resolved_comment.pull_request != pull_request:
261 resolved_comment.pull_request != pull_request:
262 log.warning('Comment tried to resolved unrelated todo comment: %s',
263 resolved_comment)
262 # comment not bound to this pull request, forbid
264 # comment not bound to this pull request, forbid
263 resolved_comment = None
265 resolved_comment = None
266
267 elif resolved_comment and resolved_comment.repo and \
268 resolved_comment.repo != repo:
269 log.warning('Comment tried to resolved unrelated todo comment: %s',
270 resolved_comment)
271 # comment not bound to this repo, forbid
272 resolved_comment = None
273
264 comment.resolved_comment = resolved_comment
274 comment.resolved_comment = resolved_comment
265
275
266 pull_request_id = pull_request
276 pull_request_id = pull_request
267
277
268 commit_obj = None
278 commit_obj = None
269 pull_request_obj = None
279 pull_request_obj = None
270
280
271 if commit_id:
281 if commit_id:
272 notification_type = EmailNotificationModel.TYPE_COMMIT_COMMENT
282 notification_type = EmailNotificationModel.TYPE_COMMIT_COMMENT
273 # do a lookup, so we don't pass something bad here
283 # do a lookup, so we don't pass something bad here
274 commit_obj = repo.scm_instance().get_commit(commit_id=commit_id)
284 commit_obj = repo.scm_instance().get_commit(commit_id=commit_id)
275 comment.revision = commit_obj.raw_id
285 comment.revision = commit_obj.raw_id
276
286
277 elif pull_request_id:
287 elif pull_request_id:
278 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST_COMMENT
288 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST_COMMENT
279 pull_request_obj = self.__get_pull_request(pull_request_id)
289 pull_request_obj = self.__get_pull_request(pull_request_id)
280 comment.pull_request = pull_request_obj
290 comment.pull_request = pull_request_obj
281 else:
291 else:
282 raise Exception('Please specify commit or pull_request_id')
292 raise Exception('Please specify commit or pull_request_id')
283
293
284 Session().add(comment)
294 Session().add(comment)
285 Session().flush()
295 Session().flush()
286 kwargs = {
296 kwargs = {
287 'user': user,
297 'user': user,
288 'renderer_type': renderer,
298 'renderer_type': renderer,
289 'repo_name': repo.repo_name,
299 'repo_name': repo.repo_name,
290 'status_change': status_change,
300 'status_change': status_change,
291 'status_change_type': status_change_type,
301 'status_change_type': status_change_type,
292 'comment_body': text,
302 'comment_body': text,
293 'comment_file': f_path,
303 'comment_file': f_path,
294 'comment_line': line_no,
304 'comment_line': line_no,
295 'comment_type': comment_type or 'note'
305 'comment_type': comment_type or 'note'
296 }
306 }
297
307
298 if commit_obj:
308 if commit_obj:
299 recipients = ChangesetComment.get_users(
309 recipients = ChangesetComment.get_users(
300 revision=commit_obj.raw_id)
310 revision=commit_obj.raw_id)
301 # add commit author if it's in RhodeCode system
311 # add commit author if it's in RhodeCode system
302 cs_author = User.get_from_cs_author(commit_obj.author)
312 cs_author = User.get_from_cs_author(commit_obj.author)
303 if not cs_author:
313 if not cs_author:
304 # use repo owner if we cannot extract the author correctly
314 # use repo owner if we cannot extract the author correctly
305 cs_author = repo.user
315 cs_author = repo.user
306 recipients += [cs_author]
316 recipients += [cs_author]
307
317
308 commit_comment_url = self.get_url(comment, request=request)
318 commit_comment_url = self.get_url(comment, request=request)
309
319
310 target_repo_url = h.link_to(
320 target_repo_url = h.link_to(
311 repo.repo_name,
321 repo.repo_name,
312 h.route_url('repo_summary', repo_name=repo.repo_name))
322 h.route_url('repo_summary', repo_name=repo.repo_name))
313
323
314 # commit specifics
324 # commit specifics
315 kwargs.update({
325 kwargs.update({
316 'commit': commit_obj,
326 'commit': commit_obj,
317 'commit_message': commit_obj.message,
327 'commit_message': commit_obj.message,
318 'commit_target_repo': target_repo_url,
328 'commit_target_repo': target_repo_url,
319 'commit_comment_url': commit_comment_url,
329 'commit_comment_url': commit_comment_url,
320 })
330 })
321
331
322 elif pull_request_obj:
332 elif pull_request_obj:
323 # get the current participants of this pull request
333 # get the current participants of this pull request
324 recipients = ChangesetComment.get_users(
334 recipients = ChangesetComment.get_users(
325 pull_request_id=pull_request_obj.pull_request_id)
335 pull_request_id=pull_request_obj.pull_request_id)
326 # add pull request author
336 # add pull request author
327 recipients += [pull_request_obj.author]
337 recipients += [pull_request_obj.author]
328
338
329 # add the reviewers to notification
339 # add the reviewers to notification
330 recipients += [x.user for x in pull_request_obj.reviewers]
340 recipients += [x.user for x in pull_request_obj.reviewers]
331
341
332 pr_target_repo = pull_request_obj.target_repo
342 pr_target_repo = pull_request_obj.target_repo
333 pr_source_repo = pull_request_obj.source_repo
343 pr_source_repo = pull_request_obj.source_repo
334
344
335 pr_comment_url = h.route_url(
345 pr_comment_url = h.route_url(
336 'pullrequest_show',
346 'pullrequest_show',
337 repo_name=pr_target_repo.repo_name,
347 repo_name=pr_target_repo.repo_name,
338 pull_request_id=pull_request_obj.pull_request_id,
348 pull_request_id=pull_request_obj.pull_request_id,
339 _anchor='comment-%s' % comment.comment_id)
349 _anchor='comment-%s' % comment.comment_id)
340
350
341 # set some variables for email notification
351 # set some variables for email notification
342 pr_target_repo_url = h.route_url(
352 pr_target_repo_url = h.route_url(
343 'repo_summary', repo_name=pr_target_repo.repo_name)
353 'repo_summary', repo_name=pr_target_repo.repo_name)
344
354
345 pr_source_repo_url = h.route_url(
355 pr_source_repo_url = h.route_url(
346 'repo_summary', repo_name=pr_source_repo.repo_name)
356 'repo_summary', repo_name=pr_source_repo.repo_name)
347
357
348 # pull request specifics
358 # pull request specifics
349 kwargs.update({
359 kwargs.update({
350 'pull_request': pull_request_obj,
360 'pull_request': pull_request_obj,
351 'pr_id': pull_request_obj.pull_request_id,
361 'pr_id': pull_request_obj.pull_request_id,
352 'pr_target_repo': pr_target_repo,
362 'pr_target_repo': pr_target_repo,
353 'pr_target_repo_url': pr_target_repo_url,
363 'pr_target_repo_url': pr_target_repo_url,
354 'pr_source_repo': pr_source_repo,
364 'pr_source_repo': pr_source_repo,
355 'pr_source_repo_url': pr_source_repo_url,
365 'pr_source_repo_url': pr_source_repo_url,
356 'pr_comment_url': pr_comment_url,
366 'pr_comment_url': pr_comment_url,
357 'pr_closing': closing_pr,
367 'pr_closing': closing_pr,
358 })
368 })
359 if send_email:
369 if send_email:
360 # pre-generate the subject for notification itself
370 # pre-generate the subject for notification itself
361 (subject,
371 (subject,
362 _h, _e, # we don't care about those
372 _h, _e, # we don't care about those
363 body_plaintext) = EmailNotificationModel().render_email(
373 body_plaintext) = EmailNotificationModel().render_email(
364 notification_type, **kwargs)
374 notification_type, **kwargs)
365
375
366 mention_recipients = set(
376 mention_recipients = set(
367 self._extract_mentions(text)).difference(recipients)
377 self._extract_mentions(text)).difference(recipients)
368
378
369 # create notification objects, and emails
379 # create notification objects, and emails
370 NotificationModel().create(
380 NotificationModel().create(
371 created_by=user,
381 created_by=user,
372 notification_subject=subject,
382 notification_subject=subject,
373 notification_body=body_plaintext,
383 notification_body=body_plaintext,
374 notification_type=notification_type,
384 notification_type=notification_type,
375 recipients=recipients,
385 recipients=recipients,
376 mention_recipients=mention_recipients,
386 mention_recipients=mention_recipients,
377 email_kwargs=kwargs,
387 email_kwargs=kwargs,
378 )
388 )
379
389
380 Session().flush()
390 Session().flush()
381 if comment.pull_request:
391 if comment.pull_request:
382 action = 'repo.pull_request.comment.create'
392 action = 'repo.pull_request.comment.create'
383 else:
393 else:
384 action = 'repo.commit.comment.create'
394 action = 'repo.commit.comment.create'
385
395
386 comment_data = comment.get_api_data()
396 comment_data = comment.get_api_data()
387 self._log_audit_action(
397 self._log_audit_action(
388 action, {'data': comment_data}, auth_user, comment)
398 action, {'data': comment_data}, auth_user, comment)
389
399
390 msg_url = ''
400 msg_url = ''
391 channel = None
401 channel = None
392 if commit_obj:
402 if commit_obj:
393 msg_url = commit_comment_url
403 msg_url = commit_comment_url
394 repo_name = repo.repo_name
404 repo_name = repo.repo_name
395 channel = u'/repo${}$/commit/{}'.format(
405 channel = u'/repo${}$/commit/{}'.format(
396 repo_name,
406 repo_name,
397 commit_obj.raw_id
407 commit_obj.raw_id
398 )
408 )
399 elif pull_request_obj:
409 elif pull_request_obj:
400 msg_url = pr_comment_url
410 msg_url = pr_comment_url
401 repo_name = pr_target_repo.repo_name
411 repo_name = pr_target_repo.repo_name
402 channel = u'/repo${}$/pr/{}'.format(
412 channel = u'/repo${}$/pr/{}'.format(
403 repo_name,
413 repo_name,
404 pull_request_id
414 pull_request_id
405 )
415 )
406
416
407 message = '<strong>{}</strong> {} - ' \
417 message = '<strong>{}</strong> {} - ' \
408 '<a onclick="window.location=\'{}\';' \
418 '<a onclick="window.location=\'{}\';' \
409 'window.location.reload()">' \
419 'window.location.reload()">' \
410 '<strong>{}</strong></a>'
420 '<strong>{}</strong></a>'
411 message = message.format(
421 message = message.format(
412 user.username, _('made a comment'), msg_url,
422 user.username, _('made a comment'), msg_url,
413 _('Show it now'))
423 _('Show it now'))
414
424
415 channelstream.post_message(
425 channelstream.post_message(
416 channel, message, user.username,
426 channel, message, user.username,
417 registry=get_current_registry())
427 registry=get_current_registry())
418
428
419 return comment
429 return comment
420
430
421 def delete(self, comment, auth_user):
431 def delete(self, comment, auth_user):
422 """
432 """
423 Deletes given comment
433 Deletes given comment
424 """
434 """
425 comment = self.__get_commit_comment(comment)
435 comment = self.__get_commit_comment(comment)
426 old_data = comment.get_api_data()
436 old_data = comment.get_api_data()
427 Session().delete(comment)
437 Session().delete(comment)
428
438
429 if comment.pull_request:
439 if comment.pull_request:
430 action = 'repo.pull_request.comment.delete'
440 action = 'repo.pull_request.comment.delete'
431 else:
441 else:
432 action = 'repo.commit.comment.delete'
442 action = 'repo.commit.comment.delete'
433
443
434 self._log_audit_action(
444 self._log_audit_action(
435 action, {'old_data': old_data}, auth_user, comment)
445 action, {'old_data': old_data}, auth_user, comment)
436
446
437 return comment
447 return comment
438
448
439 def get_all_comments(self, repo_id, revision=None, pull_request=None):
449 def get_all_comments(self, repo_id, revision=None, pull_request=None):
440 q = ChangesetComment.query()\
450 q = ChangesetComment.query()\
441 .filter(ChangesetComment.repo_id == repo_id)
451 .filter(ChangesetComment.repo_id == repo_id)
442 if revision:
452 if revision:
443 q = q.filter(ChangesetComment.revision == revision)
453 q = q.filter(ChangesetComment.revision == revision)
444 elif pull_request:
454 elif pull_request:
445 pull_request = self.__get_pull_request(pull_request)
455 pull_request = self.__get_pull_request(pull_request)
446 q = q.filter(ChangesetComment.pull_request == pull_request)
456 q = q.filter(ChangesetComment.pull_request == pull_request)
447 else:
457 else:
448 raise Exception('Please specify commit or pull_request')
458 raise Exception('Please specify commit or pull_request')
449 q = q.order_by(ChangesetComment.created_on)
459 q = q.order_by(ChangesetComment.created_on)
450 return q.all()
460 return q.all()
451
461
452 def get_url(self, comment, request=None, permalink=False):
462 def get_url(self, comment, request=None, permalink=False):
453 if not request:
463 if not request:
454 request = get_current_request()
464 request = get_current_request()
455
465
456 comment = self.__get_commit_comment(comment)
466 comment = self.__get_commit_comment(comment)
457 if comment.pull_request:
467 if comment.pull_request:
458 pull_request = comment.pull_request
468 pull_request = comment.pull_request
459 if permalink:
469 if permalink:
460 return request.route_url(
470 return request.route_url(
461 'pull_requests_global',
471 'pull_requests_global',
462 pull_request_id=pull_request.pull_request_id,
472 pull_request_id=pull_request.pull_request_id,
463 _anchor='comment-%s' % comment.comment_id)
473 _anchor='comment-%s' % comment.comment_id)
464 else:
474 else:
465 return request.route_url(
475 return request.route_url(
466 'pullrequest_show',
476 'pullrequest_show',
467 repo_name=safe_str(pull_request.target_repo.repo_name),
477 repo_name=safe_str(pull_request.target_repo.repo_name),
468 pull_request_id=pull_request.pull_request_id,
478 pull_request_id=pull_request.pull_request_id,
469 _anchor='comment-%s' % comment.comment_id)
479 _anchor='comment-%s' % comment.comment_id)
470
480
471 else:
481 else:
472 repo = comment.repo
482 repo = comment.repo
473 commit_id = comment.revision
483 commit_id = comment.revision
474
484
475 if permalink:
485 if permalink:
476 return request.route_url(
486 return request.route_url(
477 'repo_commit', repo_name=safe_str(repo.repo_id),
487 'repo_commit', repo_name=safe_str(repo.repo_id),
478 commit_id=commit_id,
488 commit_id=commit_id,
479 _anchor='comment-%s' % comment.comment_id)
489 _anchor='comment-%s' % comment.comment_id)
480
490
481 else:
491 else:
482 return request.route_url(
492 return request.route_url(
483 'repo_commit', repo_name=safe_str(repo.repo_name),
493 'repo_commit', repo_name=safe_str(repo.repo_name),
484 commit_id=commit_id,
494 commit_id=commit_id,
485 _anchor='comment-%s' % comment.comment_id)
495 _anchor='comment-%s' % comment.comment_id)
486
496
487 def get_comments(self, repo_id, revision=None, pull_request=None):
497 def get_comments(self, repo_id, revision=None, pull_request=None):
488 """
498 """
489 Gets main comments based on revision or pull_request_id
499 Gets main comments based on revision or pull_request_id
490
500
491 :param repo_id:
501 :param repo_id:
492 :param revision:
502 :param revision:
493 :param pull_request:
503 :param pull_request:
494 """
504 """
495
505
496 q = ChangesetComment.query()\
506 q = ChangesetComment.query()\
497 .filter(ChangesetComment.repo_id == repo_id)\
507 .filter(ChangesetComment.repo_id == repo_id)\
498 .filter(ChangesetComment.line_no == None)\
508 .filter(ChangesetComment.line_no == None)\
499 .filter(ChangesetComment.f_path == None)
509 .filter(ChangesetComment.f_path == None)
500 if revision:
510 if revision:
501 q = q.filter(ChangesetComment.revision == revision)
511 q = q.filter(ChangesetComment.revision == revision)
502 elif pull_request:
512 elif pull_request:
503 pull_request = self.__get_pull_request(pull_request)
513 pull_request = self.__get_pull_request(pull_request)
504 q = q.filter(ChangesetComment.pull_request == pull_request)
514 q = q.filter(ChangesetComment.pull_request == pull_request)
505 else:
515 else:
506 raise Exception('Please specify commit or pull_request')
516 raise Exception('Please specify commit or pull_request')
507 q = q.order_by(ChangesetComment.created_on)
517 q = q.order_by(ChangesetComment.created_on)
508 return q.all()
518 return q.all()
509
519
510 def get_inline_comments(self, repo_id, revision=None, pull_request=None):
520 def get_inline_comments(self, repo_id, revision=None, pull_request=None):
511 q = self._get_inline_comments_query(repo_id, revision, pull_request)
521 q = self._get_inline_comments_query(repo_id, revision, pull_request)
512 return self._group_comments_by_path_and_line_number(q)
522 return self._group_comments_by_path_and_line_number(q)
513
523
514 def get_inline_comments_count(self, inline_comments, skip_outdated=True,
524 def get_inline_comments_count(self, inline_comments, skip_outdated=True,
515 version=None):
525 version=None):
516 inline_cnt = 0
526 inline_cnt = 0
517 for fname, per_line_comments in inline_comments.iteritems():
527 for fname, per_line_comments in inline_comments.iteritems():
518 for lno, comments in per_line_comments.iteritems():
528 for lno, comments in per_line_comments.iteritems():
519 for comm in comments:
529 for comm in comments:
520 if not comm.outdated_at_version(version) and skip_outdated:
530 if not comm.outdated_at_version(version) and skip_outdated:
521 inline_cnt += 1
531 inline_cnt += 1
522
532
523 return inline_cnt
533 return inline_cnt
524
534
525 def get_outdated_comments(self, repo_id, pull_request):
535 def get_outdated_comments(self, repo_id, pull_request):
526 # TODO: johbo: Remove `repo_id`, it is not needed to find the comments
536 # TODO: johbo: Remove `repo_id`, it is not needed to find the comments
527 # of a pull request.
537 # of a pull request.
528 q = self._all_inline_comments_of_pull_request(pull_request)
538 q = self._all_inline_comments_of_pull_request(pull_request)
529 q = q.filter(
539 q = q.filter(
530 ChangesetComment.display_state ==
540 ChangesetComment.display_state ==
531 ChangesetComment.COMMENT_OUTDATED
541 ChangesetComment.COMMENT_OUTDATED
532 ).order_by(ChangesetComment.comment_id.asc())
542 ).order_by(ChangesetComment.comment_id.asc())
533
543
534 return self._group_comments_by_path_and_line_number(q)
544 return self._group_comments_by_path_and_line_number(q)
535
545
536 def _get_inline_comments_query(self, repo_id, revision, pull_request):
546 def _get_inline_comments_query(self, repo_id, revision, pull_request):
537 # TODO: johbo: Split this into two methods: One for PR and one for
547 # TODO: johbo: Split this into two methods: One for PR and one for
538 # commit.
548 # commit.
539 if revision:
549 if revision:
540 q = Session().query(ChangesetComment).filter(
550 q = Session().query(ChangesetComment).filter(
541 ChangesetComment.repo_id == repo_id,
551 ChangesetComment.repo_id == repo_id,
542 ChangesetComment.line_no != null(),
552 ChangesetComment.line_no != null(),
543 ChangesetComment.f_path != null(),
553 ChangesetComment.f_path != null(),
544 ChangesetComment.revision == revision)
554 ChangesetComment.revision == revision)
545
555
546 elif pull_request:
556 elif pull_request:
547 pull_request = self.__get_pull_request(pull_request)
557 pull_request = self.__get_pull_request(pull_request)
548 if not CommentsModel.use_outdated_comments(pull_request):
558 if not CommentsModel.use_outdated_comments(pull_request):
549 q = self._visible_inline_comments_of_pull_request(pull_request)
559 q = self._visible_inline_comments_of_pull_request(pull_request)
550 else:
560 else:
551 q = self._all_inline_comments_of_pull_request(pull_request)
561 q = self._all_inline_comments_of_pull_request(pull_request)
552
562
553 else:
563 else:
554 raise Exception('Please specify commit or pull_request_id')
564 raise Exception('Please specify commit or pull_request_id')
555 q = q.order_by(ChangesetComment.comment_id.asc())
565 q = q.order_by(ChangesetComment.comment_id.asc())
556 return q
566 return q
557
567
558 def _group_comments_by_path_and_line_number(self, q):
568 def _group_comments_by_path_and_line_number(self, q):
559 comments = q.all()
569 comments = q.all()
560 paths = collections.defaultdict(lambda: collections.defaultdict(list))
570 paths = collections.defaultdict(lambda: collections.defaultdict(list))
561 for co in comments:
571 for co in comments:
562 paths[co.f_path][co.line_no].append(co)
572 paths[co.f_path][co.line_no].append(co)
563 return paths
573 return paths
564
574
565 @classmethod
575 @classmethod
566 def needed_extra_diff_context(cls):
576 def needed_extra_diff_context(cls):
567 return max(cls.DIFF_CONTEXT_BEFORE, cls.DIFF_CONTEXT_AFTER)
577 return max(cls.DIFF_CONTEXT_BEFORE, cls.DIFF_CONTEXT_AFTER)
568
578
569 def outdate_comments(self, pull_request, old_diff_data, new_diff_data):
579 def outdate_comments(self, pull_request, old_diff_data, new_diff_data):
570 if not CommentsModel.use_outdated_comments(pull_request):
580 if not CommentsModel.use_outdated_comments(pull_request):
571 return
581 return
572
582
573 comments = self._visible_inline_comments_of_pull_request(pull_request)
583 comments = self._visible_inline_comments_of_pull_request(pull_request)
574 comments_to_outdate = comments.all()
584 comments_to_outdate = comments.all()
575
585
576 for comment in comments_to_outdate:
586 for comment in comments_to_outdate:
577 self._outdate_one_comment(comment, old_diff_data, new_diff_data)
587 self._outdate_one_comment(comment, old_diff_data, new_diff_data)
578
588
579 def _outdate_one_comment(self, comment, old_diff_proc, new_diff_proc):
589 def _outdate_one_comment(self, comment, old_diff_proc, new_diff_proc):
580 diff_line = _parse_comment_line_number(comment.line_no)
590 diff_line = _parse_comment_line_number(comment.line_no)
581
591
582 try:
592 try:
583 old_context = old_diff_proc.get_context_of_line(
593 old_context = old_diff_proc.get_context_of_line(
584 path=comment.f_path, diff_line=diff_line)
594 path=comment.f_path, diff_line=diff_line)
585 new_context = new_diff_proc.get_context_of_line(
595 new_context = new_diff_proc.get_context_of_line(
586 path=comment.f_path, diff_line=diff_line)
596 path=comment.f_path, diff_line=diff_line)
587 except (diffs.LineNotInDiffException,
597 except (diffs.LineNotInDiffException,
588 diffs.FileNotInDiffException):
598 diffs.FileNotInDiffException):
589 comment.display_state = ChangesetComment.COMMENT_OUTDATED
599 comment.display_state = ChangesetComment.COMMENT_OUTDATED
590 return
600 return
591
601
592 if old_context == new_context:
602 if old_context == new_context:
593 return
603 return
594
604
595 if self._should_relocate_diff_line(diff_line):
605 if self._should_relocate_diff_line(diff_line):
596 new_diff_lines = new_diff_proc.find_context(
606 new_diff_lines = new_diff_proc.find_context(
597 path=comment.f_path, context=old_context,
607 path=comment.f_path, context=old_context,
598 offset=self.DIFF_CONTEXT_BEFORE)
608 offset=self.DIFF_CONTEXT_BEFORE)
599 if not new_diff_lines:
609 if not new_diff_lines:
600 comment.display_state = ChangesetComment.COMMENT_OUTDATED
610 comment.display_state = ChangesetComment.COMMENT_OUTDATED
601 else:
611 else:
602 new_diff_line = self._choose_closest_diff_line(
612 new_diff_line = self._choose_closest_diff_line(
603 diff_line, new_diff_lines)
613 diff_line, new_diff_lines)
604 comment.line_no = _diff_to_comment_line_number(new_diff_line)
614 comment.line_no = _diff_to_comment_line_number(new_diff_line)
605 else:
615 else:
606 comment.display_state = ChangesetComment.COMMENT_OUTDATED
616 comment.display_state = ChangesetComment.COMMENT_OUTDATED
607
617
608 def _should_relocate_diff_line(self, diff_line):
618 def _should_relocate_diff_line(self, diff_line):
609 """
619 """
610 Checks if relocation shall be tried for the given `diff_line`.
620 Checks if relocation shall be tried for the given `diff_line`.
611
621
612 If a comment points into the first lines, then we can have a situation
622 If a comment points into the first lines, then we can have a situation
613 that after an update another line has been added on top. In this case
623 that after an update another line has been added on top. In this case
614 we would find the context still and move the comment around. This
624 we would find the context still and move the comment around. This
615 would be wrong.
625 would be wrong.
616 """
626 """
617 should_relocate = (
627 should_relocate = (
618 (diff_line.new and diff_line.new > self.DIFF_CONTEXT_BEFORE) or
628 (diff_line.new and diff_line.new > self.DIFF_CONTEXT_BEFORE) or
619 (diff_line.old and diff_line.old > self.DIFF_CONTEXT_BEFORE))
629 (diff_line.old and diff_line.old > self.DIFF_CONTEXT_BEFORE))
620 return should_relocate
630 return should_relocate
621
631
622 def _choose_closest_diff_line(self, diff_line, new_diff_lines):
632 def _choose_closest_diff_line(self, diff_line, new_diff_lines):
623 candidate = new_diff_lines[0]
633 candidate = new_diff_lines[0]
624 best_delta = _diff_line_delta(diff_line, candidate)
634 best_delta = _diff_line_delta(diff_line, candidate)
625 for new_diff_line in new_diff_lines[1:]:
635 for new_diff_line in new_diff_lines[1:]:
626 delta = _diff_line_delta(diff_line, new_diff_line)
636 delta = _diff_line_delta(diff_line, new_diff_line)
627 if delta < best_delta:
637 if delta < best_delta:
628 candidate = new_diff_line
638 candidate = new_diff_line
629 best_delta = delta
639 best_delta = delta
630 return candidate
640 return candidate
631
641
632 def _visible_inline_comments_of_pull_request(self, pull_request):
642 def _visible_inline_comments_of_pull_request(self, pull_request):
633 comments = self._all_inline_comments_of_pull_request(pull_request)
643 comments = self._all_inline_comments_of_pull_request(pull_request)
634 comments = comments.filter(
644 comments = comments.filter(
635 coalesce(ChangesetComment.display_state, '') !=
645 coalesce(ChangesetComment.display_state, '') !=
636 ChangesetComment.COMMENT_OUTDATED)
646 ChangesetComment.COMMENT_OUTDATED)
637 return comments
647 return comments
638
648
639 def _all_inline_comments_of_pull_request(self, pull_request):
649 def _all_inline_comments_of_pull_request(self, pull_request):
640 comments = Session().query(ChangesetComment)\
650 comments = Session().query(ChangesetComment)\
641 .filter(ChangesetComment.line_no != None)\
651 .filter(ChangesetComment.line_no != None)\
642 .filter(ChangesetComment.f_path != None)\
652 .filter(ChangesetComment.f_path != None)\
643 .filter(ChangesetComment.pull_request == pull_request)
653 .filter(ChangesetComment.pull_request == pull_request)
644 return comments
654 return comments
645
655
646 def _all_general_comments_of_pull_request(self, pull_request):
656 def _all_general_comments_of_pull_request(self, pull_request):
647 comments = Session().query(ChangesetComment)\
657 comments = Session().query(ChangesetComment)\
648 .filter(ChangesetComment.line_no == None)\
658 .filter(ChangesetComment.line_no == None)\
649 .filter(ChangesetComment.f_path == None)\
659 .filter(ChangesetComment.f_path == None)\
650 .filter(ChangesetComment.pull_request == pull_request)
660 .filter(ChangesetComment.pull_request == pull_request)
651 return comments
661 return comments
652
662
653 @staticmethod
663 @staticmethod
654 def use_outdated_comments(pull_request):
664 def use_outdated_comments(pull_request):
655 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
665 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
656 settings = settings_model.get_general_settings()
666 settings = settings_model.get_general_settings()
657 return settings.get('rhodecode_use_outdated_comments', False)
667 return settings.get('rhodecode_use_outdated_comments', False)
658
668
659
669
660 def _parse_comment_line_number(line_no):
670 def _parse_comment_line_number(line_no):
661 """
671 """
662 Parses line numbers of the form "(o|n)\d+" and returns them in a tuple.
672 Parses line numbers of the form "(o|n)\d+" and returns them in a tuple.
663 """
673 """
664 old_line = None
674 old_line = None
665 new_line = None
675 new_line = None
666 if line_no.startswith('o'):
676 if line_no.startswith('o'):
667 old_line = int(line_no[1:])
677 old_line = int(line_no[1:])
668 elif line_no.startswith('n'):
678 elif line_no.startswith('n'):
669 new_line = int(line_no[1:])
679 new_line = int(line_no[1:])
670 else:
680 else:
671 raise ValueError("Comment lines have to start with either 'o' or 'n'.")
681 raise ValueError("Comment lines have to start with either 'o' or 'n'.")
672 return diffs.DiffLineNumber(old_line, new_line)
682 return diffs.DiffLineNumber(old_line, new_line)
673
683
674
684
675 def _diff_to_comment_line_number(diff_line):
685 def _diff_to_comment_line_number(diff_line):
676 if diff_line.new is not None:
686 if diff_line.new is not None:
677 return u'n{}'.format(diff_line.new)
687 return u'n{}'.format(diff_line.new)
678 elif diff_line.old is not None:
688 elif diff_line.old is not None:
679 return u'o{}'.format(diff_line.old)
689 return u'o{}'.format(diff_line.old)
680 return u''
690 return u''
681
691
682
692
683 def _diff_line_delta(a, b):
693 def _diff_line_delta(a, b):
684 if None not in (a.new, b.new):
694 if None not in (a.new, b.new):
685 return abs(a.new - b.new)
695 return abs(a.new - b.new)
686 elif None not in (a.old, b.old):
696 elif None not in (a.old, b.old):
687 return abs(a.old - b.old)
697 return abs(a.old - b.old)
688 else:
698 else:
689 raise ValueError(
699 raise ValueError(
690 "Cannot compute delta between {} and {}".format(a, b))
700 "Cannot compute delta between {} and {}".format(a, b))
General Comments 0
You need to be logged in to leave comments. Login now