##// END OF EJS Templates
comments: expose a function to fetch unresolved TODOs for repository
marcink -
r3433:840bd8bd default
parent child Browse files
Show More
@@ -1,662 +1,672 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2019 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 pyramid.threadlocal import get_current_registry, get_current_request
30 30 from sqlalchemy.sql.expression import null
31 31 from sqlalchemy.sql.functions import coalesce
32 32
33 33 from rhodecode.lib import helpers as h, diffs, channelstream
34 34 from rhodecode.lib import audit_logger
35 35 from rhodecode.lib.utils2 import extract_mentioned_users, safe_str
36 36 from rhodecode.model import BaseModel
37 37 from rhodecode.model.db import (
38 38 ChangesetComment, User, Notification, PullRequest, AttributeDict)
39 39 from rhodecode.model.notification import NotificationModel
40 40 from rhodecode.model.meta import Session
41 41 from rhodecode.model.settings import VcsSettingsModel
42 42 from rhodecode.model.notification import EmailNotificationModel
43 43 from rhodecode.model.validation_schema.schemas import comment_schema
44 44
45 45
46 46 log = logging.getLogger(__name__)
47 47
48 48
49 49 class CommentsModel(BaseModel):
50 50
51 51 cls = ChangesetComment
52 52
53 53 DIFF_CONTEXT_BEFORE = 3
54 54 DIFF_CONTEXT_AFTER = 3
55 55
56 56 def __get_commit_comment(self, changeset_comment):
57 57 return self._get_instance(ChangesetComment, changeset_comment)
58 58
59 59 def __get_pull_request(self, pull_request):
60 60 return self._get_instance(PullRequest, pull_request)
61 61
62 62 def _extract_mentions(self, s):
63 63 user_objects = []
64 64 for username in extract_mentioned_users(s):
65 65 user_obj = User.get_by_username(username, case_insensitive=True)
66 66 if user_obj:
67 67 user_objects.append(user_obj)
68 68 return user_objects
69 69
70 70 def _get_renderer(self, global_renderer='rst', request=None):
71 71 request = request or get_current_request()
72 72
73 73 try:
74 74 global_renderer = request.call_context.visual.default_renderer
75 75 except AttributeError:
76 76 log.debug("Renderer not set, falling back "
77 77 "to default renderer '%s'", global_renderer)
78 78 except Exception:
79 79 log.error(traceback.format_exc())
80 80 return global_renderer
81 81
82 82 def aggregate_comments(self, comments, versions, show_version, inline=False):
83 83 # group by versions, and count until, and display objects
84 84
85 85 comment_groups = collections.defaultdict(list)
86 86 [comment_groups[
87 87 _co.pull_request_version_id].append(_co) for _co in comments]
88 88
89 89 def yield_comments(pos):
90 90 for co in comment_groups[pos]:
91 91 yield co
92 92
93 93 comment_versions = collections.defaultdict(
94 94 lambda: collections.defaultdict(list))
95 95 prev_prvid = -1
96 96 # fake last entry with None, to aggregate on "latest" version which
97 97 # doesn't have an pull_request_version_id
98 98 for ver in versions + [AttributeDict({'pull_request_version_id': None})]:
99 99 prvid = ver.pull_request_version_id
100 100 if prev_prvid == -1:
101 101 prev_prvid = prvid
102 102
103 103 for co in yield_comments(prvid):
104 104 comment_versions[prvid]['at'].append(co)
105 105
106 106 # save until
107 107 current = comment_versions[prvid]['at']
108 108 prev_until = comment_versions[prev_prvid]['until']
109 109 cur_until = prev_until + current
110 110 comment_versions[prvid]['until'].extend(cur_until)
111 111
112 112 # save outdated
113 113 if inline:
114 114 outdated = [x for x in cur_until
115 115 if x.outdated_at_version(show_version)]
116 116 else:
117 117 outdated = [x for x in cur_until
118 118 if x.older_than_version(show_version)]
119 119 display = [x for x in cur_until if x not in outdated]
120 120
121 121 comment_versions[prvid]['outdated'] = outdated
122 122 comment_versions[prvid]['display'] = display
123 123
124 124 prev_prvid = prvid
125 125
126 126 return comment_versions
127 127
128 def get_unresolved_todos(self, pull_request, show_outdated=True):
128 def get_repository_unresolved_todos(self, repo):
129 todos = Session().query(ChangesetComment) \
130 .filter(ChangesetComment.repo == repo) \
131 .filter(ChangesetComment.resolved_by == None) \
132 .filter(ChangesetComment.comment_type
133 == ChangesetComment.COMMENT_TYPE_TODO)
134 todos = todos.all()
135
136 return todos
137
138 def get_pull_request_unresolved_todos(self, pull_request, show_outdated=True):
129 139
130 140 todos = Session().query(ChangesetComment) \
131 141 .filter(ChangesetComment.pull_request == pull_request) \
132 142 .filter(ChangesetComment.resolved_by == None) \
133 143 .filter(ChangesetComment.comment_type
134 144 == ChangesetComment.COMMENT_TYPE_TODO)
135 145
136 146 if not show_outdated:
137 147 todos = todos.filter(
138 148 coalesce(ChangesetComment.display_state, '') !=
139 149 ChangesetComment.COMMENT_OUTDATED)
140 150
141 151 todos = todos.all()
142 152
143 153 return todos
144 154
145 155 def get_commit_unresolved_todos(self, commit_id, show_outdated=True):
146 156
147 157 todos = Session().query(ChangesetComment) \
148 158 .filter(ChangesetComment.revision == commit_id) \
149 159 .filter(ChangesetComment.resolved_by == None) \
150 160 .filter(ChangesetComment.comment_type
151 161 == ChangesetComment.COMMENT_TYPE_TODO)
152 162
153 163 if not show_outdated:
154 164 todos = todos.filter(
155 165 coalesce(ChangesetComment.display_state, '') !=
156 166 ChangesetComment.COMMENT_OUTDATED)
157 167
158 168 todos = todos.all()
159 169
160 170 return todos
161 171
162 172 def _log_audit_action(self, action, action_data, auth_user, comment):
163 173 audit_logger.store(
164 174 action=action,
165 175 action_data=action_data,
166 176 user=auth_user,
167 177 repo=comment.repo)
168 178
169 179 def create(self, text, repo, user, commit_id=None, pull_request=None,
170 180 f_path=None, line_no=None, status_change=None,
171 181 status_change_type=None, comment_type=None,
172 182 resolves_comment_id=None, closing_pr=False, send_email=True,
173 183 renderer=None, auth_user=None):
174 184 """
175 185 Creates new comment for commit or pull request.
176 186 IF status_change is not none this comment is associated with a
177 187 status change of commit or commit associated with pull request
178 188
179 189 :param text:
180 190 :param repo:
181 191 :param user:
182 192 :param commit_id:
183 193 :param pull_request:
184 194 :param f_path:
185 195 :param line_no:
186 196 :param status_change: Label for status change
187 197 :param comment_type: Type of comment
188 198 :param status_change_type: type of status change
189 199 :param closing_pr:
190 200 :param send_email:
191 201 :param renderer: pick renderer for this comment
192 202 """
193 203
194 204 if not text:
195 205 log.warning('Missing text for comment, skipping...')
196 206 return
197 207 request = get_current_request()
198 208 _ = request.translate
199 209
200 210 if not renderer:
201 211 renderer = self._get_renderer(request=request)
202 212
203 213 repo = self._get_repo(repo)
204 214 user = self._get_user(user)
205 215 auth_user = auth_user or user
206 216
207 217 schema = comment_schema.CommentSchema()
208 218 validated_kwargs = schema.deserialize(dict(
209 219 comment_body=text,
210 220 comment_type=comment_type,
211 221 comment_file=f_path,
212 222 comment_line=line_no,
213 223 renderer_type=renderer,
214 224 status_change=status_change_type,
215 225 resolves_comment_id=resolves_comment_id,
216 226 repo=repo.repo_id,
217 227 user=user.user_id,
218 228 ))
219 229
220 230 comment = ChangesetComment()
221 231 comment.renderer = validated_kwargs['renderer_type']
222 232 comment.text = validated_kwargs['comment_body']
223 233 comment.f_path = validated_kwargs['comment_file']
224 234 comment.line_no = validated_kwargs['comment_line']
225 235 comment.comment_type = validated_kwargs['comment_type']
226 236
227 237 comment.repo = repo
228 238 comment.author = user
229 239 resolved_comment = self.__get_commit_comment(
230 240 validated_kwargs['resolves_comment_id'])
231 241 # check if the comment actually belongs to this PR
232 242 if resolved_comment and resolved_comment.pull_request and \
233 243 resolved_comment.pull_request != pull_request:
234 244 # comment not bound to this pull request, forbid
235 245 resolved_comment = None
236 246 comment.resolved_comment = resolved_comment
237 247
238 248 pull_request_id = pull_request
239 249
240 250 commit_obj = None
241 251 pull_request_obj = None
242 252
243 253 if commit_id:
244 254 notification_type = EmailNotificationModel.TYPE_COMMIT_COMMENT
245 255 # do a lookup, so we don't pass something bad here
246 256 commit_obj = repo.scm_instance().get_commit(commit_id=commit_id)
247 257 comment.revision = commit_obj.raw_id
248 258
249 259 elif pull_request_id:
250 260 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST_COMMENT
251 261 pull_request_obj = self.__get_pull_request(pull_request_id)
252 262 comment.pull_request = pull_request_obj
253 263 else:
254 264 raise Exception('Please specify commit or pull_request_id')
255 265
256 266 Session().add(comment)
257 267 Session().flush()
258 268 kwargs = {
259 269 'user': user,
260 270 'renderer_type': renderer,
261 271 'repo_name': repo.repo_name,
262 272 'status_change': status_change,
263 273 'status_change_type': status_change_type,
264 274 'comment_body': text,
265 275 'comment_file': f_path,
266 276 'comment_line': line_no,
267 277 'comment_type': comment_type or 'note'
268 278 }
269 279
270 280 if commit_obj:
271 281 recipients = ChangesetComment.get_users(
272 282 revision=commit_obj.raw_id)
273 283 # add commit author if it's in RhodeCode system
274 284 cs_author = User.get_from_cs_author(commit_obj.author)
275 285 if not cs_author:
276 286 # use repo owner if we cannot extract the author correctly
277 287 cs_author = repo.user
278 288 recipients += [cs_author]
279 289
280 290 commit_comment_url = self.get_url(comment, request=request)
281 291
282 292 target_repo_url = h.link_to(
283 293 repo.repo_name,
284 294 h.route_url('repo_summary', repo_name=repo.repo_name))
285 295
286 296 # commit specifics
287 297 kwargs.update({
288 298 'commit': commit_obj,
289 299 'commit_message': commit_obj.message,
290 300 'commit_target_repo': target_repo_url,
291 301 'commit_comment_url': commit_comment_url,
292 302 })
293 303
294 304 elif pull_request_obj:
295 305 # get the current participants of this pull request
296 306 recipients = ChangesetComment.get_users(
297 307 pull_request_id=pull_request_obj.pull_request_id)
298 308 # add pull request author
299 309 recipients += [pull_request_obj.author]
300 310
301 311 # add the reviewers to notification
302 312 recipients += [x.user for x in pull_request_obj.reviewers]
303 313
304 314 pr_target_repo = pull_request_obj.target_repo
305 315 pr_source_repo = pull_request_obj.source_repo
306 316
307 317 pr_comment_url = h.route_url(
308 318 'pullrequest_show',
309 319 repo_name=pr_target_repo.repo_name,
310 320 pull_request_id=pull_request_obj.pull_request_id,
311 321 _anchor='comment-%s' % comment.comment_id)
312 322
313 323 # set some variables for email notification
314 324 pr_target_repo_url = h.route_url(
315 325 'repo_summary', repo_name=pr_target_repo.repo_name)
316 326
317 327 pr_source_repo_url = h.route_url(
318 328 'repo_summary', repo_name=pr_source_repo.repo_name)
319 329
320 330 # pull request specifics
321 331 kwargs.update({
322 332 'pull_request': pull_request_obj,
323 333 'pr_id': pull_request_obj.pull_request_id,
324 334 'pr_target_repo': pr_target_repo,
325 335 'pr_target_repo_url': pr_target_repo_url,
326 336 'pr_source_repo': pr_source_repo,
327 337 'pr_source_repo_url': pr_source_repo_url,
328 338 'pr_comment_url': pr_comment_url,
329 339 'pr_closing': closing_pr,
330 340 })
331 341 if send_email:
332 342 # pre-generate the subject for notification itself
333 343 (subject,
334 344 _h, _e, # we don't care about those
335 345 body_plaintext) = EmailNotificationModel().render_email(
336 346 notification_type, **kwargs)
337 347
338 348 mention_recipients = set(
339 349 self._extract_mentions(text)).difference(recipients)
340 350
341 351 # create notification objects, and emails
342 352 NotificationModel().create(
343 353 created_by=user,
344 354 notification_subject=subject,
345 355 notification_body=body_plaintext,
346 356 notification_type=notification_type,
347 357 recipients=recipients,
348 358 mention_recipients=mention_recipients,
349 359 email_kwargs=kwargs,
350 360 )
351 361
352 362 Session().flush()
353 363 if comment.pull_request:
354 364 action = 'repo.pull_request.comment.create'
355 365 else:
356 366 action = 'repo.commit.comment.create'
357 367
358 368 comment_data = comment.get_api_data()
359 369 self._log_audit_action(
360 370 action, {'data': comment_data}, auth_user, comment)
361 371
362 372 msg_url = ''
363 373 channel = None
364 374 if commit_obj:
365 375 msg_url = commit_comment_url
366 376 repo_name = repo.repo_name
367 377 channel = u'/repo${}$/commit/{}'.format(
368 378 repo_name,
369 379 commit_obj.raw_id
370 380 )
371 381 elif pull_request_obj:
372 382 msg_url = pr_comment_url
373 383 repo_name = pr_target_repo.repo_name
374 384 channel = u'/repo${}$/pr/{}'.format(
375 385 repo_name,
376 386 pull_request_id
377 387 )
378 388
379 389 message = '<strong>{}</strong> {} - ' \
380 390 '<a onclick="window.location=\'{}\';' \
381 391 'window.location.reload()">' \
382 392 '<strong>{}</strong></a>'
383 393 message = message.format(
384 394 user.username, _('made a comment'), msg_url,
385 395 _('Show it now'))
386 396
387 397 channelstream.post_message(
388 398 channel, message, user.username,
389 399 registry=get_current_registry())
390 400
391 401 return comment
392 402
393 403 def delete(self, comment, auth_user):
394 404 """
395 405 Deletes given comment
396 406 """
397 407 comment = self.__get_commit_comment(comment)
398 408 old_data = comment.get_api_data()
399 409 Session().delete(comment)
400 410
401 411 if comment.pull_request:
402 412 action = 'repo.pull_request.comment.delete'
403 413 else:
404 414 action = 'repo.commit.comment.delete'
405 415
406 416 self._log_audit_action(
407 417 action, {'old_data': old_data}, auth_user, comment)
408 418
409 419 return comment
410 420
411 421 def get_all_comments(self, repo_id, revision=None, pull_request=None):
412 422 q = ChangesetComment.query()\
413 423 .filter(ChangesetComment.repo_id == repo_id)
414 424 if revision:
415 425 q = q.filter(ChangesetComment.revision == revision)
416 426 elif pull_request:
417 427 pull_request = self.__get_pull_request(pull_request)
418 428 q = q.filter(ChangesetComment.pull_request == pull_request)
419 429 else:
420 430 raise Exception('Please specify commit or pull_request')
421 431 q = q.order_by(ChangesetComment.created_on)
422 432 return q.all()
423 433
424 434 def get_url(self, comment, request=None, permalink=False):
425 435 if not request:
426 436 request = get_current_request()
427 437
428 438 comment = self.__get_commit_comment(comment)
429 439 if comment.pull_request:
430 440 pull_request = comment.pull_request
431 441 if permalink:
432 442 return request.route_url(
433 443 'pull_requests_global',
434 444 pull_request_id=pull_request.pull_request_id,
435 445 _anchor='comment-%s' % comment.comment_id)
436 446 else:
437 447 return request.route_url(
438 448 'pullrequest_show',
439 449 repo_name=safe_str(pull_request.target_repo.repo_name),
440 450 pull_request_id=pull_request.pull_request_id,
441 451 _anchor='comment-%s' % comment.comment_id)
442 452
443 453 else:
444 454 repo = comment.repo
445 455 commit_id = comment.revision
446 456
447 457 if permalink:
448 458 return request.route_url(
449 459 'repo_commit', repo_name=safe_str(repo.repo_id),
450 460 commit_id=commit_id,
451 461 _anchor='comment-%s' % comment.comment_id)
452 462
453 463 else:
454 464 return request.route_url(
455 465 'repo_commit', repo_name=safe_str(repo.repo_name),
456 466 commit_id=commit_id,
457 467 _anchor='comment-%s' % comment.comment_id)
458 468
459 469 def get_comments(self, repo_id, revision=None, pull_request=None):
460 470 """
461 471 Gets main comments based on revision or pull_request_id
462 472
463 473 :param repo_id:
464 474 :param revision:
465 475 :param pull_request:
466 476 """
467 477
468 478 q = ChangesetComment.query()\
469 479 .filter(ChangesetComment.repo_id == repo_id)\
470 480 .filter(ChangesetComment.line_no == None)\
471 481 .filter(ChangesetComment.f_path == None)
472 482 if revision:
473 483 q = q.filter(ChangesetComment.revision == revision)
474 484 elif pull_request:
475 485 pull_request = self.__get_pull_request(pull_request)
476 486 q = q.filter(ChangesetComment.pull_request == pull_request)
477 487 else:
478 488 raise Exception('Please specify commit or pull_request')
479 489 q = q.order_by(ChangesetComment.created_on)
480 490 return q.all()
481 491
482 492 def get_inline_comments(self, repo_id, revision=None, pull_request=None):
483 493 q = self._get_inline_comments_query(repo_id, revision, pull_request)
484 494 return self._group_comments_by_path_and_line_number(q)
485 495
486 496 def get_inline_comments_count(self, inline_comments, skip_outdated=True,
487 497 version=None):
488 498 inline_cnt = 0
489 499 for fname, per_line_comments in inline_comments.iteritems():
490 500 for lno, comments in per_line_comments.iteritems():
491 501 for comm in comments:
492 502 if not comm.outdated_at_version(version) and skip_outdated:
493 503 inline_cnt += 1
494 504
495 505 return inline_cnt
496 506
497 507 def get_outdated_comments(self, repo_id, pull_request):
498 508 # TODO: johbo: Remove `repo_id`, it is not needed to find the comments
499 509 # of a pull request.
500 510 q = self._all_inline_comments_of_pull_request(pull_request)
501 511 q = q.filter(
502 512 ChangesetComment.display_state ==
503 513 ChangesetComment.COMMENT_OUTDATED
504 514 ).order_by(ChangesetComment.comment_id.asc())
505 515
506 516 return self._group_comments_by_path_and_line_number(q)
507 517
508 518 def _get_inline_comments_query(self, repo_id, revision, pull_request):
509 519 # TODO: johbo: Split this into two methods: One for PR and one for
510 520 # commit.
511 521 if revision:
512 522 q = Session().query(ChangesetComment).filter(
513 523 ChangesetComment.repo_id == repo_id,
514 524 ChangesetComment.line_no != null(),
515 525 ChangesetComment.f_path != null(),
516 526 ChangesetComment.revision == revision)
517 527
518 528 elif pull_request:
519 529 pull_request = self.__get_pull_request(pull_request)
520 530 if not CommentsModel.use_outdated_comments(pull_request):
521 531 q = self._visible_inline_comments_of_pull_request(pull_request)
522 532 else:
523 533 q = self._all_inline_comments_of_pull_request(pull_request)
524 534
525 535 else:
526 536 raise Exception('Please specify commit or pull_request_id')
527 537 q = q.order_by(ChangesetComment.comment_id.asc())
528 538 return q
529 539
530 540 def _group_comments_by_path_and_line_number(self, q):
531 541 comments = q.all()
532 542 paths = collections.defaultdict(lambda: collections.defaultdict(list))
533 543 for co in comments:
534 544 paths[co.f_path][co.line_no].append(co)
535 545 return paths
536 546
537 547 @classmethod
538 548 def needed_extra_diff_context(cls):
539 549 return max(cls.DIFF_CONTEXT_BEFORE, cls.DIFF_CONTEXT_AFTER)
540 550
541 551 def outdate_comments(self, pull_request, old_diff_data, new_diff_data):
542 552 if not CommentsModel.use_outdated_comments(pull_request):
543 553 return
544 554
545 555 comments = self._visible_inline_comments_of_pull_request(pull_request)
546 556 comments_to_outdate = comments.all()
547 557
548 558 for comment in comments_to_outdate:
549 559 self._outdate_one_comment(comment, old_diff_data, new_diff_data)
550 560
551 561 def _outdate_one_comment(self, comment, old_diff_proc, new_diff_proc):
552 562 diff_line = _parse_comment_line_number(comment.line_no)
553 563
554 564 try:
555 565 old_context = old_diff_proc.get_context_of_line(
556 566 path=comment.f_path, diff_line=diff_line)
557 567 new_context = new_diff_proc.get_context_of_line(
558 568 path=comment.f_path, diff_line=diff_line)
559 569 except (diffs.LineNotInDiffException,
560 570 diffs.FileNotInDiffException):
561 571 comment.display_state = ChangesetComment.COMMENT_OUTDATED
562 572 return
563 573
564 574 if old_context == new_context:
565 575 return
566 576
567 577 if self._should_relocate_diff_line(diff_line):
568 578 new_diff_lines = new_diff_proc.find_context(
569 579 path=comment.f_path, context=old_context,
570 580 offset=self.DIFF_CONTEXT_BEFORE)
571 581 if not new_diff_lines:
572 582 comment.display_state = ChangesetComment.COMMENT_OUTDATED
573 583 else:
574 584 new_diff_line = self._choose_closest_diff_line(
575 585 diff_line, new_diff_lines)
576 586 comment.line_no = _diff_to_comment_line_number(new_diff_line)
577 587 else:
578 588 comment.display_state = ChangesetComment.COMMENT_OUTDATED
579 589
580 590 def _should_relocate_diff_line(self, diff_line):
581 591 """
582 592 Checks if relocation shall be tried for the given `diff_line`.
583 593
584 594 If a comment points into the first lines, then we can have a situation
585 595 that after an update another line has been added on top. In this case
586 596 we would find the context still and move the comment around. This
587 597 would be wrong.
588 598 """
589 599 should_relocate = (
590 600 (diff_line.new and diff_line.new > self.DIFF_CONTEXT_BEFORE) or
591 601 (diff_line.old and diff_line.old > self.DIFF_CONTEXT_BEFORE))
592 602 return should_relocate
593 603
594 604 def _choose_closest_diff_line(self, diff_line, new_diff_lines):
595 605 candidate = new_diff_lines[0]
596 606 best_delta = _diff_line_delta(diff_line, candidate)
597 607 for new_diff_line in new_diff_lines[1:]:
598 608 delta = _diff_line_delta(diff_line, new_diff_line)
599 609 if delta < best_delta:
600 610 candidate = new_diff_line
601 611 best_delta = delta
602 612 return candidate
603 613
604 614 def _visible_inline_comments_of_pull_request(self, pull_request):
605 615 comments = self._all_inline_comments_of_pull_request(pull_request)
606 616 comments = comments.filter(
607 617 coalesce(ChangesetComment.display_state, '') !=
608 618 ChangesetComment.COMMENT_OUTDATED)
609 619 return comments
610 620
611 621 def _all_inline_comments_of_pull_request(self, pull_request):
612 622 comments = Session().query(ChangesetComment)\
613 623 .filter(ChangesetComment.line_no != None)\
614 624 .filter(ChangesetComment.f_path != None)\
615 625 .filter(ChangesetComment.pull_request == pull_request)
616 626 return comments
617 627
618 628 def _all_general_comments_of_pull_request(self, pull_request):
619 629 comments = Session().query(ChangesetComment)\
620 630 .filter(ChangesetComment.line_no == None)\
621 631 .filter(ChangesetComment.f_path == None)\
622 632 .filter(ChangesetComment.pull_request == pull_request)
623 633 return comments
624 634
625 635 @staticmethod
626 636 def use_outdated_comments(pull_request):
627 637 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
628 638 settings = settings_model.get_general_settings()
629 639 return settings.get('rhodecode_use_outdated_comments', False)
630 640
631 641
632 642 def _parse_comment_line_number(line_no):
633 643 """
634 644 Parses line numbers of the form "(o|n)\d+" and returns them in a tuple.
635 645 """
636 646 old_line = None
637 647 new_line = None
638 648 if line_no.startswith('o'):
639 649 old_line = int(line_no[1:])
640 650 elif line_no.startswith('n'):
641 651 new_line = int(line_no[1:])
642 652 else:
643 653 raise ValueError("Comment lines have to start with either 'o' or 'n'.")
644 654 return diffs.DiffLineNumber(old_line, new_line)
645 655
646 656
647 657 def _diff_to_comment_line_number(diff_line):
648 658 if diff_line.new is not None:
649 659 return u'n{}'.format(diff_line.new)
650 660 elif diff_line.old is not None:
651 661 return u'o{}'.format(diff_line.old)
652 662 return u''
653 663
654 664
655 665 def _diff_line_delta(a, b):
656 666 if None not in (a.new, b.new):
657 667 return abs(a.new - b.new)
658 668 elif None not in (a.old, b.old):
659 669 return abs(a.old - b.old)
660 670 else:
661 671 raise ValueError(
662 672 "Cannot compute delta between {} and {}".format(a, b))
@@ -1,1715 +1,1715 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2019 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21
22 22 """
23 23 pull request model for RhodeCode
24 24 """
25 25
26 26
27 27 import json
28 28 import logging
29 29 import datetime
30 30 import urllib
31 31 import collections
32 32
33 33 from pyramid import compat
34 34 from pyramid.threadlocal import get_current_request
35 35
36 36 from rhodecode import events
37 37 from rhodecode.translation import lazy_ugettext
38 38 from rhodecode.lib import helpers as h, hooks_utils, diffs
39 39 from rhodecode.lib import audit_logger
40 40 from rhodecode.lib.compat import OrderedDict
41 41 from rhodecode.lib.hooks_daemon import prepare_callback_daemon
42 42 from rhodecode.lib.markup_renderer import (
43 43 DEFAULT_COMMENTS_RENDERER, RstTemplateRenderer)
44 44 from rhodecode.lib.utils2 import safe_unicode, safe_str, md5_safe
45 45 from rhodecode.lib.vcs.backends.base import (
46 46 Reference, MergeResponse, MergeFailureReason, UpdateFailureReason)
47 47 from rhodecode.lib.vcs.conf import settings as vcs_settings
48 48 from rhodecode.lib.vcs.exceptions import (
49 49 CommitDoesNotExistError, EmptyRepositoryError)
50 50 from rhodecode.model import BaseModel
51 51 from rhodecode.model.changeset_status import ChangesetStatusModel
52 52 from rhodecode.model.comment import CommentsModel
53 53 from rhodecode.model.db import (
54 54 or_, PullRequest, PullRequestReviewers, ChangesetStatus,
55 55 PullRequestVersion, ChangesetComment, Repository, RepoReviewRule)
56 56 from rhodecode.model.meta import Session
57 57 from rhodecode.model.notification import NotificationModel, \
58 58 EmailNotificationModel
59 59 from rhodecode.model.scm import ScmModel
60 60 from rhodecode.model.settings import VcsSettingsModel
61 61
62 62
63 63 log = logging.getLogger(__name__)
64 64
65 65
66 66 # Data structure to hold the response data when updating commits during a pull
67 67 # request update.
68 68 UpdateResponse = collections.namedtuple('UpdateResponse', [
69 69 'executed', 'reason', 'new', 'old', 'changes',
70 70 'source_changed', 'target_changed'])
71 71
72 72
73 73 class PullRequestModel(BaseModel):
74 74
75 75 cls = PullRequest
76 76
77 77 DIFF_CONTEXT = diffs.DEFAULT_CONTEXT
78 78
79 79 UPDATE_STATUS_MESSAGES = {
80 80 UpdateFailureReason.NONE: lazy_ugettext(
81 81 'Pull request update successful.'),
82 82 UpdateFailureReason.UNKNOWN: lazy_ugettext(
83 83 'Pull request update failed because of an unknown error.'),
84 84 UpdateFailureReason.NO_CHANGE: lazy_ugettext(
85 85 'No update needed because the source and target have not changed.'),
86 86 UpdateFailureReason.WRONG_REF_TYPE: lazy_ugettext(
87 87 'Pull request cannot be updated because the reference type is '
88 88 'not supported for an update. Only Branch, Tag or Bookmark is allowed.'),
89 89 UpdateFailureReason.MISSING_TARGET_REF: lazy_ugettext(
90 90 'This pull request cannot be updated because the target '
91 91 'reference is missing.'),
92 92 UpdateFailureReason.MISSING_SOURCE_REF: lazy_ugettext(
93 93 'This pull request cannot be updated because the source '
94 94 'reference is missing.'),
95 95 }
96 96 REF_TYPES = ['bookmark', 'book', 'tag', 'branch']
97 97 UPDATABLE_REF_TYPES = ['bookmark', 'book', 'branch']
98 98
99 99 def __get_pull_request(self, pull_request):
100 100 return self._get_instance((
101 101 PullRequest, PullRequestVersion), pull_request)
102 102
103 103 def _check_perms(self, perms, pull_request, user, api=False):
104 104 if not api:
105 105 return h.HasRepoPermissionAny(*perms)(
106 106 user=user, repo_name=pull_request.target_repo.repo_name)
107 107 else:
108 108 return h.HasRepoPermissionAnyApi(*perms)(
109 109 user=user, repo_name=pull_request.target_repo.repo_name)
110 110
111 111 def check_user_read(self, pull_request, user, api=False):
112 112 _perms = ('repository.admin', 'repository.write', 'repository.read',)
113 113 return self._check_perms(_perms, pull_request, user, api)
114 114
115 115 def check_user_merge(self, pull_request, user, api=False):
116 116 _perms = ('repository.admin', 'repository.write', 'hg.admin',)
117 117 return self._check_perms(_perms, pull_request, user, api)
118 118
119 119 def check_user_update(self, pull_request, user, api=False):
120 120 owner = user.user_id == pull_request.user_id
121 121 return self.check_user_merge(pull_request, user, api) or owner
122 122
123 123 def check_user_delete(self, pull_request, user):
124 124 owner = user.user_id == pull_request.user_id
125 125 _perms = ('repository.admin',)
126 126 return self._check_perms(_perms, pull_request, user) or owner
127 127
128 128 def check_user_change_status(self, pull_request, user, api=False):
129 129 reviewer = user.user_id in [x.user_id for x in
130 130 pull_request.reviewers]
131 131 return self.check_user_update(pull_request, user, api) or reviewer
132 132
133 133 def check_user_comment(self, pull_request, user):
134 134 owner = user.user_id == pull_request.user_id
135 135 return self.check_user_read(pull_request, user) or owner
136 136
137 137 def get(self, pull_request):
138 138 return self.__get_pull_request(pull_request)
139 139
140 140 def _prepare_get_all_query(self, repo_name, source=False, statuses=None,
141 141 opened_by=None, order_by=None,
142 142 order_dir='desc', only_created=True):
143 143 repo = None
144 144 if repo_name:
145 145 repo = self._get_repo(repo_name)
146 146
147 147 q = PullRequest.query()
148 148
149 149 # source or target
150 150 if repo and source:
151 151 q = q.filter(PullRequest.source_repo == repo)
152 152 elif repo:
153 153 q = q.filter(PullRequest.target_repo == repo)
154 154
155 155 # closed,opened
156 156 if statuses:
157 157 q = q.filter(PullRequest.status.in_(statuses))
158 158
159 159 # opened by filter
160 160 if opened_by:
161 161 q = q.filter(PullRequest.user_id.in_(opened_by))
162 162
163 163 # only get those that are in "created" state
164 164 if only_created:
165 165 q = q.filter(PullRequest.pull_request_state == PullRequest.STATE_CREATED)
166 166
167 167 if order_by:
168 168 order_map = {
169 169 'name_raw': PullRequest.pull_request_id,
170 170 'title': PullRequest.title,
171 171 'updated_on_raw': PullRequest.updated_on,
172 172 'target_repo': PullRequest.target_repo_id
173 173 }
174 174 if order_dir == 'asc':
175 175 q = q.order_by(order_map[order_by].asc())
176 176 else:
177 177 q = q.order_by(order_map[order_by].desc())
178 178
179 179 return q
180 180
181 181 def count_all(self, repo_name, source=False, statuses=None,
182 182 opened_by=None):
183 183 """
184 184 Count the number of pull requests for a specific repository.
185 185
186 186 :param repo_name: target or source repo
187 187 :param source: boolean flag to specify if repo_name refers to source
188 188 :param statuses: list of pull request statuses
189 189 :param opened_by: author user of the pull request
190 190 :returns: int number of pull requests
191 191 """
192 192 q = self._prepare_get_all_query(
193 193 repo_name, source=source, statuses=statuses, opened_by=opened_by)
194 194
195 195 return q.count()
196 196
197 197 def get_all(self, repo_name, source=False, statuses=None, opened_by=None,
198 198 offset=0, length=None, order_by=None, order_dir='desc'):
199 199 """
200 200 Get all pull requests for a specific repository.
201 201
202 202 :param repo_name: target or source repo
203 203 :param source: boolean flag to specify if repo_name refers to source
204 204 :param statuses: list of pull request statuses
205 205 :param opened_by: author user of the pull request
206 206 :param offset: pagination offset
207 207 :param length: length of returned list
208 208 :param order_by: order of the returned list
209 209 :param order_dir: 'asc' or 'desc' ordering direction
210 210 :returns: list of pull requests
211 211 """
212 212 q = self._prepare_get_all_query(
213 213 repo_name, source=source, statuses=statuses, opened_by=opened_by,
214 214 order_by=order_by, order_dir=order_dir)
215 215
216 216 if length:
217 217 pull_requests = q.limit(length).offset(offset).all()
218 218 else:
219 219 pull_requests = q.all()
220 220
221 221 return pull_requests
222 222
223 223 def count_awaiting_review(self, repo_name, source=False, statuses=None,
224 224 opened_by=None):
225 225 """
226 226 Count the number of pull requests for a specific repository that are
227 227 awaiting review.
228 228
229 229 :param repo_name: target or source repo
230 230 :param source: boolean flag to specify if repo_name refers to source
231 231 :param statuses: list of pull request statuses
232 232 :param opened_by: author user of the pull request
233 233 :returns: int number of pull requests
234 234 """
235 235 pull_requests = self.get_awaiting_review(
236 236 repo_name, source=source, statuses=statuses, opened_by=opened_by)
237 237
238 238 return len(pull_requests)
239 239
240 240 def get_awaiting_review(self, repo_name, source=False, statuses=None,
241 241 opened_by=None, offset=0, length=None,
242 242 order_by=None, order_dir='desc'):
243 243 """
244 244 Get all pull requests for a specific repository that are awaiting
245 245 review.
246 246
247 247 :param repo_name: target or source repo
248 248 :param source: boolean flag to specify if repo_name refers to source
249 249 :param statuses: list of pull request statuses
250 250 :param opened_by: author user of the pull request
251 251 :param offset: pagination offset
252 252 :param length: length of returned list
253 253 :param order_by: order of the returned list
254 254 :param order_dir: 'asc' or 'desc' ordering direction
255 255 :returns: list of pull requests
256 256 """
257 257 pull_requests = self.get_all(
258 258 repo_name, source=source, statuses=statuses, opened_by=opened_by,
259 259 order_by=order_by, order_dir=order_dir)
260 260
261 261 _filtered_pull_requests = []
262 262 for pr in pull_requests:
263 263 status = pr.calculated_review_status()
264 264 if status in [ChangesetStatus.STATUS_NOT_REVIEWED,
265 265 ChangesetStatus.STATUS_UNDER_REVIEW]:
266 266 _filtered_pull_requests.append(pr)
267 267 if length:
268 268 return _filtered_pull_requests[offset:offset+length]
269 269 else:
270 270 return _filtered_pull_requests
271 271
272 272 def count_awaiting_my_review(self, repo_name, source=False, statuses=None,
273 273 opened_by=None, user_id=None):
274 274 """
275 275 Count the number of pull requests for a specific repository that are
276 276 awaiting review from a specific user.
277 277
278 278 :param repo_name: target or source repo
279 279 :param source: boolean flag to specify if repo_name refers to source
280 280 :param statuses: list of pull request statuses
281 281 :param opened_by: author user of the pull request
282 282 :param user_id: reviewer user of the pull request
283 283 :returns: int number of pull requests
284 284 """
285 285 pull_requests = self.get_awaiting_my_review(
286 286 repo_name, source=source, statuses=statuses, opened_by=opened_by,
287 287 user_id=user_id)
288 288
289 289 return len(pull_requests)
290 290
291 291 def get_awaiting_my_review(self, repo_name, source=False, statuses=None,
292 292 opened_by=None, user_id=None, offset=0,
293 293 length=None, order_by=None, order_dir='desc'):
294 294 """
295 295 Get all pull requests for a specific repository that are awaiting
296 296 review from a specific user.
297 297
298 298 :param repo_name: target or source repo
299 299 :param source: boolean flag to specify if repo_name refers to source
300 300 :param statuses: list of pull request statuses
301 301 :param opened_by: author user of the pull request
302 302 :param user_id: reviewer user of the pull request
303 303 :param offset: pagination offset
304 304 :param length: length of returned list
305 305 :param order_by: order of the returned list
306 306 :param order_dir: 'asc' or 'desc' ordering direction
307 307 :returns: list of pull requests
308 308 """
309 309 pull_requests = self.get_all(
310 310 repo_name, source=source, statuses=statuses, opened_by=opened_by,
311 311 order_by=order_by, order_dir=order_dir)
312 312
313 313 _my = PullRequestModel().get_not_reviewed(user_id)
314 314 my_participation = []
315 315 for pr in pull_requests:
316 316 if pr in _my:
317 317 my_participation.append(pr)
318 318 _filtered_pull_requests = my_participation
319 319 if length:
320 320 return _filtered_pull_requests[offset:offset+length]
321 321 else:
322 322 return _filtered_pull_requests
323 323
324 324 def get_not_reviewed(self, user_id):
325 325 return [
326 326 x.pull_request for x in PullRequestReviewers.query().filter(
327 327 PullRequestReviewers.user_id == user_id).all()
328 328 ]
329 329
330 330 def _prepare_participating_query(self, user_id=None, statuses=None,
331 331 order_by=None, order_dir='desc'):
332 332 q = PullRequest.query()
333 333 if user_id:
334 334 reviewers_subquery = Session().query(
335 335 PullRequestReviewers.pull_request_id).filter(
336 336 PullRequestReviewers.user_id == user_id).subquery()
337 337 user_filter = or_(
338 338 PullRequest.user_id == user_id,
339 339 PullRequest.pull_request_id.in_(reviewers_subquery)
340 340 )
341 341 q = PullRequest.query().filter(user_filter)
342 342
343 343 # closed,opened
344 344 if statuses:
345 345 q = q.filter(PullRequest.status.in_(statuses))
346 346
347 347 if order_by:
348 348 order_map = {
349 349 'name_raw': PullRequest.pull_request_id,
350 350 'title': PullRequest.title,
351 351 'updated_on_raw': PullRequest.updated_on,
352 352 'target_repo': PullRequest.target_repo_id
353 353 }
354 354 if order_dir == 'asc':
355 355 q = q.order_by(order_map[order_by].asc())
356 356 else:
357 357 q = q.order_by(order_map[order_by].desc())
358 358
359 359 return q
360 360
361 361 def count_im_participating_in(self, user_id=None, statuses=None):
362 362 q = self._prepare_participating_query(user_id, statuses=statuses)
363 363 return q.count()
364 364
365 365 def get_im_participating_in(
366 366 self, user_id=None, statuses=None, offset=0,
367 367 length=None, order_by=None, order_dir='desc'):
368 368 """
369 369 Get all Pull requests that i'm participating in, or i have opened
370 370 """
371 371
372 372 q = self._prepare_participating_query(
373 373 user_id, statuses=statuses, order_by=order_by,
374 374 order_dir=order_dir)
375 375
376 376 if length:
377 377 pull_requests = q.limit(length).offset(offset).all()
378 378 else:
379 379 pull_requests = q.all()
380 380
381 381 return pull_requests
382 382
383 383 def get_versions(self, pull_request):
384 384 """
385 385 returns version of pull request sorted by ID descending
386 386 """
387 387 return PullRequestVersion.query()\
388 388 .filter(PullRequestVersion.pull_request == pull_request)\
389 389 .order_by(PullRequestVersion.pull_request_version_id.asc())\
390 390 .all()
391 391
392 392 def get_pr_version(self, pull_request_id, version=None):
393 393 at_version = None
394 394
395 395 if version and version == 'latest':
396 396 pull_request_ver = PullRequest.get(pull_request_id)
397 397 pull_request_obj = pull_request_ver
398 398 _org_pull_request_obj = pull_request_obj
399 399 at_version = 'latest'
400 400 elif version:
401 401 pull_request_ver = PullRequestVersion.get_or_404(version)
402 402 pull_request_obj = pull_request_ver
403 403 _org_pull_request_obj = pull_request_ver.pull_request
404 404 at_version = pull_request_ver.pull_request_version_id
405 405 else:
406 406 _org_pull_request_obj = pull_request_obj = PullRequest.get_or_404(
407 407 pull_request_id)
408 408
409 409 pull_request_display_obj = PullRequest.get_pr_display_object(
410 410 pull_request_obj, _org_pull_request_obj)
411 411
412 412 return _org_pull_request_obj, pull_request_obj, \
413 413 pull_request_display_obj, at_version
414 414
415 415 def create(self, created_by, source_repo, source_ref, target_repo,
416 416 target_ref, revisions, reviewers, title, description=None,
417 417 description_renderer=None,
418 418 reviewer_data=None, translator=None, auth_user=None):
419 419 translator = translator or get_current_request().translate
420 420
421 421 created_by_user = self._get_user(created_by)
422 422 auth_user = auth_user or created_by_user.AuthUser()
423 423 source_repo = self._get_repo(source_repo)
424 424 target_repo = self._get_repo(target_repo)
425 425
426 426 pull_request = PullRequest()
427 427 pull_request.source_repo = source_repo
428 428 pull_request.source_ref = source_ref
429 429 pull_request.target_repo = target_repo
430 430 pull_request.target_ref = target_ref
431 431 pull_request.revisions = revisions
432 432 pull_request.title = title
433 433 pull_request.description = description
434 434 pull_request.description_renderer = description_renderer
435 435 pull_request.author = created_by_user
436 436 pull_request.reviewer_data = reviewer_data
437 437 pull_request.pull_request_state = pull_request.STATE_CREATING
438 438 Session().add(pull_request)
439 439 Session().flush()
440 440
441 441 reviewer_ids = set()
442 442 # members / reviewers
443 443 for reviewer_object in reviewers:
444 444 user_id, reasons, mandatory, rules = reviewer_object
445 445 user = self._get_user(user_id)
446 446
447 447 # skip duplicates
448 448 if user.user_id in reviewer_ids:
449 449 continue
450 450
451 451 reviewer_ids.add(user.user_id)
452 452
453 453 reviewer = PullRequestReviewers()
454 454 reviewer.user = user
455 455 reviewer.pull_request = pull_request
456 456 reviewer.reasons = reasons
457 457 reviewer.mandatory = mandatory
458 458
459 459 # NOTE(marcink): pick only first rule for now
460 460 rule_id = list(rules)[0] if rules else None
461 461 rule = RepoReviewRule.get(rule_id) if rule_id else None
462 462 if rule:
463 463 review_group = rule.user_group_vote_rule(user_id)
464 464 # we check if this particular reviewer is member of a voting group
465 465 if review_group:
466 466 # NOTE(marcink):
467 467 # can be that user is member of more but we pick the first same,
468 468 # same as default reviewers algo
469 469 review_group = review_group[0]
470 470
471 471 rule_data = {
472 472 'rule_name':
473 473 rule.review_rule_name,
474 474 'rule_user_group_entry_id':
475 475 review_group.repo_review_rule_users_group_id,
476 476 'rule_user_group_name':
477 477 review_group.users_group.users_group_name,
478 478 'rule_user_group_members':
479 479 [x.user.username for x in review_group.users_group.members],
480 480 'rule_user_group_members_id':
481 481 [x.user.user_id for x in review_group.users_group.members],
482 482 }
483 483 # e.g {'vote_rule': -1, 'mandatory': True}
484 484 rule_data.update(review_group.rule_data())
485 485
486 486 reviewer.rule_data = rule_data
487 487
488 488 Session().add(reviewer)
489 489 Session().flush()
490 490
491 491 # Set approval status to "Under Review" for all commits which are
492 492 # part of this pull request.
493 493 ChangesetStatusModel().set_status(
494 494 repo=target_repo,
495 495 status=ChangesetStatus.STATUS_UNDER_REVIEW,
496 496 user=created_by_user,
497 497 pull_request=pull_request
498 498 )
499 499 # we commit early at this point. This has to do with a fact
500 500 # that before queries do some row-locking. And because of that
501 501 # we need to commit and finish transaction before below validate call
502 502 # that for large repos could be long resulting in long row locks
503 503 Session().commit()
504 504
505 505 # prepare workspace, and run initial merge simulation. Set state during that
506 506 # operation
507 507 pull_request = PullRequest.get(pull_request.pull_request_id)
508 508
509 509 # set as merging, for simulation, and if finished to created so we mark
510 510 # simulation is working fine
511 511 with pull_request.set_state(PullRequest.STATE_MERGING,
512 512 final_state=PullRequest.STATE_CREATED):
513 513 MergeCheck.validate(
514 514 pull_request, auth_user=auth_user, translator=translator)
515 515
516 516 self.notify_reviewers(pull_request, reviewer_ids)
517 517 self.trigger_pull_request_hook(
518 518 pull_request, created_by_user, 'create')
519 519
520 520 creation_data = pull_request.get_api_data(with_merge_state=False)
521 521 self._log_audit_action(
522 522 'repo.pull_request.create', {'data': creation_data},
523 523 auth_user, pull_request)
524 524
525 525 return pull_request
526 526
527 527 def trigger_pull_request_hook(self, pull_request, user, action, data=None):
528 528 pull_request = self.__get_pull_request(pull_request)
529 529 target_scm = pull_request.target_repo.scm_instance()
530 530 if action == 'create':
531 531 trigger_hook = hooks_utils.trigger_log_create_pull_request_hook
532 532 elif action == 'merge':
533 533 trigger_hook = hooks_utils.trigger_log_merge_pull_request_hook
534 534 elif action == 'close':
535 535 trigger_hook = hooks_utils.trigger_log_close_pull_request_hook
536 536 elif action == 'review_status_change':
537 537 trigger_hook = hooks_utils.trigger_log_review_pull_request_hook
538 538 elif action == 'update':
539 539 trigger_hook = hooks_utils.trigger_log_update_pull_request_hook
540 540 elif action == 'comment':
541 541 # dummy hook ! for comment. We want this function to handle all cases
542 542 def trigger_hook(*args, **kwargs):
543 543 pass
544 544 comment = data['comment']
545 545 events.trigger(events.PullRequestCommentEvent(pull_request, comment))
546 546 else:
547 547 return
548 548
549 549 trigger_hook(
550 550 username=user.username,
551 551 repo_name=pull_request.target_repo.repo_name,
552 552 repo_alias=target_scm.alias,
553 553 pull_request=pull_request,
554 554 data=data)
555 555
556 556 def _get_commit_ids(self, pull_request):
557 557 """
558 558 Return the commit ids of the merged pull request.
559 559
560 560 This method is not dealing correctly yet with the lack of autoupdates
561 561 nor with the implicit target updates.
562 562 For example: if a commit in the source repo is already in the target it
563 563 will be reported anyways.
564 564 """
565 565 merge_rev = pull_request.merge_rev
566 566 if merge_rev is None:
567 567 raise ValueError('This pull request was not merged yet')
568 568
569 569 commit_ids = list(pull_request.revisions)
570 570 if merge_rev not in commit_ids:
571 571 commit_ids.append(merge_rev)
572 572
573 573 return commit_ids
574 574
575 575 def merge_repo(self, pull_request, user, extras):
576 576 log.debug("Merging pull request %s", pull_request.pull_request_id)
577 577 extras['user_agent'] = 'internal-merge'
578 578 merge_state = self._merge_pull_request(pull_request, user, extras)
579 579 if merge_state.executed:
580 580 log.debug("Merge was successful, updating the pull request comments.")
581 581 self._comment_and_close_pr(pull_request, user, merge_state)
582 582
583 583 self._log_audit_action(
584 584 'repo.pull_request.merge',
585 585 {'merge_state': merge_state.__dict__},
586 586 user, pull_request)
587 587
588 588 else:
589 589 log.warn("Merge failed, not updating the pull request.")
590 590 return merge_state
591 591
592 592 def _merge_pull_request(self, pull_request, user, extras, merge_msg=None):
593 593 target_vcs = pull_request.target_repo.scm_instance()
594 594 source_vcs = pull_request.source_repo.scm_instance()
595 595
596 596 message = safe_unicode(merge_msg or vcs_settings.MERGE_MESSAGE_TMPL).format(
597 597 pr_id=pull_request.pull_request_id,
598 598 pr_title=pull_request.title,
599 599 source_repo=source_vcs.name,
600 600 source_ref_name=pull_request.source_ref_parts.name,
601 601 target_repo=target_vcs.name,
602 602 target_ref_name=pull_request.target_ref_parts.name,
603 603 )
604 604
605 605 workspace_id = self._workspace_id(pull_request)
606 606 repo_id = pull_request.target_repo.repo_id
607 607 use_rebase = self._use_rebase_for_merging(pull_request)
608 608 close_branch = self._close_branch_before_merging(pull_request)
609 609
610 610 target_ref = self._refresh_reference(
611 611 pull_request.target_ref_parts, target_vcs)
612 612
613 613 callback_daemon, extras = prepare_callback_daemon(
614 614 extras, protocol=vcs_settings.HOOKS_PROTOCOL,
615 615 host=vcs_settings.HOOKS_HOST,
616 616 use_direct_calls=vcs_settings.HOOKS_DIRECT_CALLS)
617 617
618 618 with callback_daemon:
619 619 # TODO: johbo: Implement a clean way to run a config_override
620 620 # for a single call.
621 621 target_vcs.config.set(
622 622 'rhodecode', 'RC_SCM_DATA', json.dumps(extras))
623 623
624 624 user_name = user.short_contact
625 625 merge_state = target_vcs.merge(
626 626 repo_id, workspace_id, target_ref, source_vcs,
627 627 pull_request.source_ref_parts,
628 628 user_name=user_name, user_email=user.email,
629 629 message=message, use_rebase=use_rebase,
630 630 close_branch=close_branch)
631 631 return merge_state
632 632
633 633 def _comment_and_close_pr(self, pull_request, user, merge_state, close_msg=None):
634 634 pull_request.merge_rev = merge_state.merge_ref.commit_id
635 635 pull_request.updated_on = datetime.datetime.now()
636 636 close_msg = close_msg or 'Pull request merged and closed'
637 637
638 638 CommentsModel().create(
639 639 text=safe_unicode(close_msg),
640 640 repo=pull_request.target_repo.repo_id,
641 641 user=user.user_id,
642 642 pull_request=pull_request.pull_request_id,
643 643 f_path=None,
644 644 line_no=None,
645 645 closing_pr=True
646 646 )
647 647
648 648 Session().add(pull_request)
649 649 Session().flush()
650 650 # TODO: paris: replace invalidation with less radical solution
651 651 ScmModel().mark_for_invalidation(
652 652 pull_request.target_repo.repo_name)
653 653 self.trigger_pull_request_hook(pull_request, user, 'merge')
654 654
655 655 def has_valid_update_type(self, pull_request):
656 656 source_ref_type = pull_request.source_ref_parts.type
657 657 return source_ref_type in self.REF_TYPES
658 658
659 659 def update_commits(self, pull_request):
660 660 """
661 661 Get the updated list of commits for the pull request
662 662 and return the new pull request version and the list
663 663 of commits processed by this update action
664 664 """
665 665 pull_request = self.__get_pull_request(pull_request)
666 666 source_ref_type = pull_request.source_ref_parts.type
667 667 source_ref_name = pull_request.source_ref_parts.name
668 668 source_ref_id = pull_request.source_ref_parts.commit_id
669 669
670 670 target_ref_type = pull_request.target_ref_parts.type
671 671 target_ref_name = pull_request.target_ref_parts.name
672 672 target_ref_id = pull_request.target_ref_parts.commit_id
673 673
674 674 if not self.has_valid_update_type(pull_request):
675 675 log.debug("Skipping update of pull request %s due to ref type: %s",
676 676 pull_request, source_ref_type)
677 677 return UpdateResponse(
678 678 executed=False,
679 679 reason=UpdateFailureReason.WRONG_REF_TYPE,
680 680 old=pull_request, new=None, changes=None,
681 681 source_changed=False, target_changed=False)
682 682
683 683 # source repo
684 684 source_repo = pull_request.source_repo.scm_instance()
685 685 try:
686 686 source_commit = source_repo.get_commit(commit_id=source_ref_name)
687 687 except CommitDoesNotExistError:
688 688 return UpdateResponse(
689 689 executed=False,
690 690 reason=UpdateFailureReason.MISSING_SOURCE_REF,
691 691 old=pull_request, new=None, changes=None,
692 692 source_changed=False, target_changed=False)
693 693
694 694 source_changed = source_ref_id != source_commit.raw_id
695 695
696 696 # target repo
697 697 target_repo = pull_request.target_repo.scm_instance()
698 698 try:
699 699 target_commit = target_repo.get_commit(commit_id=target_ref_name)
700 700 except CommitDoesNotExistError:
701 701 return UpdateResponse(
702 702 executed=False,
703 703 reason=UpdateFailureReason.MISSING_TARGET_REF,
704 704 old=pull_request, new=None, changes=None,
705 705 source_changed=False, target_changed=False)
706 706 target_changed = target_ref_id != target_commit.raw_id
707 707
708 708 if not (source_changed or target_changed):
709 709 log.debug("Nothing changed in pull request %s", pull_request)
710 710 return UpdateResponse(
711 711 executed=False,
712 712 reason=UpdateFailureReason.NO_CHANGE,
713 713 old=pull_request, new=None, changes=None,
714 714 source_changed=target_changed, target_changed=source_changed)
715 715
716 716 change_in_found = 'target repo' if target_changed else 'source repo'
717 717 log.debug('Updating pull request because of change in %s detected',
718 718 change_in_found)
719 719
720 720 # Finally there is a need for an update, in case of source change
721 721 # we create a new version, else just an update
722 722 if source_changed:
723 723 pull_request_version = self._create_version_from_snapshot(pull_request)
724 724 self._link_comments_to_version(pull_request_version)
725 725 else:
726 726 try:
727 727 ver = pull_request.versions[-1]
728 728 except IndexError:
729 729 ver = None
730 730
731 731 pull_request.pull_request_version_id = \
732 732 ver.pull_request_version_id if ver else None
733 733 pull_request_version = pull_request
734 734
735 735 try:
736 736 if target_ref_type in self.REF_TYPES:
737 737 target_commit = target_repo.get_commit(target_ref_name)
738 738 else:
739 739 target_commit = target_repo.get_commit(target_ref_id)
740 740 except CommitDoesNotExistError:
741 741 return UpdateResponse(
742 742 executed=False,
743 743 reason=UpdateFailureReason.MISSING_TARGET_REF,
744 744 old=pull_request, new=None, changes=None,
745 745 source_changed=source_changed, target_changed=target_changed)
746 746
747 747 # re-compute commit ids
748 748 old_commit_ids = pull_request.revisions
749 749 pre_load = ["author", "branch", "date", "message"]
750 750 commit_ranges = target_repo.compare(
751 751 target_commit.raw_id, source_commit.raw_id, source_repo, merge=True,
752 752 pre_load=pre_load)
753 753
754 754 ancestor = target_repo.get_common_ancestor(
755 755 target_commit.raw_id, source_commit.raw_id, source_repo)
756 756
757 757 pull_request.source_ref = '%s:%s:%s' % (
758 758 source_ref_type, source_ref_name, source_commit.raw_id)
759 759 pull_request.target_ref = '%s:%s:%s' % (
760 760 target_ref_type, target_ref_name, ancestor)
761 761
762 762 pull_request.revisions = [
763 763 commit.raw_id for commit in reversed(commit_ranges)]
764 764 pull_request.updated_on = datetime.datetime.now()
765 765 Session().add(pull_request)
766 766 new_commit_ids = pull_request.revisions
767 767
768 768 old_diff_data, new_diff_data = self._generate_update_diffs(
769 769 pull_request, pull_request_version)
770 770
771 771 # calculate commit and file changes
772 772 changes = self._calculate_commit_id_changes(
773 773 old_commit_ids, new_commit_ids)
774 774 file_changes = self._calculate_file_changes(
775 775 old_diff_data, new_diff_data)
776 776
777 777 # set comments as outdated if DIFFS changed
778 778 CommentsModel().outdate_comments(
779 779 pull_request, old_diff_data=old_diff_data,
780 780 new_diff_data=new_diff_data)
781 781
782 782 commit_changes = (changes.added or changes.removed)
783 783 file_node_changes = (
784 784 file_changes.added or file_changes.modified or file_changes.removed)
785 785 pr_has_changes = commit_changes or file_node_changes
786 786
787 787 # Add an automatic comment to the pull request, in case
788 788 # anything has changed
789 789 if pr_has_changes:
790 790 update_comment = CommentsModel().create(
791 791 text=self._render_update_message(changes, file_changes),
792 792 repo=pull_request.target_repo,
793 793 user=pull_request.author,
794 794 pull_request=pull_request,
795 795 send_email=False, renderer=DEFAULT_COMMENTS_RENDERER)
796 796
797 797 # Update status to "Under Review" for added commits
798 798 for commit_id in changes.added:
799 799 ChangesetStatusModel().set_status(
800 800 repo=pull_request.source_repo,
801 801 status=ChangesetStatus.STATUS_UNDER_REVIEW,
802 802 comment=update_comment,
803 803 user=pull_request.author,
804 804 pull_request=pull_request,
805 805 revision=commit_id)
806 806
807 807 log.debug(
808 808 'Updated pull request %s, added_ids: %s, common_ids: %s, '
809 809 'removed_ids: %s', pull_request.pull_request_id,
810 810 changes.added, changes.common, changes.removed)
811 811 log.debug(
812 812 'Updated pull request with the following file changes: %s',
813 813 file_changes)
814 814
815 815 log.info(
816 816 "Updated pull request %s from commit %s to commit %s, "
817 817 "stored new version %s of this pull request.",
818 818 pull_request.pull_request_id, source_ref_id,
819 819 pull_request.source_ref_parts.commit_id,
820 820 pull_request_version.pull_request_version_id)
821 821 Session().commit()
822 822 self.trigger_pull_request_hook(pull_request, pull_request.author, 'update')
823 823
824 824 return UpdateResponse(
825 825 executed=True, reason=UpdateFailureReason.NONE,
826 826 old=pull_request, new=pull_request_version, changes=changes,
827 827 source_changed=source_changed, target_changed=target_changed)
828 828
829 829 def _create_version_from_snapshot(self, pull_request):
830 830 version = PullRequestVersion()
831 831 version.title = pull_request.title
832 832 version.description = pull_request.description
833 833 version.status = pull_request.status
834 834 version.pull_request_state = pull_request.pull_request_state
835 835 version.created_on = datetime.datetime.now()
836 836 version.updated_on = pull_request.updated_on
837 837 version.user_id = pull_request.user_id
838 838 version.source_repo = pull_request.source_repo
839 839 version.source_ref = pull_request.source_ref
840 840 version.target_repo = pull_request.target_repo
841 841 version.target_ref = pull_request.target_ref
842 842
843 843 version._last_merge_source_rev = pull_request._last_merge_source_rev
844 844 version._last_merge_target_rev = pull_request._last_merge_target_rev
845 845 version.last_merge_status = pull_request.last_merge_status
846 846 version.shadow_merge_ref = pull_request.shadow_merge_ref
847 847 version.merge_rev = pull_request.merge_rev
848 848 version.reviewer_data = pull_request.reviewer_data
849 849
850 850 version.revisions = pull_request.revisions
851 851 version.pull_request = pull_request
852 852 Session().add(version)
853 853 Session().flush()
854 854
855 855 return version
856 856
857 857 def _generate_update_diffs(self, pull_request, pull_request_version):
858 858
859 859 diff_context = (
860 860 self.DIFF_CONTEXT +
861 861 CommentsModel.needed_extra_diff_context())
862 862 hide_whitespace_changes = False
863 863 source_repo = pull_request_version.source_repo
864 864 source_ref_id = pull_request_version.source_ref_parts.commit_id
865 865 target_ref_id = pull_request_version.target_ref_parts.commit_id
866 866 old_diff = self._get_diff_from_pr_or_version(
867 867 source_repo, source_ref_id, target_ref_id,
868 868 hide_whitespace_changes=hide_whitespace_changes, diff_context=diff_context)
869 869
870 870 source_repo = pull_request.source_repo
871 871 source_ref_id = pull_request.source_ref_parts.commit_id
872 872 target_ref_id = pull_request.target_ref_parts.commit_id
873 873
874 874 new_diff = self._get_diff_from_pr_or_version(
875 875 source_repo, source_ref_id, target_ref_id,
876 876 hide_whitespace_changes=hide_whitespace_changes, diff_context=diff_context)
877 877
878 878 old_diff_data = diffs.DiffProcessor(old_diff)
879 879 old_diff_data.prepare()
880 880 new_diff_data = diffs.DiffProcessor(new_diff)
881 881 new_diff_data.prepare()
882 882
883 883 return old_diff_data, new_diff_data
884 884
885 885 def _link_comments_to_version(self, pull_request_version):
886 886 """
887 887 Link all unlinked comments of this pull request to the given version.
888 888
889 889 :param pull_request_version: The `PullRequestVersion` to which
890 890 the comments shall be linked.
891 891
892 892 """
893 893 pull_request = pull_request_version.pull_request
894 894 comments = ChangesetComment.query()\
895 895 .filter(
896 896 # TODO: johbo: Should we query for the repo at all here?
897 897 # Pending decision on how comments of PRs are to be related
898 898 # to either the source repo, the target repo or no repo at all.
899 899 ChangesetComment.repo_id == pull_request.target_repo.repo_id,
900 900 ChangesetComment.pull_request == pull_request,
901 901 ChangesetComment.pull_request_version == None)\
902 902 .order_by(ChangesetComment.comment_id.asc())
903 903
904 904 # TODO: johbo: Find out why this breaks if it is done in a bulk
905 905 # operation.
906 906 for comment in comments:
907 907 comment.pull_request_version_id = (
908 908 pull_request_version.pull_request_version_id)
909 909 Session().add(comment)
910 910
911 911 def _calculate_commit_id_changes(self, old_ids, new_ids):
912 912 added = [x for x in new_ids if x not in old_ids]
913 913 common = [x for x in new_ids if x in old_ids]
914 914 removed = [x for x in old_ids if x not in new_ids]
915 915 total = new_ids
916 916 return ChangeTuple(added, common, removed, total)
917 917
918 918 def _calculate_file_changes(self, old_diff_data, new_diff_data):
919 919
920 920 old_files = OrderedDict()
921 921 for diff_data in old_diff_data.parsed_diff:
922 922 old_files[diff_data['filename']] = md5_safe(diff_data['raw_diff'])
923 923
924 924 added_files = []
925 925 modified_files = []
926 926 removed_files = []
927 927 for diff_data in new_diff_data.parsed_diff:
928 928 new_filename = diff_data['filename']
929 929 new_hash = md5_safe(diff_data['raw_diff'])
930 930
931 931 old_hash = old_files.get(new_filename)
932 932 if not old_hash:
933 933 # file is not present in old diff, means it's added
934 934 added_files.append(new_filename)
935 935 else:
936 936 if new_hash != old_hash:
937 937 modified_files.append(new_filename)
938 938 # now remove a file from old, since we have seen it already
939 939 del old_files[new_filename]
940 940
941 941 # removed files is when there are present in old, but not in NEW,
942 942 # since we remove old files that are present in new diff, left-overs
943 943 # if any should be the removed files
944 944 removed_files.extend(old_files.keys())
945 945
946 946 return FileChangeTuple(added_files, modified_files, removed_files)
947 947
948 948 def _render_update_message(self, changes, file_changes):
949 949 """
950 950 render the message using DEFAULT_COMMENTS_RENDERER (RST renderer),
951 951 so it's always looking the same disregarding on which default
952 952 renderer system is using.
953 953
954 954 :param changes: changes named tuple
955 955 :param file_changes: file changes named tuple
956 956
957 957 """
958 958 new_status = ChangesetStatus.get_status_lbl(
959 959 ChangesetStatus.STATUS_UNDER_REVIEW)
960 960
961 961 changed_files = (
962 962 file_changes.added + file_changes.modified + file_changes.removed)
963 963
964 964 params = {
965 965 'under_review_label': new_status,
966 966 'added_commits': changes.added,
967 967 'removed_commits': changes.removed,
968 968 'changed_files': changed_files,
969 969 'added_files': file_changes.added,
970 970 'modified_files': file_changes.modified,
971 971 'removed_files': file_changes.removed,
972 972 }
973 973 renderer = RstTemplateRenderer()
974 974 return renderer.render('pull_request_update.mako', **params)
975 975
976 976 def edit(self, pull_request, title, description, description_renderer, user):
977 977 pull_request = self.__get_pull_request(pull_request)
978 978 old_data = pull_request.get_api_data(with_merge_state=False)
979 979 if pull_request.is_closed():
980 980 raise ValueError('This pull request is closed')
981 981 if title:
982 982 pull_request.title = title
983 983 pull_request.description = description
984 984 pull_request.updated_on = datetime.datetime.now()
985 985 pull_request.description_renderer = description_renderer
986 986 Session().add(pull_request)
987 987 self._log_audit_action(
988 988 'repo.pull_request.edit', {'old_data': old_data},
989 989 user, pull_request)
990 990
991 991 def update_reviewers(self, pull_request, reviewer_data, user):
992 992 """
993 993 Update the reviewers in the pull request
994 994
995 995 :param pull_request: the pr to update
996 996 :param reviewer_data: list of tuples
997 997 [(user, ['reason1', 'reason2'], mandatory_flag, [rules])]
998 998 """
999 999 pull_request = self.__get_pull_request(pull_request)
1000 1000 if pull_request.is_closed():
1001 1001 raise ValueError('This pull request is closed')
1002 1002
1003 1003 reviewers = {}
1004 1004 for user_id, reasons, mandatory, rules in reviewer_data:
1005 1005 if isinstance(user_id, (int, compat.string_types)):
1006 1006 user_id = self._get_user(user_id).user_id
1007 1007 reviewers[user_id] = {
1008 1008 'reasons': reasons, 'mandatory': mandatory}
1009 1009
1010 1010 reviewers_ids = set(reviewers.keys())
1011 1011 current_reviewers = PullRequestReviewers.query()\
1012 1012 .filter(PullRequestReviewers.pull_request ==
1013 1013 pull_request).all()
1014 1014 current_reviewers_ids = set([x.user.user_id for x in current_reviewers])
1015 1015
1016 1016 ids_to_add = reviewers_ids.difference(current_reviewers_ids)
1017 1017 ids_to_remove = current_reviewers_ids.difference(reviewers_ids)
1018 1018
1019 1019 log.debug("Adding %s reviewers", ids_to_add)
1020 1020 log.debug("Removing %s reviewers", ids_to_remove)
1021 1021 changed = False
1022 1022 for uid in ids_to_add:
1023 1023 changed = True
1024 1024 _usr = self._get_user(uid)
1025 1025 reviewer = PullRequestReviewers()
1026 1026 reviewer.user = _usr
1027 1027 reviewer.pull_request = pull_request
1028 1028 reviewer.reasons = reviewers[uid]['reasons']
1029 1029 # NOTE(marcink): mandatory shouldn't be changed now
1030 1030 # reviewer.mandatory = reviewers[uid]['reasons']
1031 1031 Session().add(reviewer)
1032 1032 self._log_audit_action(
1033 1033 'repo.pull_request.reviewer.add', {'data': reviewer.get_dict()},
1034 1034 user, pull_request)
1035 1035
1036 1036 for uid in ids_to_remove:
1037 1037 changed = True
1038 1038 reviewers = PullRequestReviewers.query()\
1039 1039 .filter(PullRequestReviewers.user_id == uid,
1040 1040 PullRequestReviewers.pull_request == pull_request)\
1041 1041 .all()
1042 1042 # use .all() in case we accidentally added the same person twice
1043 1043 # this CAN happen due to the lack of DB checks
1044 1044 for obj in reviewers:
1045 1045 old_data = obj.get_dict()
1046 1046 Session().delete(obj)
1047 1047 self._log_audit_action(
1048 1048 'repo.pull_request.reviewer.delete',
1049 1049 {'old_data': old_data}, user, pull_request)
1050 1050
1051 1051 if changed:
1052 1052 pull_request.updated_on = datetime.datetime.now()
1053 1053 Session().add(pull_request)
1054 1054
1055 1055 self.notify_reviewers(pull_request, ids_to_add)
1056 1056 return ids_to_add, ids_to_remove
1057 1057
1058 1058 def get_url(self, pull_request, request=None, permalink=False):
1059 1059 if not request:
1060 1060 request = get_current_request()
1061 1061
1062 1062 if permalink:
1063 1063 return request.route_url(
1064 1064 'pull_requests_global',
1065 1065 pull_request_id=pull_request.pull_request_id,)
1066 1066 else:
1067 1067 return request.route_url('pullrequest_show',
1068 1068 repo_name=safe_str(pull_request.target_repo.repo_name),
1069 1069 pull_request_id=pull_request.pull_request_id,)
1070 1070
1071 1071 def get_shadow_clone_url(self, pull_request, request=None):
1072 1072 """
1073 1073 Returns qualified url pointing to the shadow repository. If this pull
1074 1074 request is closed there is no shadow repository and ``None`` will be
1075 1075 returned.
1076 1076 """
1077 1077 if pull_request.is_closed():
1078 1078 return None
1079 1079 else:
1080 1080 pr_url = urllib.unquote(self.get_url(pull_request, request=request))
1081 1081 return safe_unicode('{pr_url}/repository'.format(pr_url=pr_url))
1082 1082
1083 1083 def notify_reviewers(self, pull_request, reviewers_ids):
1084 1084 # notification to reviewers
1085 1085 if not reviewers_ids:
1086 1086 return
1087 1087
1088 1088 pull_request_obj = pull_request
1089 1089 # get the current participants of this pull request
1090 1090 recipients = reviewers_ids
1091 1091 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST
1092 1092
1093 1093 pr_source_repo = pull_request_obj.source_repo
1094 1094 pr_target_repo = pull_request_obj.target_repo
1095 1095
1096 1096 pr_url = h.route_url('pullrequest_show',
1097 1097 repo_name=pr_target_repo.repo_name,
1098 1098 pull_request_id=pull_request_obj.pull_request_id,)
1099 1099
1100 1100 # set some variables for email notification
1101 1101 pr_target_repo_url = h.route_url(
1102 1102 'repo_summary', repo_name=pr_target_repo.repo_name)
1103 1103
1104 1104 pr_source_repo_url = h.route_url(
1105 1105 'repo_summary', repo_name=pr_source_repo.repo_name)
1106 1106
1107 1107 # pull request specifics
1108 1108 pull_request_commits = [
1109 1109 (x.raw_id, x.message)
1110 1110 for x in map(pr_source_repo.get_commit, pull_request.revisions)]
1111 1111
1112 1112 kwargs = {
1113 1113 'user': pull_request.author,
1114 1114 'pull_request': pull_request_obj,
1115 1115 'pull_request_commits': pull_request_commits,
1116 1116
1117 1117 'pull_request_target_repo': pr_target_repo,
1118 1118 'pull_request_target_repo_url': pr_target_repo_url,
1119 1119
1120 1120 'pull_request_source_repo': pr_source_repo,
1121 1121 'pull_request_source_repo_url': pr_source_repo_url,
1122 1122
1123 1123 'pull_request_url': pr_url,
1124 1124 }
1125 1125
1126 1126 # pre-generate the subject for notification itself
1127 1127 (subject,
1128 1128 _h, _e, # we don't care about those
1129 1129 body_plaintext) = EmailNotificationModel().render_email(
1130 1130 notification_type, **kwargs)
1131 1131
1132 1132 # create notification objects, and emails
1133 1133 NotificationModel().create(
1134 1134 created_by=pull_request.author,
1135 1135 notification_subject=subject,
1136 1136 notification_body=body_plaintext,
1137 1137 notification_type=notification_type,
1138 1138 recipients=recipients,
1139 1139 email_kwargs=kwargs,
1140 1140 )
1141 1141
1142 1142 def delete(self, pull_request, user):
1143 1143 pull_request = self.__get_pull_request(pull_request)
1144 1144 old_data = pull_request.get_api_data(with_merge_state=False)
1145 1145 self._cleanup_merge_workspace(pull_request)
1146 1146 self._log_audit_action(
1147 1147 'repo.pull_request.delete', {'old_data': old_data},
1148 1148 user, pull_request)
1149 1149 Session().delete(pull_request)
1150 1150
1151 1151 def close_pull_request(self, pull_request, user):
1152 1152 pull_request = self.__get_pull_request(pull_request)
1153 1153 self._cleanup_merge_workspace(pull_request)
1154 1154 pull_request.status = PullRequest.STATUS_CLOSED
1155 1155 pull_request.updated_on = datetime.datetime.now()
1156 1156 Session().add(pull_request)
1157 1157 self.trigger_pull_request_hook(
1158 1158 pull_request, pull_request.author, 'close')
1159 1159
1160 1160 pr_data = pull_request.get_api_data(with_merge_state=False)
1161 1161 self._log_audit_action(
1162 1162 'repo.pull_request.close', {'data': pr_data}, user, pull_request)
1163 1163
1164 1164 def close_pull_request_with_comment(
1165 1165 self, pull_request, user, repo, message=None, auth_user=None):
1166 1166
1167 1167 pull_request_review_status = pull_request.calculated_review_status()
1168 1168
1169 1169 if pull_request_review_status == ChangesetStatus.STATUS_APPROVED:
1170 1170 # approved only if we have voting consent
1171 1171 status = ChangesetStatus.STATUS_APPROVED
1172 1172 else:
1173 1173 status = ChangesetStatus.STATUS_REJECTED
1174 1174 status_lbl = ChangesetStatus.get_status_lbl(status)
1175 1175
1176 1176 default_message = (
1177 1177 'Closing with status change {transition_icon} {status}.'
1178 1178 ).format(transition_icon='>', status=status_lbl)
1179 1179 text = message or default_message
1180 1180
1181 1181 # create a comment, and link it to new status
1182 1182 comment = CommentsModel().create(
1183 1183 text=text,
1184 1184 repo=repo.repo_id,
1185 1185 user=user.user_id,
1186 1186 pull_request=pull_request.pull_request_id,
1187 1187 status_change=status_lbl,
1188 1188 status_change_type=status,
1189 1189 closing_pr=True,
1190 1190 auth_user=auth_user,
1191 1191 )
1192 1192
1193 1193 # calculate old status before we change it
1194 1194 old_calculated_status = pull_request.calculated_review_status()
1195 1195 ChangesetStatusModel().set_status(
1196 1196 repo.repo_id,
1197 1197 status,
1198 1198 user.user_id,
1199 1199 comment=comment,
1200 1200 pull_request=pull_request.pull_request_id
1201 1201 )
1202 1202
1203 1203 Session().flush()
1204 1204 events.trigger(events.PullRequestCommentEvent(pull_request, comment))
1205 1205 # we now calculate the status of pull request again, and based on that
1206 1206 # calculation trigger status change. This might happen in cases
1207 1207 # that non-reviewer admin closes a pr, which means his vote doesn't
1208 1208 # change the status, while if he's a reviewer this might change it.
1209 1209 calculated_status = pull_request.calculated_review_status()
1210 1210 if old_calculated_status != calculated_status:
1211 1211 self.trigger_pull_request_hook(
1212 1212 pull_request, user, 'review_status_change',
1213 1213 data={'status': calculated_status})
1214 1214
1215 1215 # finally close the PR
1216 1216 PullRequestModel().close_pull_request(
1217 1217 pull_request.pull_request_id, user)
1218 1218
1219 1219 return comment, status
1220 1220
1221 1221 def merge_status(self, pull_request, translator=None,
1222 1222 force_shadow_repo_refresh=False):
1223 1223 _ = translator or get_current_request().translate
1224 1224
1225 1225 if not self._is_merge_enabled(pull_request):
1226 1226 return False, _('Server-side pull request merging is disabled.')
1227 1227 if pull_request.is_closed():
1228 1228 return False, _('This pull request is closed.')
1229 1229 merge_possible, msg = self._check_repo_requirements(
1230 1230 target=pull_request.target_repo, source=pull_request.source_repo,
1231 1231 translator=_)
1232 1232 if not merge_possible:
1233 1233 return merge_possible, msg
1234 1234
1235 1235 try:
1236 1236 resp = self._try_merge(
1237 1237 pull_request,
1238 1238 force_shadow_repo_refresh=force_shadow_repo_refresh)
1239 1239 log.debug("Merge response: %s", resp)
1240 1240 status = resp.possible, resp.merge_status_message
1241 1241 except NotImplementedError:
1242 1242 status = False, _('Pull request merging is not supported.')
1243 1243
1244 1244 return status
1245 1245
1246 1246 def _check_repo_requirements(self, target, source, translator):
1247 1247 """
1248 1248 Check if `target` and `source` have compatible requirements.
1249 1249
1250 1250 Currently this is just checking for largefiles.
1251 1251 """
1252 1252 _ = translator
1253 1253 target_has_largefiles = self._has_largefiles(target)
1254 1254 source_has_largefiles = self._has_largefiles(source)
1255 1255 merge_possible = True
1256 1256 message = u''
1257 1257
1258 1258 if target_has_largefiles != source_has_largefiles:
1259 1259 merge_possible = False
1260 1260 if source_has_largefiles:
1261 1261 message = _(
1262 1262 'Target repository large files support is disabled.')
1263 1263 else:
1264 1264 message = _(
1265 1265 'Source repository large files support is disabled.')
1266 1266
1267 1267 return merge_possible, message
1268 1268
1269 1269 def _has_largefiles(self, repo):
1270 1270 largefiles_ui = VcsSettingsModel(repo=repo).get_ui_settings(
1271 1271 'extensions', 'largefiles')
1272 1272 return largefiles_ui and largefiles_ui[0].active
1273 1273
1274 1274 def _try_merge(self, pull_request, force_shadow_repo_refresh=False):
1275 1275 """
1276 1276 Try to merge the pull request and return the merge status.
1277 1277 """
1278 1278 log.debug(
1279 1279 "Trying out if the pull request %s can be merged. Force_refresh=%s",
1280 1280 pull_request.pull_request_id, force_shadow_repo_refresh)
1281 1281 target_vcs = pull_request.target_repo.scm_instance()
1282 1282 # Refresh the target reference.
1283 1283 try:
1284 1284 target_ref = self._refresh_reference(
1285 1285 pull_request.target_ref_parts, target_vcs)
1286 1286 except CommitDoesNotExistError:
1287 1287 merge_state = MergeResponse(
1288 1288 False, False, None, MergeFailureReason.MISSING_TARGET_REF,
1289 1289 metadata={'target_ref': pull_request.target_ref_parts})
1290 1290 return merge_state
1291 1291
1292 1292 target_locked = pull_request.target_repo.locked
1293 1293 if target_locked and target_locked[0]:
1294 1294 locked_by = 'user:{}'.format(target_locked[0])
1295 1295 log.debug("The target repository is locked by %s.", locked_by)
1296 1296 merge_state = MergeResponse(
1297 1297 False, False, None, MergeFailureReason.TARGET_IS_LOCKED,
1298 1298 metadata={'locked_by': locked_by})
1299 1299 elif force_shadow_repo_refresh or self._needs_merge_state_refresh(
1300 1300 pull_request, target_ref):
1301 1301 log.debug("Refreshing the merge status of the repository.")
1302 1302 merge_state = self._refresh_merge_state(
1303 1303 pull_request, target_vcs, target_ref)
1304 1304 else:
1305 1305 possible = pull_request.\
1306 1306 last_merge_status == MergeFailureReason.NONE
1307 1307 merge_state = MergeResponse(
1308 1308 possible, False, None, pull_request.last_merge_status)
1309 1309
1310 1310 return merge_state
1311 1311
1312 1312 def _refresh_reference(self, reference, vcs_repository):
1313 1313 if reference.type in self.UPDATABLE_REF_TYPES:
1314 1314 name_or_id = reference.name
1315 1315 else:
1316 1316 name_or_id = reference.commit_id
1317 1317 refreshed_commit = vcs_repository.get_commit(name_or_id)
1318 1318 refreshed_reference = Reference(
1319 1319 reference.type, reference.name, refreshed_commit.raw_id)
1320 1320 return refreshed_reference
1321 1321
1322 1322 def _needs_merge_state_refresh(self, pull_request, target_reference):
1323 1323 return not(
1324 1324 pull_request.revisions and
1325 1325 pull_request.revisions[0] == pull_request._last_merge_source_rev and
1326 1326 target_reference.commit_id == pull_request._last_merge_target_rev)
1327 1327
1328 1328 def _refresh_merge_state(self, pull_request, target_vcs, target_reference):
1329 1329 workspace_id = self._workspace_id(pull_request)
1330 1330 source_vcs = pull_request.source_repo.scm_instance()
1331 1331 repo_id = pull_request.target_repo.repo_id
1332 1332 use_rebase = self._use_rebase_for_merging(pull_request)
1333 1333 close_branch = self._close_branch_before_merging(pull_request)
1334 1334 merge_state = target_vcs.merge(
1335 1335 repo_id, workspace_id,
1336 1336 target_reference, source_vcs, pull_request.source_ref_parts,
1337 1337 dry_run=True, use_rebase=use_rebase,
1338 1338 close_branch=close_branch)
1339 1339
1340 1340 # Do not store the response if there was an unknown error.
1341 1341 if merge_state.failure_reason != MergeFailureReason.UNKNOWN:
1342 1342 pull_request._last_merge_source_rev = \
1343 1343 pull_request.source_ref_parts.commit_id
1344 1344 pull_request._last_merge_target_rev = target_reference.commit_id
1345 1345 pull_request.last_merge_status = merge_state.failure_reason
1346 1346 pull_request.shadow_merge_ref = merge_state.merge_ref
1347 1347 Session().add(pull_request)
1348 1348 Session().commit()
1349 1349
1350 1350 return merge_state
1351 1351
1352 1352 def _workspace_id(self, pull_request):
1353 1353 workspace_id = 'pr-%s' % pull_request.pull_request_id
1354 1354 return workspace_id
1355 1355
1356 1356 def generate_repo_data(self, repo, commit_id=None, branch=None,
1357 1357 bookmark=None, translator=None):
1358 1358 from rhodecode.model.repo import RepoModel
1359 1359
1360 1360 all_refs, selected_ref = \
1361 1361 self._get_repo_pullrequest_sources(
1362 1362 repo.scm_instance(), commit_id=commit_id,
1363 1363 branch=branch, bookmark=bookmark, translator=translator)
1364 1364
1365 1365 refs_select2 = []
1366 1366 for element in all_refs:
1367 1367 children = [{'id': x[0], 'text': x[1]} for x in element[0]]
1368 1368 refs_select2.append({'text': element[1], 'children': children})
1369 1369
1370 1370 return {
1371 1371 'user': {
1372 1372 'user_id': repo.user.user_id,
1373 1373 'username': repo.user.username,
1374 1374 'firstname': repo.user.first_name,
1375 1375 'lastname': repo.user.last_name,
1376 1376 'gravatar_link': h.gravatar_url(repo.user.email, 14),
1377 1377 },
1378 1378 'name': repo.repo_name,
1379 1379 'link': RepoModel().get_url(repo),
1380 1380 'description': h.chop_at_smart(repo.description_safe, '\n'),
1381 1381 'refs': {
1382 1382 'all_refs': all_refs,
1383 1383 'selected_ref': selected_ref,
1384 1384 'select2_refs': refs_select2
1385 1385 }
1386 1386 }
1387 1387
1388 1388 def generate_pullrequest_title(self, source, source_ref, target):
1389 1389 return u'{source}#{at_ref} to {target}'.format(
1390 1390 source=source,
1391 1391 at_ref=source_ref,
1392 1392 target=target,
1393 1393 )
1394 1394
1395 1395 def _cleanup_merge_workspace(self, pull_request):
1396 1396 # Merging related cleanup
1397 1397 repo_id = pull_request.target_repo.repo_id
1398 1398 target_scm = pull_request.target_repo.scm_instance()
1399 1399 workspace_id = self._workspace_id(pull_request)
1400 1400
1401 1401 try:
1402 1402 target_scm.cleanup_merge_workspace(repo_id, workspace_id)
1403 1403 except NotImplementedError:
1404 1404 pass
1405 1405
1406 1406 def _get_repo_pullrequest_sources(
1407 1407 self, repo, commit_id=None, branch=None, bookmark=None,
1408 1408 translator=None):
1409 1409 """
1410 1410 Return a structure with repo's interesting commits, suitable for
1411 1411 the selectors in pullrequest controller
1412 1412
1413 1413 :param commit_id: a commit that must be in the list somehow
1414 1414 and selected by default
1415 1415 :param branch: a branch that must be in the list and selected
1416 1416 by default - even if closed
1417 1417 :param bookmark: a bookmark that must be in the list and selected
1418 1418 """
1419 1419 _ = translator or get_current_request().translate
1420 1420
1421 1421 commit_id = safe_str(commit_id) if commit_id else None
1422 1422 branch = safe_str(branch) if branch else None
1423 1423 bookmark = safe_str(bookmark) if bookmark else None
1424 1424
1425 1425 selected = None
1426 1426
1427 1427 # order matters: first source that has commit_id in it will be selected
1428 1428 sources = []
1429 1429 sources.append(('book', repo.bookmarks.items(), _('Bookmarks'), bookmark))
1430 1430 sources.append(('branch', repo.branches.items(), _('Branches'), branch))
1431 1431
1432 1432 if commit_id:
1433 1433 ref_commit = (h.short_id(commit_id), commit_id)
1434 1434 sources.append(('rev', [ref_commit], _('Commit IDs'), commit_id))
1435 1435
1436 1436 sources.append(
1437 1437 ('branch', repo.branches_closed.items(), _('Closed Branches'), branch),
1438 1438 )
1439 1439
1440 1440 groups = []
1441 1441 for group_key, ref_list, group_name, match in sources:
1442 1442 group_refs = []
1443 1443 for ref_name, ref_id in ref_list:
1444 1444 ref_key = '%s:%s:%s' % (group_key, ref_name, ref_id)
1445 1445 group_refs.append((ref_key, ref_name))
1446 1446
1447 1447 if not selected:
1448 1448 if set([commit_id, match]) & set([ref_id, ref_name]):
1449 1449 selected = ref_key
1450 1450
1451 1451 if group_refs:
1452 1452 groups.append((group_refs, group_name))
1453 1453
1454 1454 if not selected:
1455 1455 ref = commit_id or branch or bookmark
1456 1456 if ref:
1457 1457 raise CommitDoesNotExistError(
1458 1458 'No commit refs could be found matching: %s' % ref)
1459 1459 elif repo.DEFAULT_BRANCH_NAME in repo.branches:
1460 1460 selected = 'branch:%s:%s' % (
1461 1461 repo.DEFAULT_BRANCH_NAME,
1462 1462 repo.branches[repo.DEFAULT_BRANCH_NAME]
1463 1463 )
1464 1464 elif repo.commit_ids:
1465 1465 # make the user select in this case
1466 1466 selected = None
1467 1467 else:
1468 1468 raise EmptyRepositoryError()
1469 1469 return groups, selected
1470 1470
1471 1471 def get_diff(self, source_repo, source_ref_id, target_ref_id,
1472 1472 hide_whitespace_changes, diff_context):
1473 1473
1474 1474 return self._get_diff_from_pr_or_version(
1475 1475 source_repo, source_ref_id, target_ref_id,
1476 1476 hide_whitespace_changes=hide_whitespace_changes, diff_context=diff_context)
1477 1477
1478 1478 def _get_diff_from_pr_or_version(
1479 1479 self, source_repo, source_ref_id, target_ref_id,
1480 1480 hide_whitespace_changes, diff_context):
1481 1481
1482 1482 target_commit = source_repo.get_commit(
1483 1483 commit_id=safe_str(target_ref_id))
1484 1484 source_commit = source_repo.get_commit(
1485 1485 commit_id=safe_str(source_ref_id))
1486 1486 if isinstance(source_repo, Repository):
1487 1487 vcs_repo = source_repo.scm_instance()
1488 1488 else:
1489 1489 vcs_repo = source_repo
1490 1490
1491 1491 # TODO: johbo: In the context of an update, we cannot reach
1492 1492 # the old commit anymore with our normal mechanisms. It needs
1493 1493 # some sort of special support in the vcs layer to avoid this
1494 1494 # workaround.
1495 1495 if (source_commit.raw_id == vcs_repo.EMPTY_COMMIT_ID and
1496 1496 vcs_repo.alias == 'git'):
1497 1497 source_commit.raw_id = safe_str(source_ref_id)
1498 1498
1499 1499 log.debug('calculating diff between '
1500 1500 'source_ref:%s and target_ref:%s for repo `%s`',
1501 1501 target_ref_id, source_ref_id,
1502 1502 safe_unicode(vcs_repo.path))
1503 1503
1504 1504 vcs_diff = vcs_repo.get_diff(
1505 1505 commit1=target_commit, commit2=source_commit,
1506 1506 ignore_whitespace=hide_whitespace_changes, context=diff_context)
1507 1507 return vcs_diff
1508 1508
1509 1509 def _is_merge_enabled(self, pull_request):
1510 1510 return self._get_general_setting(
1511 1511 pull_request, 'rhodecode_pr_merge_enabled')
1512 1512
1513 1513 def _use_rebase_for_merging(self, pull_request):
1514 1514 repo_type = pull_request.target_repo.repo_type
1515 1515 if repo_type == 'hg':
1516 1516 return self._get_general_setting(
1517 1517 pull_request, 'rhodecode_hg_use_rebase_for_merging')
1518 1518 elif repo_type == 'git':
1519 1519 return self._get_general_setting(
1520 1520 pull_request, 'rhodecode_git_use_rebase_for_merging')
1521 1521
1522 1522 return False
1523 1523
1524 1524 def _close_branch_before_merging(self, pull_request):
1525 1525 repo_type = pull_request.target_repo.repo_type
1526 1526 if repo_type == 'hg':
1527 1527 return self._get_general_setting(
1528 1528 pull_request, 'rhodecode_hg_close_branch_before_merging')
1529 1529 elif repo_type == 'git':
1530 1530 return self._get_general_setting(
1531 1531 pull_request, 'rhodecode_git_close_branch_before_merging')
1532 1532
1533 1533 return False
1534 1534
1535 1535 def _get_general_setting(self, pull_request, settings_key, default=False):
1536 1536 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1537 1537 settings = settings_model.get_general_settings()
1538 1538 return settings.get(settings_key, default)
1539 1539
1540 1540 def _log_audit_action(self, action, action_data, user, pull_request):
1541 1541 audit_logger.store(
1542 1542 action=action,
1543 1543 action_data=action_data,
1544 1544 user=user,
1545 1545 repo=pull_request.target_repo)
1546 1546
1547 1547 def get_reviewer_functions(self):
1548 1548 """
1549 1549 Fetches functions for validation and fetching default reviewers.
1550 1550 If available we use the EE package, else we fallback to CE
1551 1551 package functions
1552 1552 """
1553 1553 try:
1554 1554 from rc_reviewers.utils import get_default_reviewers_data
1555 1555 from rc_reviewers.utils import validate_default_reviewers
1556 1556 except ImportError:
1557 1557 from rhodecode.apps.repository.utils import get_default_reviewers_data
1558 1558 from rhodecode.apps.repository.utils import validate_default_reviewers
1559 1559
1560 1560 return get_default_reviewers_data, validate_default_reviewers
1561 1561
1562 1562
1563 1563 class MergeCheck(object):
1564 1564 """
1565 1565 Perform Merge Checks and returns a check object which stores information
1566 1566 about merge errors, and merge conditions
1567 1567 """
1568 1568 TODO_CHECK = 'todo'
1569 1569 PERM_CHECK = 'perm'
1570 1570 REVIEW_CHECK = 'review'
1571 1571 MERGE_CHECK = 'merge'
1572 1572
1573 1573 def __init__(self):
1574 1574 self.review_status = None
1575 1575 self.merge_possible = None
1576 1576 self.merge_msg = ''
1577 1577 self.failed = None
1578 1578 self.errors = []
1579 1579 self.error_details = OrderedDict()
1580 1580
1581 1581 def push_error(self, error_type, message, error_key, details):
1582 1582 self.failed = True
1583 1583 self.errors.append([error_type, message])
1584 1584 self.error_details[error_key] = dict(
1585 1585 details=details,
1586 1586 error_type=error_type,
1587 1587 message=message
1588 1588 )
1589 1589
1590 1590 @classmethod
1591 1591 def validate(cls, pull_request, auth_user, translator, fail_early=False,
1592 1592 force_shadow_repo_refresh=False):
1593 1593 _ = translator
1594 1594 merge_check = cls()
1595 1595
1596 1596 # permissions to merge
1597 1597 user_allowed_to_merge = PullRequestModel().check_user_merge(
1598 1598 pull_request, auth_user)
1599 1599 if not user_allowed_to_merge:
1600 1600 log.debug("MergeCheck: cannot merge, approval is pending.")
1601 1601
1602 1602 msg = _('User `{}` not allowed to perform merge.').format(auth_user.username)
1603 1603 merge_check.push_error('error', msg, cls.PERM_CHECK, auth_user.username)
1604 1604 if fail_early:
1605 1605 return merge_check
1606 1606
1607 1607 # permission to merge into the target branch
1608 1608 target_commit_id = pull_request.target_ref_parts.commit_id
1609 1609 if pull_request.target_ref_parts.type == 'branch':
1610 1610 branch_name = pull_request.target_ref_parts.name
1611 1611 else:
1612 1612 # for mercurial we can always figure out the branch from the commit
1613 1613 # in case of bookmark
1614 1614 target_commit = pull_request.target_repo.get_commit(target_commit_id)
1615 1615 branch_name = target_commit.branch
1616 1616
1617 1617 rule, branch_perm = auth_user.get_rule_and_branch_permission(
1618 1618 pull_request.target_repo.repo_name, branch_name)
1619 1619 if branch_perm and branch_perm == 'branch.none':
1620 1620 msg = _('Target branch `{}` changes rejected by rule {}.').format(
1621 1621 branch_name, rule)
1622 1622 merge_check.push_error('error', msg, cls.PERM_CHECK, auth_user.username)
1623 1623 if fail_early:
1624 1624 return merge_check
1625 1625
1626 1626 # review status, must be always present
1627 1627 review_status = pull_request.calculated_review_status()
1628 1628 merge_check.review_status = review_status
1629 1629
1630 1630 status_approved = review_status == ChangesetStatus.STATUS_APPROVED
1631 1631 if not status_approved:
1632 1632 log.debug("MergeCheck: cannot merge, approval is pending.")
1633 1633
1634 1634 msg = _('Pull request reviewer approval is pending.')
1635 1635
1636 1636 merge_check.push_error('warning', msg, cls.REVIEW_CHECK, review_status)
1637 1637
1638 1638 if fail_early:
1639 1639 return merge_check
1640 1640
1641 1641 # left over TODOs
1642 todos = CommentsModel().get_unresolved_todos(pull_request)
1642 todos = CommentsModel().get_pull_request_unresolved_todos(pull_request)
1643 1643 if todos:
1644 1644 log.debug("MergeCheck: cannot merge, {} "
1645 1645 "unresolved TODOs left.".format(len(todos)))
1646 1646
1647 1647 if len(todos) == 1:
1648 1648 msg = _('Cannot merge, {} TODO still not resolved.').format(
1649 1649 len(todos))
1650 1650 else:
1651 1651 msg = _('Cannot merge, {} TODOs still not resolved.').format(
1652 1652 len(todos))
1653 1653
1654 1654 merge_check.push_error('warning', msg, cls.TODO_CHECK, todos)
1655 1655
1656 1656 if fail_early:
1657 1657 return merge_check
1658 1658
1659 1659 # merge possible, here is the filesystem simulation + shadow repo
1660 1660 merge_status, msg = PullRequestModel().merge_status(
1661 1661 pull_request, translator=translator,
1662 1662 force_shadow_repo_refresh=force_shadow_repo_refresh)
1663 1663 merge_check.merge_possible = merge_status
1664 1664 merge_check.merge_msg = msg
1665 1665 if not merge_status:
1666 1666 log.debug("MergeCheck: cannot merge, pull request merge not possible.")
1667 1667 merge_check.push_error('warning', msg, cls.MERGE_CHECK, None)
1668 1668
1669 1669 if fail_early:
1670 1670 return merge_check
1671 1671
1672 1672 log.debug('MergeCheck: is failed: %s', merge_check.failed)
1673 1673 return merge_check
1674 1674
1675 1675 @classmethod
1676 1676 def get_merge_conditions(cls, pull_request, translator):
1677 1677 _ = translator
1678 1678 merge_details = {}
1679 1679
1680 1680 model = PullRequestModel()
1681 1681 use_rebase = model._use_rebase_for_merging(pull_request)
1682 1682
1683 1683 if use_rebase:
1684 1684 merge_details['merge_strategy'] = dict(
1685 1685 details={},
1686 1686 message=_('Merge strategy: rebase')
1687 1687 )
1688 1688 else:
1689 1689 merge_details['merge_strategy'] = dict(
1690 1690 details={},
1691 1691 message=_('Merge strategy: explicit merge commit')
1692 1692 )
1693 1693
1694 1694 close_branch = model._close_branch_before_merging(pull_request)
1695 1695 if close_branch:
1696 1696 repo_type = pull_request.target_repo.repo_type
1697 1697 close_msg = ''
1698 1698 if repo_type == 'hg':
1699 1699 close_msg = _('Source branch will be closed after merge.')
1700 1700 elif repo_type == 'git':
1701 1701 close_msg = _('Source branch will be deleted after merge.')
1702 1702
1703 1703 merge_details['close_branch'] = dict(
1704 1704 details={},
1705 1705 message=close_msg
1706 1706 )
1707 1707
1708 1708 return merge_details
1709 1709
1710 1710
1711 1711 ChangeTuple = collections.namedtuple(
1712 1712 'ChangeTuple', ['added', 'common', 'removed', 'total'])
1713 1713
1714 1714 FileChangeTuple = collections.namedtuple(
1715 1715 'FileChangeTuple', ['added', 'modified', 'removed'])
General Comments 0
You need to be logged in to leave comments. Login now