##// END OF EJS Templates
events: add an event for pull request comments with review status
dan -
r443:c2778156 default
parent child Browse files
Show More
@@ -1,849 +1,853 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2016 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 pull requests controller for rhodecode for initializing pull requests
23 23 """
24 24
25 25 import formencode
26 26 import logging
27 27
28 28 from webob.exc import HTTPNotFound, HTTPForbidden, HTTPBadRequest
29 29 from pylons import request, tmpl_context as c, url
30 30 from pylons.controllers.util import redirect
31 31 from pylons.i18n.translation import _
32 32 from sqlalchemy.sql import func
33 33 from sqlalchemy.sql.expression import or_
34 34
35 from rhodecode import events
35 36 from rhodecode.lib import auth, diffs, helpers as h
36 37 from rhodecode.lib.ext_json import json
37 38 from rhodecode.lib.base import (
38 39 BaseRepoController, render, vcs_operation_context)
39 40 from rhodecode.lib.auth import (
40 41 LoginRequired, HasRepoPermissionAnyDecorator, NotAnonymous,
41 42 HasAcceptedRepoType, XHRRequired)
42 43 from rhodecode.lib.utils import jsonify
43 44 from rhodecode.lib.utils2 import safe_int, safe_str, str2bool, safe_unicode
44 45 from rhodecode.lib.vcs.backends.base import EmptyCommit
45 46 from rhodecode.lib.vcs.exceptions import (
46 47 EmptyRepositoryError, CommitDoesNotExistError, RepositoryRequirementError)
47 48 from rhodecode.lib.diffs import LimitedDiffContainer
48 49 from rhodecode.model.changeset_status import ChangesetStatusModel
49 50 from rhodecode.model.comment import ChangesetCommentsModel
50 51 from rhodecode.model.db import PullRequest, ChangesetStatus, ChangesetComment, \
51 52 Repository
52 53 from rhodecode.model.forms import PullRequestForm
53 54 from rhodecode.model.meta import Session
54 55 from rhodecode.model.pull_request import PullRequestModel
55 56
56 57 log = logging.getLogger(__name__)
57 58
58 59
59 60 class PullrequestsController(BaseRepoController):
60 61 def __before__(self):
61 62 super(PullrequestsController, self).__before__()
62 63
63 64 def _load_compare_data(self, pull_request, enable_comments=True):
64 65 """
65 66 Load context data needed for generating compare diff
66 67
67 68 :param pull_request: object related to the request
68 69 :param enable_comments: flag to determine if comments are included
69 70 """
70 71 source_repo = pull_request.source_repo
71 72 source_ref_id = pull_request.source_ref_parts.commit_id
72 73
73 74 target_repo = pull_request.target_repo
74 75 target_ref_id = pull_request.target_ref_parts.commit_id
75 76
76 77 # despite opening commits for bookmarks/branches/tags, we always
77 78 # convert this to rev to prevent changes after bookmark or branch change
78 79 c.source_ref_type = 'rev'
79 80 c.source_ref = source_ref_id
80 81
81 82 c.target_ref_type = 'rev'
82 83 c.target_ref = target_ref_id
83 84
84 85 c.source_repo = source_repo
85 86 c.target_repo = target_repo
86 87
87 88 c.fulldiff = bool(request.GET.get('fulldiff'))
88 89
89 90 # diff_limit is the old behavior, will cut off the whole diff
90 91 # if the limit is applied otherwise will just hide the
91 92 # big files from the front-end
92 93 diff_limit = self.cut_off_limit_diff
93 94 file_limit = self.cut_off_limit_file
94 95
95 96 pre_load = ["author", "branch", "date", "message"]
96 97
97 98 c.commit_ranges = []
98 99 source_commit = EmptyCommit()
99 100 target_commit = EmptyCommit()
100 101 c.missing_requirements = False
101 102 try:
102 103 c.commit_ranges = [
103 104 source_repo.get_commit(commit_id=rev, pre_load=pre_load)
104 105 for rev in pull_request.revisions]
105 106
106 107 c.statuses = source_repo.statuses(
107 108 [x.raw_id for x in c.commit_ranges])
108 109
109 110 target_commit = source_repo.get_commit(
110 111 commit_id=safe_str(target_ref_id))
111 112 source_commit = source_repo.get_commit(
112 113 commit_id=safe_str(source_ref_id))
113 114 except RepositoryRequirementError:
114 115 c.missing_requirements = True
115 116
116 117 c.missing_commits = False
117 118 if (c.missing_requirements or
118 119 isinstance(source_commit, EmptyCommit) or
119 120 source_commit == target_commit):
120 121 _parsed = []
121 122 c.missing_commits = True
122 123 else:
123 124 vcs_diff = PullRequestModel().get_diff(pull_request)
124 125 diff_processor = diffs.DiffProcessor(
125 126 vcs_diff, format='gitdiff', diff_limit=diff_limit,
126 127 file_limit=file_limit, show_full_diff=c.fulldiff)
127 128 _parsed = diff_processor.prepare()
128 129
129 130 c.limited_diff = isinstance(_parsed, LimitedDiffContainer)
130 131
131 132 c.files = []
132 133 c.changes = {}
133 134 c.lines_added = 0
134 135 c.lines_deleted = 0
135 136 c.included_files = []
136 137 c.deleted_files = []
137 138
138 139 for f in _parsed:
139 140 st = f['stats']
140 141 c.lines_added += st['added']
141 142 c.lines_deleted += st['deleted']
142 143
143 144 fid = h.FID('', f['filename'])
144 145 c.files.append([fid, f['operation'], f['filename'], f['stats']])
145 146 c.included_files.append(f['filename'])
146 147 html_diff = diff_processor.as_html(enable_comments=enable_comments,
147 148 parsed_lines=[f])
148 149 c.changes[fid] = [f['operation'], f['filename'], html_diff, f]
149 150
150 151 def _extract_ordering(self, request):
151 152 column_index = safe_int(request.GET.get('order[0][column]'))
152 153 order_dir = request.GET.get('order[0][dir]', 'desc')
153 154 order_by = request.GET.get(
154 155 'columns[%s][data][sort]' % column_index, 'name_raw')
155 156 return order_by, order_dir
156 157
157 158 @LoginRequired()
158 159 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
159 160 'repository.admin')
160 161 @HasAcceptedRepoType('git', 'hg')
161 162 def show_all(self, repo_name):
162 163 # filter types
163 164 c.active = 'open'
164 165 c.source = str2bool(request.GET.get('source'))
165 166 c.closed = str2bool(request.GET.get('closed'))
166 167 c.my = str2bool(request.GET.get('my'))
167 168 c.awaiting_review = str2bool(request.GET.get('awaiting_review'))
168 169 c.awaiting_my_review = str2bool(request.GET.get('awaiting_my_review'))
169 170 c.repo_name = repo_name
170 171
171 172 opened_by = None
172 173 if c.my:
173 174 c.active = 'my'
174 175 opened_by = [c.rhodecode_user.user_id]
175 176
176 177 statuses = [PullRequest.STATUS_NEW, PullRequest.STATUS_OPEN]
177 178 if c.closed:
178 179 c.active = 'closed'
179 180 statuses = [PullRequest.STATUS_CLOSED]
180 181
181 182 if c.awaiting_review and not c.source:
182 183 c.active = 'awaiting'
183 184 if c.source and not c.awaiting_review:
184 185 c.active = 'source'
185 186 if c.awaiting_my_review:
186 187 c.active = 'awaiting_my'
187 188
188 189 data = self._get_pull_requests_list(
189 190 repo_name=repo_name, opened_by=opened_by, statuses=statuses)
190 191 if not request.is_xhr:
191 192 c.data = json.dumps(data['data'])
192 193 c.records_total = data['recordsTotal']
193 194 return render('/pullrequests/pullrequests.html')
194 195 else:
195 196 return json.dumps(data)
196 197
197 198 def _get_pull_requests_list(self, repo_name, opened_by, statuses):
198 199 # pagination
199 200 start = safe_int(request.GET.get('start'), 0)
200 201 length = safe_int(request.GET.get('length'), c.visual.dashboard_items)
201 202 order_by, order_dir = self._extract_ordering(request)
202 203
203 204 if c.awaiting_review:
204 205 pull_requests = PullRequestModel().get_awaiting_review(
205 206 repo_name, source=c.source, opened_by=opened_by,
206 207 statuses=statuses, offset=start, length=length,
207 208 order_by=order_by, order_dir=order_dir)
208 209 pull_requests_total_count = PullRequestModel(
209 210 ).count_awaiting_review(
210 211 repo_name, source=c.source, statuses=statuses,
211 212 opened_by=opened_by)
212 213 elif c.awaiting_my_review:
213 214 pull_requests = PullRequestModel().get_awaiting_my_review(
214 215 repo_name, source=c.source, opened_by=opened_by,
215 216 user_id=c.rhodecode_user.user_id, statuses=statuses,
216 217 offset=start, length=length, order_by=order_by,
217 218 order_dir=order_dir)
218 219 pull_requests_total_count = PullRequestModel(
219 220 ).count_awaiting_my_review(
220 221 repo_name, source=c.source, user_id=c.rhodecode_user.user_id,
221 222 statuses=statuses, opened_by=opened_by)
222 223 else:
223 224 pull_requests = PullRequestModel().get_all(
224 225 repo_name, source=c.source, opened_by=opened_by,
225 226 statuses=statuses, offset=start, length=length,
226 227 order_by=order_by, order_dir=order_dir)
227 228 pull_requests_total_count = PullRequestModel().count_all(
228 229 repo_name, source=c.source, statuses=statuses,
229 230 opened_by=opened_by)
230 231
231 232 from rhodecode.lib.utils import PartialRenderer
232 233 _render = PartialRenderer('data_table/_dt_elements.html')
233 234 data = []
234 235 for pr in pull_requests:
235 236 comments = ChangesetCommentsModel().get_all_comments(
236 237 c.rhodecode_db_repo.repo_id, pull_request=pr)
237 238
238 239 data.append({
239 240 'name': _render('pullrequest_name',
240 241 pr.pull_request_id, pr.target_repo.repo_name),
241 242 'name_raw': pr.pull_request_id,
242 243 'status': _render('pullrequest_status',
243 244 pr.calculated_review_status()),
244 245 'title': _render(
245 246 'pullrequest_title', pr.title, pr.description),
246 247 'description': h.escape(pr.description),
247 248 'updated_on': _render('pullrequest_updated_on',
248 249 h.datetime_to_time(pr.updated_on)),
249 250 'updated_on_raw': h.datetime_to_time(pr.updated_on),
250 251 'created_on': _render('pullrequest_updated_on',
251 252 h.datetime_to_time(pr.created_on)),
252 253 'created_on_raw': h.datetime_to_time(pr.created_on),
253 254 'author': _render('pullrequest_author',
254 255 pr.author.full_contact, ),
255 256 'author_raw': pr.author.full_name,
256 257 'comments': _render('pullrequest_comments', len(comments)),
257 258 'comments_raw': len(comments),
258 259 'closed': pr.is_closed(),
259 260 })
260 261 # json used to render the grid
261 262 data = ({
262 263 'data': data,
263 264 'recordsTotal': pull_requests_total_count,
264 265 'recordsFiltered': pull_requests_total_count,
265 266 })
266 267 return data
267 268
268 269 @LoginRequired()
269 270 @NotAnonymous()
270 271 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
271 272 'repository.admin')
272 273 @HasAcceptedRepoType('git', 'hg')
273 274 def index(self):
274 275 source_repo = c.rhodecode_db_repo
275 276
276 277 try:
277 278 source_repo.scm_instance().get_commit()
278 279 except EmptyRepositoryError:
279 280 h.flash(h.literal(_('There are no commits yet')),
280 281 category='warning')
281 282 redirect(url('summary_home', repo_name=source_repo.repo_name))
282 283
283 284 commit_id = request.GET.get('commit')
284 285 branch_ref = request.GET.get('branch')
285 286 bookmark_ref = request.GET.get('bookmark')
286 287
287 288 try:
288 289 source_repo_data = PullRequestModel().generate_repo_data(
289 290 source_repo, commit_id=commit_id,
290 291 branch=branch_ref, bookmark=bookmark_ref)
291 292 except CommitDoesNotExistError as e:
292 293 log.exception(e)
293 294 h.flash(_('Commit does not exist'), 'error')
294 295 redirect(url('pullrequest_home', repo_name=source_repo.repo_name))
295 296
296 297 default_target_repo = source_repo
297 298 if (source_repo.parent and
298 299 not source_repo.parent.scm_instance().is_empty()):
299 300 # change default if we have a parent repo
300 301 default_target_repo = source_repo.parent
301 302
302 303 target_repo_data = PullRequestModel().generate_repo_data(
303 304 default_target_repo)
304 305
305 306 selected_source_ref = source_repo_data['refs']['selected_ref']
306 307
307 308 title_source_ref = selected_source_ref.split(':', 2)[1]
308 309 c.default_title = PullRequestModel().generate_pullrequest_title(
309 310 source=source_repo.repo_name,
310 311 source_ref=title_source_ref,
311 312 target=default_target_repo.repo_name
312 313 )
313 314
314 315 c.default_repo_data = {
315 316 'source_repo_name': source_repo.repo_name,
316 317 'source_refs_json': json.dumps(source_repo_data),
317 318 'target_repo_name': default_target_repo.repo_name,
318 319 'target_refs_json': json.dumps(target_repo_data),
319 320 }
320 321 c.default_source_ref = selected_source_ref
321 322
322 323 return render('/pullrequests/pullrequest.html')
323 324
324 325 @LoginRequired()
325 326 @NotAnonymous()
326 327 @XHRRequired()
327 328 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
328 329 'repository.admin')
329 330 @jsonify
330 331 def get_repo_refs(self, repo_name, target_repo_name):
331 332 repo = Repository.get_by_repo_name(target_repo_name)
332 333 if not repo:
333 334 raise HTTPNotFound
334 335 return PullRequestModel().generate_repo_data(repo)
335 336
336 337 @LoginRequired()
337 338 @NotAnonymous()
338 339 @XHRRequired()
339 340 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
340 341 'repository.admin')
341 342 @jsonify
342 343 def get_repo_destinations(self, repo_name):
343 344 repo = Repository.get_by_repo_name(repo_name)
344 345 if not repo:
345 346 raise HTTPNotFound
346 347 filter_query = request.GET.get('query')
347 348
348 349 query = Repository.query() \
349 350 .order_by(func.length(Repository.repo_name)) \
350 351 .filter(or_(
351 352 Repository.repo_name == repo.repo_name,
352 353 Repository.fork_id == repo.repo_id))
353 354
354 355 if filter_query:
355 356 ilike_expression = u'%{}%'.format(safe_unicode(filter_query))
356 357 query = query.filter(
357 358 Repository.repo_name.ilike(ilike_expression))
358 359
359 360 add_parent = False
360 361 if repo.parent:
361 362 if filter_query in repo.parent.repo_name:
362 363 if not repo.parent.scm_instance().is_empty():
363 364 add_parent = True
364 365
365 366 limit = 20 - 1 if add_parent else 20
366 367 all_repos = query.limit(limit).all()
367 368 if add_parent:
368 369 all_repos += [repo.parent]
369 370
370 371 repos = []
371 372 for obj in self.scm_model.get_repos(all_repos):
372 373 repos.append({
373 374 'id': obj['name'],
374 375 'text': obj['name'],
375 376 'type': 'repo',
376 377 'obj': obj['dbrepo']
377 378 })
378 379
379 380 data = {
380 381 'more': False,
381 382 'results': [{
382 383 'text': _('Repositories'),
383 384 'children': repos
384 385 }] if repos else []
385 386 }
386 387 return data
387 388
388 389 @LoginRequired()
389 390 @NotAnonymous()
390 391 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
391 392 'repository.admin')
392 393 @HasAcceptedRepoType('git', 'hg')
393 394 @auth.CSRFRequired()
394 395 def create(self, repo_name):
395 396 repo = Repository.get_by_repo_name(repo_name)
396 397 if not repo:
397 398 raise HTTPNotFound
398 399
399 400 try:
400 401 _form = PullRequestForm(repo.repo_id)().to_python(request.POST)
401 402 except formencode.Invalid as errors:
402 403 if errors.error_dict.get('revisions'):
403 404 msg = 'Revisions: %s' % errors.error_dict['revisions']
404 405 elif errors.error_dict.get('pullrequest_title'):
405 406 msg = _('Pull request requires a title with min. 3 chars')
406 407 else:
407 408 msg = _('Error creating pull request: {}').format(errors)
408 409 log.exception(msg)
409 410 h.flash(msg, 'error')
410 411
411 412 # would rather just go back to form ...
412 413 return redirect(url('pullrequest_home', repo_name=repo_name))
413 414
414 415 source_repo = _form['source_repo']
415 416 source_ref = _form['source_ref']
416 417 target_repo = _form['target_repo']
417 418 target_ref = _form['target_ref']
418 419 commit_ids = _form['revisions'][::-1]
419 420 reviewers = _form['review_members']
420 421
421 422 # find the ancestor for this pr
422 423 source_db_repo = Repository.get_by_repo_name(_form['source_repo'])
423 424 target_db_repo = Repository.get_by_repo_name(_form['target_repo'])
424 425
425 426 source_scm = source_db_repo.scm_instance()
426 427 target_scm = target_db_repo.scm_instance()
427 428
428 429 source_commit = source_scm.get_commit(source_ref.split(':')[-1])
429 430 target_commit = target_scm.get_commit(target_ref.split(':')[-1])
430 431
431 432 ancestor = source_scm.get_common_ancestor(
432 433 source_commit.raw_id, target_commit.raw_id, target_scm)
433 434
434 435 target_ref_type, target_ref_name, __ = _form['target_ref'].split(':')
435 436 target_ref = ':'.join((target_ref_type, target_ref_name, ancestor))
436 437
437 438 pullrequest_title = _form['pullrequest_title']
438 439 title_source_ref = source_ref.split(':', 2)[1]
439 440 if not pullrequest_title:
440 441 pullrequest_title = PullRequestModel().generate_pullrequest_title(
441 442 source=source_repo,
442 443 source_ref=title_source_ref,
443 444 target=target_repo
444 445 )
445 446
446 447 description = _form['pullrequest_desc']
447 448 try:
448 449 pull_request = PullRequestModel().create(
449 450 c.rhodecode_user.user_id, source_repo, source_ref, target_repo,
450 451 target_ref, commit_ids, reviewers, pullrequest_title,
451 452 description
452 453 )
453 454 Session().commit()
454 455 h.flash(_('Successfully opened new pull request'),
455 456 category='success')
456 457 except Exception as e:
457 458 msg = _('Error occurred during sending pull request')
458 459 log.exception(msg)
459 460 h.flash(msg, category='error')
460 461 return redirect(url('pullrequest_home', repo_name=repo_name))
461 462
462 463 return redirect(url('pullrequest_show', repo_name=target_repo,
463 464 pull_request_id=pull_request.pull_request_id))
464 465
465 466 @LoginRequired()
466 467 @NotAnonymous()
467 468 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
468 469 'repository.admin')
469 470 @auth.CSRFRequired()
470 471 @jsonify
471 472 def update(self, repo_name, pull_request_id):
472 473 pull_request_id = safe_int(pull_request_id)
473 474 pull_request = PullRequest.get_or_404(pull_request_id)
474 475 # only owner or admin can update it
475 476 allowed_to_update = PullRequestModel().check_user_update(
476 477 pull_request, c.rhodecode_user)
477 478 if allowed_to_update:
478 479 if 'reviewers_ids' in request.POST:
479 480 self._update_reviewers(pull_request_id)
480 481 elif str2bool(request.POST.get('update_commits', 'false')):
481 482 self._update_commits(pull_request)
482 483 elif str2bool(request.POST.get('close_pull_request', 'false')):
483 484 self._reject_close(pull_request)
484 485 elif str2bool(request.POST.get('edit_pull_request', 'false')):
485 486 self._edit_pull_request(pull_request)
486 487 else:
487 488 raise HTTPBadRequest()
488 489 return True
489 490 raise HTTPForbidden()
490 491
491 492 def _edit_pull_request(self, pull_request):
492 493 try:
493 494 PullRequestModel().edit(
494 495 pull_request, request.POST.get('title'),
495 496 request.POST.get('description'))
496 497 except ValueError:
497 498 msg = _(u'Cannot update closed pull requests.')
498 499 h.flash(msg, category='error')
499 500 return
500 501 else:
501 502 Session().commit()
502 503
503 504 msg = _(u'Pull request title & description updated.')
504 505 h.flash(msg, category='success')
505 506 return
506 507
507 508 def _update_commits(self, pull_request):
508 509 try:
509 510 if PullRequestModel().has_valid_update_type(pull_request):
510 511 updated_version, changes = PullRequestModel().update_commits(
511 512 pull_request)
512 513 if updated_version:
513 514 msg = _(
514 515 u'Pull request updated to "{source_commit_id}" with '
515 516 u'{count_added} added, {count_removed} removed '
516 517 u'commits.'
517 518 ).format(
518 519 source_commit_id=pull_request.source_ref_parts.commit_id,
519 520 count_added=len(changes.added),
520 521 count_removed=len(changes.removed))
521 522 h.flash(msg, category='success')
522 523 else:
523 524 h.flash(_("Nothing changed in pull request."),
524 525 category='warning')
525 526 else:
526 527 msg = _(
527 528 u"Skipping update of pull request due to reference "
528 529 u"type: {reference_type}"
529 530 ).format(reference_type=pull_request.source_ref_parts.type)
530 531 h.flash(msg, category='warning')
531 532 except CommitDoesNotExistError:
532 533 h.flash(
533 534 _(u'Update failed due to missing commits.'), category='error')
534 535
535 536 @auth.CSRFRequired()
536 537 @LoginRequired()
537 538 @NotAnonymous()
538 539 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
539 540 'repository.admin')
540 541 def merge(self, repo_name, pull_request_id):
541 542 """
542 543 POST /{repo_name}/pull-request/{pull_request_id}
543 544
544 545 Merge will perform a server-side merge of the specified
545 546 pull request, if the pull request is approved and mergeable.
546 547 After succesfull merging, the pull request is automatically
547 548 closed, with a relevant comment.
548 549 """
549 550 pull_request_id = safe_int(pull_request_id)
550 551 pull_request = PullRequest.get_or_404(pull_request_id)
551 552 user = c.rhodecode_user
552 553
553 554 if self._meets_merge_pre_conditions(pull_request, user):
554 555 log.debug("Pre-conditions checked, trying to merge.")
555 556 extras = vcs_operation_context(
556 557 request.environ, repo_name=pull_request.target_repo.repo_name,
557 558 username=user.username, action='push',
558 559 scm=pull_request.target_repo.repo_type)
559 560 self._merge_pull_request(pull_request, user, extras)
560 561
561 562 return redirect(url(
562 563 'pullrequest_show',
563 564 repo_name=pull_request.target_repo.repo_name,
564 565 pull_request_id=pull_request.pull_request_id))
565 566
566 567 def _meets_merge_pre_conditions(self, pull_request, user):
567 568 if not PullRequestModel().check_user_merge(pull_request, user):
568 569 raise HTTPForbidden()
569 570
570 571 merge_status, msg = PullRequestModel().merge_status(pull_request)
571 572 if not merge_status:
572 573 log.debug("Cannot merge, not mergeable.")
573 574 h.flash(msg, category='error')
574 575 return False
575 576
576 577 if (pull_request.calculated_review_status()
577 578 is not ChangesetStatus.STATUS_APPROVED):
578 579 log.debug("Cannot merge, approval is pending.")
579 580 msg = _('Pull request reviewer approval is pending.')
580 581 h.flash(msg, category='error')
581 582 return False
582 583 return True
583 584
584 585 def _merge_pull_request(self, pull_request, user, extras):
585 586 merge_resp = PullRequestModel().merge(
586 587 pull_request, user, extras=extras)
587 588
588 589 if merge_resp.executed:
589 590 log.debug("The merge was successful, closing the pull request.")
590 591 PullRequestModel().close_pull_request(
591 592 pull_request.pull_request_id, user)
592 593 Session().commit()
593 594 msg = _('Pull request was successfully merged and closed.')
594 595 h.flash(msg, category='success')
595 596 else:
596 597 log.debug(
597 598 "The merge was not successful. Merge response: %s",
598 599 merge_resp)
599 600 msg = PullRequestModel().merge_status_message(
600 601 merge_resp.failure_reason)
601 602 h.flash(msg, category='error')
602 603
603 604 def _update_reviewers(self, pull_request_id):
604 605 reviewers_ids = map(int, filter(
605 606 lambda v: v not in [None, ''],
606 607 request.POST.get('reviewers_ids', '').split(',')))
607 608 PullRequestModel().update_reviewers(pull_request_id, reviewers_ids)
608 609 Session().commit()
609 610
610 611 def _reject_close(self, pull_request):
611 612 if pull_request.is_closed():
612 613 raise HTTPForbidden()
613 614
614 615 PullRequestModel().close_pull_request_with_comment(
615 616 pull_request, c.rhodecode_user, c.rhodecode_db_repo)
616 617 Session().commit()
617 618
618 619 @LoginRequired()
619 620 @NotAnonymous()
620 621 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
621 622 'repository.admin')
622 623 @auth.CSRFRequired()
623 624 @jsonify
624 625 def delete(self, repo_name, pull_request_id):
625 626 pull_request_id = safe_int(pull_request_id)
626 627 pull_request = PullRequest.get_or_404(pull_request_id)
627 628 # only owner can delete it !
628 629 if pull_request.author.user_id == c.rhodecode_user.user_id:
629 630 PullRequestModel().delete(pull_request)
630 631 Session().commit()
631 632 h.flash(_('Successfully deleted pull request'),
632 633 category='success')
633 634 return redirect(url('my_account_pullrequests'))
634 635 raise HTTPForbidden()
635 636
636 637 @LoginRequired()
637 638 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
638 639 'repository.admin')
639 640 def show(self, repo_name, pull_request_id):
640 641 pull_request_id = safe_int(pull_request_id)
641 642 c.pull_request = PullRequest.get_or_404(pull_request_id)
642 643
643 644 c.template_context['pull_request_data']['pull_request_id'] = \
644 645 pull_request_id
645 646
646 647 # pull_requests repo_name we opened it against
647 648 # ie. target_repo must match
648 649 if repo_name != c.pull_request.target_repo.repo_name:
649 650 raise HTTPNotFound
650 651
651 652 c.allowed_to_change_status = PullRequestModel(). \
652 653 check_user_change_status(c.pull_request, c.rhodecode_user)
653 654 c.allowed_to_update = PullRequestModel().check_user_update(
654 655 c.pull_request, c.rhodecode_user) and not c.pull_request.is_closed()
655 656 c.allowed_to_merge = PullRequestModel().check_user_merge(
656 657 c.pull_request, c.rhodecode_user) and not c.pull_request.is_closed()
657 658
658 659 cc_model = ChangesetCommentsModel()
659 660
660 661 c.pull_request_reviewers = c.pull_request.reviewers_statuses()
661 662
662 663 c.pull_request_review_status = c.pull_request.calculated_review_status()
663 664 c.pr_merge_status, c.pr_merge_msg = PullRequestModel().merge_status(
664 665 c.pull_request)
665 666 c.approval_msg = None
666 667 if c.pull_request_review_status != ChangesetStatus.STATUS_APPROVED:
667 668 c.approval_msg = _('Reviewer approval is pending.')
668 669 c.pr_merge_status = False
669 670 # load compare data into template context
670 671 enable_comments = not c.pull_request.is_closed()
671 672 self._load_compare_data(c.pull_request, enable_comments=enable_comments)
672 673
673 674 # this is a hack to properly display links, when creating PR, the
674 675 # compare view and others uses different notation, and
675 676 # compare_commits.html renders links based on the target_repo.
676 677 # We need to swap that here to generate it properly on the html side
677 678 c.target_repo = c.source_repo
678 679
679 680 # inline comments
680 681 c.inline_cnt = 0
681 682 c.inline_comments = cc_model.get_inline_comments(
682 683 c.rhodecode_db_repo.repo_id,
683 684 pull_request=pull_request_id).items()
684 685 # count inline comments
685 686 for __, lines in c.inline_comments:
686 687 for comments in lines.values():
687 688 c.inline_cnt += len(comments)
688 689
689 690 # outdated comments
690 691 c.outdated_cnt = 0
691 692 if ChangesetCommentsModel.use_outdated_comments(c.pull_request):
692 693 c.outdated_comments = cc_model.get_outdated_comments(
693 694 c.rhodecode_db_repo.repo_id,
694 695 pull_request=c.pull_request)
695 696 # Count outdated comments and check for deleted files
696 697 for file_name, lines in c.outdated_comments.iteritems():
697 698 for comments in lines.values():
698 699 c.outdated_cnt += len(comments)
699 700 if file_name not in c.included_files:
700 701 c.deleted_files.append(file_name)
701 702 else:
702 703 c.outdated_comments = {}
703 704
704 705 # comments
705 706 c.comments = cc_model.get_comments(c.rhodecode_db_repo.repo_id,
706 707 pull_request=pull_request_id)
707 708
708 709 if c.allowed_to_update:
709 710 force_close = ('forced_closed', _('Close Pull Request'))
710 711 statuses = ChangesetStatus.STATUSES + [force_close]
711 712 else:
712 713 statuses = ChangesetStatus.STATUSES
713 714 c.commit_statuses = statuses
714 715
715 716 c.ancestor = None # TODO: add ancestor here
716 717
717 718 return render('/pullrequests/pullrequest_show.html')
718 719
719 720 @LoginRequired()
720 721 @NotAnonymous()
721 722 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
722 723 'repository.admin')
723 724 @auth.CSRFRequired()
724 725 @jsonify
725 726 def comment(self, repo_name, pull_request_id):
726 727 pull_request_id = safe_int(pull_request_id)
727 728 pull_request = PullRequest.get_or_404(pull_request_id)
728 729 if pull_request.is_closed():
729 730 raise HTTPForbidden()
730 731
731 732 # TODO: johbo: Re-think this bit, "approved_closed" does not exist
732 733 # as a changeset status, still we want to send it in one value.
733 734 status = request.POST.get('changeset_status', None)
734 735 text = request.POST.get('text')
735 736 if status and '_closed' in status:
736 737 close_pr = True
737 738 status = status.replace('_closed', '')
738 739 else:
739 740 close_pr = False
740 741
741 742 forced = (status == 'forced')
742 743 if forced:
743 744 status = 'rejected'
744 745
745 746 allowed_to_change_status = PullRequestModel().check_user_change_status(
746 747 pull_request, c.rhodecode_user)
747 748
748 749 if status and allowed_to_change_status:
749 750 message = (_('Status change %(transition_icon)s %(status)s')
750 751 % {'transition_icon': '>',
751 752 'status': ChangesetStatus.get_status_lbl(status)})
752 753 if close_pr:
753 754 message = _('Closing with') + ' ' + message
754 755 text = text or message
755 756 comm = ChangesetCommentsModel().create(
756 757 text=text,
757 758 repo=c.rhodecode_db_repo.repo_id,
758 759 user=c.rhodecode_user.user_id,
759 760 pull_request=pull_request_id,
760 761 f_path=request.POST.get('f_path'),
761 762 line_no=request.POST.get('line'),
762 763 status_change=(ChangesetStatus.get_status_lbl(status)
763 764 if status and allowed_to_change_status else None),
764 765 closing_pr=close_pr
765 766 )
766 767
768
769
767 770 if allowed_to_change_status:
768 771 old_calculated_status = pull_request.calculated_review_status()
769 772 # get status if set !
770 773 if status:
771 774 ChangesetStatusModel().set_status(
772 775 c.rhodecode_db_repo.repo_id,
773 776 status,
774 777 c.rhodecode_user.user_id,
775 778 comm,
776 779 pull_request=pull_request_id
777 780 )
778 781
779 782 Session().flush()
783 events.trigger(events.PullRequestCommentEvent(pull_request, comm))
780 784 # we now calculate the status of pull request, and based on that
781 785 # calculation we set the commits status
782 786 calculated_status = pull_request.calculated_review_status()
783 787 if old_calculated_status != calculated_status:
784 788 PullRequestModel()._trigger_pull_request_hook(
785 789 pull_request, c.rhodecode_user, 'review_status_change')
786 790
787 791 calculated_status_lbl = ChangesetStatus.get_status_lbl(
788 792 calculated_status)
789 793
790 794 if close_pr:
791 795 status_completed = (
792 796 calculated_status in [ChangesetStatus.STATUS_APPROVED,
793 797 ChangesetStatus.STATUS_REJECTED])
794 798 if forced or status_completed:
795 799 PullRequestModel().close_pull_request(
796 800 pull_request_id, c.rhodecode_user)
797 801 else:
798 802 h.flash(_('Closing pull request on other statuses than '
799 803 'rejected or approved is forbidden. '
800 804 'Calculated status from all reviewers '
801 805 'is currently: %s') % calculated_status_lbl,
802 806 category='warning')
803 807
804 808 Session().commit()
805 809
806 810 if not request.is_xhr:
807 811 return redirect(h.url('pullrequest_show', repo_name=repo_name,
808 812 pull_request_id=pull_request_id))
809 813
810 814 data = {
811 815 'target_id': h.safeid(h.safe_unicode(request.POST.get('f_path'))),
812 816 }
813 817 if comm:
814 818 c.co = comm
815 819 data.update(comm.get_dict())
816 820 data.update({'rendered_text':
817 821 render('changeset/changeset_comment_block.html')})
818 822
819 823 return data
820 824
821 825 @LoginRequired()
822 826 @NotAnonymous()
823 827 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
824 828 'repository.admin')
825 829 @auth.CSRFRequired()
826 830 @jsonify
827 831 def delete_comment(self, repo_name, comment_id):
828 832 return self._delete_comment(comment_id)
829 833
830 834 def _delete_comment(self, comment_id):
831 835 comment_id = safe_int(comment_id)
832 836 co = ChangesetComment.get_or_404(comment_id)
833 837 if co.pull_request.is_closed():
834 838 # don't allow deleting comments on closed pull request
835 839 raise HTTPForbidden()
836 840
837 841 is_owner = co.author.user_id == c.rhodecode_user.user_id
838 842 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(c.repo_name)
839 843 if h.HasPermissionAny('hg.admin')() or is_repo_admin or is_owner:
840 844 old_calculated_status = co.pull_request.calculated_review_status()
841 845 ChangesetCommentsModel().delete(comment=co)
842 846 Session().commit()
843 847 calculated_status = co.pull_request.calculated_review_status()
844 848 if old_calculated_status != calculated_status:
845 849 PullRequestModel()._trigger_pull_request_hook(
846 850 co.pull_request, c.rhodecode_user, 'review_status_change')
847 851 return True
848 852 else:
849 853 raise HTTPForbidden()
@@ -1,70 +1,71 b''
1 1 # Copyright (C) 2016-2016 RhodeCode GmbH
2 2 #
3 3 # This program is free software: you can redistribute it and/or modify
4 4 # it under the terms of the GNU Affero General Public License, version 3
5 5 # (only), as published by the Free Software Foundation.
6 6 #
7 7 # This program is distributed in the hope that it will be useful,
8 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 # GNU General Public License for more details.
11 11 #
12 12 # You should have received a copy of the GNU Affero General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 #
15 15 # This program is dual-licensed. If you wish to learn more about the
16 16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18 18
19 19 import logging
20 20 from pyramid.threadlocal import get_current_registry
21 21
22 22 log = logging.getLogger()
23 23
24 24
25 25 def trigger(event, registry=None):
26 26 """
27 27 Helper method to send an event. This wraps the pyramid logic to send an
28 28 event.
29 29 """
30 30 # For the first step we are using pyramids thread locals here. If the
31 31 # event mechanism works out as a good solution we should think about
32 32 # passing the registry as an argument to get rid of it.
33 33 registry = registry or get_current_registry()
34 34 registry.notify(event)
35 35 log.debug('event %s triggered', event)
36 36
37 37 # Until we can work around the problem that VCS operations do not have a
38 38 # pyramid context to work with, we send the events to integrations directly
39 39
40 40 # Later it will be possible to use regular pyramid subscribers ie:
41 41 # config.add_subscriber(integrations_event_handler, RhodecodeEvent)
42 42 from rhodecode.integrations import integrations_event_handler
43 43 if isinstance(event, RhodecodeEvent):
44 44 integrations_event_handler(event)
45 45
46 46
47 47 from rhodecode.events.base import RhodecodeEvent
48 48
49 49 from rhodecode.events.user import (
50 50 UserPreCreate,
51 51 UserPreUpdate,
52 52 UserRegistered
53 53 )
54 54
55 55 from rhodecode.events.repo import (
56 56 RepoEvent,
57 57 RepoPreCreateEvent, RepoCreateEvent,
58 58 RepoPreDeleteEvent, RepoDeleteEvent,
59 59 RepoPrePushEvent, RepoPushEvent,
60 60 RepoPrePullEvent, RepoPullEvent,
61 61 )
62 62
63 63 from rhodecode.events.pullrequest import (
64 64 PullRequestEvent,
65 65 PullRequestCreateEvent,
66 66 PullRequestUpdateEvent,
67 PullRequestCommentEvent,
67 68 PullRequestReviewEvent,
68 69 PullRequestMergeEvent,
69 70 PullRequestCloseEvent,
70 71 )
@@ -1,97 +1,126 b''
1 1 # Copyright (C) 2016-2016 RhodeCode GmbH
2 2 #
3 3 # This program is free software: you can redistribute it and/or modify
4 4 # it under the terms of the GNU Affero General Public License, version 3
5 5 # (only), as published by the Free Software Foundation.
6 6 #
7 7 # This program is distributed in the hope that it will be useful,
8 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 # GNU General Public License for more details.
11 11 #
12 12 # You should have received a copy of the GNU Affero General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 #
15 15 # This program is dual-licensed. If you wish to learn more about the
16 16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18 18
19 19
20 20 from rhodecode.translation import lazy_ugettext
21 21 from rhodecode.events.repo import RepoEvent
22 22
23 23
24 24 class PullRequestEvent(RepoEvent):
25 25 """
26 26 Base class for pull request events.
27 27
28 28 :param pullrequest: a :class:`PullRequest` instance
29 29 """
30 30
31 31 def __init__(self, pullrequest):
32 32 super(PullRequestEvent, self).__init__(pullrequest.target_repo)
33 33 self.pullrequest = pullrequest
34 34
35 35 def as_dict(self):
36 36 from rhodecode.model.pull_request import PullRequestModel
37 37 data = super(PullRequestEvent, self).as_dict()
38 38
39 39 commits = self._commits_as_dict(self.pullrequest.revisions)
40 40 issues = self._issues_as_dict(commits)
41 41
42 42 data.update({
43 43 'pullrequest': {
44 44 'title': self.pullrequest.title,
45 45 'issues': issues,
46 46 'pull_request_id': self.pullrequest.pull_request_id,
47 'url': PullRequestModel().get_url(self.pullrequest)
47 'url': PullRequestModel().get_url(self.pullrequest),
48 'status': self.pullrequest.calculated_review_status(),
48 49 }
49 50 })
50 51 return data
51 52
52 53
53 54 class PullRequestCreateEvent(PullRequestEvent):
54 55 """
55 56 An instance of this class is emitted as an :term:`event` after a pull
56 57 request is created.
57 58 """
58 59 name = 'pullrequest-create'
59 60 display_name = lazy_ugettext('pullrequest created')
60 61
61 62
62 63 class PullRequestCloseEvent(PullRequestEvent):
63 64 """
64 65 An instance of this class is emitted as an :term:`event` after a pull
65 66 request is closed.
66 67 """
67 68 name = 'pullrequest-close'
68 69 display_name = lazy_ugettext('pullrequest closed')
69 70
70 71
71 72 class PullRequestUpdateEvent(PullRequestEvent):
72 73 """
73 74 An instance of this class is emitted as an :term:`event` after a pull
74 request is updated.
75 request's commits have been updated.
75 76 """
76 77 name = 'pullrequest-update'
77 display_name = lazy_ugettext('pullrequest updated')
78 display_name = lazy_ugettext('pullrequest commits updated')
79
80
81 class PullRequestReviewEvent(PullRequestEvent):
82 """
83 An instance of this class is emitted as an :term:`event` after a pull
84 request review has changed.
85 """
86 name = 'pullrequest-review'
87 display_name = lazy_ugettext('pullrequest review changed')
78 88
79 89
80 90 class PullRequestMergeEvent(PullRequestEvent):
81 91 """
82 92 An instance of this class is emitted as an :term:`event` after a pull
83 93 request is merged.
84 94 """
85 95 name = 'pullrequest-merge'
86 96 display_name = lazy_ugettext('pullrequest merged')
87 97
88 98
89 class PullRequestReviewEvent(PullRequestEvent):
99 class PullRequestCommentEvent(PullRequestEvent):
90 100 """
91 101 An instance of this class is emitted as an :term:`event` after a pull
92 request is reviewed.
102 request comment is created.
93 103 """
94 name = 'pullrequest-review'
95 display_name = lazy_ugettext('pullrequest reviewed')
104 name = 'pullrequest-comment'
105 display_name = lazy_ugettext('pullrequest commented')
106
107 def __init__(self, pullrequest, comment):
108 super(PullRequestCommentEvent, self).__init__(pullrequest)
109 self.comment = comment
110
111 def as_dict(self):
112 from rhodecode.model.comment import ChangesetCommentsModel
113 data = super(PullRequestCommentEvent, self).as_dict()
96 114
115 status = None
116 if self.comment.status_change:
117 status = self.comment.status_change[0].status
97 118
119 data.update({
120 'comment': {
121 'status': status,
122 'text': self.comment.text,
123 'url': ChangesetCommentsModel().get_url(self.comment)
124 }
125 })
126 return data
@@ -1,201 +1,246 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2016 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 from __future__ import unicode_literals
22 22
23 23 import re
24 24 import logging
25 25 import requests
26 26 import colander
27 import textwrap
27 28 from celery.task import task
28 29 from mako.template import Template
29 30
30 31 from rhodecode import events
31 32 from rhodecode.translation import lazy_ugettext
32 33 from rhodecode.lib import helpers as h
33 34 from rhodecode.lib.celerylib import run_task
34 35 from rhodecode.lib.colander_utils import strip_whitespace
35 36 from rhodecode.integrations.types.base import IntegrationTypeBase
36 37 from rhodecode.integrations.schema import IntegrationSettingsSchemaBase
37 38
38 39 log = logging.getLogger()
39 40
40 41
41 42 class SlackSettingsSchema(IntegrationSettingsSchemaBase):
42 43 service = colander.SchemaNode(
43 44 colander.String(),
44 45 title=lazy_ugettext('Slack service URL'),
45 46 description=h.literal(lazy_ugettext(
46 47 'This can be setup at the '
47 48 '<a href="https://my.slack.com/services/new/incoming-webhook/">'
48 49 'slack app manager</a>')),
49 50 default='',
50 51 placeholder='https://hooks.slack.com/services/...',
51 52 preparer=strip_whitespace,
52 53 validator=colander.url,
53 54 widget='string'
54 55 )
55 56 username = colander.SchemaNode(
56 57 colander.String(),
57 58 title=lazy_ugettext('Username'),
58 59 description=lazy_ugettext('Username to show notifications coming from.'),
59 60 missing='Rhodecode',
60 61 preparer=strip_whitespace,
61 62 widget='string',
62 63 placeholder='Rhodecode'
63 64 )
64 65 channel = colander.SchemaNode(
65 66 colander.String(),
66 67 title=lazy_ugettext('Channel'),
67 68 description=lazy_ugettext('Channel to send notifications to.'),
68 69 missing='',
69 70 preparer=strip_whitespace,
70 71 widget='string',
71 72 placeholder='#general'
72 73 )
73 74 icon_emoji = colander.SchemaNode(
74 75 colander.String(),
75 76 title=lazy_ugettext('Emoji'),
76 77 description=lazy_ugettext('Emoji to use eg. :studio_microphone:'),
77 78 missing='',
78 79 preparer=strip_whitespace,
79 80 widget='string',
80 81 placeholder=':studio_microphone:'
81 82 )
82 83
83 84
84 85 repo_push_template = Template(r'''
85 86 *${data['actor']['username']}* pushed to \
86 87 %if data['push']['branches']:
87 88 ${len(data['push']['branches']) > 1 and 'branches' or 'branch'} \
88 89 ${', '.join('<%s|%s>' % (branch['url'], branch['name']) for branch in data['push']['branches'])} \
89 90 %else:
90 91 unknown branch \
91 92 %endif
92 93 in <${data['repo']['url']}|${data['repo']['repo_name']}>
93 94 >>>
94 95 %for commit in data['push']['commits']:
95 96 <${commit['url']}|${commit['short_id']}> - ${commit['message_html']|html_to_slack_links}
96 97 %endfor
97 98 ''')
98 99
99 100
100 101 class SlackIntegrationType(IntegrationTypeBase):
101 102 key = 'slack'
102 103 display_name = lazy_ugettext('Slack')
103 104 SettingsSchema = SlackSettingsSchema
104 105 valid_events = [
105 106 events.PullRequestCloseEvent,
106 107 events.PullRequestMergeEvent,
107 108 events.PullRequestUpdateEvent,
109 events.PullRequestCommentEvent,
108 110 events.PullRequestReviewEvent,
109 111 events.PullRequestCreateEvent,
110 112 events.RepoPushEvent,
111 113 events.RepoCreateEvent,
112 114 ]
113 115
114 116 def send_event(self, event):
115 117 if event.__class__ not in self.valid_events:
116 118 log.debug('event not valid: %r' % event)
117 119 return
118 120
119 121 if event.name not in self.settings['events']:
120 122 log.debug('event ignored: %r' % event)
121 123 return
122 124
123 125 data = event.as_dict()
124 126
125 127 text = '*%s* caused a *%s* event' % (
126 128 data['actor']['username'], event.name)
127 129
128 130 log.debug('handling slack event for %s' % event.name)
129 131
130 if isinstance(event, events.PullRequestEvent):
132 if isinstance(event, events.PullRequestCommentEvent):
133 text = self.format_pull_request_comment_event(event, data)
134 elif isinstance(event, events.PullRequestReviewEvent):
135 text = self.format_pull_request_review_event(event, data)
136 elif isinstance(event, events.PullRequestEvent):
131 137 text = self.format_pull_request_event(event, data)
132 138 elif isinstance(event, events.RepoPushEvent):
133 139 text = self.format_repo_push_event(data)
134 140 elif isinstance(event, events.RepoCreateEvent):
135 141 text = self.format_repo_create_event(data)
136 142 else:
137 143 log.error('unhandled event type: %r' % event)
138 144
139 145 run_task(post_text_to_slack, self.settings, text)
140 146
141 147 @classmethod
142 148 def settings_schema(cls):
143 149 schema = SlackSettingsSchema()
144 150 schema.add(colander.SchemaNode(
145 151 colander.Set(),
146 152 widget='checkbox_list',
147 153 choices=sorted([e.name for e in cls.valid_events]),
148 154 description="Events activated for this integration",
149 155 name='events'
150 156 ))
151 157 return schema
152 158
159 def format_pull_request_comment_event(self, event, data):
160 comment_text = data['comment']['text']
161 if len(comment_text) > 200:
162 comment_text = '<{comment_url}|{comment_text}...>'.format(
163 comment_text=comment_text[:200],
164 comment_url=data['comment']['url'],
165 )
166
167 comment_status = ''
168 if data['comment']['status']:
169 comment_status = '[{}]: '.format(data['comment']['status'])
170
171 return (textwrap.dedent(
172 '''
173 {user} commented on pull request <{pr_url}|#{number}> - {pr_title}:
174 >>> {comment_status}{comment_text}
175 ''').format(
176 comment_status=comment_status,
177 user=data['actor']['username'],
178 number=data['pullrequest']['pull_request_id'],
179 pr_url=data['pullrequest']['url'],
180 pr_status=data['pullrequest']['status'],
181 pr_title=data['pullrequest']['title'],
182 comment_text=comment_text
183 )
184 )
185
186 def format_pull_request_review_event(self, event, data):
187 return (textwrap.dedent(
188 '''
189 Status changed to {pr_status} for pull request <{pr_url}|#{number}> - {pr_title}
190 ''').format(
191 user=data['actor']['username'],
192 number=data['pullrequest']['pull_request_id'],
193 pr_url=data['pullrequest']['url'],
194 pr_status=data['pullrequest']['status'],
195 pr_title=data['pullrequest']['title'],
196 )
197 )
198
153 199 def format_pull_request_event(self, event, data):
154 200 action = {
155 201 events.PullRequestCloseEvent: 'closed',
156 202 events.PullRequestMergeEvent: 'merged',
157 203 events.PullRequestUpdateEvent: 'updated',
158 events.PullRequestReviewEvent: 'reviewed',
159 204 events.PullRequestCreateEvent: 'created',
160 }.get(event.__class__, '<unknown action>')
205 }.get(event.__class__, str(event.__class__))
161 206
162 return ('Pull request <{url}|#{number}> ({title}) '
207 return ('Pull request <{url}|#{number}> - {title} '
163 208 '{action} by {user}').format(
164 209 user=data['actor']['username'],
165 210 number=data['pullrequest']['pull_request_id'],
166 211 url=data['pullrequest']['url'],
167 212 title=data['pullrequest']['title'],
168 213 action=action
169 214 )
170 215
171 216 def format_repo_push_event(self, data):
172 217 result = repo_push_template.render(
173 218 data=data,
174 219 html_to_slack_links=html_to_slack_links,
175 220 )
176 221 return result
177 222
178 223 def format_repo_create_event(self, data):
179 224 return '<{}|{}> ({}) repository created by *{}*'.format(
180 225 data['repo']['url'],
181 226 data['repo']['repo_name'],
182 227 data['repo']['repo_type'],
183 228 data['actor']['username'],
184 229 )
185 230
186 231
187 232 def html_to_slack_links(message):
188 233 return re.compile(r'<a .*?href=["\'](.+?)".*?>(.+?)</a>').sub(
189 234 r'<\1|\2>', message)
190 235
191 236
192 237 @task(ignore_result=True)
193 238 def post_text_to_slack(settings, text):
194 239 log.debug('sending %s to slack %s' % (text, settings['service']))
195 240 resp = requests.post(settings['service'], json={
196 241 "channel": settings.get('channel', ''),
197 242 "username": settings.get('username', 'Rhodecode'),
198 243 "text": text,
199 244 "icon_emoji": settings.get('icon_emoji', ':studio_microphone:')
200 245 })
201 246 resp.raise_for_status() # raise exception on a failed request
@@ -1,459 +1,471 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2016 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 comments model for RhodeCode
23 23 """
24 24
25 25 import logging
26 26 import traceback
27 27 import collections
28 28
29 29 from sqlalchemy.sql.expression import null
30 30 from sqlalchemy.sql.functions import coalesce
31 31
32 32 from rhodecode.lib import helpers as h, diffs
33 33 from rhodecode.lib.utils import action_logger
34 34 from rhodecode.lib.utils2 import extract_mentioned_users
35 35 from rhodecode.model import BaseModel
36 36 from rhodecode.model.db import (
37 37 ChangesetComment, User, Notification, PullRequest)
38 38 from rhodecode.model.notification import NotificationModel
39 39 from rhodecode.model.meta import Session
40 40 from rhodecode.model.settings import VcsSettingsModel
41 41 from rhodecode.model.notification import EmailNotificationModel
42 42
43 43 log = logging.getLogger(__name__)
44 44
45 45
46 46 class ChangesetCommentsModel(BaseModel):
47 47
48 48 cls = ChangesetComment
49 49
50 50 DIFF_CONTEXT_BEFORE = 3
51 51 DIFF_CONTEXT_AFTER = 3
52 52
53 53 def __get_commit_comment(self, changeset_comment):
54 54 return self._get_instance(ChangesetComment, changeset_comment)
55 55
56 56 def __get_pull_request(self, pull_request):
57 57 return self._get_instance(PullRequest, pull_request)
58 58
59 59 def _extract_mentions(self, s):
60 60 user_objects = []
61 61 for username in extract_mentioned_users(s):
62 62 user_obj = User.get_by_username(username, case_insensitive=True)
63 63 if user_obj:
64 64 user_objects.append(user_obj)
65 65 return user_objects
66 66
67 67 def _get_renderer(self, global_renderer='rst'):
68 68 try:
69 69 # try reading from visual context
70 70 from pylons import tmpl_context
71 71 global_renderer = tmpl_context.visual.default_renderer
72 72 except AttributeError:
73 73 log.debug("Renderer not set, falling back "
74 74 "to default renderer '%s'", global_renderer)
75 75 except Exception:
76 76 log.error(traceback.format_exc())
77 77 return global_renderer
78 78
79 79 def create(self, text, repo, user, revision=None, pull_request=None,
80 80 f_path=None, line_no=None, status_change=None, closing_pr=False,
81 81 send_email=True, renderer=None):
82 82 """
83 83 Creates new comment for commit or pull request.
84 84 IF status_change is not none this comment is associated with a
85 85 status change of commit or commit associated with pull request
86 86
87 87 :param text:
88 88 :param repo:
89 89 :param user:
90 90 :param revision:
91 91 :param pull_request:
92 92 :param f_path:
93 93 :param line_no:
94 94 :param status_change:
95 95 :param closing_pr:
96 96 :param send_email:
97 97 """
98 98 if not text:
99 99 log.warning('Missing text for comment, skipping...')
100 100 return
101 101
102 102 if not renderer:
103 103 renderer = self._get_renderer()
104 104
105 105 repo = self._get_repo(repo)
106 106 user = self._get_user(user)
107 107 comment = ChangesetComment()
108 108 comment.renderer = renderer
109 109 comment.repo = repo
110 110 comment.author = user
111 111 comment.text = text
112 112 comment.f_path = f_path
113 113 comment.line_no = line_no
114 114
115 115 #TODO (marcink): fix this and remove revision as param
116 116 commit_id = revision
117 117 pull_request_id = pull_request
118 118
119 119 commit_obj = None
120 120 pull_request_obj = None
121 121
122 122 if commit_id:
123 123 notification_type = EmailNotificationModel.TYPE_COMMIT_COMMENT
124 124 # do a lookup, so we don't pass something bad here
125 125 commit_obj = repo.scm_instance().get_commit(commit_id=commit_id)
126 126 comment.revision = commit_obj.raw_id
127 127
128 128 elif pull_request_id:
129 129 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST_COMMENT
130 130 pull_request_obj = self.__get_pull_request(pull_request_id)
131 131 comment.pull_request = pull_request_obj
132 132 else:
133 133 raise Exception('Please specify commit or pull_request_id')
134 134
135 135 Session().add(comment)
136 136 Session().flush()
137 137
138 138 if send_email:
139 139 kwargs = {
140 140 'user': user,
141 141 'renderer_type': renderer,
142 142 'repo_name': repo.repo_name,
143 143 'status_change': status_change,
144 144 'comment_body': text,
145 145 'comment_file': f_path,
146 146 'comment_line': line_no,
147 147 }
148 148
149 149 if commit_obj:
150 150 recipients = ChangesetComment.get_users(
151 151 revision=commit_obj.raw_id)
152 152 # add commit author if it's in RhodeCode system
153 153 cs_author = User.get_from_cs_author(commit_obj.author)
154 154 if not cs_author:
155 155 # use repo owner if we cannot extract the author correctly
156 156 cs_author = repo.user
157 157 recipients += [cs_author]
158 158
159 commit_comment_url = h.url(
160 'changeset_home',
161 repo_name=repo.repo_name,
162 revision=commit_obj.raw_id,
163 anchor='comment-%s' % comment.comment_id,
164 qualified=True,)
159 commit_comment_url = self.get_url(comment)
165 160
166 161 target_repo_url = h.link_to(
167 162 repo.repo_name,
168 163 h.url('summary_home',
169 164 repo_name=repo.repo_name, qualified=True))
170 165
171 166 # commit specifics
172 167 kwargs.update({
173 168 'commit': commit_obj,
174 169 'commit_message': commit_obj.message,
175 170 'commit_target_repo': target_repo_url,
176 171 'commit_comment_url': commit_comment_url,
177 172 })
178 173
179 174 elif pull_request_obj:
180 175 # get the current participants of this pull request
181 176 recipients = ChangesetComment.get_users(
182 177 pull_request_id=pull_request_obj.pull_request_id)
183 178 # add pull request author
184 179 recipients += [pull_request_obj.author]
185 180
186 181 # add the reviewers to notification
187 182 recipients += [x.user for x in pull_request_obj.reviewers]
188 183
189 184 pr_target_repo = pull_request_obj.target_repo
190 185 pr_source_repo = pull_request_obj.source_repo
191 186
192 187 pr_comment_url = h.url(
193 188 'pullrequest_show',
194 189 repo_name=pr_target_repo.repo_name,
195 190 pull_request_id=pull_request_obj.pull_request_id,
196 191 anchor='comment-%s' % comment.comment_id,
197 192 qualified=True,)
198 193
199 194 # set some variables for email notification
200 195 pr_target_repo_url = h.url(
201 196 'summary_home', repo_name=pr_target_repo.repo_name,
202 197 qualified=True)
203 198
204 199 pr_source_repo_url = h.url(
205 200 'summary_home', repo_name=pr_source_repo.repo_name,
206 201 qualified=True)
207 202
208 203 # pull request specifics
209 204 kwargs.update({
210 205 'pull_request': pull_request_obj,
211 206 'pr_id': pull_request_obj.pull_request_id,
212 207 'pr_target_repo': pr_target_repo,
213 208 'pr_target_repo_url': pr_target_repo_url,
214 209 'pr_source_repo': pr_source_repo,
215 210 'pr_source_repo_url': pr_source_repo_url,
216 211 'pr_comment_url': pr_comment_url,
217 212 'pr_closing': closing_pr,
218 213 })
219 214
220 215 # pre-generate the subject for notification itself
221 216 (subject,
222 217 _h, _e, # we don't care about those
223 218 body_plaintext) = EmailNotificationModel().render_email(
224 219 notification_type, **kwargs)
225 220
226 221 mention_recipients = set(
227 222 self._extract_mentions(text)).difference(recipients)
228 223
229 224 # create notification objects, and emails
230 225 NotificationModel().create(
231 226 created_by=user,
232 227 notification_subject=subject,
233 228 notification_body=body_plaintext,
234 229 notification_type=notification_type,
235 230 recipients=recipients,
236 231 mention_recipients=mention_recipients,
237 232 email_kwargs=kwargs,
238 233 )
239 234
240 235 action = (
241 236 'user_commented_pull_request:{}'.format(
242 237 comment.pull_request.pull_request_id)
243 238 if comment.pull_request
244 239 else 'user_commented_revision:{}'.format(comment.revision)
245 240 )
246 241 action_logger(user, action, comment.repo)
247 242
248 243 return comment
249 244
250 245 def delete(self, comment):
251 246 """
252 247 Deletes given comment
253 248
254 249 :param comment_id:
255 250 """
256 251 comment = self.__get_commit_comment(comment)
257 252 Session().delete(comment)
258 253
259 254 return comment
260 255
261 256 def get_all_comments(self, repo_id, revision=None, pull_request=None):
262 257 q = ChangesetComment.query()\
263 258 .filter(ChangesetComment.repo_id == repo_id)
264 259 if revision:
265 260 q = q.filter(ChangesetComment.revision == revision)
266 261 elif pull_request:
267 262 pull_request = self.__get_pull_request(pull_request)
268 263 q = q.filter(ChangesetComment.pull_request == pull_request)
269 264 else:
270 265 raise Exception('Please specify commit or pull_request')
271 266 q = q.order_by(ChangesetComment.created_on)
272 267 return q.all()
273 268
269 def get_url(self, comment):
270 comment = self.__get_commit_comment(comment)
271 if comment.pull_request:
272 return h.url(
273 'pullrequest_show',
274 repo_name=comment.pull_request.target_repo.repo_name,
275 pull_request_id=comment.pull_request.pull_request_id,
276 anchor='comment-%s' % comment.comment_id,
277 qualified=True,)
278 else:
279 return h.url(
280 'changeset_home',
281 repo_name=comment.repo.repo_name,
282 revision=comment.revision,
283 anchor='comment-%s' % comment.comment_id,
284 qualified=True,)
285
274 286 def get_comments(self, repo_id, revision=None, pull_request=None):
275 287 """
276 288 Gets main comments based on revision or pull_request_id
277 289
278 290 :param repo_id:
279 291 :param revision:
280 292 :param pull_request:
281 293 """
282 294
283 295 q = ChangesetComment.query()\
284 296 .filter(ChangesetComment.repo_id == repo_id)\
285 297 .filter(ChangesetComment.line_no == None)\
286 298 .filter(ChangesetComment.f_path == None)
287 299 if revision:
288 300 q = q.filter(ChangesetComment.revision == revision)
289 301 elif pull_request:
290 302 pull_request = self.__get_pull_request(pull_request)
291 303 q = q.filter(ChangesetComment.pull_request == pull_request)
292 304 else:
293 305 raise Exception('Please specify commit or pull_request')
294 306 q = q.order_by(ChangesetComment.created_on)
295 307 return q.all()
296 308
297 309 def get_inline_comments(self, repo_id, revision=None, pull_request=None):
298 310 q = self._get_inline_comments_query(repo_id, revision, pull_request)
299 311 return self._group_comments_by_path_and_line_number(q)
300 312
301 313 def get_outdated_comments(self, repo_id, pull_request):
302 314 # TODO: johbo: Remove `repo_id`, it is not needed to find the comments
303 315 # of a pull request.
304 316 q = self._all_inline_comments_of_pull_request(pull_request)
305 317 q = q.filter(
306 318 ChangesetComment.display_state ==
307 319 ChangesetComment.COMMENT_OUTDATED
308 320 ).order_by(ChangesetComment.comment_id.asc())
309 321
310 322 return self._group_comments_by_path_and_line_number(q)
311 323
312 324 def _get_inline_comments_query(self, repo_id, revision, pull_request):
313 325 # TODO: johbo: Split this into two methods: One for PR and one for
314 326 # commit.
315 327 if revision:
316 328 q = Session().query(ChangesetComment).filter(
317 329 ChangesetComment.repo_id == repo_id,
318 330 ChangesetComment.line_no != null(),
319 331 ChangesetComment.f_path != null(),
320 332 ChangesetComment.revision == revision)
321 333
322 334 elif pull_request:
323 335 pull_request = self.__get_pull_request(pull_request)
324 336 if ChangesetCommentsModel.use_outdated_comments(pull_request):
325 337 q = self._visible_inline_comments_of_pull_request(pull_request)
326 338 else:
327 339 q = self._all_inline_comments_of_pull_request(pull_request)
328 340
329 341 else:
330 342 raise Exception('Please specify commit or pull_request_id')
331 343 q = q.order_by(ChangesetComment.comment_id.asc())
332 344 return q
333 345
334 346 def _group_comments_by_path_and_line_number(self, q):
335 347 comments = q.all()
336 348 paths = collections.defaultdict(lambda: collections.defaultdict(list))
337 349 for co in comments:
338 350 paths[co.f_path][co.line_no].append(co)
339 351 return paths
340 352
341 353 @classmethod
342 354 def needed_extra_diff_context(cls):
343 355 return max(cls.DIFF_CONTEXT_BEFORE, cls.DIFF_CONTEXT_AFTER)
344 356
345 357 def outdate_comments(self, pull_request, old_diff_data, new_diff_data):
346 358 if not ChangesetCommentsModel.use_outdated_comments(pull_request):
347 359 return
348 360
349 361 comments = self._visible_inline_comments_of_pull_request(pull_request)
350 362 comments_to_outdate = comments.all()
351 363
352 364 for comment in comments_to_outdate:
353 365 self._outdate_one_comment(comment, old_diff_data, new_diff_data)
354 366
355 367 def _outdate_one_comment(self, comment, old_diff_proc, new_diff_proc):
356 368 diff_line = _parse_comment_line_number(comment.line_no)
357 369
358 370 try:
359 371 old_context = old_diff_proc.get_context_of_line(
360 372 path=comment.f_path, diff_line=diff_line)
361 373 new_context = new_diff_proc.get_context_of_line(
362 374 path=comment.f_path, diff_line=diff_line)
363 375 except (diffs.LineNotInDiffException,
364 376 diffs.FileNotInDiffException):
365 377 comment.display_state = ChangesetComment.COMMENT_OUTDATED
366 378 return
367 379
368 380 if old_context == new_context:
369 381 return
370 382
371 383 if self._should_relocate_diff_line(diff_line):
372 384 new_diff_lines = new_diff_proc.find_context(
373 385 path=comment.f_path, context=old_context,
374 386 offset=self.DIFF_CONTEXT_BEFORE)
375 387 if not new_diff_lines:
376 388 comment.display_state = ChangesetComment.COMMENT_OUTDATED
377 389 else:
378 390 new_diff_line = self._choose_closest_diff_line(
379 391 diff_line, new_diff_lines)
380 392 comment.line_no = _diff_to_comment_line_number(new_diff_line)
381 393 else:
382 394 comment.display_state = ChangesetComment.COMMENT_OUTDATED
383 395
384 396 def _should_relocate_diff_line(self, diff_line):
385 397 """
386 398 Checks if relocation shall be tried for the given `diff_line`.
387 399
388 400 If a comment points into the first lines, then we can have a situation
389 401 that after an update another line has been added on top. In this case
390 402 we would find the context still and move the comment around. This
391 403 would be wrong.
392 404 """
393 405 should_relocate = (
394 406 (diff_line.new and diff_line.new > self.DIFF_CONTEXT_BEFORE) or
395 407 (diff_line.old and diff_line.old > self.DIFF_CONTEXT_BEFORE))
396 408 return should_relocate
397 409
398 410 def _choose_closest_diff_line(self, diff_line, new_diff_lines):
399 411 candidate = new_diff_lines[0]
400 412 best_delta = _diff_line_delta(diff_line, candidate)
401 413 for new_diff_line in new_diff_lines[1:]:
402 414 delta = _diff_line_delta(diff_line, new_diff_line)
403 415 if delta < best_delta:
404 416 candidate = new_diff_line
405 417 best_delta = delta
406 418 return candidate
407 419
408 420 def _visible_inline_comments_of_pull_request(self, pull_request):
409 421 comments = self._all_inline_comments_of_pull_request(pull_request)
410 422 comments = comments.filter(
411 423 coalesce(ChangesetComment.display_state, '') !=
412 424 ChangesetComment.COMMENT_OUTDATED)
413 425 return comments
414 426
415 427 def _all_inline_comments_of_pull_request(self, pull_request):
416 428 comments = Session().query(ChangesetComment)\
417 429 .filter(ChangesetComment.line_no != None)\
418 430 .filter(ChangesetComment.f_path != None)\
419 431 .filter(ChangesetComment.pull_request == pull_request)
420 432 return comments
421 433
422 434 @staticmethod
423 435 def use_outdated_comments(pull_request):
424 436 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
425 437 settings = settings_model.get_general_settings()
426 438 return settings.get('rhodecode_use_outdated_comments', False)
427 439
428 440
429 441 def _parse_comment_line_number(line_no):
430 442 """
431 443 Parses line numbers of the form "(o|n)\d+" and returns them in a tuple.
432 444 """
433 445 old_line = None
434 446 new_line = None
435 447 if line_no.startswith('o'):
436 448 old_line = int(line_no[1:])
437 449 elif line_no.startswith('n'):
438 450 new_line = int(line_no[1:])
439 451 else:
440 452 raise ValueError("Comment lines have to start with either 'o' or 'n'.")
441 453 return diffs.DiffLineNumber(old_line, new_line)
442 454
443 455
444 456 def _diff_to_comment_line_number(diff_line):
445 457 if diff_line.new is not None:
446 458 return u'n{}'.format(diff_line.new)
447 459 elif diff_line.old is not None:
448 460 return u'o{}'.format(diff_line.old)
449 461 return u''
450 462
451 463
452 464 def _diff_line_delta(a, b):
453 465 if None not in (a.new, b.new):
454 466 return abs(a.new - b.new)
455 467 elif None not in (a.old, b.old):
456 468 return abs(a.old - b.old)
457 469 else:
458 470 raise ValueError(
459 471 "Cannot compute delta between {} and {}".format(a, b))
@@ -1,78 +1,93 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2016 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import pytest
22 22
23 23 from rhodecode.tests.events.conftest import EventCatcher
24 24
25 from rhodecode.model.comment import ChangesetCommentsModel
25 26 from rhodecode.model.pull_request import PullRequestModel
26 27 from rhodecode.events import (
27 28 PullRequestCreateEvent,
28 29 PullRequestUpdateEvent,
30 PullRequestCommentEvent,
29 31 PullRequestReviewEvent,
30 32 PullRequestMergeEvent,
31 33 PullRequestCloseEvent,
32 34 )
33 35
34 36 # TODO: dan: make the serialization tests complete json comparisons
35 37 @pytest.mark.backends("git", "hg")
36 38 @pytest.mark.parametrize('EventClass', [
37 39 PullRequestCreateEvent,
38 40 PullRequestUpdateEvent,
39 41 PullRequestReviewEvent,
40 42 PullRequestMergeEvent,
41 43 PullRequestCloseEvent,
42 44 ])
43 45 def test_pullrequest_events_serialized(pr_util, EventClass):
44 46 pr = pr_util.create_pull_request()
45 47 event = EventClass(pr)
46 48 data = event.as_dict()
47 49 assert data['name'] == EventClass.name
48 50 assert data['repo']['repo_name'] == pr.target_repo.repo_name
49 51 assert data['pullrequest']['pull_request_id'] == pr.pull_request_id
50 52 assert data['pullrequest']['url']
51 53
52 54 @pytest.mark.backends("git", "hg")
53 55 def test_create_pull_request_events(pr_util):
54 56 with EventCatcher() as event_catcher:
55 57 pr_util.create_pull_request()
56 58
57 59 assert PullRequestCreateEvent in event_catcher.events_types
58 60
61 @pytest.mark.backends("git", "hg")
62 def test_pullrequest_comment_events_serialized(pr_util):
63 pr = pr_util.create_pull_request()
64 comment = ChangesetCommentsModel().get_comments(
65 pr.target_repo.repo_id, pull_request=pr)[0]
66 event = PullRequestCommentEvent(pr, comment)
67 data = event.as_dict()
68 assert data['name'] == PullRequestCommentEvent.name
69 assert data['repo']['repo_name'] == pr.target_repo.repo_name
70 assert data['pullrequest']['pull_request_id'] == pr.pull_request_id
71 assert data['pullrequest']['url']
72 assert data['comment']['text'] == comment.text
73
59 74
60 75 @pytest.mark.backends("git", "hg")
61 76 def test_close_pull_request_events(pr_util, user_admin):
62 77 pr = pr_util.create_pull_request()
63 78
64 79 with EventCatcher() as event_catcher:
65 80 PullRequestModel().close_pull_request(pr, user_admin)
66 81
67 82 assert PullRequestCloseEvent in event_catcher.events_types
68 83
69 84
70 85 @pytest.mark.backends("git", "hg")
71 86 def test_close_pull_request_with_comment_events(pr_util, user_admin):
72 87 pr = pr_util.create_pull_request()
73 88
74 89 with EventCatcher() as event_catcher:
75 90 PullRequestModel().close_pull_request_with_comment(
76 91 pr, user_admin, pr.target_repo)
77 92
78 93 assert PullRequestCloseEvent in event_catcher.events_types
General Comments 0
You need to be logged in to leave comments. Login now