##// END OF EJS Templates
pull-request: verify resolve TODO comment needs to be bound to the same PR as we're calling
marcink -
r2441:9a41d4d3 default
parent child Browse files
Show More
@@ -1,653 +1,659 b''
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2
2
3 # Copyright (C) 2011-2017 RhodeCode GmbH
3 # Copyright (C) 2011-2017 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_unresolved_todos(self, pull_request, show_outdated=True):
128 def get_unresolved_todos(self, pull_request, show_outdated=True):
129
129
130 todos = Session().query(ChangesetComment) \
130 todos = Session().query(ChangesetComment) \
131 .filter(ChangesetComment.pull_request == pull_request) \
131 .filter(ChangesetComment.pull_request == pull_request) \
132 .filter(ChangesetComment.resolved_by == None) \
132 .filter(ChangesetComment.resolved_by == None) \
133 .filter(ChangesetComment.comment_type
133 .filter(ChangesetComment.comment_type
134 == ChangesetComment.COMMENT_TYPE_TODO)
134 == ChangesetComment.COMMENT_TYPE_TODO)
135
135
136 if not show_outdated:
136 if not show_outdated:
137 todos = todos.filter(
137 todos = todos.filter(
138 coalesce(ChangesetComment.display_state, '') !=
138 coalesce(ChangesetComment.display_state, '') !=
139 ChangesetComment.COMMENT_OUTDATED)
139 ChangesetComment.COMMENT_OUTDATED)
140
140
141 todos = todos.all()
141 todos = todos.all()
142
142
143 return todos
143 return todos
144
144
145 def get_commit_unresolved_todos(self, commit_id, show_outdated=True):
145 def get_commit_unresolved_todos(self, commit_id, show_outdated=True):
146
146
147 todos = Session().query(ChangesetComment) \
147 todos = Session().query(ChangesetComment) \
148 .filter(ChangesetComment.revision == commit_id) \
148 .filter(ChangesetComment.revision == commit_id) \
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
152
153 if not show_outdated:
153 if not show_outdated:
154 todos = todos.filter(
154 todos = todos.filter(
155 coalesce(ChangesetComment.display_state, '') !=
155 coalesce(ChangesetComment.display_state, '') !=
156 ChangesetComment.COMMENT_OUTDATED)
156 ChangesetComment.COMMENT_OUTDATED)
157
157
158 todos = todos.all()
158 todos = todos.all()
159
159
160 return todos
160 return todos
161
161
162 def _log_audit_action(self, action, action_data, user, comment):
162 def _log_audit_action(self, action, action_data, user, comment):
163 audit_logger.store(
163 audit_logger.store(
164 action=action,
164 action=action,
165 action_data=action_data,
165 action_data=action_data,
166 user=user,
166 user=user,
167 repo=comment.repo)
167 repo=comment.repo)
168
168
169 def create(self, text, repo, user, commit_id=None, pull_request=None,
169 def create(self, text, repo, user, commit_id=None, pull_request=None,
170 f_path=None, line_no=None, status_change=None,
170 f_path=None, line_no=None, status_change=None,
171 status_change_type=None, comment_type=None,
171 status_change_type=None, comment_type=None,
172 resolves_comment_id=None, closing_pr=False, send_email=True,
172 resolves_comment_id=None, closing_pr=False, send_email=True,
173 renderer=None):
173 renderer=None):
174 """
174 """
175 Creates new comment for commit or pull request.
175 Creates new comment for commit or pull request.
176 IF status_change is not none this comment is associated with a
176 IF status_change is not none this comment is associated with a
177 status change of commit or commit associated with pull request
177 status change of commit or commit associated with pull request
178
178
179 :param text:
179 :param text:
180 :param repo:
180 :param repo:
181 :param user:
181 :param user:
182 :param commit_id:
182 :param commit_id:
183 :param pull_request:
183 :param pull_request:
184 :param f_path:
184 :param f_path:
185 :param line_no:
185 :param line_no:
186 :param status_change: Label for status change
186 :param status_change: Label for status change
187 :param comment_type: Type of comment
187 :param comment_type: Type of comment
188 :param status_change_type: type of status change
188 :param status_change_type: type of status change
189 :param closing_pr:
189 :param closing_pr:
190 :param send_email:
190 :param send_email:
191 :param renderer: pick renderer for this comment
191 :param renderer: pick renderer for this comment
192 """
192 """
193 if not text:
193 if not text:
194 log.warning('Missing text for comment, skipping...')
194 log.warning('Missing text for comment, skipping...')
195 return
195 return
196 request = get_current_request()
196 request = get_current_request()
197 _ = request.translate
197 _ = request.translate
198
198
199 if not renderer:
199 if not renderer:
200 renderer = self._get_renderer(request=request)
200 renderer = self._get_renderer(request=request)
201
201
202 repo = self._get_repo(repo)
202 repo = self._get_repo(repo)
203 user = self._get_user(user)
203 user = self._get_user(user)
204
204
205 schema = comment_schema.CommentSchema()
205 schema = comment_schema.CommentSchema()
206 validated_kwargs = schema.deserialize(dict(
206 validated_kwargs = schema.deserialize(dict(
207 comment_body=text,
207 comment_body=text,
208 comment_type=comment_type,
208 comment_type=comment_type,
209 comment_file=f_path,
209 comment_file=f_path,
210 comment_line=line_no,
210 comment_line=line_no,
211 renderer_type=renderer,
211 renderer_type=renderer,
212 status_change=status_change_type,
212 status_change=status_change_type,
213 resolves_comment_id=resolves_comment_id,
213 resolves_comment_id=resolves_comment_id,
214 repo=repo.repo_id,
214 repo=repo.repo_id,
215 user=user.user_id,
215 user=user.user_id,
216 ))
216 ))
217
217
218 comment = ChangesetComment()
218 comment = ChangesetComment()
219 comment.renderer = validated_kwargs['renderer_type']
219 comment.renderer = validated_kwargs['renderer_type']
220 comment.text = validated_kwargs['comment_body']
220 comment.text = validated_kwargs['comment_body']
221 comment.f_path = validated_kwargs['comment_file']
221 comment.f_path = validated_kwargs['comment_file']
222 comment.line_no = validated_kwargs['comment_line']
222 comment.line_no = validated_kwargs['comment_line']
223 comment.comment_type = validated_kwargs['comment_type']
223 comment.comment_type = validated_kwargs['comment_type']
224
224
225 comment.repo = repo
225 comment.repo = repo
226 comment.author = user
226 comment.author = user
227 comment.resolved_comment = self.__get_commit_comment(
227 resolved_comment = self.__get_commit_comment(
228 validated_kwargs['resolves_comment_id'])
228 validated_kwargs['resolves_comment_id'])
229 # check if the comment actually belongs to this PR
230 if resolved_comment and resolved_comment.pull_request and \
231 resolved_comment.pull_request != pull_request:
232 # comment not bound to this pull request, forbid
233 resolved_comment = None
234 comment.resolved_comment = resolved_comment
229
235
230 pull_request_id = pull_request
236 pull_request_id = pull_request
231
237
232 commit_obj = None
238 commit_obj = None
233 pull_request_obj = None
239 pull_request_obj = None
234
240
235 if commit_id:
241 if commit_id:
236 notification_type = EmailNotificationModel.TYPE_COMMIT_COMMENT
242 notification_type = EmailNotificationModel.TYPE_COMMIT_COMMENT
237 # do a lookup, so we don't pass something bad here
243 # do a lookup, so we don't pass something bad here
238 commit_obj = repo.scm_instance().get_commit(commit_id=commit_id)
244 commit_obj = repo.scm_instance().get_commit(commit_id=commit_id)
239 comment.revision = commit_obj.raw_id
245 comment.revision = commit_obj.raw_id
240
246
241 elif pull_request_id:
247 elif pull_request_id:
242 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST_COMMENT
248 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST_COMMENT
243 pull_request_obj = self.__get_pull_request(pull_request_id)
249 pull_request_obj = self.__get_pull_request(pull_request_id)
244 comment.pull_request = pull_request_obj
250 comment.pull_request = pull_request_obj
245 else:
251 else:
246 raise Exception('Please specify commit or pull_request_id')
252 raise Exception('Please specify commit or pull_request_id')
247
253
248 Session().add(comment)
254 Session().add(comment)
249 Session().flush()
255 Session().flush()
250 kwargs = {
256 kwargs = {
251 'user': user,
257 'user': user,
252 'renderer_type': renderer,
258 'renderer_type': renderer,
253 'repo_name': repo.repo_name,
259 'repo_name': repo.repo_name,
254 'status_change': status_change,
260 'status_change': status_change,
255 'status_change_type': status_change_type,
261 'status_change_type': status_change_type,
256 'comment_body': text,
262 'comment_body': text,
257 'comment_file': f_path,
263 'comment_file': f_path,
258 'comment_line': line_no,
264 'comment_line': line_no,
259 'comment_type': comment_type or 'note'
265 'comment_type': comment_type or 'note'
260 }
266 }
261
267
262 if commit_obj:
268 if commit_obj:
263 recipients = ChangesetComment.get_users(
269 recipients = ChangesetComment.get_users(
264 revision=commit_obj.raw_id)
270 revision=commit_obj.raw_id)
265 # add commit author if it's in RhodeCode system
271 # add commit author if it's in RhodeCode system
266 cs_author = User.get_from_cs_author(commit_obj.author)
272 cs_author = User.get_from_cs_author(commit_obj.author)
267 if not cs_author:
273 if not cs_author:
268 # use repo owner if we cannot extract the author correctly
274 # use repo owner if we cannot extract the author correctly
269 cs_author = repo.user
275 cs_author = repo.user
270 recipients += [cs_author]
276 recipients += [cs_author]
271
277
272 commit_comment_url = self.get_url(comment, request=request)
278 commit_comment_url = self.get_url(comment, request=request)
273
279
274 target_repo_url = h.link_to(
280 target_repo_url = h.link_to(
275 repo.repo_name,
281 repo.repo_name,
276 h.route_url('repo_summary', repo_name=repo.repo_name))
282 h.route_url('repo_summary', repo_name=repo.repo_name))
277
283
278 # commit specifics
284 # commit specifics
279 kwargs.update({
285 kwargs.update({
280 'commit': commit_obj,
286 'commit': commit_obj,
281 'commit_message': commit_obj.message,
287 'commit_message': commit_obj.message,
282 'commit_target_repo': target_repo_url,
288 'commit_target_repo': target_repo_url,
283 'commit_comment_url': commit_comment_url,
289 'commit_comment_url': commit_comment_url,
284 })
290 })
285
291
286 elif pull_request_obj:
292 elif pull_request_obj:
287 # get the current participants of this pull request
293 # get the current participants of this pull request
288 recipients = ChangesetComment.get_users(
294 recipients = ChangesetComment.get_users(
289 pull_request_id=pull_request_obj.pull_request_id)
295 pull_request_id=pull_request_obj.pull_request_id)
290 # add pull request author
296 # add pull request author
291 recipients += [pull_request_obj.author]
297 recipients += [pull_request_obj.author]
292
298
293 # add the reviewers to notification
299 # add the reviewers to notification
294 recipients += [x.user for x in pull_request_obj.reviewers]
300 recipients += [x.user for x in pull_request_obj.reviewers]
295
301
296 pr_target_repo = pull_request_obj.target_repo
302 pr_target_repo = pull_request_obj.target_repo
297 pr_source_repo = pull_request_obj.source_repo
303 pr_source_repo = pull_request_obj.source_repo
298
304
299 pr_comment_url = h.route_url(
305 pr_comment_url = h.route_url(
300 'pullrequest_show',
306 'pullrequest_show',
301 repo_name=pr_target_repo.repo_name,
307 repo_name=pr_target_repo.repo_name,
302 pull_request_id=pull_request_obj.pull_request_id,
308 pull_request_id=pull_request_obj.pull_request_id,
303 _anchor='comment-%s' % comment.comment_id)
309 _anchor='comment-%s' % comment.comment_id)
304
310
305 # set some variables for email notification
311 # set some variables for email notification
306 pr_target_repo_url = h.route_url(
312 pr_target_repo_url = h.route_url(
307 'repo_summary', repo_name=pr_target_repo.repo_name)
313 'repo_summary', repo_name=pr_target_repo.repo_name)
308
314
309 pr_source_repo_url = h.route_url(
315 pr_source_repo_url = h.route_url(
310 'repo_summary', repo_name=pr_source_repo.repo_name)
316 'repo_summary', repo_name=pr_source_repo.repo_name)
311
317
312 # pull request specifics
318 # pull request specifics
313 kwargs.update({
319 kwargs.update({
314 'pull_request': pull_request_obj,
320 'pull_request': pull_request_obj,
315 'pr_id': pull_request_obj.pull_request_id,
321 'pr_id': pull_request_obj.pull_request_id,
316 'pr_target_repo': pr_target_repo,
322 'pr_target_repo': pr_target_repo,
317 'pr_target_repo_url': pr_target_repo_url,
323 'pr_target_repo_url': pr_target_repo_url,
318 'pr_source_repo': pr_source_repo,
324 'pr_source_repo': pr_source_repo,
319 'pr_source_repo_url': pr_source_repo_url,
325 'pr_source_repo_url': pr_source_repo_url,
320 'pr_comment_url': pr_comment_url,
326 'pr_comment_url': pr_comment_url,
321 'pr_closing': closing_pr,
327 'pr_closing': closing_pr,
322 })
328 })
323 if send_email:
329 if send_email:
324 # pre-generate the subject for notification itself
330 # pre-generate the subject for notification itself
325 (subject,
331 (subject,
326 _h, _e, # we don't care about those
332 _h, _e, # we don't care about those
327 body_plaintext) = EmailNotificationModel().render_email(
333 body_plaintext) = EmailNotificationModel().render_email(
328 notification_type, **kwargs)
334 notification_type, **kwargs)
329
335
330 mention_recipients = set(
336 mention_recipients = set(
331 self._extract_mentions(text)).difference(recipients)
337 self._extract_mentions(text)).difference(recipients)
332
338
333 # create notification objects, and emails
339 # create notification objects, and emails
334 NotificationModel().create(
340 NotificationModel().create(
335 created_by=user,
341 created_by=user,
336 notification_subject=subject,
342 notification_subject=subject,
337 notification_body=body_plaintext,
343 notification_body=body_plaintext,
338 notification_type=notification_type,
344 notification_type=notification_type,
339 recipients=recipients,
345 recipients=recipients,
340 mention_recipients=mention_recipients,
346 mention_recipients=mention_recipients,
341 email_kwargs=kwargs,
347 email_kwargs=kwargs,
342 )
348 )
343
349
344 Session().flush()
350 Session().flush()
345 if comment.pull_request:
351 if comment.pull_request:
346 action = 'repo.pull_request.comment.create'
352 action = 'repo.pull_request.comment.create'
347 else:
353 else:
348 action = 'repo.commit.comment.create'
354 action = 'repo.commit.comment.create'
349
355
350 comment_data = comment.get_api_data()
356 comment_data = comment.get_api_data()
351 self._log_audit_action(
357 self._log_audit_action(
352 action, {'data': comment_data}, user, comment)
358 action, {'data': comment_data}, user, comment)
353
359
354 msg_url = ''
360 msg_url = ''
355 channel = None
361 channel = None
356 if commit_obj:
362 if commit_obj:
357 msg_url = commit_comment_url
363 msg_url = commit_comment_url
358 repo_name = repo.repo_name
364 repo_name = repo.repo_name
359 channel = u'/repo${}$/commit/{}'.format(
365 channel = u'/repo${}$/commit/{}'.format(
360 repo_name,
366 repo_name,
361 commit_obj.raw_id
367 commit_obj.raw_id
362 )
368 )
363 elif pull_request_obj:
369 elif pull_request_obj:
364 msg_url = pr_comment_url
370 msg_url = pr_comment_url
365 repo_name = pr_target_repo.repo_name
371 repo_name = pr_target_repo.repo_name
366 channel = u'/repo${}$/pr/{}'.format(
372 channel = u'/repo${}$/pr/{}'.format(
367 repo_name,
373 repo_name,
368 pull_request_id
374 pull_request_id
369 )
375 )
370
376
371 message = '<strong>{}</strong> {} - ' \
377 message = '<strong>{}</strong> {} - ' \
372 '<a onclick="window.location=\'{}\';' \
378 '<a onclick="window.location=\'{}\';' \
373 'window.location.reload()">' \
379 'window.location.reload()">' \
374 '<strong>{}</strong></a>'
380 '<strong>{}</strong></a>'
375 message = message.format(
381 message = message.format(
376 user.username, _('made a comment'), msg_url,
382 user.username, _('made a comment'), msg_url,
377 _('Show it now'))
383 _('Show it now'))
378
384
379 channelstream.post_message(
385 channelstream.post_message(
380 channel, message, user.username,
386 channel, message, user.username,
381 registry=get_current_registry())
387 registry=get_current_registry())
382
388
383 return comment
389 return comment
384
390
385 def delete(self, comment, user):
391 def delete(self, comment, user):
386 """
392 """
387 Deletes given comment
393 Deletes given comment
388 """
394 """
389 comment = self.__get_commit_comment(comment)
395 comment = self.__get_commit_comment(comment)
390 old_data = comment.get_api_data()
396 old_data = comment.get_api_data()
391 Session().delete(comment)
397 Session().delete(comment)
392
398
393 if comment.pull_request:
399 if comment.pull_request:
394 action = 'repo.pull_request.comment.delete'
400 action = 'repo.pull_request.comment.delete'
395 else:
401 else:
396 action = 'repo.commit.comment.delete'
402 action = 'repo.commit.comment.delete'
397
403
398 self._log_audit_action(
404 self._log_audit_action(
399 action, {'old_data': old_data}, user, comment)
405 action, {'old_data': old_data}, user, comment)
400
406
401 return comment
407 return comment
402
408
403 def get_all_comments(self, repo_id, revision=None, pull_request=None):
409 def get_all_comments(self, repo_id, revision=None, pull_request=None):
404 q = ChangesetComment.query()\
410 q = ChangesetComment.query()\
405 .filter(ChangesetComment.repo_id == repo_id)
411 .filter(ChangesetComment.repo_id == repo_id)
406 if revision:
412 if revision:
407 q = q.filter(ChangesetComment.revision == revision)
413 q = q.filter(ChangesetComment.revision == revision)
408 elif pull_request:
414 elif pull_request:
409 pull_request = self.__get_pull_request(pull_request)
415 pull_request = self.__get_pull_request(pull_request)
410 q = q.filter(ChangesetComment.pull_request == pull_request)
416 q = q.filter(ChangesetComment.pull_request == pull_request)
411 else:
417 else:
412 raise Exception('Please specify commit or pull_request')
418 raise Exception('Please specify commit or pull_request')
413 q = q.order_by(ChangesetComment.created_on)
419 q = q.order_by(ChangesetComment.created_on)
414 return q.all()
420 return q.all()
415
421
416 def get_url(self, comment, request=None, permalink=False):
422 def get_url(self, comment, request=None, permalink=False):
417 if not request:
423 if not request:
418 request = get_current_request()
424 request = get_current_request()
419
425
420 comment = self.__get_commit_comment(comment)
426 comment = self.__get_commit_comment(comment)
421 if comment.pull_request:
427 if comment.pull_request:
422 pull_request = comment.pull_request
428 pull_request = comment.pull_request
423 if permalink:
429 if permalink:
424 return request.route_url(
430 return request.route_url(
425 'pull_requests_global',
431 'pull_requests_global',
426 pull_request_id=pull_request.pull_request_id,
432 pull_request_id=pull_request.pull_request_id,
427 _anchor='comment-%s' % comment.comment_id)
433 _anchor='comment-%s' % comment.comment_id)
428 else:
434 else:
429 return request.route_url('pullrequest_show',
435 return request.route_url('pullrequest_show',
430 repo_name=safe_str(pull_request.target_repo.repo_name),
436 repo_name=safe_str(pull_request.target_repo.repo_name),
431 pull_request_id=pull_request.pull_request_id,
437 pull_request_id=pull_request.pull_request_id,
432 _anchor='comment-%s' % comment.comment_id)
438 _anchor='comment-%s' % comment.comment_id)
433
439
434 else:
440 else:
435 repo = comment.repo
441 repo = comment.repo
436 commit_id = comment.revision
442 commit_id = comment.revision
437
443
438 if permalink:
444 if permalink:
439 return request.route_url(
445 return request.route_url(
440 'repo_commit', repo_name=safe_str(repo.repo_id),
446 'repo_commit', repo_name=safe_str(repo.repo_id),
441 commit_id=commit_id,
447 commit_id=commit_id,
442 _anchor='comment-%s' % comment.comment_id)
448 _anchor='comment-%s' % comment.comment_id)
443
449
444 else:
450 else:
445 return request.route_url(
451 return request.route_url(
446 'repo_commit', repo_name=safe_str(repo.repo_name),
452 'repo_commit', repo_name=safe_str(repo.repo_name),
447 commit_id=commit_id,
453 commit_id=commit_id,
448 _anchor='comment-%s' % comment.comment_id)
454 _anchor='comment-%s' % comment.comment_id)
449
455
450 def get_comments(self, repo_id, revision=None, pull_request=None):
456 def get_comments(self, repo_id, revision=None, pull_request=None):
451 """
457 """
452 Gets main comments based on revision or pull_request_id
458 Gets main comments based on revision or pull_request_id
453
459
454 :param repo_id:
460 :param repo_id:
455 :param revision:
461 :param revision:
456 :param pull_request:
462 :param pull_request:
457 """
463 """
458
464
459 q = ChangesetComment.query()\
465 q = ChangesetComment.query()\
460 .filter(ChangesetComment.repo_id == repo_id)\
466 .filter(ChangesetComment.repo_id == repo_id)\
461 .filter(ChangesetComment.line_no == None)\
467 .filter(ChangesetComment.line_no == None)\
462 .filter(ChangesetComment.f_path == None)
468 .filter(ChangesetComment.f_path == None)
463 if revision:
469 if revision:
464 q = q.filter(ChangesetComment.revision == revision)
470 q = q.filter(ChangesetComment.revision == revision)
465 elif pull_request:
471 elif pull_request:
466 pull_request = self.__get_pull_request(pull_request)
472 pull_request = self.__get_pull_request(pull_request)
467 q = q.filter(ChangesetComment.pull_request == pull_request)
473 q = q.filter(ChangesetComment.pull_request == pull_request)
468 else:
474 else:
469 raise Exception('Please specify commit or pull_request')
475 raise Exception('Please specify commit or pull_request')
470 q = q.order_by(ChangesetComment.created_on)
476 q = q.order_by(ChangesetComment.created_on)
471 return q.all()
477 return q.all()
472
478
473 def get_inline_comments(self, repo_id, revision=None, pull_request=None):
479 def get_inline_comments(self, repo_id, revision=None, pull_request=None):
474 q = self._get_inline_comments_query(repo_id, revision, pull_request)
480 q = self._get_inline_comments_query(repo_id, revision, pull_request)
475 return self._group_comments_by_path_and_line_number(q)
481 return self._group_comments_by_path_and_line_number(q)
476
482
477 def get_inline_comments_count(self, inline_comments, skip_outdated=True,
483 def get_inline_comments_count(self, inline_comments, skip_outdated=True,
478 version=None):
484 version=None):
479 inline_cnt = 0
485 inline_cnt = 0
480 for fname, per_line_comments in inline_comments.iteritems():
486 for fname, per_line_comments in inline_comments.iteritems():
481 for lno, comments in per_line_comments.iteritems():
487 for lno, comments in per_line_comments.iteritems():
482 for comm in comments:
488 for comm in comments:
483 if not comm.outdated_at_version(version) and skip_outdated:
489 if not comm.outdated_at_version(version) and skip_outdated:
484 inline_cnt += 1
490 inline_cnt += 1
485
491
486 return inline_cnt
492 return inline_cnt
487
493
488 def get_outdated_comments(self, repo_id, pull_request):
494 def get_outdated_comments(self, repo_id, pull_request):
489 # TODO: johbo: Remove `repo_id`, it is not needed to find the comments
495 # TODO: johbo: Remove `repo_id`, it is not needed to find the comments
490 # of a pull request.
496 # of a pull request.
491 q = self._all_inline_comments_of_pull_request(pull_request)
497 q = self._all_inline_comments_of_pull_request(pull_request)
492 q = q.filter(
498 q = q.filter(
493 ChangesetComment.display_state ==
499 ChangesetComment.display_state ==
494 ChangesetComment.COMMENT_OUTDATED
500 ChangesetComment.COMMENT_OUTDATED
495 ).order_by(ChangesetComment.comment_id.asc())
501 ).order_by(ChangesetComment.comment_id.asc())
496
502
497 return self._group_comments_by_path_and_line_number(q)
503 return self._group_comments_by_path_and_line_number(q)
498
504
499 def _get_inline_comments_query(self, repo_id, revision, pull_request):
505 def _get_inline_comments_query(self, repo_id, revision, pull_request):
500 # TODO: johbo: Split this into two methods: One for PR and one for
506 # TODO: johbo: Split this into two methods: One for PR and one for
501 # commit.
507 # commit.
502 if revision:
508 if revision:
503 q = Session().query(ChangesetComment).filter(
509 q = Session().query(ChangesetComment).filter(
504 ChangesetComment.repo_id == repo_id,
510 ChangesetComment.repo_id == repo_id,
505 ChangesetComment.line_no != null(),
511 ChangesetComment.line_no != null(),
506 ChangesetComment.f_path != null(),
512 ChangesetComment.f_path != null(),
507 ChangesetComment.revision == revision)
513 ChangesetComment.revision == revision)
508
514
509 elif pull_request:
515 elif pull_request:
510 pull_request = self.__get_pull_request(pull_request)
516 pull_request = self.__get_pull_request(pull_request)
511 if not CommentsModel.use_outdated_comments(pull_request):
517 if not CommentsModel.use_outdated_comments(pull_request):
512 q = self._visible_inline_comments_of_pull_request(pull_request)
518 q = self._visible_inline_comments_of_pull_request(pull_request)
513 else:
519 else:
514 q = self._all_inline_comments_of_pull_request(pull_request)
520 q = self._all_inline_comments_of_pull_request(pull_request)
515
521
516 else:
522 else:
517 raise Exception('Please specify commit or pull_request_id')
523 raise Exception('Please specify commit or pull_request_id')
518 q = q.order_by(ChangesetComment.comment_id.asc())
524 q = q.order_by(ChangesetComment.comment_id.asc())
519 return q
525 return q
520
526
521 def _group_comments_by_path_and_line_number(self, q):
527 def _group_comments_by_path_and_line_number(self, q):
522 comments = q.all()
528 comments = q.all()
523 paths = collections.defaultdict(lambda: collections.defaultdict(list))
529 paths = collections.defaultdict(lambda: collections.defaultdict(list))
524 for co in comments:
530 for co in comments:
525 paths[co.f_path][co.line_no].append(co)
531 paths[co.f_path][co.line_no].append(co)
526 return paths
532 return paths
527
533
528 @classmethod
534 @classmethod
529 def needed_extra_diff_context(cls):
535 def needed_extra_diff_context(cls):
530 return max(cls.DIFF_CONTEXT_BEFORE, cls.DIFF_CONTEXT_AFTER)
536 return max(cls.DIFF_CONTEXT_BEFORE, cls.DIFF_CONTEXT_AFTER)
531
537
532 def outdate_comments(self, pull_request, old_diff_data, new_diff_data):
538 def outdate_comments(self, pull_request, old_diff_data, new_diff_data):
533 if not CommentsModel.use_outdated_comments(pull_request):
539 if not CommentsModel.use_outdated_comments(pull_request):
534 return
540 return
535
541
536 comments = self._visible_inline_comments_of_pull_request(pull_request)
542 comments = self._visible_inline_comments_of_pull_request(pull_request)
537 comments_to_outdate = comments.all()
543 comments_to_outdate = comments.all()
538
544
539 for comment in comments_to_outdate:
545 for comment in comments_to_outdate:
540 self._outdate_one_comment(comment, old_diff_data, new_diff_data)
546 self._outdate_one_comment(comment, old_diff_data, new_diff_data)
541
547
542 def _outdate_one_comment(self, comment, old_diff_proc, new_diff_proc):
548 def _outdate_one_comment(self, comment, old_diff_proc, new_diff_proc):
543 diff_line = _parse_comment_line_number(comment.line_no)
549 diff_line = _parse_comment_line_number(comment.line_no)
544
550
545 try:
551 try:
546 old_context = old_diff_proc.get_context_of_line(
552 old_context = old_diff_proc.get_context_of_line(
547 path=comment.f_path, diff_line=diff_line)
553 path=comment.f_path, diff_line=diff_line)
548 new_context = new_diff_proc.get_context_of_line(
554 new_context = new_diff_proc.get_context_of_line(
549 path=comment.f_path, diff_line=diff_line)
555 path=comment.f_path, diff_line=diff_line)
550 except (diffs.LineNotInDiffException,
556 except (diffs.LineNotInDiffException,
551 diffs.FileNotInDiffException):
557 diffs.FileNotInDiffException):
552 comment.display_state = ChangesetComment.COMMENT_OUTDATED
558 comment.display_state = ChangesetComment.COMMENT_OUTDATED
553 return
559 return
554
560
555 if old_context == new_context:
561 if old_context == new_context:
556 return
562 return
557
563
558 if self._should_relocate_diff_line(diff_line):
564 if self._should_relocate_diff_line(diff_line):
559 new_diff_lines = new_diff_proc.find_context(
565 new_diff_lines = new_diff_proc.find_context(
560 path=comment.f_path, context=old_context,
566 path=comment.f_path, context=old_context,
561 offset=self.DIFF_CONTEXT_BEFORE)
567 offset=self.DIFF_CONTEXT_BEFORE)
562 if not new_diff_lines:
568 if not new_diff_lines:
563 comment.display_state = ChangesetComment.COMMENT_OUTDATED
569 comment.display_state = ChangesetComment.COMMENT_OUTDATED
564 else:
570 else:
565 new_diff_line = self._choose_closest_diff_line(
571 new_diff_line = self._choose_closest_diff_line(
566 diff_line, new_diff_lines)
572 diff_line, new_diff_lines)
567 comment.line_no = _diff_to_comment_line_number(new_diff_line)
573 comment.line_no = _diff_to_comment_line_number(new_diff_line)
568 else:
574 else:
569 comment.display_state = ChangesetComment.COMMENT_OUTDATED
575 comment.display_state = ChangesetComment.COMMENT_OUTDATED
570
576
571 def _should_relocate_diff_line(self, diff_line):
577 def _should_relocate_diff_line(self, diff_line):
572 """
578 """
573 Checks if relocation shall be tried for the given `diff_line`.
579 Checks if relocation shall be tried for the given `diff_line`.
574
580
575 If a comment points into the first lines, then we can have a situation
581 If a comment points into the first lines, then we can have a situation
576 that after an update another line has been added on top. In this case
582 that after an update another line has been added on top. In this case
577 we would find the context still and move the comment around. This
583 we would find the context still and move the comment around. This
578 would be wrong.
584 would be wrong.
579 """
585 """
580 should_relocate = (
586 should_relocate = (
581 (diff_line.new and diff_line.new > self.DIFF_CONTEXT_BEFORE) or
587 (diff_line.new and diff_line.new > self.DIFF_CONTEXT_BEFORE) or
582 (diff_line.old and diff_line.old > self.DIFF_CONTEXT_BEFORE))
588 (diff_line.old and diff_line.old > self.DIFF_CONTEXT_BEFORE))
583 return should_relocate
589 return should_relocate
584
590
585 def _choose_closest_diff_line(self, diff_line, new_diff_lines):
591 def _choose_closest_diff_line(self, diff_line, new_diff_lines):
586 candidate = new_diff_lines[0]
592 candidate = new_diff_lines[0]
587 best_delta = _diff_line_delta(diff_line, candidate)
593 best_delta = _diff_line_delta(diff_line, candidate)
588 for new_diff_line in new_diff_lines[1:]:
594 for new_diff_line in new_diff_lines[1:]:
589 delta = _diff_line_delta(diff_line, new_diff_line)
595 delta = _diff_line_delta(diff_line, new_diff_line)
590 if delta < best_delta:
596 if delta < best_delta:
591 candidate = new_diff_line
597 candidate = new_diff_line
592 best_delta = delta
598 best_delta = delta
593 return candidate
599 return candidate
594
600
595 def _visible_inline_comments_of_pull_request(self, pull_request):
601 def _visible_inline_comments_of_pull_request(self, pull_request):
596 comments = self._all_inline_comments_of_pull_request(pull_request)
602 comments = self._all_inline_comments_of_pull_request(pull_request)
597 comments = comments.filter(
603 comments = comments.filter(
598 coalesce(ChangesetComment.display_state, '') !=
604 coalesce(ChangesetComment.display_state, '') !=
599 ChangesetComment.COMMENT_OUTDATED)
605 ChangesetComment.COMMENT_OUTDATED)
600 return comments
606 return comments
601
607
602 def _all_inline_comments_of_pull_request(self, pull_request):
608 def _all_inline_comments_of_pull_request(self, pull_request):
603 comments = Session().query(ChangesetComment)\
609 comments = Session().query(ChangesetComment)\
604 .filter(ChangesetComment.line_no != None)\
610 .filter(ChangesetComment.line_no != None)\
605 .filter(ChangesetComment.f_path != None)\
611 .filter(ChangesetComment.f_path != None)\
606 .filter(ChangesetComment.pull_request == pull_request)
612 .filter(ChangesetComment.pull_request == pull_request)
607 return comments
613 return comments
608
614
609 def _all_general_comments_of_pull_request(self, pull_request):
615 def _all_general_comments_of_pull_request(self, pull_request):
610 comments = Session().query(ChangesetComment)\
616 comments = Session().query(ChangesetComment)\
611 .filter(ChangesetComment.line_no == None)\
617 .filter(ChangesetComment.line_no == None)\
612 .filter(ChangesetComment.f_path == None)\
618 .filter(ChangesetComment.f_path == None)\
613 .filter(ChangesetComment.pull_request == pull_request)
619 .filter(ChangesetComment.pull_request == pull_request)
614 return comments
620 return comments
615
621
616 @staticmethod
622 @staticmethod
617 def use_outdated_comments(pull_request):
623 def use_outdated_comments(pull_request):
618 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
624 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
619 settings = settings_model.get_general_settings()
625 settings = settings_model.get_general_settings()
620 return settings.get('rhodecode_use_outdated_comments', False)
626 return settings.get('rhodecode_use_outdated_comments', False)
621
627
622
628
623 def _parse_comment_line_number(line_no):
629 def _parse_comment_line_number(line_no):
624 """
630 """
625 Parses line numbers of the form "(o|n)\d+" and returns them in a tuple.
631 Parses line numbers of the form "(o|n)\d+" and returns them in a tuple.
626 """
632 """
627 old_line = None
633 old_line = None
628 new_line = None
634 new_line = None
629 if line_no.startswith('o'):
635 if line_no.startswith('o'):
630 old_line = int(line_no[1:])
636 old_line = int(line_no[1:])
631 elif line_no.startswith('n'):
637 elif line_no.startswith('n'):
632 new_line = int(line_no[1:])
638 new_line = int(line_no[1:])
633 else:
639 else:
634 raise ValueError("Comment lines have to start with either 'o' or 'n'.")
640 raise ValueError("Comment lines have to start with either 'o' or 'n'.")
635 return diffs.DiffLineNumber(old_line, new_line)
641 return diffs.DiffLineNumber(old_line, new_line)
636
642
637
643
638 def _diff_to_comment_line_number(diff_line):
644 def _diff_to_comment_line_number(diff_line):
639 if diff_line.new is not None:
645 if diff_line.new is not None:
640 return u'n{}'.format(diff_line.new)
646 return u'n{}'.format(diff_line.new)
641 elif diff_line.old is not None:
647 elif diff_line.old is not None:
642 return u'o{}'.format(diff_line.old)
648 return u'o{}'.format(diff_line.old)
643 return u''
649 return u''
644
650
645
651
646 def _diff_line_delta(a, b):
652 def _diff_line_delta(a, b):
647 if None not in (a.new, b.new):
653 if None not in (a.new, b.new):
648 return abs(a.new - b.new)
654 return abs(a.new - b.new)
649 elif None not in (a.old, b.old):
655 elif None not in (a.old, b.old):
650 return abs(a.old - b.old)
656 return abs(a.old - b.old)
651 else:
657 else:
652 raise ValueError(
658 raise ValueError(
653 "Cannot compute delta between {} and {}".format(a, b))
659 "Cannot compute delta between {} and {}".format(a, b))
General Comments 0
You need to be logged in to leave comments. Login now