##// END OF EJS Templates
inline-comments: added helper to properly count inline comments.
marcink -
r1206:5653a4e4 stable
parent child Browse files
Show More
@@ -1,464 +1,464 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2016 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 commit controller for RhodeCode showing changes between commits
23 23 """
24 24
25 25 import logging
26 26
27 27 from collections import defaultdict
28 28 from webob.exc import HTTPForbidden, HTTPBadRequest, HTTPNotFound
29 29
30 30 from pylons import tmpl_context as c, request, response
31 31 from pylons.i18n.translation import _
32 32 from pylons.controllers.util import redirect
33 33
34 34 from rhodecode.lib import auth
35 35 from rhodecode.lib import diffs, codeblocks
36 36 from rhodecode.lib.auth import (
37 37 LoginRequired, HasRepoPermissionAnyDecorator, NotAnonymous)
38 38 from rhodecode.lib.base import BaseRepoController, render
39 39 from rhodecode.lib.compat import OrderedDict
40 40 from rhodecode.lib.exceptions import StatusChangeOnClosedPullRequestError
41 41 import rhodecode.lib.helpers as h
42 42 from rhodecode.lib.utils import action_logger, jsonify
43 43 from rhodecode.lib.utils2 import safe_unicode
44 44 from rhodecode.lib.vcs.backends.base import EmptyCommit
45 45 from rhodecode.lib.vcs.exceptions import (
46 46 RepositoryError, CommitDoesNotExistError, NodeDoesNotExistError)
47 47 from rhodecode.model.db import ChangesetComment, ChangesetStatus
48 48 from rhodecode.model.changeset_status import ChangesetStatusModel
49 49 from rhodecode.model.comment import ChangesetCommentsModel
50 50 from rhodecode.model.meta import Session
51 51 from rhodecode.model.repo import RepoModel
52 52
53 53
54 54 log = logging.getLogger(__name__)
55 55
56 56
57 57 def _update_with_GET(params, GET):
58 58 for k in ['diff1', 'diff2', 'diff']:
59 59 params[k] += GET.getall(k)
60 60
61 61
62 62 def get_ignore_ws(fid, GET):
63 63 ig_ws_global = GET.get('ignorews')
64 64 ig_ws = filter(lambda k: k.startswith('WS'), GET.getall(fid))
65 65 if ig_ws:
66 66 try:
67 67 return int(ig_ws[0].split(':')[-1])
68 68 except Exception:
69 69 pass
70 70 return ig_ws_global
71 71
72 72
73 73 def _ignorews_url(GET, fileid=None):
74 74 fileid = str(fileid) if fileid else None
75 75 params = defaultdict(list)
76 76 _update_with_GET(params, GET)
77 77 label = _('Show whitespace')
78 78 tooltiplbl = _('Show whitespace for all diffs')
79 79 ig_ws = get_ignore_ws(fileid, GET)
80 80 ln_ctx = get_line_ctx(fileid, GET)
81 81
82 82 if ig_ws is None:
83 83 params['ignorews'] += [1]
84 84 label = _('Ignore whitespace')
85 85 tooltiplbl = _('Ignore whitespace for all diffs')
86 86 ctx_key = 'context'
87 87 ctx_val = ln_ctx
88 88
89 89 # if we have passed in ln_ctx pass it along to our params
90 90 if ln_ctx:
91 91 params[ctx_key] += [ctx_val]
92 92
93 93 if fileid:
94 94 params['anchor'] = 'a_' + fileid
95 95 return h.link_to(label, h.url.current(**params), title=tooltiplbl, class_='tooltip')
96 96
97 97
98 98 def get_line_ctx(fid, GET):
99 99 ln_ctx_global = GET.get('context')
100 100 if fid:
101 101 ln_ctx = filter(lambda k: k.startswith('C'), GET.getall(fid))
102 102 else:
103 103 _ln_ctx = filter(lambda k: k.startswith('C'), GET)
104 104 ln_ctx = GET.get(_ln_ctx[0]) if _ln_ctx else ln_ctx_global
105 105 if ln_ctx:
106 106 ln_ctx = [ln_ctx]
107 107
108 108 if ln_ctx:
109 109 retval = ln_ctx[0].split(':')[-1]
110 110 else:
111 111 retval = ln_ctx_global
112 112
113 113 try:
114 114 return int(retval)
115 115 except Exception:
116 116 return 3
117 117
118 118
119 119 def _context_url(GET, fileid=None):
120 120 """
121 121 Generates a url for context lines.
122 122
123 123 :param fileid:
124 124 """
125 125
126 126 fileid = str(fileid) if fileid else None
127 127 ig_ws = get_ignore_ws(fileid, GET)
128 128 ln_ctx = (get_line_ctx(fileid, GET) or 3) * 2
129 129
130 130 params = defaultdict(list)
131 131 _update_with_GET(params, GET)
132 132
133 133 if ln_ctx > 0:
134 134 params['context'] += [ln_ctx]
135 135
136 136 if ig_ws:
137 137 ig_ws_key = 'ignorews'
138 138 ig_ws_val = 1
139 139 params[ig_ws_key] += [ig_ws_val]
140 140
141 141 lbl = _('Increase context')
142 142 tooltiplbl = _('Increase context for all diffs')
143 143
144 144 if fileid:
145 145 params['anchor'] = 'a_' + fileid
146 146 return h.link_to(lbl, h.url.current(**params), title=tooltiplbl, class_='tooltip')
147 147
148 148
149 149 class ChangesetController(BaseRepoController):
150 150
151 151 def __before__(self):
152 152 super(ChangesetController, self).__before__()
153 153 c.affected_files_cut_off = 60
154 154
155 155 def _index(self, commit_id_range, method):
156 156 c.ignorews_url = _ignorews_url
157 157 c.context_url = _context_url
158 158 c.fulldiff = fulldiff = request.GET.get('fulldiff')
159 159
160 160 # fetch global flags of ignore ws or context lines
161 161 context_lcl = get_line_ctx('', request.GET)
162 162 ign_whitespace_lcl = get_ignore_ws('', request.GET)
163 163
164 164 # diff_limit will cut off the whole diff if the limit is applied
165 165 # otherwise it will just hide the big files from the front-end
166 166 diff_limit = self.cut_off_limit_diff
167 167 file_limit = self.cut_off_limit_file
168 168
169 169 # get ranges of commit ids if preset
170 170 commit_range = commit_id_range.split('...')[:2]
171 171
172 172 try:
173 173 pre_load = ['affected_files', 'author', 'branch', 'date',
174 174 'message', 'parents']
175 175
176 176 if len(commit_range) == 2:
177 177 commits = c.rhodecode_repo.get_commits(
178 178 start_id=commit_range[0], end_id=commit_range[1],
179 179 pre_load=pre_load)
180 180 commits = list(commits)
181 181 else:
182 182 commits = [c.rhodecode_repo.get_commit(
183 183 commit_id=commit_id_range, pre_load=pre_load)]
184 184
185 185 c.commit_ranges = commits
186 186 if not c.commit_ranges:
187 187 raise RepositoryError(
188 188 'The commit range returned an empty result')
189 189 except CommitDoesNotExistError:
190 190 msg = _('No such commit exists for this repository')
191 191 h.flash(msg, category='error')
192 192 raise HTTPNotFound()
193 193 except Exception:
194 194 log.exception("General failure")
195 195 raise HTTPNotFound()
196 196
197 197 c.changes = OrderedDict()
198 198 c.lines_added = 0
199 199 c.lines_deleted = 0
200 200
201 201 c.commit_statuses = ChangesetStatus.STATUSES
202 202 c.inline_comments = []
203 c.inline_cnt = 0
204 203 c.files = []
205 204
206 205 c.statuses = []
207 206 c.comments = []
208 207 if len(c.commit_ranges) == 1:
209 208 commit = c.commit_ranges[0]
210 209 c.comments = ChangesetCommentsModel().get_comments(
211 210 c.rhodecode_db_repo.repo_id,
212 211 revision=commit.raw_id)
213 212 c.statuses.append(ChangesetStatusModel().get_status(
214 213 c.rhodecode_db_repo.repo_id, commit.raw_id))
215 214 # comments from PR
216 215 statuses = ChangesetStatusModel().get_statuses(
217 216 c.rhodecode_db_repo.repo_id, commit.raw_id,
218 217 with_revisions=True)
219 218 prs = set(st.pull_request for st in statuses
220 219 if st.pull_request is not None)
221 220 # from associated statuses, check the pull requests, and
222 221 # show comments from them
223 222 for pr in prs:
224 223 c.comments.extend(pr.comments)
225 224
226 225 # Iterate over ranges (default commit view is always one commit)
227 226 for commit in c.commit_ranges:
228 227 c.changes[commit.raw_id] = []
229 228
230 229 commit2 = commit
231 230 commit1 = commit.parents[0] if commit.parents else EmptyCommit()
232 231
233 232 _diff = c.rhodecode_repo.get_diff(
234 233 commit1, commit2,
235 234 ignore_whitespace=ign_whitespace_lcl, context=context_lcl)
236 235 diff_processor = diffs.DiffProcessor(
237 236 _diff, format='newdiff', diff_limit=diff_limit,
238 237 file_limit=file_limit, show_full_diff=fulldiff)
239 238
240 239 commit_changes = OrderedDict()
241 240 if method == 'show':
242 241 _parsed = diff_processor.prepare()
243 242 c.limited_diff = isinstance(_parsed, diffs.LimitedDiffContainer)
244 243
245 244 _parsed = diff_processor.prepare()
246 245
247 246 def _node_getter(commit):
248 247 def get_node(fname):
249 248 try:
250 249 return commit.get_node(fname)
251 250 except NodeDoesNotExistError:
252 251 return None
253 252 return get_node
254 253
255 254 inline_comments = ChangesetCommentsModel().get_inline_comments(
256 255 c.rhodecode_db_repo.repo_id, revision=commit.raw_id)
257 c.inline_cnt += len(inline_comments)
256 c.inline_cnt = ChangesetCommentsModel().get_inline_comments_count(
257 inline_comments)
258 258
259 259 diffset = codeblocks.DiffSet(
260 260 repo_name=c.repo_name,
261 261 source_node_getter=_node_getter(commit1),
262 262 target_node_getter=_node_getter(commit2),
263 263 comments=inline_comments
264 264 ).render_patchset(_parsed, commit1.raw_id, commit2.raw_id)
265 265 c.changes[commit.raw_id] = diffset
266 266 else:
267 267 # downloads/raw we only need RAW diff nothing else
268 268 diff = diff_processor.as_raw()
269 269 c.changes[commit.raw_id] = [None, None, None, None, diff, None, None]
270 270
271 271 # sort comments by how they were generated
272 272 c.comments = sorted(c.comments, key=lambda x: x.comment_id)
273 273
274 274
275 275 if len(c.commit_ranges) == 1:
276 276 c.commit = c.commit_ranges[0]
277 277 c.parent_tmpl = ''.join(
278 278 '# Parent %s\n' % x.raw_id for x in c.commit.parents)
279 279 if method == 'download':
280 280 response.content_type = 'text/plain'
281 281 response.content_disposition = (
282 282 'attachment; filename=%s.diff' % commit_id_range[:12])
283 283 return diff
284 284 elif method == 'patch':
285 285 response.content_type = 'text/plain'
286 286 c.diff = safe_unicode(diff)
287 287 return render('changeset/patch_changeset.html')
288 288 elif method == 'raw':
289 289 response.content_type = 'text/plain'
290 290 return diff
291 291 elif method == 'show':
292 292 if len(c.commit_ranges) == 1:
293 293 return render('changeset/changeset.html')
294 294 else:
295 295 c.ancestor = None
296 296 c.target_repo = c.rhodecode_db_repo
297 297 return render('changeset/changeset_range.html')
298 298
299 299 @LoginRequired()
300 300 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
301 301 'repository.admin')
302 302 def index(self, revision, method='show'):
303 303 return self._index(revision, method=method)
304 304
305 305 @LoginRequired()
306 306 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
307 307 'repository.admin')
308 308 def changeset_raw(self, revision):
309 309 return self._index(revision, method='raw')
310 310
311 311 @LoginRequired()
312 312 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
313 313 'repository.admin')
314 314 def changeset_patch(self, revision):
315 315 return self._index(revision, method='patch')
316 316
317 317 @LoginRequired()
318 318 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
319 319 'repository.admin')
320 320 def changeset_download(self, revision):
321 321 return self._index(revision, method='download')
322 322
323 323 @LoginRequired()
324 324 @NotAnonymous()
325 325 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
326 326 'repository.admin')
327 327 @auth.CSRFRequired()
328 328 @jsonify
329 329 def comment(self, repo_name, revision):
330 330 commit_id = revision
331 331 status = request.POST.get('changeset_status', None)
332 332 text = request.POST.get('text')
333 333 if status:
334 334 text = text or (_('Status change %(transition_icon)s %(status)s')
335 335 % {'transition_icon': '>',
336 336 'status': ChangesetStatus.get_status_lbl(status)})
337 337
338 338 multi_commit_ids = filter(
339 339 lambda s: s not in ['', None],
340 340 request.POST.get('commit_ids', '').split(','),)
341 341
342 342 commit_ids = multi_commit_ids or [commit_id]
343 343 comment = None
344 344 for current_id in filter(None, commit_ids):
345 345 c.co = comment = ChangesetCommentsModel().create(
346 346 text=text,
347 347 repo=c.rhodecode_db_repo.repo_id,
348 348 user=c.rhodecode_user.user_id,
349 349 revision=current_id,
350 350 f_path=request.POST.get('f_path'),
351 351 line_no=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 )
356 356 # get status if set !
357 357 if status:
358 358 # if latest status was from pull request and it's closed
359 359 # disallow changing status !
360 360 # dont_allow_on_closed_pull_request = True !
361 361
362 362 try:
363 363 ChangesetStatusModel().set_status(
364 364 c.rhodecode_db_repo.repo_id,
365 365 status,
366 366 c.rhodecode_user.user_id,
367 367 comment,
368 368 revision=current_id,
369 369 dont_allow_on_closed_pull_request=True
370 370 )
371 371 except StatusChangeOnClosedPullRequestError:
372 372 msg = _('Changing the status of a commit associated with '
373 373 'a closed pull request is not allowed')
374 374 log.exception(msg)
375 375 h.flash(msg, category='warning')
376 376 return redirect(h.url(
377 377 'changeset_home', repo_name=repo_name,
378 378 revision=current_id))
379 379
380 380 # finalize, commit and redirect
381 381 Session().commit()
382 382
383 383 data = {
384 384 'target_id': h.safeid(h.safe_unicode(request.POST.get('f_path'))),
385 385 }
386 386 if comment:
387 387 data.update(comment.get_dict())
388 388 data.update({'rendered_text':
389 389 render('changeset/changeset_comment_block.html')})
390 390
391 391 return data
392 392
393 393 @LoginRequired()
394 394 @NotAnonymous()
395 395 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
396 396 'repository.admin')
397 397 @auth.CSRFRequired()
398 398 def preview_comment(self):
399 399 # Technically a CSRF token is not needed as no state changes with this
400 400 # call. However, as this is a POST is better to have it, so automated
401 401 # tools don't flag it as potential CSRF.
402 402 # Post is required because the payload could be bigger than the maximum
403 403 # allowed by GET.
404 404 if not request.environ.get('HTTP_X_PARTIAL_XHR'):
405 405 raise HTTPBadRequest()
406 406 text = request.POST.get('text')
407 407 renderer = request.POST.get('renderer') or 'rst'
408 408 if text:
409 409 return h.render(text, renderer=renderer, mentions=True)
410 410 return ''
411 411
412 412 @LoginRequired()
413 413 @NotAnonymous()
414 414 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
415 415 'repository.admin')
416 416 @auth.CSRFRequired()
417 417 @jsonify
418 418 def delete_comment(self, repo_name, comment_id):
419 419 comment = ChangesetComment.get(comment_id)
420 420 owner = (comment.author.user_id == c.rhodecode_user.user_id)
421 421 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(c.repo_name)
422 422 if h.HasPermissionAny('hg.admin')() or is_repo_admin or owner:
423 423 ChangesetCommentsModel().delete(comment=comment)
424 424 Session().commit()
425 425 return True
426 426 else:
427 427 raise HTTPForbidden()
428 428
429 429 @LoginRequired()
430 430 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
431 431 'repository.admin')
432 432 @jsonify
433 433 def changeset_info(self, repo_name, revision):
434 434 if request.is_xhr:
435 435 try:
436 436 return c.rhodecode_repo.get_commit(commit_id=revision)
437 437 except CommitDoesNotExistError as e:
438 438 return EmptyCommit(message=str(e))
439 439 else:
440 440 raise HTTPBadRequest()
441 441
442 442 @LoginRequired()
443 443 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
444 444 'repository.admin')
445 445 @jsonify
446 446 def changeset_children(self, repo_name, revision):
447 447 if request.is_xhr:
448 448 commit = c.rhodecode_repo.get_commit(commit_id=revision)
449 449 result = {"results": commit.children}
450 450 return result
451 451 else:
452 452 raise HTTPBadRequest()
453 453
454 454 @LoginRequired()
455 455 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
456 456 'repository.admin')
457 457 @jsonify
458 458 def changeset_parents(self, repo_name, revision):
459 459 if request.is_xhr:
460 460 commit = c.rhodecode_repo.get_commit(commit_id=revision)
461 461 result = {"results": commit.parents}
462 462 return result
463 463 else:
464 464 raise HTTPBadRequest()
@@ -1,899 +1,900 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2016 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 pull requests controller for rhodecode for initializing pull requests
23 23 """
24 24
25 25 import peppercorn
26 26 import formencode
27 27 import logging
28 28
29 29 from webob.exc import HTTPNotFound, HTTPForbidden, HTTPBadRequest
30 30 from pylons import request, tmpl_context as c, url
31 31 from pylons.controllers.util import redirect
32 32 from pylons.i18n.translation import _
33 33 from pyramid.threadlocal import get_current_registry
34 34 from sqlalchemy.sql import func
35 35 from sqlalchemy.sql.expression import or_
36 36
37 37 from rhodecode import events
38 38 from rhodecode.lib import auth, diffs, helpers as h, codeblocks
39 39 from rhodecode.lib.ext_json import json
40 40 from rhodecode.lib.base import (
41 41 BaseRepoController, render, vcs_operation_context)
42 42 from rhodecode.lib.auth import (
43 43 LoginRequired, HasRepoPermissionAnyDecorator, NotAnonymous,
44 44 HasAcceptedRepoType, XHRRequired)
45 45 from rhodecode.lib.channelstream import channelstream_request
46 46 from rhodecode.lib.compat import OrderedDict
47 47 from rhodecode.lib.utils import jsonify
48 48 from rhodecode.lib.utils2 import safe_int, safe_str, str2bool, safe_unicode
49 49 from rhodecode.lib.vcs.backends.base import EmptyCommit, UpdateFailureReason
50 50 from rhodecode.lib.vcs.exceptions import (
51 51 EmptyRepositoryError, CommitDoesNotExistError, RepositoryRequirementError,
52 52 NodeDoesNotExistError)
53 53 from rhodecode.lib.diffs import LimitedDiffContainer
54 54 from rhodecode.model.changeset_status import ChangesetStatusModel
55 55 from rhodecode.model.comment import ChangesetCommentsModel
56 56 from rhodecode.model.db import PullRequest, ChangesetStatus, ChangesetComment, \
57 57 Repository
58 58 from rhodecode.model.forms import PullRequestForm
59 59 from rhodecode.model.meta import Session
60 60 from rhodecode.model.pull_request import PullRequestModel
61 61
62 62 log = logging.getLogger(__name__)
63 63
64 64
65 65 class PullrequestsController(BaseRepoController):
66 66 def __before__(self):
67 67 super(PullrequestsController, self).__before__()
68 68
69 69 def _load_compare_data(self, pull_request, inline_comments, enable_comments=True):
70 70 """
71 71 Load context data needed for generating compare diff
72 72
73 73 :param pull_request: object related to the request
74 74 :param enable_comments: flag to determine if comments are included
75 75 """
76 76 source_repo = pull_request.source_repo
77 77 source_ref_id = pull_request.source_ref_parts.commit_id
78 78
79 79 target_repo = pull_request.target_repo
80 80 target_ref_id = pull_request.target_ref_parts.commit_id
81 81
82 82 # despite opening commits for bookmarks/branches/tags, we always
83 83 # convert this to rev to prevent changes after bookmark or branch change
84 84 c.source_ref_type = 'rev'
85 85 c.source_ref = source_ref_id
86 86
87 87 c.target_ref_type = 'rev'
88 88 c.target_ref = target_ref_id
89 89
90 90 c.source_repo = source_repo
91 91 c.target_repo = target_repo
92 92
93 93 c.fulldiff = bool(request.GET.get('fulldiff'))
94 94
95 95 # diff_limit is the old behavior, will cut off the whole diff
96 96 # if the limit is applied otherwise will just hide the
97 97 # big files from the front-end
98 98 diff_limit = self.cut_off_limit_diff
99 99 file_limit = self.cut_off_limit_file
100 100
101 101 pre_load = ["author", "branch", "date", "message"]
102 102
103 103 c.commit_ranges = []
104 104 source_commit = EmptyCommit()
105 105 target_commit = EmptyCommit()
106 106 c.missing_requirements = False
107 107 try:
108 108 c.commit_ranges = [
109 109 source_repo.get_commit(commit_id=rev, pre_load=pre_load)
110 110 for rev in pull_request.revisions]
111 111
112 112 c.statuses = source_repo.statuses(
113 113 [x.raw_id for x in c.commit_ranges])
114 114
115 115 target_commit = source_repo.get_commit(
116 116 commit_id=safe_str(target_ref_id))
117 117 source_commit = source_repo.get_commit(
118 118 commit_id=safe_str(source_ref_id))
119 119 except RepositoryRequirementError:
120 120 c.missing_requirements = True
121 121
122 122 c.changes = {}
123 123 c.missing_commits = False
124 124 if (c.missing_requirements or
125 125 isinstance(source_commit, EmptyCommit) or
126 126 source_commit == target_commit):
127 127 _parsed = []
128 128 c.missing_commits = True
129 129 else:
130 130 vcs_diff = PullRequestModel().get_diff(pull_request)
131 131 diff_processor = diffs.DiffProcessor(
132 132 vcs_diff, format='newdiff', diff_limit=diff_limit,
133 133 file_limit=file_limit, show_full_diff=c.fulldiff)
134 134 _parsed = diff_processor.prepare()
135 135
136 136 commit_changes = OrderedDict()
137 137 _parsed = diff_processor.prepare()
138 138 c.limited_diff = isinstance(_parsed, diffs.LimitedDiffContainer)
139 139
140 140 _parsed = diff_processor.prepare()
141 141
142 142 def _node_getter(commit):
143 143 def get_node(fname):
144 144 try:
145 145 return commit.get_node(fname)
146 146 except NodeDoesNotExistError:
147 147 return None
148 148 return get_node
149 149
150 150 c.diffset = codeblocks.DiffSet(
151 151 repo_name=c.repo_name,
152 152 source_node_getter=_node_getter(target_commit),
153 153 target_node_getter=_node_getter(source_commit),
154 154 comments=inline_comments
155 155 ).render_patchset(_parsed, target_commit.raw_id, source_commit.raw_id)
156 156
157 157 c.included_files = []
158 158 c.deleted_files = []
159 159
160 160 for f in _parsed:
161 161 st = f['stats']
162 162 fid = h.FID('', f['filename'])
163 163 c.included_files.append(f['filename'])
164 164
165 165 def _extract_ordering(self, request):
166 166 column_index = safe_int(request.GET.get('order[0][column]'))
167 167 order_dir = request.GET.get('order[0][dir]', 'desc')
168 168 order_by = request.GET.get(
169 169 'columns[%s][data][sort]' % column_index, 'name_raw')
170 170 return order_by, order_dir
171 171
172 172 @LoginRequired()
173 173 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
174 174 'repository.admin')
175 175 @HasAcceptedRepoType('git', 'hg')
176 176 def show_all(self, repo_name):
177 177 # filter types
178 178 c.active = 'open'
179 179 c.source = str2bool(request.GET.get('source'))
180 180 c.closed = str2bool(request.GET.get('closed'))
181 181 c.my = str2bool(request.GET.get('my'))
182 182 c.awaiting_review = str2bool(request.GET.get('awaiting_review'))
183 183 c.awaiting_my_review = str2bool(request.GET.get('awaiting_my_review'))
184 184 c.repo_name = repo_name
185 185
186 186 opened_by = None
187 187 if c.my:
188 188 c.active = 'my'
189 189 opened_by = [c.rhodecode_user.user_id]
190 190
191 191 statuses = [PullRequest.STATUS_NEW, PullRequest.STATUS_OPEN]
192 192 if c.closed:
193 193 c.active = 'closed'
194 194 statuses = [PullRequest.STATUS_CLOSED]
195 195
196 196 if c.awaiting_review and not c.source:
197 197 c.active = 'awaiting'
198 198 if c.source and not c.awaiting_review:
199 199 c.active = 'source'
200 200 if c.awaiting_my_review:
201 201 c.active = 'awaiting_my'
202 202
203 203 data = self._get_pull_requests_list(
204 204 repo_name=repo_name, opened_by=opened_by, statuses=statuses)
205 205 if not request.is_xhr:
206 206 c.data = json.dumps(data['data'])
207 207 c.records_total = data['recordsTotal']
208 208 return render('/pullrequests/pullrequests.html')
209 209 else:
210 210 return json.dumps(data)
211 211
212 212 def _get_pull_requests_list(self, repo_name, opened_by, statuses):
213 213 # pagination
214 214 start = safe_int(request.GET.get('start'), 0)
215 215 length = safe_int(request.GET.get('length'), c.visual.dashboard_items)
216 216 order_by, order_dir = self._extract_ordering(request)
217 217
218 218 if c.awaiting_review:
219 219 pull_requests = PullRequestModel().get_awaiting_review(
220 220 repo_name, source=c.source, opened_by=opened_by,
221 221 statuses=statuses, offset=start, length=length,
222 222 order_by=order_by, order_dir=order_dir)
223 223 pull_requests_total_count = PullRequestModel(
224 224 ).count_awaiting_review(
225 225 repo_name, source=c.source, statuses=statuses,
226 226 opened_by=opened_by)
227 227 elif c.awaiting_my_review:
228 228 pull_requests = PullRequestModel().get_awaiting_my_review(
229 229 repo_name, source=c.source, opened_by=opened_by,
230 230 user_id=c.rhodecode_user.user_id, statuses=statuses,
231 231 offset=start, length=length, order_by=order_by,
232 232 order_dir=order_dir)
233 233 pull_requests_total_count = PullRequestModel(
234 234 ).count_awaiting_my_review(
235 235 repo_name, source=c.source, user_id=c.rhodecode_user.user_id,
236 236 statuses=statuses, opened_by=opened_by)
237 237 else:
238 238 pull_requests = PullRequestModel().get_all(
239 239 repo_name, source=c.source, opened_by=opened_by,
240 240 statuses=statuses, offset=start, length=length,
241 241 order_by=order_by, order_dir=order_dir)
242 242 pull_requests_total_count = PullRequestModel().count_all(
243 243 repo_name, source=c.source, statuses=statuses,
244 244 opened_by=opened_by)
245 245
246 246 from rhodecode.lib.utils import PartialRenderer
247 247 _render = PartialRenderer('data_table/_dt_elements.html')
248 248 data = []
249 249 for pr in pull_requests:
250 250 comments = ChangesetCommentsModel().get_all_comments(
251 251 c.rhodecode_db_repo.repo_id, pull_request=pr)
252 252
253 253 data.append({
254 254 'name': _render('pullrequest_name',
255 255 pr.pull_request_id, pr.target_repo.repo_name),
256 256 'name_raw': pr.pull_request_id,
257 257 'status': _render('pullrequest_status',
258 258 pr.calculated_review_status()),
259 259 'title': _render(
260 260 'pullrequest_title', pr.title, pr.description),
261 261 'description': h.escape(pr.description),
262 262 'updated_on': _render('pullrequest_updated_on',
263 263 h.datetime_to_time(pr.updated_on)),
264 264 'updated_on_raw': h.datetime_to_time(pr.updated_on),
265 265 'created_on': _render('pullrequest_updated_on',
266 266 h.datetime_to_time(pr.created_on)),
267 267 'created_on_raw': h.datetime_to_time(pr.created_on),
268 268 'author': _render('pullrequest_author',
269 269 pr.author.full_contact, ),
270 270 'author_raw': pr.author.full_name,
271 271 'comments': _render('pullrequest_comments', len(comments)),
272 272 'comments_raw': len(comments),
273 273 'closed': pr.is_closed(),
274 274 })
275 275 # json used to render the grid
276 276 data = ({
277 277 'data': data,
278 278 'recordsTotal': pull_requests_total_count,
279 279 'recordsFiltered': pull_requests_total_count,
280 280 })
281 281 return data
282 282
283 283 @LoginRequired()
284 284 @NotAnonymous()
285 285 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
286 286 'repository.admin')
287 287 @HasAcceptedRepoType('git', 'hg')
288 288 def index(self):
289 289 source_repo = c.rhodecode_db_repo
290 290
291 291 try:
292 292 source_repo.scm_instance().get_commit()
293 293 except EmptyRepositoryError:
294 294 h.flash(h.literal(_('There are no commits yet')),
295 295 category='warning')
296 296 redirect(url('summary_home', repo_name=source_repo.repo_name))
297 297
298 298 commit_id = request.GET.get('commit')
299 299 branch_ref = request.GET.get('branch')
300 300 bookmark_ref = request.GET.get('bookmark')
301 301
302 302 try:
303 303 source_repo_data = PullRequestModel().generate_repo_data(
304 304 source_repo, commit_id=commit_id,
305 305 branch=branch_ref, bookmark=bookmark_ref)
306 306 except CommitDoesNotExistError as e:
307 307 log.exception(e)
308 308 h.flash(_('Commit does not exist'), 'error')
309 309 redirect(url('pullrequest_home', repo_name=source_repo.repo_name))
310 310
311 311 default_target_repo = source_repo
312 312
313 313 if source_repo.parent:
314 314 parent_vcs_obj = source_repo.parent.scm_instance()
315 315 if parent_vcs_obj and not parent_vcs_obj.is_empty():
316 316 # change default if we have a parent repo
317 317 default_target_repo = source_repo.parent
318 318
319 319 target_repo_data = PullRequestModel().generate_repo_data(
320 320 default_target_repo)
321 321
322 322 selected_source_ref = source_repo_data['refs']['selected_ref']
323 323
324 324 title_source_ref = selected_source_ref.split(':', 2)[1]
325 325 c.default_title = PullRequestModel().generate_pullrequest_title(
326 326 source=source_repo.repo_name,
327 327 source_ref=title_source_ref,
328 328 target=default_target_repo.repo_name
329 329 )
330 330
331 331 c.default_repo_data = {
332 332 'source_repo_name': source_repo.repo_name,
333 333 'source_refs_json': json.dumps(source_repo_data),
334 334 'target_repo_name': default_target_repo.repo_name,
335 335 'target_refs_json': json.dumps(target_repo_data),
336 336 }
337 337 c.default_source_ref = selected_source_ref
338 338
339 339 return render('/pullrequests/pullrequest.html')
340 340
341 341 @LoginRequired()
342 342 @NotAnonymous()
343 343 @XHRRequired()
344 344 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
345 345 'repository.admin')
346 346 @jsonify
347 347 def get_repo_refs(self, repo_name, target_repo_name):
348 348 repo = Repository.get_by_repo_name(target_repo_name)
349 349 if not repo:
350 350 raise HTTPNotFound
351 351 return PullRequestModel().generate_repo_data(repo)
352 352
353 353 @LoginRequired()
354 354 @NotAnonymous()
355 355 @XHRRequired()
356 356 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
357 357 'repository.admin')
358 358 @jsonify
359 359 def get_repo_destinations(self, repo_name):
360 360 repo = Repository.get_by_repo_name(repo_name)
361 361 if not repo:
362 362 raise HTTPNotFound
363 363 filter_query = request.GET.get('query')
364 364
365 365 query = Repository.query() \
366 366 .order_by(func.length(Repository.repo_name)) \
367 367 .filter(or_(
368 368 Repository.repo_name == repo.repo_name,
369 369 Repository.fork_id == repo.repo_id))
370 370
371 371 if filter_query:
372 372 ilike_expression = u'%{}%'.format(safe_unicode(filter_query))
373 373 query = query.filter(
374 374 Repository.repo_name.ilike(ilike_expression))
375 375
376 376 add_parent = False
377 377 if repo.parent:
378 378 if filter_query in repo.parent.repo_name:
379 379 parent_vcs_obj = repo.parent.scm_instance()
380 380 if parent_vcs_obj and not parent_vcs_obj.is_empty():
381 381 add_parent = True
382 382
383 383 limit = 20 - 1 if add_parent else 20
384 384 all_repos = query.limit(limit).all()
385 385 if add_parent:
386 386 all_repos += [repo.parent]
387 387
388 388 repos = []
389 389 for obj in self.scm_model.get_repos(all_repos):
390 390 repos.append({
391 391 'id': obj['name'],
392 392 'text': obj['name'],
393 393 'type': 'repo',
394 394 'obj': obj['dbrepo']
395 395 })
396 396
397 397 data = {
398 398 'more': False,
399 399 'results': [{
400 400 'text': _('Repositories'),
401 401 'children': repos
402 402 }] if repos else []
403 403 }
404 404 return data
405 405
406 406 @LoginRequired()
407 407 @NotAnonymous()
408 408 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
409 409 'repository.admin')
410 410 @HasAcceptedRepoType('git', 'hg')
411 411 @auth.CSRFRequired()
412 412 def create(self, repo_name):
413 413 repo = Repository.get_by_repo_name(repo_name)
414 414 if not repo:
415 415 raise HTTPNotFound
416 416
417 417 controls = peppercorn.parse(request.POST.items())
418 418
419 419 try:
420 420 _form = PullRequestForm(repo.repo_id)().to_python(controls)
421 421 except formencode.Invalid as errors:
422 422 if errors.error_dict.get('revisions'):
423 423 msg = 'Revisions: %s' % errors.error_dict['revisions']
424 424 elif errors.error_dict.get('pullrequest_title'):
425 425 msg = _('Pull request requires a title with min. 3 chars')
426 426 else:
427 427 msg = _('Error creating pull request: {}').format(errors)
428 428 log.exception(msg)
429 429 h.flash(msg, 'error')
430 430
431 431 # would rather just go back to form ...
432 432 return redirect(url('pullrequest_home', repo_name=repo_name))
433 433
434 434 source_repo = _form['source_repo']
435 435 source_ref = _form['source_ref']
436 436 target_repo = _form['target_repo']
437 437 target_ref = _form['target_ref']
438 438 commit_ids = _form['revisions'][::-1]
439 439 reviewers = [
440 440 (r['user_id'], r['reasons']) for r in _form['review_members']]
441 441
442 442 # find the ancestor for this pr
443 443 source_db_repo = Repository.get_by_repo_name(_form['source_repo'])
444 444 target_db_repo = Repository.get_by_repo_name(_form['target_repo'])
445 445
446 446 source_scm = source_db_repo.scm_instance()
447 447 target_scm = target_db_repo.scm_instance()
448 448
449 449 source_commit = source_scm.get_commit(source_ref.split(':')[-1])
450 450 target_commit = target_scm.get_commit(target_ref.split(':')[-1])
451 451
452 452 ancestor = source_scm.get_common_ancestor(
453 453 source_commit.raw_id, target_commit.raw_id, target_scm)
454 454
455 455 target_ref_type, target_ref_name, __ = _form['target_ref'].split(':')
456 456 target_ref = ':'.join((target_ref_type, target_ref_name, ancestor))
457 457
458 458 pullrequest_title = _form['pullrequest_title']
459 459 title_source_ref = source_ref.split(':', 2)[1]
460 460 if not pullrequest_title:
461 461 pullrequest_title = PullRequestModel().generate_pullrequest_title(
462 462 source=source_repo,
463 463 source_ref=title_source_ref,
464 464 target=target_repo
465 465 )
466 466
467 467 description = _form['pullrequest_desc']
468 468 try:
469 469 pull_request = PullRequestModel().create(
470 470 c.rhodecode_user.user_id, source_repo, source_ref, target_repo,
471 471 target_ref, commit_ids, reviewers, pullrequest_title,
472 472 description
473 473 )
474 474 Session().commit()
475 475 h.flash(_('Successfully opened new pull request'),
476 476 category='success')
477 477 except Exception as e:
478 478 msg = _('Error occurred during sending pull request')
479 479 log.exception(msg)
480 480 h.flash(msg, category='error')
481 481 return redirect(url('pullrequest_home', repo_name=repo_name))
482 482
483 483 return redirect(url('pullrequest_show', repo_name=target_repo,
484 484 pull_request_id=pull_request.pull_request_id))
485 485
486 486 @LoginRequired()
487 487 @NotAnonymous()
488 488 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
489 489 'repository.admin')
490 490 @auth.CSRFRequired()
491 491 @jsonify
492 492 def update(self, repo_name, pull_request_id):
493 493 pull_request_id = safe_int(pull_request_id)
494 494 pull_request = PullRequest.get_or_404(pull_request_id)
495 495 # only owner or admin can update it
496 496 allowed_to_update = PullRequestModel().check_user_update(
497 497 pull_request, c.rhodecode_user)
498 498 if allowed_to_update:
499 499 controls = peppercorn.parse(request.POST.items())
500 500
501 501 if 'review_members' in controls:
502 502 self._update_reviewers(
503 503 pull_request_id, controls['review_members'])
504 504 elif str2bool(request.POST.get('update_commits', 'false')):
505 505 self._update_commits(pull_request)
506 506 elif str2bool(request.POST.get('close_pull_request', 'false')):
507 507 self._reject_close(pull_request)
508 508 elif str2bool(request.POST.get('edit_pull_request', 'false')):
509 509 self._edit_pull_request(pull_request)
510 510 else:
511 511 raise HTTPBadRequest()
512 512 return True
513 513 raise HTTPForbidden()
514 514
515 515 def _edit_pull_request(self, pull_request):
516 516 try:
517 517 PullRequestModel().edit(
518 518 pull_request, request.POST.get('title'),
519 519 request.POST.get('description'))
520 520 except ValueError:
521 521 msg = _(u'Cannot update closed pull requests.')
522 522 h.flash(msg, category='error')
523 523 return
524 524 else:
525 525 Session().commit()
526 526
527 527 msg = _(u'Pull request title & description updated.')
528 528 h.flash(msg, category='success')
529 529 return
530 530
531 531 def _update_commits(self, pull_request):
532 532 resp = PullRequestModel().update_commits(pull_request)
533 533
534 534 if resp.executed:
535 535 msg = _(
536 536 u'Pull request updated to "{source_commit_id}" with '
537 537 u'{count_added} added, {count_removed} removed commits.')
538 538 msg = msg.format(
539 539 source_commit_id=pull_request.source_ref_parts.commit_id,
540 540 count_added=len(resp.changes.added),
541 541 count_removed=len(resp.changes.removed))
542 542 h.flash(msg, category='success')
543 543
544 544 registry = get_current_registry()
545 545 rhodecode_plugins = getattr(registry, 'rhodecode_plugins', {})
546 546 channelstream_config = rhodecode_plugins.get('channelstream', {})
547 547 if channelstream_config.get('enabled'):
548 548 message = msg + (
549 549 ' - <a onclick="window.location.reload()">'
550 550 '<strong>{}</strong></a>'.format(_('Reload page')))
551 551 channel = '/repo${}$/pr/{}'.format(
552 552 pull_request.target_repo.repo_name,
553 553 pull_request.pull_request_id
554 554 )
555 555 payload = {
556 556 'type': 'message',
557 557 'user': 'system',
558 558 'exclude_users': [request.user.username],
559 559 'channel': channel,
560 560 'message': {
561 561 'message': message,
562 562 'level': 'success',
563 563 'topic': '/notifications'
564 564 }
565 565 }
566 566 channelstream_request(
567 567 channelstream_config, [payload], '/message',
568 568 raise_exc=False)
569 569 else:
570 570 msg = PullRequestModel.UPDATE_STATUS_MESSAGES[resp.reason]
571 571 warning_reasons = [
572 572 UpdateFailureReason.NO_CHANGE,
573 573 UpdateFailureReason.WRONG_REF_TPYE,
574 574 ]
575 575 category = 'warning' if resp.reason in warning_reasons else 'error'
576 576 h.flash(msg, category=category)
577 577
578 578 @auth.CSRFRequired()
579 579 @LoginRequired()
580 580 @NotAnonymous()
581 581 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
582 582 'repository.admin')
583 583 def merge(self, repo_name, pull_request_id):
584 584 """
585 585 POST /{repo_name}/pull-request/{pull_request_id}
586 586
587 587 Merge will perform a server-side merge of the specified
588 588 pull request, if the pull request is approved and mergeable.
589 589 After succesfull merging, the pull request is automatically
590 590 closed, with a relevant comment.
591 591 """
592 592 pull_request_id = safe_int(pull_request_id)
593 593 pull_request = PullRequest.get_or_404(pull_request_id)
594 594 user = c.rhodecode_user
595 595
596 596 if self._meets_merge_pre_conditions(pull_request, user):
597 597 log.debug("Pre-conditions checked, trying to merge.")
598 598 extras = vcs_operation_context(
599 599 request.environ, repo_name=pull_request.target_repo.repo_name,
600 600 username=user.username, action='push',
601 601 scm=pull_request.target_repo.repo_type)
602 602 self._merge_pull_request(pull_request, user, extras)
603 603
604 604 return redirect(url(
605 605 'pullrequest_show',
606 606 repo_name=pull_request.target_repo.repo_name,
607 607 pull_request_id=pull_request.pull_request_id))
608 608
609 609 def _meets_merge_pre_conditions(self, pull_request, user):
610 610 if not PullRequestModel().check_user_merge(pull_request, user):
611 611 raise HTTPForbidden()
612 612
613 613 merge_status, msg = PullRequestModel().merge_status(pull_request)
614 614 if not merge_status:
615 615 log.debug("Cannot merge, not mergeable.")
616 616 h.flash(msg, category='error')
617 617 return False
618 618
619 619 if (pull_request.calculated_review_status()
620 620 is not ChangesetStatus.STATUS_APPROVED):
621 621 log.debug("Cannot merge, approval is pending.")
622 622 msg = _('Pull request reviewer approval is pending.')
623 623 h.flash(msg, category='error')
624 624 return False
625 625 return True
626 626
627 627 def _merge_pull_request(self, pull_request, user, extras):
628 628 merge_resp = PullRequestModel().merge(
629 629 pull_request, user, extras=extras)
630 630
631 631 if merge_resp.executed:
632 632 log.debug("The merge was successful, closing the pull request.")
633 633 PullRequestModel().close_pull_request(
634 634 pull_request.pull_request_id, user)
635 635 Session().commit()
636 636 msg = _('Pull request was successfully merged and closed.')
637 637 h.flash(msg, category='success')
638 638 else:
639 639 log.debug(
640 640 "The merge was not successful. Merge response: %s",
641 641 merge_resp)
642 642 msg = PullRequestModel().merge_status_message(
643 643 merge_resp.failure_reason)
644 644 h.flash(msg, category='error')
645 645
646 646 def _update_reviewers(self, pull_request_id, review_members):
647 647 reviewers = [
648 648 (int(r['user_id']), r['reasons']) for r in review_members]
649 649 PullRequestModel().update_reviewers(pull_request_id, reviewers)
650 650 Session().commit()
651 651
652 652 def _reject_close(self, pull_request):
653 653 if pull_request.is_closed():
654 654 raise HTTPForbidden()
655 655
656 656 PullRequestModel().close_pull_request_with_comment(
657 657 pull_request, c.rhodecode_user, c.rhodecode_db_repo)
658 658 Session().commit()
659 659
660 660 @LoginRequired()
661 661 @NotAnonymous()
662 662 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
663 663 'repository.admin')
664 664 @auth.CSRFRequired()
665 665 @jsonify
666 666 def delete(self, repo_name, pull_request_id):
667 667 pull_request_id = safe_int(pull_request_id)
668 668 pull_request = PullRequest.get_or_404(pull_request_id)
669 669 # only owner can delete it !
670 670 if pull_request.author.user_id == c.rhodecode_user.user_id:
671 671 PullRequestModel().delete(pull_request)
672 672 Session().commit()
673 673 h.flash(_('Successfully deleted pull request'),
674 674 category='success')
675 675 return redirect(url('my_account_pullrequests'))
676 676 raise HTTPForbidden()
677 677
678 678 @LoginRequired()
679 679 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
680 680 'repository.admin')
681 681 def show(self, repo_name, pull_request_id):
682 682 pull_request_id = safe_int(pull_request_id)
683 683 c.pull_request = PullRequest.get_or_404(pull_request_id)
684 684
685 685 c.template_context['pull_request_data']['pull_request_id'] = \
686 686 pull_request_id
687 687
688 688 # pull_requests repo_name we opened it against
689 689 # ie. target_repo must match
690 690 if repo_name != c.pull_request.target_repo.repo_name:
691 691 raise HTTPNotFound
692 692
693 693 c.allowed_to_change_status = PullRequestModel(). \
694 694 check_user_change_status(c.pull_request, c.rhodecode_user)
695 695 c.allowed_to_update = PullRequestModel().check_user_update(
696 696 c.pull_request, c.rhodecode_user) and not c.pull_request.is_closed()
697 697 c.allowed_to_merge = PullRequestModel().check_user_merge(
698 698 c.pull_request, c.rhodecode_user) and not c.pull_request.is_closed()
699 699 c.shadow_clone_url = PullRequestModel().get_shadow_clone_url(
700 700 c.pull_request)
701 701 c.allowed_to_delete = PullRequestModel().check_user_delete(
702 702 c.pull_request, c.rhodecode_user) and not c.pull_request.is_closed()
703 703
704 704 cc_model = ChangesetCommentsModel()
705 705
706 706 c.pull_request_reviewers = c.pull_request.reviewers_statuses()
707 707
708 708 c.pull_request_review_status = c.pull_request.calculated_review_status()
709 709 c.pr_merge_status, c.pr_merge_msg = PullRequestModel().merge_status(
710 710 c.pull_request)
711 711 c.approval_msg = None
712 712 if c.pull_request_review_status != ChangesetStatus.STATUS_APPROVED:
713 713 c.approval_msg = _('Reviewer approval is pending.')
714 714 c.pr_merge_status = False
715 715 # load compare data into template context
716 716 enable_comments = not c.pull_request.is_closed()
717 717
718 718
719 719 # inline comments
720 720 c.inline_comments = cc_model.get_inline_comments(
721 721 c.rhodecode_db_repo.repo_id,
722 722 pull_request=pull_request_id)
723 c.inline_cnt = len(c.inline_comments)
723
724 c.inline_cnt = cc_model.get_inline_comments_count(c.inline_comments)
724 725
725 726 self._load_compare_data(
726 727 c.pull_request, c.inline_comments, enable_comments=enable_comments)
727 728
728 729 # outdated comments
729 730 c.outdated_comments = {}
730 731 c.outdated_cnt = 0
731 732 if ChangesetCommentsModel.use_outdated_comments(c.pull_request):
732 733 c.outdated_comments = cc_model.get_outdated_comments(
733 734 c.rhodecode_db_repo.repo_id,
734 735 pull_request=c.pull_request)
735 736 # Count outdated comments and check for deleted files
736 737 for file_name, lines in c.outdated_comments.iteritems():
737 738 for comments in lines.values():
738 739 c.outdated_cnt += len(comments)
739 740 if file_name not in c.included_files:
740 741 c.deleted_files.append(file_name)
741 742
742 743
743 744 # this is a hack to properly display links, when creating PR, the
744 745 # compare view and others uses different notation, and
745 746 # compare_commits.html renders links based on the target_repo.
746 747 # We need to swap that here to generate it properly on the html side
747 748 c.target_repo = c.source_repo
748 749
749 750 # comments
750 751 c.comments = cc_model.get_comments(c.rhodecode_db_repo.repo_id,
751 752 pull_request=pull_request_id)
752 753
753 754 if c.allowed_to_update:
754 755 force_close = ('forced_closed', _('Close Pull Request'))
755 756 statuses = ChangesetStatus.STATUSES + [force_close]
756 757 else:
757 758 statuses = ChangesetStatus.STATUSES
758 759 c.commit_statuses = statuses
759 760
760 761 c.ancestor = None # TODO: add ancestor here
761 762
762 763 return render('/pullrequests/pullrequest_show.html')
763 764
764 765 @LoginRequired()
765 766 @NotAnonymous()
766 767 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
767 768 'repository.admin')
768 769 @auth.CSRFRequired()
769 770 @jsonify
770 771 def comment(self, repo_name, pull_request_id):
771 772 pull_request_id = safe_int(pull_request_id)
772 773 pull_request = PullRequest.get_or_404(pull_request_id)
773 774 if pull_request.is_closed():
774 775 raise HTTPForbidden()
775 776
776 777 # TODO: johbo: Re-think this bit, "approved_closed" does not exist
777 778 # as a changeset status, still we want to send it in one value.
778 779 status = request.POST.get('changeset_status', None)
779 780 text = request.POST.get('text')
780 781 if status and '_closed' in status:
781 782 close_pr = True
782 783 status = status.replace('_closed', '')
783 784 else:
784 785 close_pr = False
785 786
786 787 forced = (status == 'forced')
787 788 if forced:
788 789 status = 'rejected'
789 790
790 791 allowed_to_change_status = PullRequestModel().check_user_change_status(
791 792 pull_request, c.rhodecode_user)
792 793
793 794 if status and allowed_to_change_status:
794 795 message = (_('Status change %(transition_icon)s %(status)s')
795 796 % {'transition_icon': '>',
796 797 'status': ChangesetStatus.get_status_lbl(status)})
797 798 if close_pr:
798 799 message = _('Closing with') + ' ' + message
799 800 text = text or message
800 801 comm = ChangesetCommentsModel().create(
801 802 text=text,
802 803 repo=c.rhodecode_db_repo.repo_id,
803 804 user=c.rhodecode_user.user_id,
804 805 pull_request=pull_request_id,
805 806 f_path=request.POST.get('f_path'),
806 807 line_no=request.POST.get('line'),
807 808 status_change=(ChangesetStatus.get_status_lbl(status)
808 809 if status and allowed_to_change_status else None),
809 810 status_change_type=(status
810 811 if status and allowed_to_change_status else None),
811 812 closing_pr=close_pr
812 813 )
813 814
814 815
815 816
816 817 if allowed_to_change_status:
817 818 old_calculated_status = pull_request.calculated_review_status()
818 819 # get status if set !
819 820 if status:
820 821 ChangesetStatusModel().set_status(
821 822 c.rhodecode_db_repo.repo_id,
822 823 status,
823 824 c.rhodecode_user.user_id,
824 825 comm,
825 826 pull_request=pull_request_id
826 827 )
827 828
828 829 Session().flush()
829 830 events.trigger(events.PullRequestCommentEvent(pull_request, comm))
830 831 # we now calculate the status of pull request, and based on that
831 832 # calculation we set the commits status
832 833 calculated_status = pull_request.calculated_review_status()
833 834 if old_calculated_status != calculated_status:
834 835 PullRequestModel()._trigger_pull_request_hook(
835 836 pull_request, c.rhodecode_user, 'review_status_change')
836 837
837 838 calculated_status_lbl = ChangesetStatus.get_status_lbl(
838 839 calculated_status)
839 840
840 841 if close_pr:
841 842 status_completed = (
842 843 calculated_status in [ChangesetStatus.STATUS_APPROVED,
843 844 ChangesetStatus.STATUS_REJECTED])
844 845 if forced or status_completed:
845 846 PullRequestModel().close_pull_request(
846 847 pull_request_id, c.rhodecode_user)
847 848 else:
848 849 h.flash(_('Closing pull request on other statuses than '
849 850 'rejected or approved is forbidden. '
850 851 'Calculated status from all reviewers '
851 852 'is currently: %s') % calculated_status_lbl,
852 853 category='warning')
853 854
854 855 Session().commit()
855 856
856 857 if not request.is_xhr:
857 858 return redirect(h.url('pullrequest_show', repo_name=repo_name,
858 859 pull_request_id=pull_request_id))
859 860
860 861 data = {
861 862 'target_id': h.safeid(h.safe_unicode(request.POST.get('f_path'))),
862 863 }
863 864 if comm:
864 865 c.co = comm
865 866 data.update(comm.get_dict())
866 867 data.update({'rendered_text':
867 868 render('changeset/changeset_comment_block.html')})
868 869
869 870 return data
870 871
871 872 @LoginRequired()
872 873 @NotAnonymous()
873 874 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
874 875 'repository.admin')
875 876 @auth.CSRFRequired()
876 877 @jsonify
877 878 def delete_comment(self, repo_name, comment_id):
878 879 return self._delete_comment(comment_id)
879 880
880 881 def _delete_comment(self, comment_id):
881 882 comment_id = safe_int(comment_id)
882 883 co = ChangesetComment.get_or_404(comment_id)
883 884 if co.pull_request.is_closed():
884 885 # don't allow deleting comments on closed pull request
885 886 raise HTTPForbidden()
886 887
887 888 is_owner = co.author.user_id == c.rhodecode_user.user_id
888 889 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(c.repo_name)
889 890 if h.HasPermissionAny('hg.admin')() or is_repo_admin or is_owner:
890 891 old_calculated_status = co.pull_request.calculated_review_status()
891 892 ChangesetCommentsModel().delete(comment=co)
892 893 Session().commit()
893 894 calculated_status = co.pull_request.calculated_review_status()
894 895 if old_calculated_status != calculated_status:
895 896 PullRequestModel()._trigger_pull_request_hook(
896 897 co.pull_request, c.rhodecode_user, 'review_status_change')
897 898 return True
898 899 else:
899 900 raise HTTPForbidden()
@@ -1,515 +1,525 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2016 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 comments model for RhodeCode
23 23 """
24 24
25 25 import logging
26 26 import traceback
27 27 import collections
28 28
29 29 from datetime import datetime
30 30
31 31 from pylons.i18n.translation import _
32 32 from pyramid.threadlocal import get_current_registry
33 33 from sqlalchemy.sql.expression import null
34 34 from sqlalchemy.sql.functions import coalesce
35 35
36 36 from rhodecode.lib import helpers as h, diffs
37 37 from rhodecode.lib.channelstream import channelstream_request
38 38 from rhodecode.lib.utils import action_logger
39 39 from rhodecode.lib.utils2 import extract_mentioned_users
40 40 from rhodecode.model import BaseModel
41 41 from rhodecode.model.db import (
42 42 ChangesetComment, User, Notification, PullRequest)
43 43 from rhodecode.model.notification import NotificationModel
44 44 from rhodecode.model.meta import Session
45 45 from rhodecode.model.settings import VcsSettingsModel
46 46 from rhodecode.model.notification import EmailNotificationModel
47 47
48 48 log = logging.getLogger(__name__)
49 49
50 50
51 51 class ChangesetCommentsModel(BaseModel):
52 52
53 53 cls = ChangesetComment
54 54
55 55 DIFF_CONTEXT_BEFORE = 3
56 56 DIFF_CONTEXT_AFTER = 3
57 57
58 58 def __get_commit_comment(self, changeset_comment):
59 59 return self._get_instance(ChangesetComment, changeset_comment)
60 60
61 61 def __get_pull_request(self, pull_request):
62 62 return self._get_instance(PullRequest, pull_request)
63 63
64 64 def _extract_mentions(self, s):
65 65 user_objects = []
66 66 for username in extract_mentioned_users(s):
67 67 user_obj = User.get_by_username(username, case_insensitive=True)
68 68 if user_obj:
69 69 user_objects.append(user_obj)
70 70 return user_objects
71 71
72 72 def _get_renderer(self, global_renderer='rst'):
73 73 try:
74 74 # try reading from visual context
75 75 from pylons import tmpl_context
76 76 global_renderer = tmpl_context.visual.default_renderer
77 77 except AttributeError:
78 78 log.debug("Renderer not set, falling back "
79 79 "to default renderer '%s'", global_renderer)
80 80 except Exception:
81 81 log.error(traceback.format_exc())
82 82 return global_renderer
83 83
84 84 def create(self, text, repo, user, revision=None, pull_request=None,
85 85 f_path=None, line_no=None, status_change=None,
86 86 status_change_type=None, closing_pr=False,
87 87 send_email=True, renderer=None):
88 88 """
89 89 Creates new comment for commit or pull request.
90 90 IF status_change is not none this comment is associated with a
91 91 status change of commit or commit associated with pull request
92 92
93 93 :param text:
94 94 :param repo:
95 95 :param user:
96 96 :param revision:
97 97 :param pull_request:
98 98 :param f_path:
99 99 :param line_no:
100 100 :param status_change: Label for status change
101 101 :param status_change_type: type of status change
102 102 :param closing_pr:
103 103 :param send_email:
104 104 """
105 105 if not text:
106 106 log.warning('Missing text for comment, skipping...')
107 107 return
108 108
109 109 if not renderer:
110 110 renderer = self._get_renderer()
111 111
112 112 repo = self._get_repo(repo)
113 113 user = self._get_user(user)
114 114 comment = ChangesetComment()
115 115 comment.renderer = renderer
116 116 comment.repo = repo
117 117 comment.author = user
118 118 comment.text = text
119 119 comment.f_path = f_path
120 120 comment.line_no = line_no
121 121
122 122 #TODO (marcink): fix this and remove revision as param
123 123 commit_id = revision
124 124 pull_request_id = pull_request
125 125
126 126 commit_obj = None
127 127 pull_request_obj = None
128 128
129 129 if commit_id:
130 130 notification_type = EmailNotificationModel.TYPE_COMMIT_COMMENT
131 131 # do a lookup, so we don't pass something bad here
132 132 commit_obj = repo.scm_instance().get_commit(commit_id=commit_id)
133 133 comment.revision = commit_obj.raw_id
134 134
135 135 elif pull_request_id:
136 136 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST_COMMENT
137 137 pull_request_obj = self.__get_pull_request(pull_request_id)
138 138 comment.pull_request = pull_request_obj
139 139 else:
140 140 raise Exception('Please specify commit or pull_request_id')
141 141
142 142 Session().add(comment)
143 143 Session().flush()
144 144 kwargs = {
145 145 'user': user,
146 146 'renderer_type': renderer,
147 147 'repo_name': repo.repo_name,
148 148 'status_change': status_change,
149 149 'status_change_type': status_change_type,
150 150 'comment_body': text,
151 151 'comment_file': f_path,
152 152 'comment_line': line_no,
153 153 }
154 154
155 155 if commit_obj:
156 156 recipients = ChangesetComment.get_users(
157 157 revision=commit_obj.raw_id)
158 158 # add commit author if it's in RhodeCode system
159 159 cs_author = User.get_from_cs_author(commit_obj.author)
160 160 if not cs_author:
161 161 # use repo owner if we cannot extract the author correctly
162 162 cs_author = repo.user
163 163 recipients += [cs_author]
164 164
165 165 commit_comment_url = self.get_url(comment)
166 166
167 167 target_repo_url = h.link_to(
168 168 repo.repo_name,
169 169 h.url('summary_home',
170 170 repo_name=repo.repo_name, qualified=True))
171 171
172 172 # commit specifics
173 173 kwargs.update({
174 174 'commit': commit_obj,
175 175 'commit_message': commit_obj.message,
176 176 'commit_target_repo': target_repo_url,
177 177 'commit_comment_url': commit_comment_url,
178 178 })
179 179
180 180 elif pull_request_obj:
181 181 # get the current participants of this pull request
182 182 recipients = ChangesetComment.get_users(
183 183 pull_request_id=pull_request_obj.pull_request_id)
184 184 # add pull request author
185 185 recipients += [pull_request_obj.author]
186 186
187 187 # add the reviewers to notification
188 188 recipients += [x.user for x in pull_request_obj.reviewers]
189 189
190 190 pr_target_repo = pull_request_obj.target_repo
191 191 pr_source_repo = pull_request_obj.source_repo
192 192
193 193 pr_comment_url = h.url(
194 194 'pullrequest_show',
195 195 repo_name=pr_target_repo.repo_name,
196 196 pull_request_id=pull_request_obj.pull_request_id,
197 197 anchor='comment-%s' % comment.comment_id,
198 198 qualified=True,)
199 199
200 200 # set some variables for email notification
201 201 pr_target_repo_url = h.url(
202 202 'summary_home', repo_name=pr_target_repo.repo_name,
203 203 qualified=True)
204 204
205 205 pr_source_repo_url = h.url(
206 206 'summary_home', repo_name=pr_source_repo.repo_name,
207 207 qualified=True)
208 208
209 209 # pull request specifics
210 210 kwargs.update({
211 211 'pull_request': pull_request_obj,
212 212 'pr_id': pull_request_obj.pull_request_id,
213 213 'pr_target_repo': pr_target_repo,
214 214 'pr_target_repo_url': pr_target_repo_url,
215 215 'pr_source_repo': pr_source_repo,
216 216 'pr_source_repo_url': pr_source_repo_url,
217 217 'pr_comment_url': pr_comment_url,
218 218 'pr_closing': closing_pr,
219 219 })
220 220 if send_email:
221 221 # pre-generate the subject for notification itself
222 222 (subject,
223 223 _h, _e, # we don't care about those
224 224 body_plaintext) = EmailNotificationModel().render_email(
225 225 notification_type, **kwargs)
226 226
227 227 mention_recipients = set(
228 228 self._extract_mentions(text)).difference(recipients)
229 229
230 230 # create notification objects, and emails
231 231 NotificationModel().create(
232 232 created_by=user,
233 233 notification_subject=subject,
234 234 notification_body=body_plaintext,
235 235 notification_type=notification_type,
236 236 recipients=recipients,
237 237 mention_recipients=mention_recipients,
238 238 email_kwargs=kwargs,
239 239 )
240 240
241 241 action = (
242 242 'user_commented_pull_request:{}'.format(
243 243 comment.pull_request.pull_request_id)
244 244 if comment.pull_request
245 245 else 'user_commented_revision:{}'.format(comment.revision)
246 246 )
247 247 action_logger(user, action, comment.repo)
248 248
249 249 registry = get_current_registry()
250 250 rhodecode_plugins = getattr(registry, 'rhodecode_plugins', {})
251 251 channelstream_config = rhodecode_plugins.get('channelstream', {})
252 252 msg_url = ''
253 253 if commit_obj:
254 254 msg_url = commit_comment_url
255 255 repo_name = repo.repo_name
256 256 elif pull_request_obj:
257 257 msg_url = pr_comment_url
258 258 repo_name = pr_target_repo.repo_name
259 259
260 260 if channelstream_config.get('enabled'):
261 261 message = '<strong>{}</strong> {} - ' \
262 262 '<a onclick="window.location=\'{}\';' \
263 263 'window.location.reload()">' \
264 264 '<strong>{}</strong></a>'
265 265 message = message.format(
266 266 user.username, _('made a comment'), msg_url,
267 267 _('Show it now'))
268 268 channel = '/repo${}$/pr/{}'.format(
269 269 repo_name,
270 270 pull_request_id
271 271 )
272 272 payload = {
273 273 'type': 'message',
274 274 'timestamp': datetime.utcnow(),
275 275 'user': 'system',
276 276 'exclude_users': [user.username],
277 277 'channel': channel,
278 278 'message': {
279 279 'message': message,
280 280 'level': 'info',
281 281 'topic': '/notifications'
282 282 }
283 283 }
284 284 channelstream_request(channelstream_config, [payload],
285 285 '/message', raise_exc=False)
286 286
287 287 return comment
288 288
289 289 def delete(self, comment):
290 290 """
291 291 Deletes given comment
292 292
293 293 :param comment_id:
294 294 """
295 295 comment = self.__get_commit_comment(comment)
296 296 Session().delete(comment)
297 297
298 298 return comment
299 299
300 300 def get_all_comments(self, repo_id, revision=None, pull_request=None):
301 301 q = ChangesetComment.query()\
302 302 .filter(ChangesetComment.repo_id == repo_id)
303 303 if revision:
304 304 q = q.filter(ChangesetComment.revision == revision)
305 305 elif pull_request:
306 306 pull_request = self.__get_pull_request(pull_request)
307 307 q = q.filter(ChangesetComment.pull_request == pull_request)
308 308 else:
309 309 raise Exception('Please specify commit or pull_request')
310 310 q = q.order_by(ChangesetComment.created_on)
311 311 return q.all()
312 312
313 313 def get_url(self, comment):
314 314 comment = self.__get_commit_comment(comment)
315 315 if comment.pull_request:
316 316 return h.url(
317 317 'pullrequest_show',
318 318 repo_name=comment.pull_request.target_repo.repo_name,
319 319 pull_request_id=comment.pull_request.pull_request_id,
320 320 anchor='comment-%s' % comment.comment_id,
321 321 qualified=True,)
322 322 else:
323 323 return h.url(
324 324 'changeset_home',
325 325 repo_name=comment.repo.repo_name,
326 326 revision=comment.revision,
327 327 anchor='comment-%s' % comment.comment_id,
328 328 qualified=True,)
329 329
330 330 def get_comments(self, repo_id, revision=None, pull_request=None):
331 331 """
332 332 Gets main comments based on revision or pull_request_id
333 333
334 334 :param repo_id:
335 335 :param revision:
336 336 :param pull_request:
337 337 """
338 338
339 339 q = ChangesetComment.query()\
340 340 .filter(ChangesetComment.repo_id == repo_id)\
341 341 .filter(ChangesetComment.line_no == None)\
342 342 .filter(ChangesetComment.f_path == None)
343 343 if revision:
344 344 q = q.filter(ChangesetComment.revision == revision)
345 345 elif pull_request:
346 346 pull_request = self.__get_pull_request(pull_request)
347 347 q = q.filter(ChangesetComment.pull_request == pull_request)
348 348 else:
349 349 raise Exception('Please specify commit or pull_request')
350 350 q = q.order_by(ChangesetComment.created_on)
351 351 return q.all()
352 352
353 353 def get_inline_comments(self, repo_id, revision=None, pull_request=None):
354 354 q = self._get_inline_comments_query(repo_id, revision, pull_request)
355 355 return self._group_comments_by_path_and_line_number(q)
356 356
357 def get_inline_comments_count(self, inline_comments, skip_outdated=True,
358 version=None):
359 inline_cnt = 0
360 for fname, per_line_comments in inline_comments.iteritems():
361 for lno, comments in per_line_comments.iteritems():
362 inline_cnt += len(
363 [comm for comm in comments
364 if (not comm.outdated and skip_outdated)])
365 return inline_cnt
366
357 367 def get_outdated_comments(self, repo_id, pull_request):
358 368 # TODO: johbo: Remove `repo_id`, it is not needed to find the comments
359 369 # of a pull request.
360 370 q = self._all_inline_comments_of_pull_request(pull_request)
361 371 q = q.filter(
362 372 ChangesetComment.display_state ==
363 373 ChangesetComment.COMMENT_OUTDATED
364 374 ).order_by(ChangesetComment.comment_id.asc())
365 375
366 376 return self._group_comments_by_path_and_line_number(q)
367 377
368 378 def _get_inline_comments_query(self, repo_id, revision, pull_request):
369 379 # TODO: johbo: Split this into two methods: One for PR and one for
370 380 # commit.
371 381 if revision:
372 382 q = Session().query(ChangesetComment).filter(
373 383 ChangesetComment.repo_id == repo_id,
374 384 ChangesetComment.line_no != null(),
375 385 ChangesetComment.f_path != null(),
376 386 ChangesetComment.revision == revision)
377 387
378 388 elif pull_request:
379 389 pull_request = self.__get_pull_request(pull_request)
380 390 if not ChangesetCommentsModel.use_outdated_comments(pull_request):
381 391 q = self._visible_inline_comments_of_pull_request(pull_request)
382 392 else:
383 393 q = self._all_inline_comments_of_pull_request(pull_request)
384 394
385 395 else:
386 396 raise Exception('Please specify commit or pull_request_id')
387 397 q = q.order_by(ChangesetComment.comment_id.asc())
388 398 return q
389 399
390 400 def _group_comments_by_path_and_line_number(self, q):
391 401 comments = q.all()
392 402 paths = collections.defaultdict(lambda: collections.defaultdict(list))
393 403 for co in comments:
394 404 paths[co.f_path][co.line_no].append(co)
395 405 return paths
396 406
397 407 @classmethod
398 408 def needed_extra_diff_context(cls):
399 409 return max(cls.DIFF_CONTEXT_BEFORE, cls.DIFF_CONTEXT_AFTER)
400 410
401 411 def outdate_comments(self, pull_request, old_diff_data, new_diff_data):
402 412 if not ChangesetCommentsModel.use_outdated_comments(pull_request):
403 413 return
404 414
405 415 comments = self._visible_inline_comments_of_pull_request(pull_request)
406 416 comments_to_outdate = comments.all()
407 417
408 418 for comment in comments_to_outdate:
409 419 self._outdate_one_comment(comment, old_diff_data, new_diff_data)
410 420
411 421 def _outdate_one_comment(self, comment, old_diff_proc, new_diff_proc):
412 422 diff_line = _parse_comment_line_number(comment.line_no)
413 423
414 424 try:
415 425 old_context = old_diff_proc.get_context_of_line(
416 426 path=comment.f_path, diff_line=diff_line)
417 427 new_context = new_diff_proc.get_context_of_line(
418 428 path=comment.f_path, diff_line=diff_line)
419 429 except (diffs.LineNotInDiffException,
420 430 diffs.FileNotInDiffException):
421 431 comment.display_state = ChangesetComment.COMMENT_OUTDATED
422 432 return
423 433
424 434 if old_context == new_context:
425 435 return
426 436
427 437 if self._should_relocate_diff_line(diff_line):
428 438 new_diff_lines = new_diff_proc.find_context(
429 439 path=comment.f_path, context=old_context,
430 440 offset=self.DIFF_CONTEXT_BEFORE)
431 441 if not new_diff_lines:
432 442 comment.display_state = ChangesetComment.COMMENT_OUTDATED
433 443 else:
434 444 new_diff_line = self._choose_closest_diff_line(
435 445 diff_line, new_diff_lines)
436 446 comment.line_no = _diff_to_comment_line_number(new_diff_line)
437 447 else:
438 448 comment.display_state = ChangesetComment.COMMENT_OUTDATED
439 449
440 450 def _should_relocate_diff_line(self, diff_line):
441 451 """
442 452 Checks if relocation shall be tried for the given `diff_line`.
443 453
444 454 If a comment points into the first lines, then we can have a situation
445 455 that after an update another line has been added on top. In this case
446 456 we would find the context still and move the comment around. This
447 457 would be wrong.
448 458 """
449 459 should_relocate = (
450 460 (diff_line.new and diff_line.new > self.DIFF_CONTEXT_BEFORE) or
451 461 (diff_line.old and diff_line.old > self.DIFF_CONTEXT_BEFORE))
452 462 return should_relocate
453 463
454 464 def _choose_closest_diff_line(self, diff_line, new_diff_lines):
455 465 candidate = new_diff_lines[0]
456 466 best_delta = _diff_line_delta(diff_line, candidate)
457 467 for new_diff_line in new_diff_lines[1:]:
458 468 delta = _diff_line_delta(diff_line, new_diff_line)
459 469 if delta < best_delta:
460 470 candidate = new_diff_line
461 471 best_delta = delta
462 472 return candidate
463 473
464 474 def _visible_inline_comments_of_pull_request(self, pull_request):
465 475 comments = self._all_inline_comments_of_pull_request(pull_request)
466 476 comments = comments.filter(
467 477 coalesce(ChangesetComment.display_state, '') !=
468 478 ChangesetComment.COMMENT_OUTDATED)
469 479 return comments
470 480
471 481 def _all_inline_comments_of_pull_request(self, pull_request):
472 482 comments = Session().query(ChangesetComment)\
473 483 .filter(ChangesetComment.line_no != None)\
474 484 .filter(ChangesetComment.f_path != None)\
475 485 .filter(ChangesetComment.pull_request == pull_request)
476 486 return comments
477 487
478 488 @staticmethod
479 489 def use_outdated_comments(pull_request):
480 490 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
481 491 settings = settings_model.get_general_settings()
482 492 return settings.get('rhodecode_use_outdated_comments', False)
483 493
484 494
485 495 def _parse_comment_line_number(line_no):
486 496 """
487 497 Parses line numbers of the form "(o|n)\d+" and returns them in a tuple.
488 498 """
489 499 old_line = None
490 500 new_line = None
491 501 if line_no.startswith('o'):
492 502 old_line = int(line_no[1:])
493 503 elif line_no.startswith('n'):
494 504 new_line = int(line_no[1:])
495 505 else:
496 506 raise ValueError("Comment lines have to start with either 'o' or 'n'.")
497 507 return diffs.DiffLineNumber(old_line, new_line)
498 508
499 509
500 510 def _diff_to_comment_line_number(diff_line):
501 511 if diff_line.new is not None:
502 512 return u'n{}'.format(diff_line.new)
503 513 elif diff_line.old is not None:
504 514 return u'o{}'.format(diff_line.old)
505 515 return u''
506 516
507 517
508 518 def _diff_line_delta(a, b):
509 519 if None not in (a.new, b.new):
510 520 return abs(a.new - b.new)
511 521 elif None not in (a.old, b.old):
512 522 return abs(a.old - b.old)
513 523 else:
514 524 raise ValueError(
515 525 "Cannot compute delta between {} and {}".format(a, b))
@@ -1,841 +1,843 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2016 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import mock
22 22 import pytest
23 23 import textwrap
24 24
25 25 import rhodecode
26 26 from rhodecode.lib.utils2 import safe_unicode
27 27 from rhodecode.lib.vcs.backends import get_backend
28 28 from rhodecode.lib.vcs.backends.base import (
29 29 MergeResponse, MergeFailureReason, Reference)
30 30 from rhodecode.lib.vcs.exceptions import RepositoryError
31 31 from rhodecode.lib.vcs.nodes import FileNode
32 32 from rhodecode.model.comment import ChangesetCommentsModel
33 33 from rhodecode.model.db import PullRequest, Session
34 34 from rhodecode.model.pull_request import PullRequestModel
35 35 from rhodecode.model.user import UserModel
36 36 from rhodecode.tests import TEST_USER_ADMIN_LOGIN
37 37
38 38
39 39 pytestmark = [
40 40 pytest.mark.backends("git", "hg"),
41 41 ]
42 42
43 43
44 44 class TestPullRequestModel:
45 45
46 46 @pytest.fixture
47 47 def pull_request(self, request, backend, pr_util):
48 48 """
49 49 A pull request combined with multiples patches.
50 50 """
51 51 BackendClass = get_backend(backend.alias)
52 52 self.merge_patcher = mock.patch.object(BackendClass, 'merge')
53 53 self.workspace_remove_patcher = mock.patch.object(
54 54 BackendClass, 'cleanup_merge_workspace')
55 55
56 56 self.workspace_remove_mock = self.workspace_remove_patcher.start()
57 57 self.merge_mock = self.merge_patcher.start()
58 58 self.comment_patcher = mock.patch(
59 59 'rhodecode.model.changeset_status.ChangesetStatusModel.set_status')
60 60 self.comment_patcher.start()
61 61 self.notification_patcher = mock.patch(
62 62 'rhodecode.model.notification.NotificationModel.create')
63 63 self.notification_patcher.start()
64 64 self.helper_patcher = mock.patch(
65 65 'rhodecode.lib.helpers.url')
66 66 self.helper_patcher.start()
67 67
68 68 self.hook_patcher = mock.patch.object(PullRequestModel,
69 69 '_trigger_pull_request_hook')
70 70 self.hook_mock = self.hook_patcher.start()
71 71
72 72 self.invalidation_patcher = mock.patch(
73 73 'rhodecode.model.pull_request.ScmModel.mark_for_invalidation')
74 74 self.invalidation_mock = self.invalidation_patcher.start()
75 75
76 76 self.pull_request = pr_util.create_pull_request(
77 77 mergeable=True, name_suffix=u'Δ…Δ‡')
78 78 self.source_commit = self.pull_request.source_ref_parts.commit_id
79 79 self.target_commit = self.pull_request.target_ref_parts.commit_id
80 80 self.workspace_id = 'pr-%s' % self.pull_request.pull_request_id
81 81
82 82 @request.addfinalizer
83 83 def cleanup_pull_request():
84 84 calls = [mock.call(
85 85 self.pull_request, self.pull_request.author, 'create')]
86 86 self.hook_mock.assert_has_calls(calls)
87 87
88 88 self.workspace_remove_patcher.stop()
89 89 self.merge_patcher.stop()
90 90 self.comment_patcher.stop()
91 91 self.notification_patcher.stop()
92 92 self.helper_patcher.stop()
93 93 self.hook_patcher.stop()
94 94 self.invalidation_patcher.stop()
95 95
96 96 return self.pull_request
97 97
98 98 def test_get_all(self, pull_request):
99 99 prs = PullRequestModel().get_all(pull_request.target_repo)
100 100 assert isinstance(prs, list)
101 101 assert len(prs) == 1
102 102
103 103 def test_count_all(self, pull_request):
104 104 pr_count = PullRequestModel().count_all(pull_request.target_repo)
105 105 assert pr_count == 1
106 106
107 107 def test_get_awaiting_review(self, pull_request):
108 108 prs = PullRequestModel().get_awaiting_review(pull_request.target_repo)
109 109 assert isinstance(prs, list)
110 110 assert len(prs) == 1
111 111
112 112 def test_count_awaiting_review(self, pull_request):
113 113 pr_count = PullRequestModel().count_awaiting_review(
114 114 pull_request.target_repo)
115 115 assert pr_count == 1
116 116
117 117 def test_get_awaiting_my_review(self, pull_request):
118 118 PullRequestModel().update_reviewers(
119 119 pull_request, [(pull_request.author, ['author'])])
120 120 prs = PullRequestModel().get_awaiting_my_review(
121 121 pull_request.target_repo, user_id=pull_request.author.user_id)
122 122 assert isinstance(prs, list)
123 123 assert len(prs) == 1
124 124
125 125 def test_count_awaiting_my_review(self, pull_request):
126 126 PullRequestModel().update_reviewers(
127 127 pull_request, [(pull_request.author, ['author'])])
128 128 pr_count = PullRequestModel().count_awaiting_my_review(
129 129 pull_request.target_repo, user_id=pull_request.author.user_id)
130 130 assert pr_count == 1
131 131
132 132 def test_delete_calls_cleanup_merge(self, pull_request):
133 133 PullRequestModel().delete(pull_request)
134 134
135 135 self.workspace_remove_mock.assert_called_once_with(
136 136 self.workspace_id)
137 137
138 138 def test_close_calls_cleanup_and_hook(self, pull_request):
139 139 PullRequestModel().close_pull_request(
140 140 pull_request, pull_request.author)
141 141
142 142 self.workspace_remove_mock.assert_called_once_with(
143 143 self.workspace_id)
144 144 self.hook_mock.assert_called_with(
145 145 self.pull_request, self.pull_request.author, 'close')
146 146
147 147 def test_merge_status(self, pull_request):
148 148 self.merge_mock.return_value = MergeResponse(
149 149 True, False, None, MergeFailureReason.NONE)
150 150
151 151 assert pull_request._last_merge_source_rev is None
152 152 assert pull_request._last_merge_target_rev is None
153 153 assert pull_request._last_merge_status is None
154 154
155 155 status, msg = PullRequestModel().merge_status(pull_request)
156 156 assert status is True
157 157 assert msg.eval() == 'This pull request can be automatically merged.'
158 158 self.merge_mock.assert_called_once_with(
159 159 pull_request.target_ref_parts,
160 160 pull_request.source_repo.scm_instance(),
161 161 pull_request.source_ref_parts, self.workspace_id, dry_run=True,
162 162 use_rebase=False)
163 163
164 164 assert pull_request._last_merge_source_rev == self.source_commit
165 165 assert pull_request._last_merge_target_rev == self.target_commit
166 166 assert pull_request._last_merge_status is MergeFailureReason.NONE
167 167
168 168 self.merge_mock.reset_mock()
169 169 status, msg = PullRequestModel().merge_status(pull_request)
170 170 assert status is True
171 171 assert msg.eval() == 'This pull request can be automatically merged.'
172 172 assert self.merge_mock.called is False
173 173
174 174 def test_merge_status_known_failure(self, pull_request):
175 175 self.merge_mock.return_value = MergeResponse(
176 176 False, False, None, MergeFailureReason.MERGE_FAILED)
177 177
178 178 assert pull_request._last_merge_source_rev is None
179 179 assert pull_request._last_merge_target_rev is None
180 180 assert pull_request._last_merge_status is None
181 181
182 182 status, msg = PullRequestModel().merge_status(pull_request)
183 183 assert status is False
184 184 assert (
185 185 msg.eval() ==
186 186 'This pull request cannot be merged because of conflicts.')
187 187 self.merge_mock.assert_called_once_with(
188 188 pull_request.target_ref_parts,
189 189 pull_request.source_repo.scm_instance(),
190 190 pull_request.source_ref_parts, self.workspace_id, dry_run=True,
191 191 use_rebase=False)
192 192
193 193 assert pull_request._last_merge_source_rev == self.source_commit
194 194 assert pull_request._last_merge_target_rev == self.target_commit
195 195 assert (
196 196 pull_request._last_merge_status is MergeFailureReason.MERGE_FAILED)
197 197
198 198 self.merge_mock.reset_mock()
199 199 status, msg = PullRequestModel().merge_status(pull_request)
200 200 assert status is False
201 201 assert (
202 202 msg.eval() ==
203 203 'This pull request cannot be merged because of conflicts.')
204 204 assert self.merge_mock.called is False
205 205
206 206 def test_merge_status_unknown_failure(self, pull_request):
207 207 self.merge_mock.return_value = MergeResponse(
208 208 False, False, None, MergeFailureReason.UNKNOWN)
209 209
210 210 assert pull_request._last_merge_source_rev is None
211 211 assert pull_request._last_merge_target_rev is None
212 212 assert pull_request._last_merge_status is None
213 213
214 214 status, msg = PullRequestModel().merge_status(pull_request)
215 215 assert status is False
216 216 assert msg.eval() == (
217 217 'This pull request cannot be merged because of an unhandled'
218 218 ' exception.')
219 219 self.merge_mock.assert_called_once_with(
220 220 pull_request.target_ref_parts,
221 221 pull_request.source_repo.scm_instance(),
222 222 pull_request.source_ref_parts, self.workspace_id, dry_run=True,
223 223 use_rebase=False)
224 224
225 225 assert pull_request._last_merge_source_rev is None
226 226 assert pull_request._last_merge_target_rev is None
227 227 assert pull_request._last_merge_status is None
228 228
229 229 self.merge_mock.reset_mock()
230 230 status, msg = PullRequestModel().merge_status(pull_request)
231 231 assert status is False
232 232 assert msg.eval() == (
233 233 'This pull request cannot be merged because of an unhandled'
234 234 ' exception.')
235 235 assert self.merge_mock.called is True
236 236
237 237 def test_merge_status_when_target_is_locked(self, pull_request):
238 238 pull_request.target_repo.locked = [1, u'12345.50', 'lock_web']
239 239 status, msg = PullRequestModel().merge_status(pull_request)
240 240 assert status is False
241 241 assert msg.eval() == (
242 242 'This pull request cannot be merged because the target repository'
243 243 ' is locked.')
244 244
245 245 def test_merge_status_requirements_check_target(self, pull_request):
246 246
247 247 def has_largefiles(self, repo):
248 248 return repo == pull_request.source_repo
249 249
250 250 patcher = mock.patch.object(
251 251 PullRequestModel, '_has_largefiles', has_largefiles)
252 252 with patcher:
253 253 status, msg = PullRequestModel().merge_status(pull_request)
254 254
255 255 assert status is False
256 256 assert msg == 'Target repository large files support is disabled.'
257 257
258 258 def test_merge_status_requirements_check_source(self, pull_request):
259 259
260 260 def has_largefiles(self, repo):
261 261 return repo == pull_request.target_repo
262 262
263 263 patcher = mock.patch.object(
264 264 PullRequestModel, '_has_largefiles', has_largefiles)
265 265 with patcher:
266 266 status, msg = PullRequestModel().merge_status(pull_request)
267 267
268 268 assert status is False
269 269 assert msg == 'Source repository large files support is disabled.'
270 270
271 271 def test_merge(self, pull_request, merge_extras):
272 272 user = UserModel().get_by_username(TEST_USER_ADMIN_LOGIN)
273 273 merge_ref = Reference(
274 274 'type', 'name', '6126b7bfcc82ad2d3deaee22af926b082ce54cc6')
275 275 self.merge_mock.return_value = MergeResponse(
276 276 True, True, merge_ref, MergeFailureReason.NONE)
277 277
278 278 merge_extras['repository'] = pull_request.target_repo.repo_name
279 279 PullRequestModel().merge(
280 280 pull_request, pull_request.author, extras=merge_extras)
281 281
282 282 message = (
283 283 u'Merge pull request #{pr_id} from {source_repo} {source_ref_name}'
284 284 u'\n\n {pr_title}'.format(
285 285 pr_id=pull_request.pull_request_id,
286 286 source_repo=safe_unicode(
287 287 pull_request.source_repo.scm_instance().name),
288 288 source_ref_name=pull_request.source_ref_parts.name,
289 289 pr_title=safe_unicode(pull_request.title)
290 290 )
291 291 )
292 292 self.merge_mock.assert_called_once_with(
293 293 pull_request.target_ref_parts,
294 294 pull_request.source_repo.scm_instance(),
295 295 pull_request.source_ref_parts, self.workspace_id,
296 296 user_name=user.username, user_email=user.email, message=message,
297 297 use_rebase=False
298 298 )
299 299 self.invalidation_mock.assert_called_once_with(
300 300 pull_request.target_repo.repo_name)
301 301
302 302 self.hook_mock.assert_called_with(
303 303 self.pull_request, self.pull_request.author, 'merge')
304 304
305 305 pull_request = PullRequest.get(pull_request.pull_request_id)
306 306 assert (
307 307 pull_request.merge_rev ==
308 308 '6126b7bfcc82ad2d3deaee22af926b082ce54cc6')
309 309
310 310 def test_merge_failed(self, pull_request, merge_extras):
311 311 user = UserModel().get_by_username(TEST_USER_ADMIN_LOGIN)
312 312 merge_ref = Reference(
313 313 'type', 'name', '6126b7bfcc82ad2d3deaee22af926b082ce54cc6')
314 314 self.merge_mock.return_value = MergeResponse(
315 315 False, False, merge_ref, MergeFailureReason.MERGE_FAILED)
316 316
317 317 merge_extras['repository'] = pull_request.target_repo.repo_name
318 318 PullRequestModel().merge(
319 319 pull_request, pull_request.author, extras=merge_extras)
320 320
321 321 message = (
322 322 u'Merge pull request #{pr_id} from {source_repo} {source_ref_name}'
323 323 u'\n\n {pr_title}'.format(
324 324 pr_id=pull_request.pull_request_id,
325 325 source_repo=safe_unicode(
326 326 pull_request.source_repo.scm_instance().name),
327 327 source_ref_name=pull_request.source_ref_parts.name,
328 328 pr_title=safe_unicode(pull_request.title)
329 329 )
330 330 )
331 331 self.merge_mock.assert_called_once_with(
332 332 pull_request.target_ref_parts,
333 333 pull_request.source_repo.scm_instance(),
334 334 pull_request.source_ref_parts, self.workspace_id,
335 335 user_name=user.username, user_email=user.email, message=message,
336 336 use_rebase=False
337 337 )
338 338
339 339 pull_request = PullRequest.get(pull_request.pull_request_id)
340 340 assert self.invalidation_mock.called is False
341 341 assert pull_request.merge_rev is None
342 342
343 343 def test_get_commit_ids(self, pull_request):
344 344 # The PR has been not merget yet, so expect an exception
345 345 with pytest.raises(ValueError):
346 346 PullRequestModel()._get_commit_ids(pull_request)
347 347
348 348 # Merge revision is in the revisions list
349 349 pull_request.merge_rev = pull_request.revisions[0]
350 350 commit_ids = PullRequestModel()._get_commit_ids(pull_request)
351 351 assert commit_ids == pull_request.revisions
352 352
353 353 # Merge revision is not in the revisions list
354 354 pull_request.merge_rev = 'f000' * 10
355 355 commit_ids = PullRequestModel()._get_commit_ids(pull_request)
356 356 assert commit_ids == pull_request.revisions + [pull_request.merge_rev]
357 357
358 358 def test_get_diff_from_pr_version(self, pull_request):
359 359 diff = PullRequestModel()._get_diff_from_pr_or_version(
360 360 pull_request, context=6)
361 361 assert 'file_1' in diff.raw
362 362
363 363 def test_generate_title_returns_unicode(self):
364 364 title = PullRequestModel().generate_pullrequest_title(
365 365 source='source-dummy',
366 366 source_ref='source-ref-dummy',
367 367 target='target-dummy',
368 368 )
369 369 assert type(title) == unicode
370 370
371 371
372 372 class TestIntegrationMerge(object):
373 373 @pytest.mark.parametrize('extra_config', (
374 374 {'vcs.hooks.protocol': 'http', 'vcs.hooks.direct_calls': False},
375 375 {'vcs.hooks.protocol': 'Pyro4', 'vcs.hooks.direct_calls': False},
376 376 ))
377 377 def test_merge_triggers_push_hooks(
378 378 self, pr_util, user_admin, capture_rcextensions, merge_extras,
379 379 extra_config):
380 380 pull_request = pr_util.create_pull_request(
381 381 approved=True, mergeable=True)
382 382 # TODO: johbo: Needed for sqlite, try to find an automatic way for it
383 383 merge_extras['repository'] = pull_request.target_repo.repo_name
384 384 Session().commit()
385 385
386 386 with mock.patch.dict(rhodecode.CONFIG, extra_config, clear=False):
387 387 merge_state = PullRequestModel().merge(
388 388 pull_request, user_admin, extras=merge_extras)
389 389
390 390 assert merge_state.executed
391 391 assert 'pre_push' in capture_rcextensions
392 392 assert 'post_push' in capture_rcextensions
393 393
394 394 def test_merge_can_be_rejected_by_pre_push_hook(
395 395 self, pr_util, user_admin, capture_rcextensions, merge_extras):
396 396 pull_request = pr_util.create_pull_request(
397 397 approved=True, mergeable=True)
398 398 # TODO: johbo: Needed for sqlite, try to find an automatic way for it
399 399 merge_extras['repository'] = pull_request.target_repo.repo_name
400 400 Session().commit()
401 401
402 402 with mock.patch('rhodecode.EXTENSIONS.PRE_PUSH_HOOK') as pre_pull:
403 403 pre_pull.side_effect = RepositoryError("Disallow push!")
404 404 merge_status = PullRequestModel().merge(
405 405 pull_request, user_admin, extras=merge_extras)
406 406
407 407 assert not merge_status.executed
408 408 assert 'pre_push' not in capture_rcextensions
409 409 assert 'post_push' not in capture_rcextensions
410 410
411 411 def test_merge_fails_if_target_is_locked(
412 412 self, pr_util, user_regular, merge_extras):
413 413 pull_request = pr_util.create_pull_request(
414 414 approved=True, mergeable=True)
415 415 locked_by = [user_regular.user_id + 1, 12345.50, 'lock_web']
416 416 pull_request.target_repo.locked = locked_by
417 417 # TODO: johbo: Check if this can work based on the database, currently
418 418 # all data is pre-computed, that's why just updating the DB is not
419 419 # enough.
420 420 merge_extras['locked_by'] = locked_by
421 421 merge_extras['repository'] = pull_request.target_repo.repo_name
422 422 # TODO: johbo: Needed for sqlite, try to find an automatic way for it
423 423 Session().commit()
424 424 merge_status = PullRequestModel().merge(
425 425 pull_request, user_regular, extras=merge_extras)
426 426 assert not merge_status.executed
427 427
428 428
429 429 @pytest.mark.parametrize('use_outdated, inlines_count, outdated_count', [
430 430 (False, 1, 0),
431 431 (True, 0, 1),
432 432 ])
433 433 def test_outdated_comments(
434 434 pr_util, use_outdated, inlines_count, outdated_count):
435 435 pull_request = pr_util.create_pull_request()
436 436 pr_util.create_inline_comment(file_path='not_in_updated_diff')
437 437
438 438 with outdated_comments_patcher(use_outdated) as outdated_comment_mock:
439 439 pr_util.add_one_commit()
440 440 assert_inline_comments(
441 441 pull_request, visible=inlines_count, outdated=outdated_count)
442 442 outdated_comment_mock.assert_called_with(pull_request)
443 443
444 444
445 445 @pytest.fixture
446 446 def merge_extras(user_regular):
447 447 """
448 448 Context for the vcs operation when running a merge.
449 449 """
450 450 extras = {
451 451 'ip': '127.0.0.1',
452 452 'username': user_regular.username,
453 453 'action': 'push',
454 454 'repository': 'fake_target_repo_name',
455 455 'scm': 'git',
456 456 'config': 'fake_config_ini_path',
457 457 'make_lock': None,
458 458 'locked_by': [None, None, None],
459 459 'server_url': 'http://test.example.com:5000',
460 460 'hooks': ['push', 'pull'],
461 461 'is_shadow_repo': False,
462 462 }
463 463 return extras
464 464
465 465
466 466 class TestUpdateCommentHandling(object):
467 467
468 468 @pytest.fixture(autouse=True, scope='class')
469 469 def enable_outdated_comments(self, request, pylonsapp):
470 470 config_patch = mock.patch.dict(
471 471 'rhodecode.CONFIG', {'rhodecode_use_outdated_comments': True})
472 472 config_patch.start()
473 473
474 474 @request.addfinalizer
475 475 def cleanup():
476 476 config_patch.stop()
477 477
478 478 def test_comment_stays_unflagged_on_unchanged_diff(self, pr_util):
479 479 commits = [
480 480 {'message': 'a'},
481 481 {'message': 'b', 'added': [FileNode('file_b', 'test_content\n')]},
482 482 {'message': 'c', 'added': [FileNode('file_c', 'test_content\n')]},
483 483 ]
484 484 pull_request = pr_util.create_pull_request(
485 485 commits=commits, target_head='a', source_head='b', revisions=['b'])
486 486 pr_util.create_inline_comment(file_path='file_b')
487 487 pr_util.add_one_commit(head='c')
488 488
489 489 assert_inline_comments(pull_request, visible=1, outdated=0)
490 490
491 491 def test_comment_stays_unflagged_on_change_above(self, pr_util):
492 492 original_content = ''.join(
493 493 ['line {}\n'.format(x) for x in range(1, 11)])
494 494 updated_content = 'new_line_at_top\n' + original_content
495 495 commits = [
496 496 {'message': 'a'},
497 497 {'message': 'b', 'added': [FileNode('file_b', original_content)]},
498 498 {'message': 'c', 'changed': [FileNode('file_b', updated_content)]},
499 499 ]
500 500 pull_request = pr_util.create_pull_request(
501 501 commits=commits, target_head='a', source_head='b', revisions=['b'])
502 502
503 503 with outdated_comments_patcher():
504 504 comment = pr_util.create_inline_comment(
505 505 line_no=u'n8', file_path='file_b')
506 506 pr_util.add_one_commit(head='c')
507 507
508 508 assert_inline_comments(pull_request, visible=1, outdated=0)
509 509 assert comment.line_no == u'n9'
510 510
511 511 def test_comment_stays_unflagged_on_change_below(self, pr_util):
512 512 original_content = ''.join(['line {}\n'.format(x) for x in range(10)])
513 513 updated_content = original_content + 'new_line_at_end\n'
514 514 commits = [
515 515 {'message': 'a'},
516 516 {'message': 'b', 'added': [FileNode('file_b', original_content)]},
517 517 {'message': 'c', 'changed': [FileNode('file_b', updated_content)]},
518 518 ]
519 519 pull_request = pr_util.create_pull_request(
520 520 commits=commits, target_head='a', source_head='b', revisions=['b'])
521 521 pr_util.create_inline_comment(file_path='file_b')
522 522 pr_util.add_one_commit(head='c')
523 523
524 524 assert_inline_comments(pull_request, visible=1, outdated=0)
525 525
526 526 @pytest.mark.parametrize('line_no', ['n4', 'o4', 'n10', 'o9'])
527 527 def test_comment_flagged_on_change_around_context(self, pr_util, line_no):
528 528 base_lines = ['line {}\n'.format(x) for x in range(1, 13)]
529 529 change_lines = list(base_lines)
530 530 change_lines.insert(6, 'line 6a added\n')
531 531
532 532 # Changes on the last line of sight
533 533 update_lines = list(change_lines)
534 534 update_lines[0] = 'line 1 changed\n'
535 535 update_lines[-1] = 'line 12 changed\n'
536 536
537 537 def file_b(lines):
538 538 return FileNode('file_b', ''.join(lines))
539 539
540 540 commits = [
541 541 {'message': 'a', 'added': [file_b(base_lines)]},
542 542 {'message': 'b', 'changed': [file_b(change_lines)]},
543 543 {'message': 'c', 'changed': [file_b(update_lines)]},
544 544 ]
545 545
546 546 pull_request = pr_util.create_pull_request(
547 547 commits=commits, target_head='a', source_head='b', revisions=['b'])
548 548 pr_util.create_inline_comment(line_no=line_no, file_path='file_b')
549 549
550 550 with outdated_comments_patcher():
551 551 pr_util.add_one_commit(head='c')
552 552 assert_inline_comments(pull_request, visible=0, outdated=1)
553 553
554 554 @pytest.mark.parametrize("change, content", [
555 555 ('changed', 'changed\n'),
556 556 ('removed', ''),
557 557 ], ids=['changed', 'removed'])
558 558 def test_comment_flagged_on_change(self, pr_util, change, content):
559 559 commits = [
560 560 {'message': 'a'},
561 561 {'message': 'b', 'added': [FileNode('file_b', 'test_content\n')]},
562 562 {'message': 'c', change: [FileNode('file_b', content)]},
563 563 ]
564 564 pull_request = pr_util.create_pull_request(
565 565 commits=commits, target_head='a', source_head='b', revisions=['b'])
566 566 pr_util.create_inline_comment(file_path='file_b')
567 567
568 568 with outdated_comments_patcher():
569 569 pr_util.add_one_commit(head='c')
570 570 assert_inline_comments(pull_request, visible=0, outdated=1)
571 571
572 572
573 573 class TestUpdateChangedFiles(object):
574 574
575 575 def test_no_changes_on_unchanged_diff(self, pr_util):
576 576 commits = [
577 577 {'message': 'a'},
578 578 {'message': 'b',
579 579 'added': [FileNode('file_b', 'test_content b\n')]},
580 580 {'message': 'c',
581 581 'added': [FileNode('file_c', 'test_content c\n')]},
582 582 ]
583 583 # open a PR from a to b, adding file_b
584 584 pull_request = pr_util.create_pull_request(
585 585 commits=commits, target_head='a', source_head='b', revisions=['b'],
586 586 name_suffix='per-file-review')
587 587
588 588 # modify PR adding new file file_c
589 589 pr_util.add_one_commit(head='c')
590 590
591 591 assert_pr_file_changes(
592 592 pull_request,
593 593 added=['file_c'],
594 594 modified=[],
595 595 removed=[])
596 596
597 597 def test_modify_and_undo_modification_diff(self, pr_util):
598 598 commits = [
599 599 {'message': 'a'},
600 600 {'message': 'b',
601 601 'added': [FileNode('file_b', 'test_content b\n')]},
602 602 {'message': 'c',
603 603 'changed': [FileNode('file_b', 'test_content b modified\n')]},
604 604 {'message': 'd',
605 605 'changed': [FileNode('file_b', 'test_content b\n')]},
606 606 ]
607 607 # open a PR from a to b, adding file_b
608 608 pull_request = pr_util.create_pull_request(
609 609 commits=commits, target_head='a', source_head='b', revisions=['b'],
610 610 name_suffix='per-file-review')
611 611
612 612 # modify PR modifying file file_b
613 613 pr_util.add_one_commit(head='c')
614 614
615 615 assert_pr_file_changes(
616 616 pull_request,
617 617 added=[],
618 618 modified=['file_b'],
619 619 removed=[])
620 620
621 621 # move the head again to d, which rollbacks change,
622 622 # meaning we should indicate no changes
623 623 pr_util.add_one_commit(head='d')
624 624
625 625 assert_pr_file_changes(
626 626 pull_request,
627 627 added=[],
628 628 modified=[],
629 629 removed=[])
630 630
631 631 def test_updated_all_files_in_pr(self, pr_util):
632 632 commits = [
633 633 {'message': 'a'},
634 634 {'message': 'b', 'added': [
635 635 FileNode('file_a', 'test_content a\n'),
636 636 FileNode('file_b', 'test_content b\n'),
637 637 FileNode('file_c', 'test_content c\n')]},
638 638 {'message': 'c', 'changed': [
639 639 FileNode('file_a', 'test_content a changed\n'),
640 640 FileNode('file_b', 'test_content b changed\n'),
641 641 FileNode('file_c', 'test_content c changed\n')]},
642 642 ]
643 643 # open a PR from a to b, changing 3 files
644 644 pull_request = pr_util.create_pull_request(
645 645 commits=commits, target_head='a', source_head='b', revisions=['b'],
646 646 name_suffix='per-file-review')
647 647
648 648 pr_util.add_one_commit(head='c')
649 649
650 650 assert_pr_file_changes(
651 651 pull_request,
652 652 added=[],
653 653 modified=['file_a', 'file_b', 'file_c'],
654 654 removed=[])
655 655
656 656 def test_updated_and_removed_all_files_in_pr(self, pr_util):
657 657 commits = [
658 658 {'message': 'a'},
659 659 {'message': 'b', 'added': [
660 660 FileNode('file_a', 'test_content a\n'),
661 661 FileNode('file_b', 'test_content b\n'),
662 662 FileNode('file_c', 'test_content c\n')]},
663 663 {'message': 'c', 'removed': [
664 664 FileNode('file_a', 'test_content a changed\n'),
665 665 FileNode('file_b', 'test_content b changed\n'),
666 666 FileNode('file_c', 'test_content c changed\n')]},
667 667 ]
668 668 # open a PR from a to b, removing 3 files
669 669 pull_request = pr_util.create_pull_request(
670 670 commits=commits, target_head='a', source_head='b', revisions=['b'],
671 671 name_suffix='per-file-review')
672 672
673 673 pr_util.add_one_commit(head='c')
674 674
675 675 assert_pr_file_changes(
676 676 pull_request,
677 677 added=[],
678 678 modified=[],
679 679 removed=['file_a', 'file_b', 'file_c'])
680 680
681 681
682 682 def test_update_writes_snapshot_into_pull_request_version(pr_util):
683 683 model = PullRequestModel()
684 684 pull_request = pr_util.create_pull_request()
685 685 pr_util.update_source_repository()
686 686
687 687 model.update_commits(pull_request)
688 688
689 689 # Expect that it has a version entry now
690 690 assert len(model.get_versions(pull_request)) == 1
691 691
692 692
693 693 def test_update_skips_new_version_if_unchanged(pr_util):
694 694 pull_request = pr_util.create_pull_request()
695 695 model = PullRequestModel()
696 696 model.update_commits(pull_request)
697 697
698 698 # Expect that it still has no versions
699 699 assert len(model.get_versions(pull_request)) == 0
700 700
701 701
702 702 def test_update_assigns_comments_to_the_new_version(pr_util):
703 703 model = PullRequestModel()
704 704 pull_request = pr_util.create_pull_request()
705 705 comment = pr_util.create_comment()
706 706 pr_util.update_source_repository()
707 707
708 708 model.update_commits(pull_request)
709 709
710 710 # Expect that the comment is linked to the pr version now
711 711 assert comment.pull_request_version == model.get_versions(pull_request)[0]
712 712
713 713
714 714 def test_update_adds_a_comment_to_the_pull_request_about_the_change(pr_util):
715 715 model = PullRequestModel()
716 716 pull_request = pr_util.create_pull_request()
717 717 pr_util.update_source_repository()
718 718 pr_util.update_source_repository()
719 719
720 720 model.update_commits(pull_request)
721 721
722 722 # Expect to find a new comment about the change
723 723 expected_message = textwrap.dedent(
724 724 """\
725 725 Auto status change to |under_review|
726 726
727 727 .. role:: added
728 728 .. role:: removed
729 729 .. parsed-literal::
730 730
731 731 Changed commits:
732 732 * :added:`1 added`
733 733 * :removed:`0 removed`
734 734
735 735 Changed files:
736 736 * `A file_2 <#a_c--92ed3b5f07b4>`_
737 737
738 738 .. |under_review| replace:: *"Under Review"*"""
739 739 )
740 740 pull_request_comments = sorted(
741 741 pull_request.comments, key=lambda c: c.modified_at)
742 742 update_comment = pull_request_comments[-1]
743 743 assert update_comment.text == expected_message
744 744
745 745
746 746 def test_create_version_from_snapshot_updates_attributes(pr_util):
747 747 pull_request = pr_util.create_pull_request()
748 748
749 749 # Avoiding default values
750 750 pull_request.status = PullRequest.STATUS_CLOSED
751 751 pull_request._last_merge_source_rev = "0" * 40
752 752 pull_request._last_merge_target_rev = "1" * 40
753 753 pull_request._last_merge_status = 1
754 754 pull_request.merge_rev = "2" * 40
755 755
756 756 # Remember automatic values
757 757 created_on = pull_request.created_on
758 758 updated_on = pull_request.updated_on
759 759
760 760 # Create a new version of the pull request
761 761 version = PullRequestModel()._create_version_from_snapshot(pull_request)
762 762
763 763 # Check attributes
764 764 assert version.title == pr_util.create_parameters['title']
765 765 assert version.description == pr_util.create_parameters['description']
766 766 assert version.status == PullRequest.STATUS_CLOSED
767 767 assert version.created_on == created_on
768 768 assert version.updated_on == updated_on
769 769 assert version.user_id == pull_request.user_id
770 770 assert version.revisions == pr_util.create_parameters['revisions']
771 771 assert version.source_repo == pr_util.source_repository
772 772 assert version.source_ref == pr_util.create_parameters['source_ref']
773 773 assert version.target_repo == pr_util.target_repository
774 774 assert version.target_ref == pr_util.create_parameters['target_ref']
775 775 assert version._last_merge_source_rev == pull_request._last_merge_source_rev
776 776 assert version._last_merge_target_rev == pull_request._last_merge_target_rev
777 777 assert version._last_merge_status == pull_request._last_merge_status
778 778 assert version.merge_rev == pull_request.merge_rev
779 779 assert version.pull_request == pull_request
780 780
781 781
782 782 def test_link_comments_to_version_only_updates_unlinked_comments(pr_util):
783 783 version1 = pr_util.create_version_of_pull_request()
784 784 comment_linked = pr_util.create_comment(linked_to=version1)
785 785 comment_unlinked = pr_util.create_comment()
786 786 version2 = pr_util.create_version_of_pull_request()
787 787
788 788 PullRequestModel()._link_comments_to_version(version2)
789 789
790 790 # Expect that only the new comment is linked to version2
791 791 assert (
792 792 comment_unlinked.pull_request_version_id ==
793 793 version2.pull_request_version_id)
794 794 assert (
795 795 comment_linked.pull_request_version_id ==
796 796 version1.pull_request_version_id)
797 797 assert (
798 798 comment_unlinked.pull_request_version_id !=
799 799 comment_linked.pull_request_version_id)
800 800
801 801
802 802 def test_calculate_commits():
803 803 change = PullRequestModel()._calculate_commit_id_changes(
804 804 set([1, 2, 3]), set([1, 3, 4, 5]))
805 805 assert (set([4, 5]), set([1, 3]), set([2])) == (
806 806 change.added, change.common, change.removed)
807 807
808 808
809 809 def assert_inline_comments(pull_request, visible=None, outdated=None):
810 810 if visible is not None:
811 811 inline_comments = ChangesetCommentsModel().get_inline_comments(
812 812 pull_request.target_repo.repo_id, pull_request=pull_request)
813 assert len(inline_comments) == visible
813 inline_cnt = ChangesetCommentsModel().get_inline_comments_count(
814 inline_comments)
815 assert inline_cnt == visible
814 816 if outdated is not None:
815 817 outdated_comments = ChangesetCommentsModel().get_outdated_comments(
816 818 pull_request.target_repo.repo_id, pull_request)
817 819 assert len(outdated_comments) == outdated
818 820
819 821
820 822 def assert_pr_file_changes(
821 823 pull_request, added=None, modified=None, removed=None):
822 824 pr_versions = PullRequestModel().get_versions(pull_request)
823 825 # always use first version, ie original PR to calculate changes
824 826 pull_request_version = pr_versions[0]
825 827 old_diff_data, new_diff_data = PullRequestModel()._generate_update_diffs(
826 828 pull_request, pull_request_version)
827 829 file_changes = PullRequestModel()._calculate_file_changes(
828 830 old_diff_data, new_diff_data)
829 831
830 832 assert added == file_changes.added, \
831 833 'expected added:%s vs value:%s' % (added, file_changes.added)
832 834 assert modified == file_changes.modified, \
833 835 'expected modified:%s vs value:%s' % (modified, file_changes.modified)
834 836 assert removed == file_changes.removed, \
835 837 'expected removed:%s vs value:%s' % (removed, file_changes.removed)
836 838
837 839
838 840 def outdated_comments_patcher(use_outdated=True):
839 841 return mock.patch.object(
840 842 ChangesetCommentsModel, 'use_outdated_comments',
841 843 return_value=use_outdated)
General Comments 0
You need to be logged in to leave comments. Login now